diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 7de8e8b0..18a1e0a1 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -13,6 +13,7 @@ import { } from 'react-native'; import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native'; import { useTranslation } from 'react-i18next'; +import { TMDBService } from '../../services/tmdbService'; import { RootStackParamList } from '../../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; import FastImage from '@d11/react-native-fast-image'; @@ -440,35 +441,69 @@ 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 tmdbApiKey = await TMDBService.getInstance().getApiKey(); + const videosRes = await fetch( + `https://api.themoviedb.org/3/${contentType}/${tmdbId}/videos?api_key=${tmdbApiKey}&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) { @@ -491,10 +526,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(() => { @@ -1078,7 +1120,7 @@ const AppleTVHero: React.FC = ({ key={`visible-${trailerUrl}`} ref={trailerVideoRef} trailerUrl={trailerUrl} - autoPlay={globalTrailerPlaying} + autoPlay={!trailerShouldBePaused} muted={trailerMuted} style={StyleSheet.absoluteFillObject} hideLoadingSpinner={true} 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 diff --git a/src/components/metadata/TrailersSection.tsx b/src/components/metadata/TrailersSection.tsx index 3e3ec45d..7bc86baa 100644 --- a/src/components/metadata/TrailersSection.tsx +++ b/src/components/metadata/TrailersSection.tsx @@ -19,6 +19,7 @@ import { useSettings } from '../../hooks/useSettings'; import { useTrailer } from '../../contexts/TrailerContext'; import { logger } from '../../utils/logger'; import TrailerService from '../../services/trailerService'; +import { TMDBService } from '../../services/tmdbService'; import TrailerModal from './TrailerModal'; import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated'; @@ -175,10 +176,13 @@ const TrailersSection: React.FC = memo(({ try { logger.info('TrailersSection', `Fetching trailers for TMDB ID: ${tmdbId}, type: ${type}`); + // Resolve user-configured TMDB API key (falls back to default if not set) + const tmdbApiKey = await TMDBService.getInstance().getApiKey(); + // First check if the movie/TV show exists const basicEndpoint = type === 'movie' - ? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de` - : `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`; + ? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${tmdbApiKey}` + : `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=${tmdbApiKey}`; const basicResponse = await fetch(basicEndpoint); if (!basicResponse.ok) { @@ -197,7 +201,7 @@ const TrailersSection: React.FC = memo(({ if (type === 'movie') { // For movies, just fetch the main videos endpoint - const videosEndpoint = `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`; + const videosEndpoint = `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=${tmdbApiKey}&language=en-US`; logger.info('TrailersSection', `Fetching movie videos from: ${videosEndpoint}`); @@ -228,7 +232,7 @@ const TrailersSection: React.FC = memo(({ logger.info('TrailersSection', `TV show has ${numberOfSeasons} seasons`); // Fetch main TV show videos - const tvVideosEndpoint = `https://api.themoviedb.org/3/tv/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`; + const tvVideosEndpoint = `https://api.themoviedb.org/3/tv/${tmdbId}/videos?api_key=${tmdbApiKey}&language=en-US`; const tvResponse = await fetch(tvVideosEndpoint); if (tvResponse.ok) { @@ -247,7 +251,7 @@ const TrailersSection: React.FC = memo(({ const seasonPromises = []; for (let seasonNum = 1; seasonNum <= numberOfSeasons; seasonNum++) { seasonPromises.push( - fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNum}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`) + fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNum}/videos?api_key=${tmdbApiKey}&language=en-US`) .then(res => res.json()) .then(data => ({ seasonNumber: seasonNum, 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; diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index 36cb53f7..3abcc660 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -259,6 +259,17 @@ export class TMDBService { } } + /** + * Returns the resolved TMDB API key (custom user key if set, otherwise default). + * Always awaits key loading so callers get the correct value. + */ + async getApiKey(): Promise { + if (!this.apiKeyLoaded) { + await this.loadApiKey(); + } + return this.apiKey; + } + private async getHeaders() { // Ensure API key is loaded before returning headers if (!this.apiKeyLoaded) { @@ -1658,4 +1669,4 @@ export class TMDBService { } export const tmdbService = TMDBService.getInstance(); -export default tmdbService; \ No newline at end of file +export default tmdbService; diff --git a/src/services/trailerService.ts b/src/services/trailerService.ts index 1b7f4bbf..62b78e9d 100644 --- a/src/services/trailerService.ts +++ b/src/services/trailerService.ts @@ -1,4 +1,6 @@ import { logger } from '../utils/logger'; +import { Platform } from 'react-native'; +import { YouTubeExtractor } from './youtubeExtractor'; export interface TrailerData { url: string; @@ -6,373 +8,165 @@ 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 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); + 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; } + // Don't return cached .mpd file paths — the temp file may no longer exist + // after an app restart, and we'd rather re-extract than serve a dead file URI + if (entry.url.endsWith('.mpd')) { + 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; diff --git a/src/services/youtubeExtractor.ts b/src/services/youtubeExtractor.ts new file mode 100644 index 00000000..c01aeda3 --- /dev/null +++ b/src/services/youtubeExtractor.ts @@ -0,0 +1,641 @@ +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; + initRange?: { start: string; end: string }; + indexRange?: { start: string; end: 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', + }, +}; + +// Web Embedded client — good fallback for content that rejects app clients +const WEB_EMBEDDED_CONTEXT = { + client: { + clientName: 'WEB_EMBEDDED_PLAYER', + clientVersion: '2.20240726.00.00', + hl: 'en', + gl: 'US', + }, + thirdParty: { + embedUrl: 'https://www.youtube.com', + }, +}; + +// --------------------------------------------------------------------------- +// Itag reference tables +// --------------------------------------------------------------------------- + +// 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) + 59, // 480p MP4 (video+audio) — rare + 78, // 480p MP4 (video+audio) — rare +]; + +// 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 + 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, 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 + 251, // 160kbps Opus + 250, // 70kbps Opus + 249, // 50kbps Opus +]; + +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 { + const preferredIndex = PREFERRED_MUXED_ITAGS.indexOf(format.itag); + const itagBonus = preferredIndex !== -1 ? (PREFERRED_MUXED_ITAGS.length - preferredIndex) * 10000 : 0; + const height = format.height ?? 0; + const heightScore = Math.min(height, 720) * 10; + const bitrateScore = Math.min(format.bitrate ?? 0, 3_000_000) / 1000; + return itagBonus + heightScore + bitrateScore; +} + +// --------------------------------------------------------------------------- +// Adaptive stream helpers (Android/DASH only) +// --------------------------------------------------------------------------- + +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; + } + return videoOnly.sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0] ?? null; +} + +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; + } + return audioOnly.sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0] ?? null; +} + +/** + * 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