mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-02 21:54:46 +00:00
Merge pull request #570 from chrisk325/local-trailer
feat: on device local trailers for hero section on metadata screen
This commit is contained in:
commit
ea4bd8680b
7 changed files with 922 additions and 402 deletions
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native';
|
import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { TMDBService } from '../../services/tmdbService';
|
||||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import FastImage from '@d11/react-native-fast-image';
|
import FastImage from '@d11/react-native-fast-image';
|
||||||
|
|
@ -440,35 +441,69 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
thumbnailOpacity.value = withTiming(1, { duration: 300 });
|
thumbnailOpacity.value = withTiming(1, { duration: 300 });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract year from metadata
|
|
||||||
const year = currentItem.releaseInfo
|
|
||||||
? parseInt(currentItem.releaseInfo.split('-')[0], 10)
|
|
||||||
: new Date().getFullYear();
|
|
||||||
|
|
||||||
// Extract TMDB ID if available
|
// Extract TMDB ID if available
|
||||||
const tmdbId = currentItem.id?.startsWith('tmdb:')
|
const tmdbId = currentItem.id?.startsWith('tmdb:')
|
||||||
? currentItem.id.replace('tmdb:', '')
|
? currentItem.id.replace('tmdb:', '')
|
||||||
: undefined;
|
: 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';
|
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(
|
// Fetch video list from TMDB to get the YouTube video ID
|
||||||
currentItem.name,
|
const tmdbApiKey = await TMDBService.getInstance().getApiKey();
|
||||||
year,
|
const videosRes = await fetch(
|
||||||
tmdbId,
|
`https://api.themoviedb.org/3/${contentType}/${tmdbId}/videos?api_key=${tmdbApiKey}&language=en-US`
|
||||||
contentType
|
);
|
||||||
|
|
||||||
|
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 (!alive) return;
|
||||||
|
|
||||||
if (url) {
|
if (url) {
|
||||||
const bestUrl = TrailerService.getBestFormatUrl(url);
|
setTrailerUrl(url);
|
||||||
setTrailerUrl(bestUrl);
|
|
||||||
// logger.info('[AppleTVHero] Trailer URL loaded:', bestUrl);
|
|
||||||
} else {
|
} else {
|
||||||
logger.info('[AppleTVHero] No trailer found for:', currentItem.name);
|
logger.info('[AppleTVHero] No stream extracted for:', currentItem.name);
|
||||||
setTrailerUrl(null);
|
setTrailerUrl(null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -491,10 +526,17 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
}, [currentItem, currentIndex]); // Removed settings?.showTrailers from dependencies
|
}, [currentItem, currentIndex]); // Removed settings?.showTrailers from dependencies
|
||||||
|
|
||||||
// Handle trailer preloaded
|
// 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(() => {
|
const handleTrailerPreloaded = useCallback(() => {
|
||||||
|
if (isFocused && !isOutOfView && !trailerShouldBePaused) {
|
||||||
|
setTrailerPlaying(true);
|
||||||
|
}
|
||||||
setTrailerPreloaded(true);
|
setTrailerPreloaded(true);
|
||||||
logger.info('[AppleTVHero] Trailer preloaded successfully');
|
logger.info('[AppleTVHero] Trailer preloaded successfully');
|
||||||
}, []);
|
}, [isFocused, isOutOfView, trailerShouldBePaused, setTrailerPlaying]);
|
||||||
|
|
||||||
// Handle trailer ready to play
|
// Handle trailer ready to play
|
||||||
const handleTrailerReady = useCallback(() => {
|
const handleTrailerReady = useCallback(() => {
|
||||||
|
|
@ -1078,7 +1120,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
||||||
key={`visible-${trailerUrl}`}
|
key={`visible-${trailerUrl}`}
|
||||||
ref={trailerVideoRef}
|
ref={trailerVideoRef}
|
||||||
trailerUrl={trailerUrl}
|
trailerUrl={trailerUrl}
|
||||||
autoPlay={globalTrailerPlaying}
|
autoPlay={!trailerShouldBePaused}
|
||||||
muted={trailerMuted}
|
muted={trailerMuted}
|
||||||
style={StyleSheet.absoluteFillObject}
|
style={StyleSheet.absoluteFillObject}
|
||||||
hideLoadingSpinner={true}
|
hideLoadingSpinner={true}
|
||||||
|
|
|
||||||
|
|
@ -1129,12 +1129,14 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true as boolean;
|
let alive = true as boolean;
|
||||||
let timerId: any = null;
|
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
|
const fetchTrailer = async () => {
|
||||||
if (!metadata?.tmdbId && metadata?.id?.startsWith('tmdb:')) {
|
if (!metadata?.name || !settings?.showTrailers || !isFocused) return;
|
||||||
logger.info('HeroSection', `Waiting for TMDB ID for ${metadata.name}`);
|
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1143,51 +1145,66 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
setTrailerReady(false);
|
setTrailerReady(false);
|
||||||
setTrailerPreloaded(false);
|
setTrailerPreloaded(false);
|
||||||
|
|
||||||
try {
|
// Small delay to avoid blocking the UI render
|
||||||
// Use requestIdleCallback or setTimeout to prevent blocking main thread
|
timerId = setTimeout(async () => {
|
||||||
const fetchWithDelay = () => {
|
if (!alive) return;
|
||||||
// Extract TMDB ID if available
|
|
||||||
const tmdbIdString = tmdbId ? String(tmdbId) : undefined;
|
try {
|
||||||
const contentType = type === 'series' ? 'tv' : 'movie';
|
const contentType = type === 'series' ? 'tv' : 'movie';
|
||||||
|
|
||||||
// Debug logging to see what we have
|
logger.info('HeroSection', `Fetching TMDB videos for ${metadata.name} (tmdbId: ${resolvedTmdbId})`);
|
||||||
logger.info('HeroSection', `Trailer request for ${metadata.name}:`, {
|
|
||||||
hasTmdbId: !!tmdbId,
|
|
||||||
tmdbId: tmdbId,
|
|
||||||
contentType,
|
|
||||||
metadataKeys: Object.keys(metadata || {}),
|
|
||||||
metadataId: metadata?.id
|
|
||||||
});
|
|
||||||
|
|
||||||
TrailerService.getTrailerUrl(metadata.name, metadata.year, tmdbIdString, contentType)
|
// Fetch video list from TMDB to get the YouTube video ID
|
||||||
.then(url => {
|
const videosRes = await fetch(
|
||||||
if (url) {
|
`https://api.themoviedb.org/3/${contentType}/${resolvedTmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`
|
||||||
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);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Delay trailer fetch to prevent blocking UI
|
|
||||||
timerId = setTimeout(() => {
|
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
fetchWithDelay();
|
|
||||||
}, 100);
|
if (!videosRes.ok) {
|
||||||
} catch (error) {
|
logger.warn('HeroSection', `TMDB videos fetch failed: ${videosRes.status} for ${metadata.name}`);
|
||||||
logger.error('HeroSection', 'Error in trailer fetch setup:', error);
|
setTrailerLoading(false);
|
||||||
setTrailerError(true);
|
return;
|
||||||
setTrailerLoading(false);
|
}
|
||||||
}
|
|
||||||
|
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();
|
fetchTrailer();
|
||||||
|
|
@ -1195,7 +1212,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
alive = false;
|
alive = false;
|
||||||
try { if (timerId) clearTimeout(timerId); } catch (_e) { }
|
try { if (timerId) clearTimeout(timerId); } catch (_e) { }
|
||||||
};
|
};
|
||||||
}, [metadata?.name, metadata?.year, tmdbId, settings?.showTrailers, isFocused]);
|
}, [metadata?.name, tmdbId, settings?.showTrailers, isFocused]);
|
||||||
|
|
||||||
// Shimmer animation removed
|
// Shimmer animation removed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { useSettings } from '../../hooks/useSettings';
|
||||||
import { useTrailer } from '../../contexts/TrailerContext';
|
import { useTrailer } from '../../contexts/TrailerContext';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import TrailerService from '../../services/trailerService';
|
import TrailerService from '../../services/trailerService';
|
||||||
|
import { TMDBService } from '../../services/tmdbService';
|
||||||
import TrailerModal from './TrailerModal';
|
import TrailerModal from './TrailerModal';
|
||||||
import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated';
|
import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated';
|
||||||
|
|
||||||
|
|
@ -175,10 +176,13 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
try {
|
try {
|
||||||
logger.info('TrailersSection', `Fetching trailers for TMDB ID: ${tmdbId}, type: ${type}`);
|
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
|
// First check if the movie/TV show exists
|
||||||
const basicEndpoint = type === 'movie'
|
const basicEndpoint = type === 'movie'
|
||||||
? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`
|
? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${tmdbApiKey}`
|
||||||
: `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`;
|
: `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=${tmdbApiKey}`;
|
||||||
|
|
||||||
const basicResponse = await fetch(basicEndpoint);
|
const basicResponse = await fetch(basicEndpoint);
|
||||||
if (!basicResponse.ok) {
|
if (!basicResponse.ok) {
|
||||||
|
|
@ -197,7 +201,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
|
|
||||||
if (type === 'movie') {
|
if (type === 'movie') {
|
||||||
// For movies, just fetch the main videos endpoint
|
// 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}`);
|
logger.info('TrailersSection', `Fetching movie videos from: ${videosEndpoint}`);
|
||||||
|
|
||||||
|
|
@ -228,7 +232,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
logger.info('TrailersSection', `TV show has ${numberOfSeasons} seasons`);
|
logger.info('TrailersSection', `TV show has ${numberOfSeasons} seasons`);
|
||||||
|
|
||||||
// Fetch main TV show videos
|
// 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);
|
const tvResponse = await fetch(tvVideosEndpoint);
|
||||||
|
|
||||||
if (tvResponse.ok) {
|
if (tvResponse.ok) {
|
||||||
|
|
@ -247,7 +251,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
||||||
const seasonPromises = [];
|
const seasonPromises = [];
|
||||||
for (let seasonNum = 1; seasonNum <= numberOfSeasons; seasonNum++) {
|
for (let seasonNum = 1; seasonNum <= numberOfSeasons; seasonNum++) {
|
||||||
seasonPromises.push(
|
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(res => res.json())
|
||||||
.then(data => ({
|
.then(data => ({
|
||||||
seasonNumber: seasonNum,
|
seasonNumber: seasonNum,
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,11 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [isComponentMounted, setIsComponentMounted] = useState(true);
|
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
|
// Animated values
|
||||||
const controlsOpacity = useSharedValue(0);
|
const controlsOpacity = useSharedValue(0);
|
||||||
const loadingOpacity = useSharedValue(1);
|
const loadingOpacity = useSharedValue(1);
|
||||||
|
|
@ -150,6 +155,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isComponentMounted && paused === undefined) {
|
if (isComponentMounted && paused === undefined) {
|
||||||
setIsPlaying(autoPlay);
|
setIsPlaying(autoPlay);
|
||||||
|
if (autoPlay) hasBeenPlayingRef.current = true;
|
||||||
}
|
}
|
||||||
}, [autoPlay, isComponentMounted, paused]);
|
}, [autoPlay, isComponentMounted, paused]);
|
||||||
|
|
||||||
|
|
@ -163,18 +169,23 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
// Handle external paused prop to override playing state (highest priority)
|
// Handle external paused prop to override playing state (highest priority)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (paused !== undefined) {
|
if (paused !== undefined) {
|
||||||
setIsPlaying(!paused);
|
const shouldPlay = !paused;
|
||||||
logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${!paused}`);
|
setIsPlaying(shouldPlay);
|
||||||
|
if (shouldPlay) hasBeenPlayingRef.current = true;
|
||||||
|
logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${shouldPlay}`);
|
||||||
}
|
}
|
||||||
}, [paused]);
|
}, [paused]);
|
||||||
|
|
||||||
// Respond to global trailer state changes (e.g., when modal opens)
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (isComponentMounted && paused === undefined) {
|
if (isComponentMounted && paused === undefined) {
|
||||||
// Always sync with global trailer state when pausing
|
if (!globalTrailerPlaying && hasBeenPlayingRef.current) {
|
||||||
// This ensures all trailers pause when one screen loses focus
|
// Only suppress if the player was previously playing — not on initial mount
|
||||||
if (!globalTrailerPlaying) {
|
|
||||||
logger.info('TrailerPlayer', 'Global trailer paused - pausing this trailer');
|
logger.info('TrailerPlayer', 'Global trailer paused - pausing this trailer');
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
|
|
@ -364,10 +375,10 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
source={(() => {
|
source={(() => {
|
||||||
const androidHeaders = Platform.OS === 'android' ? { 'User-Agent': 'Nuvio/1.0 (Android)' } : {} as any;
|
const androidHeaders = Platform.OS === 'android' ? { 'User-Agent': 'Nuvio/1.0 (Android)' } : {} as any;
|
||||||
// Help ExoPlayer select proper MediaSource
|
|
||||||
const lower = (trailerUrl || '').toLowerCase();
|
const lower = (trailerUrl || '').toLowerCase();
|
||||||
const looksLikeHls = /\.m3u8(\b|$)/.test(lower) || /hls|applehlsencryption|playlist|m3u/.test(lower);
|
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 (Platform.OS === 'android') {
|
||||||
if (looksLikeHls) {
|
if (looksLikeHls) {
|
||||||
return { uri: trailerUrl, type: 'm3u8', headers: androidHeaders } as any;
|
return { uri: trailerUrl, type: 'm3u8', headers: androidHeaders } as any;
|
||||||
|
|
@ -595,4 +606,4 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default TrailerPlayer;
|
export default TrailerPlayer;
|
||||||
|
|
|
||||||
|
|
@ -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<string> {
|
||||||
|
if (!this.apiKeyLoaded) {
|
||||||
|
await this.loadApiKey();
|
||||||
|
}
|
||||||
|
return this.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
private async getHeaders() {
|
private async getHeaders() {
|
||||||
// Ensure API key is loaded before returning headers
|
// Ensure API key is loaded before returning headers
|
||||||
if (!this.apiKeyLoaded) {
|
if (!this.apiKeyLoaded) {
|
||||||
|
|
@ -1658,4 +1669,4 @@ export class TMDBService {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tmdbService = TMDBService.getInstance();
|
export const tmdbService = TMDBService.getInstance();
|
||||||
export default tmdbService;
|
export default tmdbService;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
import { YouTubeExtractor } from './youtubeExtractor';
|
||||||
|
|
||||||
export interface TrailerData {
|
export interface TrailerData {
|
||||||
url: string;
|
url: string;
|
||||||
|
|
@ -6,373 +8,165 @@ export interface TrailerData {
|
||||||
year: number;
|
year: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TrailerService {
|
interface CacheEntry {
|
||||||
// Environment-configurable values (Expo public env)
|
url: string;
|
||||||
private static readonly ENV_LOCAL_BASE = process.env.EXPO_PUBLIC_TRAILER_LOCAL_BASE || 'http://46.62.173.157:3001';
|
expiresAt: number;
|
||||||
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';
|
|
||||||
|
|
||||||
private static readonly LOCAL_SERVER_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_TRAILER_PATH}`;
|
export class TrailerService {
|
||||||
private static readonly AUTO_SEARCH_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_SEARCH_PATH}`;
|
// YouTube CDN URLs expire ~6h; cache for 5h
|
||||||
private static readonly TIMEOUT = 20000; // 20 seconds
|
private static readonly CACHE_TTL_MS = 5 * 60 * 60 * 1000;
|
||||||
|
private static urlCache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches trailer URL for a given title and year
|
* Get a playable stream URL from a raw YouTube video ID (e.g. from TMDB).
|
||||||
* @param title - The movie/series title
|
* Pure on-device extraction via Innertube. No server involved.
|
||||||
* @param year - The release year
|
|
||||||
* @param tmdbId - Optional TMDB ID for more accurate results
|
|
||||||
* @param type - Optional content type ('movie' or 'tv')
|
|
||||||
* @returns Promise<string | null> - The trailer URL or null if not found
|
|
||||||
*/
|
*/
|
||||||
static async getTrailerUrl(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
|
static async getTrailerFromVideoId(
|
||||||
logger.info('TrailerService', `getTrailerUrl requested: title="${title}", year=${year}, tmdbId=${tmdbId || 'n/a'}, type=${type || 'n/a'}`);
|
youtubeVideoId: string,
|
||||||
return this.getTrailerFromLocalServer(title, year, tmdbId, type);
|
title?: string,
|
||||||
|
year?: number
|
||||||
|
): Promise<string | null> {
|
||||||
|
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
|
* Called by TrailerModal which has the full YouTube URL from TMDB.
|
||||||
* @param title - The movie/series title
|
* Parses the video ID then delegates to getTrailerFromVideoId.
|
||||||
* @param year - The release year
|
|
||||||
* @param tmdbId - Optional TMDB ID for more accurate results
|
|
||||||
* @param type - Optional content type ('movie' or 'tv')
|
|
||||||
* @returns Promise<string | null> - The trailer URL or null if not found
|
|
||||||
*/
|
*/
|
||||||
private static async getTrailerFromLocalServer(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
|
static async getTrailerFromYouTubeUrl(
|
||||||
const startTime = Date.now();
|
youtubeUrl: string,
|
||||||
const controller = new AbortController();
|
title?: string,
|
||||||
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
|
year?: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
logger.info('TrailerService', `getTrailerFromYouTubeUrl: ${youtubeUrl}`);
|
||||||
|
|
||||||
// Build URL with parameters
|
const videoId = YouTubeExtractor.parseVideoId(youtubeUrl);
|
||||||
const params = new URLSearchParams();
|
if (!videoId) {
|
||||||
|
logger.warn('TrailerService', `Could not parse video ID from: ${youtubeUrl}`);
|
||||||
// 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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.getTrailerFromVideoId(
|
||||||
|
videoId,
|
||||||
|
title,
|
||||||
|
year ? parseInt(year, 10) : undefined
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates if the provided string is a valid trailer URL
|
* Called by AppleTVHero and HeroSection which only have title/year/tmdbId.
|
||||||
* @param url - The URL to validate
|
* These callers need to be updated to pass the YouTube video ID from TMDB
|
||||||
* @returns boolean - True if valid, false otherwise
|
* instead and call getTrailerFromVideoId directly. Until then this returns null.
|
||||||
*/
|
*/
|
||||||
private static isValidTrailerUrl(url: string): boolean {
|
static async getTrailerUrl(
|
||||||
try {
|
title: string,
|
||||||
const urlObj = new URL(url);
|
year: number,
|
||||||
|
_tmdbId?: string,
|
||||||
// Check if it's a valid HTTP/HTTPS URL
|
_type?: 'movie' | 'tv'
|
||||||
if (!['http:', 'https:'].includes(urlObj.protocol)) {
|
): Promise<string | null> {
|
||||||
return false;
|
logger.warn(
|
||||||
}
|
'TrailerService',
|
||||||
|
`getTrailerUrl called for "${title}" but no YouTube video ID was provided. ` +
|
||||||
// Check for common video streaming domains/patterns
|
`Update caller to fetch the YouTube video ID from TMDB and call getTrailerFromVideoId instead.`
|
||||||
const validDomains = [
|
);
|
||||||
'theplatform.com',
|
return null;
|
||||||
'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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------------
|
||||||
* Extracts the best video format URL from a multi-format URL
|
// Unchanged public helpers (API compatibility)
|
||||||
* @param url - The trailer URL that may contain multiple formats
|
// ---------------------------------------------------------------------------
|
||||||
* @returns string - The best format URL for mobile playback
|
|
||||||
*/
|
/** Legacy format URL helper kept for API compatibility. */
|
||||||
static getBestFormatUrl(url: string): string {
|
static getBestFormatUrl(url: string): string {
|
||||||
// If the URL contains format parameters, try to get the best one for mobile
|
|
||||||
if (url.includes('formats=')) {
|
if (url.includes('formats=')) {
|
||||||
// Prefer M3U (HLS) for better mobile compatibility
|
|
||||||
if (url.includes('M3U')) {
|
if (url.includes('M3U')) {
|
||||||
// Try to get M3U without encryption first, then with encryption
|
return `${url.split('?')[0]}?formats=M3U+none,M3U+appleHlsEncryption`;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
// Fallback to MP4 if available
|
|
||||||
if (url.includes('MPEG4')) {
|
if (url.includes('MPEG4')) {
|
||||||
const baseUrl = url.split('?')[0];
|
return `${url.split('?')[0]}?formats=MPEG4`;
|
||||||
const best = `${baseUrl}?formats=MPEG4`;
|
|
||||||
logger.info('TrailerService', `Optimized format URL from MPEG4: ${best.substring(0, 80)}...`);
|
|
||||||
return best;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the original URL if no format optimization is needed
|
|
||||||
// logger.info('TrailerService', 'No format optimization applied');
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static async isTrailerAvailable(videoId: string): Promise<boolean> {
|
||||||
* Checks if a trailer is available for the given title and year
|
return (await this.getTrailerFromVideoId(videoId)) !== null;
|
||||||
* @param title - The movie/series title
|
|
||||||
* @param year - The release year
|
|
||||||
* @returns Promise<boolean> - True if trailer is available
|
|
||||||
*/
|
|
||||||
static async isTrailerAvailable(title: string, year: number): Promise<boolean> {
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets trailer data with additional metadata
|
|
||||||
* @param title - The movie/series title
|
|
||||||
* @param year - The release year
|
|
||||||
* @returns Promise<TrailerData | null> - Trailer data or null if not found
|
|
||||||
*/
|
|
||||||
static async getTrailerData(title: string, year: number): Promise<TrailerData | null> {
|
static async getTrailerData(title: string, year: number): Promise<TrailerData | null> {
|
||||||
logger.info('TrailerService', `getTrailerData for: ${title} (${year})`);
|
logger.warn('TrailerService', `getTrailerData: no video ID available for "${title}"`);
|
||||||
const url = await this.getTrailerUrl(title, year);
|
return null;
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
logger.info('TrailerService', 'No trailer URL found for getTrailerData');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
url: this.getBestFormatUrl(url),
|
|
||||||
title,
|
|
||||||
year
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
static setUseLocalServer(_useLocal: boolean): void {
|
||||||
* Fetches trailer directly from a known YouTube URL
|
logger.info('TrailerService', 'setUseLocalServer: no server used, on-device only');
|
||||||
* @param youtubeUrl - The YouTube URL to process
|
|
||||||
* @param title - Optional title for logging/caching
|
|
||||||
* @param year - Optional year for logging/caching
|
|
||||||
* @returns Promise<string | null> - The direct streaming URL or null if failed
|
|
||||||
*/
|
|
||||||
static async getTrailerFromYouTubeUrl(youtubeUrl: string, title?: string, year?: string): Promise<string | null> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 } {
|
static getServerStatus(): { usingLocal: boolean; localUrl: string } {
|
||||||
return {
|
return { usingLocal: false, localUrl: '' };
|
||||||
usingLocal: true,
|
|
||||||
localUrl: this.LOCAL_SERVER_URL,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Test local server and return its status
|
|
||||||
* @returns Promise with server status information
|
|
||||||
*/
|
|
||||||
static async testServers(): Promise<{
|
static async testServers(): Promise<{
|
||||||
localServer: { status: 'online' | 'offline'; responseTime?: number };
|
localServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||||
}> {
|
}> {
|
||||||
logger.info('TrailerService', 'Testing local server');
|
return { localServer: { status: 'offline' } };
|
||||||
const results: {
|
}
|
||||||
localServer: { status: 'online' | 'offline'; responseTime?: number };
|
|
||||||
} = {
|
|
||||||
localServer: { status: 'offline' }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test local server
|
// ---------------------------------------------------------------------------
|
||||||
try {
|
// Private cache
|
||||||
const startTime = Date.now();
|
// ---------------------------------------------------------------------------
|
||||||
const response = await fetch(`${this.AUTO_SEARCH_URL}?title=test&year=2023`, {
|
|
||||||
method: 'GET',
|
private static getCached(key: string): string | null {
|
||||||
signal: AbortSignal.timeout(5000) // 5 second timeout
|
const entry = this.urlCache.get(key);
|
||||||
});
|
if (!entry) return null;
|
||||||
if (response.ok || response.status === 404) { // 404 is ok, means server is running
|
if (Date.now() > entry.expiresAt) {
|
||||||
results.localServer = {
|
this.urlCache.delete(key);
|
||||||
status: 'online',
|
return null;
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
// 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}`);
|
private static setCache(key: string, url: string): void {
|
||||||
return results;
|
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;
|
export default TrailerService;
|
||||||
|
|
|
||||||
641
src/services/youtubeExtractor.ts
Normal file
641
src/services/youtubeExtractor.ts
Normal file
|
|
@ -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 <Video> also works correctly.
|
||||||
|
* - Avoids the need for a Buffer/btoa polyfill (not guaranteed in Hermes).
|
||||||
|
*
|
||||||
|
* Uses expo-file-system which is already in the project's dependencies.
|
||||||
|
* Returns null if writing fails.
|
||||||
|
*/
|
||||||
|
async function writeDashManifestToFile(
|
||||||
|
videoFormat: InnertubeFormat,
|
||||||
|
audioFormat: InnertubeFormat,
|
||||||
|
videoId: string,
|
||||||
|
durationSeconds?: number
|
||||||
|
): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const FileSystem = await import('expo-file-system/legacy');
|
||||||
|
const cacheDir = FileSystem.cacheDirectory;
|
||||||
|
if (!cacheDir) return null;
|
||||||
|
|
||||||
|
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, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
const videoUrl = escapeXml(videoFormat.url!);
|
||||||
|
const audioUrl = escapeXml(audioFormat.url!);
|
||||||
|
|
||||||
|
// Use proper initRange/indexRange if available (YouTube adaptive streams have these)
|
||||||
|
// Without correct ranges ExoPlayer's DashMediaSource cannot parse the segment index.
|
||||||
|
// Fall back to range "0-0" only as last resort — ExoPlayer will attempt a range request.
|
||||||
|
const vInit = videoFormat.initRange
|
||||||
|
? `${videoFormat.initRange.start}-${videoFormat.initRange.end}`
|
||||||
|
: '0-0';
|
||||||
|
const vIndex = videoFormat.indexRange
|
||||||
|
? `${videoFormat.indexRange.start}-${videoFormat.indexRange.end}`
|
||||||
|
: '0-0';
|
||||||
|
const aInit = audioFormat.initRange
|
||||||
|
? `${audioFormat.initRange.start}-${audioFormat.initRange.end}`
|
||||||
|
: '0-0';
|
||||||
|
const aIndex = audioFormat.indexRange
|
||||||
|
? `${audioFormat.indexRange.start}-${audioFormat.indexRange.end}`
|
||||||
|
: '0-0';
|
||||||
|
|
||||||
|
const mpd = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<MPD xmlns="urn:mpeg:dash:schema:mpd:2011" type="static" mediaPresentationDuration="${mediaDurationISO}" minBufferTime="PT2S" profiles="urn:mpeg:dash:profile:isoff-on-demand:2011">
|
||||||
|
<Period duration="${mediaDurationISO}">
|
||||||
|
<AdaptationSet id="1" mimeType="${videoMime}" codecs="${videoCodec}" width="${width}" height="${height}" subsegmentAlignment="true" subsegmentStartsWithSAP="1">
|
||||||
|
<Representation id="v1" bandwidth="${videoBandwidth}" width="${width}" height="${height}">
|
||||||
|
<BaseURL>${videoUrl}</BaseURL>
|
||||||
|
<SegmentBase indexRange="${vIndex}">
|
||||||
|
<Initialization range="${vInit}"/>
|
||||||
|
</SegmentBase>
|
||||||
|
</Representation>
|
||||||
|
</AdaptationSet>
|
||||||
|
<AdaptationSet id="2" mimeType="${audioMime}" codecs="${audioCodec}" lang="en" subsegmentAlignment="true" subsegmentStartsWithSAP="1">
|
||||||
|
<Representation id="a1" bandwidth="${audioBandwidth}" audioSamplingRate="${audioSampleRate}">
|
||||||
|
<BaseURL>${audioUrl}</BaseURL>
|
||||||
|
<SegmentBase indexRange="${aIndex}">
|
||||||
|
<Initialization range="${aInit}"/>
|
||||||
|
</SegmentBase>
|
||||||
|
</Representation>
|
||||||
|
</AdaptationSet>
|
||||||
|
</Period>
|
||||||
|
</MPD>`;
|
||||||
|
|
||||||
|
const filePath = `${cacheDir}trailer_${videoId}.mpd`;
|
||||||
|
await FileSystem.writeAsStringAsync(filePath, mpd, { encoding: FileSystem.EncodingType.UTF8 });
|
||||||
|
logger.info('YouTubeExtractor', `DASH manifest written: ${filePath}`);
|
||||||
|
return filePath;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('YouTubeExtractor', 'writeDashManifestToFile failed:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core extractor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function fetchPlayerResponse(
|
||||||
|
videoId: string,
|
||||||
|
context: object,
|
||||||
|
userAgent: string
|
||||||
|
): Promise<InnertubePlayerResponse | null> {
|
||||||
|
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 parseMuxedFormats(playerResponse: InnertubePlayerResponse): InnertubeFormat[] {
|
||||||
|
const sd = playerResponse.streamingData;
|
||||||
|
if (!sd) return [];
|
||||||
|
const formats: InnertubeFormat[] = [];
|
||||||
|
for (const f of sd.formats ?? []) {
|
||||||
|
if (f.url) formats.push(f);
|
||||||
|
}
|
||||||
|
// Edge case: some adaptive formats are muxed
|
||||||
|
for (const f of sd.adaptiveFormats ?? []) {
|
||||||
|
if (f.url && isMuxedFormat(f)) formats.push(f);
|
||||||
|
}
|
||||||
|
return formats;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAdaptiveFormats(playerResponse: InnertubePlayerResponse): InnertubeFormat[] {
|
||||||
|
const sd = playerResponse.streamingData;
|
||||||
|
if (!sd) return [];
|
||||||
|
return (sd.adaptiveFormats ?? []).filter((f) => !!f.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBestMuxedStream(
|
||||||
|
muxedFormats: InnertubeFormat[],
|
||||||
|
adaptiveFormats: InnertubeFormat[] = []
|
||||||
|
): ExtractedStream | null {
|
||||||
|
// Prefer proper muxed streams (have both video and audio)
|
||||||
|
if (muxedFormats.length > 0) {
|
||||||
|
const mp4Formats = muxedFormats.filter(isVideoMp4);
|
||||||
|
const pool = mp4Formats.length > 0 ? mp4Formats : muxedFormats;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: if there are no muxed formats at all, use the best video-only
|
||||||
|
// adaptive stream (will have no audio, but at least something plays vs nothing)
|
||||||
|
if (adaptiveFormats.length > 0) {
|
||||||
|
const videoAdaptive = adaptiveFormats.filter(
|
||||||
|
(f) => f.url && f.qualityLabel && f.mimeType.startsWith('video/')
|
||||||
|
);
|
||||||
|
if (videoAdaptive.length > 0) {
|
||||||
|
const sorted = [...videoAdaptive].sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0));
|
||||||
|
const best = sorted[0];
|
||||||
|
logger.warn('YouTubeExtractor', `No muxed streams — using video-only adaptive itag=${best.itag} (no audio)`);
|
||||||
|
return {
|
||||||
|
url: best.url!,
|
||||||
|
quality: formatQualityLabel(best),
|
||||||
|
mimeType: best.mimeType,
|
||||||
|
itag: best.itag,
|
||||||
|
hasAudio: false,
|
||||||
|
hasVideo: true,
|
||||||
|
bitrate: best.bitrate ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export class YouTubeExtractor {
|
||||||
|
/**
|
||||||
|
* Extract a playable stream URL from a YouTube video ID or URL.
|
||||||
|
*
|
||||||
|
* On Android: attempts to build a DASH manifest from high-quality adaptive
|
||||||
|
* streams (up to 1080p) written to a temp .mpd file. Falls back to best
|
||||||
|
* muxed stream (max 720p) if adaptive streams are unavailable or file write fails.
|
||||||
|
*
|
||||||
|
* On iOS: always returns the best muxed stream. AVPlayer has no DASH support.
|
||||||
|
*/
|
||||||
|
static async extract(
|
||||||
|
videoIdOrUrl: string,
|
||||||
|
platform?: 'android' | 'ios'
|
||||||
|
): Promise<YouTubeExtractionResult | null> {
|
||||||
|
const videoId = extractVideoId(videoIdOrUrl);
|
||||||
|
if (!videoId) {
|
||||||
|
logger.warn('YouTubeExtractor', `Could not parse video ID from: ${videoIdOrUrl}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('YouTubeExtractor', `Extracting for videoId=${videoId} platform=${platform ?? 'unknown'}`);
|
||||||
|
|
||||||
|
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)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'WEB_EMBEDDED',
|
||||||
|
context: WEB_EMBEDDED_CONTEXT,
|
||||||
|
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let muxedFormats: InnertubeFormat[] = [];
|
||||||
|
let adaptiveFormats: 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}: playabilityStatus=${status}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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`);
|
||||||
|
muxedFormats = muxed;
|
||||||
|
adaptiveFormats = adaptive;
|
||||||
|
playerResponse = resp;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn('YouTubeExtractor', `${client.name} returned no usable formats`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (muxedFormats.length === 0 && adaptiveFormats.length === 0) {
|
||||||
|
logger.warn('YouTubeExtractor', `All clients failed for videoId=${videoId}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = playerResponse?.videoDetails;
|
||||||
|
const durationSeconds = details?.lengthSeconds ? parseInt(details.lengthSeconds, 10) : undefined;
|
||||||
|
|
||||||
|
let bestStream: ExtractedStream | null = null;
|
||||||
|
|
||||||
|
// Android: try DASH via temp .mpd file (works in TrailerPlayer AND TrailerModal)
|
||||||
|
if (platform === 'android' && adaptiveFormats.length > 0) {
|
||||||
|
const bestVideo = pickBestAdaptiveVideo(adaptiveFormats);
|
||||||
|
const bestAudio = pickBestAdaptiveAudio(adaptiveFormats);
|
||||||
|
|
||||||
|
if (bestVideo && bestAudio) {
|
||||||
|
const mpdFilePath = await writeDashManifestToFile(bestVideo, bestAudio, videoId, durationSeconds);
|
||||||
|
if (mpdFilePath) {
|
||||||
|
logger.info(
|
||||||
|
'YouTubeExtractor',
|
||||||
|
`DASH: video=${bestVideo.itag} (${formatQualityLabel(bestVideo)}), audio=${bestAudio.itag}`
|
||||||
|
);
|
||||||
|
bestStream = {
|
||||||
|
url: mpdFilePath, // file:// path, .mpd extension → ExoPlayer auto-detects DASH
|
||||||
|
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 file write failed — falling back to muxed');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
'YouTubeExtractor',
|
||||||
|
`No adaptive pair: video=${bestVideo?.itag ?? 'none'}, audio=${bestAudio?.itag ?? 'none'} — falling back to muxed`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// iOS or DASH fallback: use best muxed stream (or video-only adaptive as last resort)
|
||||||
|
if (!bestStream) {
|
||||||
|
bestStream = pickBestMuxedStream(muxedFormats, adaptiveFormats);
|
||||||
|
if (bestStream) {
|
||||||
|
logger.info('YouTubeExtractor', `Muxed: itag=${bestStream.itag} quality=${bestStream.quality}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const streams: ExtractedStream[] = muxedFormats.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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
streams,
|
||||||
|
bestStream,
|
||||||
|
videoId,
|
||||||
|
title: details?.title,
|
||||||
|
durationSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns just the best playable URL or null.
|
||||||
|
* Pass platform so the extractor can choose DASH vs muxed.
|
||||||
|
*/
|
||||||
|
static async getBestStreamUrl(
|
||||||
|
videoIdOrUrl: string,
|
||||||
|
platform?: 'android' | 'ios'
|
||||||
|
): Promise<string | null> {
|
||||||
|
const result = await this.extract(videoIdOrUrl, platform);
|
||||||
|
return result?.bestStream?.url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static parseVideoId(input: string): string | null {
|
||||||
|
return extractVideoId(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default YouTubeExtractor;
|
||||||
Loading…
Reference in a new issue