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/.gitignore b/.gitignore index efd2b6c5..6486d03e 100644 --- a/.gitignore +++ b/.gitignore @@ -105,4 +105,5 @@ LibTorrent/ iTorrent/ simkl-docss downloader.md -server \ No newline at end of file +server +Deliverables 2 \ No newline at end of file diff --git a/App.tsx b/App.tsx index 5bd5ff19..7ce219f0 100644 --- a/App.tsx +++ b/App.tsx @@ -48,6 +48,7 @@ import { ToastProvider } from './src/contexts/ToastContext'; import { mmkvStorage } from './src/services/mmkvStorage'; import { CampaignManager } from './src/components/promotions/CampaignManager'; import { isErrorReportingEnabledSync } from './src/services/telemetryService'; +import { supabaseSyncService } from './src/services/supabaseSyncService'; // Initialize Sentry with privacy-first defaults // Settings are loaded from telemetryService and can be controlled by user @@ -180,6 +181,15 @@ const ThemedApp = () => { const onboardingCompleted = await mmkvStorage.getItem('hasCompletedOnboarding'); setHasCompletedOnboarding(onboardingCompleted === 'true'); + // Initialize Supabase auth/session and start background sync. + // This is intentionally non-blocking for app startup UX. + supabaseSyncService + .initialize() + .then(() => supabaseSyncService.startupSync()) + .catch((error) => { + console.warn('[App] Supabase sync bootstrap failed:', error); + }); + // Initialize update service await UpdateService.initialize(); @@ -314,4 +324,4 @@ const styles = StyleSheet.create({ }, }); -export default Sentry.wrap(App); \ No newline at end of file +export default Sentry.wrap(App); 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 671cef36..9ae35957 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
- Nuvio + Nuvio

@@ -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/android/app/build.gradle b/android/app/build.gradle index 04e1b814..1ca68471 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -95,8 +95,8 @@ android { applicationId 'com.nuvio.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 35 - versionName "1.3.7" + versionCode 37 + versionName "1.4.1" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" } @@ -118,7 +118,7 @@ android { def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4] applicationVariants.all { variant -> variant.outputs.each { output -> - def baseVersionCode = 35 // Current versionCode 35 from defaultConfig + def baseVersionCode = 37 // Current versionCode 37 from defaultConfig def abiName = output.getFilter(com.android.build.OutputFile.ABI) def versionCode = baseVersionCode * 100 // Base multiplier diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 810d5111..fea5dd4c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -21,9 +21,9 @@ - + - + @@ -37,4 +37,4 @@ - \ No newline at end of file + diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png index 15b512eb..386d80e4 100644 Binary files a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png index 8b78e2f9..4c75777f 100644 Binary files a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png index 528962ab..329ead81 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png index 0969e6a3..dafc337b 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png index 7de47821..71feaf84 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 70320a98..34bef077 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index 46b9e444..bd1b79d3 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 63e85ffe..4658ecfb 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp index a5a1dfa9..725ef09a 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index 133272a5..924b4aed 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index a1156c2f..ab4a4218 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 705ab974..df2db884 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index 401543d6..a2fd709b 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 111ae461..c3e36e46 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 76fb597c..4601fb63 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index 5e694a8a..6a59a560 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index a06585cb..50486cf7 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 57b30c91..2a4c113c 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index ab60c474..844fcf56 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index acdda655..3fd4dbc7 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9b0596a4..f3acf267 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -3,5 +3,5 @@ contain false dark - 1.3.7 + 1.4.1 \ No newline at end of file diff --git a/app.json b/app.json index d5510500..357b3913 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Nuvio", "slug": "nuvio", - "version": "1.3.7", + "version": "1.4.1", "orientation": "default", "backgroundColor": "#020404", "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", @@ -17,7 +17,7 @@ "ios": { "supportsTablet": true, "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", - "buildNumber": "35", + "buildNumber": "37", "infoPlist": { "NSAppTransportSecurity": { "NSAllowsArbitraryLoads": true @@ -35,7 +35,7 @@ "LSSupportsOpeningDocumentsInPlace": true, "UIFileSharingEnabled": true }, - "bundleIdentifier": "com.nuvio.app", + "bundleIdentifier": "com.nuvio.hub", "associatedDomains": [], "jsEngine": "hermes", "appleTeamId": "8QBDZ766S3" @@ -52,7 +52,7 @@ "android.permission.WRITE_SETTINGS" ], "package": "com.nuvio.app", - "versionCode": 35, + "versionCode": 37, "architectures": [ "arm64-v8a", "armeabi-v7a", @@ -105,6 +105,6 @@ "fallbackToCacheTimeout": 30000, "url": "https://ota.nuvioapp.space/api/manifest" }, - "runtimeVersion": "1.3.7" + "runtimeVersion": "1.4.1" } } diff --git a/assets/AppIcons/android/mipmap-hdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-hdpi/ic_launcher.png index fc396770..589a0139 100644 Binary files a/assets/AppIcons/android/mipmap-hdpi/ic_launcher.png and b/assets/AppIcons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/assets/AppIcons/android/mipmap-mdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-mdpi/ic_launcher.png index 02d4de67..3a9261d7 100644 Binary files a/assets/AppIcons/android/mipmap-mdpi/ic_launcher.png and b/assets/AppIcons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/assets/AppIcons/android/mipmap-xhdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-xhdpi/ic_launcher.png index 41b56cf1..3c101f3f 100644 Binary files a/assets/AppIcons/android/mipmap-xhdpi/ic_launcher.png and b/assets/AppIcons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/assets/AppIcons/android/mipmap-xxhdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-xxhdpi/ic_launcher.png index 570413cc..4a50f3a8 100644 Binary files a/assets/AppIcons/android/mipmap-xxhdpi/ic_launcher.png and b/assets/AppIcons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/assets/AppIcons/android/mipmap-xxxhdpi/ic_launcher.png b/assets/AppIcons/android/mipmap-xxxhdpi/ic_launcher.png index a101d4a9..c72a7215 100644 Binary files a/assets/AppIcons/android/mipmap-xxxhdpi/ic_launcher.png and b/assets/AppIcons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/android/ic_launcher-web.png b/assets/android/ic_launcher-web.png index 493e3b98..b2f544c0 100644 Binary files a/assets/android/ic_launcher-web.png and b/assets/android/ic_launcher-web.png differ diff --git a/assets/android/mipmap-hdpi/ic_launcher.png b/assets/android/mipmap-hdpi/ic_launcher.png index 558dfbae..d044d633 100644 Binary files a/assets/android/mipmap-hdpi/ic_launcher.png and b/assets/android/mipmap-hdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-hdpi/ic_launcher_foreground.png b/assets/android/mipmap-hdpi/ic_launcher_foreground.png index 2f49c5f1..0d8e9ca8 100644 Binary files a/assets/android/mipmap-hdpi/ic_launcher_foreground.png and b/assets/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-hdpi/ic_launcher_round.png b/assets/android/mipmap-hdpi/ic_launcher_round.png index 1b48a3ef..589a0139 100644 Binary files a/assets/android/mipmap-hdpi/ic_launcher_round.png and b/assets/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-ldpi/ic_launcher.png b/assets/android/mipmap-ldpi/ic_launcher.png index 3524b2fc..7912de17 100644 Binary files a/assets/android/mipmap-ldpi/ic_launcher.png and b/assets/android/mipmap-ldpi/ic_launcher.png differ diff --git a/assets/android/mipmap-ldpi/ic_launcher_foreground.png b/assets/android/mipmap-ldpi/ic_launcher_foreground.png new file mode 100644 index 00000000..efd269fb Binary files /dev/null and b/assets/android/mipmap-ldpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-ldpi/ic_launcher_round.png b/assets/android/mipmap-ldpi/ic_launcher_round.png index 02020f6f..1a964dbd 100644 Binary files a/assets/android/mipmap-ldpi/ic_launcher_round.png and b/assets/android/mipmap-ldpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-mdpi/ic_launcher.png b/assets/android/mipmap-mdpi/ic_launcher.png index 6c259c80..526eaffd 100644 Binary files a/assets/android/mipmap-mdpi/ic_launcher.png and b/assets/android/mipmap-mdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-mdpi/ic_launcher_foreground.png b/assets/android/mipmap-mdpi/ic_launcher_foreground.png index 64546171..335c655b 100644 Binary files a/assets/android/mipmap-mdpi/ic_launcher_foreground.png and b/assets/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-mdpi/ic_launcher_round.png b/assets/android/mipmap-mdpi/ic_launcher_round.png index f637369f..3a9261d7 100644 Binary files a/assets/android/mipmap-mdpi/ic_launcher_round.png and b/assets/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-xhdpi/ic_launcher.png b/assets/android/mipmap-xhdpi/ic_launcher.png index 9fb69a54..3c101f3f 100644 Binary files a/assets/android/mipmap-xhdpi/ic_launcher.png and b/assets/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-xhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png index f03be67e..f964ed8d 100644 Binary files a/assets/android/mipmap-xhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-xhdpi/ic_launcher_round.png b/assets/android/mipmap-xhdpi/ic_launcher_round.png index c34a4836..3c101f3f 100644 Binary files a/assets/android/mipmap-xhdpi/ic_launcher_round.png and b/assets/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-xxhdpi/ic_launcher.png b/assets/android/mipmap-xxhdpi/ic_launcher.png index d16402dd..4a50f3a8 100644 Binary files a/assets/android/mipmap-xxhdpi/ic_launcher.png and b/assets/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png index 8605f5e6..0e2377c7 100644 Binary files a/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxhdpi/ic_launcher_round.png index 749d0724..4a50f3a8 100644 Binary files a/assets/android/mipmap-xxhdpi/ic_launcher_round.png and b/assets/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher.png b/assets/android/mipmap-xxxhdpi/ic_launcher.png index 2e35f619..c72a7215 100644 Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher.png and b/assets/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png index 6cc901eb..dd2e6bee 100644 Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png index 06cdb2f5..c72a7215 100644 Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher_round.png and b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/assets/android/playstore-icon.png b/assets/android/playstore-icon.png index e5dd4a6b..b2f544c0 100644 Binary files a/assets/android/playstore-icon.png and b/assets/android/playstore-icon.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png index 18d719cf..1fdc796e 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png index 5bd9ac8e..35572c94 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png index a526217a..92d5c55a 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png index e2841b48..8954f0f4 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png index 34e2d79c..37175fd9 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png index 11953d6c..77d6dc8d 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png index 5bd9ac8e..330fb482 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png index 56a3b787..2cda25b7 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png index c36efb6e..3678d9c9 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png index c36efb6e..3678d9c9 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png index d84612d1..7e8b1ce9 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png index 53317602..52b302e8 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png index 082be8c5..7e342789 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 2218f825..48877c47 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png index 8ac40dd4..8c09f13f 100644 Binary files a/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png and b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png differ diff --git a/assets/ios/iTunesArtwork@1x.png b/assets/ios/iTunesArtwork@1x.png index 637db4d5..95e87d0b 100644 Binary files a/assets/ios/iTunesArtwork@1x.png and b/assets/ios/iTunesArtwork@1x.png differ diff --git a/assets/ios/iTunesArtwork@2x.png b/assets/ios/iTunesArtwork@2x.png index 8ac40dd4..8c09f13f 100644 Binary files a/assets/ios/iTunesArtwork@2x.png and b/assets/ios/iTunesArtwork@2x.png differ diff --git a/assets/ios/iTunesArtwork@3x.png b/assets/ios/iTunesArtwork@3x.png index 18b16be3..ce974813 100644 Binary files a/assets/ios/iTunesArtwork@3x.png and b/assets/ios/iTunesArtwork@3x.png differ diff --git a/assets/nuvio-sync-icon-og.png b/assets/nuvio-sync-icon-og.png new file mode 100644 index 00000000..28bf19e5 Binary files /dev/null and b/assets/nuvio-sync-icon-og.png differ diff --git a/assets/player-icons/ic_player_aspect_ratio.svg b/assets/player-icons/ic_player_aspect_ratio.svg new file mode 100644 index 00000000..12b2f7b6 --- /dev/null +++ b/assets/player-icons/ic_player_aspect_ratio.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/player-icons/ic_player_audio_filled.svg b/assets/player-icons/ic_player_audio_filled.svg new file mode 100644 index 00000000..2961dd6a --- /dev/null +++ b/assets/player-icons/ic_player_audio_filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/player-icons/ic_player_audio_outline.svg b/assets/player-icons/ic_player_audio_outline.svg new file mode 100644 index 00000000..87c64646 --- /dev/null +++ b/assets/player-icons/ic_player_audio_outline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/player-icons/ic_player_episodes.svg b/assets/player-icons/ic_player_episodes.svg new file mode 100644 index 00000000..c09c7205 --- /dev/null +++ b/assets/player-icons/ic_player_episodes.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/player-icons/ic_player_pause.svg b/assets/player-icons/ic_player_pause.svg new file mode 100644 index 00000000..69f83449 --- /dev/null +++ b/assets/player-icons/ic_player_pause.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/player-icons/ic_player_play.svg b/assets/player-icons/ic_player_play.svg new file mode 100644 index 00000000..d375a176 --- /dev/null +++ b/assets/player-icons/ic_player_play.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/player-icons/ic_player_source.svg b/assets/player-icons/ic_player_source.svg new file mode 100644 index 00000000..1e79c2a3 --- /dev/null +++ b/assets/player-icons/ic_player_source.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/player-icons/ic_player_subtitles.svg b/assets/player-icons/ic_player_subtitles.svg new file mode 100644 index 00000000..bf8041e3 --- /dev/null +++ b/assets/player-icons/ic_player_subtitles.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/simkl-favicon.png b/assets/simkl-favicon.png index c8454b3e..15918960 100644 Binary files a/assets/simkl-favicon.png and b/assets/simkl-favicon.png differ diff --git a/assets/simkl-logo.png b/assets/simkl-logo.png index 6836cfc0..9efcf66f 100644 Binary files a/assets/simkl-logo.png and b/assets/simkl-logo.png differ diff --git a/assets/text_only_og.png b/assets/text_only_og.png new file mode 100644 index 00000000..35eca9a4 Binary files /dev/null and b/assets/text_only_og.png differ diff --git a/assets/trakt-favicon.png b/assets/trakt-favicon.png index e69de29b..37075b4f 100644 Binary files a/assets/trakt-favicon.png and b/assets/trakt-favicon.png differ diff --git a/assets/trakt-logo.png b/assets/trakt-logo.png deleted file mode 100644 index 3b237e43..00000000 --- a/assets/trakt-logo.png +++ /dev/null @@ -1,2 +0,0 @@ -// This is a placeholder for a binary PNG file -// Replace this file with an actual Trakt logo image \ No newline at end of file diff --git a/docs/SUPABASE_SYNC.md b/docs/SUPABASE_SYNC.md new file mode 100644 index 00000000..494c2ddc --- /dev/null +++ b/docs/SUPABASE_SYNC.md @@ -0,0 +1,1254 @@ +# NuvioTV Supabase Sync Documentation + +This document describes the complete Supabase backend used by NuvioTV for cross-device data synchronization. It covers database schema, RPC functions, authentication, device linking, and integration patterns. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Prerequisites](#prerequisites) +3. [Database Schema](#database-schema) +4. [RPC Functions](#rpc-functions) +5. [Integration Guide](#integration-guide) +6. [Data Models](#data-models) +7. [Sync Behavior & Restrictions](#sync-behavior--restrictions) +8. [Error Handling](#error-handling) + +--- + +## Overview + +NuvioTV syncs the following data to Supabase so linked devices share the same state: + +| Data | Description | Trakt Override | +|------|-------------|----------------| +| **Plugins** | JavaScript plugin repository URLs | No (always syncs) | +| **Addons** | Stremio-compatible addon manifest URLs | No (always syncs) | +| **Watch Progress** | Per-movie/episode playback position | Yes (skipped when Trakt connected) | +| **Library** | Saved movies & TV shows | Yes (skipped when Trakt connected) | +| **Watched Items** | Permanent watched history (movies & episodes) | Yes (skipped when Trakt connected) | + +### Authentication Model + +- **Anonymous**: Auto-created account, can generate/claim sync codes +- **Email/Password**: Full account with permanent data storage +- **Linked Device**: A device linked to another account via sync code; reads/writes the owner's data + +### Security Model + +All data operations use **SECURITY DEFINER** RPC functions that call `get_sync_owner()` to resolve the effective user ID. This allows linked devices to transparently access the owner's data without needing direct RLS access. + +--- + +## Prerequisites + +- Supabase project with: + - **Auth** enabled (anonymous sign-in + email/password) + - **pgcrypto** extension enabled (for `crypt()`, `gen_salt()`) +- Environment variables: + - `SUPABASE_URL` — Your Supabase project URL + - `SUPABASE_ANON_KEY` — Your Supabase anonymous/public key + +--- + +## Database Schema + +### Tables + +#### `sync_codes` + +Temporary codes for device linking, protected by a bcrypt-hashed PIN. + +```sql +CREATE TABLE sync_codes ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + code TEXT NOT NULL, + pin_hash TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ DEFAULT 'infinity'::TIMESTAMPTZ +); + +ALTER TABLE sync_codes ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own sync codes" + ON sync_codes FOR ALL + USING (auth.uid() = owner_id) + WITH CHECK (auth.uid() = owner_id); +``` + +#### `linked_devices` + +Maps a child device's user ID to a parent (owner) user ID. + +```sql +CREATE TABLE linked_devices ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + device_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + device_name TEXT, + linked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(owner_id, device_user_id) +); + +ALTER TABLE linked_devices ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Owners can read their linked devices" + ON linked_devices FOR SELECT + USING (auth.uid() = owner_id); + +CREATE POLICY "Devices can read their own link" + ON linked_devices FOR SELECT + USING (auth.uid() = device_user_id); +``` + +#### `plugins` + +Plugin repository URLs synced across devices. + +```sql +CREATE TABLE plugins ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + url TEXT NOT NULL, + name TEXT, + enabled BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_plugins_user_id ON plugins(user_id); +ALTER TABLE plugins ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own plugins" + ON plugins FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +#### `addons` + +Addon manifest URLs synced across devices. + +```sql +CREATE TABLE addons ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + url TEXT NOT NULL, + name TEXT, + enabled BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_addons_user_id ON addons(user_id); +ALTER TABLE addons ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own addons" + ON addons FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +#### `watch_progress` + +Per-movie or per-episode playback progress. + +```sql +CREATE TABLE watch_progress ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + content_id TEXT NOT NULL, + content_type TEXT NOT NULL, + video_id TEXT NOT NULL, + season INTEGER, + episode INTEGER, + position BIGINT NOT NULL DEFAULT 0, + duration BIGINT NOT NULL DEFAULT 0, + last_watched BIGINT NOT NULL DEFAULT 0, + progress_key TEXT NOT NULL +); + +CREATE INDEX idx_watch_progress_user_id ON watch_progress(user_id); +ALTER TABLE watch_progress ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own watch progress" + ON watch_progress FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +#### `library_items` + +Saved movies and TV shows (bookmarks/favorites). + +```sql +CREATE TABLE library_items ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + content_id TEXT NOT NULL, + content_type TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + poster TEXT, + poster_shape TEXT NOT NULL DEFAULT 'POSTER', + background TEXT, + description TEXT, + release_info TEXT, + imdb_rating REAL, + genres TEXT[] DEFAULT '{}', + addon_base_url TEXT, + added_at BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(user_id, content_id, content_type) +); + +CREATE INDEX idx_library_items_user_id ON library_items(user_id); +ALTER TABLE library_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own library items" + ON library_items FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +#### `watched_items` + +Permanent watched history. Unlike `watch_progress` (which is capped and stores playback position), this table is a permanent record of everything the user has watched or marked as watched. Used to determine if a movie or episode should show a "watched" checkmark. + +```sql +CREATE TABLE watched_items ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + content_id TEXT NOT NULL, + content_type TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + season INTEGER, + episode INTEGER, + watched_at BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE UNIQUE INDEX idx_watched_items_unique + ON watched_items (user_id, content_id, COALESCE(season, -1), COALESCE(episode, -1)); + +CREATE INDEX idx_watched_items_user_id ON watched_items(user_id); + +ALTER TABLE watched_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own watched items" + ON watched_items FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +> **Note:** The unique index uses `COALESCE(season, -1)` and `COALESCE(episode, -1)` because PostgreSQL treats NULLs as distinct in unique constraints. Movies have `NULL` season/episode, so without COALESCE, multiple entries for the same movie would be allowed. + +### Triggers + +```sql +-- Auto-update updated_at timestamp +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +-- Apply to tables with updated_at +CREATE TRIGGER set_updated_at BEFORE UPDATE ON plugins FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER set_updated_at BEFORE UPDATE ON addons FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER set_updated_at BEFORE UPDATE ON sync_codes FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +``` + +--- + +## RPC Functions + +### Core: `get_sync_owner()` + +Resolves the effective user ID. If the current user is a linked device, returns the owner's ID. Otherwise returns the caller's own ID. This is the foundation of the linked-device sync model. + +```sql +CREATE OR REPLACE FUNCTION get_sync_owner() +RETURNS UUID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_owner_id uuid; +BEGIN + SELECT owner_id INTO v_owner_id + FROM linked_devices + WHERE device_user_id = auth.uid() + LIMIT 1; + + RETURN COALESCE(v_owner_id, auth.uid()); +END; +$$; + +GRANT EXECUTE ON FUNCTION get_sync_owner() TO authenticated; +``` + +### Core: `can_access_user_data(p_user_id UUID)` + +Helper to check if the current user can access another user's data (either they are that user, or they are a linked device). + +```sql +CREATE OR REPLACE FUNCTION can_access_user_data(p_user_id UUID) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + IF auth.uid() = p_user_id THEN + RETURN true; + END IF; + + IF EXISTS ( + SELECT 1 FROM public.linked_devices + WHERE owner_id = p_user_id + AND device_user_id = auth.uid() + ) THEN + RETURN true; + END IF; + + RETURN false; +END; +$$; + +GRANT EXECUTE ON FUNCTION can_access_user_data(UUID) TO authenticated; +``` + +### Device Linking: `generate_sync_code(p_pin TEXT)` + +Generates a sync code for the current user. If a code already exists, updates the PIN. The code format is `XXXX-XXXX-XXXX-XXXX-XXXX` (uppercase hex). PIN is bcrypt-hashed. + +```sql +CREATE OR REPLACE FUNCTION generate_sync_code(p_pin TEXT) +RETURNS TABLE(code TEXT) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id uuid; + v_existing_code text; + v_new_code text; + v_pin_hash text; +BEGIN + v_user_id := auth.uid(); + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT sc.code INTO v_existing_code + FROM sync_codes sc + WHERE sc.owner_id = v_user_id + ORDER BY sc.created_at DESC + LIMIT 1; + + IF v_existing_code IS NOT NULL THEN + v_pin_hash := crypt(p_pin, gen_salt('bf')); + UPDATE sync_codes + SET pin_hash = v_pin_hash + WHERE sync_codes.owner_id = v_user_id + AND sync_codes.code = v_existing_code; + RETURN QUERY SELECT v_existing_code; + RETURN; + END IF; + + v_new_code := upper( + substr(md5(random()::text || clock_timestamp()::text), 1, 4) || '-' || + substr(md5(random()::text || clock_timestamp()::text), 5, 4) || '-' || + substr(md5(random()::text || clock_timestamp()::text), 9, 4) || '-' || + substr(md5(random()::text || clock_timestamp()::text), 13, 4) || '-' || + substr(md5(random()::text || clock_timestamp()::text), 17, 4) + ); + + v_pin_hash := crypt(p_pin, gen_salt('bf')); + + INSERT INTO sync_codes (owner_id, code, pin_hash) + VALUES (v_user_id, v_new_code, v_pin_hash); + + RETURN QUERY SELECT v_new_code; +END; +$$; + +GRANT EXECUTE ON FUNCTION generate_sync_code(TEXT) TO authenticated; +``` + +### Device Linking: `get_sync_code(p_pin TEXT)` + +Retrieves the existing sync code for the current user, validated by PIN. + +```sql +CREATE OR REPLACE FUNCTION get_sync_code(p_pin TEXT) +RETURNS TABLE(code TEXT) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id uuid; + v_existing_code text; + v_existing_pin_hash text; +BEGIN + v_user_id := auth.uid(); + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT sc.code, sc.pin_hash + INTO v_existing_code, v_existing_pin_hash + FROM sync_codes sc + WHERE sc.owner_id = v_user_id + ORDER BY sc.created_at DESC + LIMIT 1; + + IF v_existing_code IS NULL THEN + RAISE EXCEPTION 'No sync code found. Generate one first.'; + END IF; + + IF v_existing_pin_hash != crypt(p_pin, v_existing_pin_hash) THEN + RAISE EXCEPTION 'Incorrect PIN'; + END IF; + + RETURN QUERY SELECT v_existing_code; +END; +$$; + +GRANT EXECUTE ON FUNCTION get_sync_code(TEXT) TO authenticated; +``` + +### Device Linking: `claim_sync_code(p_code TEXT, p_pin TEXT, p_device_name TEXT)` + +Links the current device to the owner of the sync code. Validates the PIN, then creates a `linked_devices` row. + +```sql +CREATE OR REPLACE FUNCTION claim_sync_code(p_code TEXT, p_pin TEXT, p_device_name TEXT DEFAULT NULL) +RETURNS TABLE(result_owner_id UUID, success BOOLEAN, message TEXT) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_owner_id uuid; + v_pin_hash text; +BEGIN + SELECT sc.owner_id, sc.pin_hash + INTO v_owner_id, v_pin_hash + FROM sync_codes sc + WHERE sc.code = p_code; + + IF v_owner_id IS NULL THEN + RETURN QUERY SELECT NULL::uuid, false, 'Sync code not found'::text; + RETURN; + END IF; + + IF crypt(p_pin, v_pin_hash) != v_pin_hash THEN + RETURN QUERY SELECT NULL::uuid, false, 'Incorrect PIN'::text; + RETURN; + END IF; + + INSERT INTO linked_devices (owner_id, device_user_id, device_name) + VALUES (v_owner_id, auth.uid(), p_device_name) + ON CONFLICT (owner_id, device_user_id) DO UPDATE + SET device_name = EXCLUDED.device_name; + + RETURN QUERY SELECT v_owner_id, true, 'Device linked successfully'::text; +END; +$$; + +GRANT EXECUTE ON FUNCTION claim_sync_code(TEXT, TEXT, TEXT) TO authenticated; +``` + +### Device Linking: `unlink_device(p_device_user_id UUID)` + +Removes a linked device. Only the owner can unlink their devices. + +```sql +CREATE OR REPLACE FUNCTION unlink_device(p_device_user_id UUID) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + DELETE FROM linked_devices + WHERE (owner_id = auth.uid() AND device_user_id = p_device_user_id) + OR (device_user_id = auth.uid() AND device_user_id = p_device_user_id); +END; +$$; + +GRANT EXECUTE ON FUNCTION unlink_device(UUID) TO authenticated; +``` + +### Sync: `sync_push_plugins(p_plugins JSONB)` + +Full-replace push of plugin repository URLs. + +```sql +CREATE OR REPLACE FUNCTION sync_push_plugins(p_plugins JSONB) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id uuid; + v_plugin jsonb; +BEGIN + SELECT get_sync_owner() INTO v_effective_user_id; + + DELETE FROM plugins WHERE user_id = v_effective_user_id; + + FOR v_plugin IN SELECT * FROM jsonb_array_elements(p_plugins) + LOOP + INSERT INTO plugins (user_id, url, name, enabled, sort_order) + VALUES ( + v_effective_user_id, + v_plugin->>'url', + v_plugin->>'name', + COALESCE((v_plugin->>'enabled')::boolean, true), + (v_plugin->>'sort_order')::int + ); + END LOOP; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_push_plugins(JSONB) TO authenticated; +``` + +### Sync: `sync_push_addons(p_addons JSONB)` + +Full-replace push of addon manifest URLs. + +```sql +CREATE OR REPLACE FUNCTION sync_push_addons(p_addons JSONB) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id uuid; + v_addon jsonb; +BEGIN + SELECT get_sync_owner() INTO v_effective_user_id; + + DELETE FROM addons WHERE user_id = v_effective_user_id; + + FOR v_addon IN SELECT * FROM jsonb_array_elements(p_addons) + LOOP + INSERT INTO addons (user_id, url, sort_order) + VALUES ( + v_effective_user_id, + v_addon->>'url', + (v_addon->>'sort_order')::int + ); + END LOOP; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_push_addons(JSONB) TO authenticated; +``` + +### Sync: `sync_push_watch_progress(p_entries JSONB)` + +Full-replace push of watch progress entries. + +```sql +CREATE OR REPLACE FUNCTION sync_push_watch_progress(p_entries JSONB) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + + DELETE FROM watch_progress WHERE user_id = v_effective_user_id; + + INSERT INTO watch_progress ( + user_id, content_id, content_type, video_id, + season, episode, position, duration, last_watched, progress_key + ) + SELECT + v_effective_user_id, + (entry->>'content_id'), + (entry->>'content_type'), + (entry->>'video_id'), + (entry->>'season')::INTEGER, + (entry->>'episode')::INTEGER, + (entry->>'position')::BIGINT, + (entry->>'duration')::BIGINT, + (entry->>'last_watched')::BIGINT, + (entry->>'progress_key') + FROM jsonb_array_elements(p_entries) AS entry; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_push_watch_progress(JSONB) TO authenticated; +``` + +### Sync: `sync_pull_watch_progress()` + +Returns all watch progress for the effective user (owner or linked device's owner). + +```sql +CREATE OR REPLACE FUNCTION sync_pull_watch_progress() +RETURNS SETOF watch_progress +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + RETURN QUERY SELECT * FROM watch_progress WHERE user_id = v_effective_user_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_pull_watch_progress() TO authenticated; +``` + +### Sync: `sync_push_library(p_items JSONB)` + +Full-replace push of library items. + +```sql +CREATE OR REPLACE FUNCTION sync_push_library(p_items JSONB) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + + DELETE FROM library_items WHERE user_id = v_effective_user_id; + + INSERT INTO library_items ( + user_id, content_id, content_type, name, poster, poster_shape, + background, description, release_info, imdb_rating, genres, + addon_base_url, added_at + ) + SELECT + v_effective_user_id, + (item->>'content_id'), + (item->>'content_type'), + COALESCE(item->>'name', ''), + (item->>'poster'), + COALESCE(item->>'poster_shape', 'POSTER'), + (item->>'background'), + (item->>'description'), + (item->>'release_info'), + (item->>'imdb_rating')::REAL, + COALESCE( + (SELECT array_agg(g::TEXT) FROM jsonb_array_elements_text(item->'genres') AS g), + '{}' + ), + (item->>'addon_base_url'), + COALESCE((item->>'added_at')::BIGINT, EXTRACT(EPOCH FROM now())::BIGINT * 1000) + FROM jsonb_array_elements(p_items) AS item; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_push_library(JSONB) TO authenticated; +``` + +### Sync: `sync_pull_library()` + +Returns all library items for the effective user. + +```sql +CREATE OR REPLACE FUNCTION sync_pull_library() +RETURNS SETOF library_items +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + RETURN QUERY SELECT * FROM library_items WHERE user_id = v_effective_user_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_pull_library() TO authenticated; +``` + +### Sync: `sync_push_watched_items(p_items JSONB)` + +Full-replace push of watched items (permanent watched history). + +```sql +CREATE OR REPLACE FUNCTION sync_push_watched_items(p_items JSONB) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + DELETE FROM watched_items WHERE user_id = v_effective_user_id; + INSERT INTO watched_items (user_id, content_id, content_type, title, season, episode, watched_at) + SELECT + v_effective_user_id, + (item->>'content_id'), + (item->>'content_type'), + COALESCE(item->>'title', ''), + (item->>'season')::INTEGER, + (item->>'episode')::INTEGER, + (item->>'watched_at')::BIGINT + FROM jsonb_array_elements(p_items) AS item; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_push_watched_items(JSONB) TO authenticated; +``` + +### Sync: `sync_pull_watched_items()` + +Returns all watched items for the effective user. + +```sql +CREATE OR REPLACE FUNCTION sync_pull_watched_items() +RETURNS SETOF watched_items +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + RETURN QUERY SELECT * FROM watched_items WHERE user_id = v_effective_user_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_pull_watched_items() TO authenticated; +``` + +--- + +## Integration Guide + +### 1. Authentication + +All API calls require a Supabase auth session. Initialize the Supabase client and authenticate: + +``` +POST {SUPABASE_URL}/auth/v1/signup +Headers: apikey: {SUPABASE_ANON_KEY} +Body: { "email": "user@example.com", "password": "..." } +``` + +Or for anonymous sign-in: + +``` +POST {SUPABASE_URL}/auth/v1/signup +Headers: apikey: {SUPABASE_ANON_KEY} +Body: {} +``` + +All subsequent requests include: +``` +Headers: + apikey: {SUPABASE_ANON_KEY} + Authorization: Bearer {ACCESS_TOKEN} +``` + +### 2. Calling RPC Functions + +All RPCs are called via the Supabase PostgREST endpoint: + +``` +POST {SUPABASE_URL}/rest/v1/rpc/{function_name} +Headers: + apikey: {SUPABASE_ANON_KEY} + Authorization: Bearer {ACCESS_TOKEN} + Content-Type: application/json +Body: { ...parameters... } +``` + +### 3. Device Linking Flow + +**Device A (Parent) — Generate Sync Code:** + +```json +// POST /rest/v1/rpc/generate_sync_code +{ "p_pin": "1234" } + +// Response: +[{ "code": "A1B2-C3D4-E5F6-G7H8-I9J0" }] +``` + +**Device B (Child) — Claim Sync Code:** + +```json +// POST /rest/v1/rpc/claim_sync_code +{ + "p_code": "A1B2-C3D4-E5F6-G7H8-I9J0", + "p_pin": "1234", + "p_device_name": "Living Room TV" +} + +// Response: +[{ + "result_owner_id": "uuid-of-device-a-user", + "success": true, + "message": "Device linked successfully" +}] +``` + +After claiming, Device B's `get_sync_owner()` will return Device A's user ID, so all push/pull operations operate on the shared data. + +**Retrieve Existing Code (with PIN):** + +```json +// POST /rest/v1/rpc/get_sync_code +{ "p_pin": "1234" } + +// Response: +[{ "code": "A1B2-C3D4-E5F6-G7H8-I9J0" }] +``` + +**Get Linked Devices:** + +``` +GET {SUPABASE_URL}/rest/v1/linked_devices?select=*&owner_id=eq.{your_user_id} +``` + +**Unlink a Device:** + +```json +// POST /rest/v1/rpc/unlink_device +{ "p_device_user_id": "uuid-of-device-to-unlink" } +``` + +### 4. Pushing Data + +All push RPCs use a **full-replace** strategy: existing data for the effective user is deleted, then the new data is inserted. This means you must always push the **complete** local dataset, not just changes. + +#### Push Plugins + +```json +// POST /rest/v1/rpc/sync_push_plugins +{ + "p_plugins": [ + { + "url": "https://example.com/plugin-repo", + "name": "My Plugin Repo", + "enabled": true, + "sort_order": 0 + } + ] +} +``` + +#### Push Addons + +```json +// POST /rest/v1/rpc/sync_push_addons +{ + "p_addons": [ + { + "url": "https://example.com/addon/manifest.json", + "sort_order": 0 + } + ] +} +``` + +#### Push Watch Progress + +```json +// POST /rest/v1/rpc/sync_push_watch_progress +{ + "p_entries": [ + { + "content_id": "tt1234567", + "content_type": "movie", + "video_id": "tt1234567", + "season": null, + "episode": null, + "position": 3600000, + "duration": 7200000, + "last_watched": 1700000000000, + "progress_key": "tt1234567" + }, + { + "content_id": "tt7654321", + "content_type": "series", + "video_id": "tt7654321:2:5", + "season": 2, + "episode": 5, + "position": 1800000, + "duration": 3600000, + "last_watched": 1700000000000, + "progress_key": "tt7654321_s2e5" + } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `content_id` | string | IMDB ID or content identifier | +| `content_type` | string | `"movie"` or `"series"` | +| `video_id` | string | Video stream identifier | +| `season` | int/null | Season number (null for movies) | +| `episode` | int/null | Episode number (null for movies) | +| `position` | long | Playback position in milliseconds | +| `duration` | long | Total duration in milliseconds | +| `last_watched` | long | Unix timestamp in milliseconds | +| `progress_key` | string | Unique key: `contentId` for movies, `contentId_s{S}e{E}` for episodes | + +#### Push Library Items + +```json +// POST /rest/v1/rpc/sync_push_library +{ + "p_items": [ + { + "content_id": "tt1234567", + "content_type": "movie", + "name": "Example Movie", + "poster": "https://image.tmdb.org/t/p/w500/poster.jpg", + "poster_shape": "POSTER", + "background": "https://image.tmdb.org/t/p/original/backdrop.jpg", + "description": "A great movie about...", + "release_info": "2024", + "imdb_rating": 8.5, + "genres": ["Action", "Thriller"], + "addon_base_url": "https://example.com/addon" + } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `content_id` | string | Yes | IMDB ID or content identifier | +| `content_type` | string | Yes | `"movie"` or `"series"` | +| `name` | string | No | Display name (defaults to `""`) | +| `poster` | string | No | Poster image URL | +| `poster_shape` | string | No | `"POSTER"`, `"LANDSCAPE"`, or `"SQUARE"` (defaults to `"POSTER"`) | +| `background` | string | No | Background/backdrop image URL | +| `description` | string | No | Content description | +| `release_info` | string | No | Release year or date string | +| `imdb_rating` | float | No | IMDB rating (0.0-10.0) | +| `genres` | string[] | No | Genre list (defaults to `[]`) | +| `addon_base_url` | string | No | Source addon base URL | +| `added_at` | long | No | Timestamp in ms (defaults to current time) | + +#### Push Watched Items + +```json +// POST /rest/v1/rpc/sync_push_watched_items +{ + "p_items": [ + { + "content_id": "tt1234567", + "content_type": "movie", + "title": "Example Movie", + "season": null, + "episode": null, + "watched_at": 1700000000000 + }, + { + "content_id": "tt7654321", + "content_type": "series", + "title": "Example Series", + "season": 2, + "episode": 5, + "watched_at": 1700000000000 + } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `content_id` | string | Yes | IMDB ID or content identifier | +| `content_type` | string | Yes | `"movie"` or `"series"` | +| `title` | string | No | Display name (defaults to `""`) | +| `season` | int/null | No | Season number (null for movies) | +| `episode` | int/null | No | Episode number (null for movies) | +| `watched_at` | long | Yes | Unix timestamp in milliseconds | + +### 5. Pulling Data + +#### Pull Watch Progress + +```json +// POST /rest/v1/rpc/sync_pull_watch_progress +{} + +// Response: array of watch_progress rows +[ + { + "id": "uuid", + "user_id": "uuid", + "content_id": "tt1234567", + "content_type": "movie", + "video_id": "tt1234567", + "season": null, + "episode": null, + "position": 3600000, + "duration": 7200000, + "last_watched": 1700000000000, + "progress_key": "tt1234567" + } +] +``` + +#### Pull Library Items + +```json +// POST /rest/v1/rpc/sync_pull_library +{} + +// Response: array of library_items rows +[ + { + "id": "uuid", + "user_id": "uuid", + "content_id": "tt1234567", + "content_type": "movie", + "name": "Example Movie", + "poster": "https://...", + "poster_shape": "POSTER", + "background": "https://...", + "description": "...", + "release_info": "2024", + "imdb_rating": 8.5, + "genres": ["Action", "Thriller"], + "addon_base_url": "https://...", + "added_at": 1700000000000, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } +] +``` + +#### Pull Watched Items + +```json +// POST /rest/v1/rpc/sync_pull_watched_items +{} + +// Response: array of watched_items rows +[ + { + "id": "uuid", + "user_id": "uuid", + "content_id": "tt1234567", + "content_type": "movie", + "title": "Example Movie", + "season": null, + "episode": null, + "watched_at": 1700000000000, + "created_at": "2024-01-01T00:00:00Z" + } +] +``` + +#### Pull Plugins/Addons (Direct Table Query) + +Plugins and addons are pulled via direct table queries using the effective user ID: + +``` +// First, get the effective user ID +POST /rest/v1/rpc/get_sync_owner +{} +// Response: "uuid-of-effective-owner" + +// Then query tables +GET /rest/v1/addons?select=*&user_id=eq.{effective_user_id}&order=sort_order +GET /rest/v1/plugins?select=*&user_id=eq.{effective_user_id}&order=sort_order +``` + +--- + +## Data Models + +### Plugin + +```json +{ + "url": "string (required)", + "name": "string (optional)", + "enabled": "boolean (default: true)", + "sort_order": "integer (default: 0)" +} +``` + +### Addon + +```json +{ + "url": "string (required)", + "sort_order": "integer (default: 0)" +} +``` + +### Watch Progress Entry + +```json +{ + "content_id": "string (required)", + "content_type": "string (required) - 'movie' | 'series'", + "video_id": "string (required)", + "season": "integer (optional, null for movies)", + "episode": "integer (optional, null for movies)", + "position": "long (required) - playback position in ms", + "duration": "long (required) - total duration in ms", + "last_watched": "long (required) - unix timestamp in ms", + "progress_key": "string (required) - unique key per entry" +} +``` + +### Library Item + +```json +{ + "content_id": "string (required)", + "content_type": "string (required) - 'movie' | 'series'", + "name": "string (default: '')", + "poster": "string (optional) - poster image URL", + "poster_shape": "string (default: 'POSTER') - 'POSTER' | 'LANDSCAPE' | 'SQUARE'", + "background": "string (optional) - backdrop image URL", + "description": "string (optional)", + "release_info": "string (optional) - release year/date", + "imdb_rating": "float (optional) - 0.0 to 10.0", + "genres": "string[] (default: []) - list of genre names", + "addon_base_url": "string (optional) - source addon URL", + "added_at": "long (default: current time) - unix timestamp in ms" +} +``` + +### Watched Item + +```json +{ + "content_id": "string (required)", + "content_type": "string (required) - 'movie' | 'series'", + "title": "string (default: '') - display name", + "season": "integer (optional, null for movies)", + "episode": "integer (optional, null for movies)", + "watched_at": "long (required) - unix timestamp in ms" +} +``` + +### Linked Device + +```json +{ + "owner_id": "uuid (required) - parent account user ID", + "device_user_id": "uuid (required) - this device's user ID", + "device_name": "string (optional) - human-readable device name", + "linked_at": "timestamptz (auto-set)" +} +``` + +### Sync Code + +```json +{ + "owner_id": "uuid - user who generated the code", + "code": "string - format: XXXX-XXXX-XXXX-XXXX-XXXX", + "pin_hash": "string - bcrypt hash of the PIN", + "is_active": "boolean (default: true)", + "expires_at": "timestamptz (default: infinity)" +} +``` + +--- + +## Sync Behavior & Restrictions + +### Startup Sync Flow + +When the app starts and the user is authenticated (anonymous or full account): + +1. **Pull plugins** from remote → install any new ones locally +2. **Pull addons** from remote → install any new ones locally +3. If Trakt is **NOT** connected: + - **Pull watch progress** → merge into local (additive) + - **Push watch progress** → so linked devices can pull + - **Pull library items** → merge into local (additive) + - **Push library items** → so linked devices can pull + - **Pull watched items** → merge into local (additive) + - **Push watched items** → so linked devices can pull + +### On-Demand Sync + +- **Plugins/Addons**: Pushed to remote immediately when added or removed +- **Watch Progress**: Pushed with a 2-second debounce after any playback position update +- **Library Items**: Pushed with a 2-second debounce after add or remove +- **Watched Items**: Pushed with a 2-second debounce after mark/unmark as watched + +### Merge Strategy + +- **Push**: Full-replace. The entire local dataset replaces the remote dataset. +- **Pull (merge)**: Additive. Remote items not already present locally are added. Existing local items are preserved. Match keys vary by data type: `content_id` + `content_type` for library, `content_id` + `season` + `episode` for watched items. + +### Trakt Override + +When Trakt is connected: +- **Watch progress**, **library**, and **watched items** sync via Supabase is **completely skipped** +- Trakt becomes the source of truth for these data types +- **Plugins** and **addons** always sync regardless of Trakt status + +### Push on Account Events + +| Event | Action | +|-------|--------| +| Sign up (email) | Push all local data to remote | +| Sign in (email) | Pull all remote data to local | +| Generate sync code | Push all local data to remote, then generate code | +| Claim sync code | Pull all remote data from owner to local | + +--- + +## Error Handling + +### Sync Code Errors + +| Error Message | Cause | +|---------------|-------| +| `Not authenticated` | No auth session | +| `No sync code found. Generate one first.` | Calling `get_sync_code` before generating | +| `Incorrect PIN` | Wrong PIN for `get_sync_code` or `claim_sync_code` | +| `Sync code not found` | Invalid or non-existent code in `claim_sync_code` | +| `Device linked successfully` | Success response from `claim_sync_code` | + +### Auth Errors + +| Error Message | Cause | +|---------------|-------| +| `Invalid login credentials` | Wrong email or password | +| `Email not confirmed` | Email verification pending | +| `User already registered` | Duplicate email signup | +| `Password is too short/weak` | Password policy violation | +| `Signup is disabled` | Admin disabled signups | +| `Rate limit` / `Too many requests` | Too many auth attempts | + +### Network Errors + +| Error Message | Cause | +|---------------|-------| +| `Unable to resolve host` | No internet | +| `Timeout` / `Timed out` | Connection timeout | +| `Connection refused` | Server unreachable | +| `404` | RPC function not found (missing migration) | +| `400` / `Bad request` | Invalid parameters | diff --git a/eas.json b/eas.json index b208a76d..d68dbe69 100644 --- a/eas.json +++ b/eas.json @@ -5,13 +5,22 @@ }, "build": { "development": { + "env": { + "SENTRY_DISABLE_AUTO_UPLOAD": "true" + }, "developmentClient": true, "distribution": "internal" }, "preview": { + "env": { + "SENTRY_DISABLE_AUTO_UPLOAD": "true" + }, "distribution": "internal" }, "production": { + "env": { + "SENTRY_DISABLE_AUTO_UPLOAD": "true" + }, "autoIncrement": true, "extends": "apk", "android": { @@ -21,12 +30,18 @@ } }, "release": { + "env": { + "SENTRY_DISABLE_AUTO_UPLOAD": "true" + }, "distribution": "store", "android": { "buildType": "app-bundle" } }, "apk": { + "env": { + "SENTRY_DISABLE_AUTO_UPLOAD": "true" + }, "android": { "buildType": "apk", "gradleCommand": ":app:assembleRelease" diff --git a/index.html b/index.html index 749e2f0a..fe298212 100644 --- a/index.html +++ b/index.html @@ -779,9 +779,9 @@
01
-

Stremio Addon Support

-

Full compatibility with Stremio addons. Access your favorite content - providers seamlessly.

+

Stremio Addon Integration

+

Supports user-installed Stremio addons for metadata and source + integration.

@@ -984,9 +984,9 @@

Copyright & DMCA

-

We respect the intellectual property rights of others. Since Nuvio does not host any content, we - cannot remove content from the internet. However, if you believe that the application interface - itself infringes on your rights, please contact us.

+

We respect the intellectual property rights of others. Nuvio does not host media content. + If you believe this project's code, assets, or interface infringes your rights, please submit + a notice through the official project contact channels listed on this site and repository.

@@ -1157,4 +1157,4 @@ - \ No newline at end of file + diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index 08e51391..d10fbf13 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -21,10 +21,10 @@ 730F1CE52F24B27100EF7E51 /* View+applyWidgetURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373D1473F5A74CBC9DBD108B /* View+applyWidgetURL.swift */; }; 730F1CE62F24B27100EF7E51 /* ViewHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3396D68881EF486E99FD480A /* ViewHelpers.swift */; }; 730F1CE72F24B27100EF7E51 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0F1D0037D1F24E60BDB57628 /* Assets.xcassets */; }; - 9FBA88F42E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F32E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerViewManager.swift */; }; - 9FBA88F52E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F12E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerModule.swift */; }; - 9FBA88F62E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F02E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerManager.m */; }; - 9FBA88F72E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F22E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerView.swift */; }; + 9FBA88F42E86ECD700892850 /* KSPlayerViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */; }; + 9FBA88F52E86ECD700892850 /* KSPlayerModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */; }; + 9FBA88F62E86ECD700892850 /* KSPlayerManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F02E86ECD700892850 /* KSPlayerManager.m */; }; + 9FBA88F72E86ECD700892850 /* KSPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F22E86ECD700892850 /* KSPlayerView.swift */; }; A0892AA96024D9EF7CA87A8A /* libPods-Nuvio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 349BFD3B214640DED8541999 /* libPods-Nuvio.a */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; @@ -115,10 +115,10 @@ 730F1CE82F24B29C00EF7E51 /* LiveActivityDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LiveActivityDebug.entitlements; sourceTree = ""; }; 73BB213C2E9EEAC700EC03F8 /* NuvioRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = NuvioRelease.entitlements; path = Nuvio/NuvioRelease.entitlements; sourceTree = ""; }; 8034143A77A946B5A793F967 /* Color+hex.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = "Color+hex.swift"; sourceTree = ""; }; - 9FBA88F02E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ../KSPlayer/RNBridge/KSPlayerManager.m; sourceTree = ""; }; - 9FBA88F12E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerModule.swift; sourceTree = ""; }; - 9FBA88F22E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerView.swift; sourceTree = ""; }; - 9FBA88F32E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerViewManager.swift; sourceTree = ""; }; + 9FBA88F02E86ECD700892850 /* KSPlayerManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ../KSPlayer/RNBridge/KSPlayerManager.m; sourceTree = ""; }; + 9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerModule.swift; sourceTree = ""; }; + 9FBA88F22E86ECD700892850 /* KSPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerView.swift; sourceTree = ""; }; + 9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerViewManager.swift; sourceTree = ""; }; A83D742B36224176A0AB3B25 /* LiveActivityWidgetBundle.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = LiveActivityWidgetBundle.swift; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Nuvio/SplashScreen.storyboard; sourceTree = ""; }; AD48662BB71E4C9C9E340289 /* LiveActivityWidget.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = LiveActivityWidget.swift; sourceTree = ""; }; @@ -155,10 +155,10 @@ 73BB213C2E9EEAC700EC03F8 /* NuvioRelease.entitlements */, F11748412D0307B40044C1D9 /* AppDelegate.swift */, F11748442D0722820044C1D9 /* Nuvio-Bridging-Header.h */, - 9FBA88F02E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerManager.m */, - 9FBA88F12E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerModule.swift */, - 9FBA88F22E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerView.swift */, - 9FBA88F32E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerViewManager.swift */, + 9FBA88F02E86ECD700892850 /* KSPlayerManager.m */, + 9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */, + 9FBA88F22E86ECD700892850 /* KSPlayerView.swift */, + 9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */, BB2F792B24A3F905000567C9 /* Supporting */, 13B07FB51A68108700A75B9A /* Images.xcassets */, 13B07FB61A68108700A75B9A /* Info.plist */, @@ -625,10 +625,10 @@ buildActionMask = 2147483647; files = ( F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, - 9FBA88F42E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerViewManager.swift in Sources */, - 9FBA88F52E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerModule.swift in Sources */, - 9FBA88F62E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerManager.m in Sources */, - 9FBA88F72E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerView.swift in Sources */, + 9FBA88F42E86ECD700892850 /* KSPlayerViewManager.swift in Sources */, + 9FBA88F52E86ECD700892850 /* KSPlayerModule.swift in Sources */, + 9FBA88F62E86ECD700892850 /* KSPlayerManager.m in Sources */, + 9FBA88F72E86ECD700892850 /* KSPlayerView.swift in Sources */, 2AA769395C1242F225F875AF /* ExpoModulesProvider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -689,7 +689,7 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; - PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; + PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub; PRODUCT_NAME = Nuvio; SUPPORTS_MACCATALYST = YES; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; diff --git a/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png index 929e1472..516ca835 100644 Binary files a/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png and b/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png index 8649d708..838b65bf 100644 Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png differ diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png index 8649d708..838b65bf 100644 Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png differ diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png index 8649d708..838b65bf 100644 Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png differ diff --git a/ios/Nuvio/Info.plist b/ios/Nuvio/Info.plist index 6b345fac..f136eb2a 100644 --- a/ios/Nuvio/Info.plist +++ b/ios/Nuvio/Info.plist @@ -39,7 +39,7 @@ CFBundleVersion - 35 + 37 LSMinimumSystemVersion 12.0 LSRequiresIPhoneOS diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java index 72007063..7db96bd3 100644 --- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java +++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java @@ -1599,6 +1599,29 @@ public class ReactExoplayerView extends FrameLayout implements Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group); audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate); audioTrack.setSelected(isSelected); + // Encode channel count, bitrate and mimeType into title so JS can read them reliably + // e.g. "English|ch:6|br:640000|mt:audio/ac3" + String existing = audioTrack.getTitle() != null ? audioTrack.getTitle() : ""; + if (format.channelCount != Format.NO_VALUE && format.channelCount > 0) { + existing = existing + "|ch:" + format.channelCount; + } + // Use bitrate, fall back to averageBitrate then peakBitrate + int effectiveBitrate = format.bitrate; + if (effectiveBitrate == Format.NO_VALUE || effectiveBitrate <= 0) { + effectiveBitrate = format.averageBitrate; + } + if (effectiveBitrate == Format.NO_VALUE || effectiveBitrate <= 0) { + effectiveBitrate = format.peakBitrate; + } + if (effectiveBitrate != Format.NO_VALUE && effectiveBitrate > 0) { + existing = existing + "|br:" + effectiveBitrate; + } + if (format.sampleMimeType != null && !format.sampleMimeType.isEmpty()) { + existing = existing + "|mt:" + format.sampleMimeType; + } + if (!existing.isEmpty()) { + audioTrack.setTitle(existing); + } audioTracks.add(audioTrack); } @@ -1794,7 +1817,25 @@ public class ReactExoplayerView extends FrameLayout implements Track track = new Track(); track.setIndex(groupIndex); track.setLanguage(format.language != null ? format.language : "unknown"); - track.setTitle(format.label != null ? format.label : "Track " + (groupIndex + 1)); + String baseTitle = format.label != null ? format.label : ""; + if (format.channelCount != Format.NO_VALUE && format.channelCount > 0) { + baseTitle = baseTitle + "|ch:" + format.channelCount; + } + // Use bitrate, fall back to averageBitrate then peakBitrate + int effectiveBitrate = format.bitrate; + if (effectiveBitrate == Format.NO_VALUE || effectiveBitrate <= 0) { + effectiveBitrate = format.averageBitrate; + } + if (effectiveBitrate == Format.NO_VALUE || effectiveBitrate <= 0) { + effectiveBitrate = format.peakBitrate; + } + if (effectiveBitrate != Format.NO_VALUE && effectiveBitrate > 0) { + baseTitle = baseTitle + "|br:" + effectiveBitrate; + } + if (format.sampleMimeType != null && !format.sampleMimeType.isEmpty()) { + baseTitle = baseTitle + "|mt:" + format.sampleMimeType; + } + track.setTitle(baseTitle); track.setSelected(false); // Don't report selection status - let PlayerView handle it if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType); diff --git a/nuvio-source.json b/nuvio-source.json index c8919604..0ac08b83 100644 --- a/nuvio-source.json +++ b/nuvio-source.json @@ -11,10 +11,10 @@ "apps": [ { "name": "Nuvio", - "bundleIdentifier": "com.nuvio.app", + "bundleIdentifier": "com.nuvio.hub", "developerName": "Tapframe", - "subtitle": "Streaming app for movies and TV shows", - "localizedDescription": "Nuvio is a comprehensive streaming application that provides access to a vast library of movies and TV shows.", + "subtitle": "Media player and discovery app", + "localizedDescription": "Nuvio is a media player and metadata discovery application for user-provided and user-installed sources.", "iconURL": "https://github.com/tapframe/NuvioStreaming/blob/main/assets/android/playstore-icon.png?raw=true", "tintColor": "#04dcfc", "category": "entertainment", @@ -30,6 +30,14 @@ "https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true" ], "versions": [ + { + "version": "1.4.1", + "buildVersion": "37", + "date": "2026-02-27", + "localizedDescription": "## Release Notes\n\n### Audio & Subtitle Track Info — @chrisk325\n- Added audio codec, channel count, and estimated bitrate info to the track selector\n- Improved parsing of audio/subtitle track metadata from the player\n- Fixed edge cases with DTS track info, AC3 codecs, and 6.1 channel tracks\n- Fixed handling when player does not return language info\n- Removed hardcoded track info in favor of dynamic parsing\n\n### Player & Streaming Fixes — @chrisk325\n- Fixed Kitsu ID duplication that prevented streams from being fetched via Kitsu IDs\n- Fixed a crash in the player\n\n### Performance — @chrisk325\n- Improved app launch time by reducing OTA update checks at startup\n\n### Addon Configuration\n- Removed hardcoded addon config URL guesses\n\n### UI & Image Improvements — @chrisk325\n- Switched to FastImage for the IMDb logo\n\n### Asset & Logo Updates — @milicevicivan\n- Updated TMDB logo to higher quality (SVG to PNG)\n- Updated Simkl favicon and logo to higher quality versions\n- Added Trakt favicon\n\n### Internationalization (i18n) — @saimuelbr, @milicevicivan, @albyalex96, @IberianSoldierPC\n- Added i18n support for the Nuvio Sync screen — @saimuelbr\n- Updated Italian localization — @albyalex96\n- Improved Spanish translation — @IberianSoldierPC\n- Updated translations for 23 languages (AR, BG, ZH-CN, CS, DE, ES, FIL, FR, HE, HI, IT, MK, NL, PL, PT-BR, PT-PT, RO, RU, SL, SQ, SR, TR) — @milicevicivan\n\n### Contributors\n- **@chrisk325** — Audio/subtitle track info, player fixes, performance\n- **@milicevicivan** — Asset updates, translations\n- **@saimuelbr** — i18n sync screen\n- **@albyalex96** — Italian localization\n- **@IberianSoldierPC** — Spanish translation", + "downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.4.1/Stable_1-4-1.ipa", + "size": 25700000 + }, { "version": "1.3.6", "buildVersion": "34", diff --git a/package-lock.json b/package-lock.json index bb70659e..e7c7a877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1540,7 +1540,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -2098,7 +2097,6 @@ "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", "license": "MIT", - "peer": true, "dependencies": { "anser": "^1.4.9", "pretty-format": "^29.7.0", @@ -2587,7 +2585,6 @@ "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -2773,7 +2770,6 @@ "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz", "integrity": "sha512-4U5okwjRqDPkjB572hfZtLXJ/LGfCo6vDwUB2KIPEUoSgqbIlw+UrbnaqVp3GS+dRvhMD27F2JObpHpYRlpF0Q==", "license": "MIT", - "peer": true, "dependencies": { "@lottiefiles/dotlottie-web": "0.44.0" }, @@ -3129,7 +3125,7 @@ "version": "0.72.8", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz", "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "invariant": "^2.2.4", @@ -3250,7 +3246,6 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.25.tgz", "integrity": "sha512-zQeWK9txDePWbYfqTs0C6jeRdJTm/7VhQtW/1IbJNDi9/rFIRzZule8bdQPAnf8QWUsNujRmi1J9OG/hhfbalg==", "license": "MIT", - "peer": true, "dependencies": { "@react-navigation/core": "^7.13.6", "escape-string-regexp": "^4.0.0", @@ -3892,7 +3887,6 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -4113,7 +4107,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4123,9 +4116,8 @@ "version": "0.72.8", "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz", "integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native/virtualized-lists": "^0.72.4", "@types/react": "*" @@ -4661,7 +4653,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -5065,7 +5056,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6295,7 +6285,6 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.29.tgz", "integrity": "sha512-9C90gyOzV83y2S3XzCbRDCuKYNaiyCzuP9ketv46acHCEZn+QTamPK/DobdghoSiofCmlfoaiD6/SzfxDiHMnw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.19", @@ -6499,7 +6488,6 @@ "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", "integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==", "license": "MIT", - "peer": true, "dependencies": { "ua-parser-js": "^0.7.33" }, @@ -6527,7 +6515,6 @@ "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", "license": "MIT", - "peer": true, "peerDependencies": { "expo": "*", "react-native": "*" @@ -6538,7 +6525,6 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz", "integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==", "license": "MIT", - "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -6634,7 +6620,6 @@ "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz", "integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==", "license": "MIT", - "peer": true, "dependencies": { "rtl-detect": "^1.0.2" }, @@ -7694,7 +7679,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -10601,7 +10585,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10642,7 +10625,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10700,7 +10682,6 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz", "integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==", "license": "MIT", - "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.4", @@ -10789,7 +10770,6 @@ "resolved": "https://registry.npmjs.org/react-native-bottom-tabs/-/react-native-bottom-tabs-1.1.0.tgz", "integrity": "sha512-Uu1gvM3i1Hb4DjVvR/38J1QVQEs0RkPc7K6yon99HgvRWWOyLs7kjPDhUswtb8ije4pKW712skIXWJ0lgKzbyQ==", "license": "MIT", - "peer": true, "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", @@ -10820,7 +10800,6 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.29.1.tgz", "integrity": "sha512-du3qmv0e3Sm7qsd9SfmHps+AggLiylcBBQ8ztz7WUtd8ZjKs5V3kekAbi9R2W9bRLSg47Ntp4GGMYZOhikQdZA==", "license": "MIT", - "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -10919,7 +10898,6 @@ "integrity": "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -10985,7 +10963,6 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.0.tgz", "integrity": "sha512-frhu5b8/m/VvaMWz48V8RxcsXnE3hrlErQ5chr21MzAeDCpY4X14sQjvm+jvu3aOI+7Cz2atdRpyhhIuqxVaXg==", "license": "MIT", - "peer": true, "dependencies": { "react-native-is-edge-to-edge": "1.2.1", "semver": "7.7.3" @@ -11025,7 +11002,6 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -11036,7 +11012,6 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.18.0.tgz", "integrity": "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ==", "license": "MIT", - "peer": true, "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -11051,7 +11026,6 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.1.tgz", "integrity": "sha512-ZUD1xwc3Hwo4cOmOLumjJVoc7lEf9oQFlHnLmgccLC19fNm6LVEdtB+Cnip6gEi0PG3wfvVzskViEtrySQP8Fw==", "license": "MIT", - "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", @@ -11280,7 +11254,6 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -11537,7 +11510,6 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13004,24 +12976,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.19" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", - "license": "MIT" - }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -13076,19 +13030,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -13163,9 +13104,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index e5f102df..8bff9af6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", - "build": "export NODE_ENV=production && cd android && ./gradlew assembleRelease", + "build": "export NODE_ENV=production && export SENTRY_DISABLE_AUTO_UPLOAD=true && cd android && ./gradlew assembleRelease", "postinstall": "patch-package" }, "dependencies": { diff --git a/patches/react-native-video+6.19.0.patch b/patches/react-native-video+6.19.0.patch index 7b94a7ac..1b5e0e2f 100644 --- a/patches/react-native-video+6.19.0.patch +++ b/patches/react-native-video+6.19.0.patch @@ -391,7 +391,7 @@ index e16ac96..54221ef 100644 + && activity.isInPictureInPictureMode(); + boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null + && activity.isInMultiWindowMode(); - if (playInBackground || isInPictureInPicture || isInMultiWindowMode) { + if (playInBackground || isInPictureInPicture || isInMultiWindowMode || enterPictureInPictureOnLeave) { return; } @@ -403,7 +407,7 @@ public class ReactExoplayerView extends FrameLayout implements diff --git a/src/assets/splash-icon-new.png b/src/assets/splash-icon-new.png index 07445d3e..d0bbb55f 100644 Binary files a/src/assets/splash-icon-new.png and b/src/assets/splash-icon-new.png differ diff --git a/src/assets/tmdb_logo.png b/src/assets/tmdb_logo.png index 47b79fdc..eee14277 100644 Binary files a/src/assets/tmdb_logo.png and b/src/assets/tmdb_logo.png differ diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 1f257307..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'; @@ -30,6 +31,7 @@ import Animated, { Extrapolation, useAnimatedScrollHandler, SharedValue, + useAnimatedReaction, } from 'react-native-reanimated'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { StreamingContent } from '../../services/catalogService'; @@ -163,10 +165,25 @@ const AppleTVHero: React.FC = ({ const [shouldResume, setShouldResume] = useState(false); const [type, setType] = useState<'movie' | 'series'>('movie'); - // Create internal scrollY if not provided externally + // Shared value for scroll position (for parallax effects) const internalScrollY = useSharedValue(0); const scrollY = externalScrollY || internalScrollY; + const [isOutOfView, setIsOutOfView] = useState(false); + + // Track if hero is in view + useAnimatedReaction( + () => scrollY.value, + (currentScrollY) => { + // If hero is more than 80% scrolled out of view, consider it out of view + const outOfView = currentScrollY > HERO_HEIGHT * 0.8; + if (outOfView !== isOutOfView) { + runOnJS(setIsOutOfView)(outOfView); + } + }, + [isOutOfView] + ); + // Determine items to display const items = useMemo(() => { if (allFeaturedContent && allFeaturedContent.length > 0) { @@ -354,9 +371,9 @@ const AppleTVHero: React.FC = ({ } }, [currentItem, loading, heroOpacity]); - // Stop trailer when screen loses focus + // Stop trailer when screen loses focus or scrolled out of view useEffect(() => { - if (!isFocused) { + if (!isFocused || isOutOfView) { // Pause this screen's trailer setTrailerShouldBePaused(true); setTrailerPlaying(false); @@ -365,20 +382,24 @@ const AppleTVHero: React.FC = ({ trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); - logger.info('[AppleTVHero] Screen lost focus - pausing trailer'); + if (!isFocused) { + logger.info('[AppleTVHero] Screen lost focus - pausing trailer'); + } else { + logger.info('[AppleTVHero] Scrolled out of view - pausing trailer'); + } } else { - // Screen gained focus - allow trailer to resume if it was ready + // Screen gained focus and is in view - allow trailer to resume if it was ready setTrailerShouldBePaused(false); // If trailer was ready and loaded, restore the video opacity if (trailerReady && trailerUrl) { - logger.info('[AppleTVHero] Screen gained focus - restoring trailer'); + logger.info('[AppleTVHero] Screen in focus and in view - restoring trailer'); thumbnailOpacity.value = withTiming(0, { duration: 800 }); trailerOpacity.value = withTiming(1, { duration: 800 }); setTrailerPlaying(true); } } - }, [isFocused, setTrailerPlaying, trailerOpacity, thumbnailOpacity, trailerReady, trailerUrl]); + }, [isFocused, isOutOfView, setTrailerPlaying, trailerOpacity, thumbnailOpacity, trailerReady, trailerUrl]); // Listen to navigation events to stop trailer when navigating to other screens useEffect(() => { @@ -420,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) { @@ -471,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(() => { @@ -714,7 +775,7 @@ const AppleTVHero: React.FC = ({ updateSetting('trailerMuted', !trailerMuted); }, [trailerMuted, updateSetting]); - // Auto-advance timer - PAUSE when trailer is playing + // Auto-advance timer - PAUSE when trailer is playing or out of view const startAutoPlay = useCallback(() => { if (autoPlayTimerRef.current) { clearTimeout(autoPlayTimerRef.current); @@ -722,16 +783,20 @@ const AppleTVHero: React.FC = ({ if (items.length <= 1) return; - // Don't auto-advance if trailer is playing - if (globalTrailerPlaying && trailerReady) { - logger.info('[AppleTVHero] Auto-rotation paused - trailer is playing'); + // Don't auto-advance if trailer is playing or out of view + if ((globalTrailerPlaying && trailerReady) || isOutOfView) { + if (isOutOfView) { + logger.info('[AppleTVHero] Auto-rotation paused - out of view'); + } else { + logger.info('[AppleTVHero] Auto-rotation paused - trailer is playing'); + } return; } autoPlayTimerRef.current = setTimeout(() => { const timeSinceInteraction = Date.now() - lastInteractionRef.current; // Only auto-advance if user hasn't interacted recently (5 seconds) and no trailer playing - if (timeSinceInteraction >= 5000 && (!globalTrailerPlaying || !trailerReady)) { + if (timeSinceInteraction >= 5000 && (!globalTrailerPlaying || !trailerReady) && !isOutOfView) { // Set next index preview for crossfade const nextIdx = (currentIndex + 1) % items.length; setNextIndex(nextIdx); @@ -757,7 +822,7 @@ const AppleTVHero: React.FC = ({ startAutoPlay(); } }, 25000); // Auto-advance every 25 seconds - }, [items.length, globalTrailerPlaying, trailerReady, currentIndex, dragDirection, dragProgress]); + }, [items.length, globalTrailerPlaying, trailerReady, currentIndex, dragDirection, dragProgress, isOutOfView]); useEffect(() => { startAutoPlay(); @@ -1054,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 104f4d9f..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 @@ -119,6 +120,7 @@ const ContinueWatchingSection = React.forwardRef((props, re const [loading, setLoading] = useState(true); const appState = useRef(AppState.currentState); const refreshTimerRef = useRef(null); + const pendingRefreshRef = useRef(false); const [deletingItemId, setDeletingItemId] = useState(null); const longPressTimeoutRef = useRef(null); @@ -292,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) => { @@ -301,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) { @@ -315,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 }; } } @@ -326,6 +346,7 @@ const ContinueWatchingSection = React.forwardRef((props, re // Modified loadContinueWatching to render incrementally const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => { if (isRefreshingRef.current) { + pendingRefreshRef.current = true; return; } @@ -368,6 +389,13 @@ const ContinueWatchingSection = React.forwardRef((props, re return candidateProgress > existingProgress; }; + const compareCwItems = (a: ContinueWatchingItem, b: ContinueWatchingItem): number => { + // 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); + }; + type LocalProgressEntry = { episodeId?: string; season?: number; @@ -466,7 +494,7 @@ const ContinueWatchingSection = React.forwardRef((props, re } const merged = Array.from(map.values()); - merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + merged.sort(compareCwItems); return merged; }); @@ -482,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). @@ -563,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; @@ -695,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}`, @@ -748,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 @@ -792,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 } @@ -839,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); @@ -902,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}`, @@ -949,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; @@ -971,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; @@ -980,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; @@ -995,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}`, @@ -1038,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); + } } } @@ -1143,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', { @@ -1272,7 +1383,7 @@ const ContinueWatchingSection = React.forwardRef((props, re }); // Sort by lastUpdated descending and set directly - adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + adjustedItems.sort(compareCwItems); // Debug final order (only if changed) try { @@ -1383,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}`, @@ -1515,7 +1631,7 @@ const ContinueWatchingSection = React.forwardRef((props, re return it; }); - adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + adjustedItems.sort(compareCwItems); setContinueWatchingItems(adjustedItems); } catch (err) { logger.error('[SimklSync] Error in Simkl merge:', err); @@ -1524,11 +1640,48 @@ 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 { setLoading(false); isRefreshingRef.current = false; + if (pendingRefreshRef.current) { + pendingRefreshRef.current = false; + setTimeout(() => { + loadContinueWatching(true); + }, 0); + } } }, [getCachedMetadata]); @@ -1602,6 +1755,13 @@ const ContinueWatchingSection = React.forwardRef((props, re // Initial load useEffect(() => { loadContinueWatching(); + const trailingRefreshId = setTimeout(() => { + loadContinueWatching(true); + }, 4000); + + return () => { + clearTimeout(trailingRefreshId); + }; }, [loadContinueWatching]); // Refresh on screen focus (lightweight, no polling) @@ -1788,15 +1948,15 @@ const ContinueWatchingSection = React.forwardRef((props, re try { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - // For series episodes, only remove the specific episode's local progress - // Don't add a base tombstone which would block all episodes of the series + // For series episodes, remove only that episode's progress. + // Do not wipe all series entries. const isEpisode = selectedItem.type === 'series' && selectedItem.season && selectedItem.episode; if (isEpisode) { - // Only remove local progress for this specific episode (no base tombstone) - await storageService.removeAllWatchProgressForContent( + const episodeId = `${selectedItem.id}:${selectedItem.season}:${selectedItem.episode}`; + await storageService.removeWatchProgress( selectedItem.id, selectedItem.type, - { addBaseTombstone: false } + episodeId ); } else { // For movies or whole series, add the base tombstone @@ -1879,271 +2039,275 @@ const ContinueWatchingSection = React.forwardRef((props, re }, [computedPosterWidth]); // Memoized render function for poster-style continue watching items - const renderPosterStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => ( - handleContentPress(item)} - onLongPress={() => handleLongPress(item)} - delayLongPress={800} - > - {/* Poster Image */} - - + const renderPosterStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => { + return ( + 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} - )} - - - ), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedPosterWidth, computedPosterHeight, isTV, isLargeTablet, settings.posterBorderRadius]); + {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 }) => ( - handleContentPress(item)} - onLongPress={() => handleLongPress(item)} - delayLongPress={800} - > - {/* Poster Image */} - - + const renderWideStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => { + return ( + 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]); + )} + + + ); + }, [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet, settings.posterBorderRadius, t]); // Choose the appropriate render function based on settings const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => { @@ -2190,7 +2354,7 @@ const ContinueWatchingSection = React.forwardRef((props, re (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0))} + data={continueWatchingItems} renderItem={renderContinueWatchingItem} keyExtractor={keyExtractor} horizontal diff --git a/src/components/metadata/.HeroSection.tsx.swp b/src/components/metadata/.HeroSection.tsx.swp deleted file mode 100644 index 6a8c4627..00000000 Binary files a/src/components/metadata/.HeroSection.tsx.swp and /dev/null differ diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index b5476013..976a7bb6 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 && ( = ({ imdbId, type }) return ( {config.isImage ? ( - ) : config.icon ? ( diff --git a/src/components/metadata/TrailerModal.tsx b/src/components/metadata/TrailerModal.tsx index 452a4a4f..a0d1eeae 100644 --- a/src/components/metadata/TrailerModal.tsx +++ b/src/components/metadata/TrailerModal.tsx @@ -159,35 +159,19 @@ const TrailerModal: React.FC = 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(({ diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 6ae37c89..6e956ce5 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -36,7 +36,8 @@ import { usePlayerControls, usePlayerSetup, useWatchProgress, - useNextEpisode + useNextEpisode, + useSkipSegments } from './hooks'; // Platform-specific hooks @@ -47,6 +48,7 @@ import { useTraktAutosync } from '../../hooks/useTraktAutosync'; import { useMetadata } from '../../hooks/useMetadata'; import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls'; import stremioService from '../../services/stremioService'; +import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; // Utils @@ -212,6 +214,16 @@ const KSPlayerCore: React.FC = () => { episodeId }); + const { segments: skipIntervals, outroSegment } = useSkipSegments({ + imdbId: imdbId || (id?.startsWith('tt') ? id : undefined), + type, + season, + episode, + malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, + kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, + enabled: settings.skipIntroEnabled + }); + const controls = usePlayerControls({ playerRef: ksPlayerRef, paused, @@ -219,7 +231,15 @@ const KSPlayerCore: React.FC = () => { currentTime, duration, isSeeking, - isMounted + isMounted, + onSeekComplete: (timeInSeconds) => { + if (!id || !type || duration <= 0) return; + void storageService.setWatchProgress(id, type, { + currentTime: timeInSeconds, + duration, + lastUpdated: Date.now() + }, episodeId); + } }); const currentMalId = (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id; @@ -1021,6 +1041,7 @@ const KSPlayerCore: React.FC = () => { malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id} kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined} releaseDate={releaseDate} + skipIntervals={skipIntervals} currentTime={currentTime} onSkip={(endTime) => controls.seekToTime(endTime)} controlsVisible={showControls} @@ -1048,6 +1069,7 @@ const KSPlayerCore: React.FC = () => { metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined} controlsVisible={showControls} controlsFixedOffset={126} + outroSegment={outroSegment} /> {/* Modals */} diff --git a/src/components/player/android/components/VideoSurface.tsx b/src/components/player/android/components/VideoSurface.tsx index 57321d56..d728baa4 100644 --- a/src/components/player/android/components/VideoSurface.tsx +++ b/src/components/player/android/components/VideoSurface.tsx @@ -55,6 +55,8 @@ interface VideoSurfaceProps { useExoPlayer?: boolean; onCodecError?: () => void; onEngineChange?: (engine: 'exoplayer' | 'mpv') => void; + enterPictureInPictureOnLeave?: boolean; + onPictureInPictureStatusChanged?: (isInPip: boolean) => void; // Subtitle Styling subtitleSize?: number; @@ -78,6 +80,141 @@ const isCodecError = (errorString: string): boolean => { }); }; +const EXOPLAYER_LANG_MAP: Record = { + en: 'English', eng: 'English', + es: 'Spanish', spa: 'Spanish', + fr: 'French', fre: 'French', + de: 'German', ger: 'German', + it: 'Italian', ita: 'Italian', + ja: 'Japanese', jpn: 'Japanese', + ko: 'Korean', kor: 'Korean', + zh: 'Chinese', chi: 'Chinese', + ru: 'Russian', rus: 'Russian', + pt: 'Portuguese', por: 'Portuguese', + hi: 'Hindi', hin: 'Hindi', + ar: 'Arabic', ara: 'Arabic', + nl: 'Dutch', dut: 'Dutch', + pl: 'Polish', pol: 'Polish', + tr: 'Turkish', tur: 'Turkish', +}; + +const exoMimeToCodec = (mimeType?: string): string | null => { + if (!mimeType) return null; + const mime = mimeType.toLowerCase(); + if (mime.includes('eac3') || mime.includes('ec-3')) return 'Dolby Digital Plus'; + if (mime.includes('ac3') || mime.includes('ac-3')) return 'Dolby Digital'; + if (mime.includes('truehd')) return 'TrueHD'; + if (mime.includes('dts.hd') || mime.includes('dts-hd') || mime.includes('dtshd')) return 'DTS-HD'; + if (mime.includes('dts.uhd') || mime.includes('dts:x')) return 'DTS:X'; + if (mime.includes('dts')) return 'DTS'; + if (mime.includes('aac')) return 'AAC'; + if (mime.includes('opus')) return 'Opus'; + if (mime.includes('vorbis')) return 'Vorbis'; + if (mime.includes('mp4a') || mime.includes('mpeg')) return 'MP3'; + if (mime.includes('flac')) return 'FLAC'; + return null; +}; + +export const buildExoAudioTrackName = (t: any, i: number): string => { + const parts: string[] = []; + + // Check both title and name fields for encoded metadata from Java + let rawTitle: string = t.title ?? t.name ?? ''; + let channelCount: number | null = null; + let encodedBitrate: number | null = null; + let encodedMimeType: string | null = null; + + // Extract |ch:N, |br:N, |mt:mime from title + const chMatch = rawTitle.match(/\|ch:(\d+)/); + if (chMatch) channelCount = parseInt(chMatch[1], 10); + + const brMatch = rawTitle.match(/\|br:(\d+)/); + if (brMatch) encodedBitrate = parseInt(brMatch[1], 10); + + const mtMatch = rawTitle.match(/\|mt:([^|]+)/); + if (mtMatch) encodedMimeType = mtMatch[1]; + + // Strip all encoded metadata from the display title + rawTitle = rawTitle.replace(/\|ch:\d+/g, '').replace(/\|br:\d+/g, '').replace(/\|mt:[^|]+/g, '').trim(); + + if (rawTitle) { + if (t.language) { + const lang = EXOPLAYER_LANG_MAP[t.language.toLowerCase()] ?? t.language.toUpperCase(); + if (!rawTitle.toLowerCase().includes(lang.toLowerCase())) { + parts.push(lang); + } + } + parts.push(rawTitle); + } else if (t.language) { + parts.push(EXOPLAYER_LANG_MAP[t.language.toLowerCase()] ?? t.language.toUpperCase()); + } + + // Use mimeType from track object, fall back to encoded mimeType from title + const mimeType = t.mimeType ?? encodedMimeType ?? null; + const codec = exoMimeToCodec(mimeType); + // Only append codec if title doesn't already mention it + const titleLowerForCodec = rawTitle.toLowerCase(); + const codecAlreadyInTitle = titleLowerForCodec.includes('dolby') || + titleLowerForCodec.includes('dts') || + titleLowerForCodec.includes('atmos') || + titleLowerForCodec.includes('aac') || + titleLowerForCodec.includes('truehd') || + titleLowerForCodec.includes('flac'); + if (codec && !codecAlreadyInTitle) parts.push(codec); + + // Use parsed channel count, fall back to bitrate-based guess for AC3/EAC3 + let ch = channelCount ?? t.channelCount ?? null; + if (ch == null) { + const mime = (mimeType ?? '').toLowerCase(); + const br = encodedBitrate ?? t.bitrate ?? 0; + if (mime.includes('ac3') || mime.includes('eac3') || mime.includes('ec-3')) { + if (br >= 500000) ch = 6; + else if (br > 0 && br <= 320000) ch = 2; + } + } + + if (ch != null && ch > 0) { + if (ch === 8) parts.push('7.1'); + else if (ch === 7) parts.push('6.1'); + else if (ch === 6) parts.push('5.1'); + else if (ch === 2) parts.push('2.0'); + else if (ch === 1) parts.push('Mono'); + else parts.push(`${ch}ch`); + } + + const bitrate = encodedBitrate ?? t.bitrate ?? t.bitRate ?? null; + if (bitrate != null && bitrate > 0) { + parts.push(`${Math.round(bitrate / 1000)} kbps`); + } + + return parts.length > 0 ? parts.join(' ') : `Track ${i + 1}`; +}; + + +export const buildExoSubtitleTrackName = (t: any, i: number): string => { + const parts: string[] = []; + const titleLower = (t.title ?? '').toLowerCase(); + if (t.title && t.title.trim()) { + // Prepend language if available and not already in the title + if (t.language) { + const lang = EXOPLAYER_LANG_MAP[t.language.toLowerCase()] ?? t.language.toUpperCase(); + if (!t.title.toLowerCase().includes(lang.toLowerCase())) { + parts.push(lang); + } + } + parts.push(t.title.trim()); + } else if (t.language) { + parts.push(EXOPLAYER_LANG_MAP[t.language.toLowerCase()] ?? t.language.toUpperCase()); + } + if (t.isHearingImpaired || titleLower.includes('sdh') || titleLower.includes('hearing impaired') || titleLower.includes('cc')) { + if (!titleLower.includes('sdh')) parts.push('SDH'); + } + if (t.isForced || titleLower.includes('forced')) { + if (!titleLower.includes('forced')) parts.push('Forced'); + } + return parts.length > 0 ? parts.join(' ') : `Track ${i + 1}`; +}; + export const VideoSurface: React.FC = ({ processedStreamUrl, videoType, @@ -109,6 +246,8 @@ export const VideoSurface: React.FC = ({ useExoPlayer = true, onCodecError, onEngineChange, + enterPictureInPictureOnLeave = false, + onPictureInPictureStatusChanged, // Subtitle Styling subtitleSize, subtitleColor, @@ -183,6 +322,14 @@ export const VideoSurface: React.FC = ({ console.log('[VideoSurface] Headers:', exoRequestHeaders); }, [streamUrl, useExoPlayer, exoRequestHeaders]); + const lastPipAutoEnterStateRef = useRef(null); + useEffect(() => { + if (!useExoPlayer) return; + if (lastPipAutoEnterStateRef.current === enterPictureInPictureOnLeave) return; + lastPipAutoEnterStateRef.current = enterPictureInPictureOnLeave; + logger.info(`[PiP] VideoSurface auto-enter-on-leave ${enterPictureInPictureOnLeave ? 'enabled' : 'disabled'}`); + }, [useExoPlayer, enterPictureInPictureOnLeave]); + useEffect(() => { if (mpvPlayerRef?.current && !useExoPlayer) { mpvPlayerRef.current.setResizeMode(getMpvResizeMode()); @@ -224,13 +371,13 @@ export const VideoSurface: React.FC = ({ const handleExoLoad = (data: any) => { const audioTracks = data.audioTracks?.map((t: any, i: number) => ({ id: i, - name: t.title || t.language || `Track ${i + 1}`, + name: buildExoAudioTrackName(t, i), language: t.language, })) ?? []; const subtitleTracks = data.textTracks?.map((t: any, i: number) => ({ id: i, - name: t.title || t.language || `Track ${i + 1}`, + name: buildExoSubtitleTrackName(t, i), language: t.language, })) ?? []; @@ -294,6 +441,20 @@ export const VideoSurface: React.FC = ({ onSeek({ currentTime: data.currentTime }); }; + const handleExoPictureInPictureStatusChanged = (event: any) => { + const isInPictureInPicture = typeof event === 'boolean' + ? event + : Boolean( + event?.isInPictureInPicture + ?? event?.isActive + ?? event?.nativeEvent?.isInPictureInPicture + ?? event?.nativeEvent?.isActive + ?? event?.value + ); + logger.info(`[PiP] VideoSurface status event: ${isInPictureInPicture ? 'entered' : 'exited'}`); + onPictureInPictureStatusChanged?.(isInPictureInPicture); + }; + const getExoResizeMode = (): ResizeMode => { switch (resizeMode) { case 'cover': @@ -367,6 +528,10 @@ export const VideoSurface: React.FC = ({ playInBackground={false} playWhenInactive={false} ignoreSilentSwitch="ignore" + // @ts-ignore - Prop supported by patched react-native-video + enterPictureInPictureOnLeave={enterPictureInPictureOnLeave} + // @ts-ignore - Prop supported by patched react-native-video + onPictureInPictureStatusChanged={handleExoPictureInPictureStatusChanged} automaticallyWaitsToMinimizeStalling={true} useTextureView={true} subtitleStyle={{ diff --git a/src/components/player/common/UpNextButton.tsx b/src/components/player/common/UpNextButton.tsx index 9958e0c6..77f44416 100644 --- a/src/components/player/common/UpNextButton.tsx +++ b/src/components/player/common/UpNextButton.tsx @@ -4,6 +4,7 @@ import { Animated } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { logger } from '../../../utils/logger'; import { LinearGradient } from 'expo-linear-gradient'; +import { SkipInterval } from '../../../services/introService'; export interface Insets { top: number; @@ -33,6 +34,7 @@ interface UpNextButtonProps { metadata?: { poster?: string; id?: string }; // Added metadata prop controlsVisible?: boolean; controlsFixedOffset?: number; + outroSegment?: SkipInterval | null; } const UpNextButton: React.FC = ({ @@ -49,6 +51,7 @@ const UpNextButton: React.FC = ({ metadata, controlsVisible = false, controlsFixedOffset = 100, + outroSegment, }) => { const [visible, setVisible] = useState(false); const opacity = useRef(new Animated.Value(0)).current; @@ -76,10 +79,20 @@ const UpNextButton: React.FC = ({ const shouldShow = useMemo(() => { if (!nextEpisode || duration <= 0) return false; + + // 1. Determine if we have a valid ending outro (within last 5 mins) + const hasValidEndingOutro = outroSegment && (duration - outroSegment.endTime < 300); + + if (hasValidEndingOutro) { + // If we have a valid outro, ONLY show after it finishes + // This prevents the 60s fallback from "jumping the gun" + return currentTime >= outroSegment.endTime; + } + + // 2. Standard Fallback (only if no valid ending outro was found) const timeRemaining = duration - currentTime; - // Be tolerant to timer jitter: show when under ~1 minute and above 10s - return timeRemaining < 61 && timeRemaining > 10; - }, [nextEpisode, duration, currentTime]); + return timeRemaining < 61 && timeRemaining > 0; + }, [nextEpisode, duration, currentTime, outroSegment]); // Debug logging removed to reduce console noise // The state is computed in shouldShow useMemo above diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index 29861710..85e5a6b4 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -12,6 +12,14 @@ import { useSettings } from '../../../hooks/useSettings'; import { introService } from '../../../services/introService'; import { toastService } from '../../../services/toastService'; +import PlayerAspectRatioIcon from '../../../../assets/player-icons/ic_player_aspect_ratio.svg'; +import PlayerAudioFilledIcon from '../../../../assets/player-icons/ic_player_audio_filled.svg'; +import PlayerAudioOutlineIcon from '../../../../assets/player-icons/ic_player_audio_outline.svg'; +import PlayerEpisodesIcon from '../../../../assets/player-icons/ic_player_episodes.svg'; +import PlayerPauseIcon from '../../../../assets/player-icons/ic_player_pause.svg'; +import PlayerPlayIcon from '../../../../assets/player-icons/ic_player_play.svg'; +import PlayerSourceIcon from '../../../../assets/player-icons/ic_player_source.svg'; +import PlayerSubtitlesIcon from '../../../../assets/player-icons/ic_player_subtitles.svg'; interface PlayerControlsProps { showControls: boolean; @@ -59,6 +67,8 @@ interface PlayerControlsProps { // MPV Switch (Android only) onSwitchToMPV?: () => void; useExoPlayer?: boolean; + canEnterPictureInPicture?: boolean; + onEnterPictureInPicture?: () => void; isBuffering?: boolean; imdbId?: string; } @@ -106,6 +116,8 @@ export const PlayerControls: React.FC = ({ onAirPlayPress, onSwitchToMPV, useExoPlayer, + canEnterPictureInPicture, + onEnterPictureInPicture, isBuffering = false, imdbId, }) => { @@ -391,6 +403,18 @@ export const PlayerControls: React.FC = ({ /> )} + {Platform.OS === 'android' && canEnterPictureInPicture && onEnterPictureInPicture && ( + + + + )} @@ -498,11 +522,11 @@ export const PlayerControls: React.FC = ({ {isBuffering ? ( ) : ( - + paused ? ( + + ) : ( + + ) )} @@ -594,7 +618,7 @@ export const PlayerControls: React.FC = ({ {/* Left Side: Aspect Ratio Button */} - + {/* Subtitle Button */} @@ -602,7 +626,7 @@ export const PlayerControls: React.FC = ({ style={styles.iconButton} onPress={() => setShowSubtitleModal(!isSubtitleModalOpen)} > - + {/* Change Source Button */} @@ -611,7 +635,7 @@ export const PlayerControls: React.FC = ({ style={styles.iconButton} onPress={() => setShowSourcesModal(true)} > - + )} @@ -624,13 +648,13 @@ export const PlayerControls: React.FC = ({ setShowAudioModal(true)} - disabled={ksAudioTracks.length <= 1} + disabled={ksAudioTracks.length < 1} > - + {ksAudioTracks.length < 1 ? ( + + ) : ( + + )} {/* Submit Intro Button */} @@ -653,7 +677,7 @@ export const PlayerControls: React.FC = ({ style={styles.iconButton} onPress={() => setShowEpisodesModal(true)} > - + )} diff --git a/src/components/player/hooks/index.ts b/src/components/player/hooks/index.ts index 570d3de1..8041cb78 100644 --- a/src/components/player/hooks/index.ts +++ b/src/components/player/hooks/index.ts @@ -20,3 +20,4 @@ export { usePlayerSetup } from './usePlayerSetup'; // Content export { useNextEpisode } from './useNextEpisode'; export { useWatchProgress } from './useWatchProgress'; +export { useSkipSegments } from './useSkipSegments'; diff --git a/src/components/player/hooks/usePlayerControls.ts b/src/components/player/hooks/usePlayerControls.ts index f2561c08..73bf2972 100644 --- a/src/components/player/hooks/usePlayerControls.ts +++ b/src/components/player/hooks/usePlayerControls.ts @@ -17,6 +17,7 @@ interface PlayerControlsConfig { duration: number; isSeeking: MutableRefObject; isMounted: MutableRefObject; + onSeekComplete?: (timeInSeconds: number) => void; } export const usePlayerControls = (config: PlayerControlsConfig) => { @@ -27,7 +28,8 @@ export const usePlayerControls = (config: PlayerControlsConfig) => { currentTime, duration, isSeeking, - isMounted + isMounted, + onSeekComplete } = config; // iOS seeking helpers @@ -54,6 +56,7 @@ export const usePlayerControls = (config: PlayerControlsConfig) => { // Actually perform the seek playerRef.current.seek(timeInSeconds); + onSeekComplete?.(timeInSeconds); // Debounce the seeking state reset seekTimeoutRef.current = setTimeout(() => { @@ -62,7 +65,7 @@ export const usePlayerControls = (config: PlayerControlsConfig) => { } }, 500); } - }, [duration, paused, playerRef, isSeeking, isMounted]); + }, [duration, paused, playerRef, isSeeking, isMounted, onSeekComplete]); const skip = useCallback((seconds: number) => { seekToTime(currentTime + seconds); diff --git a/src/components/player/hooks/useSkipSegments.ts b/src/components/player/hooks/useSkipSegments.ts new file mode 100644 index 00000000..a5e868fe --- /dev/null +++ b/src/components/player/hooks/useSkipSegments.ts @@ -0,0 +1,102 @@ +import { useState, useEffect, useRef } from 'react'; +import { introService, SkipInterval } from '../../../services/introService'; +import { logger } from '../../../utils/logger'; + +interface UseSkipSegmentsProps { + imdbId?: string; + type?: string; + season?: number; + episode?: number; + malId?: string; + kitsuId?: string; + releaseDate?: string; + enabled: boolean; +} + +export const useSkipSegments = ({ + imdbId, + type, + season, + episode, + malId, + kitsuId, + releaseDate, + enabled +}: UseSkipSegmentsProps) => { + const [segments, setSegments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const fetchedRef = useRef(false); + const lastKeyRef = useRef(''); + + useEffect(() => { + const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${releaseDate}`; + + if (!enabled || type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) { + setSegments([]); + setIsLoading(false); + fetchedRef.current = false; + lastKeyRef.current = ''; + return; + } + + if (lastKeyRef.current === key && fetchedRef.current) { + return; + } + + // Clear stale intervals while resolving a new episode/key. + if (lastKeyRef.current !== key) { + setSegments([]); + fetchedRef.current = false; + } + + lastKeyRef.current = key; + setIsLoading(true); + let cancelled = false; + + const fetchSegments = async () => { + try { + const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, releaseDate); + + // Ignore stale responses from old requests. + if (cancelled || lastKeyRef.current !== key) return; + setSegments(intervals); + fetchedRef.current = true; + } catch (error) { + if (cancelled || lastKeyRef.current !== key) return; + logger.error('[useSkipSegments] Error fetching skip data:', error); + setSegments([]); + // Keep this key retryable on transient failures. + fetchedRef.current = false; + } finally { + if (cancelled || lastKeyRef.current !== key) return; + setIsLoading(false); + } + }; + + fetchSegments(); + + return () => { + cancelled = true; + }; + }, [imdbId, type, season, episode, malId, kitsuId, releaseDate, enabled]); + + const getActiveSegment = (currentTime: number) => { + return segments.find( + s => currentTime >= s.startTime && currentTime < (s.endTime - 0.5) + ); + }; + + const outroSegment = segments + .filter(s => ['ed', 'outro', 'mixed-ed'].includes(s.type)) + .reduce((latest, interval) => { + if (!latest || interval.endTime > latest.endTime) return interval; + return latest; + }, null); + + return { + segments, + getActiveSegment, + outroSegment, + isLoading + }; +}; diff --git a/src/components/player/hooks/useWatchProgress.ts b/src/components/player/hooks/useWatchProgress.ts index 7b1f7cef..b873756d 100644 --- a/src/components/player/hooks/useWatchProgress.ts +++ b/src/components/player/hooks/useWatchProgress.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import { AppState, AppStateStatus } from 'react-native'; +import { AppState } from 'react-native'; import { storageService } from '../../../services/storageService'; import { logger } from '../../../utils/logger'; import { useSettings } from '../../../hooks/useSettings'; @@ -22,17 +22,18 @@ export const useWatchProgress = ( releaseDate?: string, malId?: number, dayIndex?: number, - tmdbId?: number + tmdbId?: number, + isInPictureInPicture: boolean = false ) => { const [resumePosition, setResumePosition] = useState(null); const [savedDuration, setSavedDuration] = useState(null); const [initialPosition, setInitialPosition] = useState(null); const [showResumeOverlay, setShowResumeOverlay] = useState(false); - const [progressSaveInterval, setProgressSaveInterval] = useState(null); - const { settings: appSettings } = useSettings(); const initialSeekTargetRef = useRef(null); const hasScrobbledRef = useRef(false); + const wasPausedRef = useRef(paused); + const [progressSaveInterval, setProgressSaveInterval] = useState(null); // Values refs for unmount cleanup and stale closure prevention const currentTimeRef = useRef(currentTime); @@ -44,6 +45,7 @@ export const useWatchProgress = ( const malIdRef = useRef(malId); const dayIndexRef = useRef(dayIndex); const tmdbIdRef = useRef(tmdbId); + const isInPictureInPictureRef = useRef(isInPictureInPicture); // Sync refs useEffect(() => { @@ -54,7 +56,8 @@ export const useWatchProgress = ( malIdRef.current = malId; dayIndexRef.current = dayIndex; tmdbIdRef.current = tmdbId; - }, [imdbId, season, episode, releaseDate, malId, dayIndex, tmdbId]); + isInPictureInPictureRef.current = isInPictureInPicture; + }, [imdbId, season, episode, releaseDate, malId, dayIndex, tmdbId, isInPictureInPicture]); // Reset scrobble flag when content changes useEffect(() => { @@ -69,13 +72,6 @@ export const useWatchProgress = ( durationRef.current = duration; }, [duration]); - useEffect(() => { - imdbIdRef.current = imdbId; - seasonRef.current = season; - episodeRef.current = episode; - releaseDateRef.current = releaseDate; - }, [imdbId, season, episode, releaseDate]); - // Keep latest traktAutosync ref to avoid dependency cycles in listeners const traktAutosyncRef = useRef(traktAutosync); useEffect(() => { @@ -99,9 +95,13 @@ export const useWatchProgress = ( try { await storageService.setWatchProgress(id, type, progress, episodeId); - // Trakt sync (end session) - // Use 'user_close' to force immediate sync - await traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'user_close'); + if (isInPictureInPictureRef.current) { + logger.log('[useWatchProgress] In PiP mode, skipping background playback end sync'); + } else { + // Trakt sync (end session) + // Use 'user_close' to force immediate sync + await traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'user_close'); + } } catch (error) { logger.error('[useWatchProgress] Error saving background progress:', error); } @@ -198,8 +198,18 @@ export const useWatchProgress = ( } }; - // Save Interval + useEffect(() => { + // Handle pause transitions (upstream) + if (wasPausedRef.current !== paused) { + const becamePaused = paused; + wasPausedRef.current = paused; + if (becamePaused) { + void saveWatchProgress(); + } + } + + // Handle periodic save when playing (MAL branch) if (id && type && !paused) { if (progressSaveInterval) clearInterval(progressSaveInterval); diff --git a/src/components/player/modals/SubmitIntroModal.tsx b/src/components/player/modals/SubmitIntroModal.tsx index 42873ba0..11afaa7c 100644 --- a/src/components/player/modals/SubmitIntroModal.tsx +++ b/src/components/player/modals/SubmitIntroModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet, TextInput, ActivityIndicator } from 'react-native'; +import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet, TextInput, ActivityIndicator, ScrollView } from 'react-native'; import { Ionicons, MaterialIcons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import Animated, { @@ -9,7 +9,7 @@ import Animated, { SlideOutDown, } from 'react-native-reanimated'; import { useSettings } from '../../../hooks/useSettings'; -import { introService } from '../../../services/introService'; +import { introService, SkipType } from '../../../services/introService'; import { toastService } from '../../../services/toastService'; interface SubmitIntroModalProps { @@ -67,6 +67,7 @@ export const SubmitIntroModal: React.FC = ({ const [startTimeStr, setStartTimeStr] = useState('00:00'); const [endTimeStr, setEndTimeStr] = useState(formatSecondsToMMSS(currentTime)); + const [segmentType, setSegmentType] = useState('intro'); const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { @@ -107,14 +108,15 @@ export const SubmitIntroModal: React.FC = ({ season, episode, startSec, - endSec + endSec, + segmentType ); if (success) { - toastService.success(t('player_ui.intro_submitted', { defaultValue: 'Intro submitted successfully' })); + toastService.success(t('player_ui.intro_submitted', { defaultValue: 'Segment submitted successfully' })); onClose(); } else { - toastService.error(t('player_ui.intro_submit_failed', { defaultValue: 'Failed to submit intro' })); + toastService.error(t('player_ui.intro_submit_failed', { defaultValue: 'Failed to submit segment' })); } } catch (error) { toastService.error('Error', 'An unexpected error occurred'); @@ -123,9 +125,11 @@ export const SubmitIntroModal: React.FC = ({ } }; - const startVal = parseTimeToSeconds(startTimeStr); - const endVal = parseTimeToSeconds(endTimeStr); - const durationSec = (startVal !== null && endVal !== null) ? endVal - startVal : 0; + const segmentTypes: { label: string; value: SkipType; icon: any }[] = [ + { label: 'Intro', value: 'intro', icon: 'play-circle-outline' }, + { label: 'Recap', value: 'recap', icon: 'replay' }, + { label: 'Outro', value: 'outro', icon: 'stop-circle' }, + ]; return ( @@ -144,13 +148,42 @@ export const SubmitIntroModal: React.FC = ({ style={[localStyles.modalContainer, { width: Math.min(width * 0.85, 380) }]} > - Submit Intro Timestamp + Submit Timestamps - + + {/* Segment Type Selector */} + + Segment Type + + {segmentTypes.map((type) => ( + setSegmentType(type.value)} + style={[ + localStyles.typeButton, + segmentType === type.value && localStyles.typeButtonActive + ]} + > + + + {type.label} + + + ))} + + + {/* Start Time Input */} @@ -214,7 +247,7 @@ export const SubmitIntroModal: React.FC = ({ )} - + @@ -239,6 +272,7 @@ const localStyles = StyleSheet.create({ shadowOpacity: 0.5, shadowRadius: 15, elevation: 20, + maxHeight: '80%', }, header: { flexDirection: 'row', @@ -257,6 +291,34 @@ const localStyles = StyleSheet.create({ content: { gap: 20, }, + typeRow: { + flexDirection: 'row', + gap: 8, + }, + typeButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 12, + paddingVertical: 10, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + }, + typeButtonActive: { + backgroundColor: 'white', + borderColor: 'white', + }, + typeButtonText: { + color: 'rgba(255,255,255,0.6)', + fontSize: 13, + fontWeight: '600', + }, + typeButtonTextActive: { + color: 'black', + }, inputRow: { flexDirection: 'row', alignItems: 'flex-end', @@ -295,22 +357,6 @@ const localStyles = StyleSheet.create({ fontSize: 13, fontWeight: '600', }, - summaryBox: { - backgroundColor: 'rgba(255,255,255,0.03)', - borderRadius: 16, - padding: 16, - marginTop: 8, - }, - summaryText: { - color: 'rgba(255,255,255,0.5)', - fontSize: 14, - marginBottom: 4, - }, - hintText: { - color: 'rgba(255,255,255,0.3)', - fontSize: 12, - fontStyle: 'italic', - }, buttonRow: { flexDirection: 'row', gap: 12, @@ -345,3 +391,4 @@ const localStyles = StyleSheet.create({ fontWeight: '700', }, }); + diff --git a/src/components/player/overlays/SkipIntroButton.tsx b/src/components/player/overlays/SkipIntroButton.tsx index 85b865f8..9aa8021f 100644 --- a/src/components/player/overlays/SkipIntroButton.tsx +++ b/src/components/player/overlays/SkipIntroButton.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Text, TouchableOpacity, StyleSheet, Platform } from 'react-native'; +import { Text, TouchableOpacity, StyleSheet, Platform, View } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, @@ -10,10 +10,11 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialIcons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; -import { introService, SkipInterval, SkipType } from '../../../services/introService'; +import { SkipInterval } from '../../../services/introService'; import { useTheme } from '../../../contexts/ThemeContext'; import { logger } from '../../../utils/logger'; import { useSettings } from '../../../hooks/useSettings'; +import { useSkipSegments } from '../hooks/useSkipSegments'; interface SkipIntroButtonProps { imdbId: string | undefined; @@ -23,6 +24,7 @@ interface SkipIntroButtonProps { malId?: string; kitsuId?: string; releaseDate?: string; + skipIntervals?: SkipInterval[] | null; currentTime: number; onSkip: (endTime: number) => void; controlsVisible?: boolean; @@ -37,6 +39,7 @@ export const SkipIntroButton: React.FC = ({ malId, kitsuId, releaseDate, + skipIntervals: externalSkipIntervals, currentTime, onSkip, controlsVisible = false, @@ -48,16 +51,26 @@ export const SkipIntroButton: React.FC = ({ const skipIntroEnabled = settings.skipIntroEnabled; + const { segments: fetchedSkipIntervals } = useSkipSegments({ + imdbId, + type, + season, + episode, + malId, + kitsuId, + releaseDate, + // Allow parent components to provide pre-fetched intervals to avoid duplicate requests. + enabled: skipIntroEnabled && !externalSkipIntervals + }); + const skipIntervals = externalSkipIntervals ?? fetchedSkipIntervals; + // State - const [skipIntervals, setSkipIntervals] = useState([]); const [currentInterval, setCurrentInterval] = useState(null); const [isVisible, setIsVisible] = useState(false); const [hasSkippedCurrent, setHasSkippedCurrent] = useState(false); const [autoHidden, setAutoHidden] = useState(false); // Refs - const fetchedRef = useRef(false); - const lastEpisodeRef = useRef(''); const autoHideTimerRef = useRef(null); // Animation values @@ -65,55 +78,11 @@ export const SkipIntroButton: React.FC = ({ const scale = useSharedValue(0.8); const translateY = useSharedValue(0); - // Fetch skip data when episode changes + // Reset skipped state when episode changes useEffect(() => { - const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`; - - if (!skipIntroEnabled) { - setSkipIntervals([]); - setCurrentInterval(null); - setIsVisible(false); - fetchedRef.current = false; - return; - } - - // Skip if not a series or missing required data (though MAL/Kitsu ID might be enough for some cases, usually need season/ep) - if (type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) { - setSkipIntervals([]); - fetchedRef.current = false; - return; - } - - // Skip if already fetched for this episode - if (lastEpisodeRef.current === episodeKey && fetchedRef.current) { - return; - } - - lastEpisodeRef.current = episodeKey; - fetchedRef.current = true; setHasSkippedCurrent(false); setAutoHidden(false); - setSkipIntervals([]); - - const fetchSkipData = async () => { - logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`); - try { - const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, releaseDate); - setSkipIntervals(intervals); - - if (intervals.length > 0) { - logger.log(`[SkipIntroButton] ✓ Found ${intervals.length} skip intervals:`, intervals); - } else { - logger.log(`[SkipIntroButton] ✗ No skip data available for this episode`); - } - } catch (error) { - logger.error('[SkipIntroButton] Error fetching skip data:', error); - setSkipIntervals([]); - } - }; - - fetchSkipData(); - }, [imdbId, type, season, episode, malId, kitsuId, releaseDate, skipIntroEnabled]); + }, [imdbId, season, episode, malId, kitsuId, releaseDate]); // Determine active interval based on current playback position useEffect(() => { @@ -280,7 +249,7 @@ export const SkipIntroButton: React.FC = ({ style={styles.icon} /> {getButtonText()} - { ...defaultAndroidHeaders, 'Accept': 'application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain', }; -}; \ No newline at end of file +}; diff --git a/src/components/video/TrailerPlayer.tsx b/src/components/video/TrailerPlayer.tsx index de8a7b91..b7aa6162 100644 --- a/src/components/video/TrailerPlayer.tsx +++ b/src/components/video/TrailerPlayer.tsx @@ -75,6 +75,11 @@ const TrailerPlayer = React.forwardRef(({ const [isFullscreen, setIsFullscreen] = useState(false); const [isComponentMounted, setIsComponentMounted] = useState(true); + // FIX: Track whether this player has ever been in a playing state. + // This prevents the globalTrailerPlaying effect from suppressing the + // very first play attempt before the global state has been set to true. + const hasBeenPlayingRef = useRef(false); + // Animated values const controlsOpacity = useSharedValue(0); const loadingOpacity = useSharedValue(1); @@ -150,6 +155,7 @@ const TrailerPlayer = React.forwardRef(({ useEffect(() => { if (isComponentMounted && paused === undefined) { setIsPlaying(autoPlay); + if (autoPlay) hasBeenPlayingRef.current = true; } }, [autoPlay, isComponentMounted, paused]); @@ -163,18 +169,23 @@ const TrailerPlayer = React.forwardRef(({ // Handle external paused prop to override playing state (highest priority) useEffect(() => { if (paused !== undefined) { - setIsPlaying(!paused); - logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${!paused}`); + const shouldPlay = !paused; + setIsPlaying(shouldPlay); + if (shouldPlay) hasBeenPlayingRef.current = true; + logger.info('TrailerPlayer', `External paused prop changed: ${paused}, setting isPlaying to ${shouldPlay}`); } }, [paused]); // Respond to global trailer state changes (e.g., when modal opens) - // Only apply if no external paused prop is controlling this + // Only apply if no external paused prop is controlling this. + // FIX: Only pause if this player has previously been in a playing state. + // This avoids the race condition where globalTrailerPlaying is still false + // at mount time (before the parent has called setTrailerPlaying(true)), + // which was causing the trailer to be immediately paused on every load. useEffect(() => { if (isComponentMounted && paused === undefined) { - // Always sync with global trailer state when pausing - // This ensures all trailers pause when one screen loses focus - if (!globalTrailerPlaying) { + if (!globalTrailerPlaying && hasBeenPlayingRef.current) { + // Only suppress if the player was previously playing — not on initial mount logger.info('TrailerPlayer', 'Global trailer paused - pausing this trailer'); setIsPlaying(false); } @@ -363,26 +374,18 @@ const TrailerPlayer = React.forwardRef(({ {/* Switch Mode */} - {!signupDisabled && ( - setMode(mode === 'signin' ? 'signup' : 'signin')} - activeOpacity={0.7} - style={{ marginTop: 16 }} - > - - {mode === 'signin' ? "Don't have an account? " : 'Already have an account? '} - - {mode === 'signin' ? 'Sign up' : 'Sign in'} - + setMode(mode === 'signin' ? 'signup' : 'signin')} + activeOpacity={0.7} + style={{ marginTop: 16 }} + > + + {mode === 'signin' ? "Don't have an account? " : 'Already have an account? '} + + {mode === 'signin' ? 'Sign up' : 'Sign in'} - - )} - - {/* Signup disabled message */} - {signupDisabled && mode === 'signin' && ( - - - New account creation is temporarily disabled - - - )} + + {/* Skip sign in - more prominent when coming from onboarding */} = ({ donor, currentTheme, isTablet }) { color: currentTheme.colors.mediumEmphasis }, isTablet && styles.tabletContributions ]}> - {donor.amount.toFixed(2)} {donor.currency} · {formatDonationDate(donor.date)} + {formatDonationDate(donor.date)} {donor.message ? ( @@ -876,7 +876,7 @@ const ContributorsScreen: React.FC = () => { { color: currentTheme.colors.mediumEmphasis }, isTablet && styles.tabletContributions ]}> - {entry.total.toFixed(2)} {entry.currency} · {entry.count} {entry.count === 1 ? 'donation' : 'donations'} + {entry.count} {entry.count === 1 ? 'donation' : 'donations'} Rank #{entry.rank} · Last: {formatDonationDate(entry.lastDate)} diff --git a/src/screens/DownloadsScreen.tsx b/src/screens/DownloadsScreen.tsx index 98208100..912797b6 100644 --- a/src/screens/DownloadsScreen.tsx +++ b/src/screens/DownloadsScreen.tsx @@ -12,6 +12,7 @@ import { Platform, Clipboard, Linking, + ScrollView, } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; @@ -657,12 +658,17 @@ const DownloadsScreen: React.FC = () => { isTablet={isTablet} > {downloads.length > 0 && ( - + {renderFilterButton('all', t('downloads.filter_all'), stats.total)} {renderFilterButton('downloading', t('downloads.filter_active'), stats.downloading)} {renderFilterButton('completed', t('downloads.filter_done'), stats.completed)} {renderFilterButton('paused', t('downloads.filter_paused'), stats.paused)} - + )} @@ -745,9 +751,14 @@ const styles = StyleSheet.create({ padding: 8, marginLeft: 8, }, + // testing add scroll + filterScrollContainer: { + flexGrow: 0, + }, filterContainer: { flexDirection: 'row', gap: isTablet ? 16 : 12, + paddingHorizontal: 16, }, filterButton: { flexDirection: 'row', @@ -756,6 +767,8 @@ const styles = StyleSheet.create({ paddingVertical: isTablet ? 10 : 8, borderRadius: 20, gap: 8, + flexShrink: 0, + minWidth: 'auto', }, filterButtonText: { fontSize: isTablet ? 16 : 14, diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index e5dd2eb1..4e710706 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -74,6 +74,8 @@ import { useScrollToTop } from '../contexts/ScrollToTopContext'; // Constants const CATALOG_SETTINGS_KEY = 'catalog_settings'; +const MAX_CONCURRENT_CATALOG_REQUESTS = 4; +const HOME_LOADING_SCREEN_TIMEOUT_MS = 5000; // In-memory cache for catalog settings to avoid repeated MMKV reads let cachedCatalogSettings: Record | null = null; @@ -134,6 +136,7 @@ const HomeScreen = () => { const [loadedCatalogCount, setLoadedCatalogCount] = useState(0); const [hasAddons, setHasAddons] = useState(null); const [hintVisible, setHintVisible] = useState(false); + const [loadingScreenTimedOut, setLoadingScreenTimedOut] = useState(false); const totalCatalogsRef = useRef(0); const [visibleCatalogCount, setVisibleCatalogCount] = useState(5); // Reduced for memory const insets = useSafeAreaInsets(); @@ -185,6 +188,7 @@ const HomeScreen = () => { if (isFetchingRef.current) return; isFetchingRef.current = true; + setLoadingScreenTimedOut(false); setCatalogsLoading(true); setCatalogs([]); setLoadedCatalogCount(0); @@ -210,6 +214,7 @@ const HomeScreen = () => { catalogService.getAllAddons(), stremioService.getInstalledAddonsAsync() ]); + const manifestByAddonId = new Map(addonManifests.map((manifest: any) => [manifest.id, manifest])); // Set hasAddons state based on whether we have any addons - ensure on main thread InteractionManager.runAfterInteractions(() => { @@ -220,14 +225,20 @@ const HomeScreen = () => { let catalogIndex = 0; const catalogQueue: (() => Promise)[] = []; - // Launch all catalog loaders in parallel - const launchAllCatalogs = () => { - while (catalogQueue.length > 0) { - const catalogLoader = catalogQueue.shift(); - if (catalogLoader) { - catalogLoader(); + // Launch loaders with bounded concurrency to reduce startup pressure + const launchCatalogLoaders = () => { + const workerCount = Math.min(MAX_CONCURRENT_CATALOG_REQUESTS, catalogQueue.length); + const workers = Array.from({ length: workerCount }, async () => { + while (catalogQueue.length > 0) { + const catalogLoader = catalogQueue.shift(); + if (!catalogLoader) return; + await catalogLoader(); } - } + }); + + void Promise.all(workers).catch((error) => { + if (__DEV__) console.warn('[HomeScreen] Catalog loader worker failed:', error); + }); }; for (const addon of addons) { @@ -243,7 +254,7 @@ const HomeScreen = () => { const catalogLoader = async () => { try { - const manifest = addonManifests.find((a: any) => a.id === addon.id); + const manifest = manifestByAddonId.get(addon.id); if (!manifest) return; const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); @@ -345,8 +356,8 @@ const HomeScreen = () => { setCatalogs(new Array(catalogIndex).fill(null)); }); - // Start all catalog requests in parallel - launchAllCatalogs(); + // Start catalog requests with bounded concurrency + launchCatalogLoaders(); } catch (error) { if (__DEV__) console.error('[HomeScreen] Error in progressive catalog loading:', error); InteractionManager.runAfterInteractions(() => { @@ -356,14 +367,29 @@ const HomeScreen = () => { } }, []); + // Hard cap for initial home loading spinner. + // Keeps Home responsive even if one or more catalog addons are slow. + useEffect(() => { + if (!(catalogsLoading && loadedCatalogCount === 0)) { + return; + } + + const timer = setTimeout(() => { + setLoadingScreenTimedOut(true); + }, HOME_LOADING_SCREEN_TIMEOUT_MS); + + return () => clearTimeout(timer); + }, [catalogsLoading, loadedCatalogCount]); + // Only count feature section as loading if it's enabled in settings // For catalogs, we show them progressively, so loading should be false as soon as we have any content const isLoading = useMemo(() => { + if (loadingScreenTimedOut) return false; // Exit loading as soon as at least one catalog is ready, regardless of featured if (loadedCatalogCount > 0) return false; const heroLoading = showHeroSection ? featuredLoading : false; return heroLoading && (catalogsLoading && loadedCatalogCount === 0); - }, [showHeroSection, featuredLoading, catalogsLoading, loadedCatalogCount]); + }, [loadingScreenTimedOut, showHeroSection, featuredLoading, catalogsLoading, loadedCatalogCount]); // Update global loading state useEffect(() => { @@ -1482,4 +1508,3 @@ const HomeScreenWithFocusSync = (props: any) => { }; export default React.memo(HomeScreenWithFocusSync); - diff --git a/src/screens/HomeScreenSettings.tsx b/src/screens/HomeScreenSettings.tsx index 74822d07..6182920b 100644 --- a/src/screens/HomeScreenSettings.tsx +++ b/src/screens/HomeScreenSettings.tsx @@ -11,7 +11,8 @@ import { Platform, useColorScheme, Animated, - Dimensions + Dimensions, + ActivityIndicator } from 'react-native'; import { useSettings } from '../hooks/useSettings'; import { LinearGradient } from 'expo-linear-gradient'; @@ -110,7 +111,7 @@ const SectionHeader: React.FC<{ title: string; isDarkMode: boolean; colors: any const HomeScreenSettings: React.FC = () => { const { t } = useTranslation(); - const { settings, updateSetting } = useSettings(); + const { settings, updateSetting, isLoaded } = useSettings(); const systemColorScheme = useColorScheme(); const { currentTheme } = useTheme(); const colors = currentTheme.colors; @@ -264,6 +265,20 @@ const HomeScreenSettings: React.FC = () => { /> ); + if (!isLoaded) { + return ( + + + + + + + ); + } + return ( { colors={colors} renderControl={ChevronRight} onPress={() => navigation.navigate('HeroCatalogs')} - isLast={true} /> )} + ( + handleUpdateSetting('preferExternalMetaAddonDetail', value)} + /> + )} + isLast={true} + /> {settings.showHeroSection && ( @@ -698,6 +726,11 @@ const styles = StyleSheet.create({ marginLeft: 6, fontWeight: '600', }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, }); -export default HomeScreenSettings; \ No newline at end of file +export default HomeScreenSettings; diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index c15eaaea..0356dee3 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -42,6 +42,7 @@ import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner'; import { useSettings } from '../hooks/useSettings'; import { useTranslation } from 'react-i18next'; import { useScrollToTop } from '../contexts/ScrollToTopContext'; +import { TMDBService } from '../services/tmdbService'; interface LibraryItem extends StreamingContent { progress?: number; @@ -75,6 +76,7 @@ interface TraktFolder { } const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; +const TRAKT_LIBRARY_SYNC_MODE_KEY = 'trakt_library_sync_mode'; function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: number } { const horizontalPadding = 26; @@ -89,8 +91,6 @@ function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: nu return { numColumns, itemWidth }; } -import { TMDBService } from '../services/tmdbService'; - const TraktItem = React.memo(({ item, width, @@ -126,10 +126,6 @@ const TraktItem = React.memo(({ tmdbId = await tmdbService.findTMDBIdByIMDB(item.imdbId); } - if (!tmdbId && item.traktId) { - - } - if (tmdbId) { let posterPath: string | null = null; @@ -278,6 +274,9 @@ const LibraryScreen = () => { const { currentTheme } = useTheme(); const { settings } = useSettings(); const flashListRef = useRef(null); + const [isSyncing, setIsSyncing] = useState(false); + const [traktSyncMode, setTraktSyncMode] = useState<'off' | 'manual' | 'automatic'>('off'); + const hasAutoSyncedThisSession = useRef(false); const scrollToTop = useCallback(() => { flashListRef.current?.scrollToOffset({ offset: 0, animated: true }); @@ -323,6 +322,29 @@ const LibraryScreen = () => { loadAllCollections: loadSimklCollections } = useSimklContext(); + // Load Trakt sync mode preferences + useEffect(() => { + const loadSyncMode = async () => { + try { + const mode = await mmkvStorage.getItem(TRAKT_LIBRARY_SYNC_MODE_KEY); + if (mode === 'manual' || mode === 'automatic') { + setTraktSyncMode(mode); + } else { + setTraktSyncMode('off'); + } + } catch (error) { + logger.error('[LibraryScreen] Failed to load sync mode:', error); + setTraktSyncMode('off'); + } + }; + + loadSyncMode(); + + // Reload when screen is focused (to pick up changes from settings) + const unsubscribe = navigation.addListener('focus', loadSyncMode); + return unsubscribe; + }, [navigation]); + useEffect(() => { const applyStatusBarConfig = () => { StatusBar.setBarStyle('light-content'); @@ -430,6 +452,248 @@ const LibraryScreen = () => { }; }, [navigation]); + // Refs to always have access to latest context values (avoids stale closure) + const watchlistMoviesRef = useRef(watchlistMovies); + const watchlistShowsRef = useRef(watchlistShows); + + useEffect(() => { + watchlistMoviesRef.current = watchlistMovies; + watchlistShowsRef.current = watchlistShows; + }, [watchlistMovies, watchlistShows]); + + // Sync Trakt watchlist to local library + const syncTraktWatchlistToLibrary = useCallback(async () => { + if (!traktAuthenticated) { + showError('Sync Failed', 'Please connect to Trakt first'); + return; + } + + setIsSyncing(true); + logger.log('[LibraryScreen] Starting Trakt watchlist sync...'); + + try { + // Load Trakt data fresh before syncing + logger.log('[LibraryScreen] Loading Trakt collections...'); + await loadAllCollections(); + + // Wait for React to process state updates + await new Promise(resolve => setTimeout(resolve, 100)); + + // Access FRESH values from refs (updated by useEffect) + const currentMovies = watchlistMoviesRef.current; + const currentShows = watchlistShowsRef.current; + + logger.log(`[LibraryScreen] Syncing ${currentMovies?.length || 0} movies and ${currentShows?.length || 0} shows`); + + const hasMovies = currentMovies && currentMovies.length > 0; + const hasShows = currentShows && currentShows.length > 0; + + if (!hasMovies && !hasShows) { + logger.error('[LibraryScreen] No Trakt watchlist data available'); + showError( + 'Sync Failed', + 'No items found in your Trakt watchlist. Add some movies or shows to your Trakt watchlist first.' + ); + return; + } + + const tmdbService = TMDBService.getInstance(); + let addedCount = 0; + let updatedCount = 0; + let removedCount = 0; + + const currentLibraryItems = await catalogService.getLibraryItems(); + const traktWatchlistImdbIds = new Set(); + + // Collect IMDb IDs from watchlist (using FRESH refs) + if (currentMovies) { + currentMovies.forEach(item => { + if (item.movie?.ids?.imdb) { + traktWatchlistImdbIds.add(item.movie.ids.imdb); + } + }); + } + + if (currentShows) { + currentShows.forEach(item => { + if (item.show?.ids?.imdb) { + traktWatchlistImdbIds.add(item.show.ids.imdb); + } + }); + } + + // Remove items not in watchlist + for (const libraryItem of currentLibraryItems) { + const imdbId = libraryItem.id; + if (imdbId.startsWith('tt') && !traktWatchlistImdbIds.has(imdbId)) { + logger.log(`[LibraryScreen] Removing: ${libraryItem.name}`); + await catalogService.removeFromLibrary(libraryItem.type, imdbId); + removedCount++; + } + } + + // Add/update movies (using FRESH refs) + if (currentMovies) { + for (const watchlistItem of currentMovies) { + const movie = watchlistItem.movie; + if (!movie?.ids?.imdb) continue; + + const imdbId = movie.ids.imdb; + const existingItem = currentLibraryItems.find( + item => item.id === imdbId && item.type === 'movie' + ); + + let posterUrl = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image'; + let overview = ''; + let genres: string[] = []; + let year = movie.year; + + try { + const tmdbId = await tmdbService.findTMDBIdByIMDB(imdbId); + if (tmdbId) { + const details = await tmdbService.getMovieDetails(String(tmdbId)); + if (details) { + if (details.poster_path) { + posterUrl = tmdbService.getImageUrl(details.poster_path, 'w500') || posterUrl; + } + overview = details.overview || ''; + genres = details.genres?.map((g: any) => g.name) || []; + year = details.release_date ? new Date(details.release_date).getFullYear() : year; + } + } + } catch (error) { + logger.error(`Failed to fetch TMDB data for ${movie.title}:`, error); + } + + if (posterUrl === 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image' && movie.images) { + const traktPosterUrl = TraktService.getTraktPosterUrl(movie.images); + if (traktPosterUrl) posterUrl = traktPosterUrl; + } + + const contentToAdd: StreamingContent = { + id: imdbId, + type: 'movie', + name: movie.title, + poster: posterUrl, + posterShape: 'poster', + year, + description: overview, + genres, + imdbRating: undefined, + inLibrary: true, + }; + + if (existingItem) { + if (existingItem.poster !== posterUrl) { + await catalogService.addToLibrary(contentToAdd); + updatedCount++; + } + } else { + await catalogService.addToLibrary(contentToAdd); + addedCount++; + } + } + } + + // Add/update shows (using FRESH refs) + if (currentShows) { + for (const watchlistItem of currentShows) { + const show = watchlistItem.show; + if (!show?.ids?.imdb) continue; + + const imdbId = show.ids.imdb; + const existingItem = currentLibraryItems.find( + item => item.id === imdbId && item.type === 'series' + ); + + let posterUrl = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image'; + let overview = ''; + let genres: string[] = []; + let year = show.year; + + try { + const tmdbId = await tmdbService.findTMDBIdByIMDB(imdbId); + if (tmdbId) { + const details = await tmdbService.getTVShowDetails(tmdbId); + if (details) { + if (details.poster_path) { + posterUrl = tmdbService.getImageUrl(details.poster_path, 'w500') || posterUrl; + } + overview = details.overview || ''; + genres = details.genres?.map((g: any) => g.name) || []; + year = details.first_air_date ? new Date(details.first_air_date).getFullYear() : year; + } + } + } catch (error) { + logger.error(`Failed to fetch TMDB data for ${show.title}:`, error); + } + + if (posterUrl === 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image' && show.images) { + const traktPosterUrl = TraktService.getTraktPosterUrl(show.images); + if (traktPosterUrl) posterUrl = traktPosterUrl; + } + + const contentToAdd: StreamingContent = { + id: imdbId, + type: 'series', + name: show.title, + poster: posterUrl, + posterShape: 'poster', + year, + description: overview, + genres, + imdbRating: undefined, + inLibrary: true, + }; + + if (existingItem) { + if (existingItem.poster !== posterUrl) { + await catalogService.addToLibrary(contentToAdd); + updatedCount++; + } + } else { + await catalogService.addToLibrary(contentToAdd); + addedCount++; + } + } + } + + // Show result + if (addedCount > 0 || updatedCount > 0 || removedCount > 0) { + let message = ''; + if (addedCount > 0) message += `Added ${addedCount}`; + if (updatedCount > 0) message += `${message ? ', updated ' : 'Updated '}${updatedCount}`; + if (removedCount > 0) message += `${message ? ', removed ' : 'Removed '}${removedCount}`; + showInfo('Sync Complete', message); + logger.log(`[LibraryScreen] Sync complete: ${message}`); + } else { + showInfo('Sync Complete', 'Library is up to date'); + } + } catch (error) { + logger.error('[LibraryScreen] Sync failed:', error); + showError('Sync Failed', 'Unable to sync. Please try again.'); + } finally { + setIsSyncing(false); + } + }, [traktAuthenticated, loadAllCollections, showInfo, showError]); + // Removed watchlistMovies and watchlistShows from deps - we access via refs! + + // Automatic sync on first visit + useEffect(() => { + if ( + traktSyncMode === 'automatic' && + traktAuthenticated && + !hasAutoSyncedThisSession.current && + !showTraktContent && + !showSimklContent + ) { + hasAutoSyncedThisSession.current = true; + logger.log('[LibraryScreen] Performing automatic sync'); + syncTraktWatchlistToLibrary(); + } + + }, [traktSyncMode, traktAuthenticated, showTraktContent, showSimklContent, syncTraktWatchlistToLibrary]); + const filteredItems = libraryItems.filter(item => { if (filter === 'movies') return item.type === 'movie'; if (filter === 'series') return item.type === 'series'; @@ -473,7 +737,7 @@ const LibraryScreen = () => { ]; return folders.filter(folder => folder.itemCount > 0); - }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); + }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent, t]); const simklFolders = useMemo((): TraktFolder[] => { if (!simklAuthenticated) return []; @@ -1591,6 +1855,9 @@ const LibraryScreen = () => { return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768); }, [width, height]); + // Show sync button only when mode is 'manual' and viewing local library + const shouldShowSyncButton = traktSyncMode === 'manual' && !showTraktContent && !showSimklContent && traktAuthenticated; + return ( { {showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : (filter === 'mal' ? renderMalContent() : renderContent())} + {/* Sync FAB - Bottom Right (only in manual mode) */} + {shouldShowSyncButton && ( + + {isSyncing ? ( + + ) : ( + + )} + + )} + {selectedItem && ( { const handleShowStreams = useCallback(() => { const { watchProgress } = watchProgressData; + const isImdb = id.startsWith('tt'); // Ensure trailer stops immediately before navigating to Streams try { pauseTrailer(); } catch { } // Helper to build episodeId from episode object const buildEpisodeId = (ep: any): string => { - return ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; + if (ep.stremioId) return ep.stremioId; + return isImdb + ? `${id}:${ep.season_number}:${ep.episode_number}` + : `${id}:${ep.episode_number}`; }; if (Object.keys(groupedEpisodes).length > 0) { @@ -608,38 +612,28 @@ const MetadataScreen: React.FC = () => { const parts = watchProgress.episodeId.split(':'); - if (parts.length === 3) { - // showId:season:episode - currentSeason = parseInt(parts[1], 10); - currentEpisode = parseInt(parts[2], 10); - } else if (parts.length === 2) { - // season:episode - currentSeason = parseInt(parts[0], 10); - currentEpisode = parseInt(parts[1], 10); - } else { - // pattern like s5e01 - const match = watchProgress.episodeId.match(/s(\d+)e(\d+)/i); - if (match) { - currentSeason = parseInt(match[1], 10); - currentEpisode = parseInt(match[2], 10); + if (isImdb) { + if (parts.length === 3) { + currentSeason = parseInt(parts[1], 10); + currentEpisode = parseInt(parts[2], 10); + } else if (parts.length === 2) { + currentEpisode = parseInt(parts[1], 10); } + } else { + currentEpisode = parts.length === 3 ? parseInt(parts[2], 10) : null; } - if (currentSeason !== null && currentEpisode !== null) { - // DIRECT APPROACH: Just create the next episode ID directly - // This ensures we navigate to the next episode even if it's not yet in our episodes array - const nextEpisodeId = `${id}:${currentSeason}:${currentEpisode + 1}`; - if (__DEV__) console.log(`[MetadataScreen] Created next episode ID directly: ${nextEpisodeId}`); - - // Still try to find the episode in our list to verify it exists - const nextEpisodeExists = episodes.some(ep => - ep.season_number === currentSeason && ep.episode_number === (currentEpisode + 1) - ); + if (currentEpisode !== null) { + const nextEpisodeId = isImdb + ? `${id}:${currentSeason || episodes[0]?.season_number || 1}:${currentEpisode + 1}` + : `${id}:${currentEpisode + 1}`; + if (__DEV__) console.log(`[MetadataScreen] Created next episode ID: ${nextEpisodeId}`); + const nextEpisodeExists = episodes.some(ep => ep.episode_number === (currentEpisode + 1)); if (nextEpisodeExists) { - if (__DEV__) console.log(`[MetadataScreen] Verified next episode S${currentSeason}E${currentEpisode + 1} exists in episodes list`); + if (__DEV__) console.log(`[MetadataScreen] Verified next episode exists`); } else { - if (__DEV__) console.log(`[MetadataScreen] Warning: Next episode S${currentSeason}E${currentEpisode + 1} not found in episodes list, but proceeding anyway`); + if (__DEV__) console.log(`[MetadataScreen] Warning: Next episode not found`); } targetEpisodeId = nextEpisodeId; @@ -653,10 +647,14 @@ const MetadataScreen: React.FC = () => { } if (targetEpisodeId) { - // Ensure the episodeId has showId prefix (id:season:episode) + // Ensure the episodeId has showId prefix (id:season:episode or id:episode) const epParts = targetEpisodeId.split(':'); let normalizedEpisodeId = targetEpisodeId; - if (epParts.length === 2) { + + if (epParts.length === 2 && !isImdb) { + normalizedEpisodeId = `${id}:${epParts[1]}`; + } + else if (epParts.length === 2 && isImdb) { normalizedEpisodeId = `${id}:${epParts[0]}:${epParts[1]}`; } if (__DEV__) console.log(`[MetadataScreen] Navigating to streams with episodeId: ${normalizedEpisodeId}`); @@ -669,7 +667,9 @@ const MetadataScreen: React.FC = () => { let fallbackEpisodeId = episodeId; if (episodeId && episodeId.split(':').length === 2) { const p = episodeId.split(':'); - fallbackEpisodeId = `${id}:${p[0]}:${p[1]}`; + if (!p[0].startsWith('tt')) { + fallbackEpisodeId = isImdb ? `${id}:${p[0]}:${p[1]}` : `${id}:${p[1]}`; + } } if (__DEV__) console.log(`[MetadataScreen] Navigating with fallback episodeId: ${fallbackEpisodeId}`); navigation.navigate('Streams', { id, type, episodeId: fallbackEpisodeId }); @@ -679,7 +679,16 @@ const MetadataScreen: React.FC = () => { if (!isScreenFocused) return; if (__DEV__) console.log('[MetadataScreen] Selected Episode:', episode.episode_number, episode.season_number); - const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`; + + let episodeId: string; + if (episode.stremioId) { + episodeId = episode.stremioId; + } else { + const isImdb = id.startsWith('tt'); + episodeId = isImdb + ? `${id}:${episode.season_number}:${episode.episode_number}` + : `${id}:${episode.episode_number}`; + } // Optimize navigation with requestAnimationFrame requestAnimationFrame(() => { diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx index 626fe0c7..7b3387ee 100644 --- a/src/screens/PluginsScreen.tsx +++ b/src/screens/PluginsScreen.tsx @@ -1085,7 +1085,7 @@ const PluginsScreen: React.FC = () => { if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) { openAlert( t('plugins.alert_invalid_url'), - 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nor include manifest.json:\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch/manifest.json\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master' + 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nor include manifest.json:\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch/manifest.json\n\nExample:\nhttps://raw.githubusercontent.com/your-username/your-repo/refs/heads/main' ); return; } @@ -1291,7 +1291,7 @@ const PluginsScreen: React.FC = () => { if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) { openAlert( 'Invalid URL Format', - 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master' + 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nExample:\nhttps://raw.githubusercontent.com/your-username/your-repo/refs/heads/main' ); return; } @@ -1413,11 +1413,6 @@ const PluginsScreen: React.FC = () => { ); }; - const handleUseDefaultRepo = () => { - const defaultUrl = 'https://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/main'; - setRepositoryUrl(defaultUrl); - }; - const handleToggleLocalScrapers = async (enabled: boolean) => { await updateSetting('enableLocalScrapers', enabled); @@ -2250,4 +2245,4 @@ const PluginsScreen: React.FC = () => { ); }; -export default PluginsScreen; \ No newline at end of file +export default PluginsScreen; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index b2ec3f11..eee4857a 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -11,7 +11,6 @@ import { StatusBar, Platform, Dimensions, - Linking, FlatList, Image, } from 'react-native'; @@ -362,6 +361,9 @@ const SettingsScreen: React.FC = () => { if (item && item.visible === false) return false; return true; }; + const showTraktItem = isItemVisible('trakt'); + const showSimklItem = isItemVisible('simkl'); + const showCloudSyncItem = isItemVisible('cloud_sync'); // Filter categories based on conditions const visibleCategories = SETTINGS_CATEGORIES.filter(category => { @@ -377,18 +379,35 @@ const SettingsScreen: React.FC = () => { case 'account': return ( - {isItemVisible('trakt') && ( + {showCloudSyncItem && ( + + } + renderControl={() => } + onPress={() => (navigation as any).navigate('SyncSettings')} + isLast={!showTraktItem && !showSimklItem && !isItemVisible('mal')} + isTablet={isTablet} + /> + )} + {showTraktItem && ( } renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} - isLast={!isItemVisible('simkl') && !isItemVisible('mal')} + isLast={!showSimklItem && !isItemVisible('mal')} isTablet={isTablet} /> )} - {isItemVisible('simkl') && ( + {showSimklItem && ( { } onPress={() => navigation.navigate('Backup')} @@ -694,19 +713,35 @@ const SettingsScreen: React.FC = () => { contentContainerStyle={styles.scrollContent} > {/* Account */} - {(settingsConfig?.categories?.['account']?.visible !== false) && (isItemVisible('trakt') || isItemVisible('simkl') || isItemVisible('mal')) && ( + {(settingsConfig?.categories?.['account']?.visible !== false) && (showTraktItem || showSimklItem || showCloudSyncItem || isItemVisible('mal')) && ( - {isItemVisible('trakt') && ( + {showCloudSyncItem && ( + + } + renderControl={() => } + onPress={() => (navigation as any).navigate('SyncSettings')} + isLast={!showTraktItem && !showSimklItem && !isItemVisible('mal')} + /> + )} + {showTraktItem && ( } renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} - isLast={!isItemVisible('simkl') && !isItemVisible('mal')} + isLast={!showSimklItem && !isItemVisible('mal')} /> )} - {isItemVisible('simkl') && ( + {showSimklItem && ( { /> - - Linking.openURL('https://discord.gg/KVgDTjhA4H')} - activeOpacity={0.7} - > - - - - Discord - - - - - Linking.openURL('https://www.reddit.com/r/Nuvio/')} - activeOpacity={0.7} - > - - - - Reddit - - - - {/* Monkey Animation */} @@ -950,7 +950,7 @@ const SettingsScreen: React.FC = () => { @@ -1175,19 +1175,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, @@ -1220,6 +1207,14 @@ const styles = StyleSheet.create({ width: 180, height: 180, }, + syncLogoIcon: { + width: 20, + height: 20, + }, + syncLogoIconTablet: { + width: 24, + height: 24, + }, brandLogoContainer: { alignItems: 'center', justifyContent: 'center', diff --git a/src/screens/SimklSettingsScreen.tsx b/src/screens/SimklSettingsScreen.tsx index 5bcb8087..3d1529fb 100644 --- a/src/screens/SimklSettingsScreen.tsx +++ b/src/screens/SimklSettingsScreen.tsx @@ -27,7 +27,6 @@ const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; // Simkl configuration const SIMKL_CLIENT_ID = process.env.EXPO_PUBLIC_SIMKL_CLIENT_ID as string; -const SIMKL_REDIRECT_URI = process.env.EXPO_PUBLIC_SIMKL_REDIRECT_URI || 'nuvio://auth/simkl'; const discovery = { authorizationEndpoint: 'https://simkl.com/oauth/authorize', @@ -76,9 +75,10 @@ const SimklSettingsScreen: React.FC = () => { { clientId: SIMKL_CLIENT_ID, scopes: [], // Simkl doesn't strictly use scopes for basic access - redirectUri: SIMKL_REDIRECT_URI, // Must match what is set in Simkl Dashboard + redirectUri: redirectUri, responseType: ResponseType.Code, - // codeChallengeMethod: CodeChallengeMethod.S256, // Simkl might not verify PKCE, but standard compliant + usePKCE: true, + codeChallengeMethod: CodeChallengeMethod.S256, }, discovery ); @@ -90,12 +90,12 @@ const SimklSettingsScreen: React.FC = () => { // Handle the response from the auth request useEffect(() => { if (response) { - if (response.type === 'success') { + if (response.type === 'success' && request?.codeVerifier) { const { code } = response.params; setIsExchangingCode(true); logger.log('[SimklSettingsScreen] Auth code received, exchanging...'); - simklService.exchangeCodeForToken(code) + simklService.exchangeCodeForToken(code, request.codeVerifier) .then(success => { if (success) { refreshAuthStatus(); @@ -109,11 +109,14 @@ const SimklSettingsScreen: React.FC = () => { openAlert(t('common.error'), t('simkl.auth_error_generic')); }) .finally(() => setIsExchangingCode(false)); + } else if (response.type === 'success') { + logger.error('[SimklSettingsScreen] Missing PKCE code verifier on successful auth response'); + openAlert(t('common.error'), t('simkl.auth_error_msg')); } else if (response.type === 'error') { openAlert(t('simkl.auth_error_title'), t('simkl.auth_error_generic') + ' ' + (response.error?.message || t('common.unknown'))); } } - }, [response, refreshAuthStatus]); + }, [response, refreshAuthStatus, request?.codeVerifier, t]); const handleSignIn = () => { if (!SIMKL_CLIENT_ID) { diff --git a/src/screens/SyncSettingsScreen.tsx b/src/screens/SyncSettingsScreen.tsx new file mode 100644 index 00000000..ea232a03 --- /dev/null +++ b/src/screens/SyncSettingsScreen.tsx @@ -0,0 +1,497 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { + ActivityIndicator, + ScrollView, + StatusBar, + StyleSheet, + Text, + TouchableOpacity, + useWindowDimensions, + View, +} from 'react-native'; +import { NavigationProp, useFocusEffect, useNavigation } from '@react-navigation/native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { MaterialIcons } from '@expo/vector-icons'; +import { RootStackParamList } from '../navigation/AppNavigator'; +import { useTheme } from '../contexts/ThemeContext'; +import CustomAlert from '../components/CustomAlert'; +import { supabaseSyncService, SupabaseUser, RemoteSyncStats } from '../services/supabaseSyncService'; +import { useAccount } from '../contexts/AccountContext'; +import { useTraktContext } from '../contexts/TraktContext'; +import { useSimklContext } from '../contexts/SimklContext'; +import { useTranslation } from 'react-i18next'; + +const SyncSettingsScreen: React.FC = () => { + const { currentTheme } = useTheme(); + const { t } = useTranslation(); + const { width } = useWindowDimensions(); + const isTablet = width >= 768; + const navigation = useNavigation>(); + const insets = useSafeAreaInsets(); + const { user, signOut } = useAccount(); + const { isAuthenticated: traktAuthenticated } = useTraktContext(); + const { isAuthenticated: simklAuthenticated } = useSimklContext(); + + const [loading, setLoading] = useState(false); + const [syncCodeLoading, setSyncCodeLoading] = useState(false); + const [sessionUser, setSessionUser] = useState(null); + const [ownerId, setOwnerId] = useState(null); + const [remoteStats, setRemoteStats] = useState(null); + + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [alertActions, setAlertActions] = useState void; style?: object }>>([]); + + const openAlert = useCallback( + (title: string, message: string, actions?: Array<{ label: string; onPress: () => void; style?: object }>) => { + setAlertTitle(title); + setAlertMessage(message); + setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]); + setAlertVisible(true); + }, + [] + ); + + const loadSyncState = useCallback(async () => { + setLoading(true); + try { + await supabaseSyncService.initialize(); + setSessionUser(supabaseSyncService.getCurrentSessionUser()); + const owner = await supabaseSyncService.getEffectiveOwnerId(); + setOwnerId(owner); + const stats = await supabaseSyncService.getRemoteStats(); + setRemoteStats(stats); + } catch (error: any) { + openAlert(t('common.error'), error?.message || t('settings.cloud_sync.auth.not_authenticated')); + } finally { + setLoading(false); + } + }, [openAlert]); + + useFocusEffect( + useCallback(() => { + loadSyncState(); + }, [loadSyncState]) + ); + + const authLabel = useMemo(() => { + if (!supabaseSyncService.isConfigured()) return t('settings.cloud_sync.auth.not_configured'); + if (!sessionUser) return t('settings.cloud_sync.auth.not_authenticated'); + return `${t('settings.cloud_sync.auth.email_session')} ${sessionUser.email ? `(${sessionUser.email})` : ''}`; + }, [sessionUser]); + + const statItems = useMemo(() => { + if (!remoteStats) return []; + return [ + { label: t('settings.cloud_sync.stats.plugins'), value: remoteStats.plugins }, + { label: t('settings.cloud_sync.stats.addons'), value: remoteStats.addons }, + { label: t('settings.cloud_sync.stats.watch_progress'), value: remoteStats.watchProgress }, + { label: t('settings.cloud_sync.stats.library_items'), value: remoteStats.libraryItems }, + { label: t('settings.cloud_sync.stats.watched_items'), value: remoteStats.watchedItems }, + ]; + }, [remoteStats]); + const isSignedIn = Boolean(user); + const externalSyncServices = useMemo( + () => [ + traktAuthenticated ? 'Trakt' : null, + simklAuthenticated ? 'Simkl' : null, + ].filter(Boolean) as string[], + [traktAuthenticated, simklAuthenticated] + ); + const externalSyncActive = externalSyncServices.length > 0; + + const handleManualSync = async () => { + setSyncCodeLoading(true); + try { + await supabaseSyncService.pullAllToLocal(); + openAlert(t('settings.cloud_sync.alerts.pull_success_title'), t('settings.cloud_sync.alerts.pull_success_msg')); + await loadSyncState(); + } catch (error: any) { + openAlert(t('settings.cloud_sync.alerts.pull_failed_title'), error?.message || t('settings.cloud_sync.alerts.pull_failed_msg')); + } finally { + setSyncCodeLoading(false); + } + }; + + const handleUploadLocalData = async () => { + setSyncCodeLoading(true); + try { + await supabaseSyncService.pushAllLocalData(); + openAlert(t('settings.cloud_sync.alerts.push_success_title'), t('settings.cloud_sync.alerts.push_success_msg')); + await loadSyncState(); + } catch (error: any) { + openAlert(t('settings.cloud_sync.alerts.push_failed_title'), error?.message || t('settings.cloud_sync.alerts.push_failed_msg')); + } finally { + setSyncCodeLoading(false); + } + }; + + const handleSignOut = async () => { + setSyncCodeLoading(true); + try { + await signOut(); + await loadSyncState(); + } catch (error: any) { + openAlert(t('settings.cloud_sync.alerts.sign_out_failed_title'), error?.message || t('settings.cloud_sync.alerts.sign_out_failed_msg')); + } finally { + setSyncCodeLoading(false); + } + }; + + return ( + + + + navigation.goBack()} style={styles.backButton}> + + {t('settings.title')} + + + + {t('settings.cloud_sync.title')} + + {loading ? ( + + + + ) : ( + <> + + + + + + {t('settings.cloud_sync.hero_title')} + + {t('settings.cloud_sync.hero_subtitle')} + + + + + + + + + {t('settings.cloud_sync.external_sync.title')} + + + {externalSyncActive + ? t('settings.cloud_sync.external_sync.active_msg', { + services: externalSyncServices.join(' + ') + }) + : t('settings.cloud_sync.external_sync.inactive_msg')} + + + + + + + {t('settings.cloud_sync.auth.account')} + + + {user?.email + ? t('settings.cloud_sync.auth.signed_in_as', { email: user.email }) + : t('settings.cloud_sync.auth.not_signed_in') + } + + + {!isSignedIn ? ( + navigation.navigate('Account')} + > + {t('settings.cloud_sync.actions.sign_in_up')} + + ) : ( + <> + navigation.navigate('AccountManage')} + > + {t('settings.cloud_sync.actions.manage_account')} + + + {t('settings.cloud_sync.actions.sign_out')} + + + )} + + + + {!isSignedIn ? ( + + + + {t('settings.cloud_sync.pre_auth.title')} + + + {t('settings.cloud_sync.pre_auth.description')} + + + {t('settings.cloud_sync.pre_auth.point_1')} + {t('settings.cloud_sync.pre_auth.point_2')} + + {!supabaseSyncService.isConfigured() && ( + + {t('settings.cloud_sync.pre_auth.env_warning')} + + )} + + ) : ( + <> + + + + {t('settings.cloud_sync.connection')} + + {authLabel} + + {t('settings.cloud_sync.auth.effective_owner', { id: ownerId || 'Unavailable' })} + + {!supabaseSyncService.isConfigured() && ( + + {t('settings.cloud_sync.pre_auth.env_warning')} + + )} + + + + + + {t('settings.cloud_sync.stats.title')} + + {!remoteStats ? ( + + {t('settings.cloud_sync.stats.signin_required')} + + ) : ( + + {statItems.map((item) => ( + + {item.value} + {item.label} + + ))} + + )} + + + + + + {t('settings.cloud_sync.actions.title')} + + + {t('settings.cloud_sync.actions.description')} + + + + {syncCodeLoading ? ( + + ) : ( + {t('settings.cloud_sync.actions.pull_btn')} + )} + + + {t('settings.cloud_sync.actions.push_btn')} + + + + + )} + + + )} + + setAlertVisible(false)} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + }, + backText: { + marginLeft: 8, + fontSize: 16, + fontWeight: '600', + }, + headerActions: { + minWidth: 32, + }, + screenTitle: { + fontSize: 32, + fontWeight: '800', + paddingHorizontal: 16, + marginTop: 4, + marginBottom: 10, + }, + content: { + padding: 16, + gap: 14, + }, + contentTablet: { + alignSelf: 'center', + width: '100%', + maxWidth: 980, + }, + heroCard: { + borderWidth: 1, + borderRadius: 16, + padding: 16, + }, + heroTopRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 12, + }, + heroTitleWrap: { + flex: 1, + }, + heroTitle: { + fontSize: 20, + fontWeight: '800', + marginBottom: 4, + }, + heroSubtitle: { + fontSize: 13, + lineHeight: 18, + }, + card: { + borderWidth: 1, + borderRadius: 14, + padding: 14, + gap: 10, + }, + noteCard: { + borderWidth: 1, + borderRadius: 14, + padding: 14, + gap: 8, + }, + preAuthCard: { + gap: 12, + }, + preAuthList: { + gap: 6, + marginTop: 2, + }, + preAuthItem: { + fontSize: 13, + lineHeight: 18, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + cardTitle: { + fontSize: 15, + fontWeight: '700', + }, + cardText: { + fontSize: 13, + lineHeight: 18, + }, + warning: { + fontSize: 12, + marginTop: 4, + }, + statsGrid: { + marginTop: 2, + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + statTile: { + width: '48%', + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 10, + paddingVertical: 8, + }, + statValue: { + fontSize: 18, + fontWeight: '800', + marginBottom: 2, + }, + statLabel: { + fontSize: 11, + fontWeight: '600', + }, + buttonRow: { + flexDirection: 'row', + gap: 10, + }, + button: { + flex: 1, + borderRadius: 10, + minHeight: 42, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 12, + }, + primaryButton: { + borderWidth: 0, + }, + secondaryButton: { + borderWidth: 1, + }, + buttonDisabled: { + opacity: 0.55, + }, + buttonText: { + color: '#fff', + fontWeight: '700', + fontSize: 13, + }, +}); + +export default SyncSettingsScreen; diff --git a/src/screens/TMDBSettingsScreen.tsx b/src/screens/TMDBSettingsScreen.tsx index 029ae8af..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' }, @@ -1175,8 +1176,14 @@ const TMDBSettingsScreen = () => { { code: 'uk', label: 'Українська', native: 'Ukrainian' }, { code: 'vi', label: 'Tiếng Việt', native: 'Vietnamese' }, { code: 'th', label: 'ไทย', native: 'Thai' }, - { code: 'hr', -label: 'Hrvatski', native: 'Croatian' }, + { code: 'hr', label: 'Hrvatski', native: 'Croatian' }, + { code: 'sr', label: 'Српски', native: 'Serbian' }, + { code: 'bg', label: 'български', native: 'Bulgarian' }, + { code: 'sl', label: 'Slovenščina', native: 'Slovenian' }, + { code: 'mk', label: 'Македонски', native: 'Macedonian' }, + { code: 'fil', label: 'Filipino', native: 'Filipino' }, + { code: 'sq', label: 'Shqipe', native: 'Albanian' }, + { code: 'ca', label: 'Català', native: 'Catalan' }, ]; const filteredLanguages = languages.filter(({ label, code, native }) => diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index 81b57bb6..c02119f1 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react'; import { View, Text, @@ -27,6 +27,8 @@ import { useSimklIntegration } from '../hooks/useSimklIntegration'; import { colors } from '../styles'; import CustomAlert from '../components/CustomAlert'; import { useTranslation } from 'react-i18next'; +import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'; +import { mmkvStorage } from '../services/mmkvStorage'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; @@ -36,6 +38,7 @@ const TRAKT_CLIENT_ID = process.env.EXPO_PUBLIC_TRAKT_CLIENT_ID as string; if (!TRAKT_CLIENT_ID) { throw new Error('Missing EXPO_PUBLIC_TRAKT_CLIENT_ID environment variable'); } + const discovery = { authorizationEndpoint: 'https://trakt.tv/oauth/authorize', tokenEndpoint: 'https://api.trakt.tv/oauth/token', @@ -47,6 +50,15 @@ const redirectUri = makeRedirectUri({ path: 'auth/trakt', }); +// Library Sync Mode constants +const TRAKT_LIBRARY_SYNC_MODE_KEY = 'trakt_library_sync_mode'; + +const LIBRARY_SYNC_MODE_OPTIONS = [ + { value: 'off', label: 'Off', description: 'Disable Trakt library sync completely' }, + { value: 'manual', label: 'Manual', description: 'Sync only when you tap the sync button' }, + { value: 'automatic', label: 'Automatic', description: 'Sync automatically when you open Library' }, +]; + const TraktSettingsScreen: React.FC = () => { const { t } = useTranslation(); const { settings, updateSetting } = useSettings(); @@ -80,6 +92,11 @@ const TraktSettingsScreen: React.FC = () => { { label: t('common.ok'), onPress: () => setAlertVisible(false) }, ]); + // Library Sync Mode state + const [librarySyncMode, setLibrarySyncMode] = useState<'off' | 'manual' | 'automatic'>('off'); + const librarySyncSheetRef = useRef(null); + const librarySyncSnapPoints = useMemo(() => ['45%'], []); + const openAlert = ( title: string, message: string, @@ -132,6 +149,26 @@ const TraktSettingsScreen: React.FC = () => { checkAuthStatus(); }, [checkAuthStatus]); + // Load library sync mode on mount + useEffect(() => { + const loadLibrarySyncMode = async () => { + if (isAuthenticated) { + try { + const mode = await mmkvStorage.getItem(TRAKT_LIBRARY_SYNC_MODE_KEY); + if (mode === 'manual' || mode === 'automatic') { + setLibrarySyncMode(mode); + } else { + setLibrarySyncMode('off'); + } + } catch (error) { + logger.error('[TraktSettingsScreen] Failed to load library sync mode:', error); + } + } + }; + + loadLibrarySyncMode(); + }, [isAuthenticated]); + // Setup expo-auth-session hook with PKCE const [request, response, promptAsync] = useAuthRequest( { @@ -230,6 +267,42 @@ const TraktSettingsScreen: React.FC = () => { ); }; + // Library Sync Mode handlers + const renderBackdrop = useCallback( + (props: any) => ( + + ), + [] + ); + + const handleSelectLibrarySyncMode = async (mode: 'off' | 'manual' | 'automatic') => { + try { + setLibrarySyncMode(mode); + await mmkvStorage.setItem(TRAKT_LIBRARY_SYNC_MODE_KEY, mode); + librarySyncSheetRef.current?.dismiss(); + + // Show confirmation + const modeLabel = LIBRARY_SYNC_MODE_OPTIONS.find(o => o.value === mode)?.label || mode; + openAlert( + 'Library Sync Mode Updated', + `Trakt library sync is now set to: ${modeLabel}` + ); + } catch (error) { + logger.error('[TraktSettingsScreen] Failed to save library sync mode:', error); + openAlert('Error', 'Failed to update library sync mode'); + } + }; + + const getLibrarySyncModeLabel = (mode: string): string => { + const option = LIBRARY_SYNC_MODE_OPTIONS.find(o => o.value === mode); + return option?.label || 'Off'; + }; + return ( { + + {/* Library Sync Mode Setting */} + + librarySyncSheetRef.current?.present()} + > + + + Library Sync Mode + + + {getLibrarySyncModeLabel(librarySyncMode)} - Sync your Trakt watchlist to local library + + + + + + @@ -589,8 +691,6 @@ const TraktSettingsScreen: React.FC = () => { - - )} @@ -606,6 +706,64 @@ const TraktSettingsScreen: React.FC = () => { onClose={() => setAlertVisible(false)} actions={alertActions} /> + + {/* Library Sync Mode Bottom Sheet */} + + + + Library Sync Mode + + + + {LIBRARY_SYNC_MODE_OPTIONS.map((option) => { + const isSelected = option.value === librarySyncMode; + return ( + handleSelectLibrarySyncMode(option.value as 'off' | 'manual' | 'automatic')} + > + + + {option.label} + + + {option.description} + + + {isSelected && ( + + )} + + ); + })} + + ); }; @@ -857,6 +1015,45 @@ const styles = StyleSheet.create({ marginVertical: 20, paddingHorizontal: 20, }, + // Bottom Sheet styles + sheetHeader: { + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 16, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255,255,255,0.1)', + }, + sheetTitle: { + fontSize: 18, + fontWeight: '600', + }, + sheetContent: { + paddingHorizontal: 16, + paddingVertical: 12, + }, + sourceItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + padding: 16, + borderRadius: 12, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + marginBottom: 12, + }, + sourceItemContent: { + flex: 1, + marginRight: 12, + }, + sourceLabel: { + fontSize: 16, + fontWeight: '600', + marginBottom: 4, + }, + sourceDescription: { + fontSize: 13, + lineHeight: 18, + }, }); export default TraktSettingsScreen; \ No newline at end of file diff --git a/src/screens/settings/AboutSettingsScreen.tsx b/src/screens/settings/AboutSettingsScreen.tsx index 04e3d3d4..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 */} @@ -391,7 +356,7 @@ export const AboutFooter: React.FC<{ displayDownloads: number | null }> = ({ dis @@ -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/screens/settings/DeveloperSettingsScreen.tsx b/src/screens/settings/DeveloperSettingsScreen.tsx index 8b002e27..fcee9a8a 100644 --- a/src/screens/settings/DeveloperSettingsScreen.tsx +++ b/src/screens/settings/DeveloperSettingsScreen.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { View, StyleSheet, ScrollView, StatusBar } from 'react-native'; +import { View, StyleSheet, ScrollView, StatusBar, Platform } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -83,6 +83,22 @@ const DeveloperSettingsScreen: React.FC = () => { ); }; + const handleOpenPipTestStream = () => { + const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; + navigation.navigate(playerRoute as any, { + uri: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8', + title: 'PiP Test Stream', + quality: '720', + streamProvider: 'Dev Test', + streamName: 'Mux HLS', + id: 'dev-pip-test', + type: 'movie', + headers: { + 'User-Agent': 'Nuvio-PiP-Test', + }, + }); + }; + // Only show if developer mode is enabled (via __DEV__ or manually unlocked) if (!developerModeEnabled) { return null; @@ -124,6 +140,13 @@ const DeveloperSettingsScreen: React.FC = () => { icon="refresh-cw" onPress={handleResetCampaigns} renderControl={() => } + /> + } isLast /> diff --git a/src/screens/settings/PlaybackSettingsScreen.tsx b/src/screens/settings/PlaybackSettingsScreen.tsx index c3559e3c..ed0abd6b 100644 --- a/src/screens/settings/PlaybackSettingsScreen.tsx +++ b/src/screens/settings/PlaybackSettingsScreen.tsx @@ -54,6 +54,15 @@ const AVAILABLE_LANGUAGES = [ { code: 'uk', name: 'Ukrainian' }, { code: 'he', name: 'Hebrew' }, { code: 'fa', name: 'Persian' }, + { code: 'hr', name: 'Croatian' }, + { code: 'sr', name: 'Serbian' }, + { code: 'bg', name: 'Bulgarian' }, + { code: 'sl', name: 'Slovenian' }, + { code: 'mk', name: 'Macedonian' }, + { code: 'fil', name: 'Filipino' }, + { code: 'ro', name: 'Romanian' }, + { code: 'sq', name: 'Albanian' }, + { code: 'ca', name: 'Catalan' }, ]; const SUBTITLE_SOURCE_OPTIONS = [ diff --git a/src/services/AccountService.ts b/src/services/AccountService.ts index 32dc943f..3d3d4440 100644 --- a/src/services/AccountService.ts +++ b/src/services/AccountService.ts @@ -1,4 +1,6 @@ import { mmkvStorage } from './mmkvStorage'; +import { supabaseSyncService, SupabaseUser } from './supabaseSyncService'; +import { logger } from '../utils/logger'; export type AuthUser = { id: string; @@ -9,6 +11,7 @@ export type AuthUser = { const USER_DATA_KEY = '@user:data'; const USER_SCOPE_KEY = '@user:current'; +const EMAIL_CONFIRMATION_REQUIRED_PREFIX = '__EMAIL_CONFIRMATION__'; class AccountService { private static instance: AccountService; @@ -19,23 +22,79 @@ class AccountService { return AccountService.instance; } + private mapSupabaseUser(user: SupabaseUser): AuthUser { + return { + id: user.id, + email: user.email, + displayName: user.user_metadata?.display_name as string | undefined, + avatarUrl: user.user_metadata?.avatar_url as string | undefined, + }; + } + + private async persistUser(user: AuthUser): Promise { + await mmkvStorage.setItem(USER_DATA_KEY, JSON.stringify(user)); + await mmkvStorage.setItem(USER_SCOPE_KEY, 'local'); + } + async signUpWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> { - // Since signup is disabled, always return error - return { error: 'Sign up is currently disabled due to upcoming system changes' }; + const result = await supabaseSyncService.signUpWithEmail(email, password); + if (result.error) { + return { error: result.error }; + } + + const sessionUser = supabaseSyncService.getCurrentSessionUser(); + if (!sessionUser) { + return { + error: `${EMAIL_CONFIRMATION_REQUIRED_PREFIX}Account created. Check your email to verify, then sign in.`, + }; + } + + const mapped = this.mapSupabaseUser(sessionUser); + await this.persistUser(mapped); + + try { + await supabaseSyncService.onSignUpPushAll(); + } catch (error) { + logger.error('[AccountService] Sign-up push-all failed:', error); + } + + return { user: mapped }; } async signInWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> { - // Since signin is disabled, always return error - return { error: 'Authentication is currently disabled' }; + const result = await supabaseSyncService.signInWithEmail(email, password); + if (result.error || !result.user) { + return { error: result.error || 'Sign in failed' }; + } + + const mapped = this.mapSupabaseUser(result.user); + await this.persistUser(mapped); + + try { + await supabaseSyncService.onSignInPullAll(); + } catch (error) { + logger.error('[AccountService] Sign-in pull-all failed:', error); + } + + return { user: mapped }; } async signOut(): Promise { + await supabaseSyncService.signOut(); await mmkvStorage.removeItem(USER_DATA_KEY); await mmkvStorage.setItem(USER_SCOPE_KEY, 'local'); } async getCurrentUser(): Promise { try { + await supabaseSyncService.initialize(); + const sessionUser = supabaseSyncService.getCurrentSessionUser(); + if (sessionUser) { + const mapped = this.mapSupabaseUser(sessionUser); + await this.persistUser(mapped); + return mapped; + } + const userData = await mmkvStorage.getItem(USER_DATA_KEY); if (!userData) return null; return JSON.parse(userData); @@ -69,4 +128,3 @@ class AccountService { export const accountService = AccountService.getInstance(); export default accountService; - diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 8265045f..7c773a05 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -126,10 +126,18 @@ interface OpenRouterResponse { }; } +interface OpenRouterErrorResponse { + error?: { + message?: string; + code?: string; + }; +} + class AIService { private static instance: AIService; private apiKey: string | null = null; private baseUrl = 'https://openrouter.ai/api/v1'; + private defaultModel = 'openrouter/free'; private constructor() { } @@ -151,12 +159,41 @@ class AIService { } async isConfigured(): Promise { - if (!this.apiKey) { - await this.initialize(); - } + // Always refresh from storage so key changes in settings are picked up immediately. + await this.initialize(); return !!this.apiKey; } + private async getPreferredModels(): Promise { + const configuredModel = (await mmkvStorage.getItem('openrouter_model'))?.trim(); + if (!configuredModel) { + return [this.defaultModel]; + } + return [configuredModel]; + } + + private async parseErrorResponse(response: Response): Promise<{ + statusLine: string; + message: string; + raw: string; + }> { + const raw = await response.text(); + let message = ''; + + try { + const parsed = JSON.parse(raw) as OpenRouterErrorResponse; + message = parsed.error?.message || ''; + } catch { + message = raw; + } + + return { + statusLine: `${response.status} ${response.statusText}`, + message: (message || '').trim(), + raw, + }; + } + private createSystemPrompt(context: ContentContext): string { const isSeries = 'episodesBySeason' in (context as any); const isEpisode = !isSeries && 'showTitle' in (context as any); @@ -349,6 +386,9 @@ Answer questions about this movie using only the verified database information a }); } + const model = (await this.getPreferredModels())[0]; + if (__DEV__) console.log('[AIService] Using model:', model); + const response = await fetch(`${this.baseUrl}/chat/completions`, { method: 'POST', headers: { @@ -358,7 +398,7 @@ Answer questions about this movie using only the verified database information a 'X-Title': 'Nuvio - AI Chat', }, body: JSON.stringify({ - model: 'xiaomi/mimo-v2-flash:free', + model, messages, max_tokens: 1000, temperature: 0.7, @@ -369,9 +409,17 @@ Answer questions about this movie using only the verified database information a }); if (!response.ok) { - const errorText = await response.text(); - if (__DEV__) console.error('[AIService] API Error:', response.status, errorText); - throw new Error(`API request failed: ${response.status} ${response.statusText}`); + const parsedError = await this.parseErrorResponse(response); + + if (__DEV__) { + console.error('[AIService] API Error:', { + model, + status: parsedError.statusLine, + message: parsedError.message || parsedError.raw, + }); + } + + throw new Error(`API request failed: ${parsedError.statusLine} - ${parsedError.message || parsedError.raw || 'Request failed'}`); } const data: OpenRouterResponse = await response.json(); diff --git a/src/services/introService.ts b/src/services/introService.ts index 44f51d44..db6c949d 100644 --- a/src/services/introService.ts +++ b/src/services/introService.ts @@ -27,11 +27,21 @@ export interface IntroTimestamps { imdb_id: string; season: number; episode: number; - start_sec: number; - end_sec: number; - start_ms: number; - end_ms: number; - confidence: number; + intro?: { + start_sec: number; + end_sec: number; + confidence: number; + }; + recap?: { + start_sec: number; + end_sec: number; + confidence: number; + }; + outro?: { + start_sec: number; + end_sec: number; + confidence: number; + }; } async function getMalIdFromArm(imdbId: string): Promise { @@ -155,7 +165,7 @@ async function fetchFromAniSkip(malId: string, episode: number): Promise { try { - const response = await axios.get(`${INTRODB_API_URL}/intro`, { + const response = await axios.get(`${INTRODB_API_URL}/segments`, { params: { imdb_id: imdbId, season, @@ -164,26 +174,48 @@ async function fetchFromIntroDb(imdbId: string, season: number, episode: number) timeout: 5000, }); - logger.log(`[IntroService] Found intro for ${imdbId} S${season}E${episode}:`, { - start: response.data.start_sec, - end: response.data.end_sec, - confidence: response.data.confidence, - }); + const intervals: SkipInterval[] = []; - return [{ - startTime: response.data.start_sec, - endTime: response.data.end_sec, - type: 'intro', - provider: 'introdb' - }]; + if (response.data.intro) { + intervals.push({ + startTime: response.data.intro.start_sec, + endTime: response.data.intro.end_sec, + type: 'intro', + provider: 'introdb' + }); + } + + if (response.data.recap) { + intervals.push({ + startTime: response.data.recap.start_sec, + endTime: response.data.recap.end_sec, + type: 'recap', + provider: 'introdb' + }); + } + + if (response.data.outro) { + intervals.push({ + startTime: response.data.outro.start_sec, + endTime: response.data.outro.end_sec, + type: 'outro', + provider: 'introdb' + }); + } + + if (intervals.length > 0) { + logger.log(`[IntroService] Found ${intervals.length} segments for ${imdbId} S${season}E${episode}`); + } + + return intervals; } catch (error: any) { if (axios.isAxiosError(error) && error.response?.status === 404) { // No intro data available for this episode - this is expected - logger.log(`[IntroService] No intro data for ${imdbId} S${season}E${episode}`); + logger.log(`[IntroService] No segment data for ${imdbId} S${season}E${episode}`); return []; } - logger.error('[IntroService] Error fetching intro timestamps:', error?.message || error); + logger.error('[IntroService] Error fetching segments from IntroDB:', error?.message || error); return []; } } @@ -231,7 +263,8 @@ export async function submitIntro( season: number, episode: number, startTime: number, // in seconds - endTime: number // in seconds + endTime: number, // in seconds + segmentType: SkipType = 'intro' ): Promise { try { if (!apiKey) { @@ -241,8 +274,12 @@ export async function submitIntro( const response = await axios.post(`${INTRODB_API_URL}/submit`, { imdb_id: imdbId, + segment_type: segmentType === 'op' ? 'intro' : (segmentType === 'ed' ? 'outro' : segmentType), season, episode, + start_sec: startTime, + end_sec: endTime, + // Keep start_ms/end_ms for backward compatibility if the server still expects it start_ms: Math.round(startTime * 1000), end_ms: Math.round(endTime * 1000), }, { @@ -336,18 +373,24 @@ export async function getIntroTimestamps( imdbId: string, season: number, episode: number -): Promise { +): Promise { const intervals = await fetchFromIntroDb(imdbId, season, episode); - if (intervals.length > 0) { + const intro = intervals.find(i => i.type === 'intro'); + if (intro) { return { imdb_id: imdbId, season, episode, - start_sec: intervals[0].startTime, - end_sec: intervals[0].endTime, - start_ms: intervals[0].startTime * 1000, - end_ms: intervals[0].endTime * 1000, - confidence: 1.0 + start_sec: intro.startTime, + end_sec: intro.endTime, + start_ms: intro.startTime * 1000, + end_ms: intro.endTime * 1000, + confidence: 1.0, + intro: { + start_sec: intro.startTime, + end_sec: intro.endTime, + confidence: 1.0 + } }; } return null; diff --git a/src/services/pluginService.ts b/src/services/pluginService.ts index 1e0ed6c3..cd5cb8c8 100644 --- a/src/services/pluginService.ts +++ b/src/services/pluginService.ts @@ -6,6 +6,7 @@ import { Stream } from '../types/streams'; import { cacheService } from './cacheService'; import CryptoJS from 'crypto-js'; import { safeAxiosConfig, createSafeAxiosConfig } from '../utils/axiosConfig'; +import EventEmitter from 'eventemitter3'; const MAX_CONCURRENT_SCRAPERS = 5; const MAX_INFLIGHT_KEYS = 30; @@ -24,6 +25,12 @@ const VIDEO_CONTENT_TYPES = [ const MAX_PREFLIGHT_SIZE = 50 * 1024 * 1024; +export const PLUGIN_SYNC_EVENTS = { + CHANGED: 'changed', +} as const; + +const pluginSyncEmitter = new EventEmitter(); + // Types for local scrapers export interface ScraperManifest { name: string; @@ -176,6 +183,10 @@ class LocalScraperService { return LocalScraperService.instance; } + public getPluginSyncEventEmitter(): EventEmitter { + return pluginSyncEmitter; + } + private async initialize(): Promise { if (this.initialized) return; @@ -367,6 +378,7 @@ class LocalScraperService { }; this.repositories.set(id, newRepo); await this.saveRepositories(); + pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'add_repository', id: newRepo.id }); logger.log('[LocalScraperService] Added repository:', newRepo.name); return id; } @@ -386,6 +398,7 @@ class LocalScraperService { this.repositoryUrl = updatedRepo.url; this.repositoryName = updatedRepo.name; } + pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'update_repository', id }); logger.log('[LocalScraperService] Updated repository:', updatedRepo.name); } @@ -424,6 +437,7 @@ class LocalScraperService { this.repositories.delete(id); await this.saveRepositories(); await this.saveInstalledScrapers(); + pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'remove_repository', id }); logger.log('[LocalScraperService] Removed repository:', id); } @@ -450,6 +464,7 @@ class LocalScraperService { } logger.log('[LocalScraperService] Switched to repository:', repo.name); + pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'set_current_repository', id }); } getCurrentRepositoryId(): string { @@ -553,6 +568,7 @@ class LocalScraperService { this.repositories.set(id, repo); await this.saveRepositories(); + pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'toggle_repository_enabled', id, enabled }); logger.log('[LocalScraperService] Toggled repository', repo.name, 'to', enabled ? 'enabled' : 'disabled'); } diff --git a/src/services/simklService.ts b/src/services/simklService.ts index ef027ffe..ef0d4db7 100644 --- a/src/services/simklService.ts +++ b/src/services/simklService.ts @@ -301,7 +301,7 @@ export class SimklService { * Exchange code for access token * Simkl tokens do not expire */ - public async exchangeCodeForToken(code: string): Promise { + public async exchangeCodeForToken(code: string, codeVerifier: string): Promise { await this.ensureInitialized(); try { @@ -315,7 +315,8 @@ export class SimklService { client_id: SIMKL_CLIENT_ID, client_secret: SIMKL_CLIENT_SECRET, redirect_uri: SIMKL_REDIRECT_URI, - grant_type: 'authorization_code' + grant_type: 'authorization_code', + code_verifier: codeVerifier }) }); @@ -937,4 +938,4 @@ export class SimklService { return null; } } -} \ No newline at end of file +} diff --git a/src/services/storageService.ts b/src/services/storageService.ts index bd3b1484..51d13605 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -80,6 +80,29 @@ class StorageService { return `${type}:${id}${episodeId ? `:${episodeId}` : ''}`; } + private normalizeContinueWatchingEpisodeRemoveId(id: string, episodeId?: string): string | undefined { + if (!episodeId) return undefined; + const normalizedId = id?.trim(); + const normalizedEpisodeId = episodeId.trim(); + if (!normalizedId || !normalizedEpisodeId) return undefined; + + const colonMatch = normalizedEpisodeId.match(/(?:^|:)(\d+):(\d+)$/); + if (colonMatch) { + return `${normalizedId}:${colonMatch[1]}:${colonMatch[2]}`; + } + + const sxeMatch = normalizedEpisodeId.match(/s(\d+)e(\d+)/i); + if (sxeMatch) { + return `${normalizedId}:${sxeMatch[1]}:${sxeMatch[2]}`; + } + + if (normalizedEpisodeId.startsWith(`${normalizedId}:`)) { + return normalizedEpisodeId; + } + + return `${normalizedId}:${normalizedEpisodeId}`; + } + public async addWatchProgressTombstone( id: string, type: string, @@ -274,12 +297,30 @@ class StorageService { try { const removedMap = await this.getContinueWatchingRemoved(); - const removedKey = this.buildWpKeyString(id, type); - const removedAt = removedMap[removedKey]; + const removeCandidates: Array<{ removeId: string; key: string }> = []; - if (removedAt != null && timestamp > removedAt) { - logger.log(`♻️ [StorageService] restoring content to continue watching due to new progress: ${type}:${id}`); - await this.removeContinueWatchingRemoved(id, type); + const baseRemoveId = (id || '').trim(); + if (baseRemoveId) { + removeCandidates.push({ + removeId: baseRemoveId, + key: this.buildWpKeyString(baseRemoveId, type), + }); + } + + const episodeRemoveId = this.normalizeContinueWatchingEpisodeRemoveId(id, episodeId); + if (episodeRemoveId && episodeRemoveId !== baseRemoveId) { + removeCandidates.push({ + removeId: episodeRemoveId, + key: this.buildWpKeyString(episodeRemoveId, type), + }); + } + + for (const candidate of removeCandidates) { + const removedAt = removedMap[candidate.key]; + if (removedAt != null && timestamp > removedAt) { + logger.log(`♻️ [StorageService] restoring content to continue watching due to new progress: ${candidate.key}`); + await this.removeContinueWatchingRemoved(candidate.removeId, type); + } } } catch (e) { // Ignore error checks for restoration to prevent blocking save @@ -459,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); @@ -499,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/stremioService.ts b/src/services/stremioService.ts index 653c0962..5a211431 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -326,8 +326,17 @@ class StremioService { return true; } - // Check if the ID matches any supported prefix - return supportedPrefixes.some(prefix => lowerId.startsWith(prefix.toLowerCase())); + // Check if the ID matches any supported prefix. + // For prefixes without a trailing separator (e.g. "mal", "kitsu"), the ID must be + // longer than the prefix itself so that bare prefix strings like "mal" are rejected. + const result = supportedPrefixes.some(prefix => { + const lowerPrefix = prefix.toLowerCase(); + if (!lowerId.startsWith(lowerPrefix)) return false; + if (lowerPrefix.endsWith(':') || lowerPrefix.endsWith('_')) return true; + return lowerId.length > lowerPrefix.length; + }); + if (__DEV__) console.log(`🔍 [isValidContentId] Prefix match result: ${result} for ID '${id}'`); + return result; } // Get all content types supported by installed addons @@ -1254,6 +1263,49 @@ class StremioService { const addons = this.getInstalledAddons(); + // Some addons use non-standard meta types (e.g. "anime") but expect streams under the "series" endpoint. + // We'll try the requested type first, then (if no addons match) fall back to "series". + const pickStreamAddons = (requestType: string) => + addons.filter(addon => { + if (!addon.resources || !Array.isArray(addon.resources)) { + logger.log(`⚠️ [getStreams] Addon ${addon.id} has no valid resources array`); + return false; + } + + let hasStreamResource = false; + let supportsIdPrefix = false; + + for (const resource of addon.resources) { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as ResourceObject; + if (typedResource.name === 'stream' && + Array.isArray(typedResource.types) && + typedResource.types.includes(requestType)) { + hasStreamResource = true; + + if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { + supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p)); + } else { + supportsIdPrefix = true; + } + break; + } + } else if (typeof resource === 'string' && resource === 'stream' && addon.types) { + if (Array.isArray(addon.types) && addon.types.includes(requestType)) { + hasStreamResource = true; + if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) { + supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p)); + } else { + supportsIdPrefix = true; + } + break; + } + } + } + + return hasStreamResource && supportsIdPrefix; + }); + // Check if local scrapers are enabled and execute them first try { // Load settings from AsyncStorage directly (scoped with fallback) @@ -1400,64 +1452,109 @@ class StremioService { // TMDB Embed addon not found } - // Find addons that provide streams and sort them by installation order - const streamAddons = addons - .filter(addon => { - if (!addon.resources || !Array.isArray(addon.resources)) { - logger.log(`⚠️ [getStreams] Addon ${addon.id} has no valid resources array`); - return false; + let effectiveType = type; + let streamAddons = pickStreamAddons(type); + + logger.log(`🧭 [getStreams] Resolving stream addons for type='${type}' id='${id}' (matched=${streamAddons.length})`); + + if (streamAddons.length === 0) { + const fallbackTypes = ['series', 'movie', 'tv', 'channel'].filter(t => t !== type); + for (const fallbackType of fallbackTypes) { + const fallbackAddons = pickStreamAddons(fallbackType); + if (fallbackAddons.length > 0) { + effectiveType = fallbackType; + streamAddons = fallbackAddons; + logger.log(`🔁 [getStreams] No stream addons for type '${type}', falling back to '${effectiveType}' for id '${id}'`); + break; } + } + } - let hasStreamResource = false; - let supportsIdPrefix = false; - - // Iterate through the resources array, checking each element - for (const resource of addon.resources) { - // Check if the current element is a ResourceObject - if (typeof resource === 'object' && resource !== null && 'name' in resource) { - const typedResource = resource as ResourceObject; - if (typedResource.name === 'stream' && - Array.isArray(typedResource.types) && - typedResource.types.includes(type)) { - hasStreamResource = true; - - // Check if this addon supports the ID prefix (generic: any prefix that matches start of id) - if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { - supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p)); - } else { - // If no idPrefixes specified, assume it supports all prefixes - supportsIdPrefix = true; - } - break; // Found the stream resource object, no need to check further - } - } - // Check if the element is the simple string "stream" AND the addon has a top-level types array - else if (typeof resource === 'string' && resource === 'stream' && addon.types) { - if (Array.isArray(addon.types) && addon.types.includes(type)) { - hasStreamResource = true; - // For simple string resources, check addon-level idPrefixes (generic) - if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) { - supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p)); - } else { - // If no idPrefixes specified, assume it supports all prefixes - supportsIdPrefix = true; - } - break; // Found the simple stream resource string and type support - } - } - } - - const canHandleRequest = hasStreamResource && supportsIdPrefix; - - return canHandleRequest; - }); - - + if (effectiveType !== type) { + logger.log(`🧭 [getStreams] Using effectiveType='${effectiveType}' (requested='${type}') for id='${id}'`); + } if (streamAddons.length === 0) { logger.warn('⚠️ [getStreams] No addons found that can provide streams'); - // Optionally call callback with an empty result or specific status? - // For now, just return if no addons. + + // Log what the URL would have been for debugging + const encodedId = encodeURIComponent(id); + const exampleUrl = `/stream/${effectiveType}/${encodedId}.json`; + logger.log(`🚫 [getStreams] No stream addons matched. Would have requested: ${exampleUrl}`); + logger.log(`🚫 [getStreams] Details: requestedType='${type}' effectiveType='${effectiveType}' id='${id}'`); + + // Show which addons have stream capability but didn't match + const streamCapableAddons = addons.filter(addon => { + if (!addon.resources || !Array.isArray(addon.resources)) return false; + return addon.resources.some(resource => { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + return (resource as ResourceObject).name === 'stream'; + } + return typeof resource === 'string' && resource === 'stream'; + }); + }); + + if (streamCapableAddons.length > 0) { + logger.log(`🚫 [getStreams] Found ${streamCapableAddons.length} stream-capable addon(s) that didn't match:`); + + for (const addon of streamCapableAddons) { + const streamResources = addon.resources!.filter(resource => { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + return (resource as ResourceObject).name === 'stream'; + } + return typeof resource === 'string' && resource === 'stream'; + }); + + for (const resource of streamResources) { + if (typeof resource === 'object' && resource !== null) { + const typedResource = resource as ResourceObject; + const types = typedResource.types || []; + const prefixes = typedResource.idPrefixes || []; + const typeMatch = types.includes(effectiveType); + const prefixMatch = prefixes.length === 0 || prefixes.some(p => id.startsWith(p)); + + if (addon.url) { + const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); + const wouldBeUrl = queryParams + ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` + : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; + + console.log( + ` ❌ ${addon.name} (${addon.id}):\n` + + ` types=[${types.join(',')}] typeMatch=${typeMatch}\n` + + ` prefixes=[${prefixes.join(',')}] prefixMatch=${prefixMatch}\n` + + ` url=${wouldBeUrl}` + ); + } else { + console.log(` ❌ ${addon.name} (${addon.id}): no URL configured`); + } + } else if (typeof resource === 'string' && resource === 'stream') { + // String resource - check addon-level types and prefixes + const addonTypes = addon.types || []; + const addonPrefixes = addon.idPrefixes || []; + const typeMatch = addonTypes.includes(effectiveType); + const prefixMatch = addonPrefixes.length === 0 || addonPrefixes.some(p => id.startsWith(p)); + + if (addon.url) { + const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); + const wouldBeUrl = queryParams + ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` + : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; + + console.log( + ` ❌ ${addon.name} (${addon.id}) [addon-level]:\n` + + ` types=[${addonTypes.join(',')}] typeMatch=${typeMatch}\n` + + ` prefixes=[${addonPrefixes.join(',')}] prefixMatch=${prefixMatch}\n` + + ` url=${wouldBeUrl}` + ); + } + } + } + } + } else { + logger.log(`🚫 [getStreams] No stream-capable addons installed`); + } + return; } @@ -1474,9 +1571,11 @@ class StremioService { const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); const encodedId = encodeURIComponent(activeId); - const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`; + const url = queryParams ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; - logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}) [${addon.installationId}]: ${url}`); + logger.log( + `🔗 [getStreams] GET ${url} (addon='${addon.name}' id='${addon.id}' install='${addon.installationId}' requestedType='${type}' effectiveType='${effectiveType}' rawId='${id}')` + ); const response = await this.retryRequest(async () => { return await axios.get(url, safeAxiosConfig); @@ -1521,14 +1620,16 @@ class StremioService { const streamPath = `/stream/${type}/${encodedId}.json`; const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`; - logger.log(`Fetching streams from URL: ${url}`); + logger.log( + `🔗 [fetchStreamsFromAddon] GET ${url} (addon='${addon.name}' id='${addon.id}' install='${addon.installationId}' type='${type}' rawId='${id}')` + ); try { // Increase timeout for debrid services const timeout = addon.id.toLowerCase().includes('torrentio') ? 60000 : 10000; const response = await this.retryRequest(async () => { - logger.log(`Making request to ${url} with timeout ${timeout}ms`); + logger.log(`🌐 [fetchStreamsFromAddon] Requesting ${url} (timeout=${timeout}ms)`); return await axios.get(url, createSafeAxiosConfig(timeout, { headers: { 'Accept': 'application/json', @@ -1899,6 +2000,65 @@ class StremioService { return false; } + // Reconcile local addon order to match a remote ordered list of addon manifest URLs. + // Any local addons not present in the remote list are appended in their current order. + async applyAddonOrderFromManifestUrls(manifestUrls: string[]): Promise { + await this.ensureInitialized(); + if (!Array.isArray(manifestUrls) || manifestUrls.length === 0) return false; + + const normalizeManifestUrl = (raw: string): string => { + const value = (raw || '').trim(); + if (!value) return ''; + const withManifest = value.includes('manifest.json') + ? value + : `${value.replace(/\/$/, '')}/manifest.json`; + return withManifest.toLowerCase(); + }; + + const localByNormalizedUrl = new Map(); + for (const installationId of this.addonOrder) { + const addon = this.installedAddons.get(installationId); + if (!addon) continue; + const normalized = normalizeManifestUrl(addon.originalUrl || addon.url || ''); + if (!normalized) continue; + const list = localByNormalizedUrl.get(normalized) || []; + list.push(installationId); + localByNormalizedUrl.set(normalized, list); + } + + const nextOrder: string[] = []; + const seenInstallations = new Set(); + + for (const remoteUrl of manifestUrls) { + const normalizedRemote = normalizeManifestUrl(remoteUrl); + if (!normalizedRemote) continue; + const candidates = localByNormalizedUrl.get(normalizedRemote); + if (!candidates || candidates.length === 0) continue; + const installationId = candidates.shift(); + if (!installationId || seenInstallations.has(installationId)) continue; + nextOrder.push(installationId); + seenInstallations.add(installationId); + } + + for (const installationId of this.addonOrder) { + if (!this.installedAddons.has(installationId)) continue; + if (seenInstallations.has(installationId)) continue; + nextOrder.push(installationId); + seenInstallations.add(installationId); + } + + const changed = + nextOrder.length !== this.addonOrder.length || + nextOrder.some((id, index) => id !== this.addonOrder[index]); + + if (!changed) return false; + + this.addonOrder = nextOrder; + await this.saveAddonOrder(); + addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED); + return true; + } + // Check if any installed addons can provide streams (including embedded streams in metadata) async hasStreamProviders(type?: string): Promise { await this.ensureInitialized(); @@ -1999,4 +2159,4 @@ export interface AddonCatalogItem { } export const stremioService = StremioService.getInstance(); -export default stremioService; \ No newline at end of file +export default stremioService; diff --git a/src/services/supabaseSyncService.ts b/src/services/supabaseSyncService.ts new file mode 100644 index 00000000..c38b9c72 --- /dev/null +++ b/src/services/supabaseSyncService.ts @@ -0,0 +1,1491 @@ +import { AppState, Platform } from 'react-native'; +import { mmkvStorage } from './mmkvStorage'; +import { logger } from '../utils/logger'; +import { localScraperService, PLUGIN_SYNC_EVENTS } from './pluginService'; +import { stremioService, addonEmitter, ADDON_EVENTS, Manifest } from './stremioService'; +import { catalogService, StreamingContent } from './catalogService'; +import { storageService } from './storageService'; +import { watchedService, LocalWatchedItem } from './watchedService'; +import { TraktService } from './traktService'; +import { SimklService } from './simklService'; + +const SUPABASE_SESSION_KEY = '@supabase:session'; +const DEFAULT_SYNC_DEBOUNCE_MS = 2000; + +type Nullable = T | null; + +export type SupabaseUser = { + id: string; + email?: string; + user_metadata?: { + display_name?: string; + avatar_url?: string; + [key: string]: unknown; + }; + app_metadata?: { + provider?: string; + [key: string]: unknown; + }; +}; + +type SupabaseSession = { + access_token: string; + refresh_token: string; + expires_at?: number; + expires_in?: number; + token_type?: string; + user: SupabaseUser; +}; + +type PluginRow = { + url: string; + name?: string; + enabled?: boolean; + sort_order?: number; +}; + +type AddonRow = { + url: string; + sort_order: number; +}; + +type WatchProgressRow = { + content_id: string; + content_type: 'movie' | 'series'; + video_id: string; + season: Nullable; + episode: Nullable; + position: number; + duration: number; + last_watched: number; + progress_key: string; +}; + +type LibraryRow = { + content_id: string; + content_type: string; + name?: string; + poster?: string; + poster_shape?: string; + background?: string; + description?: string; + release_info?: string; + imdb_rating?: number; + genres?: string[]; + addon_base_url?: string; + added_at?: number; +}; + +type WatchedRow = { + content_id: string; + content_type: string; + title?: string; + season: Nullable; + episode: Nullable; + watched_at: number; +}; + +type RpcClaimSyncCodeRow = { + result_owner_id: Nullable; + success: boolean; + message: string; +}; + +export type LinkedDevice = { + owner_id: string; + device_user_id: string; + device_name?: string; + linked_at: string; +}; + +export type RemoteSyncStats = { + plugins: number; + addons: number; + watchProgress: number; + libraryItems: number; + watchedItems: number; + linkedDevices: number; +}; + +type PushTarget = 'plugins' | 'addons' | 'watch_progress' | 'library' | 'watched_items'; + +class SupabaseSyncService { + private static instance: SupabaseSyncService; + + private readonly supabaseUrl: string; + private readonly anonKey: string; + private session: SupabaseSession | null = null; + private initializePromise: Promise | null = null; + private startupSyncPromise: Promise | null = null; + private listenersRegistered = false; + private suppressPushes = false; + private appStateSub: { remove: () => void } | null = null; + private lastForegroundPullAt = 0; + private readonly foregroundPullCooldownMs = 30000; + private pendingWatchProgressDeleteKeys = new Set(); + private watchProgressDeleteTimer: ReturnType | null = null; + private watchProgressPushedSignatures = new Map(); + + private pendingPushTimers: Record | null> = { + plugins: null, + addons: null, + watch_progress: null, + library: null, + watched_items: null, + }; + + private constructor() { + this.supabaseUrl = (process.env.EXPO_PUBLIC_SUPABASE_URL || '').replace(/\/$/, ''); + this.anonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || ''; + } + + static getInstance(): SupabaseSyncService { + if (!SupabaseSyncService.instance) { + SupabaseSyncService.instance = new SupabaseSyncService(); + } + return SupabaseSyncService.instance; + } + + public isConfigured(): boolean { + return Boolean(this.supabaseUrl && this.anonKey); + } + + public getCurrentSessionUser(): SupabaseUser | null { + return this.session?.user || null; + } + + public isAnonymousSession(): boolean { + return this.session?.user?.app_metadata?.provider === 'anonymous'; + } + + public async initialize(): Promise { + if (!this.isConfigured()) { + logger.warn('[SupabaseSyncService] Missing Supabase env vars; sync disabled.'); + return; + } + + if (this.initializePromise) { + await this.initializePromise; + return; + } + + this.initializePromise = (async () => { + await this.loadStoredSession(); + await this.ensureValidSession(); + this.registerSyncListeners(); + })(); + + try { + await this.initializePromise; + } finally { + this.initializePromise = null; + } + } + + public async signUpWithEmail(email: string, password: string): Promise<{ user?: SupabaseUser; error?: string }> { + if (!this.isConfigured()) { + return { error: 'Supabase is not configured' }; + } + + try { + const response = await this.requestAuth<{ user?: SupabaseUser; session?: SupabaseSession }>('/auth/v1/signup', { + method: 'POST', + body: { email, password }, + }); + + if (response.session) { + await this.setSession(response.session); + } + + // In email-confirmation mode, Supabase may not establish a session immediately. + // Treat this as a successful signup attempt and let caller handle next UX step. + return { user: response.user }; + } catch (error: any) { + return { error: this.extractErrorMessage(error, 'Signup failed') }; + } + } + + public async signInWithEmail(email: string, password: string): Promise<{ user?: SupabaseUser; error?: string }> { + if (!this.isConfigured()) { + return { error: 'Supabase is not configured' }; + } + + try { + const response = await this.requestAuth('/auth/v1/token?grant_type=password', { + method: 'POST', + body: { email, password }, + }); + await this.setSession(response); + return { user: response.user }; + } catch (error: any) { + return { error: this.extractErrorMessage(error, 'Sign in failed') }; + } + } + + public async signOut(): Promise { + if (!this.isConfigured()) return; + const token = await this.getValidAccessToken(); + + if (token) { + try { + await this.request('/auth/v1/logout', { + method: 'POST', + authToken: token, + }); + } catch (error) { + logger.warn('[SupabaseSyncService] Supabase logout request failed, clearing local session:', error); + } + } + + this.session = null; + this.watchProgressPushedSignatures.clear(); + await mmkvStorage.removeItem(SUPABASE_SESSION_KEY); + } + + public async startupSync(): Promise { + if (!this.isConfigured()) return; + await this.initialize(); + logger.log('[SupabaseSyncService] startupSync: begin'); + + if (this.startupSyncPromise) { + await this.startupSyncPromise; + return; + } + + this.startupSyncPromise = this.runStartupSync(); + try { + await this.startupSyncPromise; + logger.log('[SupabaseSyncService] startupSync: complete'); + } finally { + this.startupSyncPromise = null; + } + } + + public async onSignUpPushAll(): Promise { + await this.pushAllLocalData(); + } + + public async onSignInPullAll(): Promise { + await this.pullAllToLocal(); + } + + public async syncNow(): Promise { + await this.startupSync(); + } + + public async pushAllLocalData(): Promise { + await this.initialize(); + logger.log('[SupabaseSyncService] pushAllLocalData: begin'); + + await this.pushPluginsFromLocal(); + await this.pushAddonsFromLocal(); + + await this.pushLibraryFromLocal(); + const externalProgressSyncConnected = await this.isExternalProgressSyncConnected(); + if (externalProgressSyncConnected) { + logger.log('[SupabaseSyncService] External sync (Trakt/Simkl) is connected; skipping watch progress/watched-items push, keeping library sync.'); + logger.log('[SupabaseSyncService] pushAllLocalData: complete'); + return; + } + + await this.pushWatchProgressFromLocal(); + await this.pushWatchedItemsFromLocal(); + logger.log('[SupabaseSyncService] pushAllLocalData: complete'); + } + + public async pullAllToLocal(): Promise { + await this.initialize(); + logger.log('[SupabaseSyncService] pullAllToLocal: begin'); + + await this.withSuppressedPushes(async () => { + await this.pullPluginsToLocal(); + await this.pullAddonsToLocal(); + + await this.pullLibraryToLocal(); + const externalProgressSyncConnected = await this.isExternalProgressSyncConnected(); + if (externalProgressSyncConnected) { + logger.log('[SupabaseSyncService] External sync (Trakt/Simkl) is connected; skipping watch progress/watched-items pull, keeping library sync.'); + return; + } + + await this.pullWatchProgressToLocal(); + await this.pullWatchedItemsToLocal(); + }); + logger.log('[SupabaseSyncService] pullAllToLocal: complete'); + } + + public async generateSyncCode(pin: string): Promise<{ code?: string; error?: string }> { + try { + await this.pushAllLocalData(); + const response = await this.callRpc>('generate_sync_code', { p_pin: pin }); + const code = response?.[0]?.code; + if (!code) return { error: 'Failed to generate sync code' }; + return { code }; + } catch (error: any) { + return { error: this.extractErrorMessage(error, 'Failed to generate sync code') }; + } + } + + public async getSyncCode(pin: string): Promise<{ code?: string; error?: string }> { + try { + const response = await this.callRpc>('get_sync_code', { p_pin: pin }); + const code = response?.[0]?.code; + if (!code) return { error: 'No sync code found' }; + return { code }; + } catch (error: any) { + return { error: this.extractErrorMessage(error, 'Failed to fetch sync code') }; + } + } + + public async claimSyncCode(code: string, pin: string, deviceName?: string): Promise<{ success: boolean; message: string }> { + try { + const response = await this.callRpc('claim_sync_code', { + p_code: code, + p_pin: pin, + p_device_name: deviceName || `Nuvio ${Platform.OS}`, + }); + const result = response?.[0]; + if (!result || !result.success) { + return { + success: false, + message: result?.message || 'Failed to claim sync code', + }; + } + + await this.pullAllToLocal(); + + return { + success: true, + message: result.message || 'Device linked successfully', + }; + } catch (error: any) { + return { + success: false, + message: this.extractErrorMessage(error, 'Failed to claim sync code'), + }; + } + } + + public async getLinkedDevices(): Promise { + try { + const token = await this.getValidAccessToken(); + if (!token) return []; + + const ownerId = await this.getEffectiveOwnerId(); + if (!ownerId) return []; + + return await this.request( + `/rest/v1/linked_devices?select=owner_id,device_user_id,device_name,linked_at&owner_id=eq.${encodeURIComponent(ownerId)}&order=linked_at.desc`, + { + method: 'GET', + authToken: token, + } + ); + } catch (error) { + logger.error('[SupabaseSyncService] Failed to fetch linked devices:', error); + return []; + } + } + + public async getRemoteStats(): Promise { + try { + const token = await this.getValidAccessToken(); + if (!token) return null; + + const ownerId = await this.getEffectiveOwnerId(); + if (!ownerId) return null; + + const ownerFilter = encodeURIComponent(ownerId); + const [ + pluginRows, + addonRows, + watchRows, + libraryRows, + watchedRows, + deviceRows, + ] = await Promise.all([ + 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}&profile_id=eq.1`, { + method: 'GET', + authToken: token, + }), + 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}&profile_id=eq.1`, { + method: 'GET', + authToken: token, + }), + this.request>(`/rest/v1/watched_items?select=id&user_id=eq.${ownerFilter}&profile_id=eq.1`, { + method: 'GET', + authToken: token, + }), + this.request>(`/rest/v1/linked_devices?select=device_user_id&owner_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + ]); + + return { + plugins: pluginRows?.length || 0, + addons: addonRows?.length || 0, + watchProgress: watchRows?.length || 0, + libraryItems: libraryRows?.length || 0, + watchedItems: watchedRows?.length || 0, + linkedDevices: deviceRows?.length || 0, + }; + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to fetch remote stats:', error); + return null; + } + } + + public async unlinkDevice(deviceUserId: string): Promise<{ success: boolean; error?: string }> { + try { + await this.callRpc('unlink_device', { p_device_user_id: deviceUserId }); + return { success: true }; + } catch (error: any) { + return { + success: false, + error: this.extractErrorMessage(error, 'Failed to unlink device'), + }; + } + } + + public async getEffectiveOwnerId(): Promise { + try { + const response = await this.callRpc('get_sync_owner', {}); + if (typeof response === 'string') return response; + if (Array.isArray(response)) { + const first = response[0]; + if (typeof first === 'string') return first; + if (first && typeof first === 'object') { + const candidate = (first as any).get_sync_owner || (first as any).id; + return typeof candidate === 'string' ? candidate : null; + } + } + if (response && typeof response === 'object') { + const candidate = (response as any).get_sync_owner || (response as any).id; + return typeof candidate === 'string' ? candidate : null; + } + return null; + } catch (error) { + logger.error('[SupabaseSyncService] Failed to resolve effective owner id:', error); + return null; + } + } + + private async runStartupSync(): Promise { + logger.log('[SupabaseSyncService] runStartupSync: step=pull_plugins:start'); + const pluginPullOk = await this.safeRun('pull_plugins', async () => { + await this.withSuppressedPushes(async () => { + await this.pullPluginsToLocal(); + }); + }); + logger.log(`[SupabaseSyncService] runStartupSync: step=pull_plugins:done ok=${pluginPullOk}`); + + logger.log('[SupabaseSyncService] runStartupSync: step=pull_addons:start'); + const addonPullOk = await this.safeRun('pull_addons', async () => { + await this.withSuppressedPushes(async () => { + await this.pullAddonsToLocal(); + }); + }); + logger.log(`[SupabaseSyncService] runStartupSync: step=pull_addons:done ok=${addonPullOk}`); + if (!pluginPullOk || !addonPullOk) { + logger.warn('[SupabaseSyncService] runStartupSync: one or more pull steps failed; skipped startup push-by-design'); + } + + const libraryPullOk = await this.safeRun('pull_library', async () => { + await this.withSuppressedPushes(async () => { + await this.pullLibraryToLocal(); + }); + }); + + const externalProgressSyncConnected = await this.isExternalProgressSyncConnected(); + if (externalProgressSyncConnected) { + logger.log('[SupabaseSyncService] External sync (Trakt/Simkl) is connected; skipping watch progress/watched-items Supabase sync (library still synced).'); + if (!libraryPullOk) { + logger.warn('[SupabaseSyncService] runStartupSync: library pull failed while external sync priority is active'); + } + return; + } + + const watchPullOk = await this.safeRun('pull_watch_progress', async () => { + await this.withSuppressedPushes(async () => { + await this.pullWatchProgressToLocal(); + }); + }); + + const watchedPullOk = await this.safeRun('pull_watched_items', async () => { + await this.withSuppressedPushes(async () => { + await this.pullWatchedItemsToLocal(); + }); + }); + + if (!watchPullOk || !libraryPullOk || !watchedPullOk) { + logger.warn('[SupabaseSyncService] runStartupSync: one or more content pulls failed; skipped startup push-by-design'); + } + } + + private async safeRun(step: string, task: () => Promise): Promise { + try { + await task(); + return true; + } catch (error) { + logger.error(`[SupabaseSyncService] Sync step failed (${step}):`, error); + return false; + } + } + + private registerSyncListeners(): void { + if (this.listenersRegistered) return; + this.listenersRegistered = true; + + addonEmitter.on(ADDON_EVENTS.ADDON_ADDED, () => this.schedulePush('addons')); + addonEmitter.on(ADDON_EVENTS.ADDON_REMOVED, () => this.schedulePush('addons')); + addonEmitter.on(ADDON_EVENTS.ORDER_CHANGED, () => this.schedulePush('addons')); + + localScraperService.getPluginSyncEventEmitter().on(PLUGIN_SYNC_EVENTS.CHANGED, () => this.schedulePush('plugins')); + + catalogService.onLibraryAdd(() => this.schedulePush('library')); + catalogService.onLibraryRemove(() => this.schedulePush('library')); + + storageService.subscribeToWatchProgressUpdates(() => this.schedulePush('watch_progress')); + storageService.onWatchProgressRemoved((id, type, episodeId) => { + this.schedulePush('watch_progress'); + this.scheduleWatchProgressDelete(id, type, episodeId); + }); + + watchedService.subscribeToWatchedUpdates(() => this.schedulePush('watched_items')); + + if (!this.appStateSub) { + this.appStateSub = AppState.addEventListener('change', (state) => { + if (state === 'active') { + this.onAppForeground().catch((error) => { + logger.warn('[SupabaseSyncService] Foreground pull failed:', error); + }); + } + }); + } + } + + private async onAppForeground(): Promise { + if (!this.isConfigured()) return; + if (this.suppressPushes) return; + + const now = Date.now(); + if (now - this.lastForegroundPullAt < this.foregroundPullCooldownMs) return; + this.lastForegroundPullAt = now; + logger.log('[SupabaseSyncService] App foreground: triggering pullAllToLocal'); + + await this.initialize(); + if (!this.session) return; + + await this.safeRun('foreground_pull_all', async () => { + await this.pullAllToLocal(); + }); + } + + private schedulePush(target: PushTarget): void { + if (!this.isConfigured() || this.suppressPushes) { + return; + } + + const existing = this.pendingPushTimers[target]; + if (existing) clearTimeout(existing); + logger.log(`[SupabaseSyncService] schedulePush: target=${target} delayMs=${DEFAULT_SYNC_DEBOUNCE_MS}`); + + this.pendingPushTimers[target] = setTimeout(() => { + this.pendingPushTimers[target] = null; + this.executeScheduledPush(target).catch((error) => { + logger.error(`[SupabaseSyncService] Scheduled push failed (${target}):`, error); + }); + }, DEFAULT_SYNC_DEBOUNCE_MS); + } + + private scheduleWatchProgressDelete(id: string, type: string, episodeId?: string): void { + if (!this.isConfigured() || this.suppressPushes) return; + + const keys = this.resolveWatchProgressDeleteKeys(id, type, episodeId); + if (keys.length === 0) return; + keys.forEach((key) => this.pendingWatchProgressDeleteKeys.add(key)); + + if (this.watchProgressDeleteTimer) { + clearTimeout(this.watchProgressDeleteTimer); + } + this.watchProgressDeleteTimer = setTimeout(() => { + this.watchProgressDeleteTimer = null; + this.flushWatchProgressDeletes().catch((error) => { + logger.error('[SupabaseSyncService] watch progress delete flush failed:', error); + }); + }, DEFAULT_SYNC_DEBOUNCE_MS); + } + + private async flushWatchProgressDeletes(): Promise { + if (!this.isConfigured() || this.suppressPushes) return; + + const keys = Array.from(this.pendingWatchProgressDeleteKeys); + if (keys.length === 0) return; + this.pendingWatchProgressDeleteKeys.clear(); + + await this.initialize(); + if (!this.session) { + keys.forEach((key) => this.pendingWatchProgressDeleteKeys.add(key)); + return; + } + + const externalProgressSyncConnected = await this.isExternalProgressSyncConnected(); + if (externalProgressSyncConnected) return; + + try { + logger.log(`[SupabaseSyncService] flushWatchProgressDeletes: deleting ${keys.length} keys`); + await this.callRpc('sync_delete_watch_progress', { p_keys: keys }); + } catch (error) { + keys.forEach((key) => this.pendingWatchProgressDeleteKeys.add(key)); + throw error; + } + } + + private resolveWatchProgressDeleteKeys(id: string, type: string, episodeId?: string): string[] { + const contentId = (id || '').trim(); + const contentType = (type || '').trim().toLowerCase(); + if (!contentId || !contentType) return []; + + const keys = new Set(); + + if (contentType === 'movie') { + keys.add(contentId); + return Array.from(keys); + } + + // Always delete the series mirror key when removing series progress. + keys.add(contentId); + + const normalizedEpisodeId = (episodeId || '').trim(); + if (normalizedEpisodeId) { + const parsed = this.parseSeasonEpisodeFromEpisodeId(normalizedEpisodeId); + if (parsed) { + keys.add(`${contentId}_s${parsed.season}e${parsed.episode}`); + } else { + // Fallback for any non-standard legacy progress_key format. + keys.add(`${contentId}_${normalizedEpisodeId}`); + } + } + + return Array.from(keys); + } + + private parseSeasonEpisodeFromEpisodeId( + episodeId: string + ): { season: number; episode: number } | null { + const match = episodeId.match(/(?:^|:)(\d+):(\d+)$/); + if (!match) return null; + + const season = Number(match[1]); + const episode = Number(match[2]); + if (!Number.isFinite(season) || !Number.isFinite(episode)) return null; + + return { season, episode }; + } + + private async executeScheduledPush(target: PushTarget): Promise { + await this.initialize(); + if (!this.session) return; + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:start`); + + if (target === 'plugins') { + await this.pushPluginsFromLocal(); + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`); + return; + } + + if (target === 'addons') { + await this.pushAddonsFromLocal(); + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`); + return; + } + + if (target === 'watch_progress') { + const externalProgressSyncConnected = await this.isExternalProgressSyncConnected(); + if (externalProgressSyncConnected) { + logger.log('[SupabaseSyncService] executeScheduledPush: skipping watch_progress due to external sync priority (Trakt/Simkl)'); + return; + } + await this.pushWatchProgressFromLocal(); + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`); + return; + } + if (target === 'library') { + await this.pushLibraryFromLocal(); + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`); + return; + } + + const externalProgressSyncConnected = await this.isExternalProgressSyncConnected(); + if (externalProgressSyncConnected) { + logger.log('[SupabaseSyncService] executeScheduledPush: skipping watched_items due to external sync priority (Trakt/Simkl)'); + return; + } + + await this.pushWatchedItemsFromLocal(); + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`); + } + + private async withSuppressedPushes(task: () => Promise): Promise { + this.suppressPushes = true; + try { + await task(); + } finally { + this.suppressPushes = false; + } + } + + private async loadStoredSession(): Promise { + try { + const raw = await mmkvStorage.getItem(SUPABASE_SESSION_KEY); + if (!raw) { + this.session = null; + return; + } + this.session = JSON.parse(raw) as SupabaseSession; + } catch (error) { + logger.error('[SupabaseSyncService] Failed to load stored session:', error); + this.session = null; + await mmkvStorage.removeItem(SUPABASE_SESSION_KEY); + } + } + + private async setSession(session: SupabaseSession): Promise { + // Reset per-entry push cache on session changes to avoid cross-account state bleed. + this.watchProgressPushedSignatures.clear(); + this.session = session; + await mmkvStorage.setItem(SUPABASE_SESSION_KEY, JSON.stringify(session)); + } + + private isSessionExpired(session: SupabaseSession): boolean { + if (!session.expires_at) return false; + const now = Math.floor(Date.now() / 1000); + return now >= (session.expires_at - 30); + } + + private async refreshSession(refreshToken: string): Promise { + return await this.requestAuth('/auth/v1/token?grant_type=refresh_token', { + method: 'POST', + body: { refresh_token: refreshToken }, + }); + } + + private async ensureValidSession(): Promise { + if (!this.session) return false; + if (!this.session.access_token || !this.session.refresh_token) return false; + + if (!this.isSessionExpired(this.session)) return true; + + try { + const refreshed = await this.refreshSession(this.session.refresh_token); + await this.setSession(refreshed); + return true; + } catch (error) { + logger.error('[SupabaseSyncService] Failed to refresh session:', error); + this.session = null; + this.watchProgressPushedSignatures.clear(); + await mmkvStorage.removeItem(SUPABASE_SESSION_KEY); + return false; + } + } + + private async getValidAccessToken(): Promise { + await this.initialize(); + if (!this.session) return null; + + if (this.isSessionExpired(this.session)) { + try { + const refreshed = await this.refreshSession(this.session.refresh_token); + await this.setSession(refreshed); + } catch (error) { + logger.error('[SupabaseSyncService] Token refresh failed:', error); + this.session = null; + this.watchProgressPushedSignatures.clear(); + await mmkvStorage.removeItem(SUPABASE_SESSION_KEY); + return null; + } + } + + return this.session?.access_token || null; + } + + private async requestAuth(path: string, options: { method: string; body?: unknown }): Promise { + return await this.request(path, { + method: options.method, + body: options.body, + authToken: null, + }); + } + + private async request( + path: string, + options: { + method: string; + body?: unknown; + authToken: string | null; + } + ): Promise { + if (!this.isConfigured()) { + throw new Error('Supabase is not configured'); + } + + const headers: Record = { + apikey: this.anonKey, + }; + if (options.authToken) { + headers.Authorization = `Bearer ${options.authToken}`; + } + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(`${this.supabaseUrl}${path}`, { + method: options.method, + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + + const raw = await response.text(); + const parsed = this.parsePayload(raw); + + if (!response.ok) { + throw this.buildRequestError(response.status, parsed, raw); + } + + return parsed as T; + } + + private parsePayload(raw: string): unknown { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + + private buildRequestError(status: number, parsed: unknown, raw: string): Error { + if (parsed && typeof parsed === 'object') { + const message = + (parsed as any).message || + (parsed as any).msg || + (parsed as any).error_description || + (parsed as any).error; + if (typeof message === 'string' && message.trim().length > 0) { + return new Error(message); + } + } + if (raw && raw.trim().length > 0) { + return new Error(raw); + } + return new Error(`Supabase request failed with status ${status}`); + } + + private extractErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) { + const raw = error.message.trim(); + + let parsed: any = null; + if (raw.startsWith('{') && raw.endsWith('}')) { + try { + parsed = JSON.parse(raw); + } catch { + parsed = null; + } + } + + const errorCode = (parsed?.error_code || parsed?.code || '').toString().toLowerCase(); + const message = (parsed?.msg || parsed?.message || raw).toString().trim(); + + if (errorCode === 'invalid_credentials') { + return 'Invalid email or password'; + } + if (errorCode === 'email_not_confirmed') { + return 'Email not confirmed. Check your inbox or Spam/Junk folder, verify your account, then sign in.'; + } + + if (message.length > 0) { + return message; + } + } + return fallback; + } + + private async callRpc(functionName: string, payload?: Record): Promise { + const token = await this.getValidAccessToken(); + if (!token) { + throw new Error('Not authenticated'); + } + + return await this.request(`/rest/v1/rpc/${functionName}`, { + method: 'POST', + body: payload || {}, + authToken: token, + }); + } + + private normalizeUrl(url: string): string { + let u = url.trim().toLowerCase(); + + u = u.replace(/\/manifest\.json\/?$/i, ''); + u = u.replace(/\/+$/, ''); + return u; + } + + private toBigIntNumber(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + return Math.trunc(n); + } + + private secondsToMsLong(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + return Math.trunc(n * 1000); + } + + private normalizeEpochMs(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + // If value looks like seconds, convert to milliseconds. + if (n < 1_000_000_000_000) { + return Math.trunc(n * 1000); + } + return Math.trunc(n); + } + + private msToSeconds(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + return n / 1000; + } + + private addonManifestUrl(addon: Manifest): string | null { + const raw = (addon.originalUrl || addon.url || '').trim(); + if (!raw) return null; + if (raw.includes('manifest.json')) return raw; + return `${raw.replace(/\/$/, '')}/manifest.json`; + } + + private parseWatchProgressKey(key: string): { + contentType: 'movie' | 'series'; + contentId: string; + season: number | null; + episode: number | null; + videoId: string; + progressKey: string; + } | null { + const parts = key.split(':'); + if (parts.length < 2) return null; + + const contentType: 'movie' | 'series' = parts[0] === 'movie' ? 'movie' : 'series'; + const contentId = parts[1]; + const episodeId = parts.length > 2 ? parts.slice(2).join(':') : ''; + let season: number | null = null; + let episode: number | null = null; + + if (episodeId) { + const match = episodeId.match(/:(\d+):(\d+)$/); + if (match) { + season = Number(match[1]); + episode = Number(match[2]); + } + } + + const videoId = episodeId || contentId; + const progressKey = contentType === 'movie' + ? contentId + : (season != null && episode != null ? `${contentId}_s${season}e${episode}` : `${contentId}_${videoId}`); + + return { + contentType, + contentId, + season, + episode, + videoId, + progressKey, + }; + } + + private getWatchProgressEntrySignature(value: { currentTime?: number; duration?: number; lastUpdated?: number }): string { + return [ + Number(value.currentTime || 0), + Number(value.duration || 0), + Number(value.lastUpdated || 0), + ].join('|'); + } + + private buildLocalWatchProgressKey( + contentType: 'movie' | 'series', + contentId: string, + episodeId?: string + ): string { + return `${contentType}:${contentId}${episodeId ? `:${episodeId}` : ''}`; + } + + private toStreamingContent(item: LibraryRow): StreamingContent { + const type = item.content_type === 'movie' ? 'movie' : 'series'; + const posterShape = (item.poster_shape || 'POSTER').toLowerCase() as 'poster' | 'square' | 'landscape'; + + return { + id: item.content_id, + type, + name: item.name || '', + poster: item.poster || '', + posterShape, + banner: item.background, + description: item.description, + releaseInfo: item.release_info, + imdbRating: item.imdb_rating != null ? String(item.imdb_rating) : undefined, + genres: item.genres || [], + addonId: item.addon_base_url, + addedToLibraryAt: item.added_at, + inLibrary: true, + }; + } + + private toWatchedItem(row: WatchedRow): LocalWatchedItem { + return { + content_id: row.content_id, + content_type: row.content_type === 'movie' ? 'movie' : 'series', + title: row.title || '', + season: row.season == null ? null : Number(row.season), + episode: row.episode == null ? null : Number(row.episode), + watched_at: Number(row.watched_at || Date.now()), + }; + } + + private async isTraktConnected(): Promise { + try { + return await TraktService.getInstance().isAuthenticated(); + } catch { + return false; + } + } + + private async isSimklConnected(): Promise { + try { + return await SimklService.getInstance().isAuthenticated(); + } catch { + return false; + } + } + + private async isExternalProgressSyncConnected(): Promise { + if (await this.isTraktConnected()) return true; + return await this.isSimklConnected(); + } + + private async pullPluginsToLocal(): Promise { + const token = await this.getValidAccessToken(); + if (!token) return; + const ownerId = await this.getEffectiveOwnerId(); + if (!ownerId) return; + + const rows = await this.request( + `/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, + } + ); + logger.log(`[SupabaseSyncService] pullPluginsToLocal: remoteCount=${rows?.length || 0}`); + + const localRepos = await localScraperService.getRepositories(); + const byUrl = new Map(localRepos.map((repo) => [this.normalizeUrl(repo.url), repo])); + const remoteSet = new Set( + (rows || []) + .map((row) => (row?.url ? this.normalizeUrl(row.url) : null)) + .filter((url): url is string => Boolean(url)) + ); + + for (const row of rows || []) { + if (!row.url) continue; + const normalized = this.normalizeUrl(row.url); + const existing = byUrl.get(normalized); + + if (!existing) { + await localScraperService.addRepository({ + name: row.name || localScraperService.extractRepositoryName(row.url), + url: row.url, + enabled: row.enabled !== false, + description: 'Synced from cloud', + }); + continue; + } + + const shouldUpdate = + (row.name && row.name !== existing.name) || + (typeof row.enabled === 'boolean' && row.enabled !== existing.enabled); + + if (shouldUpdate) { + await localScraperService.updateRepository(existing.id, { + name: row.name || existing.name, + enabled: typeof row.enabled === 'boolean' ? row.enabled : existing.enabled, + }); + } + } + + // Reconcile removals only when remote has at least one entry to avoid wiping local + // data if backend temporarily returns an empty set. + if (remoteSet.size > 0) { + let removedCount = 0; + for (const repo of localRepos) { + const normalized = this.normalizeUrl(repo.url); + if (remoteSet.has(normalized)) continue; + try { + await localScraperService.removeRepository(repo.id); + removedCount += 1; + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to remove local plugin repository missing in remote set:', repo.name, error); + } + } + logger.log(`[SupabaseSyncService] pullPluginsToLocal: removedLocalExtras=${removedCount}`); + } else { + logger.log('[SupabaseSyncService] pullPluginsToLocal: remote set empty, skipped local prune'); + } + } + + private async pushPluginsFromLocal(): Promise { + const repos = await localScraperService.getRepositories(); + logger.log(`[SupabaseSyncService] pushPluginsFromLocal: localCount=${repos.length}`); + const payload: PluginRow[] = repos.map((repo, index) => ({ + url: repo.url, + name: repo.name, + enabled: repo.enabled !== false, + sort_order: index, + })); + await this.callRpc('sync_push_plugins', { p_plugins: payload }); + } + + private async pullAddonsToLocal(): Promise { + const token = await this.getValidAccessToken(); + if (!token) return; + const ownerId = await this.getEffectiveOwnerId(); + if (!ownerId) return; + + const rows = await this.request( + `/rest/v1/addons?select=url,sort_order&user_id=eq.${encodeURIComponent(ownerId)}&profile_id=eq.1&order=sort_order.asc`, + { + method: 'GET', + authToken: token, + } + ); + logger.log(`[SupabaseSyncService] pullAddonsToLocal: remoteCount=${rows?.length || 0}`); + const orderedRemoteUrls: string[] = []; + const seenRemoteUrls = new Set(); + for (const row of rows || []) { + if (!row?.url) continue; + const normalized = this.normalizeUrl(row.url); + if (seenRemoteUrls.has(normalized)) continue; + seenRemoteUrls.add(normalized); + orderedRemoteUrls.push(row.url); + } + + const installed = await stremioService.getInstalledAddonsAsync(); + logger.log(`[SupabaseSyncService] pullAddonsToLocal: localInstalledBefore=${installed.length}`); + const remoteSet = new Set( + (rows || []) + .map((row) => (row?.url ? this.normalizeUrl(row.url) : null)) + .filter((url): url is string => Boolean(url)) + ); + const installedUrls = new Set( + installed + .map((addon) => this.addonManifestUrl(addon)) + .filter((url): url is string => Boolean(url)) + .map((url) => this.normalizeUrl(url)) + ); + + // Build a set of currently-installed addon manifest IDs so we can also + // skip by ID (prevents duplicate installations of stream-providing addons + // that the URL check alone might miss due to URL format differences). + const installedAddonIds = new Set( + installed.map((addon) => addon.id).filter(Boolean) + ); + + for (const row of rows || []) { + if (!row.url) continue; + const normalized = this.normalizeUrl(row.url); + if (installedUrls.has(normalized)) continue; + + try { + // Pre-check: fetch manifest to see if this addon ID is already installed. + // This prevents creating duplicate installations for stream-providing + // addons whose URLs differ only by format (e.g. with/without manifest.json). + let manifest: Manifest | null = null; + try { + manifest = await stremioService.getManifest(row.url); + } catch { + // If manifest fetch fails, fall through to installAddon which will also fail and be caught below. + } + if (manifest?.id && installedAddonIds.has(manifest.id)) { + // Addon already installed under a different URL variant — skip. + logger.log(`[SupabaseSyncService] pullAddonsToLocal: skipping duplicate addon id=${manifest.id} url=${row.url}`); + installedUrls.add(normalized); + continue; + } + await stremioService.installAddon(row.url); + installedUrls.add(normalized); + if (manifest?.id) installedAddonIds.add(manifest.id); + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to install synced addon:', row.url, error); + } + } + + // Reconcile removals only when remote has at least one entry to avoid wiping local + // data if backend temporarily returns an empty set. + if (remoteSet.size > 0) { + let removedCount = 0; + for (const addon of installed) { + const url = this.addonManifestUrl(addon); + const normalized = url ? this.normalizeUrl(url) : null; + if (!normalized || remoteSet.has(normalized)) continue; + if (!addon.installationId) continue; + try { + await stremioService.removeAddon(addon.installationId); + removedCount += 1; + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to remove local addon missing in remote set:', addon.name, error); + } + } + logger.log(`[SupabaseSyncService] pullAddonsToLocal: removedLocalExtras=${removedCount}`); + } else { + logger.log('[SupabaseSyncService] pullAddonsToLocal: remote set empty, skipped local prune'); + } + + if (orderedRemoteUrls.length > 0) { + try { + const changed = await stremioService.applyAddonOrderFromManifestUrls(orderedRemoteUrls); + logger.log(`[SupabaseSyncService] pullAddonsToLocal: orderReconciled changed=${changed}`); + } catch (error) { + logger.warn('[SupabaseSyncService] pullAddonsToLocal: failed to reconcile addon order:', error); + } + } + } + + private async pushAddonsFromLocal(): Promise { + const addons = await stremioService.getInstalledAddonsAsync(); + logger.log(`[SupabaseSyncService] pushAddonsFromLocal: localInstalledRaw=${addons.length}`); + const seen = new Set(); + const payload: AddonRow[] = addons.reduce((acc, addon) => { + const url = this.addonManifestUrl(addon); + if (!url) return acc; + const normalized = this.normalizeUrl(url); + if (seen.has(normalized)) return acc; + seen.add(normalized); + acc.push({ + url, + sort_order: acc.length, + }); + return acc; + }, []); + logger.log(`[SupabaseSyncService] pushAddonsFromLocal: payloadDeduped=${payload.length}`); + + await this.callRpc('sync_push_addons', { p_addons: payload }); + } + + private async pullWatchProgressToLocal(): Promise { + const rows = await this.callRpc('sync_pull_watch_progress', {}); + const remoteSet = new Set(); + + for (const row of rows || []) { + if (!row.content_id) continue; + const type = row.content_type === 'movie' ? 'movie' : 'series'; + const season = row.season == null ? null : Number(row.season); + const episode = row.episode == null ? null : Number(row.episode); + const episodeId = type === 'series' && season != null && episode != null + ? `${row.content_id}:${season}:${episode}` + : undefined; + remoteSet.add(this.buildLocalWatchProgressKey(type, row.content_id, episodeId)); + + const local = await storageService.getWatchProgress(row.content_id, type, episodeId); + const remoteLastWatched = this.normalizeEpochMs(row.last_watched); + if (local && Number(local.lastUpdated || 0) >= remoteLastWatched) { + continue; + } + + await storageService.setWatchProgress( + row.content_id, + type, + { + ...(local || {}), + currentTime: this.msToSeconds(row.position), + duration: this.msToSeconds(row.duration), + lastUpdated: remoteLastWatched || Date.now(), + }, + episodeId, + { + preserveTimestamp: true, + forceWrite: true, + forceNotify: true, + } + ); + } + + // Remote-first continue watching: remove local entries that no longer exist remotely. + // This intentionally treats the successful remote pull as authoritative. + const allLocal = await storageService.getAllWatchProgress(); + let pruned = 0; + for (const [localKey] of Object.entries(allLocal)) { + if (remoteSet.has(localKey)) continue; + + const parsed = this.parseWatchProgressKey(localKey); + if (!parsed) continue; + + const episodeId = parsed.videoId && parsed.videoId !== parsed.contentId ? parsed.videoId : undefined; + await storageService.removeWatchProgress(parsed.contentId, parsed.contentType, episodeId); + this.watchProgressPushedSignatures.delete(localKey); + pruned += 1; + } + + logger.log(`[SupabaseSyncService] pullWatchProgressToLocal: merged=${(rows || []).length} prunedLocalMissing=${pruned}`); + } + + private async pushWatchProgressFromLocal(): Promise { + const all = await storageService.getAllWatchProgress(); + const nextSeenKeys = new Set(); + const changedEntries: Array<{ key: string; row: WatchProgressRow; signature: string }> = []; + + for (const [key, value] of Object.entries(all)) { + nextSeenKeys.add(key); + const signature = this.getWatchProgressEntrySignature(value); + if (this.watchProgressPushedSignatures.get(key) === signature) { + continue; + } + + const parsed = this.parseWatchProgressKey(key); + if (!parsed) { + continue; + } + + changedEntries.push({ + 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, + }, + }); + } + + // Prune signatures for entries no longer present locally (deletes are handled separately). + for (const existingKey of Array.from(this.watchProgressPushedSignatures.keys())) { + if (!nextSeenKeys.has(existingKey)) { + this.watchProgressPushedSignatures.delete(existingKey); + } + } + + if (changedEntries.length === 0) { + logger.log('[SupabaseSyncService] pushWatchProgressFromLocal: no changed entries; skipping push'); + return; + } + + await this.callRpc('sync_push_watch_progress', { + p_entries: changedEntries.map((entry) => entry.row), + }); + + for (const entry of changedEntries) { + this.watchProgressPushedSignatures.set(entry.key, entry.signature); + } + logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: pushedChanged=${changedEntries.length} totalLocal=${Object.keys(all).length}`); + } + + private async pullLibraryToLocal(): Promise { + const rows = await this.callRpc('sync_pull_library', {}); + const localItems = await catalogService.getLibraryItems(); + const existing = new Set(localItems.map((item) => `${item.type}:${item.id}`)); + const remoteSet = new Set(); + + for (const row of rows || []) { + if (!row.content_id || !row.content_type) continue; + const type = row.content_type === 'movie' ? 'movie' : 'series'; + const key = `${type}:${row.content_id}`; + remoteSet.add(key); + if (existing.has(key)) continue; + + try { + await catalogService.addToLibrary(this.toStreamingContent(row)); + existing.add(key); + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to merge library item from sync:', key, error); + } + } + + // Reconcile removals only when remote has at least one entry to avoid wiping local + // data if backend temporarily returns an empty set. + if (remoteSet.size > 0) { + let removedCount = 0; + for (const item of localItems) { + const key = `${item.type}:${item.id}`; + if (remoteSet.has(key)) continue; + try { + await catalogService.removeFromLibrary(item.type, item.id); + removedCount += 1; + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to remove local library item missing in remote set:', key, error); + } + } + logger.log(`[SupabaseSyncService] pullLibraryToLocal: removedLocalExtras=${removedCount}`); + } else { + logger.log('[SupabaseSyncService] pullLibraryToLocal: remote set empty, skipped local prune'); + } + } + + private async pushLibraryFromLocal(): Promise { + const items = await catalogService.getLibraryItems(); + const payload: LibraryRow[] = items.map((item) => ({ + content_id: item.id, + content_type: item.type === 'movie' ? 'movie' : 'series', + name: item.name, + poster: item.poster, + poster_shape: (item.posterShape || 'poster').toUpperCase(), + background: item.banner || (item as any).background, + description: item.description, + release_info: item.releaseInfo, + imdb_rating: item.imdbRating != null ? Number(item.imdbRating) : undefined, + genres: item.genres || [], + addon_base_url: item.addonId, + added_at: item.addedToLibraryAt, + })); + + await this.callRpc('sync_push_library', { p_items: payload }); + } + + private async pullWatchedItemsToLocal(): Promise { + const rows = await this.callRpc('sync_pull_watched_items', {}); + const mapped = (rows || []).map((row) => this.toWatchedItem(row)); + await watchedService.reconcileRemoteWatchedItems(mapped); + } + + private async pushWatchedItemsFromLocal(): Promise { + const items = await watchedService.getAllWatchedItems(); + const payload: WatchedRow[] = items.map((item) => ({ + content_id: item.content_id, + content_type: item.content_type, + title: item.title, + season: item.season, + episode: item.episode, + watched_at: item.watched_at, + })); + await this.callRpc('sync_push_watched_items', { p_items: payload }); + } +} + +export const supabaseSyncService = SupabaseSyncService.getInstance(); +export default supabaseSyncService; diff --git a/src/services/tmdbService.ts b/src/services/tmdbService.ts index 36cb53f7..de7c0de5 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) { @@ -518,11 +529,16 @@ export class TMDBService { */ async extractTMDBIdFromStremioId(stremioId: string): Promise { try { - // Extract the base IMDB ID (remove season/episode info if present) - const imdbId = stremioId.split(':')[0]; + // Extract the base ID (remove season/episode info if present) + const baseId = stremioId.split(':')[0]; + + // Only try to convert if it's an IMDb ID (starts with 'tt') + if (!baseId.startsWith('tt')) { + return null; + } // Use the existing findTMDBIdByIMDB function to get the TMDB ID - const tmdbId = await this.findTMDBIdByIMDB(imdbId); + const tmdbId = await this.findTMDBIdByIMDB(baseId); return tmdbId; } catch (error) { return null; @@ -1658,4 +1674,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/watchedService.ts b/src/services/watchedService.ts index 68dc9477..dc339bc1 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -7,14 +7,28 @@ import { MalSync } from './mal/MalSync'; import { MalAuth } from './mal/MalAuth'; import { ArmSyncService } from './mal/ArmSyncService'; +export interface LocalWatchedItem { + content_id: string; + content_type: 'movie' | 'series'; + title: string; + season: number | null; + episode: number | null; + watched_at: number; +} + /** * WatchedService - Manages "watched" status for movies, episodes, and seasons. - * Handles both local storage and Trakt/MAL sync transparently. + * Handles both local storage and Trakt/Simkl/MAL sync transparently. + * + * When a service is authenticated, it syncs to that service. + * Always stores locally for offline access and fallback. */ class WatchedService { private static instance: WatchedService; private traktService: TraktService; private simklService: SimklService; + private readonly WATCHED_ITEMS_KEY = '@user:local:watched_items'; + private watchedSubscribers: Array<() => void> = []; private constructor() { this.traktService = TraktService.getInstance(); @@ -28,6 +42,158 @@ class WatchedService { return WatchedService.instance; } + private watchedKey(item: Pick): string { + return `${item.content_id}::${item.season ?? -1}::${item.episode ?? -1}`; + } + + private normalizeWatchedItem(item: LocalWatchedItem): LocalWatchedItem { + return { + content_id: String(item.content_id || ''), + content_type: item.content_type === 'movie' ? 'movie' : 'series', + title: item.title || '', + season: item.season == null ? null : Number(item.season), + episode: item.episode == null ? null : Number(item.episode), + watched_at: Number(item.watched_at || Date.now()), + }; + } + + private notifyWatchedSubscribers(): void { + if (this.watchedSubscribers.length === 0) return; + this.watchedSubscribers.forEach((cb) => cb()); + } + + public subscribeToWatchedUpdates(callback: () => void): () => void { + this.watchedSubscribers.push(callback); + return () => { + const index = this.watchedSubscribers.indexOf(callback); + if (index > -1) { + this.watchedSubscribers.splice(index, 1); + } + }; + } + + private async loadWatchedItems(): Promise { + try { + const json = await mmkvStorage.getItem(this.WATCHED_ITEMS_KEY); + if (!json) return []; + const parsed = JSON.parse(json); + if (!Array.isArray(parsed)) return []; + + const deduped = new Map(); + parsed.forEach((raw) => { + if (!raw || typeof raw !== 'object') return; + const normalized = this.normalizeWatchedItem(raw as LocalWatchedItem); + if (!normalized.content_id) return; + const key = this.watchedKey(normalized); + const existing = deduped.get(key); + if (!existing || normalized.watched_at > existing.watched_at) { + deduped.set(key, normalized); + } + }); + + return Array.from(deduped.values()); + } catch (error) { + logger.error('[WatchedService] Failed to load local watched items:', error); + return []; + } + } + + private async saveWatchedItems(items: LocalWatchedItem[]): Promise { + try { + await mmkvStorage.setItem(this.WATCHED_ITEMS_KEY, JSON.stringify(items)); + } catch (error) { + logger.error('[WatchedService] Failed to save local watched items:', error); + } + } + + public async getAllWatchedItems(): Promise { + return await this.loadWatchedItems(); + } + + private async upsertLocalWatchedItems(items: LocalWatchedItem[]): Promise { + if (items.length === 0) return; + + const current = await this.loadWatchedItems(); + const byKey = new Map( + current.map((item) => [this.watchedKey(item), item]) + ); + + let changed = false; + for (const raw of items) { + const normalized = this.normalizeWatchedItem(raw); + if (!normalized.content_id) continue; + + const key = this.watchedKey(normalized); + const existing = byKey.get(key); + if (!existing || normalized.watched_at > existing.watched_at || (normalized.title && normalized.title !== existing.title)) { + byKey.set(key, normalized); + changed = true; + } + } + + if (changed) { + await this.saveWatchedItems(Array.from(byKey.values())); + this.notifyWatchedSubscribers(); + } + } + + private async removeLocalWatchedItems(items: Array>): Promise { + if (items.length === 0) return; + + const current = await this.loadWatchedItems(); + const toRemove = new Set(items.map((item) => this.watchedKey({ content_id: item.content_id, season: item.season ?? null, episode: item.episode ?? null }))); + const filtered = current.filter((item) => !toRemove.has(this.watchedKey(item))); + + if (filtered.length !== current.length) { + await this.saveWatchedItems(filtered); + this.notifyWatchedSubscribers(); + } + } + + public async mergeRemoteWatchedItems(items: LocalWatchedItem[]): Promise { + const normalized = items + .map((item) => this.normalizeWatchedItem(item)) + .filter((item) => Boolean(item.content_id)); + + await this.upsertLocalWatchedItems(normalized); + + for (const item of normalized) { + if (item.content_type === 'movie') { + await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at)); + continue; + } + + if (item.season == null || item.episode == null) continue; + const episodeId = `${item.content_id}:${item.season}:${item.episode}`; + await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at)); + } + } + + public async reconcileRemoteWatchedItems(items: LocalWatchedItem[]): Promise { + const normalizedRemote = items + .map((item) => this.normalizeWatchedItem(item)) + .filter((item) => Boolean(item.content_id)); + + // Guard: do not wipe local watched data if backend temporarily returns empty. + if (normalizedRemote.length === 0) { + return; + } + + await this.saveWatchedItems(normalizedRemote); + this.notifyWatchedSubscribers(); + + for (const item of normalizedRemote) { + if (item.content_type === 'movie') { + await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at)); + continue; + } + + if (item.season == null || item.episode == null) continue; + const episodeId = `${item.content_id}:${item.season}:${item.episode}`; + await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at)); + } + } + /** * Mark a movie as watched * @param imdbId - The IMDb ID of the movie @@ -77,6 +243,16 @@ class WatchedService { // Also store locally as "completed" (100% progress) await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt); + await this.upsertLocalWatchedItems([ + { + content_id: imdbId, + content_type: 'movie', + title: imdbId, + season: null, + episode: null, + watched_at: watchedAt.getTime(), + }, + ]); return { success: true, syncedToTrakt }; } catch (error) { @@ -194,6 +370,16 @@ class WatchedService { // Store locally as "completed" const episodeId = `${showId}:${season}:${episode}`; await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt); + await this.upsertLocalWatchedItems([ + { + content_id: showImdbId, + content_type: 'series', + title: showImdbId, + season, + episode, + watched_at: watchedAt.getTime(), + }, + ]); return { success: true, syncedToTrakt }; } catch (error) { @@ -263,6 +449,17 @@ class WatchedService { await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt); } + await this.upsertLocalWatchedItems( + episodes.map((ep) => ({ + content_id: showImdbId, + content_type: 'series' as const, + title: showImdbId, + season: ep.season, + episode: ep.episode, + watched_at: watchedAt.getTime(), + })) + ); + return { success: true, syncedToTrakt, count: episodes.length }; } catch (error) { logger.error('[WatchedService] Failed to mark episodes as watched:', error); @@ -306,7 +503,6 @@ class WatchedService { const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { // Simkl doesn't have a direct "mark season" generic endpoint in the same way, but we can construct it - // We know the episodeNumbers from the arguments! const episodes = episodeNumbers.map(num => ({ number: num, watched_at: watchedAt.toISOString() })); await this.simklService.addToHistory({ shows: [{ @@ -326,6 +522,17 @@ class WatchedService { await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt); } + await this.upsertLocalWatchedItems( + episodeNumbers.map((episode) => ({ + content_id: showImdbId, + content_type: 'series' as const, + title: showImdbId, + season, + episode, + watched_at: watchedAt.getTime(), + })) + ); + return { success: true, syncedToTrakt, count: episodeNumbers.length }; } catch (error) { logger.error('[WatchedService] Failed to mark season as watched:', error); @@ -360,6 +567,9 @@ class WatchedService { // Remove local progress await storageService.removeWatchProgress(imdbId, 'movie'); await mmkvStorage.removeItem(`watched:movie:${imdbId}`); + await this.removeLocalWatchedItems([ + { content_id: imdbId, season: null, episode: null }, + ]); return { success: true, syncedToTrakt }; } catch (error) { @@ -410,6 +620,9 @@ class WatchedService { // Remove local progress const episodeId = `${showId}:${season}:${episode}`; await storageService.removeWatchProgress(showId, 'series', episodeId); + await this.removeLocalWatchedItems([ + { content_id: showImdbId, season, episode }, + ]); return { success: true, syncedToTrakt }; } catch (error) { @@ -468,6 +681,14 @@ class WatchedService { await storageService.removeWatchProgress(showId, 'series', episodeId); } + await this.removeLocalWatchedItems( + episodeNumbers.map((episode) => ({ + content_id: showImdbId, + season, + episode, + })) + ); + return { success: true, syncedToTrakt, count: episodeNumbers.length }; } catch (error) { logger.error('[WatchedService] Failed to unmark season as watched:', error); 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; diff --git a/src/utils/version.ts b/src/utils/version.ts index 06f1acea..96fe25be 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -1,7 +1,7 @@ // Single source of truth for the app version displayed in Settings // Update this when bumping app version -export const APP_VERSION = '1.3.7'; +export const APP_VERSION = '1.4.1'; export function getDisplayedAppVersion(): string { return APP_VERSION;