mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-27 03:22:53 +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[] };
|
groupedEpisodes?: { [seasonNumber: number]: any[] };
|
||||||
dynamicBackgroundColor?: string;
|
dynamicBackgroundColor?: string;
|
||||||
handleBack: () => void;
|
handleBack: () => void;
|
||||||
|
tmdbId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ultra-optimized ActionButtons Component - minimal re-renders
|
// Ultra-optimized ActionButtons Component - minimal re-renders
|
||||||
|
|
@ -741,6 +742,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
groupedEpisodes,
|
groupedEpisodes,
|
||||||
dynamicBackgroundColor,
|
dynamicBackgroundColor,
|
||||||
handleBack,
|
handleBack,
|
||||||
|
tmdbId,
|
||||||
}) => {
|
}) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
|
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
|
||||||
|
|
@ -873,6 +875,12 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
const fetchTrailer = async () => {
|
const fetchTrailer = async () => {
|
||||||
if (!metadata?.name || !metadata?.year || !settings?.showTrailers || !isFocused) return;
|
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);
|
setTrailerLoading(true);
|
||||||
setTrailerError(false);
|
setTrailerError(false);
|
||||||
setTrailerReady(false);
|
setTrailerReady(false);
|
||||||
|
|
@ -881,12 +889,25 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
try {
|
try {
|
||||||
// Use requestIdleCallback or setTimeout to prevent blocking main thread
|
// Use requestIdleCallback or setTimeout to prevent blocking main thread
|
||||||
const fetchWithDelay = () => {
|
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 => {
|
.then(url => {
|
||||||
if (url) {
|
if (url) {
|
||||||
const bestUrl = TrailerService.getBestFormatUrl(url);
|
const bestUrl = TrailerService.getBestFormatUrl(url);
|
||||||
setTrailerUrl(bestUrl);
|
setTrailerUrl(bestUrl);
|
||||||
logger.info('HeroSection', `Trailer URL loaded for ${metadata.name}`);
|
logger.info('HeroSection', `Trailer URL loaded for ${metadata.name}${tmdbId ? ` (TMDB: ${tmdbId})` : ''}`);
|
||||||
} else {
|
} else {
|
||||||
logger.info('HeroSection', `No trailer found for ${metadata.name}`);
|
logger.info('HeroSection', `No trailer found for ${metadata.name}`);
|
||||||
}
|
}
|
||||||
|
|
@ -917,7 +938,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
alive = false;
|
alive = false;
|
||||||
try { if (timerId) clearTimeout(timerId); } catch (_e) {}
|
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
|
// Optimized shimmer animation for loading state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ const MetadataScreen: React.FC = () => {
|
||||||
loadingRecommendations,
|
loadingRecommendations,
|
||||||
setMetadata,
|
setMetadata,
|
||||||
imdbId,
|
imdbId,
|
||||||
|
tmdbId,
|
||||||
} = useMetadata({ id, type, addonId });
|
} = useMetadata({ id, type, addonId });
|
||||||
|
|
||||||
// Optimized hooks with memoization and conditional loading
|
// Optimized hooks with memoization and conditional loading
|
||||||
|
|
@ -627,6 +628,7 @@ const MetadataScreen: React.FC = () => {
|
||||||
groupedEpisodes={groupedEpisodes}
|
groupedEpisodes={groupedEpisodes}
|
||||||
dynamicBackgroundColor={dynamicBackgroundColor}
|
dynamicBackgroundColor={dynamicBackgroundColor}
|
||||||
handleBack={handleBack}
|
handleBack={handleBack}
|
||||||
|
tmdbId={tmdbId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content - Optimized */}
|
{/* Main Content - Optimized */}
|
||||||
|
|
|
||||||
|
|
@ -1357,10 +1357,19 @@ export const StreamsScreen = () => {
|
||||||
const pluginStreams: Stream[] = [];
|
const pluginStreams: Stream[] = [];
|
||||||
const addonNames: string[] = [];
|
const addonNames: string[] = [];
|
||||||
const pluginNames: string[] = [];
|
const pluginNames: string[] = [];
|
||||||
|
let addonOriginalCount = 0;
|
||||||
|
let pluginOriginalCount = 0;
|
||||||
|
|
||||||
filteredEntries.forEach(([addonId, { addonName, streams: providerStreams }]) => {
|
filteredEntries.forEach(([addonId, { addonName, streams: providerStreams }]) => {
|
||||||
const isInstalledAddon = installedAddons.some(addon => addon.id === addonId);
|
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
|
// Apply quality filtering and sorting to streams
|
||||||
const filteredStreams = filterStreamsByQuality(providerStreams);
|
const filteredStreams = filterStreamsByQuality(providerStreams);
|
||||||
const sortedStreams = sortStreams(filteredStreams);
|
const sortedStreams = sortStreams(filteredStreams);
|
||||||
|
|
@ -1389,7 +1398,16 @@ export const StreamsScreen = () => {
|
||||||
addonId: 'grouped-addons',
|
addonId: 'grouped-addons',
|
||||||
data: finalSortedAddonStreams
|
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) {
|
if (pluginStreams.length > 0) {
|
||||||
// Apply final sorting to the combined plugin streams for quality-first mode
|
// 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' ?
|
||||||
|
|
@ -1400,20 +1418,34 @@ export const StreamsScreen = () => {
|
||||||
addonId: 'grouped-plugins',
|
addonId: 'grouped-plugins',
|
||||||
data: finalSortedPluginStreams
|
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;
|
return sections;
|
||||||
} else {
|
} else {
|
||||||
// Use separate sections for each provider (current behavior)
|
// Use separate sections for each provider (current behavior)
|
||||||
return filteredEntries.map(([addonId, { addonName, streams: providerStreams }]) => {
|
return filteredEntries.map(([addonId, { addonName, streams: providerStreams }]) => {
|
||||||
|
// Count original streams before filtering
|
||||||
|
const originalCount = providerStreams.length;
|
||||||
|
|
||||||
// Apply quality filtering and sorting to streams
|
// Apply quality filtering and sorting to streams
|
||||||
const filteredStreams = filterStreamsByQuality(providerStreams);
|
const filteredStreams = filterStreamsByQuality(providerStreams);
|
||||||
const sortedStreams = sortStreams(filteredStreams);
|
const sortedStreams = sortStreams(filteredStreams);
|
||||||
|
|
||||||
|
const isEmptyDueToQualityFilter = originalCount > 0 && sortedStreams.length === 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: addonName,
|
title: addonName,
|
||||||
addonId,
|
addonId,
|
||||||
data: sortedStreams
|
data: isEmptyDueToQualityFilter ? [{ isEmptyPlaceholder: true } as any] : sortedStreams,
|
||||||
|
isEmptyDueToQualityFilter
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -1475,8 +1507,25 @@ export const StreamsScreen = () => {
|
||||||
const showStillFetching = streamsEmpty && loadElapsed >= 10000;
|
const showStillFetching = streamsEmpty && loadElapsed >= 10000;
|
||||||
|
|
||||||
|
|
||||||
const renderItem = useCallback(({ item, index, section }: { item: Stream; index: number; section: any }) => {
|
const renderItem = useCallback(({ item, index, section }: { item: any; index: number; section: any }) => {
|
||||||
const stream = item;
|
// 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
|
// 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
|
const isLoading = false; // If streams are being rendered, they're available and shouldn't be loading
|
||||||
|
|
||||||
|
|
@ -1494,9 +1543,9 @@ export const StreamsScreen = () => {
|
||||||
/>
|
/>
|
||||||
</View>
|
</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];
|
const isProviderLoading = loadingProviders[section.addonId];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -1728,7 +1777,13 @@ export const StreamsScreen = () => {
|
||||||
|
|
||||||
<SectionList
|
<SectionList
|
||||||
sections={sections}
|
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}
|
renderItem={renderItem}
|
||||||
renderSectionHeader={renderSectionHeader}
|
renderSectionHeader={renderSectionHeader}
|
||||||
stickySectionHeadersEnabled={false}
|
stickySectionHeadersEnabled={false}
|
||||||
|
|
@ -1741,6 +1796,7 @@ export const StreamsScreen = () => {
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
bounces={true}
|
bounces={true}
|
||||||
overScrollMode="never"
|
overScrollMode="never"
|
||||||
|
ListEmptyComponent={null}
|
||||||
ListFooterComponent={
|
ListFooterComponent={
|
||||||
(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders ? (
|
(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders ? (
|
||||||
<View style={styles.footerLoading}>
|
<View style={styles.footerLoading}>
|
||||||
|
|
@ -2249,6 +2305,28 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: '400',
|
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
|
* Fetches trailer URL for a given title and year
|
||||||
* @param title - The movie/series title
|
* @param title - The movie/series title
|
||||||
* @param year - The release year
|
* @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
|
* @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) {
|
if (this.USE_LOCAL_SERVER) {
|
||||||
// Try local server first, fallback to XPrime if it fails
|
// 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) {
|
if (localResult) {
|
||||||
return 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 title - The movie/series title
|
||||||
* @param year - The release year
|
* @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
|
* @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 {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
|
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, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -275,9 +292,12 @@ export class TrailerService {
|
||||||
localServer: { status: 'online' | 'offline'; responseTime?: number };
|
localServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||||
xprimeServer: { status: 'online' | 'offline'; responseTime?: number };
|
xprimeServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||||
}> {
|
}> {
|
||||||
const results = {
|
const results: {
|
||||||
localServer: { status: 'offline' as const },
|
localServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||||
xprimeServer: { status: 'offline' as const }
|
xprimeServer: { status: 'online' | 'offline'; responseTime?: number };
|
||||||
|
} = {
|
||||||
|
localServer: { status: 'offline' },
|
||||||
|
xprimeServer: { status: 'offline' }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Test local server
|
// 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