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);