Added playlist yt support
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user