mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
Merge branch 'tapframe:main' into main
This commit is contained in:
commit
50b448e97e
10 changed files with 666 additions and 730 deletions
|
|
@ -11,7 +11,7 @@
|
|||
"apps": [
|
||||
{
|
||||
"name": "Nuvio",
|
||||
"bundleIdentifier": "com.nuvio.app",
|
||||
"bundleIdentifier": "com.nuvio.hub",
|
||||
"developerName": "Tapframe",
|
||||
"subtitle": "Media player and discovery app",
|
||||
"localizedDescription": "Nuvio is a media player and metadata discovery application for user-provided and user-installed sources.",
|
||||
|
|
@ -272,4 +272,4 @@
|
|||
}
|
||||
],
|
||||
"news": []
|
||||
}
|
||||
}
|
||||
|
|
@ -460,7 +460,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
// 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`
|
||||
`https://api.themoviedb.org/3/${contentType}/${tmdbId}/videos?api_key=${tmdbApiKey}`
|
||||
);
|
||||
|
||||
if (!alive) return;
|
||||
|
|
@ -475,9 +475,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const videosData = await videosRes.json();
|
||||
const results: any[] = videosData.results ?? [];
|
||||
|
||||
// Pick best YouTube trailer: official trailer > any trailer > teaser > any YouTube video
|
||||
// Pick best YouTube 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');
|
||||
|
|
|
|||
|
|
@ -1155,8 +1155,9 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
logger.info('HeroSection', `Fetching TMDB videos for ${metadata.name} (tmdbId: ${resolvedTmdbId})`);
|
||||
|
||||
// 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}/${resolvedTmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`
|
||||
`https://api.themoviedb.org/3/${contentType}/${resolvedTmdbId}/videos?api_key=${tmdbApiKey}`
|
||||
);
|
||||
|
||||
if (!alive) return;
|
||||
|
|
@ -1170,9 +1171,8 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
const videosData = await videosRes.json();
|
||||
const results: any[] = videosData.results ?? [];
|
||||
|
||||
// Pick best YouTube trailer: official trailer > any trailer > teaser > any YouTube video
|
||||
// Pick best YouTube 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');
|
||||
|
|
@ -1612,29 +1612,13 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Hidden preload trailer player - loads in background */}
|
||||
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && (
|
||||
<View style={[staticStyles.absoluteFill, { opacity: 0, pointerEvents: 'none' }]}>
|
||||
<TrailerPlayer
|
||||
key={`preload-${trailerUrl}`}
|
||||
trailerUrl={trailerUrl}
|
||||
autoPlay={false}
|
||||
muted={true}
|
||||
style={staticStyles.absoluteFill}
|
||||
hideLoadingSpinner={true}
|
||||
onLoad={handleTrailerPreloaded}
|
||||
onError={handleTrailerError}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Visible trailer player - rendered on top with fade transition and parallax */}
|
||||
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && trailerPreloaded && (
|
||||
{/* Single trailer player - starts hidden (opacity 0), fades in when ready */}
|
||||
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && (
|
||||
<Animated.View style={[staticStyles.absoluteFill, {
|
||||
opacity: trailerOpacity
|
||||
}, trailerParallaxStyle]}>
|
||||
<TrailerPlayer
|
||||
key={`visible-${trailerUrl}`}
|
||||
key={`trailer-${trailerUrl}`}
|
||||
ref={trailerVideoRef}
|
||||
trailerUrl={trailerUrl}
|
||||
autoPlay={globalTrailerPlaying}
|
||||
|
|
|
|||
|
|
@ -159,35 +159,28 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
|||
const handleVideoError = useCallback((error: any) => {
|
||||
logger.error('TrailerModal', 'Video error:', error);
|
||||
|
||||
// Check if this is a permission/network error that might benefit from retry
|
||||
const errorCode = error?.error?.code;
|
||||
const isRetryableError = errorCode === -1102 || errorCode === -1009 || errorCode === -1005;
|
||||
|
||||
if (isRetryableError && retryCount < 2) {
|
||||
// Silent retry - increment count and try again
|
||||
logger.info('TrailerModal', `Retrying video load (attempt ${retryCount + 1}/2)`);
|
||||
setRetryCount(prev => prev + 1);
|
||||
|
||||
// Small delay before retry to avoid rapid-fire attempts
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
// Force video to reload by changing the source briefly
|
||||
setTrailerUrl(null);
|
||||
setTimeout(() => {
|
||||
if (trailerUrl) {
|
||||
setTrailerUrl(trailerUrl);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, 1000);
|
||||
// Capture current URL before clearing it
|
||||
setTrailerUrl(current => {
|
||||
const urlToRestore = current;
|
||||
setTimeout(() => {
|
||||
setTrailerUrl(urlToRestore);
|
||||
}, 500);
|
||||
return null; // Clear first to force remount
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// After 2 retries or for non-retryable errors, show the error
|
||||
logger.error('TrailerModal', 'Video error after retries or non-retryable:', error);
|
||||
setError('Unable to play trailer. Please try again.');
|
||||
setLoading(false);
|
||||
}, [retryCount, trailerUrl]);
|
||||
}, [retryCount]);
|
||||
|
||||
const handleTrailerEnd = useCallback(() => {
|
||||
setIsPlaying(false);
|
||||
|
|
@ -270,7 +263,18 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
|||
<View style={styles.playerWrapper}>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
source={{ uri: trailerUrl }}
|
||||
source={(() => {
|
||||
const lower = (trailerUrl || '').toLowerCase();
|
||||
const looksLikeHls = /\.m3u8(\b|$)/.test(lower) || /hls|playlist|m3u/.test(lower);
|
||||
if (Platform.OS === 'android') {
|
||||
const headers = { 'User-Agent': 'Nuvio/1.0 (Android)' };
|
||||
if (looksLikeHls) {
|
||||
return { uri: trailerUrl, type: 'm3u8', headers } as any;
|
||||
}
|
||||
return { uri: trailerUrl, headers } as any;
|
||||
}
|
||||
return { uri: trailerUrl } as any;
|
||||
})()}
|
||||
style={styles.player}
|
||||
controls={true}
|
||||
paused={!isPlaying}
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
|
||||
if (type === 'movie') {
|
||||
// For movies, just fetch the main videos endpoint
|
||||
const videosEndpoint = `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=${tmdbApiKey}&language=en-US`;
|
||||
const videosEndpoint = `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=${tmdbApiKey}`;
|
||||
|
||||
logger.info('TrailersSection', `Fetching movie videos from: ${videosEndpoint}`);
|
||||
|
||||
|
|
@ -232,7 +232,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = 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=${tmdbApiKey}&language=en-US`;
|
||||
const tvVideosEndpoint = `https://api.themoviedb.org/3/tv/${tmdbId}/videos?api_key=${tmdbApiKey}`;
|
||||
const tvResponse = await fetch(tvVideosEndpoint);
|
||||
|
||||
if (tvResponse.ok) {
|
||||
|
|
@ -251,7 +251,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = 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=${tmdbApiKey}&language=en-US`)
|
||||
fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNum}/videos?api_key=${tmdbApiKey}`)
|
||||
.then(res => res.json())
|
||||
.then(data => ({
|
||||
seasonNumber: seasonNum,
|
||||
|
|
|
|||
|
|
@ -374,26 +374,18 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
<Video
|
||||
ref={videoRef}
|
||||
source={(() => {
|
||||
const androidHeaders = Platform.OS === 'android' ? { 'User-Agent': 'Nuvio/1.0 (Android)' } : {} as any;
|
||||
const lower = (trailerUrl || '').toLowerCase();
|
||||
const looksLikeHls = /\.m3u8(\b|$)/.test(lower) || /hls|applehlsencryption|playlist|m3u/.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') {
|
||||
const androidHeaders = { 'User-Agent': 'Nuvio/1.0 (Android)' };
|
||||
if (looksLikeHls) {
|
||||
return { uri: trailerUrl, type: 'm3u8', headers: androidHeaders } as any;
|
||||
}
|
||||
if (looksLikeDash) {
|
||||
return { uri: trailerUrl, type: 'mpd', headers: androidHeaders } as any;
|
||||
}
|
||||
return { uri: trailerUrl, headers: androidHeaders } as any;
|
||||
}
|
||||
return { uri: trailerUrl } as any;
|
||||
})()}
|
||||
style={[
|
||||
styles.video,
|
||||
contentType === 'movie' && styles.movieVideoScale,
|
||||
]}
|
||||
style={styles.video}
|
||||
resizeMode="cover"
|
||||
paused={!isPlaying}
|
||||
repeat={false}
|
||||
|
|
@ -524,9 +516,7 @@ const styles = StyleSheet.create({
|
|||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
movieVideoScale: {
|
||||
transform: [{ scale: 1.30 }], // Custom scale for movies to crop black bars
|
||||
},
|
||||
|
||||
videoOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ interface TrailerContextValue {
|
|||
const TrailerContext = createContext<TrailerContextValue | undefined>(undefined);
|
||||
|
||||
export const TrailerProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [isTrailerPlaying, setIsTrailerPlaying] = useState(true);
|
||||
const [isTrailerPlaying, setIsTrailerPlaying] = useState(false);
|
||||
|
||||
const pauseTrailer = useCallback(() => {
|
||||
setIsTrailerPlaying(false);
|
||||
|
|
|
|||
|
|
@ -289,7 +289,7 @@ const DonorCard: React.FC<DonorCardProps> = ({ donor, currentTheme, isTablet })
|
|||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
isTablet && styles.tabletContributions
|
||||
]}>
|
||||
{donor.amount.toFixed(2)} {donor.currency} · {formatDonationDate(donor.date)}
|
||||
{formatDonationDate(donor.date)}
|
||||
</Text>
|
||||
{donor.message ? (
|
||||
<Text style={[styles.donorMessage, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
|
|
@ -876,7 +876,7 @@ const ContributorsScreen: React.FC = () => {
|
|||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
isTablet && styles.tabletContributions
|
||||
]}>
|
||||
{entry.total.toFixed(2)} {entry.currency} · {entry.count} {entry.count === 1 ? 'donation' : 'donations'}
|
||||
{entry.count} {entry.count === 1 ? 'donation' : 'donations'}
|
||||
</Text>
|
||||
<Text style={[styles.donorMessage, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Rank #{entry.rank} · Last: {formatDonationDate(entry.lastDate)}
|
||||
|
|
|
|||
|
|
@ -14,20 +14,8 @@ interface CacheEntry {
|
|||
}
|
||||
|
||||
export class TrailerService {
|
||||
// ---- Remote server (fallback only) ----
|
||||
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';
|
||||
|
||||
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 SERVER_TIMEOUT = 20000;
|
||||
|
||||
// YouTube CDN URLs expire ~6h; cache for 5h
|
||||
private static readonly CACHE_TTL_MS = 5 * 60 * 60 * 1000;
|
||||
// Cache for 3 minutes — just enough to avoid re-extracting on quick re-renders
|
||||
private static readonly CACHE_TTL_MS = 30 * 1000;
|
||||
private static urlCache = new Map<string, CacheEntry>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -36,7 +24,7 @@ export class TrailerService {
|
|||
|
||||
/**
|
||||
* Get a playable stream URL from a raw YouTube video ID (e.g. from TMDB).
|
||||
* Tries on-device extraction first, falls back to remote server.
|
||||
* Uses on-device extraction only.
|
||||
*/
|
||||
static async getTrailerFromVideoId(
|
||||
youtubeVideoId: string,
|
||||
|
|
@ -53,30 +41,19 @@ export class TrailerService {
|
|||
return cached;
|
||||
}
|
||||
|
||||
// 1. On-device extraction via Innertube
|
||||
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}`);
|
||||
logger.info('TrailerService', `Extraction succeeded for ${youtubeVideoId}`);
|
||||
this.setCache(youtubeVideoId, url);
|
||||
return url;
|
||||
}
|
||||
logger.warn('TrailerService', `On-device extraction returned null for ${youtubeVideoId}`);
|
||||
logger.warn('TrailerService', `Extraction returned null for ${youtubeVideoId}`);
|
||||
} catch (err) {
|
||||
logger.warn('TrailerService', `On-device extraction threw for ${youtubeVideoId}:`, err);
|
||||
logger.warn('TrailerService', `Extraction threw for ${youtubeVideoId}:`, err);
|
||||
}
|
||||
|
||||
// 2. Server fallback
|
||||
logger.info('TrailerService', `Falling back to server for ${youtubeVideoId}`);
|
||||
const youtubeUrl = `https://www.youtube.com/watch?v=${youtubeVideoId}`;
|
||||
const serverUrl = await this.fetchFromServer(youtubeUrl, title, year?.toString());
|
||||
if (serverUrl) {
|
||||
this.setCache(youtubeVideoId, serverUrl);
|
||||
return serverUrl;
|
||||
}
|
||||
|
||||
logger.warn('TrailerService', `Both on-device and server failed for ${youtubeVideoId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -94,8 +71,7 @@ export class TrailerService {
|
|||
const videoId = YouTubeExtractor.parseVideoId(youtubeUrl);
|
||||
if (!videoId) {
|
||||
logger.warn('TrailerService', `Could not parse video ID from: ${youtubeUrl}`);
|
||||
// No video ID — try server directly with the raw URL
|
||||
return this.fetchFromServer(youtubeUrl, title, year);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getTrailerFromVideoId(
|
||||
|
|
@ -107,43 +83,24 @@ export class TrailerService {
|
|||
|
||||
/**
|
||||
* Called by AppleTVHero and HeroSection which only have title/year/tmdbId.
|
||||
* No YouTube video ID available — goes straight to server search.
|
||||
* Without a YouTube video ID there is nothing to extract — returns null.
|
||||
* Callers should ensure they pass a video ID via getTrailerFromVideoId instead.
|
||||
*/
|
||||
static async getTrailerUrl(
|
||||
title: string,
|
||||
year: number,
|
||||
tmdbId?: string,
|
||||
type?: 'movie' | 'tv'
|
||||
_tmdbId?: string,
|
||||
_type?: 'movie' | 'tv'
|
||||
): Promise<string | null> {
|
||||
logger.warn(
|
||||
'TrailerService',
|
||||
`getTrailerUrl called for "${title}" — no YouTube video ID, using server search`
|
||||
);
|
||||
|
||||
const cacheKey = `search:${title}:${year}:${tmdbId ?? ''}`;
|
||||
const cached = this.getCached(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const serverResult = await this.getTrailerFromServer(title, year, tmdbId, type);
|
||||
if (serverResult) {
|
||||
this.setCache(cacheKey, serverResult);
|
||||
}
|
||||
return serverResult;
|
||||
logger.warn('TrailerService', `getTrailerUrl called for "${title}" but no YouTube video ID available — cannot extract`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unchanged public helpers (API compatibility)
|
||||
// Public helpers (API compatibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static getBestFormatUrl(url: string): string {
|
||||
if (url.includes('formats=')) {
|
||||
if (url.includes('M3U')) {
|
||||
return `${url.split('?')[0]}?formats=M3U+none,M3U+appleHlsEncryption`;
|
||||
}
|
||||
if (url.includes('MPEG4')) {
|
||||
return `${url.split('?')[0]}?formats=MPEG4`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
|
|
@ -154,92 +111,21 @@ export class TrailerService {
|
|||
static async getTrailerData(title: string, year: number): Promise<TrailerData | null> {
|
||||
const url = await this.getTrailerUrl(title, year);
|
||||
if (!url) return null;
|
||||
return { url: this.getBestFormatUrl(url), title, year };
|
||||
return { url, title, year };
|
||||
}
|
||||
|
||||
static setUseLocalServer(_useLocal: boolean): void {
|
||||
logger.info('TrailerService', 'setUseLocalServer: server used as fallback only');
|
||||
}
|
||||
static setUseLocalServer(_useLocal: boolean): void {}
|
||||
|
||||
static getServerStatus(): { usingLocal: boolean; localUrl: string } {
|
||||
return { usingLocal: true, localUrl: this.LOCAL_SERVER_URL };
|
||||
return { usingLocal: false, localUrl: '' };
|
||||
}
|
||||
|
||||
static async testServers(): Promise<{
|
||||
localServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||
}> {
|
||||
try {
|
||||
const t = Date.now();
|
||||
const r = await fetch(`${this.AUTO_SEARCH_URL}?title=test&year=2023`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (r.ok || r.status === 404) {
|
||||
return { localServer: { status: 'online', responseTime: Date.now() - t } };
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
return { localServer: { status: 'offline' } };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private — server requests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static async getTrailerFromServer(
|
||||
title: string,
|
||||
year: number,
|
||||
tmdbId?: string,
|
||||
type?: 'movie' | 'tv'
|
||||
): Promise<string | null> {
|
||||
const params = new URLSearchParams({ title, year: year.toString() });
|
||||
if (tmdbId) {
|
||||
params.append('tmdbId', tmdbId);
|
||||
params.append('type', type ?? 'movie');
|
||||
}
|
||||
return this.doServerFetch(`${this.AUTO_SEARCH_URL}?${params}`);
|
||||
}
|
||||
|
||||
private static async fetchFromServer(
|
||||
youtubeUrl: string,
|
||||
title?: string,
|
||||
year?: string
|
||||
): Promise<string | null> {
|
||||
const params = new URLSearchParams({ youtube_url: youtubeUrl });
|
||||
if (title) params.append('title', title);
|
||||
if (year) params.append('year', year);
|
||||
return this.doServerFetch(`${this.LOCAL_SERVER_URL}?${params}`);
|
||||
}
|
||||
|
||||
private static async doServerFetch(url: string): Promise<string | null> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.SERVER_TIMEOUT);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { Accept: 'application/json', 'User-Agent': 'Nuvio/1.0' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) {
|
||||
logger.warn('TrailerService', `Server ${res.status} for ${url}`);
|
||||
return null;
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.url || !this.isValidTrailerUrl(data.url)) {
|
||||
logger.warn('TrailerService', `Server returned invalid URL: ${data.url}`);
|
||||
return null;
|
||||
}
|
||||
logger.info('TrailerService', `Server fallback succeeded: ${String(data.url).substring(0, 80)}`);
|
||||
return data.url as string;
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
logger.warn('TrailerService', `Server timed out: ${url}`);
|
||||
} else {
|
||||
logger.warn('TrailerService', `Server fetch error:`, err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private — cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -251,11 +137,21 @@ export class TrailerService {
|
|||
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;
|
||||
// Check the URL's own CDN expiry — googlevideo.com URLs carry an `expire`
|
||||
// param (Unix timestamp). Treat as stale if it expires within 2 minutes.
|
||||
if (entry.url.includes('googlevideo.com')) {
|
||||
try {
|
||||
const u = new URL(entry.url);
|
||||
const expire = u.searchParams.get('expire');
|
||||
if (expire) {
|
||||
const expiresAt = parseInt(expire, 10) * 1000;
|
||||
if (Date.now() > expiresAt - 2 * 60 * 1000) {
|
||||
logger.info('TrailerService', `Cached URL expired or expiring soon — re-extracting`);
|
||||
this.urlCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return entry.url;
|
||||
}
|
||||
|
|
@ -267,26 +163,6 @@ export class TrailerService {
|
|||
if (oldest) this.urlCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private — URL validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static isValidTrailerUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (!['http:', 'https:'].includes(u.protocol)) return false;
|
||||
const host = u.hostname.toLowerCase();
|
||||
return (
|
||||
['theplatform.com', 'youtube.com', 'youtu.be', 'vimeo.com',
|
||||
'dailymotion.com', 'twitch.tv', 'amazonaws.com',
|
||||
'cloudfront.net', 'googlevideo.com'].some(d => host.includes(d)) ||
|
||||
/\.(mp4|m3u8|mpd|webm|mov)(\?|$)/i.test(u.pathname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TrailerService;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue