mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
Merge branch 'tapframe:main' into main
This commit is contained in:
commit
2d52b9916e
29 changed files with 3612 additions and 990 deletions
217
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
217
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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.
|
||||
78
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
78
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
43
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
80
CONTRIBUTING.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
1492
src/i18n/locales/ja.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
729
src/services/youtubeExtractor.ts
Normal file
729
src/services/youtubeExtractor.ts
Normal 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;
|
||||
Loading…
Reference in a new issue