# 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 |