direct youtube channel fetching
This commit is contained in:
parent
b8a2a5365a
commit
f9540a89bf
158
index.ts
158
index.ts
|
|
@ -1,5 +1,4 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { Innertube } from 'youtubei.js'
|
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import * as fs from 'node:fs'
|
import * as fs from 'node:fs'
|
||||||
|
|
||||||
|
|
@ -12,7 +11,7 @@ import {
|
||||||
} from './utils/sabr-stream-factory.js';
|
} from './utils/sabr-stream-factory.js';
|
||||||
import type { SabrPlaybackOptions } from 'googlevideo/sabr-stream';
|
import type { SabrPlaybackOptions } from 'googlevideo/sabr-stream';
|
||||||
import { getVideoStreams, downloadStream, getInfo } from './utils/companion';
|
import { getVideoStreams, downloadStream, getInfo } from './utils/companion';
|
||||||
import { createInnertubePool } from './utils/innertube-pool.js';
|
import { fetchChannelVideos, getVideoThumbnails, parseViewCount, relativeTimeToTimestamp, fetchChannelInfo } from './utils/youtube-channel.js';
|
||||||
|
|
||||||
const ffmpeg = require('fluent-ffmpeg')
|
const ffmpeg = require('fluent-ffmpeg')
|
||||||
|
|
||||||
|
|
@ -20,8 +19,6 @@ const app = express();
|
||||||
require('express-ws')(app)
|
require('express-ws')(app)
|
||||||
ffmpeg.setFfmpegPath('/usr/local/bin/ffmpeg')
|
ffmpeg.setFfmpegPath('/usr/local/bin/ffmpeg')
|
||||||
|
|
||||||
const maxRetries = 5
|
|
||||||
const innertubePool = createInnertubePool(parseInt(Bun.env.INNERTUBE_POOL_SIZE || '4'))
|
|
||||||
|
|
||||||
let consecutiveFailures = 0;
|
let consecutiveFailures = 0;
|
||||||
|
|
||||||
|
|
@ -95,55 +92,64 @@ app.get('/video/:id', async (req, res) => {
|
||||||
|
|
||||||
app.get('/channel/:id', async (req, res) => {
|
app.get('/channel/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const info = await innertubePool.use(async (innertube) => await innertube.getChannel(req.params.id), maxRetries)
|
const info = await fetchChannelInfo(req.params.id);
|
||||||
|
|
||||||
if (!info) {
|
if (!info) {
|
||||||
return res.json({ error: 'ErrorCantConnectToServiceAPI' })
|
return res.json({ error: 'ErrorCantConnectToServiceAPI' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json(info)
|
return res.json(info)
|
||||||
} catch {
|
} catch (error: any) {
|
||||||
|
console.error("Error in /channel/:id:", error);
|
||||||
return res.json({ error: 'ErrorUnknown' })
|
return res.json({ error: 'ErrorUnknown' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
app.get('/videos/:id', async (req, res) => {
|
app.get('/videos/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const videos = await innertubePool.use(async (innertube) => {
|
const channelId = req.params.id;
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
const videos: any[] = [];
|
||||||
const videos: any[] = []
|
let continuation: string | null = null;
|
||||||
const channel = await innertube.getChannel(req.params.id)
|
|
||||||
|
|
||||||
let json = await channel.getVideos()
|
// Fetch first page
|
||||||
videos.push(...json.videos)
|
let result = await fetchChannelVideos(channelId, 'newest');
|
||||||
|
videos.push(...result.videos);
|
||||||
|
continuation = result.continuation;
|
||||||
|
|
||||||
while (json.has_continuation && videos.length < 60) {
|
// Fetch subsequent pages if continuation is available and we have fewer than 60 videos
|
||||||
json = await getNextPage(json)
|
while (continuation && videos.length < 60) {
|
||||||
videos.push(...json.videos)
|
result = await fetchChannelVideos(channelId, 'newest', continuation);
|
||||||
}
|
videos.push(...result.videos);
|
||||||
|
continuation = result.continuation;
|
||||||
if (videos.length) {
|
|
||||||
return videos
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return []
|
|
||||||
}, maxRetries)
|
|
||||||
|
|
||||||
return res.json(videos)
|
|
||||||
} catch (e: any) {
|
|
||||||
console.log(e)
|
|
||||||
|
|
||||||
if (e.message.includes('Tab "videos" not found')) {
|
|
||||||
return res.json([])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json(false)
|
// Format each video to have both YouTubeI and Invidious compatible fields
|
||||||
}
|
const formattedVideos = videos.map(v => {
|
||||||
|
const videoId = v.id || '';
|
||||||
|
const authorId = v.authorId || channelId;
|
||||||
|
return {
|
||||||
|
...v,
|
||||||
|
type: "video",
|
||||||
|
title: v.title || '',
|
||||||
|
videoId: videoId,
|
||||||
|
author: v.author || '',
|
||||||
|
authorId: authorId,
|
||||||
|
authorUrl: `/channel/${authorId}`,
|
||||||
|
videoThumbnails: getVideoThumbnails(videoId),
|
||||||
|
viewCount: parseViewCount(v.viewCountText),
|
||||||
|
viewCountText: v.viewCountText || '',
|
||||||
|
published: relativeTimeToTimestamp(v.publishedText),
|
||||||
|
publishedText: v.publishedText || '',
|
||||||
|
lengthSeconds: v.lengthSeconds || 0,
|
||||||
|
liveNow: v.liveNow || false
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
async function getNextPage(json: any) {
|
return res.json(formattedVideos);
|
||||||
const page = await json.getContinuation();
|
} catch (e: any) {
|
||||||
return page;
|
console.error("Error fetching channel videos:", e);
|
||||||
|
return res.json(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -220,47 +226,49 @@ app.ws('/download/:id', async (ws, req) => {
|
||||||
downloadStream(videoStreamUrl, selectedFormats.videoFormat, videoOutputStream.stream, ws, 'video'),
|
downloadStream(videoStreamUrl, selectedFormats.videoFormat, videoOutputStream.stream, ws, 'video'),
|
||||||
downloadStream(audioStreamUrl, selectedFormats.audioFormat, audioOutputStream.stream, ws, 'audio')
|
downloadStream(audioStreamUrl, selectedFormats.audioFormat, audioOutputStream.stream, ws, 'audio')
|
||||||
]);
|
]);
|
||||||
} else {
|
|
||||||
const streamOptions: SabrPlaybackOptions = {
|
|
||||||
videoQuality: quality,
|
|
||||||
audioQuality: 'AUDIO_QUALITY_LOW',
|
|
||||||
enabledTrackTypes: EnabledTrackTypes.VIDEO_AND_AUDIO
|
|
||||||
};
|
|
||||||
const lease = await innertubePool.acquire()
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { streamResults } = await createSabrStream(req.params.id, streamOptions, lease.innertube)
|
|
||||||
const { videoStream, audioStream, selectedFormats } = streamResults
|
|
||||||
|
|
||||||
const videoSizeTotal = (selectedFormats.audioFormat.contentLength || 0)
|
|
||||||
+ (selectedFormats.videoFormat.contentLength || 0)
|
|
||||||
|
|
||||||
if (videoSizeTotal > (1_048_576 * config.maxVideoSize) && !config.whitelist.includes(req.params.id)) {
|
|
||||||
ws.send('Is this content considered high risk? If so, please email me at admin@preservetube.com.');
|
|
||||||
ws.send('This video is too large, and unfortunately, Preservetube does not have unlimited storage.');
|
|
||||||
return ws.close()
|
|
||||||
} else if (!selectedFormats.videoFormat.contentLength) {
|
|
||||||
ws.send('Youtube isn\'t giving us enough information to be able to tell if we can process this video.')
|
|
||||||
ws.send('Please try again later.')
|
|
||||||
return ws.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
ws.send(`VIDEOSIZE-${videoSizeTotal}`)
|
|
||||||
audioOutputStream = createOutputStream(req.params.id, selectedFormats.audioFormat.mimeType!);
|
|
||||||
videoOutputStream = createOutputStream(req.params.id, selectedFormats.videoFormat.mimeType!);
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
videoStream.pipeTo(createStreamSink(selectedFormats.videoFormat, videoOutputStream.stream, ws, 'video')),
|
|
||||||
audioStream.pipeTo(createStreamSink(selectedFormats.audioFormat, audioOutputStream.stream, ws, 'audio'))
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
await lease.refresh()
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
lease.release()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// else {
|
||||||
|
// const streamOptions: SabrPlaybackOptions = {
|
||||||
|
// videoQuality: quality,
|
||||||
|
// audioQuality: 'AUDIO_QUALITY_LOW',
|
||||||
|
// enabledTrackTypes: EnabledTrackTypes.VIDEO_AND_AUDIO
|
||||||
|
// };
|
||||||
|
// const lease = await innertubePool.acquire()
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// const { streamResults } = await createSabrStream(req.params.id, streamOptions, lease.innertube)
|
||||||
|
// const { videoStream, audioStream, selectedFormats } = streamResults
|
||||||
|
|
||||||
|
// const videoSizeTotal = (selectedFormats.audioFormat.contentLength || 0)
|
||||||
|
// + (selectedFormats.videoFormat.contentLength || 0)
|
||||||
|
|
||||||
|
// if (videoSizeTotal > (1_048_576 * config.maxVideoSize) && !config.whitelist.includes(req.params.id)) {
|
||||||
|
// ws.send('Is this content considered high risk? If so, please email me at admin@preservetube.com.');
|
||||||
|
// ws.send('This video is too large, and unfortunately, Preservetube does not have unlimited storage.');
|
||||||
|
// return ws.close()
|
||||||
|
// } else if (!selectedFormats.videoFormat.contentLength) {
|
||||||
|
// ws.send('Youtube isn\'t giving us enough information to be able to tell if we can process this video.')
|
||||||
|
// ws.send('Please try again later.')
|
||||||
|
// return ws.close()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// ws.send(`VIDEOSIZE-${videoSizeTotal}`)
|
||||||
|
// audioOutputStream = createOutputStream(req.params.id, selectedFormats.audioFormat.mimeType!);
|
||||||
|
// videoOutputStream = createOutputStream(req.params.id, selectedFormats.videoFormat.mimeType!);
|
||||||
|
|
||||||
|
// await Promise.all([
|
||||||
|
// videoStream.pipeTo(createStreamSink(selectedFormats.videoFormat, videoOutputStream.stream, ws, 'video')),
|
||||||
|
// audioStream.pipeTo(createStreamSink(selectedFormats.audioFormat, audioOutputStream.stream, ws, 'audio'))
|
||||||
|
// ]);
|
||||||
|
// } catch (error) {
|
||||||
|
// await lease.refresh()
|
||||||
|
// throw error
|
||||||
|
// } finally {
|
||||||
|
// lease.release()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
if (audioOutputStream == undefined || videoOutputStream == undefined) {
|
if (audioOutputStream == undefined || videoOutputStream == undefined) {
|
||||||
ws.send('This should not happen. Please report it via admin@preservetube.com.')
|
ws.send('This should not happen. Please report it via admin@preservetube.com.')
|
||||||
return ws.close()
|
return ws.close()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,644 @@
|
||||||
|
import { Buffer } from 'node:buffer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lightweight utility to recreate Invidious's YouTube channel video listing and pagination logic.
|
||||||
|
* It builds synthetic continuation tokens and fetches videos directly using the InnerTube API.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Helper to write unsigned varints
|
||||||
|
function writeVarint(val: number | bigint): Uint8Array {
|
||||||
|
const bytes: number[] = [];
|
||||||
|
let n = BigInt(val);
|
||||||
|
while (n > 127n) {
|
||||||
|
bytes.push(Number((n & 0x7fn) | 0x80n));
|
||||||
|
n >>= 7n;
|
||||||
|
}
|
||||||
|
bytes.push(Number(n));
|
||||||
|
return new Uint8Array(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate multiple Uint8Arrays
|
||||||
|
function concat(...arrays: Uint8Array[]): Uint8Array {
|
||||||
|
let totalLength = 0;
|
||||||
|
for (const arr of arrays) {
|
||||||
|
totalLength += arr.length;
|
||||||
|
}
|
||||||
|
const result = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const arr of arrays) {
|
||||||
|
result.set(arr, offset);
|
||||||
|
offset += arr.length;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes a JS representation of nested protobuf messages into raw bytes.
|
||||||
|
* The input object should have keys formatted as "fieldNumber:wireType"
|
||||||
|
* e.g., "15:embedded", "4:varint", "1:string".
|
||||||
|
*/
|
||||||
|
export function serializeProto(obj: any): Uint8Array {
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
for (const [key, val] of Object.entries(obj)) {
|
||||||
|
if (val === undefined || val === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const parts = key.split(':');
|
||||||
|
const fieldNum = parseInt(parts[0], 10);
|
||||||
|
const type = parts[1];
|
||||||
|
|
||||||
|
if (isNaN(fieldNum)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'varint') {
|
||||||
|
const tag = (fieldNum << 3) | 0;
|
||||||
|
const tagBytes = writeVarint(tag);
|
||||||
|
const valBytes = writeVarint(typeof val === 'boolean' ? (val ? 1 : 0) : (val as number | bigint));
|
||||||
|
chunks.push(concat(tagBytes, valBytes));
|
||||||
|
} else if (type === 'string') {
|
||||||
|
const tag = (fieldNum << 3) | 2;
|
||||||
|
const tagBytes = writeVarint(tag);
|
||||||
|
const strBytes = typeof val === 'string' ? new TextEncoder().encode(val) : (val as Uint8Array);
|
||||||
|
const lenBytes = writeVarint(strBytes.length);
|
||||||
|
chunks.push(concat(tagBytes, lenBytes, strBytes));
|
||||||
|
} else if (type === 'embedded') {
|
||||||
|
const tag = (fieldNum << 3) | 2;
|
||||||
|
const tagBytes = writeVarint(tag);
|
||||||
|
const subBytes = serializeProto(val);
|
||||||
|
const lenBytes = writeVarint(subBytes.length);
|
||||||
|
chunks.push(concat(tagBytes, lenBytes, subBytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return concat(...chunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortOptionsVideosShort(sortBy: string): number {
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'newest': return 4;
|
||||||
|
case 'popular': return 2;
|
||||||
|
case 'oldest': return 5;
|
||||||
|
default: return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a tab-specific object into the outer continuation token structure
|
||||||
|
*/
|
||||||
|
function channelCtokenWrap(ucid: string, object: any): string {
|
||||||
|
const objectInner = {
|
||||||
|
"110:embedded": {
|
||||||
|
"3:embedded": object
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const innerSerialized = serializeProto(objectInner);
|
||||||
|
const objectInnerEncoded = Buffer.from(innerSerialized).toString('base64url');
|
||||||
|
|
||||||
|
const outerObject = {
|
||||||
|
"80226972:embedded": {
|
||||||
|
"2:string": ucid,
|
||||||
|
"3:string": objectInnerEncoded
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const outerSerialized = serializeProto(outerObject);
|
||||||
|
return Buffer.from(outerSerialized).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the initial continuation token for the Videos tab
|
||||||
|
*/
|
||||||
|
export function makeInitialVideosCtoken(ucid: string, sortBy = 'newest'): string {
|
||||||
|
const sortVal = sortOptionsVideosShort(sortBy);
|
||||||
|
const object = {
|
||||||
|
"15:embedded": {
|
||||||
|
"2:embedded": {
|
||||||
|
"1:string": "00000000-0000-0000-0000-000000000000"
|
||||||
|
},
|
||||||
|
"4:varint": sortVal,
|
||||||
|
"8:embedded": {
|
||||||
|
"1:string": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"3:varint": sortVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return channelCtokenWrap(ucid, object);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the initial continuation token for the Shorts tab
|
||||||
|
*/
|
||||||
|
export function makeInitialShortsCtoken(ucid: string, sortBy = 'newest'): string {
|
||||||
|
const sortVal = sortOptionsVideosShort(sortBy);
|
||||||
|
const object = {
|
||||||
|
"10:embedded": {
|
||||||
|
"2:embedded": {
|
||||||
|
"1:string": "00000000-0000-0000-0000-000000000000"
|
||||||
|
},
|
||||||
|
"4:varint": sortVal,
|
||||||
|
"7:embedded": {
|
||||||
|
"1:string": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"3:varint": sortVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return channelCtokenWrap(ucid, object);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the initial continuation token for the Livestreams tab
|
||||||
|
*/
|
||||||
|
export function makeInitialLivestreamsCtoken(ucid: string, sortBy = 'newest'): string {
|
||||||
|
let sortVal = 12;
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'newest': sortVal = 12; break;
|
||||||
|
case 'popular': sortVal = 14; break;
|
||||||
|
case 'oldest': sortVal = 13; break;
|
||||||
|
}
|
||||||
|
const object = {
|
||||||
|
"14:embedded": {
|
||||||
|
"2:embedded": {
|
||||||
|
"1:string": "00000000-0000-0000-0000-000000000000"
|
||||||
|
},
|
||||||
|
"5:varint": sortVal,
|
||||||
|
"8:embedded": {
|
||||||
|
"1:string": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"3:varint": sortVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return channelCtokenWrap(ucid, object);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseText(node: any): string {
|
||||||
|
if (!node) return '';
|
||||||
|
if (typeof node.simpleText === 'string') {
|
||||||
|
return node.simpleText;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.runs)) {
|
||||||
|
return node.runs.map((run: any) => run.text || '').join('');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeLengthSeconds(lengthText: string): number {
|
||||||
|
if (!lengthText) return 0;
|
||||||
|
const parts = lengthText.split(':').map(Number);
|
||||||
|
if (parts.some(isNaN)) return 0;
|
||||||
|
|
||||||
|
let seconds = 0;
|
||||||
|
if (parts.length === 2) {
|
||||||
|
seconds = parts[0] * 60 + parts[1];
|
||||||
|
} else if (parts.length === 3) {
|
||||||
|
seconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
}
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVideoRenderer(renderer: any) {
|
||||||
|
const videoId = renderer.videoId || '';
|
||||||
|
const title = parseText(renderer.title);
|
||||||
|
|
||||||
|
let author = '';
|
||||||
|
let authorId = '';
|
||||||
|
|
||||||
|
const ownerText = renderer.ownerText || renderer.shortBylineText;
|
||||||
|
if (ownerText && Array.isArray(ownerText.runs) && ownerText.runs[0]) {
|
||||||
|
author = ownerText.runs[0].text || '';
|
||||||
|
authorId = ownerText.runs[0].navigationEndpoint?.browseEndpoint?.browseId || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishedText = parseText(renderer.publishedTimeText);
|
||||||
|
|
||||||
|
let viewCountText = parseText(renderer.viewCountText);
|
||||||
|
if (!viewCountText && renderer.shortViewCountText) {
|
||||||
|
viewCountText = parseText(renderer.shortViewCountText);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lengthSeconds = 0;
|
||||||
|
if (renderer.lengthText) {
|
||||||
|
lengthSeconds = decodeLengthSeconds(parseText(renderer.lengthText));
|
||||||
|
} else if (Array.isArray(renderer.thumbnailOverlays)) {
|
||||||
|
const timeOverlay = renderer.thumbnailOverlays.find((overlay: any) => overlay.thumbnailOverlayTimeStatusRenderer);
|
||||||
|
if (timeOverlay) {
|
||||||
|
const timeText = parseText(timeOverlay.thumbnailOverlayTimeStatusRenderer.text);
|
||||||
|
if (timeText === 'SHORTS') {
|
||||||
|
lengthSeconds = 60;
|
||||||
|
} else {
|
||||||
|
lengthSeconds = decodeLengthSeconds(timeText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let liveNow = false;
|
||||||
|
if (Array.isArray(renderer.badges)) {
|
||||||
|
liveNow = renderer.badges.some((badge: any) =>
|
||||||
|
badge.metadataBadgeRenderer?.label === 'LIVE'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: videoId,
|
||||||
|
title,
|
||||||
|
author,
|
||||||
|
authorId,
|
||||||
|
publishedText,
|
||||||
|
viewCountText,
|
||||||
|
lengthSeconds,
|
||||||
|
liveNow,
|
||||||
|
isPlaylist: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseShortsLockup(model: any) {
|
||||||
|
const videoId = model.onTap?.innertubeCommand?.reelWatchEndpoint?.videoId || '';
|
||||||
|
const title = model.overlayMetadata?.primaryText?.content || '';
|
||||||
|
const viewCountText = model.overlayMetadata?.secondaryText?.content || '';
|
||||||
|
return {
|
||||||
|
id: videoId,
|
||||||
|
title,
|
||||||
|
author: '',
|
||||||
|
authorId: '',
|
||||||
|
publishedText: '',
|
||||||
|
viewCountText,
|
||||||
|
lengthSeconds: 60,
|
||||||
|
liveNow: false,
|
||||||
|
isPlaylist: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLockupViewModel(model: any) {
|
||||||
|
const isVideo = model.contentType === 'LOCKUP_CONTENT_TYPE_VIDEO';
|
||||||
|
const id = model.contentId || model.onTap?.innertubeCommand?.watchEndpoint?.videoId || '';
|
||||||
|
const title = model.metadata?.lockupMetadataViewModel?.title?.content || '';
|
||||||
|
|
||||||
|
let viewCountText = '';
|
||||||
|
let publishedText = '';
|
||||||
|
|
||||||
|
const metadataRows = model.metadata?.lockupMetadataViewModel?.metadata?.contentMetadataViewModel?.metadataRows;
|
||||||
|
if (Array.isArray(metadataRows) && metadataRows[0]) {
|
||||||
|
const parts = metadataRows[0].metadataParts;
|
||||||
|
if (Array.isArray(parts)) {
|
||||||
|
if (parts[0]?.text?.content) {
|
||||||
|
viewCountText = parts[0].text.content;
|
||||||
|
}
|
||||||
|
if (parts[1]?.text?.content) {
|
||||||
|
publishedText = parts[1].text.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lengthSeconds = 0;
|
||||||
|
const overlays = model.contentImage?.thumbnailViewModel?.overlays;
|
||||||
|
if (Array.isArray(overlays)) {
|
||||||
|
const timeOverlay = overlays.find((overlay: any) => overlay.thumbnailOverlayTimeStatusRenderer);
|
||||||
|
if (timeOverlay) {
|
||||||
|
const timeText = timeOverlay.thumbnailOverlayTimeStatusRenderer.text?.content || '';
|
||||||
|
if (timeText === 'SHORTS') {
|
||||||
|
lengthSeconds = 60;
|
||||||
|
} else {
|
||||||
|
lengthSeconds = decodeLengthSeconds(timeText);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const bottomOverlay = overlays.find((overlay: any) => overlay.thumbnailBottomOverlayViewModel);
|
||||||
|
if (bottomOverlay && Array.isArray(bottomOverlay.thumbnailBottomOverlayViewModel.badges)) {
|
||||||
|
const badge = bottomOverlay.thumbnailBottomOverlayViewModel.badges.find((b: any) => b.thumbnailBadgeViewModel);
|
||||||
|
if (badge) {
|
||||||
|
const timeText = badge.thumbnailBadgeViewModel.text || '';
|
||||||
|
if (timeText === 'SHORTS') {
|
||||||
|
lengthSeconds = 60;
|
||||||
|
} else {
|
||||||
|
lengthSeconds = decodeLengthSeconds(timeText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
author: '',
|
||||||
|
authorId: '',
|
||||||
|
publishedText,
|
||||||
|
viewCountText,
|
||||||
|
lengthSeconds,
|
||||||
|
liveNow: false,
|
||||||
|
isPlaylist: !isVideo
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree walker to find video/playlist renderers and continuation tokens recursively
|
||||||
|
function findRenderers(obj: any, results: { videos: any[]; continuationToken: string | null }) {
|
||||||
|
if (!obj || typeof obj !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
for (const item of obj) {
|
||||||
|
findRenderers(item, results);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for continuation token
|
||||||
|
if (obj.continuationItemRenderer) {
|
||||||
|
const token = obj.continuationItemRenderer.continuationEndpoint?.continuationCommand?.token;
|
||||||
|
if (token) {
|
||||||
|
results.continuationToken = token;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for video renderer
|
||||||
|
if (obj.videoRenderer || obj.gridVideoRenderer) {
|
||||||
|
const renderer = obj.videoRenderer || obj.gridVideoRenderer;
|
||||||
|
results.videos.push(parseVideoRenderer(renderer));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for shorts/lockup view models
|
||||||
|
if (obj.shortsLockupViewModel) {
|
||||||
|
results.videos.push(parseShortsLockup(obj.shortsLockupViewModel));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.lockupViewModel) {
|
||||||
|
results.videos.push(parseLockupViewModel(obj.lockupViewModel));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
findRenderers(obj[key], results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses the raw InnerTube response to extract video objects and continuation token.
|
||||||
|
*/
|
||||||
|
export function parseBrowseResponse(data: any): { videos: any[]; continuation: string | null } {
|
||||||
|
const results: { videos: any[]; continuationToken: string | null } = {
|
||||||
|
videos: [],
|
||||||
|
continuationToken: null
|
||||||
|
};
|
||||||
|
|
||||||
|
findRenderers(data, results);
|
||||||
|
|
||||||
|
return {
|
||||||
|
videos: results.videos,
|
||||||
|
continuation: results.continuationToken
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches videos from a channel using Invidious ctoken generation and browse endpoint.
|
||||||
|
*/
|
||||||
|
export async function fetchChannelVideos(
|
||||||
|
ucid: string,
|
||||||
|
sortBy: 'newest' | 'popular' | 'oldest' = 'newest',
|
||||||
|
continuation?: string
|
||||||
|
): Promise<{ videos: any[]; continuation: string | null }> {
|
||||||
|
const token = continuation || makeInitialVideosCtoken(ucid, sortBy);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
context: {
|
||||||
|
client: {
|
||||||
|
hl: 'en',
|
||||||
|
gl: 'US',
|
||||||
|
clientName: 'WEB',
|
||||||
|
clientVersion: '2.20250222.10.00'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
continuation: token
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-youtube-client-name': '1',
|
||||||
|
'x-youtube-client-version': '2.20250222.10.00',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`YouTube API returned HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as any;
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(`YouTube API error ${data.error.code}: ${data.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseBrowseResponse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVideoThumbnails(videoId: string) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
quality: "default",
|
||||||
|
url: `https://i.ytimg.com/vi/${videoId}/default.jpg`,
|
||||||
|
width: 120,
|
||||||
|
height: 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quality: "medium",
|
||||||
|
url: `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`,
|
||||||
|
width: 320,
|
||||||
|
height: 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quality: "high",
|
||||||
|
url: `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`,
|
||||||
|
width: 480,
|
||||||
|
height: 360
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quality: "standard",
|
||||||
|
url: `https://i.ytimg.com/vi/${videoId}/sddefault.jpg`,
|
||||||
|
width: 640,
|
||||||
|
height: 480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quality: "sddefault",
|
||||||
|
url: `https://i.ytimg.com/vi/${videoId}/sddefault.jpg`,
|
||||||
|
width: 640,
|
||||||
|
height: 480
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quality: "maxres",
|
||||||
|
url: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
|
||||||
|
width: 1280,
|
||||||
|
height: 720
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quality: "maxresdefault",
|
||||||
|
url: `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`,
|
||||||
|
width: 1280,
|
||||||
|
height: 720
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseViewCount(text: string): number {
|
||||||
|
if (!text) return 0;
|
||||||
|
const match = text.replace(/,/g, '').match(/\d+/);
|
||||||
|
if (match && match[0]) {
|
||||||
|
return parseInt(match[0], 10);
|
||||||
|
}
|
||||||
|
const clean = text.toLowerCase();
|
||||||
|
if (clean.includes('k')) {
|
||||||
|
const num = parseFloat(clean.split('k')[0] || '0');
|
||||||
|
return isNaN(num) ? 0 : Math.floor(num * 1000);
|
||||||
|
}
|
||||||
|
if (clean.includes('m')) {
|
||||||
|
const num = parseFloat(clean.split('m')[0] || '0');
|
||||||
|
return isNaN(num) ? 0 : Math.floor(num * 1000000);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeTimeToTimestamp(publishedText: string): number {
|
||||||
|
if (!publishedText) return 0;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const match = publishedText.match(/(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago/i);
|
||||||
|
if (!match || !match[1] || !match[2]) return now;
|
||||||
|
const value = parseInt(match[1], 10);
|
||||||
|
const unit = match[2].toLowerCase();
|
||||||
|
let diff = 0;
|
||||||
|
switch (unit) {
|
||||||
|
case 'second': diff = value; break;
|
||||||
|
case 'minute': diff = value * 60; break;
|
||||||
|
case 'hour': diff = value * 3600; break;
|
||||||
|
case 'day': diff = value * 86400; break;
|
||||||
|
case 'week': diff = value * 604800; break;
|
||||||
|
case 'month': diff = value * 2592000; break;
|
||||||
|
case 'year': diff = value * 31536000; break;
|
||||||
|
}
|
||||||
|
return (now - diff) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchChannelInfo(channelId: string) {
|
||||||
|
const payload = {
|
||||||
|
context: {
|
||||||
|
client: {
|
||||||
|
hl: 'en',
|
||||||
|
gl: 'US',
|
||||||
|
clientName: 'WEB',
|
||||||
|
clientVersion: '2.20250222.10.00'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
browseId: channelId
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-youtube-client-name': '1',
|
||||||
|
'x-youtube-client-version': '2.20250222.10.00',
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36'
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`YouTube API returned HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as any;
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(`YouTube API error ${data.error.code}: ${data.error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderer = data.metadata?.channelMetadataRenderer;
|
||||||
|
if (!renderer) {
|
||||||
|
throw new Error('Could not find channel metadata renderer in YouTube response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const author = renderer.title || '';
|
||||||
|
const authorId = renderer.externalId || channelId;
|
||||||
|
const authorUrl = `/channel/${authorId}`;
|
||||||
|
|
||||||
|
const rawAvatarUrl = renderer.avatar?.thumbnails?.[0]?.url || '';
|
||||||
|
const authorThumbnails = rawAvatarUrl ? getAuthorThumbnails(rawAvatarUrl) : [];
|
||||||
|
|
||||||
|
// Parse subscriber and video counts
|
||||||
|
let subCount = 0;
|
||||||
|
let videoCount = 0;
|
||||||
|
const metadataRows = data.header?.pageHeaderRenderer?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows;
|
||||||
|
if (Array.isArray(metadataRows)) {
|
||||||
|
for (const row of metadataRows) {
|
||||||
|
if (Array.isArray(row.metadataParts)) {
|
||||||
|
for (const part of row.metadataParts) {
|
||||||
|
const text = part.text?.content || '';
|
||||||
|
if (text.includes('subscriber')) {
|
||||||
|
const subText = text.split(/\s+/)[0];
|
||||||
|
subCount = shortTextToNumber(subText);
|
||||||
|
} else if (text.includes('video')) {
|
||||||
|
const videoText = text.split(/\s+/)[0];
|
||||||
|
videoCount = shortTextToNumber(videoText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const description = renderer.description || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'channel',
|
||||||
|
author,
|
||||||
|
authorId,
|
||||||
|
authorUrl,
|
||||||
|
authorThumbnails,
|
||||||
|
subCount,
|
||||||
|
videoCount,
|
||||||
|
description,
|
||||||
|
descriptionHtml: description,
|
||||||
|
authorVerified: false,
|
||||||
|
autoGenerated: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthorThumbnails(avatarUrl: string) {
|
||||||
|
const qualities = [32, 48, 76, 100, 176, 512];
|
||||||
|
return qualities.map(quality => {
|
||||||
|
let url = avatarUrl;
|
||||||
|
if (url.includes('=s')) {
|
||||||
|
url = url.replace(/=s\d+/, `=s${quality}`);
|
||||||
|
} else {
|
||||||
|
url = url + `=s${quality}`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
width: quality,
|
||||||
|
height: quality
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortTextToNumber(shortText: string): number {
|
||||||
|
if (!shortText) return 0;
|
||||||
|
const match = shortText.match(/(\d+(?:\.\d+)?)\s*([mMkKbB]?)/i);
|
||||||
|
if (!match) return 0;
|
||||||
|
let number = parseFloat(match[1]);
|
||||||
|
const suffix = match[2]?.toLowerCase();
|
||||||
|
switch (suffix) {
|
||||||
|
case 'k': number *= 1000; break;
|
||||||
|
case 'm': number *= 1000000; break;
|
||||||
|
case 'b': number *= 1000000000; break;
|
||||||
|
}
|
||||||
|
return Math.floor(number);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue