diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..b33a6b5f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..80724f85 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..e359e5bb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..163ac398 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,43 @@ +## Summary + + + +## PR type + + +- Bug fix +- Small maintenance improvement +- Docs fix +- Approved larger change (link approval below) + +## Why + + + +## Policy check + + +- [ ] 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. + + + +## Testing + + +- [ ] iOS tested +- [ ] Android tested + +## Screenshots / Video (UI changes only) + + + +## Breaking changes + + + +## Linked issues + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..59c7d56e --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index dca575ef..9ae35957 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/nuvio-source.json b/nuvio-source.json index 06059a82..0ac08b83 100644 --- a/nuvio-source.json +++ b/nuvio-source.json @@ -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": [] -} +} \ No newline at end of file diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 7de8e8b0..35976293 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -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 = ({ 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 = ({ }, [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 = ({ key={`visible-${trailerUrl}`} ref={trailerVideoRef} trailerUrl={trailerUrl} - autoPlay={globalTrailerPlaying} + autoPlay={!trailerShouldBePaused} muted={trailerMuted} style={StyleSheet.absoluteFillObject} hideLoadingSpinner={true} diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 586d66e1..96778a91 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -33,6 +33,7 @@ import { stremioService } from '../../services/stremioService'; import { streamCacheService } from '../../services/streamCacheService'; import { useSettings } from '../../hooks/useSettings'; import { useBottomSheetBackHandler } from '../../hooks/useBottomSheetBackHandler'; +import { watchedService } from '../../services/watchedService'; // Define interface for continue watching items @@ -293,8 +294,10 @@ const ContinueWatchingSection = React.forwardRef((props, re currentEpisode: number, videos: any[], watchedSet?: Set, - showId?: string - ) => { + showId?: string, + localWatchedMap?: Map, + baseTimestamp: number = 0 + ): { video: any; lastWatched: number } | null => { if (!videos || !Array.isArray(videos)) return null; const sortedVideos = [...videos].sort((a, b) => { @@ -302,11 +305,27 @@ const ContinueWatchingSection = React.forwardRef((props, re return a.episode - b.episode; }); - const isAlreadyWatched = (season: number, episode: number): boolean => { - if (!watchedSet || !showId) return false; + let latestWatchedTimestamp = baseTimestamp; + + if (localWatchedMap && showId) { const cleanShowId = showId.startsWith('tt') ? showId : `tt${showId}`; - return watchedSet.has(`${cleanShowId}:${season}:${episode}`) || - watchedSet.has(`${showId}:${season}:${episode}`); + for (const video of sortedVideos) { + const sig1 = `${cleanShowId}:${video.season}:${video.episode}`; + const sig2 = `${showId}:${video.season}:${video.episode}`; + const t1 = localWatchedMap.get(sig1) || 0; + const t2 = localWatchedMap.get(sig2) || 0; + latestWatchedTimestamp = Math.max(latestWatchedTimestamp, t1, t2); + } + } + + const isAlreadyWatched = (season: number, episode: number): boolean => { + if (!showId) return false; + const cleanShowId = showId.startsWith('tt') ? showId : `tt${showId}`; + const sig1 = `${cleanShowId}:${season}:${episode}`; + const sig2 = `${showId}:${season}:${episode}`; + if (watchedSet && (watchedSet.has(sig1) || watchedSet.has(sig2))) return true; + if (localWatchedMap && (localWatchedMap.has(sig1) || localWatchedMap.has(sig2))) return true; + return false; }; for (const video of sortedVideos) { @@ -316,7 +335,7 @@ const ContinueWatchingSection = React.forwardRef((props, re if (isAlreadyWatched(video.season, video.episode)) continue; if (isEpisodeReleased(video)) { - return video; + return { video, lastWatched: latestWatchedTimestamp }; } } @@ -371,16 +390,9 @@ const ContinueWatchingSection = React.forwardRef((props, re }; const compareCwItems = (a: ContinueWatchingItem, b: ContinueWatchingItem): number => { - const aProgress = a.progress ?? 0; - const bProgress = b.progress ?? 0; - const aIsUpNext = a.type === 'series' && aProgress <= 0; - const bIsUpNext = b.type === 'series' && bProgress <= 0; - - // Keep active in-progress items ahead of "Up Next" placeholders. - if (aIsUpNext !== bIsUpNext) { - return aIsUpNext ? 1 : -1; - } - + // Sort purely by recency — most recently watched first. + // "Up Next" placeholders (progress=0) carry the timestamp of the last watched episode + // so they naturally bubble up next to the other recently-watched items. return (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0); }; @@ -498,8 +510,103 @@ const ContinueWatchingSection = React.forwardRef((props, re logger.log(`[CW] Providers authed: trakt=${isTraktAuthed} simkl=${isSimklAuthed}`); - // Declare groupPromises outside the if block let groupPromises: Promise[] = []; + const allLocalItems: ContinueWatchingItem[] = []; + + // Fetch Trakt watched movies once and reuse + const traktMoviesSetPromise = (async () => { + try { + if (!isTraktAuthed) return new Set(); + if (typeof (traktService as any).getWatchedMovies === 'function') { + const watched = await (traktService as any).getWatchedMovies(); + const watchedSet = new Set(); + + if (Array.isArray(watched)) { + watched.forEach((movie: any) => { + const ids = movie?.movie?.ids; + if (!ids) return; + + const imdb = ids.imdb; + if (imdb) { + watchedSet.add(imdb.startsWith('tt') ? imdb : `tt${imdb}`); + } + if (ids.tmdb) { + watchedSet.add(ids.tmdb.toString()); + } + }); + } + return watchedSet; + } + return new Set(); + } catch { + return new Set(); + } + })(); + + // Fetch Trakt watched shows once and reuse + const traktShowsSetPromise = (async () => { + try { + if (!isTraktAuthed) return new Set(); + + if (typeof (traktService as any).getWatchedShows === 'function') { + const watched = await (traktService as any).getWatchedShows(); + const watchedSet = new Set(); + + if (Array.isArray(watched)) { + watched.forEach((show: any) => { + const ids = show?.show?.ids; + if (!ids) return; + + const imdbId = ids.imdb; + const tmdbId = ids.tmdb; + + if (show.seasons && Array.isArray(show.seasons)) { + show.seasons.forEach((season: any) => { + if (season.episodes && Array.isArray(season.episodes)) { + season.episodes.forEach((episode: any) => { + if (imdbId) { + const cleanImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; + watchedSet.add(`${cleanImdbId}:${season.number}:${episode.number}`); + } + if (tmdbId) { + watchedSet.add(`${tmdbId}:${season.number}:${episode.number}`); + } + }); + } + }); + } + }); + } + return watchedSet; + } + return new Set(); + } catch { + return new Set(); + } + })(); + + // Fetch local supervised watched items + const localWatchedShowsMapPromise = (async () => { + try { + const watched = await watchedService.getAllWatchedItems(); + const watchedMap = new Map(); + watched.forEach(item => { + if (item.content_id) { + const cleanId = item.content_id.startsWith('tt') ? item.content_id : `tt${item.content_id}`; + if (item.season != null && item.episode != null) { + watchedMap.set(`${cleanId}:${item.season}:${item.episode}`, item.watched_at); + watchedMap.set(`${item.content_id}:${item.season}:${item.episode}`, item.watched_at); + } else { + watchedMap.set(cleanId, item.watched_at); + watchedMap.set(item.content_id, item.watched_at); + } + } + }); + return watchedMap; + } catch { + return new Map(); + } + })(); // In Trakt mode, CW is sourced from Trakt only, but we still want to overlay local progress // when local is ahead (scrobble lag/offline playback). @@ -579,79 +686,8 @@ const ContinueWatchingSection = React.forwardRef((props, re contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent }); } - // Fetch Trakt watched movies once and reuse - const traktMoviesSetPromise = (async () => { - try { - if (!isTraktAuthed) return new Set(); - if (typeof (traktService as any).getWatchedMovies === 'function') { - const watched = await (traktService as any).getWatchedMovies(); - const watchedSet = new Set(); + // (Promises are now declared at the top of the function) - if (Array.isArray(watched)) { - watched.forEach((w: any) => { - const ids = w?.movie?.ids; - if (!ids) return; - - if (ids.imdb) { - const imdb = ids.imdb; - watchedSet.add(imdb.startsWith('tt') ? imdb : `tt${imdb}`); - } - if (ids.tmdb) { - watchedSet.add(ids.tmdb.toString()); - } - }); - } - return watchedSet; - } - return new Set(); - } catch { - return new Set(); - } - })(); - - // Fetch Trakt watched shows once and reuse - const traktShowsSetPromise = (async () => { - try { - if (!isTraktAuthed) return new Set(); - - if (typeof (traktService as any).getWatchedShows === 'function') { - const watched = await (traktService as any).getWatchedShows(); - const watchedSet = new Set(); - - if (Array.isArray(watched)) { - watched.forEach((show: any) => { - const ids = show?.show?.ids; - if (!ids) return; - - const imdbId = ids.imdb; - const tmdbId = ids.tmdb; - - if (show.seasons && Array.isArray(show.seasons)) { - show.seasons.forEach((season: any) => { - if (season.episodes && Array.isArray(season.episodes)) { - season.episodes.forEach((episode: any) => { - if (imdbId) { - const cleanImdbId = imdbId.startsWith('tt') ? imdbId : `tt${imdbId}`; - watchedSet.add(`${cleanImdbId}:${season.number}:${episode.number}`); - } - if (tmdbId) { - watchedSet.add(`${tmdbId}:${season.number}:${episode.number}`); - } - }); - } - }); - } - }); - } - return watchedSet; - } - return new Set(); - } catch { - return new Set(); - } - })(); - - // Process each content group concurrently, merging results as they arrive groupPromises = Object.values(contentGroups).map(async (group) => { try { if (!isSupportedId(group.id)) return; @@ -711,20 +747,24 @@ const ContinueWatchingSection = React.forwardRef((props, re // If we have valid season/episode info, find the next episode if (completedSeason !== undefined && completedEpisode !== undefined && metadata?.videos) { const watchedEpisodesSet = await traktShowsSetPromise; - const nextEpisode = findNextEpisode( + const localWatchedMap = await localWatchedShowsMapPromise; + const nextEpisodeResult = findNextEpisode( completedSeason, completedEpisode, metadata.videos, watchedEpisodesSet, - group.id + group.id, + localWatchedMap, + progress.lastUpdated ); - if (nextEpisode) { + if (nextEpisodeResult) { + const nextEpisode = nextEpisodeResult.video; logger.log(`📺 [ContinueWatching] Found next episode: S${nextEpisode.season}E${nextEpisode.episode} for ${basicContent.name}`); batch.push({ ...basicContent, progress: 0, // Up next - no progress yet - lastUpdated: progress.lastUpdated, // Keep the timestamp from completed episode + lastUpdated: nextEpisodeResult.lastWatched, // Keep the timestamp from completed episode or watched item season: nextEpisode.season, episode: nextEpisode.episode, episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, @@ -764,13 +804,20 @@ const ContinueWatchingSection = React.forwardRef((props, re // Check if this specific episode is watched on Trakt if (season !== undefined && episodeNumber !== undefined) { const watchedEpisodesSet = await traktShowsSetPromise; - // Try with both raw ID and tt-prefixed ID, and TMDB ID (which is just the ID string) + const localWatchedMap = await localWatchedShowsMapPromise; const rawId = group.id.replace(/^tt/, ''); const ttId = `tt${rawId}`; - if (watchedEpisodesSet.has(`${ttId}:${season}:${episodeNumber}`) || - watchedEpisodesSet.has(`${rawId}:${season}:${episodeNumber}`) || - watchedEpisodesSet.has(`${group.id}:${season}:${episodeNumber}`)) { + const sig1 = `${ttId}:${season}:${episodeNumber}`; + const sig2 = `${rawId}:${season}:${episodeNumber}`; + const sig3 = `${group.id}:${season}:${episodeNumber}`; + + if (watchedEpisodesSet.has(sig1) || + watchedEpisodesSet.has(sig2) || + watchedEpisodesSet.has(sig3) || + localWatchedMap.has(sig1) || + localWatchedMap.has(sig2) || + localWatchedMap.has(sig3)) { isWatchedOnTrakt = true; // Update local storage to reflect watched status @@ -808,7 +855,7 @@ const ContinueWatchingSection = React.forwardRef((props, re } as ContinueWatchingItem); } - if (batch.length > 0) await mergeBatchIntoState(batch); + if (batch.length > 0) allLocalItems.push(...batch); } catch (error) { // Continue processing other groups even if one fails } @@ -855,6 +902,36 @@ const ContinueWatchingSection = React.forwardRef((props, re const traktBatch: ContinueWatchingItem[] = []; + // Pre-fetch watched shows so both Step 1 and Step 2 can use the watched episode sets + // This fixes "Up Next" suggesting already-watched episodes when the watched set is missing + let watchedShowsData: Awaited> = []; + // Map from showImdb -> Set of "imdb:season:episode" strings + const watchedEpisodeSetByShow = new Map>(); + try { + watchedShowsData = await traktService.getWatchedShows(); + for (const ws of watchedShowsData) { + if (!ws.show?.ids?.imdb) continue; + const imdb = ws.show.ids.imdb.startsWith('tt') ? ws.show.ids.imdb : `tt${ws.show.ids.imdb}`; + const resetAt = ws.reset_at ? new Date(ws.reset_at).getTime() : 0; + const episodeSet = new Set(); + if (ws.seasons) { + for (const season of ws.seasons) { + for (const episode of season.episodes) { + // Respect reset_at: skip episodes watched before the reset + if (resetAt > 0) { + const watchedAt = new Date(episode.last_watched_at).getTime(); + if (watchedAt < resetAt) continue; + } + episodeSet.add(`${imdb}:${season.number}:${episode.number}`); + } + } + } + watchedEpisodeSetByShow.set(imdb, episodeSet); + } + } catch { + // Non-fatal — fall back to no watched set + } + // STEP 1: Process playback progress items (in-progress, paused) // These have actual progress percentage from Trakt const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); @@ -918,22 +995,28 @@ const ContinueWatchingSection = React.forwardRef((props, re if (item.progress >= 85) { const metadata = cachedData.metadata; if (metadata?.videos) { - const nextEpisode = findNextEpisode( + // Use pre-fetched watched set so already-watched episodes are skipped + const watchedSetForShow = watchedEpisodeSetByShow.get(showImdb); + const localWatchedMap = await localWatchedShowsMapPromise; + const nextEpisodeResult = findNextEpisode( item.episode.season, item.episode.number, metadata.videos, - undefined, // No watched set needed, findNextEpisode handles it - showImdb + watchedSetForShow, + showImdb, + localWatchedMap, + pausedAt ); - if (nextEpisode) { + if (nextEpisodeResult) { + const nextEpisode = nextEpisodeResult.video; logger.log(`📺 [TraktPlayback] Episode completed, adding next: S${nextEpisode.season}E${nextEpisode.episode} for ${item.show.title}`); traktBatch.push({ ...cachedData.basicContent, id: showImdb, type: 'series', progress: 0, // Up next - no progress yet - lastUpdated: pausedAt, + lastUpdated: nextEpisodeResult.lastWatched, season: nextEpisode.season, episode: nextEpisode.episode, episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, @@ -965,13 +1048,13 @@ const ContinueWatchingSection = React.forwardRef((props, re } } - // STEP 2: Get watched shows and find "Up Next" episodes - // This handles cases where episodes are fully completed and removed from playback progress + // STEP 2: Find "Up Next" episodes using pre-fetched watched shows data + // Reuses watchedShowsData fetched before Step 1 — no extra API call + // Also respects reset_at (Bug 4 fix) and uses pre-built watched episode sets (Bug 3 fix) try { - const watchedShows = await traktService.getWatchedShows(); const thirtyDaysAgoForShows = Date.now() - (30 * 24 * 60 * 60 * 1000); - for (const watchedShow of watchedShows) { + for (const watchedShow of watchedShowsData) { try { if (!watchedShow.show?.ids?.imdb) continue; @@ -987,7 +1070,9 @@ const ContinueWatchingSection = React.forwardRef((props, re const showKey = `series:${showImdb}`; if (recentlyRemovedRef.current.has(showKey)) continue; - // Find the last watched episode + const resetAt = watchedShow.reset_at ? new Date(watchedShow.reset_at).getTime() : 0; + + // Find the last watched episode (respecting reset_at) let lastWatchedSeason = 0; let lastWatchedEpisode = 0; let latestEpisodeTimestamp = 0; @@ -996,6 +1081,8 @@ const ContinueWatchingSection = React.forwardRef((props, re for (const season of watchedShow.seasons) { for (const episode of season.episodes) { const episodeTimestamp = new Date(episode.last_watched_at).getTime(); + // Skip episodes watched before the user reset their progress + if (resetAt > 0 && episodeTimestamp < resetAt) continue; if (episodeTimestamp > latestEpisodeTimestamp) { latestEpisodeTimestamp = episodeTimestamp; lastWatchedSeason = season.number; @@ -1011,33 +1098,30 @@ const ContinueWatchingSection = React.forwardRef((props, re const cachedData = await getCachedMetadata('series', showImdb); if (!cachedData?.basicContent || !cachedData?.metadata?.videos) continue; - // Build a set of watched episodes for this show - const watchedEpisodeSet = new Set(); - if (watchedShow.seasons) { - for (const season of watchedShow.seasons) { - for (const episode of season.episodes) { - watchedEpisodeSet.add(`${showImdb}:${season.number}:${episode.number}`); - } - } - } + // Use pre-built watched episode set (already respects reset_at) + const watchedEpisodeSet = watchedEpisodeSetByShow.get(showImdb) ?? new Set(); + const localWatchedMap = await localWatchedShowsMapPromise; // Find the next unwatched episode - const nextEpisode = findNextEpisode( + const nextEpisodeResult = findNextEpisode( lastWatchedSeason, lastWatchedEpisode, cachedData.metadata.videos, watchedEpisodeSet, - showImdb + showImdb, + localWatchedMap, + latestEpisodeTimestamp ); - if (nextEpisode) { + if (nextEpisodeResult) { + const nextEpisode = nextEpisodeResult.video; logger.log(`📺 [TraktWatched] Found Up Next: ${watchedShow.show.title} S${nextEpisode.season}E${nextEpisode.episode}`); traktBatch.push({ ...cachedData.basicContent, id: showImdb, type: 'series', progress: 0, // Up next - no progress yet - lastUpdated: latestEpisodeTimestamp, + lastUpdated: nextEpisodeResult.lastWatched, season: nextEpisode.season, episode: nextEpisode.episode, episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, @@ -1054,13 +1138,24 @@ const ContinueWatchingSection = React.forwardRef((props, re // Trakt mode: show ONLY Trakt items, but override progress with local if local is higher. if (traktBatch.length > 0) { - // Dedupe (keep most recent per show/movie) + // Dedupe (keep in-progress over "Up Next"; then prefer most recent) const deduped = new Map(); for (const item of traktBatch) { const key = `${item.type}:${item.id}`; const existing = deduped.get(key); - if (!existing || (item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { + if (!existing) { deduped.set(key, item); + } else { + const existingHasProgress = (existing.progress ?? 0) > 0; + const candidateHasProgress = (item.progress ?? 0) > 0; + if (candidateHasProgress && !existingHasProgress) { + // Always prefer actual in-progress over "Up Next" placeholder + deduped.set(key, item); + } else if (!candidateHasProgress && existingHasProgress) { + // Keep existing in-progress item + } else if ((item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { + deduped.set(key, item); + } } } @@ -1159,13 +1254,13 @@ const ContinueWatchingSection = React.forwardRef((props, re if (!mostRecentLocal || !highestLocal) return it; - // IMPORTANT: - // In Trakt-auth mode, the "most recently watched" ordering should reflect local playback, - // not Trakt's paused_at (which can be stale or even appear newer than local). - // So: if we have any local match, use its timestamp for ordering. - const mergedLastUpdated = (mostRecentLocal.lastUpdated ?? 0) > 0 - ? (mostRecentLocal.lastUpdated ?? 0) - : (it.lastUpdated ?? 0); + // Use the most recent timestamp between local and Trakt. + // Always preferring local was wrong: if you watched on another device, + // Trakt's paused_at is newer and should win for ordering purposes. + const mergedLastUpdated = Math.max( + (mostRecentLocal.lastUpdated ?? 0), + (it.lastUpdated ?? 0) + ); try { logger.log('[CW][Trakt][Overlay] item/local summary', { @@ -1399,21 +1494,26 @@ const ContinueWatchingSection = React.forwardRef((props, re if (item.progress >= 85) { const metadata = cachedData.metadata; if (metadata?.videos) { - const nextEpisode = findNextEpisode( + const watchedEpisodesSet = await traktShowsSetPromise; + const localWatchedMap = await localWatchedShowsMapPromise; + const nextEpisodeResult = findNextEpisode( item.episode.season, episodeNum, metadata.videos, - undefined, - showImdb + watchedEpisodesSet, + showImdb, + localWatchedMap, + pausedAt ); - if (nextEpisode) { + if (nextEpisodeResult) { + const nextEpisode = nextEpisodeResult.video; simklBatch.push({ ...cachedData.basicContent, id: showImdb, type: 'series', progress: 0, - lastUpdated: pausedAt, + lastUpdated: nextEpisodeResult.lastWatched, season: nextEpisode.season, episode: nextEpisode.episode, episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, @@ -1540,6 +1640,37 @@ const ContinueWatchingSection = React.forwardRef((props, re // Wait for all groups and provider merges to settle, then finalize loading state await Promise.allSettled([...groupPromises, traktMergePromise, simklMergePromise]); + + if (allLocalItems.length > 0) { + const map = new Map(); + for (const it of allLocalItems) { + const key = `${it.type}:${it.id}`; + const existing = map.get(key); + if (!existing || shouldPreferCandidate(it, existing)) { + map.set(key, it); + } + } + + const sorted = Array.from(map.values()); + sorted.sort(compareCwItems); + + // Filter removed items + const filtered: ContinueWatchingItem[] = []; + for (const it of sorted) { + const key = it.type === 'series' && it.season && it.episode + ? `${it.type}:${it.id}:${it.season}:${it.episode}` + : `${it.type}:${it.id}`; + if (recentlyRemovedRef.current.has(key)) continue; + + const removeId = it.type === 'series' && it.season && it.episode + ? `${it.id}:${it.season}:${it.episode}` + : it.id; + const isRemoved = await storageService.isContinueWatchingRemoved(removeId, it.type); + if (!isRemoved) filtered.push(it); + } + + setContinueWatchingItems(filtered); + } } catch (error) { // Continue even if loading fails } finally { @@ -1910,271 +2041,271 @@ const ContinueWatchingSection = React.forwardRef((props, re // Memoized render function for poster-style continue watching items const renderPosterStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => { return ( - handleContentPress(item)} - onLongPress={() => handleLongPress(item)} - delayLongPress={800} - > - {/* Poster Image */} - - + handleContentPress(item)} + onLongPress={() => handleLongPress(item)} + delayLongPress={800} + > + {/* Poster Image */} + + - {/* Gradient overlay */} - + {/* Gradient overlay */} + - {/* Episode Info Overlay */} - {item.type === 'series' && item.season && item.episode && ( - - - S{item.season} E{item.episode} - - - )} - - {/* Up Next Badge */} - {item.type === 'series' && item.progress === 0 && ( - - {t('home.up_next_caps')} - - )} - - {/* Progress Bar */} - {item.progress > 0 && ( - - - + {/* Episode Info Overlay */} + {item.type === 'series' && item.season && item.episode && ( + + + S{item.season} E{item.episode} + - - )} + )} - {/* Delete Indicator Overlay */} - {deletingItemId === item.id && ( - - - - )} - + {/* Up Next Badge */} + {item.type === 'series' && item.progress === 0 && ( + + {t('home.up_next_caps')} + + )} - {/* Title below poster */} - - - {item.name} - - {item.progress > 0 && ( - - {Math.round(item.progress)}% + {/* Progress Bar */} + {item.progress > 0 && ( + + + + + + )} + + {/* Delete Indicator Overlay */} + {deletingItemId === item.id && ( + + + + )} + + + {/* Title below poster */} + + + {item.name} - )} - - + {item.progress > 0 && ( + + {Math.round(item.progress)}% + + )} + + ); }, [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedPosterWidth, computedPosterHeight, isTV, isLargeTablet, settings.posterBorderRadius]); // Memoized render function for wide-style continue watching items const renderWideStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => { return ( - handleContentPress(item)} - onLongPress={() => handleLongPress(item)} - delayLongPress={800} - > - {/* Poster Image */} - - + handleContentPress(item)} + onLongPress={() => handleLongPress(item)} + delayLongPress={800} + > + {/* Poster Image */} + + - {/* Delete Indicator Overlay */} - {deletingItemId === item.id && ( - - - - )} - - - {/* Content Details */} - - {(() => { - const isUpNext = item.type === 'series' && item.progress === 0; - return ( - - - {item.name} - - {isUpNext && ( - - {t('home.up_next')} - - )} + {/* Delete Indicator Overlay */} + {deletingItemId === item.id && ( + + - ); - })()} + )} + - {/* Episode Info or Year */} - {(() => { - if (item.type === 'series' && item.season && item.episode) { + {/* Content Details */} + + {(() => { + const isUpNext = item.type === 'series' && item.progress === 0; return ( - + + + {item.name} + + {isUpNext && ( + + {t('home.up_next')} + + )} + + ); + })()} + + {/* Episode Info or Year */} + {(() => { + if (item.type === 'series' && item.season && item.episode) { + return ( + + + {t('home.season', { season: item.season })} + + {item.episodeTitle && ( + + {item.episodeTitle} + + )} + + ); + } else { + return ( - {t('home.season', { season: item.season })} + {item.year} • {item.type === 'movie' ? t('home.movie') : t('home.series')} - {item.episodeTitle && ( - - {item.episodeTitle} - - )} - - ); - } else { - return ( - 0 && ( + + - {item.year} • {item.type === 'movie' ? t('home.movie') : t('home.series')} + + + + {t('home.percent_watched', { percent: Math.round(item.progress) })} - ); - } - })()} - - {/* Progress Bar */} - {item.progress > 0 && ( - - - - - {t('home.percent_watched', { percent: Math.round(item.progress) })} - - - )} - - + )} + + ); }, [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet, settings.posterBorderRadius, t]); @@ -2223,14 +2354,7 @@ const ContinueWatchingSection = React.forwardRef((props, re { - const aProgress = a.progress ?? 0; - const bProgress = b.progress ?? 0; - const aIsUpNext = a.type === 'series' && aProgress <= 0; - const bIsUpNext = b.type === 'series' && bProgress <= 0; - if (aIsUpNext !== bIsUpNext) return aIsUpNext ? 1 : -1; - return (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0); - })} + data={continueWatchingItems} renderItem={renderContinueWatchingItem} keyExtractor={keyExtractor} horizontal diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 911986c1..37fd01e2 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -1129,12 +1129,14 @@ const HeroSection: React.FC = 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 = 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 = 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 = memo(({ )} - {/* Hidden preload trailer player - loads in background */} - {shouldLoadSecondaryData && settings?.showTrailers && trailerUrl && !trailerLoading && !trailerError && !trailerPreloaded && ( - - - - )} - - {/* 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 && ( = 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 = memo(({ {/* 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, diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx index 01f3b11d..73edcb69 100644 --- a/src/screens/TMDBSettingsScreen.tsx +++ b/src/screens/TMDBSettingsScreen.tsx @@ -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' }, diff --git a/src/screens/settings/AboutSettingsScreen.tsx b/src/screens/settings/AboutSettingsScreen.tsx index 18f39bb1..8fac2a35 100644 --- a/src/screens/settings/AboutSettingsScreen.tsx +++ b/src/screens/settings/AboutSettingsScreen.tsx @@ -341,41 +341,6 @@ export const AboutFooter: React.FC<{ displayDownloads: number | null }> = ({ dis /> - - Linking.openURL('https://discord.gg/KVgDTjhA4H')} - activeOpacity={0.7} - > - - - - Discord - - - - - Linking.openURL('https://www.reddit.com/r/Nuvio/')} - activeOpacity={0.7} - > - - - - Reddit - - - - {/* 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, diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 74402527..51d13605 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -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); diff --git a/src/services/supabaseSyncService.ts b/src/services/supabaseSyncService.ts index 1149c79f..c38b9c72 100644 --- a/src/services/supabaseSyncService.ts +++ b/src/services/supabaseSyncService.ts @@ -404,23 +404,23 @@ class SupabaseSyncService { watchedRows, deviceRows, ] = await Promise.all([ - this.request>(`/rest/v1/plugins?select=id&user_id=eq.${ownerFilter}`, { + this.request>(`/rest/v1/plugins?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, { method: 'GET', authToken: token, }), - this.request>(`/rest/v1/addons?select=id&user_id=eq.${ownerFilter}`, { + this.request>(`/rest/v1/addons?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, { method: 'GET', authToken: token, }), - this.request>(`/rest/v1/watch_progress?select=id&user_id=eq.${ownerFilter}`, { + this.request>(`/rest/v1/watch_progress?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, { method: 'GET', authToken: token, }), - this.request>(`/rest/v1/library_items?select=id&user_id=eq.${ownerFilter}`, { + this.request>(`/rest/v1/library_items?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, { method: 'GET', authToken: token, }), - this.request>(`/rest/v1/watched_items?select=id&user_id=eq.${ownerFilter}`, { + this.request>(`/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( - `/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( - `/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, }, }); } diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index 36cb53f7..3abcc660 100644 --- a/src/services/tmdbService.ts +++ b/src/services/tmdbService.ts @@ -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 { + 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; \ No newline at end of file +export default tmdbService; diff --git a/src/services/trailerService.ts b/src/services/trailerService.ts index 1b7f4bbf..f2fa5492 100644 --- a/src/services/trailerService.ts +++ b/src/services/trailerService.ts @@ -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(); + + // --------------------------------------------------------------------------- + // 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 - 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 { - 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 { + 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 - 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 { - 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 { + 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 { + 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 - True if trailer is available - */ - static async isTrailerAvailable(title: string, year: number): Promise { - 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 { + 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 - Trailer data or null if not found - */ static async getTrailerData(title: string, year: number): Promise { - 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 - The direct streaming URL or null if failed - */ - static async getTrailerFromYouTubeUrl(youtubeUrl: string, title?: string, year?: string): Promise { - 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; \ No newline at end of file +export default TrailerService; diff --git a/src/services/youtubeExtractor.ts b/src/services/youtubeExtractor.ts new file mode 100644 index 00000000..d1d0985d --- /dev/null +++ b/src/services/youtubeExtractor.ts @@ -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 = { + '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; + 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 { + // 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 { + 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 { + 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 = { + ...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 { + 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 = {}; + 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 { + 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 { + 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 { + 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 { + const result = await this.extract(videoIdOrUrl, platform); + return result?.videoUrl ?? null; + } + + static parseVideoId(input: string): string | null { + return parseVideoId(input); + } +} + +export default YouTubeExtractor;