Merge branch 'tapframe:main' into main

This commit is contained in:
aicon 2026-03-07 10:18:21 +01:00 committed by GitHub
commit 2d52b9916e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 3612 additions and 990 deletions

217
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,217 @@
name: Bug report
description: Report a reproducible bug (one per issue).
title: "[Bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug.
If we can reproduce it, we can usually fix it. This form is just to get the basics in one place.
- type: markdown
attributes:
value: |
## Quick checks
- type: checkboxes
id: checks
attributes:
label: Pre-flight checks
options:
- label: I searched existing issues and this is not a duplicate.
required: true
- label: I can reproduce this on the latest release or latest main build.
required: false
- label: This issue is limited to a single bug (not multiple unrelated problems).
required: true
- type: markdown
attributes:
value: |
## Version & device
- type: input
id: app_version
attributes:
label: App version / OTA update ID
description: Release version, commit hash, or OTA update ID. You can find your OTA update ID in Settings > App updates > Current version (hold to copy).
placeholder: "e.g. 1.2.3, main@abc1234, or an OTA ID"
validations:
required: true
- type: dropdown
id: install_method
attributes:
label: Install method
options:
- GitHub Release APK / IPA
- Expo Go
- Built from source
- Other (please describe below)
validations:
required: true
- type: dropdown
id: platform
attributes:
label: Platform
options:
- Android phone/tablet
- iOS (iPhone/iPad)
- Android emulator
- iOS Simulator
- Other (please describe below)
validations:
required: true
- type: input
id: device_model
attributes:
label: Device model
description: "Example: iPhone 15 Pro, Pixel 8, Galaxy S23 Ultra, iPad Pro, etc."
placeholder: "e.g. iPhone 15 Pro"
validations:
required: true
- type: input
id: os_version
attributes:
label: OS version
placeholder: "e.g. Android 14, iOS 17.2"
validations:
required: true
- type: dropdown
id: player_mode
attributes:
label: Player mode
description: If you are using an external player, most playback issues must be reported to that player instead.
options:
- Internal player (iOS: KSPlayer)
- Internal player (Android: ExoPlayer)
- Internal player (Android: MPV)
- External player
- Ask every time
- Not sure
validations:
required: true
- type: markdown
attributes:
value: |
## What happened?
- type: dropdown
id: area
attributes:
label: Area (tag)
description: Pick the closest match. It helps triage.
options:
- Playback (start/stop/buffering)
- Streams / Sources (selection, loading, errors)
- Next Episode / Auto-play
- Watch Progress (resume, watched state, history)
- Subtitles (styling, sync)
- Audio tracks
- UI / Layout / Animations
- Settings
- Sync (Trakt / SIMKL / remote)
- Downloads
- Other
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Exact steps. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links.
placeholder: |
1. Open ...
2. Navigate to ...
3. Press ...
4. Observe ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
placeholder: "What you expected to happen."
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
placeholder: "What actually happened (include any on-screen error text/codes)."
validations:
required: true
- type: dropdown
id: frequency
attributes:
label: Frequency
options:
- Always
- Often (more than 50%)
- Sometimes
- Rarely
- Once
validations:
required: true
- type: dropdown
id: regression
attributes:
label: Did this work before?
options:
- Not sure
- Yes, it used to work
- No, it never worked
validations:
required: true
- type: markdown
attributes:
value: |
## Extra context (optional)
- type: textarea
id: media_details
attributes:
label: Media details (optional)
description: Only include what you can safely share.
placeholder: |
- Content type: series/movie
- Season/Episode: S1E2
- Stream/source: (addon name / source label)
- Video format: (if known)
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs (optional but helpful)
description: |
Not required, but super helpful for playback/crash issues.
If you can, include a short snippet from Metro bundler, Xcode, or `adb logcat`.
render: shell
placeholder: |
adb logcat -d | tail -n 300
validations:
required: false
- type: textarea
id: extra
attributes:
label: Anything else? (optional)
description: Screenshots/recordings, related issues, workarounds, etc.
validations:
required: false

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Downloads / Releases
url: https://github.com/tapframe/NuvioMobile/releases
about: Grab the latest GitHub Release APK/IPA here.
- name: Documentation
url: https://github.com/tapframe/NuvioMobile/blob/main/README.md
about: Read the README for setup and usage details.

View file

@ -0,0 +1,78 @@
name: Feature request
description: Suggest an improvement or new feature.
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
One feature request per issue, please. The more real-world your use case is, the easier it is to evaluate.
Feature requests are reviewed as product proposals first.
Please do not open a pull request for a new feature, major UX change, or broad cosmetic update unless a maintainer has explicitly approved it first.
Unapproved feature PRs will usually be closed.
- type: dropdown
id: area
attributes:
label: Area (tag)
options:
- Playback
- Streams / Sources
- Next Episode / Auto-play
- Watch Progress
- Subtitles
- Audio
- UI / Layout / Animations
- Settings
- Sync (Trakt / SIMKL / remote)
- Downloads
- Other
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem statement
description: What problem are you trying to solve?
placeholder: "I want to be able to..."
validations:
required: true
- type: textarea
id: proposed
attributes:
label: Proposed solution
description: What would you like the app to do?
validations:
required: true
- type: dropdown
id: contribution_plan
attributes:
label: Are you planning to implement this yourself?
description: Major features are usually implemented in-house unless approved first.
options:
- No, this is only a proposal
- Maybe, but only if approved first
- Yes, but I understand implementation still needs maintainer approval
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered (optional)
description: Any workarounds or other approaches you considered.
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional context (optional)
description: Mockups, examples from other apps, etc.
validations:
required: false

43
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,43 @@
## Summary
<!-- What changed in this PR? -->
## PR type
<!-- Pick one and delete the others -->
- Bug fix
- Small maintenance improvement
- Docs fix
- Approved larger change (link approval below)
## Why
<!-- Why this change is needed. Link bug/issue/context. -->
## Policy check
<!-- Confirm these before requesting review -->
- [ ] This PR is not cosmetic only.
- [ ] This PR does not add a new major feature without prior approval.
- [ ] This PR is small in scope and focused on one problem.
- [ ] If this is a larger or directional change, I linked the issue where it was approved.
<!-- PRs that do not match this policy will usually be closed without merge. -->
## Testing
<!-- What you tested and how (manual + automated). -->
- [ ] iOS tested
- [ ] Android tested
## Screenshots / Video (UI changes only)
<!-- If UI changed, add before/after screenshots or a short clip. -->
## Breaking changes
<!-- Any breaking behavior/config/schema changes? If none, write: None -->
## Linked issues
<!-- Example: Fixes #123 -->

80
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,80 @@
# Contributing
Thanks for helping improve NuvioMobile.
## PR policy
Pull requests are currently intended for:
- Reproducible bug fixes
- Small stability improvements
- Minor maintenance work
- Small documentation fixes that improve accuracy
Pull requests are generally **not** accepted for:
- New major features
- Product direction changes
- Large UX / UI redesigns
- Cosmetic-only changes
- Refactors without a clear user-facing or maintenance benefit
For feature ideas and bigger changes, please open an issue first. Feature implementation is usually kept in-house unless it has been discussed and explicitly approved beforehand.
## Where to ask questions
- Use **Issues** for bugs, feature requests, setup help, and general support.
## Bug reports (rules)
To keep issues fixable, bug reports should include:
- App version or OTA update ID (Settings > App updates > Current version, hold to copy)
- Platform + device model + OS version (Android/iOS)
- Install method (release APK/IPA / Expo Go / built from source)
- Steps to reproduce (exact steps)
- Expected vs actual behavior
- Frequency (always/sometimes/once)
Logs are **optional**, but they help a lot for playback/crash issues.
### How to capture logs (optional)
If you can, reproduce the issue once, then attach a short log snippet from around the time it happened:
For Android:
```sh
adb logcat -d | tail -n 300
```
For iOS/Metro:
```sh
# Copy from your Metro bundler output or Xcode console
```
If the issue is a crash, also include any stack trace shown by Android Studio, Xcode, or `adb logcat`.
## Feature requests (rules)
Please include:
- The problem you are solving (use case)
- Your proposed solution
- Alternatives considered (if any)
Opening a feature request does **not** mean a pull request will be accepted for it. If the feature affects product scope, UX direction, or adds a significant new surface area, do not start implementation unless a maintainer explicitly approves it first.
## Before opening a PR
Please make sure your PR is all of the following:
- Small in scope
- Focused on one problem
- Clearly aligned with the current direction of the project
- Not cosmetic-only
- Not a new major feature unless it was discussed and approved first
PRs that do not fit this policy will usually be closed without merge so review time can stay focused on bugs, regressions, and small improvements.
## One issue per problem
Please open separate issues for separate bugs/features. It makes tracking, fixing, and closing issues much faster.

View file

@ -54,7 +54,7 @@ Nuvio functions solely as a client-side interface for browsing metadata and play
Nuvio is not affiliated with any third-party extensions, catalogs, sources, or content providers. It does not host, store, or distribute any media content.
For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://tapframe.github.io/NuvioStreaming/#legal)**.
For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://nuvioapp.space/legal)**.
## Built With

View file

@ -11,7 +11,7 @@
"apps": [
{
"name": "Nuvio",
"bundleIdentifier": "com.nuvio.app",
"bundleIdentifier": "com.nuvio.hub",
"developerName": "Tapframe",
"subtitle": "Media player and discovery app",
"localizedDescription": "Nuvio is a media player and metadata discovery application for user-provided and user-installed sources.",
@ -272,4 +272,4 @@
}
],
"news": []
}
}

View file

@ -13,6 +13,7 @@ import {
} from 'react-native';
import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { TMDBService } from '../../services/tmdbService';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image';
@ -440,35 +441,68 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
thumbnailOpacity.value = withTiming(1, { duration: 300 });
try {
// Extract year from metadata
const year = currentItem.releaseInfo
? parseInt(currentItem.releaseInfo.split('-')[0], 10)
: new Date().getFullYear();
// Extract TMDB ID if available
const tmdbId = currentItem.id?.startsWith('tmdb:')
? currentItem.id.replace('tmdb:', '')
: undefined;
if (!tmdbId) {
logger.info('[AppleTVHero] No TMDB ID for:', currentItem.name, '- skipping trailer');
setTrailerUrl(null);
setTrailerLoading(false);
return;
}
const contentType = currentItem.type === 'series' ? 'tv' : 'movie';
logger.info('[AppleTVHero] Fetching trailer for:', currentItem.name, year, tmdbId);
logger.info('[AppleTVHero] Fetching TMDB videos for:', currentItem.name, 'tmdbId:', tmdbId);
const url = await TrailerService.getTrailerUrl(
currentItem.name,
year,
tmdbId,
contentType
// Fetch video list from TMDB to get the YouTube video ID
const tmdbApiKey = await TMDBService.getInstance().getApiKey();
const videosRes = await fetch(
`https://api.themoviedb.org/3/${contentType}/${tmdbId}/videos?api_key=${tmdbApiKey}`
);
if (!alive) return;
if (!videosRes.ok) {
logger.warn('[AppleTVHero] TMDB videos fetch failed:', videosRes.status);
setTrailerUrl(null);
setTrailerLoading(false);
return;
}
const videosData = await videosRes.json();
const results: any[] = videosData.results ?? [];
// Pick best YouTube trailer: any trailer > teaser > any YouTube video
const pick =
results.find((v) => v.site === 'YouTube' && v.type === 'Trailer') ??
results.find((v) => v.site === 'YouTube' && v.type === 'Teaser') ??
results.find((v) => v.site === 'YouTube');
if (!alive) return;
if (!pick) {
logger.info('[AppleTVHero] No YouTube video found for:', currentItem.name);
setTrailerUrl(null);
setTrailerLoading(false);
return;
}
logger.info('[AppleTVHero] Extracting stream for videoId:', pick.key, currentItem.name);
const url = await TrailerService.getTrailerFromVideoId(
pick.key,
currentItem.name
);
if (!alive) return;
if (url) {
const bestUrl = TrailerService.getBestFormatUrl(url);
setTrailerUrl(bestUrl);
// logger.info('[AppleTVHero] Trailer URL loaded:', bestUrl);
setTrailerUrl(url);
} else {
logger.info('[AppleTVHero] No trailer found for:', currentItem.name);
logger.info('[AppleTVHero] No stream extracted for:', currentItem.name);
setTrailerUrl(null);
}
} catch (error) {
@ -491,10 +525,17 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
}, [currentItem, currentIndex]); // Removed settings?.showTrailers from dependencies
// Handle trailer preloaded
// FIX: Set global trailer playing to true HERE — before the visible player mounts —
// so that when the visible player's autoPlay prop is evaluated it is already true,
// eliminating the race condition that previously caused the global state effect in
// TrailerPlayer to immediately pause the video on first render.
const handleTrailerPreloaded = useCallback(() => {
if (isFocused && !isOutOfView && !trailerShouldBePaused) {
setTrailerPlaying(true);
}
setTrailerPreloaded(true);
logger.info('[AppleTVHero] Trailer preloaded successfully');
}, []);
}, [isFocused, isOutOfView, trailerShouldBePaused, setTrailerPlaying]);
// Handle trailer ready to play
const handleTrailerReady = useCallback(() => {
@ -1078,7 +1119,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
key={`visible-${trailerUrl}`}
ref={trailerVideoRef}
trailerUrl={trailerUrl}
autoPlay={globalTrailerPlaying}
autoPlay={!trailerShouldBePaused}
muted={trailerMuted}
style={StyleSheet.absoluteFillObject}
hideLoadingSpinner={true}

File diff suppressed because it is too large Load diff

View file

@ -1129,12 +1129,14 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
useEffect(() => {
let alive = true as boolean;
let timerId: any = null;
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}`);
const fetchTrailer = async () => {
if (!metadata?.name || !settings?.showTrailers || !isFocused) return;
// Need a TMDB ID to look up the YouTube video ID
const resolvedTmdbId = tmdbId ? String(tmdbId) : undefined;
if (!resolvedTmdbId) {
logger.info('HeroSection', `No TMDB ID for ${metadata.name} - skipping trailer`);
return;
}
@ -1142,52 +1144,68 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
setTrailerError(false);
setTrailerReady(false);
setTrailerPreloaded(false);
startedOnReadyRef.current = false;
try {
// Use requestIdleCallback or setTimeout to prevent blocking main thread
const fetchWithDelay = () => {
// Extract TMDB ID if available
const tmdbIdString = tmdbId ? String(tmdbId) : undefined;
// Small delay to avoid blocking the UI render
timerId = setTimeout(async () => {
if (!alive) return;
try {
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
});
logger.info('HeroSection', `Fetching TMDB videos for ${metadata.name} (tmdbId: ${resolvedTmdbId})`);
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}${tmdbId ? ` (TMDB: ${tmdbId})` : ''}`);
} else {
logger.info('HeroSection', `No trailer found for ${metadata.name}`);
}
})
.catch(error => {
logger.error('HeroSection', 'Error fetching trailer:', error);
setTrailerError(true);
})
.finally(() => {
setTrailerLoading(false);
});
};
// Fetch video list from TMDB to get the YouTube video ID
const tmdbApiKey = await TMDBService.getInstance().getApiKey();
const videosRes = await fetch(
`https://api.themoviedb.org/3/${contentType}/${resolvedTmdbId}/videos?api_key=${tmdbApiKey}`
);
// Delay trailer fetch to prevent blocking UI
timerId = setTimeout(() => {
if (!alive) return;
fetchWithDelay();
}, 100);
} catch (error) {
logger.error('HeroSection', 'Error in trailer fetch setup:', error);
setTrailerError(true);
setTrailerLoading(false);
}
if (!videosRes.ok) {
logger.warn('HeroSection', `TMDB videos fetch failed: ${videosRes.status} for ${metadata.name}`);
setTrailerLoading(false);
return;
}
const videosData = await videosRes.json();
const results: any[] = videosData.results ?? [];
// Pick best YouTube trailer: any trailer > teaser > any YouTube video
const pick =
results.find((v) => v.site === 'YouTube' && v.type === 'Trailer') ??
results.find((v) => v.site === 'YouTube' && v.type === 'Teaser') ??
results.find((v) => v.site === 'YouTube');
if (!alive) return;
if (!pick) {
logger.info('HeroSection', `No YouTube video found for ${metadata.name}`);
setTrailerLoading(false);
return;
}
logger.info('HeroSection', `Extracting stream for videoId: ${pick.key} (${metadata.name})`);
const url = await TrailerService.getTrailerFromVideoId(pick.key, metadata.name);
if (!alive) return;
if (url) {
setTrailerUrl(url);
logger.info('HeroSection', `Trailer loaded for ${metadata.name}`);
} else {
logger.info('HeroSection', `No stream extracted for ${metadata.name}`);
}
} catch (error) {
if (!alive) return;
logger.error('HeroSection', 'Error fetching trailer:', error);
setTrailerError(true);
} finally {
if (alive) setTrailerLoading(false);
}
}, 100);
};
fetchTrailer();
@ -1195,7 +1213,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
alive = false;
try { if (timerId) clearTimeout(timerId); } catch (_e) { }
};
}, [metadata?.name, metadata?.year, tmdbId, settings?.showTrailers, isFocused]);
}, [metadata?.name, tmdbId, settings?.showTrailers, isFocused]);
// Shimmer animation removed
@ -1595,29 +1613,13 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
</Animated.View>
)}
{/* Hidden preload trailer player - loads in background */}
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && (
<View style={[staticStyles.absoluteFill, { opacity: 0, pointerEvents: 'none' }]}>
<TrailerPlayer
key={`preload-${trailerUrl}`}
trailerUrl={trailerUrl}
autoPlay={false}
muted={true}
style={staticStyles.absoluteFill}
hideLoadingSpinner={true}
onLoad={handleTrailerPreloaded}
onError={handleTrailerError}
/>
</View>
)}
{/* Visible trailer player - rendered on top with fade transition and parallax */}
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && trailerPreloaded && (
{/* Single trailer player - starts hidden (opacity 0), fades in when ready */}
{shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && (
<Animated.View style={[staticStyles.absoluteFill, {
opacity: trailerOpacity
}, trailerParallaxStyle]}>
<TrailerPlayer
key={`visible-${trailerUrl}`}
key={`trailer-${trailerUrl}`}
ref={trailerVideoRef}
trailerUrl={trailerUrl}
autoPlay={globalTrailerPlaying}

View file

@ -159,35 +159,19 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
const handleVideoError = useCallback((error: any) => {
logger.error('TrailerModal', 'Video error:', error);
// Check if this is a permission/network error that might benefit from retry
const errorCode = error?.error?.code;
const isRetryableError = errorCode === -1102 || errorCode === -1009 || errorCode === -1005;
if (isRetryableError && retryCount < 2) {
// Silent retry - increment count and try again
logger.info('TrailerModal', `Retrying video load (attempt ${retryCount + 1}/2)`);
if (retryCount < 2) {
logger.info('TrailerModal', `Re-extracting trailer (attempt ${retryCount + 1}/2)`);
setRetryCount(prev => prev + 1);
// Small delay before retry to avoid rapid-fire attempts
setTimeout(() => {
if (videoRef.current) {
// Force video to reload by changing the source briefly
setTrailerUrl(null);
setTimeout(() => {
if (trailerUrl) {
setTrailerUrl(trailerUrl);
}
}, 100);
}
}, 1000);
// Invalidate cache so loadTrailer gets a fresh URL, not the same bad one
if (trailer?.key) TrailerService.invalidateCache(trailer.key);
loadTrailer();
return;
}
// After 2 retries or for non-retryable errors, show the error
logger.error('TrailerModal', 'Video error after retries or non-retryable:', error);
logger.error('TrailerModal', 'Video error after retries:', error);
setError('Unable to play trailer. Please try again.');
setLoading(false);
}, [retryCount, trailerUrl]);
}, [retryCount, loadTrailer, trailer?.key]);
const handleTrailerEnd = useCallback(() => {
setIsPlaying(false);
@ -270,7 +254,18 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
<View style={styles.playerWrapper}>
<Video
ref={videoRef}
source={{ uri: trailerUrl }}
source={(() => {
const lower = (trailerUrl || '').toLowerCase();
const looksLikeHls = /\.m3u8(\b|$)/.test(lower) || /hls|playlist|m3u/.test(lower);
if (Platform.OS === 'android') {
const headers = { 'User-Agent': 'Nuvio/1.0 (Android)' };
if (looksLikeHls) {
return { uri: trailerUrl, type: 'm3u8', headers } as any;
}
return { uri: trailerUrl, headers } as any;
}
return { uri: trailerUrl } as any;
})()}
style={styles.player}
controls={true}
paused={!isPlaying}

View file

@ -19,6 +19,7 @@ import { useSettings } from '../../hooks/useSettings';
import { useTrailer } from '../../contexts/TrailerContext';
import { logger } from '../../utils/logger';
import TrailerService from '../../services/trailerService';
import { TMDBService } from '../../services/tmdbService';
import TrailerModal from './TrailerModal';
import Animated, { useSharedValue, withTiming, withDelay, useAnimatedStyle } from 'react-native-reanimated';
@ -175,10 +176,13 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
try {
logger.info('TrailersSection', `Fetching trailers for TMDB ID: ${tmdbId}, type: ${type}`);
// Resolve user-configured TMDB API key (falls back to default if not set)
const tmdbApiKey = await TMDBService.getInstance().getApiKey();
// First check if the movie/TV show exists
const basicEndpoint = type === 'movie'
? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`
: `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=d131017ccc6e5462a81c9304d21476de`;
? `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${tmdbApiKey}`
: `https://api.themoviedb.org/3/tv/${tmdbId}?api_key=${tmdbApiKey}`;
const basicResponse = await fetch(basicEndpoint);
if (!basicResponse.ok) {
@ -197,7 +201,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
if (type === 'movie') {
// For movies, just fetch the main videos endpoint
const videosEndpoint = `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`;
const videosEndpoint = `https://api.themoviedb.org/3/movie/${tmdbId}/videos?api_key=${tmdbApiKey}`;
logger.info('TrailersSection', `Fetching movie videos from: ${videosEndpoint}`);
@ -228,7 +232,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
logger.info('TrailersSection', `TV show has ${numberOfSeasons} seasons`);
// Fetch main TV show videos
const tvVideosEndpoint = `https://api.themoviedb.org/3/tv/${tmdbId}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`;
const tvVideosEndpoint = `https://api.themoviedb.org/3/tv/${tmdbId}/videos?api_key=${tmdbApiKey}`;
const tvResponse = await fetch(tvVideosEndpoint);
if (tvResponse.ok) {
@ -247,7 +251,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
const seasonPromises = [];
for (let seasonNum = 1; seasonNum <= numberOfSeasons; seasonNum++) {
seasonPromises.push(
fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNum}/videos?api_key=d131017ccc6e5462a81c9304d21476de&language=en-US`)
fetch(`https://api.themoviedb.org/3/tv/${tmdbId}/season/${seasonNum}/videos?api_key=${tmdbApiKey}`)
.then(res => res.json())
.then(data => ({
seasonNumber: seasonNum,

View file

@ -75,6 +75,11 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
const [isFullscreen, setIsFullscreen] = useState(false);
const [isComponentMounted, setIsComponentMounted] = useState(true);
// FIX: Track whether this player has ever been in a playing state.
// This prevents the globalTrailerPlaying effect from suppressing the
// very first play attempt before the global state has been set to true.
const hasBeenPlayingRef = useRef(false);
// Animated values
const controlsOpacity = useSharedValue(0);
const loadingOpacity = useSharedValue(1);
@ -150,6 +155,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
useEffect(() => {
if (isComponentMounted && paused === undefined) {
setIsPlaying(autoPlay);
if (autoPlay) hasBeenPlayingRef.current = true;
}
}, [autoPlay, isComponentMounted, paused]);
@ -163,18 +169,23 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
// Handle external paused prop to override playing state (highest priority)
useEffect(() => {
if (paused !== undefined) {
setIsPlaying(!paused);
logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${!paused}`);
const shouldPlay = !paused;
setIsPlaying(shouldPlay);
if (shouldPlay) hasBeenPlayingRef.current = true;
logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${shouldPlay}`);
}
}, [paused]);
// Respond to global trailer state changes (e.g., when modal opens)
// Only apply if no external paused prop is controlling this
// Only apply if no external paused prop is controlling this.
// FIX: Only pause if this player has previously been in a playing state.
// This avoids the race condition where globalTrailerPlaying is still false
// at mount time (before the parent has called setTrailerPlaying(true)),
// which was causing the trailer to be immediately paused on every load.
useEffect(() => {
if (isComponentMounted && paused === undefined) {
// Always sync with global trailer state when pausing
// This ensures all trailers pause when one screen loses focus
if (!globalTrailerPlaying) {
if (!globalTrailerPlaying && hasBeenPlayingRef.current) {
// Only suppress if the player was previously playing — not on initial mount
logger.info('TrailerPlayer', 'Global trailer paused - pausing this trailer');
setIsPlaying(false);
}
@ -363,26 +374,18 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
<Video
ref={videoRef}
source={(() => {
const androidHeaders = Platform.OS === 'android' ? { 'User-Agent': 'Nuvio/1.0 (Android)' } : {} as any;
// Help ExoPlayer select proper MediaSource
const lower = (trailerUrl || '').toLowerCase();
const looksLikeHls = /\.m3u8(\b|$)/.test(lower) || /hls|applehlsencryption|playlist|m3u/.test(lower);
const looksLikeDash = /\.mpd(\b|$)/.test(lower) || /dash|manifest/.test(lower);
if (Platform.OS === 'android') {
const androidHeaders = { 'User-Agent': 'Nuvio/1.0 (Android)' };
if (looksLikeHls) {
return { uri: trailerUrl, type: 'm3u8', headers: androidHeaders } as any;
}
if (looksLikeDash) {
return { uri: trailerUrl, type: 'mpd', headers: androidHeaders } as any;
}
return { uri: trailerUrl, headers: androidHeaders } as any;
}
return { uri: trailerUrl } as any;
})()}
style={[
styles.video,
contentType === 'movie' && styles.movieVideoScale,
]}
style={styles.video}
resizeMode="cover"
paused={!isPlaying}
repeat={false}
@ -513,9 +516,7 @@ const styles = StyleSheet.create({
width: '100%',
height: '100%',
},
movieVideoScale: {
transform: [{ scale: 1.30 }], // Custom scale for movies to crop black bars
},
videoOverlay: {
position: 'absolute',
top: 0,
@ -595,4 +596,4 @@ const styles = StyleSheet.create({
},
});
export default TrailerPlayer;
export default TrailerPlayer;

View file

@ -10,7 +10,7 @@ interface TrailerContextValue {
const TrailerContext = createContext<TrailerContextValue | undefined>(undefined);
export const TrailerProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [isTrailerPlaying, setIsTrailerPlaying] = useState(true);
const [isTrailerPlaying, setIsTrailerPlaying] = useState(false);
const pauseTrailer = useCallback(() => {
setIsTrailerPlaying(false);

View file

@ -68,7 +68,38 @@ export const useCalendarData = (): UseCalendarDataReturn => {
);
if (cachedData) {
setCalendarData(cachedData);
// Apply watched filter even on cached data
if (traktAuthenticated && watchedShows && watchedShows.length > 0) {
const cachedWatchedSet = new Set<string>();
for (const ws of watchedShows) {
const imdb = ws.show?.ids?.imdb;
if (!imdb) continue;
const showId = imdb.startsWith('tt') ? imdb : `tt${imdb}`;
const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0;
if (ws.seasons) {
for (const season of ws.seasons) {
for (const episode of season.episodes) {
if (resetAt > 0 && new Date(episode.last_watched_at).getTime() < resetAt) continue;
cachedWatchedSet.add(`${showId}:${season.number}:${episode.number}`);
}
}
}
}
const filtered = cachedData.map(section => {
if (section.title !== 'This Week') return section;
return {
...section,
data: section.data.filter((ep: any) => {
const showId = ep.seriesId?.startsWith('tt') ? ep.seriesId : `tt${ep.seriesId}`;
return !cachedWatchedSet.has(`${showId}:${ep.season}:${ep.episode}`) &&
!cachedWatchedSet.has(`${ep.seriesId}:${ep.season}:${ep.episode}`);
})
};
});
setCalendarData(filtered);
} else {
setCalendarData(cachedData);
}
setLoading(false);
return;
}
@ -314,6 +345,29 @@ export const useCalendarData = (): UseCalendarDataReturn => {
logger.log(`[CalendarData] Total episodes fetched: ${allEpisodes.length}`);
// Build a set of watched episodes from Trakt so we can filter them out of This Week
const watchedEpisodeSet = new Set<string>();
if (traktAuthenticated && watchedShows) {
for (const ws of watchedShows) {
const imdb = ws.show?.ids?.imdb;
if (!imdb) continue;
const showId = imdb.startsWith('tt') ? imdb : `tt${imdb}`;
const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0;
if (ws.seasons) {
for (const season of ws.seasons) {
for (const episode of season.episodes) {
// Respect reset_at
if (resetAt > 0) {
const watchedAt = new Date(episode.last_watched_at).getTime();
if (watchedAt < resetAt) continue;
}
watchedEpisodeSet.add(`${showId}:${season.number}:${episode.number}`);
}
}
}
}
}
// Use memory-efficient filtering with error handling
const thisWeekEpisodes = await memoryManager.filterLargeArray(
allEpisodes,
@ -321,8 +375,18 @@ export const useCalendarData = (): UseCalendarDataReturn => {
try {
if (!ep.releaseDate) return false;
const parsed = parseISO(ep.releaseDate);
// Show all episodes for this week, including released ones
return isThisWeek(parsed);
if (!isThisWeek(parsed)) return false;
// Filter out already-watched episodes when Trakt is authenticated
if (traktAuthenticated && watchedEpisodeSet.size > 0) {
const showId = ep.seriesId?.startsWith('tt') ? ep.seriesId : `tt${ep.seriesId}`;
if (
watchedEpisodeSet.has(`${showId}:${ep.season}:${ep.episode}`) ||
watchedEpisodeSet.has(`${ep.seriesId}:${ep.season}:${ep.episode}`)
) {
return false;
}
}
return true;
} catch (error) {
logger.warn(`[CalendarData] Error parsing date for this week filtering: ${ep.releaseDate}`, error);
return false;

View file

@ -217,7 +217,7 @@ let settingsCacheTimestamp = 0;
const SETTINGS_CACHE_TTL = 60000; // 1 minute
export const useSettings = () => {
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const [settings, setSettings] = useState<AppSettings>(() => cachedSettings || DEFAULT_SETTINGS);
const [isLoaded, setIsLoaded] = useState<boolean>(false);
useEffect(() => {
@ -289,10 +289,32 @@ export const useSettings = () => {
value: AppSettings[K],
emitEvent: boolean = true
) => {
const newSettings = { ...settings, [key]: value };
try {
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`;
// Always merge against the latest persisted/cached settings to avoid stale-overwrite races
// when multiple screens update settings concurrently.
let baseSettings: AppSettings = cachedSettings || DEFAULT_SETTINGS;
if (!cachedSettings) {
const [scopedJson, legacyJson] = await Promise.all([
mmkvStorage.getItem(scopedKey),
mmkvStorage.getItem(SETTINGS_STORAGE_KEY),
]);
const parsedScoped = scopedJson ? JSON.parse(scopedJson) : null;
const parsedLegacy = legacyJson ? JSON.parse(legacyJson) : null;
const merged = parsedScoped || parsedLegacy;
if (merged) {
baseSettings = { ...DEFAULT_SETTINGS, ...merged };
}
}
const newSettings = { ...baseSettings, [key]: value };
// Update cache/UI immediately so subsequent updates in the same tick see fresh state.
cachedSettings = newSettings;
settingsCacheTimestamp = Date.now();
setSettings(newSettings);
// Write to both scoped key (multi-user aware) and legacy key for backward compatibility
await Promise.all([
mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)),
@ -300,12 +322,6 @@ export const useSettings = () => {
]);
// Ensure a current scope exists to avoid future loads missing the chosen scope
await mmkvStorage.setItem('@user:current', scope);
// Update cache
cachedSettings = newSettings;
settingsCacheTimestamp = Date.now();
setSettings(newSettings);
if (__DEV__) console.log(`Setting updated: ${key}`, value);
// Notify all subscribers that settings have changed (if requested)
@ -317,7 +333,7 @@ export const useSettings = () => {
} catch (error) {
if (__DEV__) console.error('Failed to save settings:', error);
}
}, [settings]);
}, []);
return {
settings,

1492
src/i18n/locales/ja.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@ import nlNL from './locales/nl-NL.json';
import ro from './locales/ro.json';
import sq from './locales/sq.json';
import ca from './locales/ca.json';
import ja from './locales/ja.json';
export const resources = {
en: { translation: en },
@ -51,4 +52,5 @@ export const resources = {
ro: { translation: ro },
sq: { translation: sq },
ca: { translation: ca },
ja: { translation: ja },
};

View file

@ -406,6 +406,7 @@ const AuthScreen: React.FC = () => {
placeholder="Password (min 6 characters)"
placeholderTextColor="rgba(255,255,255,0.4)"
style={[styles.input, { color: currentTheme.colors.white }]}
autoCapitalize="none"
secureTextEntry={!showPassword}
value={password}
onChangeText={setPassword}
@ -454,6 +455,7 @@ const AuthScreen: React.FC = () => {
placeholder="Confirm password"
placeholderTextColor="rgba(255,255,255,0.4)"
style={[styles.input, { color: currentTheme.colors.white }]}
autoCapitalize="none"
secureTextEntry={!showConfirm}
value={confirmPassword}
onChangeText={setConfirmPassword}

View file

@ -289,7 +289,7 @@ const DonorCard: React.FC<DonorCardProps> = ({ donor, currentTheme, isTablet })
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletContributions
]}>
{donor.amount.toFixed(2)} {donor.currency} · {formatDonationDate(donor.date)}
{formatDonationDate(donor.date)}
</Text>
{donor.message ? (
<Text style={[styles.donorMessage, { color: currentTheme.colors.mediumEmphasis }]}>
@ -876,7 +876,7 @@ const ContributorsScreen: React.FC = () => {
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletContributions
]}>
{entry.total.toFixed(2)} {entry.currency} · {entry.count} {entry.count === 1 ? 'donation' : 'donations'}
{entry.count} {entry.count === 1 ? 'donation' : 'donations'}
</Text>
<Text style={[styles.donorMessage, { color: currentTheme.colors.mediumEmphasis }]}>
Rank #{entry.rank} · Last: {formatDonationDate(entry.lastDate)}

View file

@ -11,7 +11,8 @@ import {
Platform,
useColorScheme,
Animated,
Dimensions
Dimensions,
ActivityIndicator
} from 'react-native';
import { useSettings } from '../hooks/useSettings';
import { LinearGradient } from 'expo-linear-gradient';
@ -110,7 +111,7 @@ const SectionHeader: React.FC<{ title: string; isDarkMode: boolean; colors: any
const HomeScreenSettings: React.FC = () => {
const { t } = useTranslation();
const { settings, updateSetting } = useSettings();
const { settings, updateSetting, isLoaded } = useSettings();
const systemColorScheme = useColorScheme();
const { currentTheme } = useTheme();
const colors = currentTheme.colors;
@ -264,6 +265,20 @@ const HomeScreenSettings: React.FC = () => {
/>
);
if (!isLoaded) {
return (
<SafeAreaView style={[
styles.container,
{ backgroundColor: isDarkMode ? colors.darkBackground : '#F2F2F7' }
]}>
<StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={[
styles.container,
@ -711,6 +726,11 @@ const styles = StyleSheet.create({
marginLeft: 6,
fontWeight: '600',
},
loadingContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
export default HomeScreenSettings;
export default HomeScreenSettings;

View file

@ -11,7 +11,6 @@ import {
StatusBar,
Platform,
Dimensions,
Linking,
FlatList,
} from 'react-native';
import { BottomSheetModal, BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet';
@ -914,41 +913,6 @@ const SettingsScreen: React.FC = () => {
/>
</TouchableOpacity>
<View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
<TouchableOpacity
style={[styles.discordButton, { backgroundColor: currentTheme.colors.elevation1 }]}
onPress={() => Linking.openURL('https://discord.gg/KVgDTjhA4H')}
activeOpacity={0.7}
>
<View style={styles.discordButtonContent}>
<FastImage
source={{ uri: 'https://pngimg.com/uploads/discord/discord_PNG3.png' }}
style={styles.discordLogo}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
Discord
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]}
onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')}
activeOpacity={0.7}
>
<View style={styles.discordButtonContent}>
<FastImage
source={{ uri: 'https://www.iconpacks.net/icons/2/free-reddit-logo-icon-2436-thumb.png' }}
style={styles.discordLogo}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[styles.discordButtonText, { color: '#FF4500' }]}>
Reddit
</Text>
</View>
</TouchableOpacity>
</View>
</View>
{/* Monkey Animation */}
@ -1189,19 +1153,6 @@ const styles = StyleSheet.create({
borderRadius: 10,
maxWidth: 200,
},
discordButtonContent: {
flexDirection: 'row',
alignItems: 'center',
},
discordLogo: {
width: 18,
height: 18,
marginRight: 10,
},
discordButtonText: {
fontSize: 14,
fontWeight: '600',
},
kofiImage: {
height: 34,
width: 155,

View file

@ -1155,7 +1155,8 @@ const TMDBSettingsScreen = () => {
{ code: 'fr', label: 'Français', native: 'French' },
{ code: 'de', label: 'Deutsch', native: 'German' },
{ code: 'it', label: 'Italiano', native: 'Italian' },
{ code: 'pt', label: 'Português', native: 'Portuguese' },
{ code: 'pt-BR', label: 'Português (Brasil)', native: 'Português (Brasil)' },
{ code: 'pt', label: 'Português (Portugal)', native: 'Português' },
{ code: 'ru', label: 'Русский', native: 'Russian' },
{ code: 'tr', label: 'Türkçe', native: 'Turkish' },
{ code: 'ja', label: '日本語', native: 'Japanese' },

View file

@ -341,41 +341,6 @@ export const AboutFooter: React.FC<{ displayDownloads: number | null }> = ({ dis
/>
</TouchableOpacity>
<View style={styles.socialRow}>
<TouchableOpacity
style={[styles.socialButton, { backgroundColor: currentTheme.colors.elevation1 }]}
onPress={() => Linking.openURL('https://discord.gg/KVgDTjhA4H')}
activeOpacity={0.7}
>
<View style={styles.socialButtonContent}>
<FastImage
source={{ uri: 'https://pngimg.com/uploads/discord/discord_PNG3.png' }}
style={styles.socialLogo}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[styles.socialButtonText, { color: currentTheme.colors.highEmphasis }]}>
Discord
</Text>
</View>
</TouchableOpacity>
<TouchableOpacity
style={[styles.socialButton, { backgroundColor: '#FF4500' + '15' }]}
onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')}
activeOpacity={0.7}
>
<View style={styles.socialButtonContent}>
<FastImage
source={{ uri: 'https://www.iconpacks.net/icons/2/free-reddit-logo-icon-2436-thumb.png' }}
style={styles.socialLogo}
resizeMode={FastImage.resizeMode.contain}
/>
<Text style={[styles.socialButtonText, { color: '#FF4500' }]}>
Reddit
</Text>
</View>
</TouchableOpacity>
</View>
</View>
{/* Monkey Animation */}
@ -469,30 +434,6 @@ const styles = StyleSheet.create({
width: 200,
height: 50,
},
socialRow: {
flexDirection: 'row',
gap: 12,
flexWrap: 'wrap',
justifyContent: 'center',
},
socialButton: {
paddingHorizontal: 20,
paddingVertical: 12,
borderRadius: 12,
},
socialButtonContent: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
socialLogo: {
width: 24,
height: 24,
},
socialButtonText: {
fontSize: 15,
fontWeight: '600',
},
monkeyContainer: {
alignItems: 'center',
marginTop: 32,

View file

@ -500,7 +500,10 @@ class StorageService {
traktProgress: highestTraktProgress,
currentTime: highestCurrentTime,
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
// preserveTimestamp: true prevents lastUpdated from being bumped to Date.now(),
// which would make getUnsyncedProgress() think the entry needs re-syncing and
// re-add already-watched movies/episodes back to Trakt history.
await this.setWatchProgress(id, type, updatedProgress, episodeId, { preserveTimestamp: true });
}
} catch (error) {
logger.error('Error updating Trakt sync status:', error);
@ -540,7 +543,9 @@ class StorageService {
simklProgress: highestSimklProgress,
currentTime: highestCurrentTime,
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
// preserveTimestamp: true prevents lastUpdated from being bumped to Date.now(),
// which would make getUnsyncedProgress() treat synced entries as needing re-sync.
await this.setWatchProgress(id, type, updatedProgress, episodeId, { preserveTimestamp: true });
}
} catch (error) {
logger.error('Error updating Simkl sync status:', error);

View file

@ -404,23 +404,23 @@ class SupabaseSyncService {
watchedRows,
deviceRows,
] = await Promise.all([
this.request<Array<{ id: string }>>(`/rest/v1/plugins?select=id&user_id=eq.${ownerFilter}`, {
this.request<Array<{ id: string }>>(`/rest/v1/plugins?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/addons?select=id&user_id=eq.${ownerFilter}`, {
this.request<Array<{ id: string }>>(`/rest/v1/addons?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/watch_progress?select=id&user_id=eq.${ownerFilter}`, {
this.request<Array<{ id: string }>>(`/rest/v1/watch_progress?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/library_items?select=id&user_id=eq.${ownerFilter}`, {
this.request<Array<{ id: string }>>(`/rest/v1/library_items?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, {
method: 'GET',
authToken: token,
}),
this.request<Array<{ id: string }>>(`/rest/v1/watched_items?select=id&user_id=eq.${ownerFilter}`, {
this.request<Array<{ id: string }>>(`/rest/v1/watched_items?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, {
method: 'GET',
authToken: token,
}),
@ -935,7 +935,7 @@ class SupabaseSyncService {
private normalizeUrl(url: string): string {
let u = url.trim().toLowerCase();
u = u.replace(/\/manifest\.json\/?$/i, '');
u = u.replace(/\/+$/, '');
return u;
@ -1092,7 +1092,7 @@ class SupabaseSyncService {
if (!ownerId) return;
const rows = await this.request<PluginRow[]>(
`/rest/v1/plugins?select=url,name,enabled,sort_order&user_id=eq.${encodeURIComponent(ownerId)}&order=sort_order.asc`,
`/rest/v1/plugins?select=url,name,enabled,sort_order&user_id=eq.${encodeURIComponent(ownerId)}&profile_id=eq.1&order=sort_order.asc`,
{
method: 'GET',
authToken: token,
@ -1174,7 +1174,7 @@ class SupabaseSyncService {
if (!ownerId) return;
const rows = await this.request<AddonRow[]>(
`/rest/v1/addons?select=url,sort_order&user_id=eq.${encodeURIComponent(ownerId)}&order=sort_order.asc`,
`/rest/v1/addons?select=url,sort_order&user_id=eq.${encodeURIComponent(ownerId)}&profile_id=eq.1&order=sort_order.asc`,
{
method: 'GET',
authToken: token,
@ -1371,15 +1371,15 @@ class SupabaseSyncService {
key,
signature,
row: {
content_id: parsed.contentId,
content_type: parsed.contentType,
video_id: parsed.videoId,
season: parsed.season,
episode: parsed.episode,
position: this.secondsToMsLong(value.currentTime),
duration: this.secondsToMsLong(value.duration),
last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()),
progress_key: parsed.progressKey,
content_id: parsed.contentId,
content_type: parsed.contentType,
video_id: parsed.videoId,
season: parsed.season,
episode: parsed.episode,
position: this.secondsToMsLong(value.currentTime),
duration: this.secondsToMsLong(value.duration),
last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()),
progress_key: parsed.progressKey,
},
});
}

View file

@ -259,6 +259,17 @@ export class TMDBService {
}
}
/**
* Returns the resolved TMDB API key (custom user key if set, otherwise default).
* Always awaits key loading so callers get the correct value.
*/
async getApiKey(): Promise<string> {
if (!this.apiKeyLoaded) {
await this.loadApiKey();
}
return this.apiKey;
}
private async getHeaders() {
// Ensure API key is loaded before returning headers
if (!this.apiKeyLoaded) {
@ -1658,4 +1669,4 @@ export class TMDBService {
}
export const tmdbService = TMDBService.getInstance();
export default tmdbService;
export default tmdbService;

View file

@ -1,4 +1,6 @@
import { logger } from '../utils/logger';
import { Platform } from 'react-native';
import { YouTubeExtractor } from './youtubeExtractor';
export interface TrailerData {
url: string;
@ -6,373 +8,166 @@ export interface TrailerData {
year: number;
}
export class TrailerService {
// Environment-configurable values (Expo public env)
private static readonly ENV_LOCAL_BASE = process.env.EXPO_PUBLIC_TRAILER_LOCAL_BASE || 'http://46.62.173.157:3001';
private static readonly ENV_LOCAL_TRAILER_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_TRAILER_PATH || '/trailer';
private static readonly ENV_LOCAL_SEARCH_PATH = process.env.EXPO_PUBLIC_TRAILER_LOCAL_SEARCH_PATH || '/search-trailer';
interface CacheEntry {
url: string;
expiresAt: number;
}
private static readonly LOCAL_SERVER_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_TRAILER_PATH}`;
private static readonly AUTO_SEARCH_URL = `${TrailerService.ENV_LOCAL_BASE}${TrailerService.ENV_LOCAL_SEARCH_PATH}`;
private static readonly TIMEOUT = 20000; // 20 seconds
export class TrailerService {
// Cache for 5 seconds — just enough to avoid re-extracting on quick re-renders
private static readonly CACHE_TTL_MS = 5 * 1000;
private static urlCache = new Map<string, CacheEntry>();
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* 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
* Get a playable stream URL from a raw YouTube video ID (e.g. from TMDB).
* Uses on-device extraction only.
*/
static async getTrailerUrl(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
logger.info('TrailerService', `getTrailerUrl requested: title="${title}", year=${year}, tmdbId=${tmdbId || 'n/a'}, type=${type || 'n/a'}`);
return this.getTrailerFromLocalServer(title, year, tmdbId, type);
static async getTrailerFromVideoId(
youtubeVideoId: string,
title?: string,
year?: number
): Promise<string | null> {
if (!youtubeVideoId) return null;
logger.info('TrailerService', `getTrailerFromVideoId: ${youtubeVideoId} (${title ?? '?'} ${year ?? ''})`);
const cached = this.getCached(youtubeVideoId);
if (cached) {
logger.info('TrailerService', `Cache hit for videoId=${youtubeVideoId}`);
return cached;
}
try {
const platform = Platform.OS === 'android' ? 'android' : 'ios';
const url = await YouTubeExtractor.getBestStreamUrl(youtubeVideoId, platform);
if (url) {
logger.info('TrailerService', `Extraction succeeded for ${youtubeVideoId}`);
this.setCache(youtubeVideoId, url);
return url;
}
logger.warn('TrailerService', `Extraction returned null for ${youtubeVideoId}`);
} catch (err) {
logger.warn('TrailerService', `Extraction threw for ${youtubeVideoId}:`, err);
}
return null;
}
/**
* 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
* Called by TrailerModal which has the full YouTube URL from TMDB.
* Parses the video ID then delegates to getTrailerFromVideoId.
*/
private static async getTrailerFromLocalServer(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
const startTime = Date.now();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
static async getTrailerFromYouTubeUrl(
youtubeUrl: string,
title?: string,
year?: string
): Promise<string | null> {
logger.info('TrailerService', `getTrailerFromYouTubeUrl: ${youtubeUrl}`);
// Build URL with parameters
const params = new URLSearchParams();
// Always send title and year for logging and fallback
params.append('title', title);
params.append('year', year.toString());
if (tmdbId) {
params.append('tmdbId', tmdbId);
params.append('type', type || 'movie');
logger.info('TrailerService', `Using TMDB API for: ${title} (TMDB ID: ${tmdbId})`);
} else {
logger.info('TrailerService', `Auto-searching trailer for: ${title} (${year})`);
}
const url = `${this.AUTO_SEARCH_URL}?${params.toString()}`;
logger.info('TrailerService', `Local server request URL: ${url}`);
logger.info('TrailerService', `Local server timeout set to ${this.TIMEOUT}ms`);
logger.info('TrailerService', `Making fetch request to: ${url}`);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'Nuvio/1.0',
},
signal: controller.signal,
});
// logger.info('TrailerService', `Fetch request completed. Response status: ${response.status}`);
clearTimeout(timeoutId);
const elapsed = Date.now() - startTime;
const contentType = response.headers.get('content-type') || 'unknown';
// logger.info('TrailerService', `Local server response: status=${response.status} ok=${response.ok} content-type=${contentType} elapsedMs=${elapsed}`);
// Read body as text first so we can log it even on non-200s
let rawText = '';
try {
rawText = await response.text();
if (rawText) {
/*
const preview = rawText.length > 200 ? `${rawText.slice(0, 200)}...` : rawText;
logger.info('TrailerService', `Local server body preview: ${preview}`);
*/
} else {
// logger.info('TrailerService', 'Local server body is empty');
}
} catch (e) {
const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
logger.warn('TrailerService', `Failed reading local server body text: ${msg}`);
}
if (!response.ok) {
logger.warn('TrailerService', `Auto-search failed: ${response.status} ${response.statusText}`);
return null;
}
// Attempt to parse JSON from the raw text
let data: any = null;
try {
data = rawText ? JSON.parse(rawText) : null;
// const keys = typeof data === 'object' && data !== null ? Object.keys(data).join(',') : typeof data;
// logger.info('TrailerService', `Local server JSON parsed. Keys/Type: ${keys}`);
} catch (e) {
const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
logger.warn('TrailerService', `Failed to parse local server JSON: ${msg}`);
return null;
}
if (!data.url || !this.isValidTrailerUrl(data.url)) {
logger.warn('TrailerService', `Invalid trailer URL from auto-search: ${data.url}`);
return null;
}
// logger.info('TrailerService', `Successfully found trailer: ${String(data.url).substring(0, 80)}...`);
return data.url;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
logger.warn('TrailerService', `Auto-search request timed out after ${this.TIMEOUT}ms`);
} else {
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
logger.error('TrailerService', `Error in auto-search: ${msg}`);
logger.error('TrailerService', `Error details:`, {
name: (error as any)?.name,
message: (error as any)?.message,
stack: (error as any)?.stack,
url: url
});
}
const videoId = YouTubeExtractor.parseVideoId(youtubeUrl);
if (!videoId) {
logger.warn('TrailerService', `Could not parse video ID from: ${youtubeUrl}`);
return null;
}
return this.getTrailerFromVideoId(
videoId,
title,
year ? parseInt(year, 10) : undefined
);
}
/**
* Validates if the provided string is a valid trailer URL
* @param url - The URL to validate
* @returns boolean - True if valid, false otherwise
* Called by AppleTVHero and HeroSection which only have title/year/tmdbId.
* Without a YouTube video ID there is nothing to extract returns null.
* Callers should ensure they pass a video ID via getTrailerFromVideoId instead.
*/
private static isValidTrailerUrl(url: string): boolean {
try {
const urlObj = new URL(url);
// Check if it's a valid HTTP/HTTPS URL
if (!['http:', 'https:'].includes(urlObj.protocol)) {
return false;
}
// Check for common video streaming domains/patterns
const validDomains = [
'theplatform.com',
'youtube.com',
'youtu.be',
'vimeo.com',
'dailymotion.com',
'twitch.tv',
'amazonaws.com',
'cloudfront.net',
'googlevideo.com', // Google's CDN for YouTube videos
'sn-aigl6nzr.googlevideo.com', // Specific Google CDN servers
'sn-aigl6nze.googlevideo.com',
'sn-aigl6nsk.googlevideo.com',
'sn-aigl6ns6.googlevideo.com'
];
const hostname = urlObj.hostname.toLowerCase();
const isValidDomain = validDomains.some(domain =>
hostname.includes(domain) || hostname.endsWith(domain)
);
// Special check for Google Video CDN (YouTube direct streaming URLs)
const isGoogleVideoCDN = hostname.includes('googlevideo.com') ||
hostname.includes('sn-') && hostname.includes('.googlevideo.com');
// Check for video file extensions or streaming formats
const hasVideoFormat = /\.(mp4|m3u8|mpd|webm|mov|avi|mkv)$/i.test(urlObj.pathname) ||
url.includes('formats=') ||
url.includes('manifest') ||
url.includes('playlist');
return isValidDomain || hasVideoFormat || isGoogleVideoCDN;
} catch {
return false;
}
static async getTrailerUrl(
title: string,
year: number,
_tmdbId?: string,
_type?: 'movie' | 'tv'
): Promise<string | null> {
logger.warn('TrailerService', `getTrailerUrl called for "${title}" but no YouTube video ID available — cannot extract`);
return null;
}
/**
* Extracts the best video format URL from a multi-format URL
* @param url - The trailer URL that may contain multiple formats
* @returns string - The best format URL for mobile playback
*/
// ---------------------------------------------------------------------------
// Public helpers (API compatibility)
// ---------------------------------------------------------------------------
static getBestFormatUrl(url: string): string {
// If the URL contains format parameters, try to get the best one for mobile
if (url.includes('formats=')) {
// Prefer M3U (HLS) for better mobile compatibility
if (url.includes('M3U')) {
// Try to get M3U without encryption first, then with encryption
const baseUrl = url.split('?')[0];
const best = `${baseUrl}?formats=M3U+none,M3U+appleHlsEncryption`;
logger.info('TrailerService', `Optimized format URL from M3U: ${best.substring(0, 80)}...`);
return best;
}
// Fallback to MP4 if available
if (url.includes('MPEG4')) {
const baseUrl = url.split('?')[0];
const best = `${baseUrl}?formats=MPEG4`;
logger.info('TrailerService', `Optimized format URL from MPEG4: ${best.substring(0, 80)}...`);
return best;
}
}
// Return the original URL if no format optimization is needed
// logger.info('TrailerService', 'No format optimization applied');
return url;
}
/**
* Checks if a trailer is available for the given title and year
* @param title - The movie/series title
* @param year - The release year
* @returns Promise<boolean> - True if trailer is available
*/
static async isTrailerAvailable(title: string, year: number): Promise<boolean> {
logger.info('TrailerService', `Checking trailer availability for: ${title} (${year})`);
const trailerUrl = await this.getTrailerUrl(title, year);
logger.info('TrailerService', `Trailer availability for ${title} (${year}): ${trailerUrl ? 'available' : 'not available'}`);
return trailerUrl !== null;
static async isTrailerAvailable(videoId: string): Promise<boolean> {
return (await this.getTrailerFromVideoId(videoId)) !== null;
}
/**
* Gets trailer data with additional metadata
* @param title - The movie/series title
* @param year - The release year
* @returns Promise<TrailerData | null> - Trailer data or null if not found
*/
static async getTrailerData(title: string, year: number): Promise<TrailerData | null> {
logger.info('TrailerService', `getTrailerData for: ${title} (${year})`);
const url = await this.getTrailerUrl(title, year);
if (!url) {
logger.info('TrailerService', 'No trailer URL found for getTrailerData');
return null;
}
return {
url: this.getBestFormatUrl(url),
title,
year
};
if (!url) return null;
return { url, title, year };
}
/**
* Fetches trailer directly from a known YouTube URL
* @param youtubeUrl - The YouTube URL to process
* @param title - Optional title for logging/caching
* @param year - Optional year for logging/caching
* @returns Promise<string | null> - The direct streaming URL or null if failed
*/
static async getTrailerFromYouTubeUrl(youtubeUrl: string, title?: string, year?: string): Promise<string | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
const params = new URLSearchParams();
params.append('youtube_url', youtubeUrl);
if (title) params.append('title', title);
if (year) params.append('year', year.toString());
const url = `${this.ENV_LOCAL_BASE}${this.ENV_LOCAL_TRAILER_PATH}?${params.toString()}`;
logger.info('TrailerService', `Fetching trailer directly from YouTube URL: ${youtubeUrl}`);
logger.info('TrailerService', `Direct trailer request URL: ${url}`);
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'Nuvio/1.0',
},
signal: controller.signal,
});
clearTimeout(timeoutId);
logger.info('TrailerService', `Direct trailer response: status=${response.status} ok=${response.ok}`);
if (!response.ok) {
logger.warn('TrailerService', `Direct trailer failed: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
if (!data.url || !this.isValidTrailerUrl(data.url)) {
logger.warn('TrailerService', `Invalid trailer URL from direct fetch: ${data.url}`);
return null;
}
logger.info('TrailerService', `Successfully got direct trailer: ${String(data.url).substring(0, 80)}...`);
return data.url;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
logger.warn('TrailerService', `Direct trailer request timed out after ${this.TIMEOUT}ms`);
} else {
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
logger.error('TrailerService', `Error in direct trailer fetch: ${msg}`);
}
return null;
}
static invalidateCache(videoId: string): void {
this.urlCache.delete(videoId);
logger.info('TrailerService', `Cache invalidated for videoId=${videoId}`);
}
/**
* Switch between local server (deprecated - always uses local server now)
* @param useLocal - true for local server (always true now)
*/
static setUseLocalServer(useLocal: boolean): void {
if (!useLocal) {
logger.warn('TrailerService', 'XPrime API is no longer supported. Always using local server.');
}
logger.info('TrailerService', 'Using local server');
}
static setUseLocalServer(_useLocal: boolean): void {}
/**
* Get current server status
* @returns object with server information
*/
static getServerStatus(): { usingLocal: boolean; localUrl: string } {
return {
usingLocal: true,
localUrl: this.LOCAL_SERVER_URL,
};
return { usingLocal: false, localUrl: '' };
}
/**
* Test local server and return its status
* @returns Promise with server status information
*/
static async testServers(): Promise<{
localServer: { status: 'online' | 'offline'; responseTime?: number };
}> {
logger.info('TrailerService', 'Testing local server');
const results: {
localServer: { status: 'online' | 'offline'; responseTime?: number };
} = {
localServer: { status: 'offline' }
};
return { localServer: { status: 'offline' } };
}
// Test local server
try {
const startTime = Date.now();
const response = await fetch(`${this.AUTO_SEARCH_URL}?title=test&year=2023`, {
method: 'GET',
signal: AbortSignal.timeout(5000) // 5 second timeout
});
if (response.ok || response.status === 404) { // 404 is ok, means server is running
results.localServer = {
status: 'online',
responseTime: Date.now() - startTime
};
logger.info('TrailerService', `Local server online. Response time: ${results.localServer.responseTime}ms`);
}
} catch (error) {
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
logger.warn('TrailerService', `Local server test failed: ${msg}`);
// ---------------------------------------------------------------------------
// Private — cache
// ---------------------------------------------------------------------------
private static getCached(key: string): string | null {
const entry = this.urlCache.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.urlCache.delete(key);
return null;
}
// Check the URL's own CDN expiry — googlevideo.com URLs carry an `expire`
// param (Unix timestamp). Treat as stale if it expires within 2 minutes.
if (entry.url.includes('googlevideo.com')) {
try {
const u = new URL(entry.url);
const expire = u.searchParams.get('expire');
if (expire) {
const expiresAt = parseInt(expire, 10) * 1000;
if (Date.now() > expiresAt - 2 * 60 * 1000) {
logger.info('TrailerService', `Cached URL expired or expiring soon — re-extracting`);
this.urlCache.delete(key);
return null;
}
}
} catch { /* ignore */ }
}
return entry.url;
}
logger.info('TrailerService', `Server test results -> local: ${results.localServer.status}`);
return results;
private static setCache(key: string, url: string): void {
this.urlCache.set(key, { url, expiresAt: Date.now() + this.CACHE_TTL_MS });
if (this.urlCache.size > 100) {
const oldest = this.urlCache.keys().next().value;
if (oldest) this.urlCache.delete(oldest);
}
}
}
export default TrailerService;
export default TrailerService;

View file

@ -0,0 +1,729 @@
import { logger } from '../utils/logger';
import { Platform } from 'react-native';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface InnertubeFormat {
itag?: number;
url?: string;
mimeType?: string;
bitrate?: number;
averageBitrate?: number;
width?: number;
height?: number;
fps?: number;
quality?: string;
qualityLabel?: string;
audioQuality?: string;
audioSampleRate?: string;
initRange?: { start: string; end: string };
indexRange?: { start: string; end: string };
}
interface PlayerResponse {
streamingData?: {
formats?: InnertubeFormat[];
adaptiveFormats?: InnertubeFormat[];
hlsManifestUrl?: string;
};
playabilityStatus?: {
status?: string;
reason?: string;
};
}
interface StreamCandidate {
client: string;
priority: number;
url: string;
score: number;
height: number;
fps: number;
ext: 'mp4' | 'webm' | 'm4a' | 'other';
bitrate: number;
audioSampleRate?: string;
mimeType: string;
}
interface HlsVariant {
url: string;
width: number;
height: number;
bandwidth: number;
}
export interface YouTubeExtractionResult {
/** Primary playable URL — HLS manifest, progressive muxed, or video-only adaptive */
videoUrl: string;
/** Separate audio URL when adaptive video-only is used. null for HLS/progressive. */
audioUrl: string | null;
quality: string;
videoId: string;
}
// ---------------------------------------------------------------------------
// Constants — matching the Kotlin extractor exactly
// ---------------------------------------------------------------------------
// Used for all GET requests (watch page, HLS manifest fetch)
const DEFAULT_USER_AGENT =
'Mozilla/5.0 (Linux; Android 12; Android TV) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36';
const DEFAULT_HEADERS: Record<string, string> = {
'accept-language': 'en-US,en;q=0.9',
'user-agent': DEFAULT_USER_AGENT,
};
const PREFERRED_ADAPTIVE_CLIENT = 'android_vr';
const REQUEST_TIMEOUT_MS = 6000; // player API + HLS manifest requests
const WATCH_PAGE_TIMEOUT_MS = 3000; // watch page scrape — best-effort only
const MAX_RETRIES = 2; // retry extraction up to 2 times on total failure
interface ClientDef {
key: string;
id: string;
version: string;
userAgent: string;
context: Record<string, any>;
priority: number;
}
// Matching the Kotlin extractor client list exactly (versions updated to current)
const CLIENTS: ClientDef[] = [
{
key: 'android_vr',
id: '28',
version: '1.62.27',
userAgent:
'com.google.android.apps.youtube.vr.oculus/1.62.27 ' +
'(Linux; U; Android 12; en_US; Quest 3; Build/SQ3A.220605.009.A1) gzip',
context: {
clientName: 'ANDROID_VR',
clientVersion: '1.62.27',
deviceMake: 'Oculus',
deviceModel: 'Quest 3',
osName: 'Android',
osVersion: '12',
platform: 'MOBILE',
androidSdkVersion: 32,
hl: 'en',
gl: 'US',
},
priority: 0,
},
{
key: 'android',
id: '3',
version: '20.10.38',
userAgent:
'com.google.android.youtube/20.10.38 (Linux; U; Android 14; en_US) gzip',
context: {
clientName: 'ANDROID',
clientVersion: '20.10.38',
osName: 'Android',
osVersion: '14',
platform: 'MOBILE',
androidSdkVersion: 34,
hl: 'en',
gl: 'US',
},
priority: 1,
},
{
key: 'ios',
id: '5',
version: '20.10.1',
userAgent:
'com.google.ios.youtube/20.10.1 (iPhone16,2; U; CPU iOS 17_4 like Mac OS X)',
context: {
clientName: 'IOS',
clientVersion: '20.10.1',
deviceModel: 'iPhone16,2',
osName: 'iPhone',
osVersion: '17.4.0.21E219',
platform: 'MOBILE',
hl: 'en',
gl: 'US',
},
priority: 2,
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseVideoId(input: string): string | null {
if (!input) return null;
const trimmed = input.trim();
if (/^[A-Za-z0-9_-]{11}$/.test(trimmed)) return trimmed;
try {
const url = new URL(trimmed.startsWith('http') ? trimmed : `https://${trimmed}`);
const host = url.hostname.toLowerCase();
if (host.endsWith('youtu.be')) {
const id = url.pathname.slice(1).split('/')[0];
if (/^[A-Za-z0-9_-]{11}$/.test(id)) return id;
}
const v = url.searchParams.get('v');
if (v && /^[A-Za-z0-9_-]{11}$/.test(v)) return v;
const m = url.pathname.match(/\/(embed|shorts|live|v)\/([A-Za-z0-9_-]{11})/);
if (m) return m[2];
} catch {
const m = trimmed.match(/[?&]v=([A-Za-z0-9_-]{11})/);
if (m) return m[1];
}
return null;
}
function getMimeBase(mimeType?: string): string {
return (mimeType ?? '').split(';')[0].trim();
}
function getExt(mimeType?: string): 'mp4' | 'webm' | 'm4a' | 'other' {
const base = getMimeBase(mimeType);
if (base === 'video/mp4' || base === 'audio/mp4') return 'mp4';
if (base.includes('webm')) return 'webm';
if (base.includes('m4a')) return 'm4a';
return 'other';
}
function containerScore(ext: string): number {
return ext === 'mp4' || ext === 'm4a' ? 0 : ext === 'webm' ? 1 : 2;
}
function videoScore(height: number, fps: number, bitrate: number): number {
return height * 1_000_000_000 + fps * 1_000_000 + bitrate;
}
function audioScore(bitrate: number, sampleRate: number): number {
return bitrate * 1_000_000 + sampleRate;
}
function parseQualityLabel(label?: string): number {
const m = (label ?? '').match(/(\d{2,4})p/);
return m ? parseInt(m[1], 10) : 0;
}
function summarizeUrl(url: string): string {
try {
const u = new URL(url);
return `${u.hostname}${u.pathname.substring(0, 40)}`;
} catch {
return url.substring(0, 80);
}
}
// ---------------------------------------------------------------------------
// URL validation — HEAD request to check if URL is actually accessible
// ---------------------------------------------------------------------------
async function validateUrl(url: string, userAgent: string): Promise<boolean> {
// Only validate googlevideo.com CDN URLs — other URLs (HLS manifests) are fine
if (!url.includes('googlevideo.com')) return true;
// Check expiry param before making a network request
try {
const u = new URL(url);
const expire = u.searchParams.get('expire');
if (expire) {
const expiresAt = parseInt(expire, 10) * 1000;
if (Date.now() > expiresAt - 30000) {
logger.warn('YouTubeExtractor', `URL expired or expiring in <30s: expire=${expire}`);
return false;
}
}
} catch { /* ignore URL parse errors */ }
// Quick HEAD request to confirm URL is accessible
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 4000);
try {
const res = await fetch(url, {
method: 'HEAD',
headers: { 'User-Agent': userAgent },
signal: controller.signal,
});
clearTimeout(timer);
if (res.status === 403 || res.status === 401) {
logger.warn('YouTubeExtractor', `URL validation failed: HTTP ${res.status}`);
return false;
}
return true;
} catch (err) {
clearTimeout(timer);
// Network error or timeout — assume valid and let the player try
logger.warn('YouTubeExtractor', `URL validation request failed (assuming valid):`, err);
return true;
}
}
// ---------------------------------------------------------------------------
// android_vr preferred selection — only fall back to other clients if
// android_vr returned zero formats (likely PO token required for others)
// ---------------------------------------------------------------------------
function filterPreferAndroidVr(items: StreamCandidate[]): StreamCandidate[] {
const fromVr = items.filter(c => c.client === 'android_vr');
return fromVr.length > 0 ? fromVr : items;
}
function sortCandidates(items: StreamCandidate[]): StreamCandidate[] {
return [...items].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const ca = containerScore(a.ext), cb = containerScore(b.ext);
if (ca !== cb) return ca - cb;
return a.priority - b.priority;
});
}
function pickBestForClient(
items: StreamCandidate[],
preferredClient: string,
): StreamCandidate | null {
const fromPreferred = items.filter(c => c.client === preferredClient);
const pool = fromPreferred.length > 0 ? fromPreferred : items;
return sortCandidates(pool)[0] ?? null;
}
// ---------------------------------------------------------------------------
// Watch page — extract API key + visitor data dynamically
// ---------------------------------------------------------------------------
interface WatchConfig {
apiKey: string | null;
visitorData: string | null;
}
async function fetchWatchConfig(videoId: string): Promise<WatchConfig> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), WATCH_PAGE_TIMEOUT_MS);
try {
const res = await fetch(
`https://www.youtube.com/watch?v=${videoId}&hl=en`,
{ headers: DEFAULT_HEADERS, signal: controller.signal },
);
clearTimeout(timer);
if (!res.ok) {
logger.warn('YouTubeExtractor', `Watch page ${res.status}`);
return { apiKey: null, visitorData: null };
}
const html = await res.text();
const apiKey = html.match(/"INNERTUBE_API_KEY":"([^"]+)"/)?.[1] ?? null;
const visitorData = html.match(/"VISITOR_DATA":"([^"]+)"/)?.[1] ?? null;
logger.info('YouTubeExtractor', `Watch page: apiKey=${apiKey ? 'found' : 'missing'} visitorData=${visitorData ? 'found' : 'missing'}`);
return { apiKey, visitorData };
} catch (err) {
clearTimeout(timer);
logger.warn('YouTubeExtractor', 'Watch page error:', err);
return { apiKey: null, visitorData: null };
}
}
// ---------------------------------------------------------------------------
// Player API
// ---------------------------------------------------------------------------
async function fetchPlayerResponse(
videoId: string,
client: ClientDef,
apiKey: string | null,
visitorData: string | null,
): Promise<PlayerResponse | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const endpoint = apiKey
? `https://www.youtube.com/youtubei/v1/player?key=${encodeURIComponent(apiKey)}&prettyPrint=false`
: `https://www.youtube.com/youtubei/v1/player?prettyPrint=false`;
const headers: Record<string, string> = {
...DEFAULT_HEADERS,
'content-type': 'application/json',
'origin': 'https://www.youtube.com',
'referer': `https://www.youtube.com/watch?v=${videoId}`,
'x-youtube-client-name': client.id,
'x-youtube-client-version': client.version,
'user-agent': client.userAgent,
};
if (visitorData) headers['x-goog-visitor-id'] = visitorData;
const body = JSON.stringify({
videoId,
contentCheckOk: true,
racyCheckOk: true,
context: { client: client.context },
playbackContext: {
contentPlaybackContext: { html5Preference: 'HTML5_PREF_WANTS' },
},
});
try {
const res = await fetch(endpoint, {
method: 'POST',
headers,
body,
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) {
logger.warn('YouTubeExtractor', `[${client.key}] HTTP ${res.status}`);
return null;
}
return await res.json() as PlayerResponse;
} catch (err) {
clearTimeout(timer);
if (err instanceof Error && err.name === 'AbortError') {
logger.warn('YouTubeExtractor', `[${client.key}] Timed out`);
} else {
logger.warn('YouTubeExtractor', `[${client.key}] Error:`, err);
}
return null;
}
}
// ---------------------------------------------------------------------------
// HLS manifest parsing
// ---------------------------------------------------------------------------
async function parseBestHlsVariant(manifestUrl: string): Promise<HlsVariant | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const res = await fetch(manifestUrl, {
headers: DEFAULT_HEADERS,
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) return null;
const text = await res.text();
const lines = text.split('\n').map(l => l.trim()).filter(Boolean);
let best: HlsVariant | null = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.startsWith('#EXT-X-STREAM-INF:')) continue;
const nextLine = lines[i + 1];
if (!nextLine || nextLine.startsWith('#')) continue;
// Parse attribute list
const attrs: Record<string, string> = {};
let key = '', val = '', inKey = true, inQuote = false;
for (const ch of line.substring(line.indexOf(':') + 1)) {
if (inKey) { if (ch === '=') inKey = false; else key += ch; continue; }
if (ch === '"') { inQuote = !inQuote; continue; }
if (ch === ',' && !inQuote) {
if (key.trim()) attrs[key.trim()] = val.trim();
key = ''; val = ''; inKey = true; continue;
}
val += ch;
}
if (key.trim()) attrs[key.trim()] = val.trim();
const res2 = (attrs['RESOLUTION'] ?? '').split('x');
const width = parseInt(res2[0] ?? '0', 10) || 0;
const height = parseInt(res2[1] ?? '0', 10) || 0;
const bandwidth = parseInt(attrs['BANDWIDTH'] ?? '0', 10) || 0;
let variantUrl = nextLine;
if (!variantUrl.startsWith('http')) {
try { variantUrl = new URL(variantUrl, manifestUrl).toString(); } catch { /* keep */ }
}
const candidate: HlsVariant = { url: variantUrl, width, height, bandwidth };
if (
!best ||
candidate.height > best.height ||
(candidate.height === best.height && candidate.bandwidth > best.bandwidth)
) {
best = candidate;
}
}
return best;
} catch (err) {
clearTimeout(timer);
logger.warn('YouTubeExtractor', 'HLS manifest parse error:', err);
return null;
}
}
// ---------------------------------------------------------------------------
// Format collection — tries ALL clients, collects from all (matching Kotlin)
// ---------------------------------------------------------------------------
interface CollectedFormats {
progressive: StreamCandidate[];
adaptiveVideo: StreamCandidate[];
adaptiveAudio: StreamCandidate[];
hlsManifests: Array<{ clientKey: string; priority: number; url: string }>;
}
async function collectAllFormats(
videoId: string,
apiKey: string | null,
visitorData: string | null,
): Promise<CollectedFormats> {
const progressive: StreamCandidate[] = [];
const adaptiveVideo: StreamCandidate[] = [];
const adaptiveAudio: StreamCandidate[] = [];
const hlsManifests: Array<{ clientKey: string; priority: number; url: string }> = [];
// Fire all client requests in parallel — same approach as Kotlin coroutines
const results = await Promise.allSettled(
CLIENTS.map(client => fetchPlayerResponse(videoId, client, apiKey, visitorData)
.then(resp => ({ client, resp }))
)
);
for (const result of results) {
if (result.status === 'rejected') {
logger.warn('YouTubeExtractor', `Client request rejected:`, result.reason);
continue;
}
const { client, resp } = result.value;
if (!resp) continue;
const status = resp.playabilityStatus?.status;
if (status && status !== 'OK' && status !== 'CONTENT_CHECK_REQUIRED') {
logger.warn('YouTubeExtractor', `[${client.key}] status=${status} reason=${resp.playabilityStatus?.reason ?? ''}`);
continue;
}
const sd = resp.streamingData;
if (!sd) continue;
if (sd.hlsManifestUrl) {
hlsManifests.push({ clientKey: client.key, priority: client.priority, url: sd.hlsManifestUrl });
}
let nProg = 0, nVid = 0, nAud = 0;
// Progressive (muxed) formats — matching Kotlin: skip non-video mimeTypes
for (const f of (sd.formats ?? [])) {
if (!f.url) continue;
const mimeBase = getMimeBase(f.mimeType);
if (f.mimeType && !mimeBase.startsWith('video/')) continue;
const height = f.height ?? parseQualityLabel(f.qualityLabel);
const fps = f.fps ?? 0;
const bitrate = f.bitrate ?? f.averageBitrate ?? 0;
progressive.push({
client: client.key,
priority: client.priority,
url: f.url,
score: videoScore(height, fps, bitrate),
height,
fps,
ext: getExt(f.mimeType),
bitrate,
mimeType: f.mimeType ?? '',
});
nProg++;
}
// Adaptive formats
for (const f of (sd.adaptiveFormats ?? [])) {
if (!f.url) continue;
const mimeBase = getMimeBase(f.mimeType);
if (mimeBase.startsWith('video/')) {
const height = f.height ?? parseQualityLabel(f.qualityLabel);
const fps = f.fps ?? 0;
const bitrate = f.bitrate ?? f.averageBitrate ?? 0;
adaptiveVideo.push({
client: client.key,
priority: client.priority,
url: f.url,
score: videoScore(height, fps, bitrate),
height,
fps,
ext: getExt(f.mimeType),
bitrate,
mimeType: f.mimeType ?? '',
});
nVid++;
} else if (mimeBase.startsWith('audio/')) {
const bitrate = f.bitrate ?? f.averageBitrate ?? 0;
const sampleRate = parseFloat(f.audioSampleRate ?? '0') || 0;
adaptiveAudio.push({
client: client.key,
priority: client.priority,
url: f.url,
score: audioScore(bitrate, sampleRate),
height: 0,
fps: 0,
ext: getExt(f.mimeType),
bitrate,
audioSampleRate: f.audioSampleRate,
mimeType: f.mimeType ?? '',
});
nAud++;
}
}
logger.info('YouTubeExtractor', `[${client.key}] progressive=${nProg} video=${nVid} audio=${nAud} hls=${sd.hlsManifestUrl ? 1 : 0}`);
}
return { progressive, adaptiveVideo, adaptiveAudio, hlsManifests };
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export class YouTubeExtractor {
/**
* Extract a playable source from a YouTube video ID or URL.
*
* Matches the Kotlin InAppYouTubeExtractor approach:
* 1. Fetch watch page for dynamic API key + visitor data
* 2. Try ALL clients, collect formats from all that succeed
* 3. Pick best HLS variant (by resolution/bandwidth) as primary
* 4. Fall back to best progressive (muxed) if no HLS
*
* Note: Unlike the Kotlin version, we do not return separate videoUrl/audioUrl
* for adaptive streams react-native-video cannot merge two sources. HLS
* provides the best quality without needing a separate audio track.
*/
static async extract(
videoIdOrUrl: string,
platform?: 'android' | 'ios',
): Promise<YouTubeExtractionResult | null> {
const videoId = parseVideoId(videoIdOrUrl);
if (!videoId) {
logger.warn('YouTubeExtractor', `Could not parse video ID: ${videoIdOrUrl}`);
return null;
}
const effectivePlatform = platform ?? (Platform.OS === 'android' ? 'android' : 'ios');
for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) {
if (attempt > 1) {
const delay = attempt * 300;
logger.info('YouTubeExtractor', `Retry attempt ${attempt}/${MAX_RETRIES + 1} after ${delay}ms`);
await new Promise(resolve => setTimeout(resolve, delay));
}
const result = await this.extractOnce(videoId, effectivePlatform);
if (result) return result;
logger.warn('YouTubeExtractor', `Attempt ${attempt} failed for videoId=${videoId}`);
}
logger.warn('YouTubeExtractor', `All ${MAX_RETRIES + 1} attempts failed for videoId=${videoId}`);
return null;
}
private static async extractOnce(
videoId: string,
effectivePlatform: 'android' | 'ios',
): Promise<YouTubeExtractionResult | null> {
logger.info('YouTubeExtractor', `Extracting videoId=${videoId} platform=${effectivePlatform}`);
const { apiKey, visitorData } = await fetchWatchConfig(videoId);
// Step 2: collect formats from all clients
const { progressive, adaptiveVideo, adaptiveAudio, hlsManifests } =
await collectAllFormats(videoId, apiKey, visitorData);
logger.info('YouTubeExtractor',
`Totals: progressive=${progressive.length} adaptiveVideo=${adaptiveVideo.length} ` +
`adaptiveAudio=${adaptiveAudio.length} hls=${hlsManifests.length}`
);
if (progressive.length === 0 && adaptiveVideo.length === 0 && hlsManifests.length === 0) {
logger.warn('YouTubeExtractor', `No usable formats for videoId=${videoId}`);
return null;
}
// Step 3: pick best HLS variant across all manifests
let bestHls: (HlsVariant & { manifestUrl: string }) | null = null;
for (const { url } of hlsManifests.sort((a, b) => a.priority - b.priority)) {
const variant = await parseBestHlsVariant(url);
if (
variant &&
(!bestHls ||
variant.height > bestHls.height ||
(variant.height === bestHls.height && variant.bandwidth > bestHls.bandwidth))
) {
bestHls = { ...variant, manifestUrl: url };
}
}
// Prefer android_vr formats exclusively — other clients may require PO tokens
// and return URLs that 403 at the CDN level during playback
const preferredProgressive = sortCandidates(filterPreferAndroidVr(progressive));
const bestAdaptiveVideo = pickBestForClient(adaptiveVideo, PREFERRED_ADAPTIVE_CLIENT);
const bestAdaptiveAudio = pickBestForClient(adaptiveAudio, PREFERRED_ADAPTIVE_CLIENT);
if (bestHls) logger.info('YouTubeExtractor', `Best HLS: ${bestHls.height}p ${bestHls.bandwidth}bps`);
if (preferredProgressive[0]) logger.info('YouTubeExtractor', `Best progressive: ${preferredProgressive[0].height}p client=${preferredProgressive[0].client}`);
if (bestAdaptiveVideo) logger.info('YouTubeExtractor', `Best adaptive video: ${bestAdaptiveVideo.height}p client=${bestAdaptiveVideo.client}`);
if (bestAdaptiveAudio) logger.info('YouTubeExtractor', `Best adaptive audio: ${bestAdaptiveAudio.bitrate}bps client=${bestAdaptiveAudio.client}`);
// VR client user agent used for CDN URL validation
const vrUserAgent = CLIENTS.find(c => c.key === 'android_vr')!.userAgent;
// Step 4: select final source with URL validation
// Priority: HLS > progressive muxed
// HLS manifests don't need validation — they're not CDN segment URLs
if (bestHls) {
// Return the specific best variant URL, not the master playlist.
// Master playlist lets the player pick quality adaptively (often starts low).
// Pinning to the best variant ensures consistent high quality playback.
logger.info('YouTubeExtractor', `Using HLS variant: ${summarizeUrl(bestHls.url)} ${bestHls.height}p`);
return {
videoUrl: bestHls.url,
audioUrl: null,
quality: `${bestHls.height}p`,
videoId,
};
}
// Validate progressive candidates in order, return first valid one
for (const candidate of preferredProgressive) {
const valid = await validateUrl(candidate.url, vrUserAgent);
if (valid) {
logger.info('YouTubeExtractor', `Using progressive: ${summarizeUrl(candidate.url)} ${candidate.height}p`);
return {
videoUrl: candidate.url,
audioUrl: null,
quality: `${candidate.height}p`,
videoId,
};
}
logger.warn('YouTubeExtractor', `Progressive URL invalid, trying next candidate`);
}
// Last resort: video-only adaptive (no audio, but beats nothing)
if (bestAdaptiveVideo) {
const valid = await validateUrl(bestAdaptiveVideo.url, vrUserAgent);
if (valid) {
logger.warn('YouTubeExtractor', `Using video-only adaptive (no audio): ${bestAdaptiveVideo.height}p`);
return {
videoUrl: bestAdaptiveVideo.url,
audioUrl: null,
quality: `${bestAdaptiveVideo.height}p`,
videoId,
};
}
}
logger.warn('YouTubeExtractor', `No playable source for videoId=${videoId}`);
return null;
}
static async getBestStreamUrl(
videoIdOrUrl: string,
platform?: 'android' | 'ios',
): Promise<string | null> {
const result = await this.extract(videoIdOrUrl, platform);
return result?.videoUrl ?? null;
}
static parseVideoId(input: string): string | null {
return parseVideoId(input);
}
}
export default YouTubeExtractor;