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