mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-15 23:45:59 +00:00
1254 lines
33 KiB
Markdown
1254 lines
33 KiB
Markdown
# 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 |
|