mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
Update youtubeExtractor.ts
This commit is contained in:
parent
25de499931
commit
da39ca47bc
1 changed files with 165 additions and 289 deletions
|
|
@ -18,26 +18,15 @@ interface InnertubeFormat {
|
||||||
qualityLabel?: string;
|
qualityLabel?: string;
|
||||||
audioQuality?: string;
|
audioQuality?: string;
|
||||||
audioSampleRate?: string;
|
audioSampleRate?: string;
|
||||||
audioChannels?: number;
|
|
||||||
approxDurationMs?: string;
|
|
||||||
initRange?: { start: string; end: string };
|
initRange?: { start: string; end: string };
|
||||||
indexRange?: { start: string; end: string };
|
indexRange?: { start: string; end: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StreamingData {
|
|
||||||
formats?: InnertubeFormat[];
|
|
||||||
adaptiveFormats?: InnertubeFormat[];
|
|
||||||
hlsManifestUrl?: string;
|
|
||||||
expiresInSeconds?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PlayerResponse {
|
interface PlayerResponse {
|
||||||
streamingData?: StreamingData;
|
streamingData?: {
|
||||||
videoDetails?: {
|
formats?: InnertubeFormat[];
|
||||||
videoId?: string;
|
adaptiveFormats?: InnertubeFormat[];
|
||||||
title?: string;
|
hlsManifestUrl?: string;
|
||||||
lengthSeconds?: string;
|
|
||||||
isLive?: boolean;
|
|
||||||
};
|
};
|
||||||
playabilityStatus?: {
|
playabilityStatus?: {
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
@ -53,12 +42,9 @@ interface StreamCandidate {
|
||||||
height: number;
|
height: number;
|
||||||
fps: number;
|
fps: number;
|
||||||
ext: 'mp4' | 'webm' | 'm4a' | 'other';
|
ext: 'mp4' | 'webm' | 'm4a' | 'other';
|
||||||
initRange?: { start: string; end: string };
|
|
||||||
indexRange?: { start: string; end: string };
|
|
||||||
bitrate: number;
|
bitrate: number;
|
||||||
audioSampleRate?: string;
|
audioSampleRate?: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
itag?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HlsVariant {
|
interface HlsVariant {
|
||||||
|
|
@ -68,25 +54,32 @@ interface HlsVariant {
|
||||||
bandwidth: number;
|
bandwidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrailerPlaybackSource {
|
|
||||||
videoUrl: string; // best video (may be muxed, DASH video-only, HLS, or progressive)
|
|
||||||
audioUrl: string | null; // separate audio URL if adaptive, null if muxed/HLS
|
|
||||||
quality: string;
|
|
||||||
isMuxed: boolean; // true if videoUrl already contains audio
|
|
||||||
isDash: boolean; // true if we should build a DASH manifest
|
|
||||||
durationSeconds: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface YouTubeExtractionResult {
|
export interface YouTubeExtractionResult {
|
||||||
source: TrailerPlaybackSource;
|
/** Primary playable URL — HLS manifest, progressive muxed, or video-only adaptive */
|
||||||
|
videoUrl: string;
|
||||||
|
/** Separate audio URL when adaptive video-only is used. null for HLS/progressive. */
|
||||||
|
audioUrl: string | null;
|
||||||
|
quality: string;
|
||||||
videoId: string;
|
videoId: string;
|
||||||
title?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Client definitions
|
// Constants — matching the Kotlin extractor exactly
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Used for all GET requests (watch page, HLS manifest fetch)
|
||||||
|
const DEFAULT_USER_AGENT =
|
||||||
|
'Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 ' +
|
||||||
|
'(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
const DEFAULT_HEADERS: Record<string, string> = {
|
||||||
|
'accept-language': 'en-US,en;q=0.9',
|
||||||
|
'user-agent': DEFAULT_USER_AGENT,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFERRED_ADAPTIVE_CLIENT = 'android_vr';
|
||||||
|
const REQUEST_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
interface ClientDef {
|
interface ClientDef {
|
||||||
key: string;
|
key: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -96,21 +89,22 @@ interface ClientDef {
|
||||||
priority: number;
|
priority: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PREFERRED_ADAPTIVE_CLIENT = 'android_vr';
|
// Matching the Kotlin extractor client list exactly (versions updated to current)
|
||||||
|
|
||||||
const CLIENTS: ClientDef[] = [
|
const CLIENTS: ClientDef[] = [
|
||||||
{
|
{
|
||||||
key: 'android_vr',
|
key: 'android_vr',
|
||||||
id: '28',
|
id: '28',
|
||||||
version: '1.62.27',
|
version: '1.62.27',
|
||||||
userAgent: 'com.google.android.apps.youtube.vr.oculus/1.62.27 (Linux; U; Android 12L; eureka-user Build/SQ3A.220605.009.A1) gzip',
|
userAgent:
|
||||||
|
'com.google.android.apps.youtube.vr.oculus/1.62.27 ' +
|
||||||
|
'(Linux; U; Android 12; en_US; Quest 3; Build/SQ3A.220605.009.A1) gzip',
|
||||||
context: {
|
context: {
|
||||||
clientName: 'ANDROID_VR',
|
clientName: 'ANDROID_VR',
|
||||||
clientVersion: '1.62.27',
|
clientVersion: '1.62.27',
|
||||||
deviceMake: 'Oculus',
|
deviceMake: 'Oculus',
|
||||||
deviceModel: 'Quest 3',
|
deviceModel: 'Quest 3',
|
||||||
osName: 'Android',
|
osName: 'Android',
|
||||||
osVersion: '12L',
|
osVersion: '12',
|
||||||
platform: 'MOBILE',
|
platform: 'MOBILE',
|
||||||
androidSdkVersion: 32,
|
androidSdkVersion: 32,
|
||||||
hl: 'en',
|
hl: 'en',
|
||||||
|
|
@ -122,7 +116,8 @@ const CLIENTS: ClientDef[] = [
|
||||||
key: 'android',
|
key: 'android',
|
||||||
id: '3',
|
id: '3',
|
||||||
version: '20.10.38',
|
version: '20.10.38',
|
||||||
userAgent: 'com.google.android.youtube/20.10.38 (Linux; U; Android 14; en_US) gzip',
|
userAgent:
|
||||||
|
'com.google.android.youtube/20.10.38 (Linux; U; Android 14; en_US) gzip',
|
||||||
context: {
|
context: {
|
||||||
clientName: 'ANDROID',
|
clientName: 'ANDROID',
|
||||||
clientVersion: '20.10.38',
|
clientVersion: '20.10.38',
|
||||||
|
|
@ -139,7 +134,8 @@ const CLIENTS: ClientDef[] = [
|
||||||
key: 'ios',
|
key: 'ios',
|
||||||
id: '5',
|
id: '5',
|
||||||
version: '20.10.1',
|
version: '20.10.1',
|
||||||
userAgent: 'com.google.ios.youtube/20.10.1 (iPhone16,2; U; CPU iOS 17_4 like Mac OS X)',
|
userAgent:
|
||||||
|
'com.google.ios.youtube/20.10.1 (iPhone16,2; U; CPU iOS 17_4 like Mac OS X)',
|
||||||
context: {
|
context: {
|
||||||
clientName: 'IOS',
|
clientName: 'IOS',
|
||||||
clientVersion: '20.10.1',
|
clientVersion: '20.10.1',
|
||||||
|
|
@ -154,13 +150,6 @@ const CLIENTS: ClientDef[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 20000;
|
|
||||||
const DEFAULT_UA = 'Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36';
|
|
||||||
const DEFAULT_HEADERS: Record<string, string> = {
|
|
||||||
'accept-language': 'en-US,en;q=0.9',
|
|
||||||
'user-agent': DEFAULT_UA,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -191,26 +180,16 @@ function getMimeBase(mimeType?: string): string {
|
||||||
return (mimeType ?? '').split(';')[0].trim();
|
return (mimeType ?? '').split(';')[0].trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCodecs(mimeType?: string): string {
|
|
||||||
const m = (mimeType ?? '').match(/codecs="?([^"]+)"?/i);
|
|
||||||
return m ? m[1].trim() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getExt(mimeType?: string): 'mp4' | 'webm' | 'm4a' | 'other' {
|
function getExt(mimeType?: string): 'mp4' | 'webm' | 'm4a' | 'other' {
|
||||||
const base = getMimeBase(mimeType);
|
const base = getMimeBase(mimeType);
|
||||||
if (base.includes('mp4') || base === 'audio/mp4') return 'mp4';
|
if (base === 'video/mp4' || base === 'audio/mp4') return 'mp4';
|
||||||
if (base.includes('webm')) return 'webm';
|
if (base.includes('webm')) return 'webm';
|
||||||
if (base === 'audio/mp4' || base.includes('m4a')) return 'm4a';
|
if (base.includes('m4a')) return 'm4a';
|
||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
function containerScore(ext: string): number {
|
function containerScore(ext: string): number {
|
||||||
switch (ext) {
|
return ext === 'mp4' || ext === 'm4a' ? 0 : ext === 'webm' ? 1 : 2;
|
||||||
case 'mp4':
|
|
||||||
case 'm4a': return 0;
|
|
||||||
case 'webm': return 1;
|
|
||||||
default: return 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function videoScore(height: number, fps: number, bitrate: number): number {
|
function videoScore(height: number, fps: number, bitrate: number): number {
|
||||||
|
|
@ -222,24 +201,10 @@ function audioScore(bitrate: number, sampleRate: number): number {
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseQualityLabel(label?: string): number {
|
function parseQualityLabel(label?: string): number {
|
||||||
if (!label) return 0;
|
const m = (label ?? '').match(/(\d{2,4})p/);
|
||||||
const m = label.match(/(\d{2,4})p/);
|
|
||||||
return m ? parseInt(m[1], 10) : 0;
|
return m ? parseInt(m[1], 10) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isMuxedFormat(f: InnertubeFormat): boolean {
|
|
||||||
const codecs = getCodecs(f.mimeType);
|
|
||||||
return (!!f.qualityLabel && !!f.audioQuality) || codecs.includes(',');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isVideoOnly(f: InnertubeFormat): boolean {
|
|
||||||
return !!(f.qualityLabel && !f.audioQuality && f.mimeType?.startsWith('video/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAudioOnly(f: InnertubeFormat): boolean {
|
|
||||||
return !!(f.audioQuality && !f.qualityLabel && f.mimeType?.startsWith('audio/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeUrl(url: string): string {
|
function summarizeUrl(url: string): string {
|
||||||
try {
|
try {
|
||||||
const u = new URL(url);
|
const u = new URL(url);
|
||||||
|
|
@ -249,6 +214,24 @@ function summarizeUrl(url: string): string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortCandidates(items: StreamCandidate[]): StreamCandidate[] {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
if (b.score !== a.score) return b.score - a.score;
|
||||||
|
const ca = containerScore(a.ext), cb = containerScore(b.ext);
|
||||||
|
if (ca !== cb) return ca - cb;
|
||||||
|
return a.priority - b.priority;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestForClient(
|
||||||
|
items: StreamCandidate[],
|
||||||
|
preferredClient: string,
|
||||||
|
): StreamCandidate | null {
|
||||||
|
const fromPreferred = items.filter(c => c.client === preferredClient);
|
||||||
|
const pool = fromPreferred.length > 0 ? fromPreferred : items;
|
||||||
|
return sortCandidates(pool)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Watch page — extract API key + visitor data dynamically
|
// Watch page — extract API key + visitor data dynamically
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -262,31 +245,29 @@ async function fetchWatchConfig(videoId: string): Promise<WatchConfig> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`https://www.youtube.com/watch?v=${videoId}&hl=en`, {
|
const res = await fetch(
|
||||||
headers: DEFAULT_HEADERS,
|
`https://www.youtube.com/watch?v=${videoId}&hl=en`,
|
||||||
signal: controller.signal,
|
{ headers: DEFAULT_HEADERS, signal: controller.signal },
|
||||||
});
|
);
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
logger.warn('YouTubeExtractor', `Watch page fetch failed: ${res.status}`);
|
logger.warn('YouTubeExtractor', `Watch page ${res.status}`);
|
||||||
return { apiKey: null, visitorData: null };
|
return { apiKey: null, visitorData: null };
|
||||||
}
|
}
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
const apiKeyMatch = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/);
|
const apiKey = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/)?.[1] ?? null;
|
||||||
const visitorMatch = html.match(/"VISITOR_DATA":"([^"]+)"/);
|
const visitorData = html.match(/"VISITOR_DATA":"([^"]+)"/)?.[1] ?? null;
|
||||||
return {
|
logger.info('YouTubeExtractor', `Watch page: apiKey=${apiKey ? 'found' : 'missing'} visitorData=${visitorData ? 'found' : 'missing'}`);
|
||||||
apiKey: apiKeyMatch?.[1] ?? null,
|
return { apiKey, visitorData };
|
||||||
visitorData: visitorMatch?.[1] ?? null,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
logger.warn('YouTubeExtractor', 'Watch page fetch error:', err);
|
logger.warn('YouTubeExtractor', 'Watch page error:', err);
|
||||||
return { apiKey: null, visitorData: null };
|
return { apiKey: null, visitorData: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Player API request
|
// Player API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function fetchPlayerResponse(
|
async function fetchPlayerResponse(
|
||||||
|
|
@ -298,8 +279,7 @@ async function fetchPlayerResponse(
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
|
||||||
// Use dynamic API key if available, otherwise omit (deprecated but harmless)
|
const endpoint = apiKey
|
||||||
const url = apiKey
|
|
||||||
? `https://www.youtube.com/youtubei/v1/player?key=${encodeURIComponent(apiKey)}&prettyPrint=false`
|
? `https://www.youtube.com/youtubei/v1/player?key=${encodeURIComponent(apiKey)}&prettyPrint=false`
|
||||||
: `https://www.youtube.com/youtubei/v1/player?prettyPrint=false`;
|
: `https://www.youtube.com/youtubei/v1/player?prettyPrint=false`;
|
||||||
|
|
||||||
|
|
@ -316,19 +296,24 @@ async function fetchPlayerResponse(
|
||||||
|
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
videoId,
|
videoId,
|
||||||
context: { client: client.context },
|
|
||||||
contentCheckOk: true,
|
contentCheckOk: true,
|
||||||
racyCheckOk: true,
|
racyCheckOk: true,
|
||||||
|
context: { client: client.context },
|
||||||
playbackContext: {
|
playbackContext: {
|
||||||
contentPlaybackContext: { html5Preference: 'HTML5_PREF_WANTS' },
|
contentPlaybackContext: { html5Preference: 'HTML5_PREF_WANTS' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
|
const res = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
logger.warn('YouTubeExtractor', `[${client.key}] player API HTTP ${res.status}`);
|
logger.warn('YouTubeExtractor', `[${client.key}] HTTP ${res.status}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return await res.json() as PlayerResponse;
|
return await res.json() as PlayerResponse;
|
||||||
|
|
@ -344,14 +329,17 @@ async function fetchPlayerResponse(
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// HLS manifest parsing — pick best variant by height then bandwidth
|
// HLS manifest parsing
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function parseBestHlsVariant(manifestUrl: string): Promise<HlsVariant | null> {
|
async function parseBestHlsVariant(manifestUrl: string): Promise<HlsVariant | null> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(manifestUrl, { headers: DEFAULT_HEADERS, signal: controller.signal });
|
const res = await fetch(manifestUrl, {
|
||||||
|
headers: DEFAULT_HEADERS,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
|
|
@ -364,14 +352,16 @@ async function parseBestHlsVariant(manifestUrl: string): Promise<HlsVariant | nu
|
||||||
const nextLine = lines[i + 1];
|
const nextLine = lines[i + 1];
|
||||||
if (!nextLine || nextLine.startsWith('#')) continue;
|
if (!nextLine || nextLine.startsWith('#')) continue;
|
||||||
|
|
||||||
// Parse attributes
|
// Parse attribute list
|
||||||
const attrs: Record<string, string> = {};
|
const attrs: Record<string, string> = {};
|
||||||
const attrStr = line.substring(line.indexOf(':') + 1);
|
|
||||||
let key = '', val = '', inKey = true, inQuote = false;
|
let key = '', val = '', inKey = true, inQuote = false;
|
||||||
for (const ch of attrStr) {
|
for (const ch of line.substring(line.indexOf(':') + 1)) {
|
||||||
if (inKey) { if (ch === '=') inKey = false; else key += ch; continue; }
|
if (inKey) { if (ch === '=') inKey = false; else key += ch; continue; }
|
||||||
if (ch === '"') { inQuote = !inQuote; continue; }
|
if (ch === '"') { inQuote = !inQuote; continue; }
|
||||||
if (ch === ',' && !inQuote) { attrs[key.trim()] = val.trim(); key = ''; val = ''; inKey = true; continue; }
|
if (ch === ',' && !inQuote) {
|
||||||
|
if (key.trim()) attrs[key.trim()] = val.trim();
|
||||||
|
key = ''; val = ''; inKey = true; continue;
|
||||||
|
}
|
||||||
val += ch;
|
val += ch;
|
||||||
}
|
}
|
||||||
if (key.trim()) attrs[key.trim()] = val.trim();
|
if (key.trim()) attrs[key.trim()] = val.trim();
|
||||||
|
|
@ -381,14 +371,17 @@ async function parseBestHlsVariant(manifestUrl: string): Promise<HlsVariant | nu
|
||||||
const height = parseInt(res2[1] ?? '0', 10) || 0;
|
const height = parseInt(res2[1] ?? '0', 10) || 0;
|
||||||
const bandwidth = parseInt(attrs['BANDWIDTH'] ?? '0', 10) || 0;
|
const bandwidth = parseInt(attrs['BANDWIDTH'] ?? '0', 10) || 0;
|
||||||
|
|
||||||
// Absolutize URL
|
|
||||||
let variantUrl = nextLine;
|
let variantUrl = nextLine;
|
||||||
if (!variantUrl.startsWith('http')) {
|
if (!variantUrl.startsWith('http')) {
|
||||||
try { variantUrl = new URL(variantUrl, manifestUrl).toString(); } catch { /* keep as-is */ }
|
try { variantUrl = new URL(variantUrl, manifestUrl).toString(); } catch { /* keep */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate: HlsVariant = { url: variantUrl, width, height, bandwidth };
|
const candidate: HlsVariant = { url: variantUrl, width, height, bandwidth };
|
||||||
if (!best || height > best.height || (height === best.height && bandwidth > best.bandwidth)) {
|
if (
|
||||||
|
!best ||
|
||||||
|
candidate.height > best.height ||
|
||||||
|
(candidate.height === best.height && candidate.bandwidth > best.bandwidth)
|
||||||
|
) {
|
||||||
best = candidate;
|
best = candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -401,13 +394,13 @@ async function parseBestHlsVariant(manifestUrl: string): Promise<HlsVariant | nu
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Format collection — tries ALL clients, collects from all
|
// Format collection — tries ALL clients, collects from all (matching Kotlin)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface CollectedFormats {
|
interface CollectedFormats {
|
||||||
progressive: StreamCandidate[]; // muxed video+audio
|
progressive: StreamCandidate[];
|
||||||
adaptiveVideo: StreamCandidate[]; // video-only adaptive
|
adaptiveVideo: StreamCandidate[];
|
||||||
adaptiveAudio: StreamCandidate[]; // audio-only adaptive
|
adaptiveAudio: StreamCandidate[];
|
||||||
hlsManifests: Array<{ clientKey: string; priority: number; url: string }>;
|
hlsManifests: Array<{ clientKey: string; priority: number; url: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -435,16 +428,17 @@ async function collectAllFormats(
|
||||||
const sd = resp.streamingData;
|
const sd = resp.streamingData;
|
||||||
if (!sd) continue;
|
if (!sd) continue;
|
||||||
|
|
||||||
// Collect HLS manifest URL if present
|
|
||||||
if (sd.hlsManifestUrl) {
|
if (sd.hlsManifestUrl) {
|
||||||
hlsManifests.push({ clientKey: client.key, priority: client.priority, url: sd.hlsManifestUrl });
|
hlsManifests.push({ clientKey: client.key, priority: client.priority, url: sd.hlsManifestUrl });
|
||||||
}
|
}
|
||||||
|
|
||||||
let clientProgressive = 0, clientVideo = 0, clientAudio = 0;
|
let nProg = 0, nVid = 0, nAud = 0;
|
||||||
|
|
||||||
// Progressive (muxed) formats
|
// Progressive (muxed) formats — matching Kotlin: skip non-video mimeTypes
|
||||||
for (const f of (sd.formats ?? [])) {
|
for (const f of (sd.formats ?? [])) {
|
||||||
if (!f.url || !f.mimeType?.startsWith('video/')) continue;
|
if (!f.url) continue;
|
||||||
|
const mimeBase = getMimeBase(f.mimeType);
|
||||||
|
if (f.mimeType && !mimeBase.startsWith('video/')) continue;
|
||||||
const height = f.height ?? parseQualityLabel(f.qualityLabel);
|
const height = f.height ?? parseQualityLabel(f.qualityLabel);
|
||||||
const fps = f.fps ?? 0;
|
const fps = f.fps ?? 0;
|
||||||
const bitrate = f.bitrate ?? f.averageBitrate ?? 0;
|
const bitrate = f.bitrate ?? f.averageBitrate ?? 0;
|
||||||
|
|
@ -456,13 +450,10 @@ async function collectAllFormats(
|
||||||
height,
|
height,
|
||||||
fps,
|
fps,
|
||||||
ext: getExt(f.mimeType),
|
ext: getExt(f.mimeType),
|
||||||
initRange: f.initRange,
|
|
||||||
indexRange: f.indexRange,
|
|
||||||
bitrate,
|
bitrate,
|
||||||
mimeType: f.mimeType,
|
mimeType: f.mimeType ?? '',
|
||||||
itag: f.itag,
|
|
||||||
});
|
});
|
||||||
clientProgressive++;
|
nProg++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adaptive formats
|
// Adaptive formats
|
||||||
|
|
@ -482,13 +473,10 @@ async function collectAllFormats(
|
||||||
height,
|
height,
|
||||||
fps,
|
fps,
|
||||||
ext: getExt(f.mimeType),
|
ext: getExt(f.mimeType),
|
||||||
initRange: f.initRange,
|
|
||||||
indexRange: f.indexRange,
|
|
||||||
bitrate,
|
bitrate,
|
||||||
mimeType: f.mimeType,
|
mimeType: f.mimeType ?? '',
|
||||||
itag: f.itag,
|
|
||||||
});
|
});
|
||||||
clientVideo++;
|
nVid++;
|
||||||
} else if (mimeBase.startsWith('audio/')) {
|
} else if (mimeBase.startsWith('audio/')) {
|
||||||
const bitrate = f.bitrate ?? f.averageBitrate ?? 0;
|
const bitrate = f.bitrate ?? f.averageBitrate ?? 0;
|
||||||
const sampleRate = parseFloat(f.audioSampleRate ?? '0') || 0;
|
const sampleRate = parseFloat(f.audioSampleRate ?? '0') || 0;
|
||||||
|
|
@ -500,18 +488,15 @@ async function collectAllFormats(
|
||||||
height: 0,
|
height: 0,
|
||||||
fps: 0,
|
fps: 0,
|
||||||
ext: getExt(f.mimeType),
|
ext: getExt(f.mimeType),
|
||||||
initRange: f.initRange,
|
|
||||||
indexRange: f.indexRange,
|
|
||||||
bitrate,
|
bitrate,
|
||||||
audioSampleRate: f.audioSampleRate,
|
audioSampleRate: f.audioSampleRate,
|
||||||
mimeType: f.mimeType,
|
mimeType: f.mimeType ?? '',
|
||||||
itag: f.itag,
|
|
||||||
});
|
});
|
||||||
clientAudio++;
|
nAud++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('YouTubeExtractor', `[${client.key}] progressive=${clientProgressive} video=${clientVideo} audio=${clientAudio} hls=${sd.hlsManifestUrl ? 1 : 0}`);
|
logger.info('YouTubeExtractor', `[${client.key}] progressive=${nProg} video=${nVid} audio=${nAud} hls=${sd.hlsManifestUrl ? 1 : 0}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('YouTubeExtractor', `[${client.key}] Failed:`, err);
|
logger.warn('YouTubeExtractor', `[${client.key}] Failed:`, err);
|
||||||
}
|
}
|
||||||
|
|
@ -520,80 +505,6 @@ async function collectAllFormats(
|
||||||
return { progressive, adaptiveVideo, adaptiveAudio, hlsManifests };
|
return { progressive, adaptiveVideo, adaptiveAudio, hlsManifests };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Sorting and selection
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function sortCandidates(items: StreamCandidate[]): StreamCandidate[] {
|
|
||||||
return [...items].sort((a, b) => {
|
|
||||||
if (b.score !== a.score) return b.score - a.score;
|
|
||||||
const ca = containerScore(a.ext), cb = containerScore(b.ext);
|
|
||||||
if (ca !== cb) return ca - cb;
|
|
||||||
return a.priority - b.priority;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickBestForClient(items: StreamCandidate[], preferredClient: string): StreamCandidate | null {
|
|
||||||
const fromPreferred = items.filter(c => c.client === preferredClient);
|
|
||||||
const pool = fromPreferred.length > 0 ? fromPreferred : items;
|
|
||||||
return sortCandidates(pool)[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DASH manifest builder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async function buildDashManifest(
|
|
||||||
video: StreamCandidate,
|
|
||||||
audio: StreamCandidate,
|
|
||||||
videoId: string,
|
|
||||||
durationSeconds: number,
|
|
||||||
): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const FileSystem = await import('expo-file-system/legacy');
|
|
||||||
if (!FileSystem.cacheDirectory) return null;
|
|
||||||
|
|
||||||
const esc = (s: string) =>
|
|
||||||
s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
|
|
||||||
const dur = `PT${durationSeconds}S`;
|
|
||||||
const vMime = getMimeBase(video.mimeType);
|
|
||||||
const aMime = getMimeBase(audio.mimeType);
|
|
||||||
const vCodec = getCodecs(video.mimeType);
|
|
||||||
const aCodec = getCodecs(audio.mimeType);
|
|
||||||
const vInit = video.initRange ? `${video.initRange.start}-${video.initRange.end}` : '0-0';
|
|
||||||
const vIdx = video.indexRange ? `${video.indexRange.start}-${video.indexRange.end}` : '0-0';
|
|
||||||
const aInit = audio.initRange ? `${audio.initRange.start}-${audio.initRange.end}` : '0-0';
|
|
||||||
const aIdx = audio.indexRange ? `${audio.indexRange.start}-${audio.indexRange.end}` : '0-0';
|
|
||||||
|
|
||||||
const mpd = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="${dur}" minBufferTime="PT2S" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">
|
|
||||||
<Period duration="${dur}">
|
|
||||||
<AdaptationSet id="1" mimeType="${vMime}" codecs="${vCodec}" subsegmentAlignment="true" subsegmentStartsWithSAP="1">
|
|
||||||
<Representation id="v1" bandwidth="${video.bitrate}" height="${video.height}">
|
|
||||||
<BaseURL>${esc(video.url)}</BaseURL>
|
|
||||||
<SegmentBase indexRange="${vIdx}"><Initialization range="${vInit}"/></SegmentBase>
|
|
||||||
</Representation>
|
|
||||||
</AdaptationSet>
|
|
||||||
<AdaptationSet id="2" mimeType="${aMime}" codecs="${aCodec}" lang="en" subsegmentAlignment="true" subsegmentStartsWithSAP="1">
|
|
||||||
<Representation id="a1" bandwidth="${audio.bitrate}" audioSamplingRate="${audio.audioSampleRate ?? '44100'}">
|
|
||||||
<BaseURL>${esc(audio.url)}</BaseURL>
|
|
||||||
<SegmentBase indexRange="${aIdx}"><Initialization range="${aInit}"/></SegmentBase>
|
|
||||||
</Representation>
|
|
||||||
</AdaptationSet>
|
|
||||||
</Period>
|
|
||||||
</MPD>`;
|
|
||||||
|
|
||||||
const path = `${FileSystem.cacheDirectory}trailer_${videoId}.mpd`;
|
|
||||||
await FileSystem.writeAsStringAsync(path, mpd, { encoding: FileSystem.EncodingType.UTF8 });
|
|
||||||
logger.info('YouTubeExtractor', `DASH manifest: ${path} (video=${video.itag} ${video.height}p, audio=${audio.itag})`);
|
|
||||||
return path;
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('YouTubeExtractor', 'DASH manifest write failed:', err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public API
|
// Public API
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -602,12 +513,15 @@ export class YouTubeExtractor {
|
||||||
/**
|
/**
|
||||||
* Extract a playable source from a YouTube video ID or URL.
|
* Extract a playable source from a YouTube video ID or URL.
|
||||||
*
|
*
|
||||||
* Strategy:
|
* Matches the Kotlin InAppYouTubeExtractor approach:
|
||||||
* 1. Fetch watch page to get dynamic API key + visitor data
|
* 1. Fetch watch page for dynamic API key + visitor data
|
||||||
* 2. Try ALL clients in parallel — collect formats from all that succeed
|
* 2. Try ALL clients, collect formats from all that succeed
|
||||||
* 3. On Android: prefer DASH (adaptive video from android_vr + best audio)
|
* 3. Pick best HLS variant (by resolution/bandwidth) as primary
|
||||||
* falling back to best HLS manifest, then best progressive
|
* 4. Fall back to best progressive (muxed) if no HLS
|
||||||
* 4. On iOS: prefer HLS manifest (AVPlayer native), then progressive (muxed)
|
*
|
||||||
|
* Note: Unlike the Kotlin version, we do not return separate videoUrl/audioUrl
|
||||||
|
* for adaptive streams — react-native-video cannot merge two sources. HLS
|
||||||
|
* provides the best quality without needing a separate audio track.
|
||||||
*/
|
*/
|
||||||
static async extract(
|
static async extract(
|
||||||
videoIdOrUrl: string,
|
videoIdOrUrl: string,
|
||||||
|
|
@ -622,30 +536,33 @@ export class YouTubeExtractor {
|
||||||
const effectivePlatform = platform ?? (Platform.OS === 'android' ? 'android' : 'ios');
|
const effectivePlatform = platform ?? (Platform.OS === 'android' ? 'android' : 'ios');
|
||||||
logger.info('YouTubeExtractor', `Extracting videoId=${videoId} platform=${effectivePlatform}`);
|
logger.info('YouTubeExtractor', `Extracting videoId=${videoId} platform=${effectivePlatform}`);
|
||||||
|
|
||||||
// Step 1: fetch watch page for dynamic API key + visitor data
|
// Step 1: watch page for dynamic API key + visitor data
|
||||||
const watchConfig = await fetchWatchConfig(videoId);
|
const { apiKey, visitorData } = await fetchWatchConfig(videoId);
|
||||||
if (watchConfig.apiKey) {
|
|
||||||
logger.info('YouTubeExtractor', `Got apiKey and visitorData from watch page`);
|
|
||||||
} else {
|
|
||||||
logger.warn('YouTubeExtractor', `Could not get apiKey from watch page — proceeding without`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: collect formats from all clients
|
// Step 2: collect formats from all clients
|
||||||
const { progressive, adaptiveVideo, adaptiveAudio, hlsManifests } =
|
const { progressive, adaptiveVideo, adaptiveAudio, hlsManifests } =
|
||||||
await collectAllFormats(videoId, watchConfig.apiKey, watchConfig.visitorData);
|
await collectAllFormats(videoId, apiKey, visitorData);
|
||||||
|
|
||||||
logger.info('YouTubeExtractor', `Totals: progressive=${progressive.length} adaptiveVideo=${adaptiveVideo.length} adaptiveAudio=${adaptiveAudio.length} hls=${hlsManifests.length}`);
|
logger.info('YouTubeExtractor',
|
||||||
|
`Totals: progressive=${progressive.length} adaptiveVideo=${adaptiveVideo.length} ` +
|
||||||
|
`adaptiveAudio=${adaptiveAudio.length} hls=${hlsManifests.length}`
|
||||||
|
);
|
||||||
|
|
||||||
if (progressive.length === 0 && adaptiveVideo.length === 0 && hlsManifests.length === 0) {
|
if (progressive.length === 0 && adaptiveVideo.length === 0 && hlsManifests.length === 0) {
|
||||||
logger.warn('YouTubeExtractor', `No usable formats for videoId=${videoId}`);
|
logger.warn('YouTubeExtractor', `No usable formats for videoId=${videoId}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: pick best HLS variant (by resolution/bandwidth)
|
// Step 3: pick best HLS variant across all manifests
|
||||||
let bestHls: (HlsVariant & { manifestUrl: string }) | null = null;
|
let bestHls: (HlsVariant & { manifestUrl: string }) | null = null;
|
||||||
for (const { url, priority } of hlsManifests.sort((a, b) => a.priority - b.priority)) {
|
for (const { url } of hlsManifests.sort((a, b) => a.priority - b.priority)) {
|
||||||
const variant = await parseBestHlsVariant(url);
|
const variant = await parseBestHlsVariant(url);
|
||||||
if (variant && (!bestHls || variant.height > bestHls.height || (variant.height === bestHls.height && variant.bandwidth > bestHls.bandwidth))) {
|
if (
|
||||||
|
variant &&
|
||||||
|
(!bestHls ||
|
||||||
|
variant.height > bestHls.height ||
|
||||||
|
(variant.height === bestHls.height && variant.bandwidth > bestHls.bandwidth))
|
||||||
|
) {
|
||||||
bestHls = { ...variant, manifestUrl: url };
|
bestHls = { ...variant, manifestUrl: url };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -655,95 +572,54 @@ export class YouTubeExtractor {
|
||||||
const bestAdaptiveAudio = pickBestForClient(adaptiveAudio, PREFERRED_ADAPTIVE_CLIENT);
|
const bestAdaptiveAudio = pickBestForClient(adaptiveAudio, PREFERRED_ADAPTIVE_CLIENT);
|
||||||
|
|
||||||
if (bestHls) logger.info('YouTubeExtractor', `Best HLS: ${bestHls.height}p ${bestHls.bandwidth}bps`);
|
if (bestHls) logger.info('YouTubeExtractor', `Best HLS: ${bestHls.height}p ${bestHls.bandwidth}bps`);
|
||||||
if (bestProgressive) logger.info('YouTubeExtractor', `Best progressive: ${bestProgressive.height}p score=${bestProgressive.score}`);
|
if (bestProgressive) logger.info('YouTubeExtractor', `Best progressive: ${bestProgressive.height}p`);
|
||||||
if (bestAdaptiveVideo) logger.info('YouTubeExtractor', `Best adaptive video: ${bestAdaptiveVideo.height}p client=${bestAdaptiveVideo.client}`);
|
if (bestAdaptiveVideo) logger.info('YouTubeExtractor', `Best adaptive video: ${bestAdaptiveVideo.height}p client=${bestAdaptiveVideo.client}`);
|
||||||
if (bestAdaptiveAudio) logger.info('YouTubeExtractor', `Best adaptive audio: bitrate=${bestAdaptiveAudio.bitrate} client=${bestAdaptiveAudio.client}`);
|
if (bestAdaptiveAudio) logger.info('YouTubeExtractor', `Best adaptive audio: ${bestAdaptiveAudio.bitrate}bps client=${bestAdaptiveAudio.client}`);
|
||||||
|
|
||||||
// Placeholder duration (refined below)
|
// Step 4: select final source
|
||||||
const durationSeconds = 300;
|
// Priority: HLS > progressive muxed
|
||||||
|
// (No DASH/MPD — react-native-video cannot merge separate video+audio streams)
|
||||||
let source: TrailerPlaybackSource | null = null;
|
if (bestHls) {
|
||||||
|
logger.info('YouTubeExtractor', `Using HLS: ${summarizeUrl(bestHls.manifestUrl)}`);
|
||||||
if (effectivePlatform === 'android') {
|
return {
|
||||||
// Android priority: DASH adaptive > HLS > progressive
|
videoUrl: bestHls.manifestUrl,
|
||||||
if (bestAdaptiveVideo && bestAdaptiveAudio) {
|
audioUrl: null,
|
||||||
const mpdPath = await buildDashManifest(bestAdaptiveVideo, bestAdaptiveAudio, videoId, durationSeconds);
|
quality: `${bestHls.height}p`,
|
||||||
if (mpdPath) {
|
videoId,
|
||||||
source = {
|
};
|
||||||
videoUrl: mpdPath,
|
|
||||||
audioUrl: null,
|
|
||||||
quality: `${bestAdaptiveVideo.height}p`,
|
|
||||||
isMuxed: true, // MPD contains both tracks
|
|
||||||
isDash: true,
|
|
||||||
durationSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!source && bestHls) {
|
|
||||||
source = {
|
|
||||||
videoUrl: bestHls.manifestUrl,
|
|
||||||
audioUrl: null,
|
|
||||||
quality: `${bestHls.height}p`,
|
|
||||||
isMuxed: true,
|
|
||||||
isDash: false,
|
|
||||||
durationSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!source && bestProgressive) {
|
|
||||||
source = {
|
|
||||||
videoUrl: bestProgressive.url,
|
|
||||||
audioUrl: null,
|
|
||||||
quality: `${bestProgressive.height}p`,
|
|
||||||
isMuxed: true,
|
|
||||||
isDash: false,
|
|
||||||
durationSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// iOS priority: HLS (native AVPlayer support) > progressive
|
|
||||||
if (bestHls) {
|
|
||||||
source = {
|
|
||||||
videoUrl: bestHls.manifestUrl,
|
|
||||||
audioUrl: null,
|
|
||||||
quality: `${bestHls.height}p`,
|
|
||||||
isMuxed: true,
|
|
||||||
isDash: false,
|
|
||||||
durationSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!source && bestProgressive) {
|
|
||||||
source = {
|
|
||||||
videoUrl: bestProgressive.url,
|
|
||||||
audioUrl: null,
|
|
||||||
quality: `${bestProgressive.height}p`,
|
|
||||||
isMuxed: true,
|
|
||||||
isDash: false,
|
|
||||||
durationSeconds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!source) {
|
if (bestProgressive) {
|
||||||
logger.warn('YouTubeExtractor', `Could not build a playable source for videoId=${videoId}`);
|
logger.info('YouTubeExtractor', `Using progressive: ${summarizeUrl(bestProgressive.url)}`);
|
||||||
return null;
|
return {
|
||||||
|
videoUrl: bestProgressive.url,
|
||||||
|
audioUrl: null,
|
||||||
|
quality: `${bestProgressive.height}p`,
|
||||||
|
videoId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('YouTubeExtractor', `Final source: ${summarizeUrl(source.videoUrl)} quality=${source.quality} dash=${source.isDash}`);
|
// Last resort: video-only adaptive (no audio, but beats nothing)
|
||||||
|
if (bestAdaptiveVideo) {
|
||||||
|
logger.warn('YouTubeExtractor', `Using video-only adaptive (no audio): ${bestAdaptiveVideo.height}p`);
|
||||||
|
return {
|
||||||
|
videoUrl: bestAdaptiveVideo.url,
|
||||||
|
audioUrl: null,
|
||||||
|
quality: `${bestAdaptiveVideo.height}p`,
|
||||||
|
videoId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
logger.warn('YouTubeExtractor', `No playable source for videoId=${videoId}`);
|
||||||
source,
|
return null;
|
||||||
videoId,
|
|
||||||
title: undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convenience: returns just the best playable URL or null. */
|
|
||||||
static async getBestStreamUrl(
|
static async getBestStreamUrl(
|
||||||
videoIdOrUrl: string,
|
videoIdOrUrl: string,
|
||||||
platform?: 'android' | 'ios',
|
platform?: 'android' | 'ios',
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const result = await this.extract(videoIdOrUrl, platform);
|
const result = await this.extract(videoIdOrUrl, platform);
|
||||||
return result?.source.videoUrl ?? null;
|
return result?.videoUrl ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static parseVideoId(input: string): string | null {
|
static parseVideoId(input: string): string | null {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue