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/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png index 15b512eb..84066f8c 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..a1907f1b 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..5e73c55f 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..81d907ff 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..bb8a75f5 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..409cd825 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..fa1a496b 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..9ea4744d 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..0d22646c 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..7acd5195 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..e152d6b3 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..fa2054ea 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..f1994a0d 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..8d958805 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..72ed3e7e 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..e0989481 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..6f370d8c 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..7ec5130e 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..9ac056b7 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..6fe44f1f 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/app.json b/app.json index d5510500..f1007308 100644 --- a/app.json +++ b/app.json @@ -35,7 +35,7 @@ "LSSupportsOpeningDocumentsInPlace": true, "UIFileSharingEnabled": true }, - "bundleIdentifier": "com.nuvio.app", + "bundleIdentifier": "com.nuvio.hub", "associatedDomains": [], "jsEngine": "hermes", "appleTeamId": "8QBDZ766S3" diff --git a/assets/android/ic_launcher-web.png b/assets/android/ic_launcher-web.png index 493e3b98..130efa79 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..c304d0eb 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..b6d88502 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..c304d0eb 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..fa0c0fdd 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..1a8d780b 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..fa0c0fdd 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..6605b555 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..0fd39bfd 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..6605b555 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..0587b0f6 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..98921102 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..0587b0f6 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..df92e92b 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..1be0f327 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..df92e92b 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..947a0bdd 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..dbb45959 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..947a0bdd 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..130efa79 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..812a1b4d 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..bd953ebc 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..ddf78c6e 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..8deccd3e 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..6ef6611a 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..0a314649 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..bd953ebc 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..b1c0ba2a 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..a18395f6 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..a18395f6 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..262b4f38 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..46ea3f49 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..2067acf4 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..5e270ab1 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..6f5a8d75 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..130efa79 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..6f5a8d75 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..9fc0d4ad 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/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/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/index.html b/index.html index 749e2f0a..fe298212 100644 --- a/index.html +++ b/index.html @@ -779,9 +779,9 @@
Full compatibility with Stremio addons. Access your favorite content - providers seamlessly.
+Supports user-installed Stremio addons for metadata and source + integration.
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.