Added playlist yt support

This commit is contained in:
WardPearce
2026-02-12 15:24:20 +13:00
parent db31565b67
commit 8a92b25559
14 changed files with 209 additions and 82 deletions
+37 -1
View File
@@ -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<string, string> } {
}
export async function getPopular(fetchOptions?: RequestInit): Promise<Video[]> {
// Doesn't exist in YTjs.
if (isYTBackend()) {
return [];
}
@@ -191,7 +193,14 @@ export async function searchChannelContent(
channelId: string,
search: string,
fetchOptions?: RequestInit
) {
): Promise<ChannelContent> {
// 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<string[]> {
// 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<PlaylistPage> {
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<PlaylistPage[]> {
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<string, Record<string, string>> = 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<string, Record<string, string>> = 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',
+2 -1
View File
@@ -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();
+1
View File
@@ -244,6 +244,7 @@ export interface PlaylistPage extends Omit<Playlist, 'videos'> {
updated: number;
isListed: boolean;
videos: PlaylistPageVideo[];
getContinuation?: () => Promise<PlaylistPage>;
}
export interface ChannelPage extends Channel {
@@ -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<PlaylistPage> {
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<PlaylistPage> {
const innertube = await getInnertube();
const playlist = await innertube.getPlaylist(playlistId);
return fetchPlaylistWithContinuation(playlist, playlistId);
}
@@ -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';
@@ -176,7 +176,7 @@
{#if !thumbnail}
<div class="secondary-container" style="width: 100%;height: {placeholderHeight}px;"></div>
{:else}
<div class:crop={thumbnail.height > 180}>
<div class:crop={thumbnail.height > 300}>
<img class="responsive" loading="lazy" src={thumbnail.src} alt="Thumbnail for video" />
</div>
{/if}
@@ -12,7 +12,8 @@
'ResolveUrl',
'Search',
'SearchSuggestions',
'Video'
'Video',
'Playlist'
];
function enableFallback(event: Event) {
@@ -156,7 +156,7 @@
<div class="field label suffix border">
<select name="backend-in-use" onchange={setBackend}>
<option selected={$backendInUseStore === 'ivg'} value="ivg">Invidious</option>
<option selected={$backendInUseStore === 'yt'} value="yt">YouTube</option>
<option selected={$backendInUseStore === 'yt'} value="yt">YouTube (Experimental)</option>
</select>
<label for="backend-in-use">{$_('backend')}</label>
<i>arrow_drop_down</i>
+44 -29
View File
@@ -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<string>();
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<string>,
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);
}
+1 -4
View File
@@ -84,8 +84,7 @@ export async function storyboardThumbnails(WebVTT: string): Promise<TimelineThum
type: 'vtt'
});
let index = 0;
thumbnailsSheets.cues.forEach((cue) => {
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): Promise<TimelineThum
xCoord,
yCoord
});
index++;
});
return thumbnails;
+25 -21
View File
@@ -39,7 +39,7 @@
import { _ } from '$lib/i18n';
import { get } from 'svelte/store';
import { pwaInfo } from 'virtual:pwa-info';
import { logoutStores, truncate } from '$lib/misc';
import { isYTBackend, logoutStores, truncate } from '$lib/misc';
import Author from '$lib/components/Author.svelte';
import Toast from '$lib/components/Toast.svelte';
@@ -282,16 +282,18 @@
<i>settings</i>
<div>{$_('layout.settings')}</div>
</a>
{#if !isLoggedIn}
<a onclick={login} href="#login">
<i>login</i>
<div>{$_('layout.login')}</div>
</a>
{:else}
<a onclick={logout} href="#logout">
<i>logout</i>
<div>{$_('layout.logout')}</div>
</a>
{#if !isYTBackend()}
{#if !isLoggedIn}
<a onclick={login} href="#login">
<i>login</i>
<div>{$_('layout.login')}</div>
</a>
{:else}
<a onclick={logout} href="#logout">
<i>logout</i>
<div>{$_('layout.logout')}</div>
</a>
{/if}
{/if}
{/if}
</nav>
@@ -366,16 +368,18 @@
<div class="tooltip bottom">{$_('layout.settings')}</div>
</button>
{#if !isLoggedIn}
<button onclick={login} class="circle large transparent">
<i>login</i>
<div class="tooltip bottom">{$_('layout.login')}</div>
</button>
{:else}
<button onclick={logout} class="circle large transparent">
<i>logout</i>
<div class="tooltip bottom">{$_('layout.logout')}</div>
</button>
{#if !isYTBackend()}
{#if !isLoggedIn}
<button onclick={login} class="circle large transparent">
<i>login</i>
<div class="tooltip bottom">{$_('layout.login')}</div>
</button>
{:else}
<button onclick={logout} class="circle large transparent">
<i>logout</i>
<div class="tooltip bottom">{$_('layout.logout')}</div>
</button>
{/if}
{/if}
{/if}
</nav>
@@ -1,11 +1,6 @@
<script lang="ts">
import { getChannelContent, searchChannelContent } from '$lib/api';
import type {
ChannelContentPlaylists,
ChannelContentTypes,
ChannelContentVideos,
ChannelSortBy
} from '$lib/api/model';
import type { ChannelContent, ChannelContentTypes, ChannelSortBy } from '$lib/api/model';
import PageLoading from '$lib/components/PageLoading.svelte';
import { proxyGoogleImage } from '$lib/images';
import { cleanNumber } from '$lib/numbers';
@@ -28,8 +23,7 @@
let showSearch: boolean = $state(false);
let channelSearch: string = $state('');
let displayContent: ChannelContentPlaylists | ChannelContentVideos | undefined =
$state(undefined);
let displayContent: ChannelContent | undefined = $state(undefined);
onMount(() => {
displayContent = $channelCacheStore[page.params.slug].displayContent.videos;
@@ -28,7 +28,7 @@
</script>
<nav class="right-align">
<a class="button outline" href={resolve('/subscriptions/manage', {})}>
<a class="button surface-container-highest" href={resolve('/subscriptions/manage', {})}>
{$_('subscriptions.manageSubscriptions')}
</a>
</nav>
@@ -384,8 +384,6 @@
pauseTimerSeconds = 0;
clearTimeout(pauseTimeout);
}, pauseTimerSeconds * 1000);
ui('#pause-timer');
}
</script>
@@ -688,21 +686,39 @@
{/if}
</div>
<dialog
id="pause-timer"
onclose={(event: Event) => {
if (pauseTimerSeconds > 0) setPauseTimer();
(event.target as HTMLDialogElement).close();
}}
>
<dialog id="pause-timer">
<div>
<h6>{$_('player.pauseVideoIn')} {humanizeSeconds(pauseTimerSeconds)}</h6>
<nav class="group">
<button onclick={() => (pauseTimerSeconds += 300)} class="left-round">+5 mins</button>
<button onclick={() => (pauseTimerSeconds += 1800)} class="no-round">+30 mins</button>
<button onclick={() => (pauseTimerSeconds += 3600)} class="no-round">+1 hr</button>
<button onclick={() => (pauseTimerSeconds += 7200)} class="right-round">+2 hrs</button>
<button
onclick={() => {
pauseTimerSeconds += 300;
setPauseTimer();
}}
class="left-round">+5 mins</button
>
<button
onclick={() => {
pauseTimerSeconds += 1800;
setPauseTimer();
}}
class="no-round">+30 mins</button
>
<button
onclick={() => {
pauseTimerSeconds += 3600;
setPauseTimer();
}}
class="no-round">+1 hr</button
>
<button
onclick={() => {
pauseTimerSeconds += 7200;
setPauseTimer();
}}
class="right-round">+2 hrs</button
>
</nav>
<div class="space"></div>