mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-05 11:01:37 +00:00
local extractor
This commit is contained in:
parent
9d5bbaf3b1
commit
9a4af754f0
1 changed files with 110 additions and 324 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { logger } from '../utils/logger';
|
||||
import { YouTubeExtractor } from './youtubeExtractor';
|
||||
|
||||
export interface TrailerData {
|
||||
url: string;
|
||||
|
|
@ -6,373 +7,158 @@ export interface TrailerData {
|
|||
year: number;
|
||||
}
|
||||
|
||||
export class TrailerService {
|
||||
// Environment-configurable values (Expo public env)
|
||||
private static readonly ENV_LOCAL_BASE = process.env.EXPO_PUBLIC_TRAILER_LOCAL_BASE || 'http://46.62.173.157:3001';
|
||||
private static readonly ENV_LOCAL_TRAILER_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_TRAILER_PATH || '/trailer';
|
||||
private static readonly ENV_LOCAL_SEARCH_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_SEARCH_PATH || '/search-trailer';
|
||||
interface CacheEntry {
|
||||
url: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
private static readonly LOCAL_SERVER_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_TRAILER_PATH}`;
|
||||
private static readonly AUTO_SEARCH_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_SEARCH_PATH}`;
|
||||
private static readonly TIMEOUT = 20000; // 20 seconds
|
||||
export class TrailerService {
|
||||
// YouTube CDN URLs expire ~6h; cache for 5h
|
||||
private static readonly CACHE_TTL_MS = 5 * 60 * 60 * 1000;
|
||||
private static urlCache = new Map<string, CacheEntry>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetches trailer URL for a given title and year
|
||||
* @param title - The movie/series title
|
||||
* @param year - The release year
|
||||
* @param tmdbId - Optional TMDB ID for more accurate results
|
||||
* @param type - Optional content type ('movie' or 'tv')
|
||||
* @returns Promise<string | null> - The trailer URL or null if not found
|
||||
* Get a playable stream URL from a raw YouTube video ID (e.g. from TMDB).
|
||||
* Pure on-device extraction via Innertube. No server involved.
|
||||
*/
|
||||
static async getTrailerUrl(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
|
||||
logger.info('TrailerService', `getTrailerUrl requested: title="${title}", year=${year}, tmdbId=${tmdbId || 'n/a'}, type=${type || 'n/a'}`);
|
||||
return this.getTrailerFromLocalServer(title, year, tmdbId, type);
|
||||
static async getTrailerFromVideoId(
|
||||
youtubeVideoId: string,
|
||||
title?: string,
|
||||
year?: number
|
||||
): Promise<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 url = await YouTubeExtractor.getBestStreamUrl(youtubeVideoId);
|
||||
if (url) {
|
||||
logger.info('TrailerService', `On-device extraction succeeded for ${youtubeVideoId}`);
|
||||
this.setCache(youtubeVideoId, url);
|
||||
return url;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('TrailerService', `On-device extraction threw for ${youtubeVideoId}:`, err);
|
||||
}
|
||||
|
||||
logger.warn('TrailerService', `Extraction failed for ${youtubeVideoId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches trailer from local server using TMDB API or auto-search
|
||||
* @param title - The movie/series title
|
||||
* @param year - The release year
|
||||
* @param tmdbId - Optional TMDB ID for more accurate results
|
||||
* @param type - Optional content type ('movie' or 'tv')
|
||||
* @returns Promise<string | null> - The trailer URL or null if not found
|
||||
* Called by TrailerModal which has the full YouTube URL from TMDB.
|
||||
* Parses the video ID then delegates to getTrailerFromVideoId.
|
||||
*/
|
||||
private static async getTrailerFromLocalServer(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
|
||||
const startTime = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
|
||||
static async getTrailerFromYouTubeUrl(
|
||||
youtubeUrl: string,
|
||||
title?: string,
|
||||
year?: string
|
||||
): Promise<string | null> {
|
||||
logger.info('TrailerService', `getTrailerFromYouTubeUrl: ${youtubeUrl}`);
|
||||
|
||||
// Build URL with parameters
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Always send title and year for logging and fallback
|
||||
params.append('title', title);
|
||||
params.append('year', year.toString());
|
||||
|
||||
if (tmdbId) {
|
||||
params.append('tmdbId', tmdbId);
|
||||
params.append('type', type || 'movie');
|
||||
logger.info('TrailerService', `Using TMDB API for: ${title} (TMDB ID: ${tmdbId})`);
|
||||
} else {
|
||||
logger.info('TrailerService', `Auto-searching trailer for: ${title} (${year})`);
|
||||
}
|
||||
|
||||
const url = `${this.AUTO_SEARCH_URL}?${params.toString()}`;
|
||||
logger.info('TrailerService', `Local server request URL: ${url}`);
|
||||
logger.info('TrailerService', `Local server timeout set to ${this.TIMEOUT}ms`);
|
||||
logger.info('TrailerService', `Making fetch request to: ${url}`);
|
||||
|
||||
try {
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'User-Agent': 'Nuvio/1.0',
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// logger.info('TrailerService', `Fetch request completed. Response status: ${response.status}`);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const contentType = response.headers.get('content-type') || 'unknown';
|
||||
// logger.info('TrailerService', `Local server response: status=${response.status} ok=${response.ok} content-type=${contentType} elapsedMs=${elapsed}`);
|
||||
|
||||
// Read body as text first so we can log it even on non-200s
|
||||
let rawText = '';
|
||||
try {
|
||||
rawText = await response.text();
|
||||
if (rawText) {
|
||||
/*
|
||||
const preview = rawText.length > 200 ? `${rawText.slice(0, 200)}...` : rawText;
|
||||
logger.info('TrailerService', `Local server body preview: ${preview}`);
|
||||
*/
|
||||
} else {
|
||||
// logger.info('TrailerService', 'Local server body is empty');
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
||||
logger.warn('TrailerService', `Failed reading local server body text: ${msg}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('TrailerService', `Auto-search failed: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Attempt to parse JSON from the raw text
|
||||
let data: any = null;
|
||||
try {
|
||||
data = rawText ? JSON.parse(rawText) : null;
|
||||
// const keys = typeof data === 'object' && data !== null ? Object.keys(data).join(',') : typeof data;
|
||||
// logger.info('TrailerService', `Local server JSON parsed. Keys/Type: ${keys}`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
||||
logger.warn('TrailerService', `Failed to parse local server JSON: ${msg}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data.url || !this.isValidTrailerUrl(data.url)) {
|
||||
logger.warn('TrailerService', `Invalid trailer URL from auto-search: ${data.url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// logger.info('TrailerService', `Successfully found trailer: ${String(data.url).substring(0, 80)}...`);
|
||||
return data.url;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
logger.warn('TrailerService', `Auto-search request timed out after ${this.TIMEOUT}ms`);
|
||||
} else {
|
||||
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
logger.error('TrailerService', `Error in auto-search: ${msg}`);
|
||||
logger.error('TrailerService', `Error details:`, {
|
||||
name: (error as any)?.name,
|
||||
message: (error as any)?.message,
|
||||
stack: (error as any)?.stack,
|
||||
url: url
|
||||
});
|
||||
}
|
||||
const videoId = YouTubeExtractor.parseVideoId(youtubeUrl);
|
||||
if (!videoId) {
|
||||
logger.warn('TrailerService', `Could not parse video ID from: ${youtubeUrl}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getTrailerFromVideoId(
|
||||
videoId,
|
||||
title,
|
||||
year ? parseInt(year, 10) : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the provided string is a valid trailer URL
|
||||
* @param url - The URL to validate
|
||||
* @returns boolean - True if valid, false otherwise
|
||||
* Called by AppleTVHero and HeroSection which only have title/year/tmdbId.
|
||||
* These callers need to be updated to pass the YouTube video ID from TMDB
|
||||
* instead and call getTrailerFromVideoId directly. Until then this returns null.
|
||||
*/
|
||||
private static isValidTrailerUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Check if it's a valid HTTP/HTTPS URL
|
||||
if (!['http:', 'https:'].includes(urlObj.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for common video streaming domains/patterns
|
||||
const validDomains = [
|
||||
'theplatform.com',
|
||||
'youtube.com',
|
||||
'youtu.be',
|
||||
'vimeo.com',
|
||||
'dailymotion.com',
|
||||
'twitch.tv',
|
||||
'amazonaws.com',
|
||||
'cloudfront.net',
|
||||
'googlevideo.com', // Google's CDN for YouTube videos
|
||||
'sn-aigl6nzr.googlevideo.com', // Specific Google CDN servers
|
||||
'sn-aigl6nze.googlevideo.com',
|
||||
'sn-aigl6nsk.googlevideo.com',
|
||||
'sn-aigl6ns6.googlevideo.com'
|
||||
];
|
||||
|
||||
const hostname = urlObj.hostname.toLowerCase();
|
||||
const isValidDomain = validDomains.some(domain =>
|
||||
hostname.includes(domain) || hostname.endsWith(domain)
|
||||
);
|
||||
|
||||
// Special check for Google Video CDN (YouTube direct streaming URLs)
|
||||
const isGoogleVideoCDN = hostname.includes('googlevideo.com') ||
|
||||
hostname.includes('sn-') && hostname.includes('.googlevideo.com');
|
||||
|
||||
// Check for video file extensions or streaming formats
|
||||
const hasVideoFormat = /\.(mp4|m3u8|mpd|webm|mov|avi|mkv)$/i.test(urlObj.pathname) ||
|
||||
url.includes('formats=') ||
|
||||
url.includes('manifest') ||
|
||||
url.includes('playlist');
|
||||
|
||||
return isValidDomain || hasVideoFormat || isGoogleVideoCDN;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
static async getTrailerUrl(
|
||||
title: string,
|
||||
year: number,
|
||||
_tmdbId?: string,
|
||||
_type?: 'movie' | 'tv'
|
||||
): Promise<string | null> {
|
||||
logger.warn(
|
||||
'TrailerService',
|
||||
`getTrailerUrl called for "${title}" but no YouTube video ID was provided. ` +
|
||||
`Update caller to fetch the YouTube video ID from TMDB and call getTrailerFromVideoId instead.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the best video format URL from a multi-format URL
|
||||
* @param url - The trailer URL that may contain multiple formats
|
||||
* @returns string - The best format URL for mobile playback
|
||||
*/
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unchanged public helpers (API compatibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Legacy format URL helper kept for API compatibility. */
|
||||
static getBestFormatUrl(url: string): string {
|
||||
// If the URL contains format parameters, try to get the best one for mobile
|
||||
if (url.includes('formats=')) {
|
||||
// Prefer M3U (HLS) for better mobile compatibility
|
||||
if (url.includes('M3U')) {
|
||||
// Try to get M3U without encryption first, then with encryption
|
||||
const baseUrl = url.split('?')[0];
|
||||
const best = `${baseUrl}?formats=M3U+none,M3U+appleHlsEncryption`;
|
||||
logger.info('TrailerService', `Optimized format URL from M3U: ${best.substring(0, 80)}...`);
|
||||
return best;
|
||||
return `${url.split('?')[0]}?formats=M3U+none,M3U+appleHlsEncryption`;
|
||||
}
|
||||
// Fallback to MP4 if available
|
||||
if (url.includes('MPEG4')) {
|
||||
const baseUrl = url.split('?')[0];
|
||||
const best = `${baseUrl}?formats=MPEG4`;
|
||||
logger.info('TrailerService', `Optimized format URL from MPEG4: ${best.substring(0, 80)}...`);
|
||||
return best;
|
||||
return `${url.split('?')[0]}?formats=MPEG4`;
|
||||
}
|
||||
}
|
||||
|
||||
// Return the original URL if no format optimization is needed
|
||||
// logger.info('TrailerService', 'No format optimization applied');
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a trailer is available for the given title and year
|
||||
* @param title - The movie/series title
|
||||
* @param year - The release year
|
||||
* @returns Promise<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;
|
||||
static async isTrailerAvailable(videoId: string): Promise<boolean> {
|
||||
return (await this.getTrailerFromVideoId(videoId)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets trailer data with additional metadata
|
||||
* @param title - The movie/series title
|
||||
* @param year - The release year
|
||||
* @returns Promise<TrailerData | null> - Trailer data or null if not found
|
||||
*/
|
||||
static async getTrailerData(title: string, year: number): Promise<TrailerData | null> {
|
||||
logger.info('TrailerService', `getTrailerData for: ${title} (${year})`);
|
||||
const url = await this.getTrailerUrl(title, year);
|
||||
|
||||
if (!url) {
|
||||
logger.info('TrailerService', 'No trailer URL found for getTrailerData');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
url: this.getBestFormatUrl(url),
|
||||
title,
|
||||
year
|
||||
};
|
||||
logger.warn('TrailerService', `getTrailerData: no video ID available for "${title}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches trailer directly from a known YouTube URL
|
||||
* @param youtubeUrl - The YouTube URL to process
|
||||
* @param title - Optional title for logging/caching
|
||||
* @param year - Optional year for logging/caching
|
||||
* @returns Promise<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;
|
||||
}
|
||||
static setUseLocalServer(_useLocal: boolean): void {
|
||||
logger.info('TrailerService', 'setUseLocalServer: no server used, on-device only');
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch between local server (deprecated - always uses local server now)
|
||||
* @param useLocal - true for local server (always true now)
|
||||
*/
|
||||
static setUseLocalServer(useLocal: boolean): void {
|
||||
if (!useLocal) {
|
||||
logger.warn('TrailerService', 'XPrime API is no longer supported. Always using local server.');
|
||||
}
|
||||
logger.info('TrailerService', 'Using local server');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current server status
|
||||
* @returns object with server information
|
||||
*/
|
||||
static getServerStatus(): { usingLocal: boolean; localUrl: string } {
|
||||
return {
|
||||
usingLocal: true,
|
||||
localUrl: this.LOCAL_SERVER_URL,
|
||||
};
|
||||
return { usingLocal: false, localUrl: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Test local server and return its status
|
||||
* @returns Promise with server status information
|
||||
*/
|
||||
static async testServers(): Promise<{
|
||||
localServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||
}> {
|
||||
logger.info('TrailerService', 'Testing local server');
|
||||
const results: {
|
||||
localServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||
} = {
|
||||
localServer: { status: 'offline' }
|
||||
};
|
||||
return { localServer: { status: 'offline' } };
|
||||
}
|
||||
|
||||
// Test local server
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const response = await fetch(`${this.AUTO_SEARCH_URL}?title=test&year=2023`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(5000) // 5 second timeout
|
||||
});
|
||||
if (response.ok || response.status === 404) { // 404 is ok, means server is running
|
||||
results.localServer = {
|
||||
status: 'online',
|
||||
responseTime: Date.now() - startTime
|
||||
};
|
||||
logger.info('TrailerService', `Local server online. Response time: ${results.localServer.responseTime}ms`);
|
||||
}
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
logger.warn('TrailerService', `Local server test failed: ${msg}`);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static getCached(key: string): string | null {
|
||||
const entry = this.urlCache.get(key);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.urlCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.url;
|
||||
}
|
||||
|
||||
logger.info('TrailerService', `Server test results -> local: ${results.localServer.status}`);
|
||||
return results;
|
||||
private static setCache(key: string, url: string): void {
|
||||
this.urlCache.set(key, { url, expiresAt: Date.now() + this.CACHE_TTL_MS });
|
||||
if (this.urlCache.size > 100) {
|
||||
const oldest = this.urlCache.keys().next().value;
|
||||
if (oldest) this.urlCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TrailerService;
|
||||
export default TrailerService;
|
||||
|
|
|
|||
Loading…
Reference in a new issue