NuvioStreaming/docs/SUPABASE_SYNC.md
2026-02-16 21:39:41 +05:30

33 KiB

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
  2. Prerequisites
  3. Database Schema
  4. RPC Functions
  5. Integration Guide
  6. Data Models
  7. Sync Behavior & Restrictions
  8. 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.

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.

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.

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.

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.

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

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.

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

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

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

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.

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.

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.

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;

Removes a linked device. Only the owner can unlink their devices.

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.

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.

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.

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

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.

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.

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

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.

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:

// POST /rest/v1/rpc/generate_sync_code
{ "p_pin": "1234" }

// Response:
[{ "code": "A1B2-C3D4-E5F6-G7H8-I9J0" }]

Device B (Child) — Claim Sync Code:

// 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):

// 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:

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

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

// POST /rest/v1/rpc/sync_push_addons
{
  "p_addons": [
    {
      "url": "https://example.com/addon/manifest.json",
      "sort_order": 0
    }
  ]
}

Push Watch Progress

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

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

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

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

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

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

{
  "url": "string (required)",
  "name": "string (optional)",
  "enabled": "boolean (default: true)",
  "sort_order": "integer (default: 0)"
}

Addon

{
  "url": "string (required)",
  "sort_order": "integer (default: 0)"
}

Watch Progress Entry

{
  "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

{
  "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

{
  "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

{
  "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

{
  "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