Progress on channel videos

This commit is contained in:
WardPearce
2026-02-11 23:10:48 +13:00
parent 92fc3d442d
commit cfcf83c9f2
7 changed files with 189 additions and 84 deletions
+16 -14
View File
@@ -29,7 +29,8 @@ import type {
VideoPlay,
SearchOptions,
SearchResults,
CommentsOptions
CommentsOptions,
ChannelOptions
} from './model';
import { commentsSetDefaults, searchSetDefaults } from './misc';
import { getSearchYTjs } from './youtubejs/search';
@@ -37,6 +38,7 @@ import { isYTBackend } from '$lib/misc';
import { getSearchSuggestionsYTjs } from './youtubejs/searchSuggestions';
import { getResolveUrlYTjs } from './youtubejs/misc';
import { getCommentsYTjs } from './youtubejs/comments';
import { getChannelContentYTjs, getChannelYTjs } from './youtubejs/channel';
export function buildPath(path: string): URL {
return new URL(`${get(instanceStore)}/api/v1/${path}`);
@@ -145,32 +147,32 @@ export async function getChannel(
channelId: string,
fetchOptions?: RequestInit
): Promise<ChannelPage> {
if (isYTBackend()) {
return getChannelYTjs(channelId);
}
const resp = await fetchErrorHandle(
await fetch(buildPath(`channels/${channelId}`), fetchOptions)
);
return await resp.json();
}
export type channelSortBy = 'oldest' | 'newest' | 'popular';
export type channelContentTypes = 'videos' | 'playlists' | 'streams' | 'shorts';
export async function getChannelContent(
channelId: string,
parameters: {
type?: channelContentTypes;
continuation?: string;
sortBy?: channelSortBy;
},
options: ChannelOptions,
fetchOptions?: RequestInit
): Promise<ChannelContentVideos | ChannelContentPlaylists> {
if (typeof parameters.type === 'undefined') parameters.type = 'videos';
if (typeof options.type === 'undefined') options.type = 'videos';
const url = buildPath(`channels/${channelId}/${parameters.type}`);
const url = buildPath(`channels/${channelId}/${options.type}`);
if (typeof parameters.continuation !== 'undefined')
url.searchParams.set('continuation', parameters.continuation);
if (typeof options.continuation !== 'undefined')
url.searchParams.set('continuation', options.continuation);
if (typeof parameters.sortBy !== 'undefined') url.searchParams.set('sort_by', parameters.sortBy);
if (typeof options.sortBy !== 'undefined') url.searchParams.set('sort_by', options.sortBy);
if (isYTBackend()) {
return await getChannelContentYTjs(channelId, options);
}
const resp = await fetchErrorHandle(await fetch(url.toString(), fetchOptions));
return await resp.json();
+9
View File
@@ -314,3 +314,12 @@ export type CommentsOptions = {
sort_by?: 'top' | 'new';
continuation?: string;
};
export type ChannelSortBy = 'oldest' | 'newest' | 'popular';
export type ChannelContentTypes = 'videos' | 'playlists' | 'streams' | 'shorts';
export type ChannelOptions = {
type?: ChannelContentTypes;
continuation?: string;
sortBy?: ChannelSortBy;
};
@@ -0,0 +1,82 @@
import { YT, YTNodes } from 'youtubei.js';
import { getInnertube } from '.';
import type {
ChannelContentPlaylists,
ChannelContentVideos,
ChannelOptions,
ChannelPage,
Image,
Video
} from '../model';
import { invidiousItemSchema } from './schema';
export async function getChannelYTjs(channelId: string): Promise<ChannelPage> {
const innertube = await getInnertube();
const innerResults = await innertube.getChannel(channelId);
const authorId = innerResults.metadata.url?.split('/')[4] ?? '';
let authorBanners: Image[] = [];
if (innerResults.header?.is(YTNodes.PageHeaderView)) {
authorBanners = innerResults.header.banner?.image ?? [];
}
return {
type: 'channel',
allowedRegions: innerResults.metadata.available_countries ?? [],
tabs: innerResults.tabs.map((tab) => tab.toLowerCase()),
latestVideos: [],
isFamilyFriendly: true,
author: innerResults.metadata.title ?? '',
authorThumbnails: innerResults.metadata.avatar ?? [],
authorId: authorId,
authorBanners,
joined: 0,
authorVerified: true,
totalViews: 0,
authorUrl: `/channel/${authorId}`,
subCount: 0,
autoGenerated: false,
description: '',
descriptionHml: ''
};
}
export async function getChannelContentYTjs(
channelId: string,
options: ChannelOptions
): Promise<ChannelContentVideos | ChannelContentPlaylists> {
const innertube = await getInnertube();
const channel = await innertube.getChannel(channelId);
let innerResults: YT.Channel;
if (options.type === 'videos') {
innerResults = await channel.getVideos();
} else if (options.type === 'playlists') {
innerResults = await channel.getPlaylists();
} else if (options.type === 'shorts') {
innerResults = await channel.getShorts();
} else {
innerResults = await channel.getLiveStreams();
}
const videos: Video[] = [];
if (innerResults.current_tab?.content?.is(YTNodes.RichGrid)) {
innerResults.current_tab.content.contents.forEach((item) => {
if (item.is(YTNodes.RichItem)) {
const invidiousSchema = invidiousItemSchema(item.content);
if (invidiousSchema?.type === 'video') {
invidiousSchema.author = channel.metadata.title ?? '';
videos.push(invidiousSchema);
}
}
});
}
return {
continuation: '',
videos
};
}
@@ -0,0 +1,44 @@
import { cleanNumber, extractNumber } from '$lib/numbers';
import { convertToSeconds } from '$lib/time';
import type { Channel, Thumbnail, Video } from '../model';
import { Helpers, YTNodes } from 'youtubei.js';
export function invidiousItemSchema(item: Helpers.YTNode): Video | Channel | undefined {
if (item.is(YTNodes.Video)) {
const views = extractNumber(item.view_count?.toString() || '');
return {
type: 'video',
title: item.title.toString(),
videoId: item.video_id,
viewCountText: cleanNumber(views),
viewCount: views,
videoThumbnails: item.thumbnails as Thumbnail[],
published: 0,
publishedText: item.published?.toString() || '',
description: '',
descriptionHtml: '',
authorUrl: `/channel/${item.author.id}`,
authorId: item.author.id,
authorVerified: false,
liveNow: false,
isUpcoming: false,
premium: false,
author: item.author.name,
lengthSeconds: item.length_text?.text ? convertToSeconds(item.length_text.text) : 0
};
} else if (item.is(YTNodes.Channel)) {
return {
type: 'channel',
authorId: item.id,
author: item.author.name,
authorUrl: `/channel/${item.id}`,
authorVerified: item.author.is_verified === true,
subCount: item.video_count.text ? extractNumber(item.video_count.text) : 0,
totalViews: 0,
autoGenerated: false,
description: item.description_snippet.text ?? '',
descriptionHml: item.description_snippet.toHTML() ?? '',
authorThumbnails: item.author.thumbnails as Thumbnail[]
};
}
}
+5 -43
View File
@@ -1,52 +1,14 @@
import { cleanNumber, extractNumber } from '$lib/numbers';
import { convertToSeconds } from '$lib/time';
import { getInnertube } from '.';
import type { Channel, SearchOptions, SearchResults, Thumbnail, Video } from '../model';
import { YTNodes, type Types, YT } from 'youtubei.js';
import type { SearchOptions, SearchResults } from '../model';
import { type Types, YT } from 'youtubei.js';
import { invidiousItemSchema } from './schema';
function invidiousSchema(innerResults: YT.Search): SearchResults {
const searchResults: SearchResults = [];
innerResults.results.forEach((result) => {
if (result.is(YTNodes.Video)) {
const views = extractNumber(result.view_count?.toString() || '');
const patchedResult: Video = {
type: 'video',
title: result.title.toString(),
videoId: result.video_id,
viewCountText: cleanNumber(views),
viewCount: views,
videoThumbnails: result.thumbnails as Thumbnail[],
published: 0,
publishedText: result.published?.toString() || '',
description: '',
descriptionHtml: '',
authorUrl: `/channel/${result.author.id}`,
authorId: result.author.id,
authorVerified: false,
liveNow: false,
isUpcoming: false,
premium: false,
author: result.author.name,
lengthSeconds: result.length_text?.text ? convertToSeconds(result.length_text.text) : 0
};
searchResults.push(patchedResult);
} else if (result.is(YTNodes.Channel)) {
const patchedResult: Channel = {
type: 'channel',
authorId: result.id,
author: result.author.name,
authorUrl: `/channel/${result.id}`,
authorVerified: result.author.is_verified === true,
subCount: result.video_count.text ? extractNumber(result.video_count.text) : 0,
totalViews: 0,
autoGenerated: false,
description: result.description_snippet.text ?? '',
descriptionHml: result.description_snippet.toHTML() ?? '',
authorThumbnails: result.author.thumbnails as Thumbnail[]
};
searchResults.push(patchedResult);
}
const item = invidiousItemSchema(result);
if (item) searchResults.push(item);
});
return searchResults;
+3
View File
@@ -1,6 +1,7 @@
import { get } from 'svelte/store';
import type { Image } from './api/model';
import { instanceStore } from './store';
import { isYTBackend } from './misc';
export class ImageCache {
private cache = new Map<string, HTMLImageElement>();
@@ -52,6 +53,8 @@ export function getBestThumbnail(
}
export function proxyGoogleImage(source: string): string {
if (isYTBackend()) return source;
if (source.startsWith('//')) source = `https:${source}`;
let path: string | undefined;
@@ -1,11 +1,11 @@
<script lang="ts">
import {
getChannelContent,
searchChannelContent,
type channelContentTypes,
type channelSortBy
} from '$lib/api';
import type { ChannelContentPlaylists, ChannelContentVideos } from '$lib/api/model';
import { getChannelContent, searchChannelContent } from '$lib/api';
import type {
ChannelContentPlaylists,
ChannelContentTypes,
ChannelContentVideos,
ChannelSortBy
} from '$lib/api/model';
import PageLoading from '$lib/components/PageLoading.svelte';
import { proxyGoogleImage } from '$lib/images';
import { cleanNumber } from '$lib/numbers';
@@ -18,11 +18,12 @@
import Author from '$lib/components/Author.svelte';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { isYTBackend } from '$lib/misc';
let tab: channelContentTypes = $state('videos');
let tab: ChannelContentTypes = $state('videos');
let sortBy: channelSortBy = $state('newest');
const sortByOptions: channelSortBy[] = ['newest', 'oldest', 'popular'];
let sortBy: ChannelSortBy = $state('newest');
const sortByOptions: ChannelSortBy[] = ['newest', 'oldest', 'popular'];
let showSearch: boolean = $state(false);
let channelSearch: string = $state('');
@@ -179,23 +180,25 @@
{/each}
</nav>
</div>
<div class="s12 m6 l6">
{#if showSearch}
<div class="max field suffix prefix small no-margin surface-variant">
<i class="front">search</i><input
bind:value={channelSearch}
oninput={searchChannel}
type="text"
placeholder={$_('searchPlaceholder')}
/>
</div>
{:else}
<nav class="right-align m l">
<button onclick={() => (showSearch = true)}><i>search</i></button>
</nav>
<button class="s" onclick={() => (showSearch = true)}><i>search</i></button>
{/if}
</div>
{#if !isYTBackend()}
<div class="s12 m6 l6">
{#if showSearch}
<div class="max field suffix prefix small no-margin surface-variant">
<i class="front">search</i><input
bind:value={channelSearch}
oninput={searchChannel}
type="text"
placeholder={$_('searchPlaceholder')}
/>
</div>
{:else}
<nav class="right-align m l">
<button onclick={() => (showSearch = true)}><i>search</i></button>
</nav>
<button class="s" onclick={() => (showSearch = true)}><i>search</i></button>
{/if}
</div>
{/if}
</div>
</div>