This commit is contained in:
tapframe 2025-09-13 17:39:50 +05:30
parent fe483ea7aa
commit e430dced9f
16 changed files with 158 additions and 2484 deletions

View file

@ -80,6 +80,7 @@ interface HeroSectionProps {
groupedEpisodes?: { [seasonNumber: number]: any[] };
dynamicBackgroundColor?: string;
handleBack: () => void;
tmdbId?: number | null;
}
// Ultra-optimized ActionButtons Component - minimal re-renders
@ -741,6 +742,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
groupedEpisodes,
dynamicBackgroundColor,
handleBack,
tmdbId,
}) => {
const { currentTheme } = useTheme();
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
@ -873,6 +875,12 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
const fetchTrailer = async () => {
if (!metadata?.name || !metadata?.year || !settings?.showTrailers || !isFocused) return;
// If we expect TMDB ID but don't have it yet, wait a bit more
if (!metadata?.tmdbId && metadata?.id?.startsWith('tmdb:')) {
logger.info('HeroSection', `Waiting for TMDB ID for ${metadata.name}`);
return;
}
setTrailerLoading(true);
setTrailerError(false);
setTrailerReady(false);
@ -881,12 +889,25 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
try {
// Use requestIdleCallback or setTimeout to prevent blocking main thread
const fetchWithDelay = () => {
TrailerService.getTrailerUrl(metadata.name, metadata.year)
// Extract TMDB ID if available
const tmdbIdString = tmdbId ? String(tmdbId) : undefined;
const contentType = type === 'series' ? 'tv' : 'movie';
// Debug logging to see what we have
logger.info('HeroSection', `Trailer request for ${metadata.name}:`, {
hasTmdbId: !!tmdbId,
tmdbId: tmdbId,
contentType,
metadataKeys: Object.keys(metadata || {}),
metadataId: metadata?.id
});
TrailerService.getTrailerUrl(metadata.name, metadata.year, tmdbIdString, contentType)
.then(url => {
if (url) {
const bestUrl = TrailerService.getBestFormatUrl(url);
setTrailerUrl(bestUrl);
logger.info('HeroSection', `Trailer URL loaded for ${metadata.name}`);
logger.info('HeroSection', `Trailer URL loaded for ${metadata.name}${tmdbId ? ` (TMDB: ${tmdbId})` : ''}`);
} else {
logger.info('HeroSection', `No trailer found for ${metadata.name}`);
}
@ -917,7 +938,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
alive = false;
try { if (timerId) clearTimeout(timerId); } catch (_e) {}
};
}, [metadata?.name, metadata?.year, settings?.showTrailers, isFocused]);
}, [metadata?.name, metadata?.year, tmdbId, settings?.showTrailers, isFocused]);
// Optimized shimmer animation for loading state
useEffect(() => {

View file

@ -102,6 +102,7 @@ const MetadataScreen: React.FC = () => {
loadingRecommendations,
setMetadata,
imdbId,
tmdbId,
} = useMetadata({ id, type, addonId });
// Optimized hooks with memoization and conditional loading
@ -627,6 +628,7 @@ const MetadataScreen: React.FC = () => {
groupedEpisodes={groupedEpisodes}
dynamicBackgroundColor={dynamicBackgroundColor}
handleBack={handleBack}
tmdbId={tmdbId}
/>
{/* Main Content - Optimized */}

View file

@ -1329,13 +1329,13 @@ export const StreamsScreen = () => {
if (selectedProvider === 'all') {
return true;
}
// In grouped mode, handle special 'grouped-plugins' filter
if (settings.streamDisplayMode === 'grouped' && selectedProvider === 'grouped-plugins') {
const isInstalledAddon = installedAddons.some(addon => addon.id === addonId);
return !isInstalledAddon; // Show only plugins (non-installed addons)
}
// Otherwise only show the selected provider
return addonId === selectedProvider;
})
@ -1343,7 +1343,7 @@ export const StreamsScreen = () => {
// Sort by Stremio addon installation order
const indexA = installedAddons.findIndex(addon => addon.id === addonIdA);
const indexB = installedAddons.findIndex(addon => addon.id === addonIdB);
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
@ -1357,14 +1357,23 @@ export const StreamsScreen = () => {
const pluginStreams: Stream[] = [];
const addonNames: string[] = [];
const pluginNames: string[] = [];
let addonOriginalCount = 0;
let pluginOriginalCount = 0;
filteredEntries.forEach(([addonId, { addonName, streams: providerStreams }]) => {
const isInstalledAddon = installedAddons.some(addon => addon.id === addonId);
// Count original streams before filtering
if (isInstalledAddon) {
addonOriginalCount += providerStreams.length;
} else {
pluginOriginalCount += providerStreams.length;
}
// Apply quality filtering and sorting to streams
const filteredStreams = filterStreamsByQuality(providerStreams);
const sortedStreams = sortStreams(filteredStreams);
if (isInstalledAddon) {
addonStreams.push(...sortedStreams);
if (!addonNames.includes(addonName)) {
@ -1377,43 +1386,66 @@ export const StreamsScreen = () => {
}
}
});
const sections = [];
if (addonStreams.length > 0) {
// Apply final sorting to the combined addon streams for quality-first mode
const finalSortedAddonStreams = settings.streamSortMode === 'quality-then-scraper' ?
const finalSortedAddonStreams = settings.streamSortMode === 'quality-then-scraper' ?
sortStreams(addonStreams) : addonStreams;
sections.push({
title: addonNames.join(', '),
addonId: 'grouped-addons',
data: finalSortedAddonStreams
});
} else if (addonOriginalCount > 0 && addonStreams.length === 0) {
// Show empty section with message for addons that had streams but all were filtered
sections.push({
title: addonNames.join(', '),
addonId: 'grouped-addons',
data: [{ isEmptyPlaceholder: true } as any],
isEmptyDueToQualityFilter: true
});
}
if (pluginStreams.length > 0) {
// Apply final sorting to the combined plugin streams for quality-first mode
const finalSortedPluginStreams = settings.streamSortMode === 'quality-then-scraper' ?
const finalSortedPluginStreams = settings.streamSortMode === 'quality-then-scraper' ?
sortStreams(pluginStreams) : pluginStreams;
sections.push({
title: localScraperService.getRepositoryName(),
addonId: 'grouped-plugins',
data: finalSortedPluginStreams
});
} else if (pluginOriginalCount > 0 && pluginStreams.length === 0) {
// Show empty section with message for plugins that had streams but all were filtered
sections.push({
title: localScraperService.getRepositoryName(),
addonId: 'grouped-plugins',
data: [{ isEmptyPlaceholder: true } as any],
isEmptyDueToQualityFilter: true
});
}
return sections;
} else {
// Use separate sections for each provider (current behavior)
return filteredEntries.map(([addonId, { addonName, streams: providerStreams }]) => {
// Count original streams before filtering
const originalCount = providerStreams.length;
// Apply quality filtering and sorting to streams
const filteredStreams = filterStreamsByQuality(providerStreams);
const sortedStreams = sortStreams(filteredStreams);
const isEmptyDueToQualityFilter = originalCount > 0 && sortedStreams.length === 0;
return {
title: addonName,
addonId,
data: sortedStreams
data: isEmptyDueToQualityFilter ? [{ isEmptyPlaceholder: true } as any] : sortedStreams,
isEmptyDueToQualityFilter
};
});
}
@ -1475,16 +1507,33 @@ export const StreamsScreen = () => {
const showStillFetching = streamsEmpty && loadElapsed >= 10000;
const renderItem = useCallback(({ item, index, section }: { item: Stream; index: number; section: any }) => {
const stream = item;
const renderItem = useCallback(({ item, index, section }: { item: any; index: number; section: any }) => {
// Handle empty sections due to quality filtering
if (item.isEmptyPlaceholder && section.isEmptyDueToQualityFilter) {
return (
<View style={styles.emptySectionContainer}>
<View style={styles.emptySectionContent}>
<MaterialIcons name="filter-list-off" size={32} color={colors.mediumEmphasis} />
<Text style={[styles.emptySectionTitle, { color: colors.mediumEmphasis }]}>
No streams available
</Text>
<Text style={[styles.emptySectionSubtitle, { color: colors.textMuted }]}>
All streams were filtered by your quality settings
</Text>
</View>
</View>
);
}
const stream = item as Stream;
// Don't show loading for individual streams that are already available and displayed
const isLoading = false; // If streams are being rendered, they're available and shouldn't be loading
return (
<View>
<StreamCard
stream={stream}
onPress={() => handleStreamPress(stream)}
<StreamCard
stream={stream}
onPress={() => handleStreamPress(stream)}
index={index}
isLoading={isLoading}
statusMessage={undefined}
@ -1494,11 +1543,11 @@ export const StreamsScreen = () => {
/>
</View>
);
}, [handleStreamPress, currentTheme, settings.showScraperLogos, scraperLogos]);
}, [handleStreamPress, currentTheme, settings.showScraperLogos, scraperLogos, colors.mediumEmphasis, colors.textMuted, styles.emptySectionContainer, styles.emptySectionContent, styles.emptySectionTitle, styles.emptySectionSubtitle]);
const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => {
const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => {
const isProviderLoading = loadingProviders[section.addonId];
return (
<View style={styles.sectionHeaderContainer}>
<View style={styles.sectionHeaderContent}>
@ -1728,7 +1777,13 @@ export const StreamsScreen = () => {
<SectionList
sections={sections}
keyExtractor={(item) => item.url || `${item.name}-${item.title}`}
keyExtractor={(item, index) => {
if (item && item.url) {
return item.url;
}
// For empty sections, use a special key
return `empty-${index}`;
}}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
stickySectionHeadersEnabled={false}
@ -1741,6 +1796,7 @@ export const StreamsScreen = () => {
showsVerticalScrollIndicator={false}
bounces={true}
overScrollMode="never"
ListEmptyComponent={null}
ListFooterComponent={
(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders ? (
<View style={styles.footerLoading}>
@ -2249,6 +2305,28 @@ const createStyles = (colors: any) => StyleSheet.create({
fontSize: 11,
fontWeight: '400',
},
emptySectionContainer: {
padding: 16,
alignItems: 'center',
justifyContent: 'center',
minHeight: 80,
},
emptySectionContent: {
alignItems: 'center',
justifyContent: 'center',
},
emptySectionTitle: {
fontSize: 14,
fontWeight: '600',
marginTop: 8,
textAlign: 'center',
},
emptySectionSubtitle: {
fontSize: 12,
marginTop: 4,
textAlign: 'center',
lineHeight: 16,
},
});
export default memo(StreamsScreen);
export default memo(StreamsScreen);

View file

@ -17,12 +17,14 @@ export class TrailerService {
* 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
*/
static async getTrailerUrl(title: string, year: number): Promise<string | null> {
static async getTrailerUrl(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): 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);
const localResult = await this.getTrailerFromLocalServer(title, year, tmdbId, type);
if (localResult) {
return localResult;
}
@ -35,19 +37,34 @@ export class TrailerService {
}
/**
* Fetches trailer from local server using auto-search (no YouTube URL needed)
* 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
*/
private static async getTrailerFromLocalServer(title: string, year: number): Promise<string | null> {
private static async getTrailerFromLocalServer(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
const url = `${this.AUTO_SEARCH_URL}?title=${encodeURIComponent(title)}&year=${year}`;
// Build URL with parameters
const params = new URLSearchParams();
logger.info('TrailerService', `Auto-searching trailer for: ${title} (${year})`);
// 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',
@ -275,9 +292,12 @@ export class TrailerService {
localServer: { status: 'online' | 'offline'; responseTime?: number };
xprimeServer: { status: 'online' | 'offline'; responseTime?: number };
}> {
const results = {
localServer: { status: 'offline' as const },
xprimeServer: { status: 'offline' as const }
const results: {
localServer: { status: 'online' | 'offline'; responseTime?: number };
xprimeServer: { status: 'online' | 'offline'; responseTime?: number };
} = {
localServer: { status: 'offline' },
xprimeServer: { status: 'offline' }
};
// Test local server

View file

@ -1,137 +0,0 @@
# 🚀 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
```

View file

@ -1,182 +0,0 @@
# 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

View file

@ -1,27 +0,0 @@
[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"

View file

@ -1,44 +0,0 @@
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' }),
};
};

View file

@ -1,26 +0,0 @@
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'
}),
};
};

View file

@ -1,148 +0,0 @@
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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,28 +0,0 @@
{
"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"
}

View file

@ -1,105 +0,0 @@
<!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>

View file

@ -1,288 +0,0 @@
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;

View file

@ -1,87 +0,0 @@
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);

View file

@ -1,58 +0,0 @@
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 };