mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-23 11:07:44 +00:00
changes
This commit is contained in:
parent
fe483ea7aa
commit
e430dced9f
16 changed files with 158 additions and 2484 deletions
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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' }),
|
||||
};
|
||||
};
|
||||
|
|
@ -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'
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
1317
trailer-server/package-lock.json
generated
1317
trailer-server/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 };
|
||||
Loading…
Reference in a new issue