From cfcf83c9f289678a6af81d845ad526991e8febbc Mon Sep 17 00:00:00 2001 From: WardPearce Date: Wed, 11 Feb 2026 23:10:48 +1300 Subject: [PATCH] Progress on channel videos --- materialious/src/lib/api/index.ts | 30 +++---- materialious/src/lib/api/model.ts | 9 ++ materialious/src/lib/api/youtubejs/channel.ts | 82 +++++++++++++++++++ materialious/src/lib/api/youtubejs/schema.ts | 44 ++++++++++ materialious/src/lib/api/youtubejs/search.ts | 48 ++--------- materialious/src/lib/images.ts | 3 + .../routes/(app)/channel/[slug]/+page.svelte | 57 +++++++------ 7 files changed, 189 insertions(+), 84 deletions(-) create mode 100644 materialious/src/lib/api/youtubejs/channel.ts create mode 100644 materialious/src/lib/api/youtubejs/schema.ts diff --git a/materialious/src/lib/api/index.ts b/materialious/src/lib/api/index.ts index 6582c461..564752b6 100644 --- a/materialious/src/lib/api/index.ts +++ b/materialious/src/lib/api/index.ts @@ -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 { + 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 { - 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(); diff --git a/materialious/src/lib/api/model.ts b/materialious/src/lib/api/model.ts index 30e0fe3a..a594dedb 100644 --- a/materialious/src/lib/api/model.ts +++ b/materialious/src/lib/api/model.ts @@ -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; +}; diff --git a/materialious/src/lib/api/youtubejs/channel.ts b/materialious/src/lib/api/youtubejs/channel.ts new file mode 100644 index 00000000..695ebead --- /dev/null +++ b/materialious/src/lib/api/youtubejs/channel.ts @@ -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 { + 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 { + 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 + }; +} diff --git a/materialious/src/lib/api/youtubejs/schema.ts b/materialious/src/lib/api/youtubejs/schema.ts new file mode 100644 index 00000000..be62f6f4 --- /dev/null +++ b/materialious/src/lib/api/youtubejs/schema.ts @@ -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[] + }; + } +} diff --git a/materialious/src/lib/api/youtubejs/search.ts b/materialious/src/lib/api/youtubejs/search.ts index 632528fa..3fddc814 100644 --- a/materialious/src/lib/api/youtubejs/search.ts +++ b/materialious/src/lib/api/youtubejs/search.ts @@ -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; diff --git a/materialious/src/lib/images.ts b/materialious/src/lib/images.ts index eed24359..5b9cd3b6 100644 --- a/materialious/src/lib/images.ts +++ b/materialious/src/lib/images.ts @@ -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(); @@ -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; diff --git a/materialious/src/routes/(app)/channel/[slug]/+page.svelte b/materialious/src/routes/(app)/channel/[slug]/+page.svelte index fded0d42..4e9c75c3 100644 --- a/materialious/src/routes/(app)/channel/[slug]/+page.svelte +++ b/materialious/src/routes/(app)/channel/[slug]/+page.svelte @@ -1,11 +1,11 @@