Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion src/renderer/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,19 @@ import packageDetails from '../../package.json'
import { openExternalLink, openInternalPath, showToast } from './helpers/utils'
import { translateWindowTitle } from './helpers/strings'
import { loadLocale } from './i18n/index'
import { getLocalClip } from './helpers/api/local.js'
import { getClipInvidious } from './helpers/api/invidious.js'

const route = useRoute()
const router = useRouter()
const { locale, t } = useI18n()

/** @type {import('vue').ComputedRef<'local' | 'invidious'>} */
const backendPreference = computed(() => store.getters.getBackendPreference)

/** @type {import('vue').ComputedRef<boolean>} */
const backendFallback = computed(() => store.getters.getBackendFallback)

/** @type {import('vue').ComputedRef<boolean>} */
const isSideNavOpen = computed(() => store.getters.getIsSideNavOpen)

Expand Down Expand Up @@ -431,8 +439,19 @@ async function handleYoutubeLink(href, { doCreateNewWindow = false } = {}) {
const result = await store.dispatch('getYoutubeUrlInfo', href)

switch (result.urlType) {
case 'clip':
case 'video': {
const { videoId, timestamp, playlistId } = result
let videoId, timestamp, playlistId

if (result.urlType === 'video') {
videoId = result.videoId
timestamp = result.timestamp
playlistId = result.playlistId
} else if (result.urlType === 'clip') {
const clipResult = await getClip(result.clipId)
videoId = clipResult.videoId
timestamp = clipResult.startTime
}

const query = {}
if (timestamp) {
Expand Down Expand Up @@ -722,6 +741,37 @@ function handleDragStart(event) {
event.stopPropagation()
}
}

async function getClip(clipId) {
if (!process.env.SUPPORTS_LOCAL_API || backendPreference.value === 'invidious') {
try {
return await getClipInvidious(clipId)
} catch (err) {
console.error(err)

if (process.env.SUPPORTS_LOCAL_API && backendFallback.value) {
console.error(
'Error resolving clip URL. Falling back to Local API'
)
return await getLocalClip(clipId)
}
}
} else {
try {
return await getLocalClip(clipId)
} catch (err) {
console.error(err)

if (backendFallback.value) {
console.error(
'Error resolving clip URL. Falling back to Invidious API'
)
return await getClipInvidious(clipId)
}
}
}
}

</script>

<style src="./themes.css" />
Expand Down
1 change: 1 addition & 0 deletions src/renderer/components/FtInput/FtInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ async function handleActionIconChange() {
case 'subscriptions':
case 'history':
case 'userplaylists':
case 'clip':
isYoutubeLink = true
break
Expand Down
49 changes: 45 additions & 4 deletions src/renderer/components/TopNav/TopNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,8 @@ import store from '../../store/index'
import { KeyboardShortcuts, MOBILE_WIDTH_THRESHOLD, SEARCH_RESULTS_DISPLAY_LIMIT } from '../../../constants'
import { debounce, localizeAndAddKeyboardShortcutToActionTitle, openInternalPath } from '../../helpers/utils'
import { translateWindowTitle } from '../../helpers/strings'
import { clearLocalSearchSuggestionsSession, getLocalSearchSuggestions } from '../../helpers/api/local'
import { getInvidiousSearchSuggestions } from '../../helpers/api/invidious'
import { clearLocalSearchSuggestionsSession, getLocalClip, getLocalSearchSuggestions } from '../../helpers/api/local'
import { getClipInvidious, getInvidiousSearchSuggestions } from '../../helpers/api/invidious'

const { t } = useI18n()
const router = useRouter()
Expand Down Expand Up @@ -389,10 +389,21 @@ function goToSearch(queryText, { event }) {

clearLocalSearchSuggestionsSession()

store.dispatch('getYoutubeUrlInfo', queryText).then((result) => {
store.dispatch('getYoutubeUrlInfo', queryText).then(async (result) => {
switch (result.urlType) {
case 'clip':
case 'video': {
const { videoId, timestamp, playlistId } = result
let videoId, timestamp, playlistId

if (result.urlType === 'video') {
videoId = result.videoId
timestamp = result.timestamp
playlistId = result.playlistId
} else if (result.urlType === 'clip') {
const clipResult = await getClip(result.clipId)
videoId = clipResult.videoId
timestamp = clipResult.startTime
}

const query = {}
if (timestamp) {
Expand Down Expand Up @@ -626,6 +637,36 @@ function handleWindowResize() {
}
}

async function getClip(clipId) {
if (!process.env.SUPPORTS_LOCAL_API || backendPreference.value === 'invidious') {
try {
return await getClipInvidious(clipId)
} catch (err) {
console.error(err)

if (process.env.SUPPORTS_LOCAL_API && backendFallback.value) {
console.error(
'Error resolving clip URL. Falling back to Local API'
)
return await getLocalClip(clipId)
}
}
} else {
try {
return await getLocalClip(clipId)
} catch (err) {
console.error(err)

if (backendFallback.value) {
console.error(
'Error resolving clip URL. Falling back to Invidious API'
)
return await getClipInvidious(clipId)
}
}
}
}

onMounted(() => {
previousWindowWidth = window.innerWidth
if (window.innerWidth <= MOBILE_WIDTH_THRESHOLD) {
Expand Down
16 changes: 15 additions & 1 deletion src/renderer/helpers/api/invidious.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import store from '../../store/index'
import { calculatePublishedDate, getRelativeTimeFromDate } from '../utils'
import { isNullOrEmpty } from '../strings'
import autolinker from 'autolinker'
import { FormatUtils, Misc, Player } from 'youtubei.js'
import { FormatUtils, Misc, Player, Utils } from 'youtubei.js'
import { ClipParams } from '../../../../node_modules/youtubei.js/dist/protos/generated/misc/params'

/** @typedef {{url: string, width: number, height: number}} InvidiousImageObject */
/** @typedef {{quality: string, url: string, width: number, height: number}} InvidiousThumbnailObject */
Expand Down Expand Up @@ -840,6 +841,19 @@ export async function getHashtagInvidious(hashtag, page = 1) {
return response.results
}

export async function getClipInvidious(clipId) {
const response = await resolveUrl('https://www.youtube.com/clip/' + clipId)

const parsedParams = ClipParams.decode(decodeURIComponent(Utils.base64ToU8(response.params)))
return {
videoId: response.videoId,
startTime: parsedParams.clipParamsData.startTime / 1000, // convert to seconds
endTime: parsedParams.clipParamsData.endTime / 1000, // convert to seconds
clipTitle: parsedParams.clipParamsData.clipTitle,
clipMetadata: parsedParams.clipParamsData.clipMetadata
}
}

/**
* Generates a DASH manifest locally from Invidious' adaptive formats and manifest,
* doing so allows us to support multiple audio tracks, which Invidious doesn't support yet
Expand Down
19 changes: 19 additions & 0 deletions src/renderer/helpers/api/local.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ClientType, Constants, Innertube, Misc, Mixins, Parser, Platform, UniversalCache, Utils, YT, YTNodes } from 'youtubei.js'
import { ClipParams } from '../../../../node_modules/youtubei.js/dist/protos/generated/misc/params'
import Autolinker from 'autolinker'
import { SEARCH_CHAR_LIMIT } from '../../../constants'

Expand Down Expand Up @@ -2272,3 +2273,21 @@ export async function getLocalCommunityPostComments(postId, channelId) {

return await innertube.getPostComments(postId, channelId)
}

export async function getLocalClip(clipId) {
const innertube = await createInnertube()

const clipResponse = await innertube.resolveURL('https://www.youtube.com/clip/' + clipId)

const videoId = clipResponse?.payload?.videoId

const parsedParams = ClipParams.decode(Utils.base64ToU8(decodeURIComponent(clipResponse.payload.params)))

return {
videoId,
startTime: parsedParams.clipParamsData.startTime / 1000, // convert to seconds
endTime: parsedParams.clipParamsData.endTime / 1000, // convert to seconds
clipTitle: parsedParams.clipParamsData.clipTitle,
clipMetadata: parsedParams.clipParamsData.clipMetadata
}
}
13 changes: 12 additions & 1 deletion src/renderer/store/modules/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,9 @@ const actions = {
// If `urlType` is "channel"
// - channelId [String]
//
// If `urlType` is "clip"
// - clipId
//
// If `urlType` is "unknown"
// Nothing else
//
Expand Down Expand Up @@ -376,7 +379,7 @@ const actions = {
/^\/(?:(?:channel|user|c)\/)?(?<channelId>[^/]+)(?:\/(?<tab>join|featured|videos|shorts|live|streams|podcasts|releases|courses|playlists|about|community|channels))?\/?$/

const hashtagPattern = /^\/hashtag\/(?<tag>[^#&/?]+)$/

const clipPattern = /^\/clip\/(?<clipId>.+)/
const postPattern = /^\/post\/(?<postId>.+)/
const feedPattern = /^\/feed\/(?<type>trending|subscriptions|history|playlists|you|library)/
const typePatterns = new Map([
Expand All @@ -386,6 +389,7 @@ const actions = {
['post', postPattern],
['feed', feedPattern],
['channel', channelPattern],
['clip', clipPattern]
])

for (const [type, pattern] of typePatterns) {
Expand Down Expand Up @@ -463,6 +467,13 @@ const actions = {
}
}

case 'clip': {
const match = url.pathname.match(clipPattern)
const clipId = match.groups.clipId

return { urlType: 'clip', clipId }
}

case 'post': {
const match = url.pathname.match(postPattern)
const postId = match.groups.postId
Expand Down