diff --git a/src/services/youtubeExtractor.ts b/src/services/youtubeExtractor.ts
index c340bc0fb..928cc8ec1 100644
--- a/src/services/youtubeExtractor.ts
+++ b/src/services/youtubeExtractor.ts
@@ -107,15 +107,39 @@ const TVHTML5_EMBEDDED_CONTEXT = {
},
};
-// Preferred itags: muxed (video+audio) formats, best quality first
-// These are single-file MP4s ExoPlayer can play directly
+// ---------------------------------------------------------------------------
+// Itag reference tables
+// ---------------------------------------------------------------------------
+
+// Muxed (video+audio in one file) — these are the ONLY formats iOS AVPlayer
+// can play without a DASH bridge. Max quality is 720p (itag 22), often absent.
const PREFERRED_MUXED_ITAGS = [
22, // 720p MP4 (video+audio)
18, // 360p MP4 (video+audio)
59, // 480p MP4 (video+audio) — rare
78, // 480p MP4 (video+audio) — rare
- 135, // 480p video-only (fallback)
- 134, // 360p video-only (fallback)
+];
+
+// Adaptive video-only itags in descending quality order.
+// ExoPlayer on Android can combine these with an audio stream via DASH.
+const ADAPTIVE_VIDEO_ITAGS_RANKED = [
+ 137, // 1080p MP4 video-only
+ 248, // 1080p WebM video-only
+ 136, // 720p MP4 video-only
+ 247, // 720p WebM video-only
+ 135, // 480p MP4 video-only
+ 244, // 480p WebM video-only
+ 134, // 360p MP4 video-only
+ 243, // 360p WebM video-only
+];
+
+// Adaptive audio-only itags in descending quality order.
+const ADAPTIVE_AUDIO_ITAGS_RANKED = [
+ 141, // 256kbps AAC
+ 140, // 128kbps AAC ← most common
+ 251, // 160kbps Opus
+ 250, // 70kbps Opus
+ 249, // 50kbps Opus
];
const REQUEST_TIMEOUT_MS = 12000;
@@ -182,19 +206,103 @@ function formatQualityLabel(format: InnertubeFormat): string {
}
function scoreFormat(format: InnertubeFormat): number {
- // Prioritise:
- // 1. Preferred itags (pre-muxed MP4 with audio)
- // 2. Height (higher = better, but cap at 720 for stability)
- // 3. Bitrate
const preferredIndex = PREFERRED_MUXED_ITAGS.indexOf(format.itag);
const itagBonus = preferredIndex !== -1 ? (PREFERRED_MUXED_ITAGS.length - preferredIndex) * 10000 : 0;
const height = format.height ?? 0;
- // Don't prefer > 720p because those are usually adaptive-only
const heightScore = Math.min(height, 720) * 10;
const bitrateScore = Math.min(format.bitrate ?? 0, 3_000_000) / 1000;
return itagBonus + heightScore + bitrateScore;
}
+// ---------------------------------------------------------------------------
+// Adaptive stream selection helpers
+// ---------------------------------------------------------------------------
+
+/** Pick the best video-only adaptive format available (MP4 preferred). */
+function pickBestAdaptiveVideo(adaptiveFormats: InnertubeFormat[]): InnertubeFormat | null {
+ const videoOnly = adaptiveFormats.filter(
+ (f) => f.url && f.qualityLabel && !f.audioQuality && f.mimeType.startsWith('video/')
+ );
+ if (videoOnly.length === 0) return null;
+
+ for (const itag of ADAPTIVE_VIDEO_ITAGS_RANKED) {
+ const match = videoOnly.find((f) => f.itag === itag);
+ if (match) return match;
+ }
+ return videoOnly.sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0] ?? null;
+}
+
+/** Pick the best audio-only adaptive format available (AAC preferred). */
+function pickBestAdaptiveAudio(adaptiveFormats: InnertubeFormat[]): InnertubeFormat | null {
+ const audioOnly = adaptiveFormats.filter(
+ (f) => f.url && f.audioQuality && !f.qualityLabel && f.mimeType.startsWith('audio/')
+ );
+ if (audioOnly.length === 0) return null;
+
+ for (const itag of ADAPTIVE_AUDIO_ITAGS_RANKED) {
+ const match = audioOnly.find((f) => f.itag === itag);
+ if (match) return match;
+ }
+ return audioOnly.sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0] ?? null;
+}
+
+/**
+ * Build an in-memory DASH MPD XML that references separate video + audio streams.
+ * ExoPlayer (Android) can parse a data:application/dash+xml;base64,... URI directly.
+ * iOS AVPlayer does NOT support DASH — this path is Android-only.
+ */
+function buildDashManifest(
+ videoFormat: InnertubeFormat,
+ audioFormat: InnertubeFormat,
+ durationSeconds?: number
+): string | null {
+ try {
+ const duration = durationSeconds ?? 300;
+ const mediaDurationISO = `PT${duration}S`;
+
+ const videoCodec = parseMimeType(videoFormat.mimeType).codecs.replace(/"/g, '').trim();
+ const audioCodec = parseMimeType(audioFormat.mimeType).codecs.replace(/"/g, '').trim();
+ const videoMime = videoFormat.mimeType.split(';')[0].trim();
+ const audioMime = audioFormat.mimeType.split(';')[0].trim();
+
+ const width = videoFormat.width ?? 1920;
+ const height = videoFormat.height ?? 1080;
+ const videoBandwidth = videoFormat.bitrate ?? 2_000_000;
+ const audioBandwidth = audioFormat.bitrate ?? 128_000;
+ const audioSampleRate = audioFormat.audioSampleRate ?? '44100';
+
+ const escapeXml = (s: string) =>
+ s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>');
+
+ const videoUrl = escapeXml(videoFormat.url!);
+ const audioUrl = escapeXml(audioFormat.url!);
+
+ const mpd = `
+
+
+
+
+ ${videoUrl}
+
+
+
+
+
+ ${audioUrl}
+
+
+
+
+`;
+
+ const b64 = Buffer.from(mpd, 'utf8').toString('base64');
+ return `data:application/dash+xml;base64,${b64}`;
+ } catch (err) {
+ logger.warn('YouTubeExtractor', 'Failed to build DASH manifest:', err);
+ return null;
+ }
+}
+
// ---------------------------------------------------------------------------
// Core extractor
// ---------------------------------------------------------------------------
@@ -251,34 +359,40 @@ async function fetchPlayerResponse(
}
}
-function parseFormats(playerResponse: InnertubePlayerResponse): InnertubeFormat[] {
+/**
+ * Returns muxed formats (video+audio) from sd.formats, plus any muxed adaptive formats.
+ * Used as the iOS fallback and the basis for the muxed bestStream.
+ */
+function parseMuxedFormats(playerResponse: InnertubePlayerResponse): InnertubeFormat[] {
const sd = playerResponse.streamingData;
if (!sd) return [];
const formats: InnertubeFormat[] = [];
-
- // Include muxed formats (video+audio in one file)
for (const f of sd.formats ?? []) {
if (f.url) formats.push(f);
}
-
- // Also scan adaptiveFormats for any that happen to have a direct URL
- // and look muxed (edge case but occasionally seen)
+ // Edge case: some adaptive formats are actually muxed
for (const f of sd.adaptiveFormats ?? []) {
if (f.url && isMuxedFormat(f)) formats.push(f);
}
-
return formats;
}
-function pickBestStream(formats: InnertubeFormat[]): ExtractedStream | null {
+/**
+ * Returns all adaptive formats (video-only + audio-only) that have direct URLs.
+ * Used for DASH manifest building on Android.
+ */
+function parseAdaptiveFormats(playerResponse: InnertubePlayerResponse): InnertubeFormat[] {
+ const sd = playerResponse.streamingData;
+ if (!sd) return [];
+ return (sd.adaptiveFormats ?? []).filter((f) => !!f.url);
+}
+
+function pickBestMuxedStream(formats: InnertubeFormat[]): ExtractedStream | null {
if (formats.length === 0) return null;
- // Filter to MP4 only for maximum ExoPlayer compatibility
const mp4Formats = formats.filter(isVideoMp4);
const pool = mp4Formats.length > 0 ? mp4Formats : formats;
-
- // Sort by score descending
const sorted = [...pool].sort((a, b) => scoreFormat(b) - scoreFormat(a));
const best = sorted[0];
@@ -300,31 +414,33 @@ function pickBestStream(formats: InnertubeFormat[]): ExtractedStream | null {
export class YouTubeExtractor {
/**
* Extract a playable stream URL from a YouTube video ID or URL.
- * Tries Android client first (no cipher), then iOS, then TV embedded.
- * Returns null if all attempts fail.
+ *
+ * Strategy:
+ * - Android: Try to build a DASH manifest from the best adaptive video +
+ * audio streams (up to 1080p). Falls back to best muxed stream (≤720p).
+ * - iOS: Use best muxed stream only (AVPlayer has no DASH support).
+ *
+ * Tries Android Innertube client first, then iOS, then TV Embedded.
*/
- static async extract(videoIdOrUrl: string): Promise {
+ static async extract(videoIdOrUrl: string, platform?: 'android' | 'ios'): Promise {
const videoId = extractVideoId(videoIdOrUrl);
if (!videoId) {
logger.warn('YouTubeExtractor', `Could not parse video ID from: ${videoIdOrUrl}`);
return null;
}
- logger.info('YouTubeExtractor', `Extracting streams for videoId=${videoId}`);
+ logger.info('YouTubeExtractor', `Extracting streams for videoId=${videoId} platform=${platform ?? 'unknown'}`);
- // Try each client in order until we get usable formats
const clients: Array<{ context: object; userAgent: string; name: string }> = [
{
name: 'ANDROID',
context: ANDROID_CLIENT_CONTEXT,
- userAgent:
- 'com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip',
+ userAgent: 'com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip',
},
{
name: 'IOS',
context: IOS_CLIENT_CONTEXT,
- userAgent:
- 'com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iPhone OS 15_6 like Mac OS X)',
+ userAgent: 'com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iPhone OS 15_6 like Mac OS X)',
},
{
name: 'TVHTML5_EMBEDDED',
@@ -333,7 +449,8 @@ export class YouTubeExtractor {
},
];
- let bestFormats: InnertubeFormat[] = [];
+ let muxedFormats: InnertubeFormat[] = [];
+ let adaptiveFormats: InnertubeFormat[] = [];
let playerResponse: InnertubePlayerResponse | null = null;
for (const client of clients) {
@@ -344,33 +461,73 @@ export class YouTubeExtractor {
const status = resp.playabilityStatus?.status;
if (status === 'UNPLAYABLE' || status === 'LOGIN_REQUIRED') {
- logger.warn(
- 'YouTubeExtractor',
- `${client.name} got playabilityStatus=${status} (${resp.playabilityStatus?.reason ?? ''})`
- );
+ logger.warn('YouTubeExtractor', `${client.name}: playabilityStatus=${status}`);
continue;
}
- const formats = parseFormats(resp);
- if (formats.length > 0) {
- logger.info(
- 'YouTubeExtractor',
- `${client.name} returned ${formats.length} usable formats`
- );
- bestFormats = formats;
+ const muxed = parseMuxedFormats(resp);
+ const adaptive = parseAdaptiveFormats(resp);
+
+ if (muxed.length > 0 || adaptive.length > 0) {
+ logger.info('YouTubeExtractor', `${client.name}: ${muxed.length} muxed, ${adaptive.length} adaptive formats`);
+ muxedFormats = muxed;
+ adaptiveFormats = adaptive;
playerResponse = resp;
break;
}
- logger.warn('YouTubeExtractor', `${client.name} returned no direct-URL formats`);
+ logger.warn('YouTubeExtractor', `${client.name} returned no usable formats`);
}
- if (bestFormats.length === 0) {
+ if (muxedFormats.length === 0 && adaptiveFormats.length === 0) {
logger.warn('YouTubeExtractor', `All clients failed for videoId=${videoId}`);
return null;
}
- const streams: ExtractedStream[] = bestFormats.map((f) => ({
+ const details = playerResponse?.videoDetails;
+ const durationSeconds = details?.lengthSeconds ? parseInt(details.lengthSeconds, 10) : undefined;
+
+ // --- Android: attempt high-quality DASH manifest ---
+ let bestStream: ExtractedStream | null = null;
+
+ if (platform === 'android' && adaptiveFormats.length > 0) {
+ const bestVideo = pickBestAdaptiveVideo(adaptiveFormats);
+ const bestAudio = pickBestAdaptiveAudio(adaptiveFormats);
+
+ if (bestVideo && bestAudio) {
+ const dashUri = buildDashManifest(bestVideo, bestAudio, durationSeconds);
+ if (dashUri) {
+ logger.info(
+ 'YouTubeExtractor',
+ `DASH manifest built: video itag=${bestVideo.itag} (${formatQualityLabel(bestVideo)}), audio itag=${bestAudio.itag}`
+ );
+ bestStream = {
+ url: dashUri,
+ quality: formatQualityLabel(bestVideo),
+ mimeType: 'application/dash+xml',
+ itag: bestVideo.itag,
+ hasAudio: true,
+ hasVideo: true,
+ bitrate: (bestVideo.bitrate ?? 0) + (bestAudio.bitrate ?? 0),
+ };
+ } else {
+ logger.warn('YouTubeExtractor', 'DASH manifest build failed, falling back to muxed');
+ }
+ } else {
+ logger.info('YouTubeExtractor', `Adaptive: bestVideo=${bestVideo?.itag ?? 'none'}, bestAudio=${bestAudio?.itag ?? 'none'} — falling back to muxed`);
+ }
+ }
+
+ // --- iOS or DASH fallback: use best muxed stream ---
+ if (!bestStream) {
+ bestStream = pickBestMuxedStream(muxedFormats);
+ if (bestStream) {
+ logger.info('YouTubeExtractor', `Muxed fallback: itag=${bestStream.itag} quality=${bestStream.quality}`);
+ }
+ }
+
+ // Build the full streams list from muxed formats for the result object
+ const streams: ExtractedStream[] = muxedFormats.map((f) => ({
url: f.url!,
quality: formatQualityLabel(f),
mimeType: f.mimeType,
@@ -380,40 +537,26 @@ export class YouTubeExtractor {
bitrate: f.bitrate ?? 0,
}));
- const bestStream = pickBestStream(bestFormats);
-
- const details = playerResponse?.videoDetails;
- const result: YouTubeExtractionResult = {
+ return {
streams,
bestStream,
videoId,
title: details?.title,
- durationSeconds: details?.lengthSeconds
- ? parseInt(details.lengthSeconds, 10)
- : undefined,
+ durationSeconds,
};
-
- if (bestStream) {
- logger.info(
- 'YouTubeExtractor',
- `Best stream: itag=${bestStream.itag} quality=${bestStream.quality} mimeType=${bestStream.mimeType}`
- );
- }
-
- return result;
}
/**
* Convenience method — returns just the best playable URL or null.
+ * Pass platform so the extractor can choose DASH vs muxed appropriately.
*/
- static async getBestStreamUrl(videoIdOrUrl: string): Promise {
- const result = await this.extract(videoIdOrUrl);
+ static async getBestStreamUrl(videoIdOrUrl: string, platform?: 'android' | 'ios'): Promise {
+ const result = await this.extract(videoIdOrUrl, platform);
return result?.bestStream?.url ?? null;
}
/**
* Parse a video ID from any YouTube URL format or bare ID.
- * Exposed so callers can validate IDs before calling extract().
*/
static parseVideoId(input: string): string | null {
return extractVideoId(input);