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/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/src/components/metadata/.HeroSection.tsx.swp b/src/components/metadata/.HeroSection.tsx.swp index 6a8c4627..64b827be 100644 Binary files a/src/components/metadata/.HeroSection.tsx.swp and b/src/components/metadata/.HeroSection.tsx.swp differ diff --git a/src/contexts/AccountContext.tsx b/src/contexts/AccountContext.tsx index a2d36997..be417230 100644 --- a/src/contexts/AccountContext.tsx +++ b/src/contexts/AccountContext.tsx @@ -38,11 +38,17 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ child user, loading, signIn: async (email: string, password: string) => { - const { error } = await accountService.signInWithEmail(email, password); + const { user: signedInUser, error } = await accountService.signInWithEmail(email, password); + if (!error && signedInUser) { + setUser(signedInUser); + } return error || null; }, signUp: async (email: string, password: string) => { - const { error } = await accountService.signUpWithEmail(email, password); + const { user: signedUpUser, error } = await accountService.signUpWithEmail(email, password); + if (!error && signedUpUser) { + setUser(signedUpUser); + } return error || null; }, signOut: async () => { @@ -107,4 +113,3 @@ export const useAccount = (): AccountContextValue => { }; export default AccountContext; - diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index ec219b70..a818fcf2 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -39,6 +39,7 @@ if (Platform.OS === 'ios') { import HomeScreen from '../screens/HomeScreen'; import LibraryScreen from '../screens/LibraryScreen'; import SettingsScreen from '../screens/SettingsScreen'; +import SyncSettingsScreen from '../screens/SyncSettingsScreen'; import DownloadsScreen from '../screens/DownloadsScreen'; import MetadataScreen from '../screens/MetadataScreen'; import KSPlayerCore from '../components/player/KSPlayerCore'; @@ -105,6 +106,7 @@ export type RootStackParamList = { Home: undefined; Library: undefined; Settings: undefined; + SyncSettings: undefined; Update: undefined; Search: undefined; Calendar: undefined; @@ -1854,7 +1856,12 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> - + + @@ -1924,7 +1931,6 @@ const ConditionalPostHogProvider: React.FC<{ children: React.ReactNode }> = ({ c apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C" options={{ host: "https://us.i.posthog.com", - autocapture: analyticsEnabled, // Start opted out if analytics is disabled defaultOptIn: analyticsEnabled, }} diff --git a/src/screens/AuthScreen.tsx b/src/screens/AuthScreen.tsx index c890208f..d19b8d21 100644 --- a/src/screens/AuthScreen.tsx +++ b/src/screens/AuthScreen.tsx @@ -27,7 +27,6 @@ const AuthScreen: React.FC = () => { const [showPassword, setShowPassword] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const [mode, setMode] = useState<'signin' | 'signup'>('signin'); - const signupDisabled = true; // Signup disabled due to upcoming system replacement const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [showWarningDetails, setShowWarningDetails] = useState(false); @@ -145,16 +144,7 @@ const AuthScreen: React.FC = () => { const handleSubmit = async () => { if (loading) return; - - // Prevent signup if disabled - if (mode === 'signup' && signupDisabled) { - const msg = 'Sign up is currently disabled due to upcoming system changes'; - setError(msg); - showError('Sign Up Disabled', 'Sign up is currently disabled due to upcoming system changes'); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); - return; - } - + if (!isEmailValid) { const msg = 'Enter a valid email address'; setError(msg); @@ -404,21 +394,17 @@ const AuthScreen: React.FC = () => { !signupDisabled && setMode('signup')} - activeOpacity={signupDisabled ? 1 : 0.8} - disabled={signupDisabled} + style={styles.switchButton} + onPress={() => setMode('signup')} + activeOpacity={0.8} > - Sign Up {signupDisabled && '(Disabled)'} + Sign Up @@ -583,29 +569,18 @@ const AuthScreen: React.FC = () => { {/* 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 */} { 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 => { @@ -376,24 +379,35 @@ const SettingsScreen: React.FC = () => { case 'account': return ( - {isItemVisible('trakt') && ( - } + {showTraktItem && ( + } renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} - isLast={!isItemVisible('simkl')} + isLast={!showSimklItem && !showCloudSyncItem} isTablet={isTablet} /> )} - {isItemVisible('simkl') && ( - } + {showSimklItem && ( + } renderControl={() => } onPress={() => navigation.navigate('SimklSettings')} + isLast={!showCloudSyncItem} + isTablet={isTablet} + /> + )} + {showCloudSyncItem && ( + } + onPress={() => (navigation as any).navigate('SyncSettings')} isLast={true} isTablet={isTablet} /> @@ -682,25 +696,35 @@ const SettingsScreen: React.FC = () => { contentContainerStyle={styles.scrollContent} > {/* Account */} - {(settingsConfig?.categories?.['account']?.visible !== false) && (isItemVisible('trakt') || isItemVisible('simkl')) && ( + {(settingsConfig?.categories?.['account']?.visible !== false) && (showTraktItem || showSimklItem || showCloudSyncItem) && ( - {isItemVisible('trakt') && ( + {showTraktItem && ( } renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} - isLast={!isItemVisible('simkl')} + isLast={!showSimklItem && !showCloudSyncItem} /> )} - {isItemVisible('simkl') && ( + {showSimklItem && ( } renderControl={() => } onPress={() => navigation.navigate('SimklSettings')} + isLast={!showCloudSyncItem} + /> + )} + {showCloudSyncItem && ( + } + onPress={() => (navigation as any).navigate('SyncSettings')} isLast={true} /> )} @@ -1211,4 +1235,4 @@ const styles = StyleSheet.create({ }, }); -export default SettingsScreen; \ No newline at end of file +export default SettingsScreen; diff --git a/src/screens/SyncSettingsScreen.tsx b/src/screens/SyncSettingsScreen.tsx new file mode 100644 index 00000000..c33f5c38 --- /dev/null +++ b/src/screens/SyncSettingsScreen.tsx @@ -0,0 +1,467 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { + ActivityIndicator, + ScrollView, + StatusBar, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { NavigationProp, useFocusEffect, useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { RootStackParamList } from '../navigation/AppNavigator'; +import ScreenHeader from '../components/common/ScreenHeader'; +import { useTheme } from '../contexts/ThemeContext'; +import CustomAlert from '../components/CustomAlert'; +import { supabaseSyncService, SupabaseUser, LinkedDevice } from '../services/supabaseSyncService'; +import { useAccount } from '../contexts/AccountContext'; + +const SyncSettingsScreen: React.FC = () => { + const { currentTheme } = useTheme(); + const navigation = useNavigation>(); + const insets = useSafeAreaInsets(); + const { user, signOut } = useAccount(); + + const [loading, setLoading] = useState(false); + const [syncCodeLoading, setSyncCodeLoading] = useState(false); + const [sessionUser, setSessionUser] = useState(null); + const [ownerId, setOwnerId] = useState(null); + const [linkedDevices, setLinkedDevices] = useState([]); + const [lastCode, setLastCode] = useState(''); + const [pin, setPin] = useState(''); + const [claimCode, setClaimCode] = useState(''); + const [claimPin, setClaimPin] = useState(''); + const [deviceName, setDeviceName] = useState(''); + + 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 devices = await supabaseSyncService.getLinkedDevices(); + setLinkedDevices(devices); + } catch (error: any) { + openAlert('Sync Error', error?.message || 'Failed to load sync state'); + } finally { + setLoading(false); + } + }, [openAlert]); + + useFocusEffect( + useCallback(() => { + loadSyncState(); + }, [loadSyncState]) + ); + + const authLabel = useMemo(() => { + if (!supabaseSyncService.isConfigured()) return 'Supabase not configured'; + if (!sessionUser) return 'Not authenticated'; + return `Email session${sessionUser.email ? ` (${sessionUser.email})` : ''}`; + }, [sessionUser]); + + const handleGenerateCode = async () => { + if (!pin.trim()) { + openAlert('PIN Required', 'Enter a PIN before generating a sync code.'); + return; + } + setSyncCodeLoading(true); + try { + const result = await supabaseSyncService.generateSyncCode(pin.trim()); + if (result.error || !result.code) { + openAlert('Generate Failed', result.error || 'Unable to generate sync code'); + } else { + setLastCode(result.code); + openAlert('Sync Code Ready', `Code: ${result.code}`); + await loadSyncState(); + } + } finally { + setSyncCodeLoading(false); + } + }; + + const handleGetCode = async () => { + if (!pin.trim()) { + openAlert('PIN Required', 'Enter your PIN to retrieve the current sync code.'); + return; + } + setSyncCodeLoading(true); + try { + const result = await supabaseSyncService.getSyncCode(pin.trim()); + if (result.error || !result.code) { + openAlert('Fetch Failed', result.error || 'Unable to fetch sync code'); + } else { + setLastCode(result.code); + openAlert('Current Sync Code', `Code: ${result.code}`); + } + } finally { + setSyncCodeLoading(false); + } + }; + + const handleClaimCode = async () => { + if (!claimCode.trim() || !claimPin.trim()) { + openAlert('Missing Details', 'Enter both sync code and PIN to claim.'); + return; + } + setSyncCodeLoading(true); + try { + const result = await supabaseSyncService.claimSyncCode( + claimCode.trim().toUpperCase(), + claimPin.trim(), + deviceName.trim() || undefined + ); + if (!result.success) { + openAlert('Claim Failed', result.message); + } else { + openAlert('Device Linked', result.message); + setClaimCode(''); + setClaimPin(''); + await loadSyncState(); + } + } finally { + setSyncCodeLoading(false); + } + }; + + const handleManualSync = async () => { + setSyncCodeLoading(true); + try { + await supabaseSyncService.syncNow(); + openAlert('Sync Complete', 'Manual sync completed successfully.'); + await loadSyncState(); + } catch (error: any) { + openAlert('Sync Failed', error?.message || 'Manual sync failed'); + } finally { + setSyncCodeLoading(false); + } + }; + + const handleUnlinkDevice = (deviceUserId: string) => { + openAlert('Unlink Device', 'Are you sure you want to unlink this device?', [ + { label: 'Cancel', onPress: () => {} }, + { + label: 'Unlink', + onPress: async () => { + setSyncCodeLoading(true); + try { + const result = await supabaseSyncService.unlinkDevice(deviceUserId); + if (!result.success) { + openAlert('Unlink Failed', result.error || 'Unable to unlink device'); + } else { + await loadSyncState(); + } + } finally { + setSyncCodeLoading(false); + } + }, + }, + ]); + }; + + const handleSignOut = async () => { + setSyncCodeLoading(true); + try { + await signOut(); + await loadSyncState(); + } catch (error: any) { + openAlert('Sign Out Failed', error?.message || 'Failed to sign out'); + } finally { + setSyncCodeLoading(false); + } + }; + + if (loading) { + return ( + + + navigation.goBack()} /> + + + + + ); + } + + return ( + + + navigation.goBack()} /> + + + + Account + + {user?.email ? `Signed in as ${user.email}` : 'Not signed in'} + + + {!user ? ( + navigation.navigate('Account')} + > + Sign In / Sign Up + + ) : ( + <> + navigation.navigate('AccountManage')} + > + Manage Account + + + Sign Out + + + )} + + + + + Connection Status + {authLabel} + + Effective owner: {ownerId || 'Unavailable'} + + {!supabaseSyncService.isConfigured() && ( + + Set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY to enable sync. + + )} + + + + Sync Code + + {!!lastCode && ( + + Latest code: {lastCode} + + )} + + + Generate Code + + + Get Existing Code + + + + + + Claim Sync Code + + + + + Claim Code + + + + + Linked Devices + {linkedDevices.length === 0 && ( + No linked devices. + )} + {linkedDevices.map((device) => ( + + + + {device.device_name || 'Unnamed device'} + + + {device.device_user_id} + + + handleUnlinkDevice(device.device_user_id)} + > + Unlink + + + ))} + + + + {syncCodeLoading ? ( + + ) : ( + Sync Now + )} + + + + setAlertVisible(false)} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + content: { + padding: 16, + gap: 16, + }, + card: { + borderWidth: 1, + borderRadius: 14, + padding: 14, + gap: 10, + }, + cardTitle: { + fontSize: 16, + fontWeight: '700', + }, + cardText: { + fontSize: 13, + lineHeight: 18, + }, + warning: { + fontSize: 12, + marginTop: 4, + }, + input: { + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 10, + fontSize: 14, + }, + buttonRow: { + flexDirection: 'row', + gap: 10, + }, + button: { + flex: 1, + borderRadius: 10, + minHeight: 42, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 12, + }, + buttonText: { + color: '#fff', + fontWeight: '700', + fontSize: 13, + }, + codeText: { + fontSize: 13, + fontWeight: '600', + }, + deviceRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 12, + paddingVertical: 6, + }, + deviceInfo: { + flex: 1, + }, + deviceName: { + fontSize: 14, + fontWeight: '600', + }, + deviceMeta: { + fontSize: 12, + marginTop: 2, + }, + unlinkButton: { + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 6, + }, + unlinkText: { + fontSize: 12, + fontWeight: '700', + }, + syncNowButton: { + minHeight: 48, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, +}); + +export default SyncSettingsScreen; diff --git a/src/services/AccountService.ts b/src/services/AccountService.ts index 32dc943f..f17d8c66 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; @@ -19,23 +21,72 @@ 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 || !result.user) { + return { error: result.error || 'Sign up failed' }; + } + + const mapped = this.mapSupabaseUser(result.user); + 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 +120,3 @@ class AccountService { export const accountService = AccountService.getInstance(); export default accountService; - diff --git a/src/services/pluginService.ts b/src/services/pluginService.ts index 8c7e2b25..a84437f4 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'); } @@ -1777,4 +1793,4 @@ class LocalScraperService { export const localScraperService = LocalScraperService.getInstance(); export const pluginService = localScraperService; // Alias for UI consistency -export default localScraperService; \ No newline at end of file +export default localScraperService; diff --git a/src/services/supabaseSyncService.ts b/src/services/supabaseSyncService.ts new file mode 100644 index 00000000..6eae07cb --- /dev/null +++ b/src/services/supabaseSyncService.ts @@ -0,0 +1,1145 @@ +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'; + +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; +}; + +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 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); + } + + if (!response.user) { + return { error: 'Signup failed: user not returned by Supabase' }; + } + + 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; + 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(); + + const traktConnected = await this.isTraktConnected(); + if (traktConnected) { + return; + } + + await this.pushWatchProgressFromLocal(); + await this.pushLibraryFromLocal(); + 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(); + + const traktConnected = await this.isTraktConnected(); + if (traktConnected) { + return; + } + + await this.pullWatchProgressToLocal(); + await this.pullLibraryToLocal(); + 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 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) { + logger.log('[SupabaseSyncService] runStartupSync: step=push_plugins:start'); + await this.safeRun('push_plugins', async () => { + await this.pushPluginsFromLocal(); + }); + logger.log('[SupabaseSyncService] runStartupSync: step=push_plugins:done'); + } + + if (addonPullOk) { + logger.log('[SupabaseSyncService] runStartupSync: step=push_addons:start'); + await this.safeRun('push_addons', async () => { + await this.pushAddonsFromLocal(); + }); + logger.log('[SupabaseSyncService] runStartupSync: step=push_addons:done'); + } + + const traktConnected = await this.isTraktConnected(); + if (traktConnected) { + logger.log('[SupabaseSyncService] Trakt is connected; skipping progress/library/watched Supabase sync.'); + return; + } + + const watchPullOk = await this.safeRun('pull_watch_progress', async () => { + await this.withSuppressedPushes(async () => { + await this.pullWatchProgressToLocal(); + }); + }); + + const libraryPullOk = await this.safeRun('pull_library', async () => { + await this.withSuppressedPushes(async () => { + await this.pullLibraryToLocal(); + }); + }); + + const watchedPullOk = await this.safeRun('pull_watched_items', async () => { + await this.withSuppressedPushes(async () => { + await this.pullWatchedItemsToLocal(); + }); + }); + + if (watchPullOk) { + await this.safeRun('push_watch_progress', async () => { + await this.pushWatchProgressFromLocal(); + }); + } + + if (libraryPullOk) { + await this.safeRun('push_library', async () => { + await this.pushLibraryFromLocal(); + }); + } + + if (watchedPullOk) { + await this.safeRun('push_watched_items', async () => { + await this.pushWatchedItemsFromLocal(); + }); + } + } + + 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(() => this.schedulePush('watch_progress')); + + 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 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; + } + + const traktConnected = await this.isTraktConnected(); + if (traktConnected) { + return; + } + + if (target === 'watch_progress') { + 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; + } + + 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 { + 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; + 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; + 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).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) { + return error.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 { + return url.trim().toLowerCase(); + } + + 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 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 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)}&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])); + + 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, + }); + } + } + } + + 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)}&order=sort_order.asc`, + { + method: 'GET', + authToken: token, + } + ); + logger.log(`[SupabaseSyncService] pullAddonsToLocal: remoteCount=${rows?.length || 0}`); + + 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)) + ); + + for (const row of rows || []) { + if (!row.url) continue; + const normalized = this.normalizeUrl(row.url); + if (installedUrls.has(normalized)) continue; + + try { + await stremioService.installAddon(row.url); + installedUrls.add(normalized); + } 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'); + } + } + + 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', {}); + + 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; + + 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, + } + ); + } + } + + private async pushWatchProgressFromLocal(): Promise { + const all = await storageService.getAllWatchProgress(); + const entries: WatchProgressRow[] = Object.entries(all).reduce((acc, [key, value]) => { + const parsed = this.parseWatchProgressKey(key); + if (!parsed) return acc; + acc.push({ + 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, + }); + return acc; + }, []); + + await this.callRpc('sync_push_watch_progress', { p_entries: entries }); + } + + 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}`)); + + 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}`; + 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); + } + } + } + + 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.mergeRemoteWatchedItems(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/watchedService.ts b/src/services/watchedService.ts index 50218aeb..71344e08 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -4,10 +4,19 @@ import { storageService } from './storageService'; import { mmkvStorage } from './mmkvStorage'; import { logger } from '../utils/logger'; +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 sync transparently. - * + * * When Trakt is authenticated, it syncs to Trakt. * When not authenticated, it stores locally. */ @@ -15,6 +24,8 @@ 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 +39,133 @@ 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)); + } + } + /** * Mark a movie as watched * @param imdbId - The IMDb ID of the movie @@ -59,6 +197,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) { @@ -119,6 +267,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) { @@ -188,6 +346,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); @@ -231,7 +400,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: [{ @@ -251,6 +419,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); @@ -285,6 +464,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) { @@ -335,6 +517,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) { @@ -368,10 +553,6 @@ class WatchedService { showImdbId, season ); - syncedToTrakt = await this.traktService.removeSeasonFromHistory( - showImdbId, - season - ); logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`); } @@ -397,6 +578,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);