From 8a92b255596aa8135393bd78a4f3083d9b457078 Mon Sep 17 00:00:00 2001 From: WardPearce Date: Thu, 12 Feb 2026 15:24:20 +1300 Subject: [PATCH] Added playlist yt support --- materialious/src/lib/api/index.ts | 38 +++++++++- materialious/src/lib/api/misc.ts | 3 +- materialious/src/lib/api/model.ts | 1 + .../src/lib/api/youtubejs/playlist.ts | 62 ++++++++++++++++ .../src/lib/api/youtubejs/subscriptions.ts | 2 +- .../src/lib/components/Thumbnail.svelte | 2 +- .../src/lib/components/settings/Engine.svelte | 3 +- .../lib/components/settings/Interface.svelte | 2 +- materialious/src/lib/playlist.ts | 73 +++++++++++-------- materialious/src/lib/timelineThumbnails.ts | 5 +- materialious/src/routes/(app)/+layout.svelte | 46 ++++++------ .../routes/(app)/channel/[slug]/+page.svelte | 10 +-- .../routes/(app)/subscriptions/+page.svelte | 2 +- .../routes/(app)/watch/[slug]/+page.svelte | 42 +++++++---- 14 files changed, 209 insertions(+), 82 deletions(-) create mode 100644 materialious/src/lib/api/youtubejs/playlist.ts diff --git a/materialious/src/lib/api/index.ts b/materialious/src/lib/api/index.ts index c0c8e6e2..b60b9481 100644 --- a/materialious/src/lib/api/index.ts +++ b/materialious/src/lib/api/index.ts @@ -45,6 +45,7 @@ import { getSubscriptionsYTjs, postSubscribeYTjs } from './youtubejs/subscriptions'; +import { getPlaylistYTjs } from './youtubejs/playlist'; export function buildPath(path: string): URL { return new URL(`${get(instanceStore)}/api/v1/${path}`); @@ -85,6 +86,7 @@ export function buildAuthHeaders(): { headers: Record } { } export async function getPopular(fetchOptions?: RequestInit): Promise { + // Doesn't exist in YTjs. if (isYTBackend()) { return []; } @@ -191,7 +193,14 @@ export async function searchChannelContent( channelId: string, search: string, fetchOptions?: RequestInit -) { +): Promise { + // Not Implemented in YTjs + if (isYTBackend()) { + return { + videos: [] + }; + } + const path = buildPath(`channel/${channelId}/search`); path.search = new URLSearchParams({ q: search }).toString(); const resp = await fetchErrorHandle(await fetch(path, fetchOptions)); @@ -213,6 +222,9 @@ export async function getSearchSuggestions( } export async function getHashtag(tag: string, page: number = 0): Promise<{ results: Video[] }> { + // TODO: Implement in YTjs + if (isYTBackend()) return { results: [] }; + const resp = await fetchErrorHandle(await fetch(buildPath(`hashtag/${tag}?page=${page}`))); return await resp.json(); } @@ -255,6 +267,7 @@ export async function getFeed( } export async function notificationsMarkAsRead(fetchOptions: RequestInit = {}) { + // Not support functionality of YTjs if (isYTBackend()) return; const path = buildPath('auth/notifications'); @@ -324,6 +337,11 @@ export async function getHistory( maxResults: number = 20, fetchOptions: RequestInit = {} ): Promise { + // Not supported functionality of YTjs. + if (isYTBackend()) { + return []; + } + const resp = await fetchErrorHandle( await fetch(buildPath(`auth/history?page=${page}&max_results=${maxResults}`), { ...buildAuthHeaders(), @@ -337,6 +355,8 @@ export async function deleteHistory( videoId: string | undefined = undefined, fetchOptions: RequestInit = {} ) { + if (isYTBackend()) return; + let url = '/api/v1/auth/history'; if (typeof videoId !== 'undefined') { url += `/${videoId}`; @@ -352,6 +372,8 @@ export async function deleteHistory( } export async function postHistory(videoId: string, fetchOptions: RequestInit = {}) { + if (isYTBackend()) return; + await fetchErrorHandle( await fetch(buildPath(`auth/history/${videoId}`), { method: 'POST', @@ -366,6 +388,10 @@ export async function getPlaylist( page: number = 1, fetchOptions: RequestInit = {} ): Promise { + if (isYTBackend() || useEngineFallback('Playlist')) { + return await getPlaylistYTjs(playlistId); + } + let resp; if (get(authStore)) { @@ -383,6 +409,8 @@ export async function getPlaylist( export async function getPersonalPlaylists( fetchOptions: RequestInit = {} ): Promise { + if (isYTBackend()) return []; + const resp = await fetchErrorHandle( await fetch(buildPath('auth/playlists'), { ...buildAuthHeaders(), ...fetchOptions }) ); @@ -390,6 +418,8 @@ export async function getPersonalPlaylists( } export async function deletePersonalPlaylist(playlistId: string) { + if (isYTBackend()) return; + await fetchErrorHandle( await fetch(buildPath(`auth/playlists/${playlistId}`), { method: 'DELETE', @@ -403,6 +433,8 @@ export async function postPersonalPlaylist( privacy: 'public' | 'private' | 'unlisted', fetchOptions: RequestInit = {} ) { + if (isYTBackend()) return; + const headers: Record> = buildAuthHeaders(); headers['headers']['Content-type'] = 'application/json'; @@ -424,6 +456,8 @@ export async function addPlaylistVideo( videoId: string, fetchOptions: RequestInit = {} ) { + if (isYTBackend()) return; + const headers: Record> = buildAuthHeaders(); headers['headers']['Content-type'] = 'application/json'; @@ -444,6 +478,8 @@ export async function removePlaylistVideo( indexId: string, fetchOptions: RequestInit = {} ) { + if (isYTBackend()) return; + await fetchErrorHandle( await fetch(buildPath(`auth/playlists/${playlistId}/videos/${indexId}`), { method: 'DELETE', diff --git a/materialious/src/lib/api/misc.ts b/materialious/src/lib/api/misc.ts index 26a20a22..30e4cb8e 100644 --- a/materialious/src/lib/api/misc.ts +++ b/materialious/src/lib/api/misc.ts @@ -30,7 +30,8 @@ export type EngineFallback = | 'Channel' | 'ChannelContent' | 'SearchSuggestions' - | 'Search'; + | 'Search' + | 'Playlist'; export function useEngineFallback(fallback: EngineFallback): boolean { return get(engineFallbacksStore).includes(fallback) && Capacitor.isNativePlatform(); diff --git a/materialious/src/lib/api/model.ts b/materialious/src/lib/api/model.ts index 1e6053f7..7902c11c 100644 --- a/materialious/src/lib/api/model.ts +++ b/materialious/src/lib/api/model.ts @@ -244,6 +244,7 @@ export interface PlaylistPage extends Omit { updated: number; isListed: boolean; videos: PlaylistPageVideo[]; + getContinuation?: () => Promise; } export interface ChannelPage extends Channel { diff --git a/materialious/src/lib/api/youtubejs/playlist.ts b/materialious/src/lib/api/youtubejs/playlist.ts new file mode 100644 index 00000000..35f475de --- /dev/null +++ b/materialious/src/lib/api/youtubejs/playlist.ts @@ -0,0 +1,62 @@ +import { extractNumber } from '$lib/numbers'; +import { YT, YTNodes } from 'youtubei.js'; +import { getInnertube } from '.'; +import type { PlaylistPage, PlaylistPageVideo } from '../model'; + +async function fetchPlaylistWithContinuation( + playlist: YT.Playlist, + playlistId: string +): Promise { + const videos: PlaylistPageVideo[] = []; + + playlist.videos.forEach((video) => { + if (video.is(YTNodes.PlaylistVideo)) { + const videoIndex = video.index.text ?? '0'; + videos.push({ + type: 'video', + author: video.author.name, + authorId: video.author.id, + index: extractNumber(videoIndex), + indexId: videoIndex, + viewCount: 0, + title: video.title.text ?? '', + videoId: video.id ?? '', + lengthSeconds: video.duration.seconds, + videoThumbnails: video.thumbnails + }); + } + }); + + const playlistPage: PlaylistPage = { + type: 'playlist', + title: playlist.info.title ?? '', + description: playlist.info.description ?? '', + descriptionHtml: playlist.info.description ?? '', + viewCount: extractNumber(playlist.info.views ?? '0'), + updated: 0, + isListed: true, + videos: videos, + playlistId, + videoCount: playlist.videos.length, + author: playlist.info.author.name, + authorId: playlist.info.author.id, + authorVerified: true, + playlistThumbnail: playlist.info.thumbnails[0].url ?? '' + }; + + if (playlist) { + playlistPage.getContinuation = async () => { + const continuation = await playlist.getContinuation(); + return fetchPlaylistWithContinuation(continuation, playlistId); + }; + } + + return playlistPage; +} + +export async function getPlaylistYTjs(playlistId: string): Promise { + const innertube = await getInnertube(); + const playlist = await innertube.getPlaylist(playlistId); + + return fetchPlaylistWithContinuation(playlist, playlistId); +} diff --git a/materialious/src/lib/api/youtubejs/subscriptions.ts b/materialious/src/lib/api/youtubejs/subscriptions.ts index 809cfc25..66d513ed 100644 --- a/materialious/src/lib/api/youtubejs/subscriptions.ts +++ b/materialious/src/lib/api/youtubejs/subscriptions.ts @@ -3,7 +3,7 @@ import { logoutStores } from '$lib/misc'; import { cleanNumber } from '$lib/numbers'; import { relativeTimestamp } from '$lib/time'; import { get } from 'svelte/store'; -import type { Feed, Subscription, Thumbnail, Video } from '../model'; +import type { Feed, Subscription, Thumbnail } from '../model'; import { getChannelYTjs } from './channel'; import { engineCooldownYTStore, engineCullYTStore } from '$lib/store'; diff --git a/materialious/src/lib/components/Thumbnail.svelte b/materialious/src/lib/components/Thumbnail.svelte index b0d76a44..ee5767c0 100644 --- a/materialious/src/lib/components/Thumbnail.svelte +++ b/materialious/src/lib/components/Thumbnail.svelte @@ -176,7 +176,7 @@ {#if !thumbnail}
{:else} -
180}> +
300}> Thumbnail for video
{/if} diff --git a/materialious/src/lib/components/settings/Engine.svelte b/materialious/src/lib/components/settings/Engine.svelte index 18080c7f..23f9a7c2 100644 --- a/materialious/src/lib/components/settings/Engine.svelte +++ b/materialious/src/lib/components/settings/Engine.svelte @@ -12,7 +12,8 @@ 'ResolveUrl', 'Search', 'SearchSuggestions', - 'Video' + 'Video', + 'Playlist' ]; function enableFallback(event: Event) { diff --git a/materialious/src/lib/components/settings/Interface.svelte b/materialious/src/lib/components/settings/Interface.svelte index 9905c47b..8e2aadb6 100644 --- a/materialious/src/lib/components/settings/Interface.svelte +++ b/materialious/src/lib/components/settings/Interface.svelte @@ -156,7 +156,7 @@
arrow_drop_down diff --git a/materialious/src/lib/playlist.ts b/materialious/src/lib/playlist.ts index 9438fc82..ee816daf 100644 --- a/materialious/src/lib/playlist.ts +++ b/materialious/src/lib/playlist.ts @@ -11,43 +11,58 @@ export async function loadEntirePlaylist( return cachedPlaylists[playlistId]; } - let playlistVideos: PlaylistPageVideo[] = []; - let playlist: PlaylistPage | undefined = undefined; + const playlistVideos: PlaylistPageVideo[] = []; + const ignoreVideos = new Set(); - const ignoreVideos: string[] = []; + let newPlaylist = await getPlaylist(playlistId, 1); + if (newPlaylist.getContinuation) { + let firstVideoId: string = ''; - for (let page = 1; page < Infinity; page++) { - const newPlaylist = await getPlaylist(playlistId, page); - if (page === 1) { - playlist = newPlaylist; - } - let newVideos = newPlaylist.videos; - if (newVideos.length === 0) { - break; + processVideos(newPlaylist.videos, ignoreVideos, playlistVideos); + + while (newPlaylist.getContinuation) { + const continuationResult = await newPlaylist.getContinuation(); + processVideos(continuationResult.videos, ignoreVideos, playlistVideos); + + if (firstVideoId === continuationResult.videos[0].videoId) break; + + firstVideoId = continuationResult.videos[0].videoId; } + } else { + let page = 1; + while (true) { + newPlaylist = await getPlaylist(playlistId, page); - newVideos = newVideos.filter((playlistVideo) => { - playlistVideo.type = 'video'; - return playlistVideo.lengthSeconds > 0 && !ignoreVideos.includes(playlistVideo.videoId); - }); - - newVideos.forEach((playlistVideo) => { - ignoreVideos.push(playlistVideo.videoId); - }); - - playlistVideos = [...playlistVideos, ...newVideos].sort( - (a: PlaylistPageVideo, b: PlaylistPageVideo) => { - return a.index < b.index ? -1 : 1; + if (newPlaylist.videos.length === 0) { + break; } - ); + + processVideos(newPlaylist.videos, ignoreVideos, playlistVideos); + + page++; + } } - if (typeof playlist === 'undefined') { - throw new Error('Unable to fetch playlist'); - } - - const combined = { videos: playlistVideos, info: playlist }; + const combined = { videos: playlistVideos, info: newPlaylist }; playlistCacheStore.set({ [playlistId]: combined }); return combined; } + +function processVideos( + videos: PlaylistPageVideo[], + ignoreVideos: Set, + playlistVideos: PlaylistPageVideo[] +) { + const newVideos = videos.filter((playlistVideo) => { + playlistVideo.type = 'video'; + return playlistVideo.lengthSeconds > 0 && !ignoreVideos.has(playlistVideo.videoId); + }); + + newVideos.forEach((playlistVideo) => { + ignoreVideos.add(playlistVideo.videoId); + }); + + playlistVideos.push(...newVideos); + playlistVideos.sort((a: PlaylistPageVideo, b: PlaylistPageVideo) => a.index - b.index); +} diff --git a/materialious/src/lib/timelineThumbnails.ts b/materialious/src/lib/timelineThumbnails.ts index 15269785..39f76dd4 100644 --- a/materialious/src/lib/timelineThumbnails.ts +++ b/materialious/src/lib/timelineThumbnails.ts @@ -84,8 +84,7 @@ export async function storyboardThumbnails(WebVTT: string): Promise { + thumbnailsSheets.cues.forEach((cue, index) => { const urlParts = cue.text.split('#xywh='); const xywh = urlParts[1]; const xywhValues = xywh.split(','); @@ -106,8 +105,6 @@ export async function storyboardThumbnails(WebVTT: string): Promisesettings
{$_('layout.settings')}
- {#if !isLoggedIn} - - login -
{$_('layout.login')}
-
- {:else} - - logout -
{$_('layout.logout')}
-
+ {#if !isYTBackend()} + {#if !isLoggedIn} + + login +
{$_('layout.login')}
+
+ {:else} + + logout +
{$_('layout.logout')}
+
+ {/if} {/if} {/if} @@ -366,16 +368,18 @@
{$_('layout.settings')}
- {#if !isLoggedIn} - - {:else} - + {#if !isYTBackend()} + {#if !isLoggedIn} + + {:else} + + {/if} {/if} {/if} diff --git a/materialious/src/routes/(app)/channel/[slug]/+page.svelte b/materialious/src/routes/(app)/channel/[slug]/+page.svelte index 4e9c75c3..0a54dbb5 100644 --- a/materialious/src/routes/(app)/channel/[slug]/+page.svelte +++ b/materialious/src/routes/(app)/channel/[slug]/+page.svelte @@ -1,11 +1,6 @@ diff --git a/materialious/src/routes/(app)/watch/[slug]/+page.svelte b/materialious/src/routes/(app)/watch/[slug]/+page.svelte index ff98da2d..b9fa61de 100644 --- a/materialious/src/routes/(app)/watch/[slug]/+page.svelte +++ b/materialious/src/routes/(app)/watch/[slug]/+page.svelte @@ -384,8 +384,6 @@ pauseTimerSeconds = 0; clearTimeout(pauseTimeout); }, pauseTimerSeconds * 1000); - - ui('#pause-timer'); } @@ -688,21 +686,39 @@ {/if}
- { - if (pauseTimerSeconds > 0) setPauseTimer(); - (event.target as HTMLDialogElement).close(); - }} -> +
{$_('player.pauseVideoIn')} {humanizeSeconds(pauseTimerSeconds)}