mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
trailer server test.
This commit is contained in:
parent
b03f550765
commit
fe483ea7aa
16 changed files with 2671 additions and 15 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit 37b9ae4298bd38b8119d45e8670f40bec2780a8b
|
||||
Subproject commit e6c0e8c0d75a8595031fe450e29db0c40969b5b8
|
||||
|
|
@ -11,12 +11,12 @@ import {
|
|||
RefreshControl,
|
||||
StatusBar,
|
||||
Platform,
|
||||
Image,
|
||||
ActivityIndicator,
|
||||
Modal,
|
||||
Dimensions,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
|
|
@ -1475,8 +1475,8 @@ const PluginsScreen: React.FC = () => {
|
|||
{scraper.logo ? (
|
||||
<Image
|
||||
source={{ uri: scraper.logo }}
|
||||
style={styles.scraperLogo}
|
||||
resizeMode="contain"
|
||||
style={styles.scraperLogo}
|
||||
contentFit="contain"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.scraperLogo} />
|
||||
|
|
|
|||
|
|
@ -7,8 +7,11 @@ export interface TrailerData {
|
|||
}
|
||||
|
||||
export class TrailerService {
|
||||
private static readonly BASE_URL = 'https://db.xprime.tv/trailers';
|
||||
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
|
||||
|
|
@ -17,13 +20,84 @@ export class TrailerService {
|
|||
* @returns Promise<string | null> - The trailer URL or null if not found
|
||||
*/
|
||||
static async getTrailerUrl(title: string, year: number): Promise<string | null> {
|
||||
if (this.USE_LOCAL_SERVER) {
|
||||
// Try local server first, fallback to XPrime if it fails
|
||||
const localResult = await this.getTrailerFromLocalServer(title, year);
|
||||
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 auto-search (no YouTube URL needed)
|
||||
* @param title - The movie/series title
|
||||
* @param year - The release year
|
||||
* @returns Promise<string | null> - The trailer URL or null if not found
|
||||
*/
|
||||
private static async getTrailerFromLocalServer(title: string, year: number): Promise<string | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
|
||||
|
||||
const url = `${this.BASE_URL}?title=${encodeURIComponent(title)}&year=${year}`;
|
||||
const url = `${this.AUTO_SEARCH_URL}?title=${encodeURIComponent(title)}&year=${year}`;
|
||||
|
||||
logger.info('TrailerService', `Fetching trailer for: ${title} (${year})`);
|
||||
logger.info('TrailerService', `Auto-searching trailer for: ${title} (${year})`);
|
||||
|
||||
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<string | null> - The trailer URL or null if not found
|
||||
*/
|
||||
private static async getTrailerFromXPrime(title: string, year: number): Promise<string | null> {
|
||||
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',
|
||||
|
|
@ -37,32 +111,32 @@ export class TrailerService {
|
|||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn('TrailerService', `Failed to fetch trailer: ${response.status} ${response.statusText}`);
|
||||
logger.warn('TrailerService', `XPrime failed: ${response.status} ${response.statusText}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const trailerUrl = await response.text();
|
||||
|
||||
// Validate the response is a valid URL
|
||||
if (!trailerUrl || !this.isValidTrailerUrl(trailerUrl.trim())) {
|
||||
logger.warn('TrailerService', `Invalid trailer URL received: ${trailerUrl}`);
|
||||
logger.warn('TrailerService', `Invalid trailer URL from XPrime: ${trailerUrl}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanUrl = trailerUrl.trim();
|
||||
logger.info('TrailerService', `Successfully fetched trailer URL: ${cleanUrl}`);
|
||||
logger.info('TrailerService', `Successfully fetched trailer from XPrime: ${cleanUrl}`);
|
||||
|
||||
return cleanUrl;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
logger.warn('TrailerService', 'Trailer fetch request timed out');
|
||||
logger.warn('TrailerService', 'XPrime request timed out');
|
||||
} else {
|
||||
logger.error('TrailerService', 'Error fetching trailer:', error);
|
||||
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
|
||||
|
|
@ -86,7 +160,12 @@ export class TrailerService {
|
|||
'dailymotion.com',
|
||||
'twitch.tv',
|
||||
'amazonaws.com',
|
||||
'cloudfront.net'
|
||||
'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();
|
||||
|
|
@ -94,13 +173,17 @@ export class TrailerService {
|
|||
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;
|
||||
return isValidDomain || hasVideoFormat || isGoogleVideoCDN;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -161,6 +244,78 @@ export class TrailerService {
|
|||
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: 'offline' as const },
|
||||
xprimeServer: { status: 'offline' as const }
|
||||
};
|
||||
|
||||
// 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;
|
||||
54
test-trailer-integration.js
Normal file
54
test-trailer-integration.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Quick test to verify TrailerService integration
|
||||
// Run this from the main Nuvio directory
|
||||
|
||||
const TrailerService = require('./src/services/trailerService.ts');
|
||||
|
||||
async function testTrailerIntegration() {
|
||||
console.log('🧪 Testing TrailerService Integration...\n');
|
||||
|
||||
// Test 1: Check server status
|
||||
console.log('1️⃣ Server Status:');
|
||||
const status = TrailerService.getServerStatus();
|
||||
console.log('✅ Using Local Server:', status.usingLocal);
|
||||
console.log('🔗 Local URL:', status.localUrl);
|
||||
console.log('🔗 XPrime URL:', status.xprimeUrl);
|
||||
|
||||
console.log('\n');
|
||||
|
||||
// Test 2: Try to fetch a trailer
|
||||
console.log('2️⃣ Testing trailer fetch...');
|
||||
try {
|
||||
const trailerUrl = await TrailerService.getTrailerUrl('Test Movie', 2023);
|
||||
if (trailerUrl) {
|
||||
console.log('✅ Trailer URL fetched successfully!');
|
||||
console.log('🔗 URL:', trailerUrl.substring(0, 80) + '...');
|
||||
} else {
|
||||
console.log('❌ No trailer URL returned');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Error fetching trailer:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
|
||||
// Test 3: Test trailer data
|
||||
console.log('3️⃣ Testing trailer data...');
|
||||
try {
|
||||
const trailerData = await TrailerService.getTrailerData('Test Movie', 2023);
|
||||
if (trailerData) {
|
||||
console.log('✅ Trailer data fetched successfully!');
|
||||
console.log('📹 Title:', trailerData.title);
|
||||
console.log('📅 Year:', trailerData.year);
|
||||
console.log('🔗 URL:', trailerData.url.substring(0, 80) + '...');
|
||||
} else {
|
||||
console.log('❌ No trailer data returned');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Error fetching trailer data:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n🏁 Integration test complete!');
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testTrailerIntegration().catch(console.error);
|
||||
137
trailer-server/DEPLOYMENT.md
Normal file
137
trailer-server/DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# 🚀 Deployment Guide
|
||||
|
||||
## Netlify Deployment
|
||||
|
||||
### Option 1: Deploy via Netlify CLI
|
||||
|
||||
1. **Install Netlify CLI:**
|
||||
```bash
|
||||
npm install -g netlify-cli
|
||||
```
|
||||
|
||||
2. **Login to Netlify:**
|
||||
```bash
|
||||
netlify login
|
||||
```
|
||||
|
||||
3. **Deploy:**
|
||||
```bash
|
||||
netlify deploy --prod --dir=.
|
||||
```
|
||||
|
||||
### Option 2: Deploy via GitHub
|
||||
|
||||
1. **Push to GitHub:**
|
||||
```bash
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial trailer server"
|
||||
git remote add origin https://github.com/yourusername/nuvio-trailer-server.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
2. **Connect to Netlify:**
|
||||
- Go to [netlify.com](https://netlify.com)
|
||||
- Click "New site from Git"
|
||||
- Connect your GitHub repository
|
||||
- Build settings will be auto-detected from `netlify.toml`
|
||||
|
||||
### Option 3: Manual Deploy
|
||||
|
||||
1. **Build the functions:**
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. **Upload to Netlify:**
|
||||
- Zip the entire folder
|
||||
- Upload via Netlify dashboard
|
||||
|
||||
## Important Notes
|
||||
|
||||
### ⚠️ yt-dlp Limitation
|
||||
**Netlify Functions don't support yt-dlp by default.** You have a few options:
|
||||
|
||||
1. **Use Railway/Render instead** (recommended)
|
||||
2. **Use a different approach** (see alternatives below)
|
||||
3. **Custom Netlify build** (complex)
|
||||
|
||||
### Alternative Platforms
|
||||
|
||||
#### Railway (Recommended)
|
||||
```bash
|
||||
# Install Railway CLI
|
||||
npm install -g @railway/cli
|
||||
|
||||
# Login and deploy
|
||||
railway login
|
||||
railway init
|
||||
railway up
|
||||
```
|
||||
|
||||
#### Render
|
||||
1. Connect GitHub repository
|
||||
2. Set build command: `npm install`
|
||||
3. Set start command: `npm start`
|
||||
4. Deploy
|
||||
|
||||
#### Vercel
|
||||
```bash
|
||||
# Install Vercel CLI
|
||||
npm install -g vercel
|
||||
|
||||
# Deploy
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
## Update Your App
|
||||
|
||||
After deployment, update your TrailerService:
|
||||
|
||||
```typescript
|
||||
// In src/services/trailerService.ts
|
||||
private static readonly LOCAL_SERVER_URL = 'https://your-deployed-url.netlify.app/trailer';
|
||||
```
|
||||
|
||||
## Testing Deployment
|
||||
|
||||
```bash
|
||||
# Test health endpoint
|
||||
curl https://your-deployed-url.netlify.app/health
|
||||
|
||||
# Test trailer endpoint
|
||||
curl "https://your-deployed-url.netlify.app/trailer?youtube_url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&title=Test&year=2023"
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Set these in your deployment platform:
|
||||
|
||||
- `NODE_ENV`: `production`
|
||||
- `PORT`: `3001` (if needed)
|
||||
|
||||
## Monitoring
|
||||
|
||||
- Check Netlify Functions dashboard for logs
|
||||
- Monitor function execution time
|
||||
- Watch for rate limiting issues
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues:
|
||||
|
||||
1. **yt-dlp not found**: Use Railway/Render instead of Netlify
|
||||
2. **Function timeout**: Increase timeout in platform settings
|
||||
3. **Rate limiting**: Implement better caching
|
||||
4. **CORS issues**: Check headers in functions
|
||||
|
||||
### Debug Commands:
|
||||
|
||||
```bash
|
||||
# Test locally
|
||||
npm test
|
||||
|
||||
# Check function logs
|
||||
netlify functions:list
|
||||
netlify functions:invoke trailer
|
||||
```
|
||||
182
trailer-server/README.md
Normal file
182
trailer-server/README.md
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
# Nuvio Trailer Server
|
||||
|
||||
A Node.js server that converts YouTube trailer URLs to direct streaming links using yt-dlp.
|
||||
|
||||
## Features
|
||||
|
||||
- 🎬 Convert YouTube URLs to direct streaming links
|
||||
- 💾 Intelligent caching (24-hour TTL)
|
||||
- 🚦 Rate limiting (10 requests/minute per IP)
|
||||
- 🔒 Security headers with Helmet
|
||||
- 📊 Health monitoring endpoint
|
||||
- 🧪 Built-in testing suite
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 16+
|
||||
- yt-dlp installed on your system
|
||||
|
||||
### Install yt-dlp
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install yt-dlp
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
pip install yt-dlp
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
pip install yt-dlp
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Clone/Navigate to the server directory:**
|
||||
```bash
|
||||
cd trailer-server
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Start the server:**
|
||||
```bash
|
||||
# Development mode (with auto-restart)
|
||||
npm run dev
|
||||
|
||||
# Production mode
|
||||
npm start
|
||||
```
|
||||
|
||||
The server will start on `http://localhost:3001`
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET /health
|
||||
Health check endpoint
|
||||
```bash
|
||||
curl http://localhost:3001/health
|
||||
```
|
||||
|
||||
### GET /trailer
|
||||
Get direct streaming URL for a YouTube trailer
|
||||
|
||||
**Parameters:**
|
||||
- `youtube_url` (required): YouTube URL of the trailer
|
||||
- `title` (optional): Movie/show title
|
||||
- `year` (optional): Release year
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl "http://localhost:3001/trailer?youtube_url=https://www.youtube.com/watch?v=example&title=Avengers&year=2019"
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"url": "https://direct-streaming-url.com/video.mp4",
|
||||
"title": "Avengers",
|
||||
"year": "2019",
|
||||
"source": "youtube",
|
||||
"cached": false,
|
||||
"timestamp": "2023-12-01T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /cache
|
||||
View cached trailers (for debugging)
|
||||
|
||||
### DELETE /cache
|
||||
Clear all cached trailers
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
This will test:
|
||||
- Health endpoint
|
||||
- Trailer fetching
|
||||
- Cache functionality
|
||||
- Rate limiting
|
||||
|
||||
## Integration with Nuvio App
|
||||
|
||||
Update your `TrailerService.ts` to use the local server:
|
||||
|
||||
```typescript
|
||||
// In src/services/trailerService.ts
|
||||
export class TrailerService {
|
||||
private static readonly BASE_URL = 'http://localhost:3001/trailer';
|
||||
|
||||
static async getTrailerUrl(title: string, year: number): Promise<string | null> {
|
||||
try {
|
||||
// You'll need to find the YouTube URL first
|
||||
const youtubeUrl = await this.findYouTubeTrailer(title, year);
|
||||
if (!youtubeUrl) return null;
|
||||
|
||||
const response = await fetch(
|
||||
`${this.BASE_URL}?youtube_url=${encodeURIComponent(youtubeUrl)}&title=${encodeURIComponent(title)}&year=${year}`
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
return data.url;
|
||||
} catch (error) {
|
||||
logger.error('TrailerService', 'Error fetching trailer:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `PORT`: Server port (default: 3001)
|
||||
- `NODE_ENV`: Environment (development/production)
|
||||
|
||||
## Deployment
|
||||
|
||||
### Netlify Functions
|
||||
1. Create `netlify/functions/trailer.js`
|
||||
2. Adapt the server code for serverless
|
||||
3. Deploy to Netlify
|
||||
|
||||
### Vercel
|
||||
1. Create `api/trailer.js`
|
||||
2. Adapt for Vercel's serverless functions
|
||||
3. Deploy to Vercel
|
||||
|
||||
### Railway/Render
|
||||
1. Push to GitHub
|
||||
2. Connect to Railway/Render
|
||||
3. Set environment variables
|
||||
4. Deploy
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**yt-dlp not found:**
|
||||
- Ensure yt-dlp is installed and in PATH
|
||||
- Try: `which yt-dlp` to verify installation
|
||||
|
||||
**Rate limited:**
|
||||
- Wait 1 minute or clear cache
|
||||
- Check rate limiting settings
|
||||
|
||||
**Trailer not found:**
|
||||
- Verify YouTube URL is valid
|
||||
- Check if video is available in your region
|
||||
- Try different quality settings
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
27
trailer-server/netlify.toml
Normal file
27
trailer-server/netlify.toml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
[build]
|
||||
command = "npm install"
|
||||
functions = "netlify/functions"
|
||||
publish = "public"
|
||||
|
||||
[functions]
|
||||
node_bundler = "esbuild"
|
||||
|
||||
[[redirects]]
|
||||
from = "/trailer"
|
||||
to = "/.netlify/functions/trailer"
|
||||
status = 200
|
||||
|
||||
[[redirects]]
|
||||
from = "/health"
|
||||
to = "/.netlify/functions/health"
|
||||
status = 200
|
||||
|
||||
[[redirects]]
|
||||
from = "/cache"
|
||||
to = "/.netlify/functions/cache"
|
||||
status = 200
|
||||
|
||||
[dev]
|
||||
command = "npm run dev"
|
||||
port = 3001
|
||||
publish = "public"
|
||||
44
trailer-server/netlify/functions/cache.js
Normal file
44
trailer-server/netlify/functions/cache.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
exports.handler = async (event, context) => {
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'GET, DELETE, OPTIONS',
|
||||
};
|
||||
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
if (event.httpMethod === 'GET') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
message: 'Cache endpoint available',
|
||||
timestamp: new Date().toISOString(),
|
||||
note: 'Cache is managed per function instance in Netlify'
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (event.httpMethod === 'DELETE') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
message: 'Cache cleared (per function instance)',
|
||||
timestamp: new Date().toISOString()
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 405,
|
||||
headers,
|
||||
body: JSON.stringify({ error: 'Method not allowed' }),
|
||||
};
|
||||
};
|
||||
26
trailer-server/netlify/functions/health.js
Normal file
26
trailer-server/netlify/functions/health.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
exports.handler = async (event, context) => {
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
};
|
||||
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: 'netlify',
|
||||
function: 'health'
|
||||
}),
|
||||
};
|
||||
};
|
||||
148
trailer-server/netlify/functions/trailer.js
Normal file
148
trailer-server/netlify/functions/trailer.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Simple in-memory cache for Netlify functions
|
||||
const cache = new Map();
|
||||
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
exports.handler = async (event, context) => {
|
||||
// Enable CORS
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
};
|
||||
|
||||
// Handle preflight requests
|
||||
if (event.httpMethod === 'OPTIONS') {
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { youtube_url, title, year } = event.queryStringParameters || {};
|
||||
|
||||
// Validate required parameters
|
||||
if (!youtube_url) {
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'youtube_url parameter is required'
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Create cache key
|
||||
const cacheKey = `trailer_${title}_${year}_${youtube_url}`;
|
||||
|
||||
// Check cache first
|
||||
const cachedResult = cache.get(cacheKey);
|
||||
if (cachedResult && (Date.now() - cachedResult.timestamp) < CACHE_TTL) {
|
||||
console.log(`🎯 Cache hit for: ${title} (${year})`);
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify(cachedResult.data),
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`🔍 Fetching trailer for: ${title} (${year})`);
|
||||
|
||||
// Use yt-dlp to get direct streaming URL
|
||||
// Note: yt-dlp needs to be available in the Netlify environment
|
||||
const command = `yt-dlp -f "best[height<=720][ext=mp4]/best[height<=720]/best" -g --no-playlist "${youtube_url}"`;
|
||||
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: 30000, // 30 second timeout
|
||||
maxBuffer: 1024 * 1024 // 1MB buffer
|
||||
});
|
||||
|
||||
if (stderr && !stderr.includes('WARNING')) {
|
||||
console.error('yt-dlp stderr:', stderr);
|
||||
}
|
||||
|
||||
const directUrl = stdout.trim();
|
||||
|
||||
if (!directUrl || !isValidUrl(directUrl)) {
|
||||
console.log(`❌ No valid URL found for: ${title} (${year})`);
|
||||
return {
|
||||
statusCode: 404,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Trailer not found or invalid URL'
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const result = {
|
||||
url: directUrl,
|
||||
title: title || 'Unknown',
|
||||
year: year || 'Unknown',
|
||||
source: 'youtube',
|
||||
cached: false,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
cache.set(cacheKey, {
|
||||
data: result,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
console.log(`✅ Successfully fetched trailer for: ${title} (${year})`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
headers,
|
||||
body: JSON.stringify(result),
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching trailer:', error);
|
||||
|
||||
if (error.code === 'TIMEOUT') {
|
||||
return {
|
||||
statusCode: 408,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Request timeout - video processing took too long'
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (error.message.includes('not found') || error.message.includes('unavailable')) {
|
||||
return {
|
||||
statusCode: 404,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Trailer not found'
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
error: 'Internal server error',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : 'Something went wrong'
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to validate URLs
|
||||
function isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
1317
trailer-server/package-lock.json
generated
Normal file
1317
trailer-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
trailer-server/package.json
Normal file
28
trailer-server/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "nuvio-trailer-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Trailer server for Nuvio app using yt-dlp",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "node test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0",
|
||||
"rate-limiter-flexible": "^2.4.1",
|
||||
"node-cache": "^5.1.2",
|
||||
"node-fetch": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"keywords": ["trailer", "yt-dlp", "streaming", "nuvio"],
|
||||
"author": "Nuvio Team",
|
||||
"license": "MIT"
|
||||
}
|
||||
105
trailer-server/public/index.html
Normal file
105
trailer-server/public/index.html
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nuvio Trailer Server</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.container {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 20px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.endpoint {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid #4CAF50;
|
||||
}
|
||||
.method {
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
.url {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.description {
|
||||
margin-top: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.status {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
font-size: 1.5em;
|
||||
color: #4CAF50;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🎬 Nuvio Trailer Server</h1>
|
||||
|
||||
<div class="status">
|
||||
✅ Server is running and ready!
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<div class="method">GET</div>
|
||||
<div class="url">/health</div>
|
||||
<div class="description">Health check endpoint to verify server status</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<div class="method">GET</div>
|
||||
<div class="url">/trailer?youtube_url=YOUTUBE_URL&title=TITLE&year=YEAR</div>
|
||||
<div class="description">Convert YouTube trailer URL to direct streaming link using yt-dlp</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<div class="method">GET</div>
|
||||
<div class="url">/cache</div>
|
||||
<div class="description">View cached trailers (for debugging)</div>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<div class="method">DELETE</div>
|
||||
<div class="url">/cache</div>
|
||||
<div class="description">Clear all cached trailers</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>🚀 Powered by yt-dlp and Express.js</p>
|
||||
<p>Built for Nuvio Streaming App</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
288
trailer-server/server.js
Normal file
288
trailer-server/server.js
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const { RateLimiterMemory } = require('rate-limiter-flexible');
|
||||
const NodeCache = require('node-cache');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const { searchYouTubeTrailer } = require('./youtube-search');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Cache configuration - cache trailer URLs for 24 hours
|
||||
const trailerCache = new NodeCache({
|
||||
stdTTL: 24 * 60 * 60, // 24 hours
|
||||
checkperiod: 60 * 60 // Check for expired keys every hour
|
||||
});
|
||||
|
||||
// Rate limiting - 10 requests per minute per IP
|
||||
const rateLimiter = new RateLimiterMemory({
|
||||
keyPrefix: 'trailer_api',
|
||||
points: 10, // Number of requests
|
||||
duration: 60, // Per 60 seconds
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Rate limiting middleware
|
||||
const rateLimiterMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
await rateLimiter.consume(req.ip);
|
||||
next();
|
||||
} catch (rejRes) {
|
||||
res.status(429).json({
|
||||
error: 'Too many requests',
|
||||
retryAfter: Math.round(rejRes.msBeforeNext / 1000) || 1
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
cache: {
|
||||
keys: trailerCache.keys().length,
|
||||
stats: trailerCache.getStats()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-search trailer endpoint (no YouTube URL needed)
|
||||
app.get('/search-trailer', rateLimiterMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { title, year } = req.query;
|
||||
|
||||
// Validate required parameters
|
||||
if (!title) {
|
||||
return res.status(400).json({
|
||||
error: 'title parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Create cache key
|
||||
const cacheKey = `search_${title}_${year}`;
|
||||
|
||||
// Check cache first
|
||||
const cachedResult = trailerCache.get(cacheKey);
|
||||
if (cachedResult) {
|
||||
console.log(`🎯 Cache hit for search: ${title} (${year})`);
|
||||
return res.json(cachedResult);
|
||||
}
|
||||
|
||||
console.log(`🔍 Auto-searching trailer for: ${title} (${year})`);
|
||||
|
||||
// Search for YouTube trailer
|
||||
const searchQuery = `${title} ${year || ''} official trailer`.trim();
|
||||
const youtubeUrl = await searchYouTubeTrailer(searchQuery);
|
||||
|
||||
if (!youtubeUrl) {
|
||||
console.log(`❌ No trailer found for: ${title} (${year})`);
|
||||
return res.status(404).json({
|
||||
error: 'Trailer not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Now get the direct streaming URL
|
||||
const command = `yt-dlp -f "best[height<=720][ext=mp4]/best[height<=720]/best" -g --no-playlist "${youtubeUrl}"`;
|
||||
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: 30000,
|
||||
maxBuffer: 1024 * 1024
|
||||
});
|
||||
|
||||
if (stderr && !stderr.includes('WARNING')) {
|
||||
console.error('yt-dlp stderr:', stderr);
|
||||
}
|
||||
|
||||
const directUrl = stdout.trim();
|
||||
|
||||
if (!directUrl || !isValidUrl(directUrl)) {
|
||||
console.log(`❌ No valid streaming URL found for: ${title} (${year})`);
|
||||
return res.status(404).json({
|
||||
error: 'Trailer not found or invalid URL'
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
url: directUrl,
|
||||
title: title || 'Unknown',
|
||||
year: year || 'Unknown',
|
||||
source: 'youtube',
|
||||
youtubeUrl: youtubeUrl,
|
||||
cached: false,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
trailerCache.set(cacheKey, result);
|
||||
console.log(`✅ Successfully found and processed trailer for: ${title} (${year})`);
|
||||
|
||||
res.json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error in auto-search:', error);
|
||||
|
||||
if (error.code === 'TIMEOUT') {
|
||||
return res.status(408).json({
|
||||
error: 'Request timeout - video processing took too long'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : 'Something went wrong'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Main trailer endpoint
|
||||
app.get('/trailer', rateLimiterMiddleware, async (req, res) => {
|
||||
try {
|
||||
const { youtube_url, title, year } = req.query;
|
||||
|
||||
// Validate required parameters
|
||||
if (!youtube_url) {
|
||||
return res.status(400).json({
|
||||
error: 'youtube_url parameter is required'
|
||||
});
|
||||
}
|
||||
|
||||
// Create cache key
|
||||
const cacheKey = `trailer_${title}_${year}_${youtube_url}`;
|
||||
|
||||
// Check cache first
|
||||
const cachedResult = trailerCache.get(cacheKey);
|
||||
if (cachedResult) {
|
||||
console.log(`🎯 Cache hit for: ${title} (${year})`);
|
||||
return res.json(cachedResult);
|
||||
}
|
||||
|
||||
console.log(`🔍 Fetching trailer for: ${title} (${year})`);
|
||||
|
||||
// Use yt-dlp to get direct streaming URL
|
||||
// Prefer MP4 format, max 720p for better compatibility
|
||||
const command = `yt-dlp -f "best[height<=720][ext=mp4]/best[height<=720]/best" -g --no-playlist "${youtube_url}"`;
|
||||
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: 30000, // 30 second timeout
|
||||
maxBuffer: 1024 * 1024 // 1MB buffer
|
||||
});
|
||||
|
||||
if (stderr && !stderr.includes('WARNING')) {
|
||||
console.error('yt-dlp stderr:', stderr);
|
||||
}
|
||||
|
||||
const directUrl = stdout.trim();
|
||||
|
||||
if (!directUrl || !isValidUrl(directUrl)) {
|
||||
console.log(`❌ No valid URL found for: ${title} (${year})`);
|
||||
return res.status(404).json({
|
||||
error: 'Trailer not found or invalid URL'
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
url: directUrl,
|
||||
title: title || 'Unknown',
|
||||
year: year || 'Unknown',
|
||||
source: 'youtube',
|
||||
cached: false,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
trailerCache.set(cacheKey, result);
|
||||
console.log(`✅ Successfully fetched trailer for: ${title} (${year})`);
|
||||
|
||||
res.json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching trailer:', error);
|
||||
|
||||
if (error.code === 'TIMEOUT') {
|
||||
return res.status(408).json({
|
||||
error: 'Request timeout - video processing took too long'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('not found') || error.message.includes('unavailable')) {
|
||||
return res.status(404).json({
|
||||
error: 'Trailer not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : 'Something went wrong'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Get cached trailers (for debugging)
|
||||
app.get('/cache', (req, res) => {
|
||||
const keys = trailerCache.keys();
|
||||
const cacheData = {};
|
||||
|
||||
keys.forEach(key => {
|
||||
cacheData[key] = trailerCache.get(key);
|
||||
});
|
||||
|
||||
res.json({
|
||||
count: keys.length,
|
||||
keys: keys,
|
||||
data: cacheData
|
||||
});
|
||||
});
|
||||
|
||||
// Clear cache endpoint (for maintenance)
|
||||
app.delete('/cache', (req, res) => {
|
||||
trailerCache.flushAll();
|
||||
res.json({
|
||||
message: 'Cache cleared successfully',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to validate URLs
|
||||
function isValidUrl(string) {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling middleware
|
||||
app.use((error, req, res, next) => {
|
||||
console.error('Unhandled error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error'
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({
|
||||
error: 'Endpoint not found',
|
||||
availableEndpoints: ['/health', '/trailer', '/cache']
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Trailer server running on port ${PORT}`);
|
||||
console.log(`📊 Health check: http://localhost:${PORT}/health`);
|
||||
console.log(`🎬 Trailer endpoint: http://localhost:${PORT}/trailer`);
|
||||
console.log(`💾 Cache endpoint: http://localhost:${PORT}/cache`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
87
trailer-server/test.js
Normal file
87
trailer-server/test.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
const fetch = require('node-fetch');
|
||||
|
||||
const SERVER_URL = 'http://localhost:3001';
|
||||
|
||||
async function testServer() {
|
||||
console.log('🧪 Testing Trailer Server...\n');
|
||||
|
||||
// Test 1: Health check
|
||||
console.log('1️⃣ Testing health endpoint...');
|
||||
try {
|
||||
const healthResponse = await fetch(`${SERVER_URL}/health`);
|
||||
const healthData = await healthResponse.json();
|
||||
console.log('✅ Health check passed:', healthData.status);
|
||||
console.log('📊 Cache stats:', healthData.cache);
|
||||
} catch (error) {
|
||||
console.log('❌ Health check failed:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
|
||||
// Test 2: Trailer endpoint with sample YouTube URL
|
||||
console.log('2️⃣ Testing trailer endpoint...');
|
||||
const testTrailer = {
|
||||
youtube_url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', // Rick Roll for testing
|
||||
title: 'Test Movie',
|
||||
year: '2023'
|
||||
};
|
||||
|
||||
try {
|
||||
const trailerResponse = await fetch(
|
||||
`${SERVER_URL}/trailer?${new URLSearchParams(testTrailer)}`
|
||||
);
|
||||
|
||||
if (trailerResponse.ok) {
|
||||
const trailerData = await trailerResponse.json();
|
||||
console.log('✅ Trailer fetch successful!');
|
||||
console.log('📹 Title:', trailerData.title);
|
||||
console.log('📅 Year:', trailerData.year);
|
||||
console.log('🔗 URL:', trailerData.url.substring(0, 50) + '...');
|
||||
console.log('⏰ Timestamp:', trailerData.timestamp);
|
||||
} else {
|
||||
const errorData = await trailerResponse.json();
|
||||
console.log('❌ Trailer fetch failed:', errorData.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Trailer test failed:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
|
||||
// Test 3: Cache endpoint
|
||||
console.log('3️⃣ Testing cache endpoint...');
|
||||
try {
|
||||
const cacheResponse = await fetch(`${SERVER_URL}/cache`);
|
||||
const cacheData = await cacheResponse.json();
|
||||
console.log('✅ Cache endpoint working');
|
||||
console.log('📦 Cached items:', cacheData.count);
|
||||
} catch (error) {
|
||||
console.log('❌ Cache test failed:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
|
||||
// Test 4: Rate limiting
|
||||
console.log('4️⃣ Testing rate limiting...');
|
||||
try {
|
||||
const promises = Array(12).fill().map(() =>
|
||||
fetch(`${SERVER_URL}/trailer?youtube_url=https://www.youtube.com/watch?v=dQw4w9WgXcQ&title=Test&year=2023`)
|
||||
);
|
||||
|
||||
const responses = await Promise.all(promises);
|
||||
const rateLimited = responses.some(r => r.status === 429);
|
||||
|
||||
if (rateLimited) {
|
||||
console.log('✅ Rate limiting working correctly');
|
||||
} else {
|
||||
console.log('⚠️ Rate limiting may not be working');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ Rate limiting test failed:', error.message);
|
||||
}
|
||||
|
||||
console.log('\n🏁 Testing complete!');
|
||||
}
|
||||
|
||||
// Run tests
|
||||
testServer().catch(console.error);
|
||||
58
trailer-server/youtube-search.js
Normal file
58
trailer-server/youtube-search.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Search YouTube for trailers using yt-dlp search functionality
|
||||
* @param {string} query - Search query (e.g., "Avengers Endgame 2019 official trailer")
|
||||
* @returns {Promise<string|null>} - YouTube URL or null if not found
|
||||
*/
|
||||
async function searchYouTubeTrailer(query) {
|
||||
try {
|
||||
console.log(`🔍 Searching YouTube for: ${query}`);
|
||||
|
||||
// Use yt-dlp to search YouTube and get the YouTube URL (not direct streaming URL)
|
||||
// --get-url returns direct streaming URLs, we need --get-id to get YouTube video ID
|
||||
const command = `yt-dlp --get-id --no-playlist "ytsearch1:${query}"`;
|
||||
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: 15000, // 15 second timeout
|
||||
maxBuffer: 1024 * 1024 // 1MB buffer
|
||||
});
|
||||
|
||||
if (stderr && !stderr.includes('WARNING')) {
|
||||
console.error('yt-dlp search stderr:', stderr);
|
||||
}
|
||||
|
||||
const videoId = stdout.trim();
|
||||
|
||||
if (!videoId || videoId.length !== 11) {
|
||||
console.log(`❌ No valid YouTube video ID found for: ${query}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const youtubeUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
console.log(`✅ Found YouTube URL: ${youtubeUrl}`);
|
||||
return youtubeUrl;
|
||||
} catch (error) {
|
||||
console.error('Error searching YouTube:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if the URL is a valid YouTube URL
|
||||
* @param {string} url - URL to validate
|
||||
* @returns {boolean} - True if valid YouTube URL
|
||||
*/
|
||||
function isValidYouTubeUrl(url) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname.includes('youtube.com') || urlObj.hostname.includes('youtu.be');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { searchYouTubeTrailer };
|
||||
Loading…
Reference in a new issue