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 @@