Skip to content
Open
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
22 changes: 22 additions & 0 deletions src/renderer/helpers/api/invidious.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { calculatePublishedDate, getRelativeTimeFromDate } from '../utils'
import { isNullOrEmpty } from '../strings'
import autolinker from 'autolinker'
import { FormatUtils, Misc, Player } from 'youtubei.js'
import { parseVideoClipsParams } from './shared'

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

export async function getClipInvidious(clipId) {
if (process.env.SUPPORTS_LOCAL_API) {
// reuse parsing from local api
const response = await resolveUrl('https://www.youtube.com/clip/' + clipId)
return parseVideoClipsParams(response.videoId, response.params)
}

// fallback to invidious clips api when local api isn't available (makes an expensive fetch video call)
const clipResponse = await invidiousAPICall({
resource: 'clips',
id: clipId,
})

return {
videoId: clipResponse.video.videoId,
startTime: clipResponse.startTime,
endTime: clipResponse.endTime,
clipTitle: clipResponse.clipTitle,
}
}

/**
* 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
11 changes: 11 additions & 0 deletions src/renderer/helpers/api/local.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getChannelPlaylistId,
getRelativeTimeFromDate,
} from '../utils'
import { parseVideoClipsParams } from './shared'

const TRACKING_PARAM_NAMES = [
'utm_source',
Expand Down Expand Up @@ -2486,3 +2487,13 @@ 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

return parseVideoClipsParams(videoId, clipResponse.payload.params)
}
19 changes: 19 additions & 0 deletions src/renderer/helpers/api/shared.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// code shared between invidious and local api (like parsing or types)
export function parseVideoClipsParams(videoId, params) {
if (process.env.SUPPORTS_LOCAL_API) {
const { Utils } = require('youtubei.js')
const { ClipParams } = require('../../../../node_modules/youtubei.js/dist/protos/generated/misc/params')
Comment on lines +4 to +5

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use proper ESM static imports like we do in all other files, importing with require forces webpack to treat the entirety of YouTube.js as commonjs which means larger bundles and slower performance, as every reference to YouTube.js across the entire code base will go through the simulated commonjs runtime.


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

return {
videoId,
startTime: parsedParams.clipParamData.startTime / 1000, // convert to seconds
endTime: parsedParams.clipParamData.endTime / 1000, // convert to seconds
clipTitle: parsedParams.clipParamData.clipTitle,
clipMetadata: parsedParams.clipParamData.clipMetadata
}
} else {
return null
}
}
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