From fb217ecfdb8b7a369cea03dd1c330020262dcadd Mon Sep 17 00:00:00 2001 From: WardPearce Date: Wed, 11 Feb 2026 19:28:37 +1300 Subject: [PATCH 01/32] Bump versions --- materialious/android/app/build.gradle | 4 ++-- materialious/electron/materialious.metainfo.xml | 6 +++++- materialious/electron/package.json | 2 +- materialious/package.json | 2 +- update_versions.py | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/materialious/android/app/build.gradle b/materialious/android/app/build.gradle index 703f47a5..d885f88f 100644 --- a/materialious/android/app/build.gradle +++ b/materialious/android/app/build.gradle @@ -7,8 +7,8 @@ android { applicationId "us.materialio.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 203 - versionName "1.13.19" + versionCode 204 + versionName "1.14.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/materialious/electron/materialious.metainfo.xml b/materialious/electron/materialious.metainfo.xml index 8f8f3e7c..835956c6 100644 --- a/materialious/electron/materialious.metainfo.xml +++ b/materialious/electron/materialious.metainfo.xml @@ -102,7 +102,11 @@ - + + + https://github.com/Materialious/Materialious/releases/tag/1.14.0 + + https://github.com/Materialious/Materialious/releases/tag/1.13.19 diff --git a/materialious/electron/package.json b/materialious/electron/package.json index 360b7000..bfc50309 100644 --- a/materialious/electron/package.json +++ b/materialious/electron/package.json @@ -1,6 +1,6 @@ { "name": "Materialious", - "version": "1.13.19", + "version": "1.14.0", "description": "Modern material design for Invidious.", "author": { "name": "Ward Pearce", diff --git a/materialious/package.json b/materialious/package.json index caf7742d..f34cd510 100644 --- a/materialious/package.json +++ b/materialious/package.json @@ -1,6 +1,6 @@ { "name": "materialious", - "version": "1.13.19", + "version": "1.14.0", "private": true, "scripts": { "dev": "npm run patch:github && vite dev", diff --git a/update_versions.py b/update_versions.py index 728e87b0..ab145056 100644 --- a/update_versions.py +++ b/update_versions.py @@ -3,7 +3,7 @@ import os import re from datetime import datetime -LATEST_VERSION = "1.13.19" +LATEST_VERSION = "1.14.0" RELEASE_DATE = datetime.now().strftime("%Y-%-m-%d") # Format: YYYY-M-D WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious") From 65d8344ae840cecb3b2b85a7975d5c406e4f4ca5 Mon Sep 17 00:00:00 2001 From: WardPearce Date: Wed, 11 Feb 2026 20:23:22 +1300 Subject: [PATCH 02/32] Started inital search patch with ytjs --- materialious/electron/package-lock.json | 4 +- materialious/src/lib/api/index.ts | 38 +++-------- materialious/src/lib/api/misc.ts | 15 ++++ materialious/src/lib/api/model.ts | 12 +++- materialious/src/lib/api/youtubejs/index.ts | 19 ++++++ materialious/src/lib/api/youtubejs/search.ts | 68 +++++++++++++++++++ .../api/{youtubejs.ts => youtubejs/video.ts} | 43 ++++++------ materialious/src/lib/components/Player.svelte | 4 +- materialious/src/lib/numbers.ts | 5 ++ .../routes/(app)/search/[slug]/+page.svelte | 3 +- .../static/localApi/ghContributors.json | 2 +- 11 files changed, 157 insertions(+), 56 deletions(-) create mode 100644 materialious/src/lib/api/misc.ts create mode 100644 materialious/src/lib/api/youtubejs/index.ts create mode 100644 materialious/src/lib/api/youtubejs/search.ts rename materialious/src/lib/api/{youtubejs.ts => youtubejs/video.ts} (88%) diff --git a/materialious/electron/package-lock.json b/materialious/electron/package-lock.json index a40e535a..b5175bc0 100644 --- a/materialious/electron/package-lock.json +++ b/materialious/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "Materialious", - "version": "1.13.19", + "version": "1.14.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Materialious", - "version": "1.13.19", + "version": "1.14.0", "license": "MIT", "dependencies": { "@capacitor-community/electron": "^5.0.0", diff --git a/materialious/src/lib/api/index.ts b/materialious/src/lib/api/index.ts index 1d8f3350..f35ac880 100644 --- a/materialious/src/lib/api/index.ts +++ b/materialious/src/lib/api/index.ts @@ -1,4 +1,4 @@ -import { getVideoTYjs } from '$lib/api/youtubejs'; +import { getVideoYTjs } from '$lib/api/youtubejs/video'; import { Capacitor } from '@capacitor/core'; import { get } from 'svelte/store'; import { @@ -13,15 +13,12 @@ import { synciousInstanceStore } from '../store'; import type { - Channel, ChannelContentPlaylists, ChannelContentVideos, ChannelPage, Comments, DeArrow, Feed, - HashTag, - Playlist, PlaylistPage, ResolvedUrl, ReturnYTDislikes, @@ -29,8 +26,12 @@ import type { Subscription, ApiExntendedProgressModel, Video, - VideoPlay + VideoPlay, + SearchOptions, + SearchResults } from './model'; +import { searchSetDefaults } from './misc'; +import { getSearchYTjs } from './youtubejs/search'; export function buildPath(path: string): URL { return new URL(`${get(instanceStore)}/api/v1/${path}`); @@ -86,13 +87,13 @@ export async function getVideo( fetchOptions?: RequestInit ): Promise { if (get(playerYouTubeJsAlways) && Capacitor.isNativePlatform()) { - return await getVideoTYjs(videoId); + return await getVideoYTjs(videoId); } const resp = await fetch(setRegion(buildPath(`videos/${videoId}?local=${local}`)), fetchOptions); if (!resp.ok && get(playerYouTubeJsFallback) && Capacitor.isNativePlatform()) { - return await getVideoTYjs(videoId); + return await getVideoYTjs(videoId); } else { await fetchErrorHandle(resp); } @@ -193,31 +194,14 @@ export async function getHashtag(tag: string, page: number = 0): Promise<{ resul return await resp.json(); } -export interface SearchOptions { - sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count'; - type?: 'video' | 'playlist' | 'channel' | 'all'; - duration?: 'short' | 'medium' | 'long'; - date?: 'hour' | 'today' | 'week' | 'month' | 'year'; - features?: string; - page?: string; -} - export async function getSearch( search: string, options: SearchOptions, fetchOptions?: RequestInit -): Promise<(Channel | Video | Playlist | HashTag)[]> { - if (typeof options.sort_by === 'undefined') { - options.sort_by = 'relevance'; - } +): Promise { + searchSetDefaults(options); - if (typeof options.type === 'undefined') { - options.type = 'all'; - } - - if (typeof options.page === 'undefined') { - options.page = '1'; - } + await getSearchYTjs(search, options); const path = buildPath('search'); path.search = new URLSearchParams({ ...options, q: search }).toString(); diff --git a/materialious/src/lib/api/misc.ts b/materialious/src/lib/api/misc.ts new file mode 100644 index 00000000..180be092 --- /dev/null +++ b/materialious/src/lib/api/misc.ts @@ -0,0 +1,15 @@ +import type { SearchOptions } from './model'; + +export function searchSetDefaults(options: SearchOptions) { + if (typeof options.sort_by === 'undefined') { + options.sort_by = 'relevance'; + } + + if (typeof options.type === 'undefined') { + options.type = 'all'; + } + + if (typeof options.page === 'undefined') { + options.page = '1'; + } +} diff --git a/materialious/src/lib/api/model.ts b/materialious/src/lib/api/model.ts index 63e68398..faef1a43 100644 --- a/materialious/src/lib/api/model.ts +++ b/materialious/src/lib/api/model.ts @@ -1,5 +1,14 @@ import type { ApiResponse, Innertube, YT } from 'youtubei.js'; +export interface SearchOptions { + sort_by?: 'relevance' | 'rating' | 'upload_date' | 'view_count'; + type?: 'video' | 'playlist' | 'channel' | 'all'; + duration?: 'short' | 'medium' | 'long'; + date?: 'hour' | 'today' | 'week' | 'month' | 'year'; + features?: string; + page?: string; +} + export interface Image { url: string; width: number; @@ -7,7 +16,6 @@ export interface Image { } export interface Thumbnail { - quality: string; url: string; width: number; height: number; @@ -295,3 +303,5 @@ export interface ApiExntendedProgressModel { export interface SynciousSaveProgressModel { time: number; } + +export type SearchResults = (Channel | Video | Playlist | HashTag)[]; diff --git a/materialious/src/lib/api/youtubejs/index.ts b/materialious/src/lib/api/youtubejs/index.ts new file mode 100644 index 00000000..b8a45735 --- /dev/null +++ b/materialious/src/lib/api/youtubejs/index.ts @@ -0,0 +1,19 @@ +import { interfaceRegionStore } from '$lib/store'; +import { USER_AGENT } from 'bgutils-js'; +import { get } from 'svelte/store'; +import Innertube, { UniversalCache } from 'youtubei.js'; + +let innertube: Innertube | undefined; + +export async function getInnertube(): Promise { + if (innertube) return innertube; + + innertube = await Innertube.create({ + fetch: fetch, + cache: new UniversalCache(true), + location: get(interfaceRegionStore), + user_agent: USER_AGENT + }); + + return innertube; +} diff --git a/materialious/src/lib/api/youtubejs/search.ts b/materialious/src/lib/api/youtubejs/search.ts new file mode 100644 index 00000000..b2a3601b --- /dev/null +++ b/materialious/src/lib/api/youtubejs/search.ts @@ -0,0 +1,68 @@ +import { cleanNumber, extractNumber } from '$lib/numbers'; +import { convertToSeconds } from '$lib/time'; +import { getInnertube } from '.'; +import { searchSetDefaults } from '../misc'; +import type { Channel, SearchOptions, SearchResults, Thumbnail, Video } from '../model'; +import { YTNodes, type Types } from 'youtubei.js'; + +export async function getSearchYTjs( + search: string, + options: SearchOptions +): Promise { + const innertube = await getInnertube(); + + searchSetDefaults(options); + + const innerResults = await innertube.search(search, { + sort_by: options.sort_by, + duration: options.duration, + features: [options.features] as Types.Feature[], + upload_date: options.date + }); + + 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); + } + }); + + return searchResults; +} diff --git a/materialious/src/lib/api/youtubejs.ts b/materialious/src/lib/api/youtubejs/video.ts similarity index 88% rename from materialious/src/lib/api/youtubejs.ts rename to materialious/src/lib/api/youtubejs/video.ts index e484441b..992b5a82 100644 --- a/materialious/src/lib/api/youtubejs.ts +++ b/materialious/src/lib/api/youtubejs/video.ts @@ -8,13 +8,14 @@ import type { VideoBase, VideoPlay } from '$lib/api/model'; -import { interfaceRegionStore, poTokenCacheStore } from '$lib/store'; +import { poTokenCacheStore } from '$lib/store'; import { convertToSeconds } from '$lib/time'; import { Capacitor } from '@capacitor/core'; -import { USER_AGENT } from 'bgutils-js'; import { get } from 'svelte/store'; import type { Types } from 'youtubei.js'; -import { Innertube, UniversalCache, Utils, YT, YTNodes, Platform } from 'youtubei.js'; +import { Utils, YT, YTNodes, Platform } from 'youtubei.js'; +import { getInnertube } from '.'; +import { cleanNumber, extractNumber } from '$lib/numbers'; Platform.shim.eval = async ( data: Types.BuildScriptResult, @@ -35,17 +36,12 @@ Platform.shim.eval = async ( return new Function(code)(); }; -export async function getVideoTYjs(videoId: string): Promise { +export async function getVideoYTjs(videoId: string): Promise { if (!Capacitor.isNativePlatform()) { throw new Error('Platform not supported'); } - const youtube = await Innertube.create({ - fetch: fetch, - cache: new UniversalCache(false), - location: get(interfaceRegionStore), - user_agent: USER_AGENT - }); + const innertube = await getInnertube(); const requestKey = 'O43z0dpjhgX20SCx4KAo'; @@ -57,7 +53,7 @@ export async function getVideoTYjs(videoId: string): Promise { const clientPlaybackNonce = Utils.generateRandomString(16); const watchEndpoint = new YTNodes.NavigationEndpoint({ watchEndpoint: { videoId } }); - const rawPlayerResponse = await watchEndpoint.call(youtube.actions, { + const rawPlayerResponse = await watchEndpoint.call(innertube.actions, { contentCheckOk: true, racyCheckOk: true, playbackContext: { @@ -65,16 +61,16 @@ export async function getVideoTYjs(videoId: string): Promise { pyv: true }, contentPlaybackContext: { - signatureTimestamp: youtube.session.player?.signature_timestamp + signatureTimestamp: innertube.session.player?.signature_timestamp } } }); - const rawNextResponse = await watchEndpoint.call(youtube.actions, { + const rawNextResponse = await watchEndpoint.call(innertube.actions, { override_endpoint: '/next' }); const video = new YT.VideoInfo( [rawPlayerResponse, rawNextResponse], - youtube.actions, + innertube.actions, clientPlaybackNonce ); @@ -82,7 +78,7 @@ export async function getVideoTYjs(videoId: string): Promise { throw new Error('Unable to pull video info from youtube.js'); } - const challengeResponse = await youtube.getAttestationChallenge('ENGAGEMENT_TYPE_UNBOUND'); + const challengeResponse = await innertube.getAttestationChallenge('ENGAGEMENT_TYPE_UNBOUND'); poTokenCacheStore.set(await platformMinter(requestKey, videoId, challengeResponse)); let dashUri: string | undefined; @@ -139,7 +135,7 @@ export async function getVideoTYjs(videoId: string): Promise { let authorThumbnails: Image[]; if (video.basic_info.channel_id) { - const channel = await youtube.getChannel(video.basic_info.channel_id); + const channel = await innertube.getChannel(video.basic_info.channel_id); authorThumbnails = channel.metadata.avatar as Image[]; } else { authorThumbnails = []; @@ -151,7 +147,7 @@ export async function getVideoTYjs(videoId: string): Promise { url.searchParams.set('potc', '1'); url.searchParams.set('pot', get(poTokenCacheStore) ?? ''); - url.searchParams.set('c', youtube.session.context.client.clientName); + url.searchParams.set('c', innertube.session.context.client.clientName); url.searchParams.set('fmt', 'vtt'); // Remove &xosf=1 as it adds `position:63% line:0%` to the subtitle lines @@ -190,10 +186,13 @@ export async function getVideoTYjs(videoId: string): Promise { videoThumbnails: (recommended?.content_image.image as Thumbnail[]) || [], videoId: recommended.content_id, title: recommended.metadata.title.toString(), - viewCountText: - (recommended.metadata.metadata.metadata_rows[1]?.metadata_parts?.[0]?.text ?? '') - .toString() - .replace('views', '') || '', + viewCountText: cleanNumber( + extractNumber( + ( + recommended.metadata.metadata.metadata_rows[1]?.metadata_parts?.[0]?.text ?? '' + ).toString() + ) + ), author: ( recommended.metadata.metadata.metadata_rows[0]?.metadata_parts?.[0]?.text ?? '' @@ -271,7 +270,7 @@ export async function getVideoTYjs(videoId: string): Promise { keywords: video.basic_info.keywords || [], allowedRegions: [], ytjs: { - innertube: youtube, + innertube: innertube, video: video, clientPlaybackNonce: clientPlaybackNonce, rawApiResponse: rawPlayerResponse diff --git a/materialious/src/lib/components/Player.svelte b/materialious/src/lib/components/Player.svelte index a6a20cf8..5fd68784 100644 --- a/materialious/src/lib/components/Player.svelte +++ b/materialious/src/lib/components/Player.svelte @@ -44,7 +44,7 @@ synciousStore } from '../store'; import { setStatusBarColor } from '../theme'; - import { getVideoTYjs } from '$lib/api/youtubejs'; + import { getVideoYTjs } from '$lib/api/youtubejs/video'; import { goToNextVideo, goToPreviousVideo, @@ -624,7 +624,7 @@ async function reloadVideo() { showVideoRetry = false; - data.video = await getVideoTYjs(data.video.videoId); + data.video = await getVideoYTjs(data.video.videoId); await loadVideo(); } diff --git a/materialious/src/lib/numbers.ts b/materialious/src/lib/numbers.ts index bbb367b2..2fd65888 100644 --- a/materialious/src/lib/numbers.ts +++ b/materialious/src/lib/numbers.ts @@ -1,5 +1,10 @@ import humanNumber from 'human-number'; +export function extractNumber(input: string): number { + const digits = input.replace(/\D+/g, ''); + return digits === '' ? NaN : Number(digits); +} + export function numberWithCommas(number: number) { if (typeof number === 'undefined') return; return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); diff --git a/materialious/src/routes/(app)/search/[slug]/+page.svelte b/materialious/src/routes/(app)/search/[slug]/+page.svelte index 391ce2d6..8a1e8d5e 100644 --- a/materialious/src/routes/(app)/search/[slug]/+page.svelte +++ b/materialious/src/routes/(app)/search/[slug]/+page.svelte @@ -1,10 +1,11 @@ {#if Capacitor.isNativePlatform()} -
- -
+
+ + + arrow_drop_down +
+ + {#if $backendInUseStore === 'ivg'} +
+ +
+ {:else} +
+ {/if} {#if isMobile()} {#if invalidInstance} diff --git a/materialious/src/lib/i18n/locales/en.json b/materialious/src/lib/i18n/locales/en.json index 0176424d..6fbc465e 100644 --- a/materialious/src/lib/i18n/locales/en.json +++ b/materialious/src/lib/i18n/locales/en.json @@ -15,6 +15,7 @@ "videos": "videos", "cancel": "Cancel", "create": "Create", + "backend": "Backend", "filters": { "filters": "Filters", "type": "Type", diff --git a/materialious/src/lib/store.ts b/materialious/src/lib/store.ts index 9dcf1856..fe437ccd 100644 --- a/materialious/src/lib/store.ts +++ b/materialious/src/lib/store.ts @@ -24,8 +24,8 @@ import type { VideoBase, VideoPlay } from './api/model'; -import { ensureNoTrailingSlash } from './misc'; import type { PhasedDescription } from './description'; +import { ensureNoTrailingSlash } from './misc'; function createListenerFunctions(): { callListeners: (eventKey: string, newValue: any) => void; @@ -101,6 +101,12 @@ export const instanceStore: Writable = persist( 'invidiousInstance' ); +export const backendInUseStore: Writable<'ivg' | 'yt'> = persist( + writable('ivg'), + createStorage(), + 'backendInUse' +); + export const authStore: Writable = persist( writable(null), createStorage(), From c3a1aa56c530db5bab24618f8c0685f857dd9dfb Mon Sep 17 00:00:00 2001 From: WardPearce Date: Wed, 11 Feb 2026 21:01:46 +1300 Subject: [PATCH 04/32] Improved yt search --- materialious/src/lib/api/model.ts | 4 +- materialious/src/lib/api/youtubejs/search.ts | 48 ++++++++++++------- materialious/src/lib/store.ts | 3 +- .../routes/(app)/search/[slug]/+page.svelte | 26 ++++++---- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/materialious/src/lib/api/model.ts b/materialious/src/lib/api/model.ts index faef1a43..34ce0b87 100644 --- a/materialious/src/lib/api/model.ts +++ b/materialious/src/lib/api/model.ts @@ -304,4 +304,6 @@ export interface SynciousSaveProgressModel { time: number; } -export type SearchResults = (Channel | Video | Playlist | HashTag)[]; +export type SearchResults = (Channel | Video | Playlist | HashTag)[] & { + getContinuation?: () => Promise; +}; diff --git a/materialious/src/lib/api/youtubejs/search.ts b/materialious/src/lib/api/youtubejs/search.ts index b2a3601b..1f495f96 100644 --- a/materialious/src/lib/api/youtubejs/search.ts +++ b/materialious/src/lib/api/youtubejs/search.ts @@ -3,23 +3,9 @@ import { convertToSeconds } from '$lib/time'; import { getInnertube } from '.'; import { searchSetDefaults } from '../misc'; import type { Channel, SearchOptions, SearchResults, Thumbnail, Video } from '../model'; -import { YTNodes, type Types } from 'youtubei.js'; - -export async function getSearchYTjs( - search: string, - options: SearchOptions -): Promise { - const innertube = await getInnertube(); - - searchSetDefaults(options); - - const innerResults = await innertube.search(search, { - sort_by: options.sort_by, - duration: options.duration, - features: [options.features] as Types.Feature[], - upload_date: options.date - }); +import { YTNodes, type Types, YT } from 'youtubei.js'; +function invidiousSchema(innerResults: YT.Search): SearchResults { const searchResults: SearchResults = []; innerResults.results.forEach((result) => { @@ -66,3 +52,33 @@ export async function getSearchYTjs( return searchResults; } + +function setGetContinuation(innerResults: YT.Search) { + return async () => { + const result = await innerResults.getContinuation(); + const searchResults = invidiousSchema(result); + searchResults.getContinuation = setGetContinuation(result); + return searchResults; + }; +} + +export async function getSearchYTjs( + search: string, + options: SearchOptions +): Promise { + const innertube = await getInnertube(); + + searchSetDefaults(options); + + const innerResults = await innertube.search(search, { + sort_by: options.sort_by, + duration: options.duration, + features: [options.features] as Types.Feature[], + upload_date: options.date + }); + + const searchResults = invidiousSchema(innerResults); + searchResults.getContinuation = setGetContinuation(innerResults); + + return searchResults; +} diff --git a/materialious/src/lib/store.ts b/materialious/src/lib/store.ts index fe437ccd..d6ccb185 100644 --- a/materialious/src/lib/store.ts +++ b/materialious/src/lib/store.ts @@ -20,6 +20,7 @@ import type { Playlist, PlaylistPage, PlaylistPageVideo, + SearchResults, Video, VideoBase, VideoPlay @@ -334,7 +335,7 @@ export const feedCacheStore: Writable<{ [key: string]: (VideoBase | Video | PlaylistPageVideo)[]; }> = writable({}); export const searchCacheStore: Writable<{ - [searchTypeAndQuery: string]: (Channel | Video | Playlist | HashTag)[]; + [searchTypeAndQuery: string]: SearchResults; }> = writable({}); export const feedLastItemId: Writable = writable(undefined); export const playlistCacheStore: Writable<{ diff --git a/materialious/src/routes/(app)/search/[slug]/+page.svelte b/materialious/src/routes/(app)/search/[slug]/+page.svelte index 8a1e8d5e..8f6fb22a 100644 --- a/materialious/src/routes/(app)/search/[slug]/+page.svelte +++ b/materialious/src/routes/(app)/search/[slug]/+page.svelte @@ -5,7 +5,7 @@ import { _ } from '$lib/i18n'; import InfiniteLoading, { type InfiniteEvent } from 'svelte-infinite-loading'; import ItemsList from '$lib/components/ItemsList.svelte'; - import type { SearchOptions } from '$lib/api/model.js'; + import type { SearchOptions, SearchResults } from '$lib/api/model.js'; let { data } = $props(); @@ -69,18 +69,28 @@ } async function loadMore(event: InfiniteEvent) { - currentPage++; - searchOptions = { - ...searchOptions, - page: currentPage.toString() - }; - const newSearch = await getSearch(data.slug, searchOptions); + let newSearch: SearchResults; + + const searchCacheItem = $searchCacheStore[data.searchStoreId]; + + if (searchCacheItem.getContinuation) { + newSearch = await searchCacheItem.getContinuation(); + searchCacheItem.getContinuation = newSearch.getContinuation; + } else { + currentPage++; + searchOptions = { + ...searchOptions, + page: currentPage.toString() + }; + + newSearch = await getSearch(data.slug, searchOptions); + } if (newSearch.length === 0) { event.detail.complete(); } else { searchCacheStore.set({ - [data.searchStoreId]: [...($searchCacheStore[data.searchStoreId] ?? []), ...newSearch] + [data.searchStoreId]: [...(searchCacheItem ?? []), ...newSearch] }); event.detail.loaded(); } From cbc16ecc7f3429cff16a181eb06e625d88581b01 Mon Sep 17 00:00:00 2001 From: WardPearce Date: Wed, 11 Feb 2026 22:09:26 +1300 Subject: [PATCH 05/32] Implemented get comment replies --- materialious/src/lib/api/index.ts | 47 +++++++----- materialious/src/lib/api/misc.ts | 8 +- materialious/src/lib/api/model.ts | 6 ++ .../src/lib/api/youtubejs/comments.ts | 76 +++++++++++++++++++ materialious/src/lib/api/youtubejs/misc.ts | 14 ++++ materialious/src/lib/api/youtubejs/search.ts | 3 - .../lib/api/youtubejs/searchSuggestions.ts | 13 ++++ materialious/src/lib/components/Player.svelte | 4 +- .../src/lib/components/watch/Comment.svelte | 19 +++-- materialious/src/lib/misc.ts | 5 ++ materialious/src/lib/watch.ts | 4 +- 11 files changed, 164 insertions(+), 35 deletions(-) create mode 100644 materialious/src/lib/api/youtubejs/comments.ts create mode 100644 materialious/src/lib/api/youtubejs/misc.ts create mode 100644 materialious/src/lib/api/youtubejs/searchSuggestions.ts diff --git a/materialious/src/lib/api/index.ts b/materialious/src/lib/api/index.ts index af91b420..6582c461 100644 --- a/materialious/src/lib/api/index.ts +++ b/materialious/src/lib/api/index.ts @@ -3,7 +3,6 @@ import { Capacitor } from '@capacitor/core'; import { get } from 'svelte/store'; import { authStore, - backendInUseStore, deArrowInstanceStore, deArrowThumbnailInstanceStore, instanceStore, @@ -29,10 +28,15 @@ import type { Video, VideoPlay, SearchOptions, - SearchResults + SearchResults, + CommentsOptions } from './model'; -import { searchSetDefaults } from './misc'; +import { commentsSetDefaults, searchSetDefaults } from './misc'; import { getSearchYTjs } from './youtubejs/search'; +import { isYTBackend } from '$lib/misc'; +import { getSearchSuggestionsYTjs } from './youtubejs/searchSuggestions'; +import { getResolveUrlYTjs } from './youtubejs/misc'; +import { getCommentsYTjs } from './youtubejs/comments'; export function buildPath(path: string): URL { return new URL(`${get(instanceStore)}/api/v1/${path}`); @@ -73,11 +77,20 @@ export function buildAuthHeaders(): { headers: Record } { } export async function getPopular(fetchOptions?: RequestInit): Promise { + if (isYTBackend()) { + // Home page will be subscription page when using Youtube backend + return []; + } + const resp = await fetchErrorHandle(await fetch(buildPath('popular'), fetchOptions)); return await resp.json(); } export async function getResolveUrl(url: string): Promise { + if (isYTBackend()) { + return await getResolveUrlYTjs(url); + } + const resp = await fetchErrorHandle(await fetch(`${buildPath('resolveurl')}?url=${url}`)); return await resp.json(); } @@ -87,7 +100,7 @@ export async function getVideo( local: boolean = false, fetchOptions?: RequestInit ): Promise { - if (get(playerYouTubeJsAlways) && Capacitor.isNativePlatform()) { + if ((get(playerYouTubeJsAlways) && Capacitor.isNativePlatform()) || isYTBackend()) { return await getVideoYTjs(videoId); } @@ -113,23 +126,17 @@ export async function getDislikes( export async function getComments( videoId: string, - parameters: { - sort_by?: 'top' | 'new'; - source?: 'youtube' | 'reddit'; - continuation?: string; - }, + options: CommentsOptions, fetchOptions?: RequestInit ): Promise { - if (typeof parameters.sort_by === 'undefined') { - parameters.sort_by = 'top'; - } + commentsSetDefaults(options); - if (typeof parameters.source === 'undefined') { - parameters.source = 'youtube'; + if (isYTBackend()) { + return await getCommentsYTjs(videoId, options); } const path = buildPath(`comments/${videoId}`); - path.search = new URLSearchParams(parameters).toString(); + path.search = new URLSearchParams(options).toString(); const resp = await fetchErrorHandle(await fetch(path, fetchOptions)); return await resp.json(); } @@ -184,6 +191,10 @@ export async function getSearchSuggestions( search: string, fetchOptions?: RequestInit ): Promise { + if (isYTBackend()) { + return getSearchSuggestionsYTjs(search); + } + const path = buildPath('search/suggestions'); path.search = new URLSearchParams({ q: search }).toString(); const resp = await fetchErrorHandle(await fetch(path, fetchOptions)); @@ -200,12 +211,12 @@ export async function getSearch( options: SearchOptions, fetchOptions?: RequestInit ): Promise { - if (get(backendInUseStore) === 'yt') { + searchSetDefaults(options); + + if (isYTBackend()) { return await getSearchYTjs(search, options); } - searchSetDefaults(options); - const path = buildPath('search'); path.search = new URLSearchParams({ ...options, q: search }).toString(); const resp = await fetchErrorHandle(await fetch(setRegion(path), fetchOptions)); diff --git a/materialious/src/lib/api/misc.ts b/materialious/src/lib/api/misc.ts index 180be092..f220a95a 100644 --- a/materialious/src/lib/api/misc.ts +++ b/materialious/src/lib/api/misc.ts @@ -1,4 +1,4 @@ -import type { SearchOptions } from './model'; +import type { CommentsOptions, SearchOptions } from './model'; export function searchSetDefaults(options: SearchOptions) { if (typeof options.sort_by === 'undefined') { @@ -13,3 +13,9 @@ export function searchSetDefaults(options: SearchOptions) { options.page = '1'; } } + +export function commentsSetDefaults(options: CommentsOptions) { + if (typeof options.sort_by === 'undefined') { + options.sort_by = 'top'; + } +} diff --git a/materialious/src/lib/api/model.ts b/materialious/src/lib/api/model.ts index 34ce0b87..906215f6 100644 --- a/materialious/src/lib/api/model.ts +++ b/materialious/src/lib/api/model.ts @@ -168,6 +168,7 @@ export interface Comment { replyCount: number; continuation: string; }; + getReplies?: () => Promise; } export interface Comments { @@ -307,3 +308,8 @@ export interface SynciousSaveProgressModel { export type SearchResults = (Channel | Video | Playlist | HashTag)[] & { getContinuation?: () => Promise; }; + +export type CommentsOptions = { + sort_by?: 'top' | 'new'; + continuation?: string; +}; diff --git a/materialious/src/lib/api/youtubejs/comments.ts b/materialious/src/lib/api/youtubejs/comments.ts new file mode 100644 index 00000000..895cac97 --- /dev/null +++ b/materialious/src/lib/api/youtubejs/comments.ts @@ -0,0 +1,76 @@ +import { extractNumber } from '$lib/numbers'; +import { YTNodes } from 'youtubei.js'; +import { getInnertube } from '.'; +import type { Comment, Comments, CommentsOptions, Thumbnail } from '../model'; + +function invidiousSchema(result: YTNodes.CommentView): Comment | undefined { + if (!result.author) return; + + return { + author: result.author.name ?? '', + authorId: result.author.id ?? '', + authorUrl: `/channel/${result.author.id}`, + authorThumbnails: result.author.thumbnails as Thumbnail[], + isEdited: false, + isPinned: result.is_pinned === true, + content: result.content?.text ?? '', + contentHtml: result.content?.toHTML() ?? '', + published: 0, // Placeholder, adjust accordingly + publishedText: result.published_time ?? '', + likeCount: extractNumber(result.like_count ?? '0'), + authorIsChannelOwner: result.author_is_channel_owner === true, + creatorHeart: { + creatorName: result.heart_active_tooltip?.split('@')[1] ?? '', + creatorThumbnail: result.creator_thumbnail_url ?? '' + }, + replies: { + replyCount: extractNumber(result.reply_count ?? '0'), + continuation: '' + } + }; +} + +export async function getCommentsYTjs( + videoId: string, + options: CommentsOptions +): Promise { + const innertube = await getInnertube(); + + const innerResults = await innertube.getComments( + videoId, + options.sort_by === 'top' ? 'TOP_COMMENTS' : 'NEWEST_FIRST' + ); + + const comments: Comment[] = []; + + innerResults.contents.forEach(async (result) => { + if (result.comment) { + const invidiousResult = invidiousSchema(result.comment); + if (invidiousResult) { + if (result.has_replies) + invidiousResult.getReplies = async (): Promise => { + const replies = await result.getReplies(); + if (!replies.replies) return { videoId: '', comments: [], commentCount: 0 }; + + const comments: Comment[] = replies.replies + .map((reply) => invidiousSchema(reply)) + .filter((comment): comment is Comment => !!comment); + + return { + videoId: '', + comments: comments, + commentCount: 0 + }; + }; + + comments.push(invidiousResult); + } + } + }); + + return { + videoId: videoId, + comments: comments, + commentCount: extractNumber(innerResults.header?.comments_count.text ?? '0') + }; +} diff --git a/materialious/src/lib/api/youtubejs/misc.ts b/materialious/src/lib/api/youtubejs/misc.ts new file mode 100644 index 00000000..5219be15 --- /dev/null +++ b/materialious/src/lib/api/youtubejs/misc.ts @@ -0,0 +1,14 @@ +import { getInnertube } from '.'; +import type { ResolvedUrl } from '../model'; + +export async function getResolveUrlYTjs(url: string): Promise { + const innertube = await getInnertube(); + + const resloved = await innertube.resolveURL(url); + + return { + pageType: resloved.metadata.page_type ?? '', + params: '', + ucid: resloved.metadata.url ? resloved.metadata.url?.split('/')[2] : '' + }; +} diff --git a/materialious/src/lib/api/youtubejs/search.ts b/materialious/src/lib/api/youtubejs/search.ts index 1f495f96..632528fa 100644 --- a/materialious/src/lib/api/youtubejs/search.ts +++ b/materialious/src/lib/api/youtubejs/search.ts @@ -1,7 +1,6 @@ import { cleanNumber, extractNumber } from '$lib/numbers'; import { convertToSeconds } from '$lib/time'; import { getInnertube } from '.'; -import { searchSetDefaults } from '../misc'; import type { Channel, SearchOptions, SearchResults, Thumbnail, Video } from '../model'; import { YTNodes, type Types, YT } from 'youtubei.js'; @@ -68,8 +67,6 @@ export async function getSearchYTjs( ): Promise { const innertube = await getInnertube(); - searchSetDefaults(options); - const innerResults = await innertube.search(search, { sort_by: options.sort_by, duration: options.duration, diff --git a/materialious/src/lib/api/youtubejs/searchSuggestions.ts b/materialious/src/lib/api/youtubejs/searchSuggestions.ts new file mode 100644 index 00000000..078fe57e --- /dev/null +++ b/materialious/src/lib/api/youtubejs/searchSuggestions.ts @@ -0,0 +1,13 @@ +import { getInnertube } from '.'; +import type { SearchSuggestion } from '../model'; + +export async function getSearchSuggestionsYTjs(search: string): Promise { + const innertube = await getInnertube(); + + const searchSuggestions = await innertube.getSearchSuggestions(search); + + return { + query: search, + suggestions: searchSuggestions + }; +} diff --git a/materialious/src/lib/components/Player.svelte b/materialious/src/lib/components/Player.svelte index 5fd68784..38ab2280 100644 --- a/materialious/src/lib/components/Player.svelte +++ b/materialious/src/lib/components/Player.svelte @@ -58,7 +58,7 @@ import { Network, type ConnectionStatus } from '@capacitor/network'; import { fade } from 'svelte/transition'; import { addToast } from './Toast.svelte'; - import { isMobile, truncate } from '$lib/misc'; + import { isMobile, isYTBackend, truncate } from '$lib/misc'; import { generateThumbnailWebVTT, drawTimelineThumbnail, @@ -485,7 +485,7 @@ sabrAdapter = await injectSabr(data.video, player); - if (data.video.fallbackPatch === 'youtubejs') { + if (data.video.fallbackPatch === 'youtubejs' && !isYTBackend()) { addToast({ data: { text: $_('player.youtubeJsFallBack') } }); } diff --git a/materialious/src/lib/components/watch/Comment.svelte b/materialious/src/lib/components/watch/Comment.svelte index 38ba9b07..8654d065 100644 --- a/materialious/src/lib/components/watch/Comment.svelte +++ b/materialious/src/lib/components/watch/Comment.svelte @@ -23,14 +23,17 @@ const replyText: string = comment.replies?.replyCount > 1 ? $_('replies') : $_('reply'); async function loadReplies(continuation: string) { - try { - replies = await getComments(videoId, { - continuation: continuation, - sort_by: 'top', - source: 'youtube' - }); - } catch { - // Continue regardless of error + if (comment.getReplies) { + replies = await comment.getReplies(); + } else { + try { + replies = await getComments(videoId, { + continuation: continuation, + sort_by: 'top' + }); + } catch { + // Continue regardless of error + } } } diff --git a/materialious/src/lib/misc.ts b/materialious/src/lib/misc.ts index da0c950f..9abe8b3a 100644 --- a/materialious/src/lib/misc.ts +++ b/materialious/src/lib/misc.ts @@ -4,6 +4,7 @@ import he from 'he'; import type Peer from 'peerjs'; import { get } from 'svelte/store'; import { + backendInUseStore, interfaceAllowInsecureRequests, interfaceAndroidUseNativeShare, isAndroidTvStore @@ -222,3 +223,7 @@ export function findElementForTime( return null; } + +export function isYTBackend(): boolean { + return get(backendInUseStore) === 'yt' && Capacitor.isNativePlatform(); +} diff --git a/materialious/src/lib/watch.ts b/materialious/src/lib/watch.ts index fa51916d..95a61285 100644 --- a/materialious/src/lib/watch.ts +++ b/materialious/src/lib/watch.ts @@ -47,9 +47,7 @@ export async function getWatchDetails(videoId: string, url: URL) { let comments; try { - comments = video.liveNow - ? null - : getComments(videoId, { sort_by: 'top', source: 'youtube' }, { priority: 'low' }); + comments = video.liveNow ? null : getComments(videoId, { sort_by: 'top' }, { priority: 'low' }); } catch { comments = null; } From 92fc3d442d7d5dbd0e9edcb7012d8cae89decfe9 Mon Sep 17 00:00:00 2001 From: WardPearce Date: Wed, 11 Feb 2026 22:19:09 +1300 Subject: [PATCH 06/32] Added comment loading --- materialious/src/lib/api/model.ts | 1 + .../src/lib/api/youtubejs/comments.ts | 84 +++++++++++-------- .../routes/(app)/watch/[slug]/+page.svelte | 12 ++- 3 files changed, 60 insertions(+), 37 deletions(-) diff --git a/materialious/src/lib/api/model.ts b/materialious/src/lib/api/model.ts index 906215f6..30e0fe3a 100644 --- a/materialious/src/lib/api/model.ts +++ b/materialious/src/lib/api/model.ts @@ -176,6 +176,7 @@ export interface Comments { videoId: string; continuation?: string; comments: Comment[]; + getContinuation?: () => Promise; } export interface Channel { diff --git a/materialious/src/lib/api/youtubejs/comments.ts b/materialious/src/lib/api/youtubejs/comments.ts index 895cac97..2b5d0ba4 100644 --- a/materialious/src/lib/api/youtubejs/comments.ts +++ b/materialious/src/lib/api/youtubejs/comments.ts @@ -1,9 +1,9 @@ import { extractNumber } from '$lib/numbers'; -import { YTNodes } from 'youtubei.js'; +import { YT, YTNodes } from 'youtubei.js'; import { getInnertube } from '.'; import type { Comment, Comments, CommentsOptions, Thumbnail } from '../model'; -function invidiousSchema(result: YTNodes.CommentView): Comment | undefined { +function invidiousCommentContentSchema(result: YTNodes.CommentView): Comment | undefined { if (!result.author) return; return { @@ -15,7 +15,7 @@ function invidiousSchema(result: YTNodes.CommentView): Comment | undefined { isPinned: result.is_pinned === true, content: result.content?.text ?? '', contentHtml: result.content?.toHTML() ?? '', - published: 0, // Placeholder, adjust accordingly + published: 0, publishedText: result.published_time ?? '', likeCount: extractNumber(result.like_count ?? '0'), authorIsChannelOwner: result.author_is_channel_owner === true, @@ -30,6 +30,37 @@ function invidiousSchema(result: YTNodes.CommentView): Comment | undefined { }; } +function invidiousCommentSchema(innerResults: YT.Comments, videoId: string) { + const comment: Comment[] = []; + + innerResults.contents.forEach(async (result) => { + if (result.comment) { + const invidiousResult = invidiousCommentContentSchema(result.comment); + if (invidiousResult) { + if (result.has_replies) + invidiousResult.getReplies = async (): Promise => { + const replies = await result.getReplies(); + if (!replies.replies) return { videoId: '', comments: [], commentCount: 0 }; + + const comments: Comment[] = replies.replies + .map((reply) => invidiousCommentContentSchema(reply)) + .filter((comment): comment is Comment => !!comment); + + return { + videoId: videoId, + comments: comments, + commentCount: 0 + }; + }; + + comment.push(invidiousResult); + } + } + }); + + return comment; +} + export async function getCommentsYTjs( videoId: string, options: CommentsOptions @@ -41,36 +72,23 @@ export async function getCommentsYTjs( options.sort_by === 'top' ? 'TOP_COMMENTS' : 'NEWEST_FIRST' ); - const comments: Comment[] = []; - - innerResults.contents.forEach(async (result) => { - if (result.comment) { - const invidiousResult = invidiousSchema(result.comment); - if (invidiousResult) { - if (result.has_replies) - invidiousResult.getReplies = async (): Promise => { - const replies = await result.getReplies(); - if (!replies.replies) return { videoId: '', comments: [], commentCount: 0 }; - - const comments: Comment[] = replies.replies - .map((reply) => invidiousSchema(reply)) - .filter((comment): comment is Comment => !!comment); - - return { - videoId: '', - comments: comments, - commentCount: 0 - }; - }; - - comments.push(invidiousResult); - } - } - }); - - return { + const comments: Comments = { videoId: videoId, - comments: comments, - commentCount: extractNumber(innerResults.header?.comments_count.text ?? '0') + comments: invidiousCommentSchema(innerResults, videoId), + commentCount: extractNumber(innerResults.header?.comments_count.text ?? '0'), + continuation: innerResults.has_continuation ? 'logicalPlaceholder' : undefined }; + + if (comments.continuation) { + comments.getContinuation = async () => { + return { + videoId: videoId, + comments: invidiousCommentSchema(await innerResults.getContinuation(), videoId), + commentCount: extractNumber(innerResults.header?.comments_count.text ?? '0'), + continuation: innerResults.has_continuation ? 'logicalPlaceholder' : undefined + }; + }; + } + + return comments; } diff --git a/materialious/src/routes/(app)/watch/[slug]/+page.svelte b/materialious/src/routes/(app)/watch/[slug]/+page.svelte index 8a417f55..0935f112 100644 --- a/materialious/src/routes/(app)/watch/[slug]/+page.svelte +++ b/materialious/src/routes/(app)/watch/[slug]/+page.svelte @@ -348,12 +348,16 @@ return; } - const loadedComments = await getComments(data.video.videoId, { - continuation: comments?.continuation - }); + let loadedComments: Comments; + if (comments.getContinuation) { + loadedComments = await comments.getContinuation(); + } else { + loadedComments = await getComments(data.video.videoId, { + continuation: comments?.continuation + }); + } comments.continuation = loadedComments.continuation; - comments.comments = [...comments.comments, ...loadedComments.comments]; } From cfcf83c9f289678a6af81d845ad526991e8febbc Mon Sep 17 00:00:00 2001 From: WardPearce Date: Wed, 11 Feb 2026 23:10:48 +1300 Subject: [PATCH 07/32] 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 @@ diff --git a/materialious/src/routes/(app)/watch/[slug]/+page.svelte b/materialious/src/routes/(app)/watch/[slug]/+page.svelte index 0935f112..691c6b62 100644 --- a/materialious/src/routes/(app)/watch/[slug]/+page.svelte +++ b/materialious/src/routes/(app)/watch/[slug]/+page.svelte @@ -349,16 +349,25 @@ } let loadedComments: Comments; - if (comments.getContinuation) { - loadedComments = await comments.getContinuation(); - } else { - loadedComments = await getComments(data.video.videoId, { - continuation: comments?.continuation - }); - } - comments.continuation = loadedComments.continuation; - comments.comments = [...comments.comments, ...loadedComments.comments]; + try { + if (comments.getContinuation) { + loadedComments = await comments.getContinuation(); + } else if (comments.continuation) { + loadedComments = await getComments(data.video.videoId, { + continuation: comments.continuation + }); + } else { + return; + } + + if (loadedComments.comments.length > 0) { + comments.continuation = loadedComments.continuation; + comments.comments = [...comments.comments, ...loadedComments.comments]; + } + } catch (error) { + console.error('Error loading more comments:', error); + } } function toggleTheatreMode() { From 1bb26984c10719e2949e5e1b376b784a48e72bde Mon Sep 17 00:00:00 2001 From: WardPearce Date: Wed, 11 Feb 2026 23:58:06 +1300 Subject: [PATCH 10/32] Improvements to changing backends --- .../lib/components/settings/Interface.svelte | 19 +++++++++---------- materialious/src/lib/misc.ts | 15 ++++++++++++++- materialious/src/routes/(app)/+layout.svelte | 10 ++-------- 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/materialious/src/lib/components/settings/Interface.svelte b/materialious/src/lib/components/settings/Interface.svelte index d2a3ea28..917e0d30 100644 --- a/materialious/src/lib/components/settings/Interface.svelte +++ b/materialious/src/lib/components/settings/Interface.svelte @@ -11,14 +11,13 @@ import type { RgbaColor, HsvaColor, Colord } from 'colord'; import { _ } from '$lib/i18n'; import { get } from 'svelte/store'; - import { ensureNoTrailingSlash, isMobile } from '../../misc'; + import { ensureNoTrailingSlash, isMobile, logoutStores } from '../../misc'; import { getPages, type Pages } from '../../navPages'; import ColorPicker from 'svelte-awesome-color-picker'; import { authStore, backendInUseStore, darkModeStore, - feedCacheStore, instanceStore, interfaceAllowInsecureRequests, interfaceAmoledTheme, @@ -33,8 +32,6 @@ interfaceRegionStore, interfaceSearchHistoryEnabled, interfaceSearchSuggestionsStore, - playlistCacheStore, - searchCacheStore, searchHistoryStore, themeColorStore } from '../../store'; @@ -78,6 +75,12 @@ } } + function clearPreviousInstance() { + logoutStores(); + ui('#dialog-settings'); + location.reload(); + } + async function setInstance(event: Event) { event.preventDefault(); @@ -108,18 +111,14 @@ } instanceStore.set(instance); - authStore.set(null); - feedCacheStore.set({}); - searchCacheStore.set({}); - playlistCacheStore.set({}); - goto(resolve('/', {}), { replaceState: true }); - ui('#dialog-settings'); + clearPreviousInstance(); } async function setBackend(event: Event) { const select = event.target as HTMLSelectElement; backendInUseStore.set(select.value as 'ivg' | 'yt'); + clearPreviousInstance(); } function allowInsecureRequests() { diff --git a/materialious/src/lib/misc.ts b/materialious/src/lib/misc.ts index 9abe8b3a..50629233 100644 --- a/materialious/src/lib/misc.ts +++ b/materialious/src/lib/misc.ts @@ -4,10 +4,15 @@ import he from 'he'; import type Peer from 'peerjs'; import { get } from 'svelte/store'; import { + authStore, backendInUseStore, + channelCacheStore, + feedCacheStore, interfaceAllowInsecureRequests, interfaceAndroidUseNativeShare, - isAndroidTvStore + isAndroidTvStore, + playlistCacheStore, + searchCacheStore } from './store'; import type { Channel, @@ -227,3 +232,11 @@ export function findElementForTime( export function isYTBackend(): boolean { return get(backendInUseStore) === 'yt' && Capacitor.isNativePlatform(); } + +export function logoutStores() { + authStore.set(null); + feedCacheStore.set({}); + searchCacheStore.set({}); + playlistCacheStore.set({}); + channelCacheStore.set({}); +} diff --git a/materialious/src/routes/(app)/+layout.svelte b/materialious/src/routes/(app)/+layout.svelte index 20f620c9..3a0ea44c 100644 --- a/materialious/src/routes/(app)/+layout.svelte +++ b/materialious/src/routes/(app)/+layout.svelte @@ -19,15 +19,12 @@ import { authStore, darkModeStore, - feedCacheStore, instanceStore, interfaceAmoledTheme, interfaceDefaultPage, isAndroidTvStore, playerState, playertheatreModeIsActive, - playlistCacheStore, - searchCacheStore, syncPartyPeerStore, themeColorStore } from '$lib/store'; @@ -42,7 +39,7 @@ import { _ } from '$lib/i18n'; import { get } from 'svelte/store'; import { pwaInfo } from 'virtual:pwa-info'; - import { truncate } from '$lib/misc'; + import { logoutStores, truncate } from '$lib/misc'; import Author from '$lib/components/Author.svelte'; import Toast from '$lib/components/Toast.svelte'; @@ -182,10 +179,7 @@ } function logout() { - authStore.set(null); - feedCacheStore.set({}); - searchCacheStore.set({}); - playlistCacheStore.set({}); + logoutStores(); goto(resolve('/', {})); } From d6f9eef362ecafe7fc860c1aade2e4d2d4325d42 Mon Sep 17 00:00:00 2001 From: WardPearce Date: Thu, 12 Feb 2026 00:16:56 +1300 Subject: [PATCH 11/32] Fixes to channel pfp not loading when using yt backend. --- materialious/src/lib/images.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/materialious/src/lib/images.ts b/materialious/src/lib/images.ts index 5b9cd3b6..a6607ff5 100644 --- a/materialious/src/lib/images.ts +++ b/materialious/src/lib/images.ts @@ -53,10 +53,10 @@ export function getBestThumbnail( } export function proxyGoogleImage(source: string): string { - if (isYTBackend()) return source; - if (source.startsWith('//')) source = `https:${source}`; + if (isYTBackend()) return source; + let path: string | undefined; try { path = new URL(source).pathname; From d5514b2d7da5ac66c374e01df8068d3ad7bbc913 Mon Sep 17 00:00:00 2001 From: WardPearce Date: Thu, 12 Feb 2026 00:40:09 +1300 Subject: [PATCH 12/32] Added support for playlist thumbnails --- materialious/src/lib/api/youtubejs/schema.ts | 25 +++++++++++++++++-- .../lib/components/PlaylistThumbnail.svelte | 24 ++++++++---------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/materialious/src/lib/api/youtubejs/schema.ts b/materialious/src/lib/api/youtubejs/schema.ts index be62f6f4..ba673718 100644 --- a/materialious/src/lib/api/youtubejs/schema.ts +++ b/materialious/src/lib/api/youtubejs/schema.ts @@ -1,9 +1,9 @@ import { cleanNumber, extractNumber } from '$lib/numbers'; import { convertToSeconds } from '$lib/time'; -import type { Channel, Thumbnail, Video } from '../model'; +import type { Channel, Playlist, Thumbnail, Video } from '../model'; import { Helpers, YTNodes } from 'youtubei.js'; -export function invidiousItemSchema(item: Helpers.YTNode): Video | Channel | undefined { +export function invidiousItemSchema(item: Helpers.YTNode): Video | Channel | Playlist | undefined { if (item.is(YTNodes.Video)) { const views = extractNumber(item.view_count?.toString() || ''); return { @@ -40,5 +40,26 @@ export function invidiousItemSchema(item: Helpers.YTNode): Video | Channel | und descriptionHml: item.description_snippet.toHTML() ?? '', authorThumbnails: item.author.thumbnails as Thumbnail[] }; + } else if (item.is(YTNodes.LockupView) && item.content_type === 'PLAYLIST') { + let author = ''; + const metadataRows = item.metadata?.metadata?.metadata_rows[0]; + if (metadataRows && metadataRows.metadata_parts) { + author = metadataRows.metadata_parts[0].text?.text ?? ''; + } + return { + type: 'playlist', + title: item.metadata?.title.text ?? '', + playlistId: item.content_id, + playlistThumbnail: item.content_image?.is(YTNodes.CollectionThumbnailView) + ? item.content_image.primary_thumbnail?.is(YTNodes.ThumbnailView) + ? item.content_image.primary_thumbnail.image[0].url + : '' + : '', + authorVerified: false, + author, + authorId: '', + videoCount: 0, + videos: [] + }; } } diff --git a/materialious/src/lib/components/PlaylistThumbnail.svelte b/materialious/src/lib/components/PlaylistThumbnail.svelte index b7cb69cf..c89f1a7a 100644 --- a/materialious/src/lib/components/PlaylistThumbnail.svelte +++ b/materialious/src/lib/components/PlaylistThumbnail.svelte @@ -25,10 +25,10 @@ onMount(async () => { if (get(interfaceLowBandwidthMode)) return; - let imgSrc = ''; + let imgSrc; if (playlist.videos && playlist.videos.length > 0) { imgSrc = getBestThumbnail(playlist.videos[0].videoThumbnails) || ''; - } else if (playlist.playlistThumbnail && playlist.playlistThumbnail.length > 0) { + } else if (playlist.playlistThumbnail) { imgSrc = playlist.playlistThumbnail; } else { imgSrc = ''; @@ -53,18 +53,14 @@ style="width: 100%; overflow: hidden;min-height:100px;" class="wave" > - {#if playlist.videoCount > 0 && !$interfaceLowBandwidthMode} - {#if loading} - - {:else if img && img.src !== ''} - Thumbnail for playlist - {/if} + {#if !$interfaceLowBandwidthMode && img} + Thumbnail for playlist {:else}
No image
{/if} From 2521b1fbc2025d40aaf27a19442b421e37f339dd Mon Sep 17 00:00:00 2001 From: WardPearce Date: Thu, 12 Feb 2026 02:12:50 +1300 Subject: [PATCH 13/32] Implementation of youtube feed --- materialious/src/lib/api/index.ts | 29 +++- .../src/lib/api/youtubejs/subscriptions.ts | 152 ++++++++++++++++++ materialious/src/lib/components/Author.svelte | 4 +- materialious/src/lib/dexie.ts | 15 +- materialious/src/lib/navPages.ts | 3 +- materialious/src/lib/time.ts | 3 +- materialious/src/routes/(app)/+page.ts | 8 + .../routes/(app)/watch/[slug]/+page.svelte | 4 +- 8 files changed, 207 insertions(+), 11 deletions(-) create mode 100644 materialious/src/lib/api/youtubejs/subscriptions.ts diff --git a/materialious/src/lib/api/index.ts b/materialious/src/lib/api/index.ts index 4f4c35e4..d36dec27 100644 --- a/materialious/src/lib/api/index.ts +++ b/materialious/src/lib/api/index.ts @@ -13,8 +13,6 @@ import { synciousInstanceStore } from '../store'; import type { - ChannelContentPlaylists, - ChannelContentVideos, ChannelPage, Comments, DeArrow, @@ -40,6 +38,13 @@ import { getSearchSuggestionsYTjs } from './youtubejs/searchSuggestions'; import { getResolveUrlYTjs } from './youtubejs/misc'; import { getCommentsYTjs } from './youtubejs/comments'; import { getChannelContentYTjs, getChannelYTjs } from './youtubejs/channel'; +import { + amSubscribedYTjs, + deleteUnsubscribeYTjs, + getFeedYTjs, + getSubscriptionsYTjs, + postSubscribeYTjs +} from './youtubejs/subscriptions'; export function buildPath(path: string): URL { return new URL(`${get(instanceStore)}/api/v1/${path}`); @@ -81,7 +86,6 @@ export function buildAuthHeaders(): { headers: Record } { export async function getPopular(fetchOptions?: RequestInit): Promise { if (isYTBackend()) { - // Home page will be subscription page when using Youtube backend return []; } @@ -231,6 +235,10 @@ export async function getFeed( page: number, fetchOptions: RequestInit = {} ): Promise { + if (isYTBackend()) { + return getFeedYTjs(maxResults, page); + } + const path = buildPath('auth/feed'); path.search = new URLSearchParams({ max_results: maxResults.toString(), @@ -243,6 +251,9 @@ export async function getFeed( } export async function getSubscriptions(fetchOptions: RequestInit = {}): Promise { + if (isYTBackend()) { + return getSubscriptionsYTjs(); + } const resp = await fetchErrorHandle( await fetch(buildPath('auth/subscriptions'), { ...buildAuthHeaders(), ...fetchOptions }) ); @@ -253,6 +264,10 @@ export async function amSubscribed( authorId: string, fetchOptions: RequestInit = {} ): Promise { + if (isYTBackend()) { + return amSubscribedYTjs(authorId); + } + if (!get(authStore)) return false; try { @@ -266,6 +281,10 @@ export async function amSubscribed( } export async function postSubscribe(authorId: string, fetchOptions: RequestInit = {}) { + if (isYTBackend()) { + return postSubscribeYTjs(authorId); + } + await fetchErrorHandle( await fetch(buildPath(`auth/subscriptions/${authorId}`), { method: 'POST', @@ -276,6 +295,10 @@ export async function postSubscribe(authorId: string, fetchOptions: RequestInit } export async function deleteUnsubscribe(authorId: string, fetchOptions: RequestInit = {}) { + if (isYTBackend()) { + return deleteUnsubscribeYTjs(authorId); + } + await fetchErrorHandle( await fetch(buildPath(`auth/subscriptions/${authorId}`), { method: 'DELETE', diff --git a/materialious/src/lib/api/youtubejs/subscriptions.ts b/materialious/src/lib/api/youtubejs/subscriptions.ts new file mode 100644 index 00000000..125406ff --- /dev/null +++ b/materialious/src/lib/api/youtubejs/subscriptions.ts @@ -0,0 +1,152 @@ +import { localDb } from '$lib/dexie'; +import { logoutStores } from '$lib/misc'; +import { cleanNumber } from '$lib/numbers'; +import { relativeTimestamp } from '$lib/time'; +import type { Feed, Subscription, Thumbnail, Video } from '../model'; +import { getChannelYTjs } from './channel'; + +export async function getSubscriptionsYTjs(): Promise { + const subscriptions: Subscription[] = []; + + await localDb.channelSubscriptions.each((subscription) => { + subscriptions.push({ + author: subscription.channelName, + authorId: subscription.channelId + }); + }); + + return subscriptions; +} + +export async function amSubscribedYTjs(authorId: string): Promise { + return (await localDb.channelSubscriptions.where('channelId').equals(authorId).count()) > 0; +} + +export async function postSubscribeYTjs(authorId: string) { + const channel = await getChannelYTjs(authorId); + + try { + await localDb.channelSubscriptions.add({ + channelId: authorId, + channelName: channel.author, + lastRSSFetch: new Date(0) + }); + } catch { + // Continue regardless + } + + // Store channel into db in background + parseChannelRSS(authorId); +} + +export async function deleteUnsubscribeYTjs(authorId: string) { + await localDb.channelSubscriptions.where('channelId').equals(authorId).delete(); + await localDb.subscriptionFeed.where('authorId').equals(authorId).delete(); + logoutStores(); +} + +export async function parseChannelRSS(channelId: string): Promise { + const feedUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`; + + const response = await fetch(feedUrl); + const text = await response.text(); + + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(text, 'text/xml'); + const entries = xmlDoc.getElementsByTagName('entry'); + + for (const entry of entries) { + const videoId = entry.getElementsByTagName('yt:videoId')[0]?.textContent || ''; + const title = entry.getElementsByTagName('title')[0]?.textContent || 'Untitled'; + const description = + entry.getElementsByTagName('media:description')[0]?.textContent || 'No description available'; + const descriptionHtml = description; + const publishedAt = new Date( + entry.getElementsByTagName('published')[0]?.textContent || new Date() + ); + const published = publishedAt.getTime(); + const publishedText = relativeTimestamp(published); + const author = + entry.getElementsByTagName('author')[0]?.getElementsByTagName('name')[0]?.textContent || + 'Unknown Author'; + const authorId = + entry + .getElementsByTagName('author')[0] + ?.getElementsByTagName('uri')[0] + ?.textContent.split('/')[4] || ''; + + const authorUrl = authorId ? `/channel/${authorId}` : ''; + const authorVerified = false; + + // Extracting video thumbnails + const videoThumbnails: Thumbnail[] = Array.from( + entry.getElementsByTagName('media:group')[0]?.getElementsByTagName('media:thumbnail') || [] + ).map((thumbElement) => ({ + url: thumbElement.getAttribute('url') || '', + width: parseInt(thumbElement.getAttribute('width') || '0'), + height: parseInt(thumbElement.getAttribute('height') || '0') + })); + + const liveNow = false; + const isUpcoming = false; + const premium = false; + const lengthSeconds = 0; + const viewCountText = + entry.getElementsByTagName('media:statistics')[0]?.getAttribute('views') || '0'; + const viewCount = parseInt(viewCountText) || 0; + + try { + await localDb.subscriptionFeed.add({ + type: 'video', + videoId, + title, + videoThumbnails, + author, + authorId, + description, + descriptionHtml, + published, + publishedText, + liveNow, + premium, + isUpcoming, + lengthSeconds, + viewCountText: cleanNumber(viewCount), + viewCount, + authorUrl, + authorVerified + }); + } catch { + // Continue regardless of error + } + + await localDb.channelSubscriptions.where('channelId').equals(channelId).modify({ + lastRSSFetch: new Date() + }); + } +} + +export async function getFeedYTjs(maxResults: number, page: number): Promise { + const channelSubscriptions = await localDb.channelSubscriptions.toArray(); + + const now = new Date(); + for (const channel of channelSubscriptions) { + const lastRSSFetch = new Date(channel.lastRSSFetch); + const timeDifference = now.getTime() - lastRSSFetch.getTime(); + const oneDayInMillis = 6 * 60 * 60 * 1000; // Update channel content every 6 hours + if (timeDifference > oneDayInMillis) { + parseChannelRSS(channel.channelId); + } + } + + const offset = (page - 1) * maxResults; + + return { + notifications: [], + videos: await localDb.subscriptionFeed.toArray().then((videos) => { + videos.sort((a, b) => b.published - a.published); + const paginatedVideos = videos.slice(offset, offset + maxResults); + return paginatedVideos; + }) + }; +} diff --git a/materialious/src/lib/components/Author.svelte b/materialious/src/lib/components/Author.svelte index 6242d9e8..61a07149 100644 --- a/materialious/src/lib/components/Author.svelte +++ b/materialious/src/lib/components/Author.svelte @@ -3,7 +3,7 @@ import { amSubscribed, deleteUnsubscribe, postSubscribe } from '$lib/api'; import type { Image } from '$lib/api/model'; import { getBestThumbnail, proxyGoogleImage } from '$lib/images'; - import { truncate } from '$lib/misc'; + import { isYTBackend, truncate } from '$lib/misc'; import { authStore, interfaceLowBandwidthMode, isAndroidTvStore } from '$lib/store'; import { _ } from '$lib/i18n'; import { localDb } from '$lib/dexie'; @@ -71,7 +71,7 @@ {#if !hideSubscribe} - {#if $authStore} + {#if $authStore || isYTBackend()} @@ -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)}
From 66e2229b8a5d973446f6de70557b28438e02fcfa Mon Sep 17 00:00:00 2001 From: WardPearce Date: Thu, 12 Feb 2026 15:34:33 +1300 Subject: [PATCH 23/32] Player style improvements --- materialious/src/lib/components/Author.svelte | 10 +++---- materialious/src/lib/components/Player.svelte | 26 +++++++++++-------- .../lib/components/watch/LikesDislikes.svelte | 4 +-- .../routes/(app)/watch/[slug]/+page.svelte | 12 ++++----- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/materialious/src/lib/components/Author.svelte b/materialious/src/lib/components/Author.svelte index 61a07149..5bced802 100644 --- a/materialious/src/lib/components/Author.svelte +++ b/materialious/src/lib/components/Author.svelte @@ -75,8 +75,8 @@ {:else} - {/if} - {#if playerElement && hasWebkitShowPlaybackTargetPicker(playerElement)} - {/if} {#if document.pictureInPictureEnabled} {/if} - {:else} - diff --git a/materialious/src/routes/(app)/watch/[slug]/+page.svelte b/materialious/src/routes/(app)/watch/[slug]/+page.svelte index b9fa61de..96e11cac 100644 --- a/materialious/src/routes/(app)/watch/[slug]/+page.svelte +++ b/materialious/src/routes/(app)/watch/[slug]/+page.svelte @@ -418,7 +418,7 @@ - {#if personalPlaylists && personalPlaylists.length > 0} - {:else} -