From 9d5bbaf3b1ddfa828ec613b9094828a0f7bd1e3b Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 13:19:31 +0530 Subject: [PATCH 01/19] added local extractor --- src/services/youtubeExtractor.ts | 423 +++++++++++++++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 src/services/youtubeExtractor.ts diff --git a/src/services/youtubeExtractor.ts b/src/services/youtubeExtractor.ts new file mode 100644 index 00000000..c340bc0f --- /dev/null +++ b/src/services/youtubeExtractor.ts @@ -0,0 +1,423 @@ +import { logger } from '../utils/logger'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface InnertubeFormat { + itag: number; + url?: string; + signatureCipher?: string; + mimeType: string; + bitrate: number; + width?: number; + height?: number; + contentLength?: string; + quality: string; + qualityLabel?: string; + audioQuality?: string; + audioSampleRate?: string; + audioChannels?: number; + approxDurationMs?: string; + lastModified?: string; + projectionType?: string; +} + +interface InnertubeStreamingData { + formats: InnertubeFormat[]; + adaptiveFormats: InnertubeFormat[]; + expiresInSeconds?: string; +} + +interface InnertubePlayerResponse { + streamingData?: InnertubeStreamingData; + videoDetails?: { + videoId: string; + title: string; + lengthSeconds: string; + isLive?: boolean; + isLiveDvr?: boolean; + }; + playabilityStatus?: { + status: string; + reason?: string; + }; +} + +export interface ExtractedStream { + url: string; + quality: string; // e.g. "720p", "480p" + mimeType: string; // e.g. "video/mp4" + itag: number; + hasAudio: boolean; + hasVideo: boolean; + bitrate: number; +} + +export interface YouTubeExtractionResult { + streams: ExtractedStream[]; + bestStream: ExtractedStream | null; + videoId: string; + title?: string; + durationSeconds?: number; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +// Innertube client configs — we use Android (no cipher, direct URLs) +// and web as fallback (may need cipher decode) +const INNERTUBE_API_KEY = 'AIzaSyA8ggJvXiQHQFN-YMEoM30s0s3RlxEYJuA'; +const INNERTUBE_URL = 'https://www.youtube.com/youtubei/v1/player'; + +// Android client gives direct URLs without cipher obfuscation +const ANDROID_CLIENT_CONTEXT = { + client: { + clientName: 'ANDROID', + clientVersion: '19.09.37', + androidSdkVersion: 30, + userAgent: + 'com.google.android.youtube/19.09.37 (Linux; U; Android 11) gzip', + hl: 'en', + gl: 'US', + }, +}; + +// iOS client as secondary fallback +const IOS_CLIENT_CONTEXT = { + client: { + clientName: 'IOS', + clientVersion: '19.09.3', + deviceModel: 'iPhone14,3', + userAgent: + 'com.google.ios.youtube/19.09.3 (iPhone14,3; U; CPU iPhone OS 15_6 like Mac OS X)', + hl: 'en', + gl: 'US', + }, +}; + +// TV Embedded client — works for age-restricted / embed-allowed content +const TVHTML5_EMBEDDED_CONTEXT = { + client: { + clientName: 'TVHTML5_SIMPLY_EMBEDDED_PLAYER', + clientVersion: '2.0', + hl: 'en', + gl: 'US', + }, +}; + +// Preferred itags: muxed (video+audio) formats, best quality first +// These are single-file MP4s ExoPlayer can play directly +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) +]; + +const REQUEST_TIMEOUT_MS = 12000; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function extractVideoId(input: string): string | null { + if (!input) return null; + + // Already a bare video ID (11 chars, alphanumeric + _ -) + if (/^[A-Za-z0-9_-]{11}$/.test(input.trim())) { + return input.trim(); + } + + try { + const url = new URL(input); + + // youtu.be/VIDEO_ID + if (url.hostname === 'youtu.be') { + const id = url.pathname.slice(1).split('/')[0]; + if (id && /^[A-Za-z0-9_-]{11}$/.test(id)) return id; + } + + // youtube.com/watch?v=VIDEO_ID + const v = url.searchParams.get('v'); + if (v && /^[A-Za-z0-9_-]{11}$/.test(v)) return v; + + // youtube.com/embed/VIDEO_ID or /shorts/VIDEO_ID + const pathMatch = url.pathname.match(/\/(embed|shorts|v)\/([A-Za-z0-9_-]{11})/); + if (pathMatch) return pathMatch[2]; + } catch { + // Not a valid URL — try regex fallback + const match = input.match(/[?&]v=([A-Za-z0-9_-]{11})/); + if (match) return match[1]; + } + + return null; +} + +function parseMimeType(mimeType: string): { container: string; codecs: string } { + // e.g. 'video/mp4; codecs="avc1.64001F, mp4a.40.2"' + const [base, codecsPart] = mimeType.split(';'); + const container = base.trim(); + const codecs = codecsPart ? codecsPart.replace(/codecs=["']?/i, '').replace(/["']$/, '').trim() : ''; + return { container, codecs }; +} + +function isMuxedFormat(format: InnertubeFormat): boolean { + // A muxed format has both video and audio codecs in its mimeType + const { codecs } = parseMimeType(format.mimeType); + // MP4 muxed: "avc1.xxx, mp4a.xxx" + // WebM muxed: "vp8, vorbis" etc. + return codecs.includes(',') || (!!format.audioQuality && !!format.qualityLabel); +} + +function isVideoMp4(format: InnertubeFormat): boolean { + return format.mimeType.startsWith('video/mp4'); +} + +function formatQualityLabel(format: InnertubeFormat): string { + return format.qualityLabel || format.quality || 'unknown'; +} + +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; +} + +// --------------------------------------------------------------------------- +// Core extractor +// --------------------------------------------------------------------------- + +async function fetchPlayerResponse( + videoId: string, + context: object, + userAgent: string +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + try { + const body = { + videoId, + context, + contentCheckOk: true, + racyCheckOk: true, + }; + + const response = await fetch( + `${INNERTUBE_URL}?key=${INNERTUBE_API_KEY}&prettyPrint=false`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + 'X-YouTube-Client-Name': '3', + 'Origin': 'https://www.youtube.com', + 'Referer': `https://www.youtube.com/watch?v=${videoId}`, + }, + body: JSON.stringify(body), + signal: controller.signal, + } + ); + + clearTimeout(timer); + + if (!response.ok) { + logger.warn('YouTubeExtractor', `Innertube HTTP ${response.status} for videoId=${videoId}`); + return null; + } + + const data: InnertubePlayerResponse = await response.json(); + return data; + } catch (err) { + clearTimeout(timer); + if (err instanceof Error && err.name === 'AbortError') { + logger.warn('YouTubeExtractor', `Request timed out for videoId=${videoId}`); + } else { + logger.warn('YouTubeExtractor', `Fetch error for videoId=${videoId}:`, err); + } + return null; + } +} + +function parseFormats(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) + for (const f of sd.adaptiveFormats ?? []) { + if (f.url && isMuxedFormat(f)) formats.push(f); + } + + return formats; +} + +function pickBestStream(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]; + + return { + url: best.url!, + quality: formatQualityLabel(best), + mimeType: best.mimeType, + itag: best.itag, + hasAudio: !!best.audioQuality || isMuxedFormat(best), + hasVideo: !!best.qualityLabel || best.mimeType.startsWith('video/'), + bitrate: best.bitrate ?? 0, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +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. + */ + static async extract(videoIdOrUrl: string): 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}`); + + // 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', + }, + { + 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)', + }, + { + name: 'TVHTML5_EMBEDDED', + context: TVHTML5_EMBEDDED_CONTEXT, + userAgent: 'Mozilla/5.0 (SMART-TV; Linux; Tizen 6.0)', + }, + ]; + + let bestFormats: InnertubeFormat[] = []; + let playerResponse: InnertubePlayerResponse | null = null; + + for (const client of clients) { + logger.info('YouTubeExtractor', `Trying ${client.name} client...`); + const resp = await fetchPlayerResponse(videoId, client.context, client.userAgent); + + if (!resp) continue; + + const status = resp.playabilityStatus?.status; + if (status === 'UNPLAYABLE' || status === 'LOGIN_REQUIRED') { + logger.warn( + 'YouTubeExtractor', + `${client.name} got playabilityStatus=${status} (${resp.playabilityStatus?.reason ?? ''})` + ); + continue; + } + + const formats = parseFormats(resp); + if (formats.length > 0) { + logger.info( + 'YouTubeExtractor', + `${client.name} returned ${formats.length} usable formats` + ); + bestFormats = formats; + playerResponse = resp; + break; + } + + logger.warn('YouTubeExtractor', `${client.name} returned no direct-URL formats`); + } + + if (bestFormats.length === 0) { + logger.warn('YouTubeExtractor', `All clients failed for videoId=${videoId}`); + return null; + } + + const streams: ExtractedStream[] = bestFormats.map((f) => ({ + url: f.url!, + quality: formatQualityLabel(f), + mimeType: f.mimeType, + itag: f.itag, + hasAudio: !!f.audioQuality || isMuxedFormat(f), + hasVideo: !!f.qualityLabel || f.mimeType.startsWith('video/'), + bitrate: f.bitrate ?? 0, + })); + + const bestStream = pickBestStream(bestFormats); + + const details = playerResponse?.videoDetails; + const result: YouTubeExtractionResult = { + streams, + bestStream, + videoId, + title: details?.title, + durationSeconds: details?.lengthSeconds + ? parseInt(details.lengthSeconds, 10) + : undefined, + }; + + 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. + */ + static async getBestStreamUrl(videoIdOrUrl: string): Promise { + const result = await this.extract(videoIdOrUrl); + 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); + } +} + +export default YouTubeExtractor; From 9a4af754f04034a1a581d9b00523147bca215c87 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 13:21:02 +0530 Subject: [PATCH 02/19] local extractor --- src/services/trailerService.ts | 434 +++++++++------------------------ 1 file changed, 110 insertions(+), 324 deletions(-) diff --git a/src/services/trailerService.ts b/src/services/trailerService.ts index 1b7f4bbf..5044a93b 100644 --- a/src/services/trailerService.ts +++ b/src/services/trailerService.ts @@ -1,4 +1,5 @@ import { logger } from '../utils/logger'; +import { YouTubeExtractor } from './youtubeExtractor'; export interface TrailerData { url: string; @@ -6,373 +7,158 @@ export interface TrailerData { year: number; } -export class TrailerService { - // Environment-configurable values (Expo public env) - private static readonly ENV_LOCAL_BASE = process.env.EXPO_PUBLIC_TRAILER_LOCAL_BASE || 'http://46.62.173.157:3001'; - private static readonly ENV_LOCAL_TRAILER_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_TRAILER_PATH || '/trailer'; - private static readonly ENV_LOCAL_SEARCH_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_SEARCH_PATH || '/search-trailer'; +interface CacheEntry { + url: string; + expiresAt: number; +} - private static readonly LOCAL_SERVER_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_TRAILER_PATH}`; - private static readonly AUTO_SEARCH_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_SEARCH_PATH}`; - private static readonly TIMEOUT = 20000; // 20 seconds +export class TrailerService { + // YouTube CDN URLs expire ~6h; cache for 5h + private static readonly CACHE_TTL_MS = 5 * 60 * 60 * 1000; + private static urlCache = new Map(); + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- /** - * Fetches trailer URL for a given title and year - * @param title - The movie/series title - * @param year - The release year - * @param tmdbId - Optional TMDB ID for more accurate results - * @param type - Optional content type ('movie' or 'tv') - * @returns Promise - The trailer URL or null if not found + * Get a playable stream URL from a raw YouTube video ID (e.g. from TMDB). + * Pure on-device extraction via Innertube. No server involved. */ - static async getTrailerUrl(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise { - logger.info('TrailerService', `getTrailerUrl requested: title="${title}", year=${year}, tmdbId=${tmdbId || 'n/a'}, type=${type || 'n/a'}`); - return this.getTrailerFromLocalServer(title, year, tmdbId, type); + static async getTrailerFromVideoId( + youtubeVideoId: string, + title?: string, + year?: number + ): Promise { + if (!youtubeVideoId) return null; + + logger.info('TrailerService', `getTrailerFromVideoId: ${youtubeVideoId} (${title ?? '?'} ${year ?? ''})`); + + const cached = this.getCached(youtubeVideoId); + if (cached) { + logger.info('TrailerService', `Cache hit for videoId=${youtubeVideoId}`); + return cached; + } + + try { + const url = await YouTubeExtractor.getBestStreamUrl(youtubeVideoId); + if (url) { + logger.info('TrailerService', `On-device extraction succeeded for ${youtubeVideoId}`); + this.setCache(youtubeVideoId, url); + return url; + } + } catch (err) { + logger.warn('TrailerService', `On-device extraction threw for ${youtubeVideoId}:`, err); + } + + logger.warn('TrailerService', `Extraction failed for ${youtubeVideoId}`); + return null; } /** - * Fetches trailer from local server using TMDB API or auto-search - * @param title - The movie/series title - * @param year - The release year - * @param tmdbId - Optional TMDB ID for more accurate results - * @param type - Optional content type ('movie' or 'tv') - * @returns Promise - The trailer URL or null if not found + * Called by TrailerModal which has the full YouTube URL from TMDB. + * Parses the video ID then delegates to getTrailerFromVideoId. */ - private static async getTrailerFromLocalServer(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise { - const startTime = Date.now(); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT); + static async getTrailerFromYouTubeUrl( + youtubeUrl: string, + title?: string, + year?: string + ): Promise { + logger.info('TrailerService', `getTrailerFromYouTubeUrl: ${youtubeUrl}`); - // Build URL with parameters - const params = new URLSearchParams(); - - // Always send title and year for logging and fallback - params.append('title', title); - params.append('year', year.toString()); - - if (tmdbId) { - params.append('tmdbId', tmdbId); - params.append('type', type || 'movie'); - logger.info('TrailerService', `Using TMDB API for: ${title} (TMDB ID: ${tmdbId})`); - } else { - logger.info('TrailerService', `Auto-searching trailer for: ${title} (${year})`); - } - - const url = `${this.AUTO_SEARCH_URL}?${params.toString()}`; - logger.info('TrailerService', `Local server request URL: ${url}`); - logger.info('TrailerService', `Local server timeout set to ${this.TIMEOUT}ms`); - logger.info('TrailerService', `Making fetch request to: ${url}`); - - try { - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'User-Agent': 'Nuvio/1.0', - }, - signal: controller.signal, - }); - - // logger.info('TrailerService', `Fetch request completed. Response status: ${response.status}`); - - clearTimeout(timeoutId); - - const elapsed = Date.now() - startTime; - const contentType = response.headers.get('content-type') || 'unknown'; - // logger.info('TrailerService', `Local server response: status=${response.status} ok=${response.ok} content-type=${contentType} elapsedMs=${elapsed}`); - - // Read body as text first so we can log it even on non-200s - let rawText = ''; - try { - rawText = await response.text(); - if (rawText) { - /* - const preview = rawText.length > 200 ? `${rawText.slice(0, 200)}...` : rawText; - logger.info('TrailerService', `Local server body preview: ${preview}`); - */ - } else { - // logger.info('TrailerService', 'Local server body is empty'); - } - } catch (e) { - const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e); - logger.warn('TrailerService', `Failed reading local server body text: ${msg}`); - } - - if (!response.ok) { - logger.warn('TrailerService', `Auto-search failed: ${response.status} ${response.statusText}`); - return null; - } - - // Attempt to parse JSON from the raw text - let data: any = null; - try { - data = rawText ? JSON.parse(rawText) : null; - // const keys = typeof data === 'object' && data !== null ? Object.keys(data).join(',') : typeof data; - // logger.info('TrailerService', `Local server JSON parsed. Keys/Type: ${keys}`); - } catch (e) { - const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e); - logger.warn('TrailerService', `Failed to parse local server JSON: ${msg}`); - return null; - } - - if (!data.url || !this.isValidTrailerUrl(data.url)) { - logger.warn('TrailerService', `Invalid trailer URL from auto-search: ${data.url}`); - return null; - } - - // logger.info('TrailerService', `Successfully found trailer: ${String(data.url).substring(0, 80)}...`); - return data.url; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - logger.warn('TrailerService', `Auto-search request timed out after ${this.TIMEOUT}ms`); - } else { - const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error); - logger.error('TrailerService', `Error in auto-search: ${msg}`); - logger.error('TrailerService', `Error details:`, { - name: (error as any)?.name, - message: (error as any)?.message, - stack: (error as any)?.stack, - url: url - }); - } + const videoId = YouTubeExtractor.parseVideoId(youtubeUrl); + if (!videoId) { + logger.warn('TrailerService', `Could not parse video ID from: ${youtubeUrl}`); return null; } + + return this.getTrailerFromVideoId( + videoId, + title, + year ? parseInt(year, 10) : undefined + ); } /** - * Validates if the provided string is a valid trailer URL - * @param url - The URL to validate - * @returns boolean - True if valid, false otherwise + * Called by AppleTVHero and HeroSection which only have title/year/tmdbId. + * These callers need to be updated to pass the YouTube video ID from TMDB + * instead and call getTrailerFromVideoId directly. Until then this returns null. */ - private static isValidTrailerUrl(url: string): boolean { - try { - const urlObj = new URL(url); - - // Check if it's a valid HTTP/HTTPS URL - if (!['http:', 'https:'].includes(urlObj.protocol)) { - return false; - } - - // Check for common video streaming domains/patterns - const validDomains = [ - 'theplatform.com', - 'youtube.com', - 'youtu.be', - 'vimeo.com', - 'dailymotion.com', - 'twitch.tv', - 'amazonaws.com', - 'cloudfront.net', - 'googlevideo.com', // Google's CDN for YouTube videos - 'sn-aigl6nzr.googlevideo.com', // Specific Google CDN servers - 'sn-aigl6nze.googlevideo.com', - 'sn-aigl6nsk.googlevideo.com', - 'sn-aigl6ns6.googlevideo.com' - ]; - - const hostname = urlObj.hostname.toLowerCase(); - const isValidDomain = validDomains.some(domain => - hostname.includes(domain) || hostname.endsWith(domain) - ); - - // Special check for Google Video CDN (YouTube direct streaming URLs) - const isGoogleVideoCDN = hostname.includes('googlevideo.com') || - hostname.includes('sn-') && hostname.includes('.googlevideo.com'); - - // Check for video file extensions or streaming formats - const hasVideoFormat = /\.(mp4|m3u8|mpd|webm|mov|avi|mkv)$/i.test(urlObj.pathname) || - url.includes('formats=') || - url.includes('manifest') || - url.includes('playlist'); - - return isValidDomain || hasVideoFormat || isGoogleVideoCDN; - } catch { - return false; - } + static async getTrailerUrl( + title: string, + year: number, + _tmdbId?: string, + _type?: 'movie' | 'tv' + ): Promise { + logger.warn( + 'TrailerService', + `getTrailerUrl called for "${title}" but no YouTube video ID was provided. ` + + `Update caller to fetch the YouTube video ID from TMDB and call getTrailerFromVideoId instead.` + ); + return null; } - /** - * Extracts the best video format URL from a multi-format URL - * @param url - The trailer URL that may contain multiple formats - * @returns string - The best format URL for mobile playback - */ + // --------------------------------------------------------------------------- + // Unchanged public helpers (API compatibility) + // --------------------------------------------------------------------------- + + /** Legacy format URL helper kept for API compatibility. */ static getBestFormatUrl(url: string): string { - // If the URL contains format parameters, try to get the best one for mobile if (url.includes('formats=')) { - // Prefer M3U (HLS) for better mobile compatibility if (url.includes('M3U')) { - // Try to get M3U without encryption first, then with encryption - const baseUrl = url.split('?')[0]; - const best = `${baseUrl}?formats=M3U+none,M3U+appleHlsEncryption`; - logger.info('TrailerService', `Optimized format URL from M3U: ${best.substring(0, 80)}...`); - return best; + return `${url.split('?')[0]}?formats=M3U+none,M3U+appleHlsEncryption`; } - // Fallback to MP4 if available if (url.includes('MPEG4')) { - const baseUrl = url.split('?')[0]; - const best = `${baseUrl}?formats=MPEG4`; - logger.info('TrailerService', `Optimized format URL from MPEG4: ${best.substring(0, 80)}...`); - return best; + return `${url.split('?')[0]}?formats=MPEG4`; } } - - // Return the original URL if no format optimization is needed - // logger.info('TrailerService', 'No format optimization applied'); return url; } - /** - * Checks if a trailer is available for the given title and year - * @param title - The movie/series title - * @param year - The release year - * @returns Promise - True if trailer is available - */ - static async isTrailerAvailable(title: string, year: number): Promise { - logger.info('TrailerService', `Checking trailer availability for: ${title} (${year})`); - const trailerUrl = await this.getTrailerUrl(title, year); - logger.info('TrailerService', `Trailer availability for ${title} (${year}): ${trailerUrl ? 'available' : 'not available'}`); - return trailerUrl !== null; + static async isTrailerAvailable(videoId: string): Promise { + return (await this.getTrailerFromVideoId(videoId)) !== null; } - /** - * Gets trailer data with additional metadata - * @param title - The movie/series title - * @param year - The release year - * @returns Promise - Trailer data or null if not found - */ static async getTrailerData(title: string, year: number): Promise { - logger.info('TrailerService', `getTrailerData for: ${title} (${year})`); - const url = await this.getTrailerUrl(title, year); - - if (!url) { - logger.info('TrailerService', 'No trailer URL found for getTrailerData'); - return null; - } - - return { - url: this.getBestFormatUrl(url), - title, - year - }; + logger.warn('TrailerService', `getTrailerData: no video ID available for "${title}"`); + return null; } - /** - * Fetches trailer directly from a known YouTube URL - * @param youtubeUrl - The YouTube URL to process - * @param title - Optional title for logging/caching - * @param year - Optional year for logging/caching - * @returns Promise - The direct streaming URL or null if failed - */ - static async getTrailerFromYouTubeUrl(youtubeUrl: string, title?: string, year?: string): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT); - - const params = new URLSearchParams(); - params.append('youtube_url', youtubeUrl); - if (title) params.append('title', title); - if (year) params.append('year', year.toString()); - - const url = `${this.ENV_LOCAL_BASE}${this.ENV_LOCAL_TRAILER_PATH}?${params.toString()}`; - logger.info('TrailerService', `Fetching trailer directly from YouTube URL: ${youtubeUrl}`); - logger.info('TrailerService', `Direct trailer request URL: ${url}`); - - const response = await fetch(url, { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'User-Agent': 'Nuvio/1.0', - }, - signal: controller.signal, - }); - - clearTimeout(timeoutId); - - logger.info('TrailerService', `Direct trailer response: status=${response.status} ok=${response.ok}`); - - if (!response.ok) { - logger.warn('TrailerService', `Direct trailer failed: ${response.status} ${response.statusText}`); - return null; - } - - const data = await response.json(); - - if (!data.url || !this.isValidTrailerUrl(data.url)) { - logger.warn('TrailerService', `Invalid trailer URL from direct fetch: ${data.url}`); - return null; - } - - logger.info('TrailerService', `Successfully got direct trailer: ${String(data.url).substring(0, 80)}...`); - return data.url; - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - logger.warn('TrailerService', `Direct trailer request timed out after ${this.TIMEOUT}ms`); - } else { - const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error); - logger.error('TrailerService', `Error in direct trailer fetch: ${msg}`); - } - return null; - } + static setUseLocalServer(_useLocal: boolean): void { + logger.info('TrailerService', 'setUseLocalServer: no server used, on-device only'); } - /** - * Switch between local server (deprecated - always uses local server now) - * @param useLocal - true for local server (always true now) - */ - static setUseLocalServer(useLocal: boolean): void { - if (!useLocal) { - logger.warn('TrailerService', 'XPrime API is no longer supported. Always using local server.'); - } - logger.info('TrailerService', 'Using local server'); - } - - /** - * Get current server status - * @returns object with server information - */ static getServerStatus(): { usingLocal: boolean; localUrl: string } { - return { - usingLocal: true, - localUrl: this.LOCAL_SERVER_URL, - }; + return { usingLocal: false, localUrl: '' }; } - /** - * Test local server and return its status - * @returns Promise with server status information - */ static async testServers(): Promise<{ localServer: { status: 'online' | 'offline'; responseTime?: number }; }> { - logger.info('TrailerService', 'Testing local server'); - const results: { - localServer: { status: 'online' | 'offline'; responseTime?: number }; - } = { - localServer: { status: 'offline' } - }; + return { localServer: { status: 'offline' } }; + } - // Test local server - try { - const startTime = Date.now(); - const response = await fetch(`${this.AUTO_SEARCH_URL}?title=test&year=2023`, { - method: 'GET', - signal: AbortSignal.timeout(5000) // 5 second timeout - }); - if (response.ok || response.status === 404) { // 404 is ok, means server is running - results.localServer = { - status: 'online', - responseTime: Date.now() - startTime - }; - logger.info('TrailerService', `Local server online. Response time: ${results.localServer.responseTime}ms`); - } - } catch (error) { - const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error); - logger.warn('TrailerService', `Local server test failed: ${msg}`); + // --------------------------------------------------------------------------- + // Private cache + // --------------------------------------------------------------------------- + + private static getCached(key: string): string | null { + const entry = this.urlCache.get(key); + if (!entry) return null; + if (Date.now() > entry.expiresAt) { + this.urlCache.delete(key); + return null; } + return entry.url; + } - logger.info('TrailerService', `Server test results -> local: ${results.localServer.status}`); - return results; + private static setCache(key: string, url: string): void { + this.urlCache.set(key, { url, expiresAt: Date.now() + this.CACHE_TTL_MS }); + if (this.urlCache.size > 100) { + const oldest = this.urlCache.keys().next().value; + if (oldest) this.urlCache.delete(oldest); + } } } -export default TrailerService; \ No newline at end of file +export default TrailerService; From 9134c3eb95ec29bb2a23c3583cdd098bd283b3d5 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 13:23:31 +0530 Subject: [PATCH 03/19] local trailers for herosection --- src/components/home/AppleTVHero.tsx | 63 ++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 7de8e8b0..5192ac34 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -440,35 +440,68 @@ const AppleTVHero: React.FC = ({ thumbnailOpacity.value = withTiming(1, { duration: 300 }); try { - // Extract year from metadata - const year = currentItem.releaseInfo - ? parseInt(currentItem.releaseInfo.split('-')[0], 10) - : new Date().getFullYear(); - // Extract TMDB ID if available const tmdbId = currentItem.id?.startsWith('tmdb:') ? currentItem.id.replace('tmdb:', '') : undefined; + if (!tmdbId) { + logger.info('[AppleTVHero] No TMDB ID for:', currentItem.name, '- skipping trailer'); + setTrailerUrl(null); + setTrailerLoading(false); + return; + } + const contentType = currentItem.type === 'series' ? 'tv' : 'movie'; - logger.info('[AppleTVHero] Fetching trailer for:', currentItem.name, year, tmdbId); + logger.info('[AppleTVHero] Fetching TMDB videos for:', currentItem.name, 'tmdbId:', tmdbId); - const url = await TrailerService.getTrailerUrl( - currentItem.name, - year, - tmdbId, - contentType + // Fetch video list from TMDB to get the YouTube video ID + const videosRes = await fetch( + `https://api.themoviedb.org/3/${contentType}/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US` + ); + + if (!alive) return; + + if (!videosRes.ok) { + logger.warn('[AppleTVHero] TMDB videos fetch failed:', videosRes.status); + setTrailerUrl(null); + setTrailerLoading(false); + return; + } + + const videosData = await videosRes.json(); + const results: any[] = videosData.results ?? []; + + // Pick best YouTube trailer: official trailer > any trailer > teaser > any YouTube video + const pick = + results.find((v) => v.site === 'YouTube' && v.type === 'Trailer' && v.official) ?? + results.find((v) => v.site === 'YouTube' && v.type === 'Trailer') ?? + results.find((v) => v.site === 'YouTube' && v.type === 'Teaser') ?? + results.find((v) => v.site === 'YouTube'); + + if (!alive) return; + + if (!pick) { + logger.info('[AppleTVHero] No YouTube video found for:', currentItem.name); + setTrailerUrl(null); + setTrailerLoading(false); + return; + } + + logger.info('[AppleTVHero] Extracting stream for videoId:', pick.key, currentItem.name); + + const url = await TrailerService.getTrailerFromVideoId( + pick.key, + currentItem.name ); if (!alive) return; if (url) { - const bestUrl = TrailerService.getBestFormatUrl(url); - setTrailerUrl(bestUrl); - // logger.info('[AppleTVHero] Trailer URL loaded:', bestUrl); + setTrailerUrl(url); } else { - logger.info('[AppleTVHero] No trailer found for:', currentItem.name); + logger.info('[AppleTVHero] No stream extracted for:', currentItem.name); setTrailerUrl(null); } } catch (error) { From 47a484fe3d454d03761d70d1b249474da94a3a31 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 13:24:33 +0530 Subject: [PATCH 04/19] local trailers --- src/components/metadata/HeroSection.tsx | 109 ++++++++++++++---------- 1 file changed, 63 insertions(+), 46 deletions(-) diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 911986c1..67f6221c 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -1129,12 +1129,14 @@ const HeroSection: React.FC = memo(({ useEffect(() => { let alive = true as boolean; let timerId: any = null; - const fetchTrailer = async () => { - if (!metadata?.name || !metadata?.year || !settings?.showTrailers || !isFocused) return; - // If we expect TMDB ID but don't have it yet, wait a bit more - if (!metadata?.tmdbId && metadata?.id?.startsWith('tmdb:')) { - logger.info('HeroSection', `Waiting for TMDB ID for ${metadata.name}`); + const fetchTrailer = async () => { + if (!metadata?.name || !settings?.showTrailers || !isFocused) return; + + // Need a TMDB ID to look up the YouTube video ID + const resolvedTmdbId = tmdbId ? String(tmdbId) : undefined; + if (!resolvedTmdbId) { + logger.info('HeroSection', `No TMDB ID for ${metadata.name} - skipping trailer`); return; } @@ -1143,51 +1145,66 @@ const HeroSection: React.FC = memo(({ setTrailerReady(false); setTrailerPreloaded(false); - try { - // Use requestIdleCallback or setTimeout to prevent blocking main thread - const fetchWithDelay = () => { - // Extract TMDB ID if available - const tmdbIdString = tmdbId ? String(tmdbId) : undefined; + // Small delay to avoid blocking the UI render + timerId = setTimeout(async () => { + if (!alive) return; + + try { const contentType = type === 'series' ? 'tv' : 'movie'; - // Debug logging to see what we have - logger.info('HeroSection', `Trailer request for ${metadata.name}:`, { - hasTmdbId: !!tmdbId, - tmdbId: tmdbId, - contentType, - metadataKeys: Object.keys(metadata || {}), - metadataId: metadata?.id - }); + logger.info('HeroSection', `Fetching TMDB videos for ${metadata.name} (tmdbId: ${resolvedTmdbId})`); - TrailerService.getTrailerUrl(metadata.name, metadata.year, tmdbIdString, contentType) - .then(url => { - if (url) { - const bestUrl = TrailerService.getBestFormatUrl(url); - setTrailerUrl(bestUrl); - logger.info('HeroSection', `Trailer URL loaded for ${metadata.name}${tmdbId ? ` (TMDB: ${tmdbId})` : ''}`); - } else { - logger.info('HeroSection', `No trailer found for ${metadata.name}`); - } - }) - .catch(error => { - logger.error('HeroSection', 'Error fetching trailer:', error); - setTrailerError(true); - }) - .finally(() => { - setTrailerLoading(false); - }); - }; + // Fetch video list from TMDB to get the YouTube video ID + const videosRes = await fetch( + `https://api.themoviedb.org/3/${contentType}/${resolvedTmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US` + ); - // Delay trailer fetch to prevent blocking UI - timerId = setTimeout(() => { if (!alive) return; - fetchWithDelay(); - }, 100); - } catch (error) { - logger.error('HeroSection', 'Error in trailer fetch setup:', error); - setTrailerError(true); - setTrailerLoading(false); - } + + if (!videosRes.ok) { + logger.warn('HeroSection', `TMDB videos fetch failed: ${videosRes.status} for ${metadata.name}`); + setTrailerLoading(false); + return; + } + + const videosData = await videosRes.json(); + const results: any[] = videosData.results ?? []; + + // Pick best YouTube trailer: official trailer > any trailer > teaser > any YouTube video + const pick = + results.find((v) => v.site === 'YouTube' && v.type === 'Trailer' && v.official) ?? + results.find((v) => v.site === 'YouTube' && v.type === 'Trailer') ?? + results.find((v) => v.site === 'YouTube' && v.type === 'Teaser') ?? + results.find((v) => v.site === 'YouTube'); + + if (!alive) return; + + if (!pick) { + logger.info('HeroSection', `No YouTube video found for ${metadata.name}`); + setTrailerLoading(false); + return; + } + + logger.info('HeroSection', `Extracting stream for videoId: ${pick.key} (${metadata.name})`); + + const url = await TrailerService.getTrailerFromVideoId(pick.key, metadata.name); + + if (!alive) return; + + if (url) { + setTrailerUrl(url); + logger.info('HeroSection', `Trailer loaded for ${metadata.name}`); + } else { + logger.info('HeroSection', `No stream extracted for ${metadata.name}`); + } + } catch (error) { + if (!alive) return; + logger.error('HeroSection', 'Error fetching trailer:', error); + setTrailerError(true); + } finally { + if (alive) setTrailerLoading(false); + } + }, 100); }; fetchTrailer(); @@ -1195,7 +1212,7 @@ const HeroSection: React.FC = memo(({ alive = false; try { if (timerId) clearTimeout(timerId); } catch (_e) { } }; - }, [metadata?.name, metadata?.year, tmdbId, settings?.showTrailers, isFocused]); + }, [metadata?.name, tmdbId, settings?.showTrailers, isFocused]); // Shimmer animation removed From 5b6554ff37d789669bb8627221641aeb2db9e2a3 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:05:10 +0530 Subject: [PATCH 05/19] fix trailers in homescreen hero --- src/components/home/AppleTVHero.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 5192ac34..8b3e7226 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -524,10 +524,17 @@ const AppleTVHero: React.FC = ({ }, [currentItem, currentIndex]); // Removed settings?.showTrailers from dependencies // Handle trailer preloaded + // FIX: Set global trailer playing to true HERE — before the visible player mounts — + // so that when the visible player's autoPlay prop is evaluated it is already true, + // eliminating the race condition that previously caused the global state effect in + // TrailerPlayer to immediately pause the video on first render. const handleTrailerPreloaded = useCallback(() => { + if (isFocused && !isOutOfView && !trailerShouldBePaused) { + setTrailerPlaying(true); + } setTrailerPreloaded(true); logger.info('[AppleTVHero] Trailer preloaded successfully'); - }, []); + }, [isFocused, isOutOfView, trailerShouldBePaused, setTrailerPlaying]); // Handle trailer ready to play const handleTrailerReady = useCallback(() => { @@ -1111,7 +1118,7 @@ const AppleTVHero: React.FC = ({ key={`visible-${trailerUrl}`} ref={trailerVideoRef} trailerUrl={trailerUrl} - autoPlay={globalTrailerPlaying} + autoPlay={!trailerShouldBePaused} muted={trailerMuted} style={StyleSheet.absoluteFillObject} hideLoadingSpinner={true} From c764faf2a74847a300f09152b463df0ea8a3b003 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:06:50 +0530 Subject: [PATCH 06/19] fix race conditions --- src/components/video/TrailerPlayer.tsx | 29 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/components/video/TrailerPlayer.tsx b/src/components/video/TrailerPlayer.tsx index de8a7b91..cd5d9e75 100644 --- a/src/components/video/TrailerPlayer.tsx +++ b/src/components/video/TrailerPlayer.tsx @@ -75,6 +75,11 @@ const TrailerPlayer = React.forwardRef(({ const [isFullscreen, setIsFullscreen] = useState(false); const [isComponentMounted, setIsComponentMounted] = useState(true); + // FIX: Track whether this player has ever been in a playing state. + // This prevents the globalTrailerPlaying effect from suppressing the + // very first play attempt before the global state has been set to true. + const hasBeenPlayingRef = useRef(false); + // Animated values const controlsOpacity = useSharedValue(0); const loadingOpacity = useSharedValue(1); @@ -150,6 +155,7 @@ const TrailerPlayer = React.forwardRef(({ useEffect(() => { if (isComponentMounted && paused === undefined) { setIsPlaying(autoPlay); + if (autoPlay) hasBeenPlayingRef.current = true; } }, [autoPlay, isComponentMounted, paused]); @@ -163,18 +169,23 @@ const TrailerPlayer = React.forwardRef(({ // Handle external paused prop to override playing state (highest priority) useEffect(() => { if (paused !== undefined) { - setIsPlaying(!paused); - logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${!paused}`); + const shouldPlay = !paused; + setIsPlaying(shouldPlay); + if (shouldPlay) hasBeenPlayingRef.current = true; + logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${shouldPlay}`); } }, [paused]); // Respond to global trailer state changes (e.g., when modal opens) - // Only apply if no external paused prop is controlling this + // Only apply if no external paused prop is controlling this. + // FIX: Only pause if this player has previously been in a playing state. + // This avoids the race condition where globalTrailerPlaying is still false + // at mount time (before the parent has called setTrailerPlaying(true)), + // which was causing the trailer to be immediately paused on every load. useEffect(() => { if (isComponentMounted && paused === undefined) { - // Always sync with global trailer state when pausing - // This ensures all trailers pause when one screen loses focus - if (!globalTrailerPlaying) { + if (!globalTrailerPlaying && hasBeenPlayingRef.current) { + // Only suppress if the player was previously playing — not on initial mount logger.info('TrailerPlayer', 'Global trailer paused - pausing this trailer'); setIsPlaying(false); } @@ -364,10 +375,10 @@ const TrailerPlayer = React.forwardRef(({ ref={videoRef} source={(() => { const androidHeaders = Platform.OS === 'android' ? { 'User-Agent': 'Nuvio/1.0 (Android)' } : {} as any; - // Help ExoPlayer select proper MediaSource const lower = (trailerUrl || '').toLowerCase(); const looksLikeHls = /\.m3u8(\b|$)/.test(lower) || /hls|applehlsencryption|playlist|m3u/.test(lower); - const looksLikeDash = /\.mpd(\b|$)/.test(lower) || /dash|manifest/.test(lower); + // Detect both .mpd URLs and inline data: DASH manifests + const looksLikeDash = /\.mpd(\b|$)/.test(lower) || /dash|manifest/.test(lower) || lower.startsWith('data:application/dash'); if (Platform.OS === 'android') { if (looksLikeHls) { return { uri: trailerUrl, type: 'm3u8', headers: androidHeaders } as any; @@ -595,4 +606,4 @@ const styles = StyleSheet.create({ }, }); -export default TrailerPlayer; \ No newline at end of file +export default TrailerPlayer; From f653aa9aa7b7e362f2c9f8acbbd061a1033a9b3a Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:09:49 +0530 Subject: [PATCH 07/19] fix for low quality trailers --- src/services/youtubeExtractor.ts | 269 +++++++++++++++++++++++-------- 1 file changed, 206 insertions(+), 63 deletions(-) diff --git a/src/services/youtubeExtractor.ts b/src/services/youtubeExtractor.ts index c340bc0f..928cc8ec 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); From e475feae84535706cfa5ba311fb360d624354e3d Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:10:59 +0530 Subject: [PATCH 08/19] fix for low quality trailers --- src/services/trailerService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/trailerService.ts b/src/services/trailerService.ts index 5044a93b..2cff19d3 100644 --- a/src/services/trailerService.ts +++ b/src/services/trailerService.ts @@ -1,4 +1,5 @@ import { logger } from '../utils/logger'; +import { Platform } from 'react-native'; import { YouTubeExtractor } from './youtubeExtractor'; export interface TrailerData { @@ -41,7 +42,8 @@ export class TrailerService { } try { - const url = await YouTubeExtractor.getBestStreamUrl(youtubeVideoId); + const platform = Platform.OS === 'android' ? 'android' : 'ios'; + const url = await YouTubeExtractor.getBestStreamUrl(youtubeVideoId, platform); if (url) { logger.info('TrailerService', `On-device extraction succeeded for ${youtubeVideoId}`); this.setCache(youtubeVideoId, url); From ca8c503a47bc56e02f82ec0d45393d1006200b2b Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:12:26 +0530 Subject: [PATCH 09/19] fix for low quality trailers From 005e4b7ea901f6e7e898874e5b846bdf3a59409b Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:20:09 +0530 Subject: [PATCH 10/19] fix From 8cd1ec8188465e977cca5964d82bb4eac6d74b38 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:21:37 +0530 Subject: [PATCH 11/19] fix From cadcda27df209939cdce0f838b121409232900fe Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Sun, 1 Mar 2026 15:22:14 +0530 Subject: [PATCH 12/19] fix --- src/services/youtubeExtractor.ts | 120 +++++++++++++++++-------------- 1 file changed, 65 insertions(+), 55 deletions(-) diff --git a/src/services/youtubeExtractor.ts b/src/services/youtubeExtractor.ts index 928cc8ec..393876cf 100644 --- a/src/services/youtubeExtractor.ts +++ b/src/services/youtubeExtractor.ts @@ -111,8 +111,9 @@ const TVHTML5_EMBEDDED_CONTEXT = { // 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. +// Muxed (video+audio in one file). +// iOS AVPlayer can ONLY use these. Max quality YouTube provides is 720p (itag 22), +// but it is often absent on modern videos, leaving 360p (itag 18) as the fallback. const PREFERRED_MUXED_ITAGS = [ 22, // 720p MP4 (video+audio) 18, // 360p MP4 (video+audio) @@ -120,8 +121,8 @@ const PREFERRED_MUXED_ITAGS = [ 78, // 480p MP4 (video+audio) — rare ]; -// Adaptive video-only itags in descending quality order. -// ExoPlayer on Android can combine these with an audio stream via DASH. +// Adaptive video-only itags, best quality first (MP4 preferred over WebM). +// Used for DASH on Android only. const ADAPTIVE_VIDEO_ITAGS_RANKED = [ 137, // 1080p MP4 video-only 248, // 1080p WebM video-only @@ -133,7 +134,8 @@ const ADAPTIVE_VIDEO_ITAGS_RANKED = [ 243, // 360p WebM video-only ]; -// Adaptive audio-only itags in descending quality order. +// Adaptive audio-only itags, best quality first (AAC preferred over Opus). +// Used for DASH on Android only. const ADAPTIVE_AUDIO_ITAGS_RANKED = [ 141, // 256kbps AAC 140, // 128kbps AAC ← most common @@ -215,16 +217,15 @@ function scoreFormat(format: InnertubeFormat): number { } // --------------------------------------------------------------------------- -// Adaptive stream selection helpers +// Adaptive stream helpers (Android/DASH only) // --------------------------------------------------------------------------- -/** Pick the best video-only adaptive format available (MP4 preferred). */ function pickBestAdaptiveVideo(adaptiveFormats: InnertubeFormat[]): InnertubeFormat | null { + // Video-only: has qualityLabel, no audioQuality, has direct URL 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; @@ -232,13 +233,12 @@ function pickBestAdaptiveVideo(adaptiveFormats: InnertubeFormat[]): InnertubeFor 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 { + // Audio-only: has audioQuality, no qualityLabel, has direct URL 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; @@ -247,16 +247,29 @@ function pickBestAdaptiveAudio(adaptiveFormats: InnertubeFormat[]): InnertubeFor } /** - * 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. + * Write a DASH MPD manifest to a temp file and return its file:// URI. + * + * We use a file URI rather than a data: URI because: + * - ExoPlayer's DefaultDataSource handles file:// URIs natively via FileDataSource. + * - The .mpd file extension lets ExoPlayer auto-detect the type even without an + * explicit 'type' hint — meaning TrailerModal's bare