import { logger } from '../utils/logger'; export interface TrailerData { url: string; title: string; year: number; } export class TrailerService { private static readonly XPRIME_URL = 'https://db.xprime.tv/trailers'; private static readonly LOCAL_SERVER_URL = 'http://192.168.1.11:3001/trailer'; private static readonly AUTO_SEARCH_URL = 'http://192.168.1.11:3001/search-trailer'; private static readonly TIMEOUT = 10000; // 10 seconds private static readonly USE_LOCAL_SERVER = true; // Toggle between local and XPrime /** * Fetches trailer URL for a given title and year * @param title - The movie/series title * @param year - The release year * @param tmdbId - Optional TMDB ID for more accurate results * @param type - Optional content type ('movie' or 'tv') * @returns Promise - The trailer URL or null if not found */ static async getTrailerUrl(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise { if (this.USE_LOCAL_SERVER) { // Try local server first, fallback to XPrime if it fails const localResult = await this.getTrailerFromLocalServer(title, year, tmdbId, type); if (localResult) { return localResult; } logger.info('TrailerService', `Local server failed, falling back to XPrime for: ${title} (${year})`); return this.getTrailerFromXPrime(title, year); } else { return this.getTrailerFromXPrime(title, year); } } /** * Fetches trailer from local server using TMDB API or auto-search * @param title - The movie/series title * @param year - The release year * @param tmdbId - Optional TMDB ID for more accurate results * @param type - Optional content type ('movie' or 'tv') * @returns Promise - The trailer URL or null if not found */ private static async getTrailerFromLocalServer(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT); // 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()}`; const response = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json', 'User-Agent': 'Nuvio/1.0', }, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { logger.warn('TrailerService', `Auto-search 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 auto-search: ${data.url}`); return null; } logger.info('TrailerService', `Successfully found trailer: ${data.url.substring(0, 50)}...`); return data.url; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { logger.warn('TrailerService', 'Auto-search request timed out'); } else { logger.error('TrailerService', 'Error in auto-search:', error); } return null; // Return null to trigger XPrime fallback } } /** * Fetches trailer from XPrime API (original method) * @param title - The movie/series title * @param year - The release year * @returns Promise - The trailer URL or null if not found */ private static async getTrailerFromXPrime(title: string, year: number): Promise { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT); const url = `${this.XPRIME_URL}?title=${encodeURIComponent(title)}&year=${year}`; logger.info('TrailerService', `Fetching trailer from XPrime for: ${title} (${year})`); const response = await fetch(url, { method: 'GET', headers: { 'Accept': 'text/plain', 'User-Agent': 'Nuvio/1.0', }, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { logger.warn('TrailerService', `XPrime failed: ${response.status} ${response.statusText}`); return null; } const trailerUrl = await response.text(); if (!trailerUrl || !this.isValidTrailerUrl(trailerUrl.trim())) { logger.warn('TrailerService', `Invalid trailer URL from XPrime: ${trailerUrl}`); return null; } const cleanUrl = trailerUrl.trim(); logger.info('TrailerService', `Successfully fetched trailer from XPrime: ${cleanUrl}`); return cleanUrl; } catch (error) { if (error instanceof Error && error.name === 'AbortError') { logger.warn('TrailerService', 'XPrime request timed out'); } else { logger.error('TrailerService', 'Error fetching from XPrime:', error); } return null; } } /** * Validates if the provided string is a valid trailer URL * @param url - The URL to validate * @returns boolean - True if valid, false otherwise */ 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; } } /** * 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 */ 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]; return `${baseUrl}?formats=M3U+none,M3U+appleHlsEncryption`; } // Fallback to MP4 if available if (url.includes('MPEG4')) { const baseUrl = url.split('?')[0]; return `${baseUrl}?formats=MPEG4`; } } // Return the original URL if no format optimization is needed return url; } /** * Checks if a trailer is available for the given title and year * @param title - The movie/series title * @param year - The release year * @returns Promise - True if trailer is available */ static async isTrailerAvailable(title: string, year: number): Promise { const trailerUrl = await this.getTrailerUrl(title, year); return trailerUrl !== null; } /** * Gets trailer data with additional metadata * @param title - The movie/series title * @param year - The release year * @returns Promise - Trailer data or null if not found */ static async getTrailerData(title: string, year: number): Promise { const url = await this.getTrailerUrl(title, year); if (!url) { return null; } return { url: this.getBestFormatUrl(url), title, year }; } /** * Switch between local server and XPrime API * @param useLocal - true for local server, false for XPrime */ static setUseLocalServer(useLocal: boolean): void { (this as any).USE_LOCAL_SERVER = useLocal; logger.info('TrailerService', `Switched to ${useLocal ? 'local server' : 'XPrime API'}`); } /** * Get current server status * @returns object with server information */ static getServerStatus(): { usingLocal: boolean; localUrl: string; xprimeUrl: string; fallbackEnabled: boolean } { return { usingLocal: this.USE_LOCAL_SERVER, localUrl: this.LOCAL_SERVER_URL, xprimeUrl: this.XPRIME_URL, fallbackEnabled: true // Always enabled now }; } /** * Test both servers and return their status * @returns Promise with server status information */ static async testServers(): Promise<{ localServer: { status: 'online' | 'offline'; responseTime?: number }; xprimeServer: { status: 'online' | 'offline'; responseTime?: number }; }> { const results: { localServer: { status: 'online' | 'offline'; responseTime?: number }; xprimeServer: { status: 'online' | 'offline'; responseTime?: number }; } = { localServer: { status: 'offline' }, xprimeServer: { 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 }; } } catch (error) { logger.warn('TrailerService', 'Local server test failed:', error); } // Test XPrime server try { const startTime = Date.now(); const response = await fetch(`${this.XPRIME_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.xprimeServer = { status: 'online', responseTime: Date.now() - startTime }; } } catch (error) { logger.warn('TrailerService', 'XPrime server test failed:', error); } return results; } } export default TrailerService;