* feat(invitations): add emailed pre-provisioned invitations
Admins can invite a specific person by email: the invitation pre-binds
role, access group, and library access, and the invitee only chooses a
password. Their email address becomes their username, so login gains an
email fallback (username lookup first, email column only on miss for
inputs that parse as a bare address).
- invitations table: single-use token (SHA-256 at rest) bound to one
address; a partial unique index makes resend-supersedes atomic; no
users row exists until accept, so a typo'd address can't squat a
username. Status is derived from timestamps, not stored.
- internal/invitations: repository, service, and branded email through
the shared internal/mail sender. When SMTP is off the claim URL is
returned for manual delivery instead of failing.
- Admin endpoints /admin/invitations (list/create/resend/revoke) beside
the existing invite-codes routes; public claim endpoints
/invitations/{token} (+/accept) rate-limited with the other auth
endpoints. Unknown/expired/revoked/used tokens are indistinguishable.
- Accept returns the same login response shape as signup, so clients
reuse their session plumbing.
Spec: docs/superpowers/specs/2026-07-27-invitations-and-onboarding-design.md
Plan: docs/superpowers/plans/2026-07-27-invitations-and-onboarding.md
Part of #215
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): add invitation admin tab, claim page, and household setup
- Admin → Users gains an Invitations tab: compose (email, role, access
group, libraries, note, first-profile and tour toggles), list with
derived status, resend, revoke. When the server has no SMTP the create
response's claim URL is surfaced for copy-paste instead of a fake
success.
- /invite/:token claim page: everything but the password was decided at
send time, so it asks for exactly one thing and lands the user signed
in. Expired/used links get an explanatory card, not a 404.
- /household-setup ("Who's watching?"): profile tiles plus the existing
ProfileEditorDialog, all through the existing /profiles endpoint —
no new backend. "Just me for now" is a first-class exit.
Part of #215
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(onboarding): add server-driven onboarding manifest and state
GET /onboarding/flow returns the ordered first-run tour for this server
and profile: steps for disabled features (requests, watch together,
recommendations, notifications) are filtered out server-side, surface=tv
drops steps needing text entry, and child profiles never see stops they
can't act on. Copy lives in Go, so a wording fix is a deploy — clients
render step kinds they know and skip unknown ones by contract.
setting_choice steps name an explicit write target (profile_field /
setting / device_setting) because playback quality is a profile column,
not a settings key — the tour writes through the same APIs the settings
screens use.
Per-profile completion state lives in the user store (SQLite schema v14
+ a Postgres twin table), keyed by (profile_id, tour_id) with monotonic
completed/skipped timestamps: finishing on one device silences every
other; a later progress write can never un-complete.
Part of #215
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): add the first-run feature tour
TourHost renders the server manifest as a modal overlay on Home: unknown
step kinds are skipped silently (the forward-compat contract), progress
posts per step, and setting_choice steps write real values through the
existing profile/settings mutations — by the last step the account is
genuinely configured. Skip is always one click and recorded server-side,
so no other device re-prompts. The tour ends by handing off to the
existing taste-seed picker, which now waits for the tour to finish
before its own redirect. Settings → Personalize gains a replay entry.
An invitation sent with show_tour=false plants a local hint that the
gate converts into a server-side skip for the first profile.
Part of #215
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): satisfy noUncheckedIndexedAccess in the tour's advance step
The Docker web build runs `tsc -b`, which applies the project's
noUncheckedIndexedAccess; the bounds check didn't narrow steps[next].
Look the step up once and branch on its presence instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): blur the whole app behind the tour, sidebar included
The tour overlay rendered inside the app layout, where an ancestor
creates a fixed-position containing block — inset-0 pinned to the
content pane, leaving the sidebar completely un-scrimmed. Portal the
dialog to <body> so the scrim truly covers the viewport, and raise the
backdrop blur from sm (4px) to xl (24px) so card titles and nav labels
aren't legible through it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(onboarding): name features by their UI labels in the tour copy
"Same movie, different couches" never said what the feature is called.
Every feature card now leads with the name the sidebar actually uses —
Watch Party, Requests, Watchlist, Calendar, Notifications — and says
where to find it, so the tour teaches vocabulary, not just concepts.
Server-side copy, so all three clients pick this up with no release.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(onboarding): add apps and Jellyfin-compat steps to the tour
Two new web-only feature cards near the end of the tour:
- "Take Silo with you" — native apps for iPhone/iPad/Apple TV and
Android/Android TV, with outbound TestFlight and Play Store links.
Steps gain an additive links field (label + url) that older clients
ignore; the web TourHost renders them as external-link buttons.
- "Already use a Jellyfin app? It works here" — Infuse/VidHub/Findroid/
Swiftfin connect via the Jellyfin API. Gated on
jellyfin_compat.enabled (default-on, so unset counts as enabled;
only an explicit "false" hides it).
Both steps are web-only: the apps card is pointless inside the apps it
advertises, and TV can't open store links. surface=phone/tv manifests
skip them, covered by tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(web): keep the tour card responsive on phone widths
Verified every step at 1600px, 390px, and 320px with an automated
overflow check. Fixes it found:
- Link buttons (apps step) now wrap and truncate instead of extending
past the card edge.
- The footer wraps at very narrow widths, so the handoff step's wide
primary button drops to its own line rather than overflowing.
- Progress pips hide on phones — decorative, and they crowded the
Back/Next buttons.
- The card scrolls within 85dvh so a tall step never pins its buttons
off-screen on landscape phones.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(web): render store links as branded badges in the tour
The apps step's plain outline buttons now render as store badges: the
Apple or Google Play mark with a store eyebrow (TestFlight beta /
Google Play) over the platform label — the familiar app-store badge
idiom. The brand is inferred from the link's host on the client, so
the server contract stays icon-free and non-store links keep the plain
external-link button. Labels drop the parenthesized store name the
eyebrow now carries.
Verified at 1600px and 390px with the overflow sweep: none.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
726 lines
22 KiB
Go
726 lines
22 KiB
Go
// Package userdb provides per-user SQLite database management for Silo.
|
|
// Each user gets their own SQLite file storing profiles, watch progress,
|
|
// favorites, collections, playback sessions, and settings.
|
|
package userdb
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
)
|
|
|
|
// Schema is the full SQLite schema for per-user databases.
|
|
const Schema = `
|
|
CREATE TABLE IF NOT EXISTS profiles (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
avatar TEXT,
|
|
pin_hash TEXT,
|
|
is_child BOOLEAN DEFAULT false,
|
|
is_primary BOOLEAN NOT NULL DEFAULT false,
|
|
max_content_rating TEXT,
|
|
quality_preference TEXT DEFAULT '1080p',
|
|
language TEXT DEFAULT 'en',
|
|
subtitle_language TEXT,
|
|
subtitle_mode TEXT DEFAULT 'auto',
|
|
auto_skip_intro BOOLEAN DEFAULT false,
|
|
auto_skip_credits BOOLEAN DEFAULT false,
|
|
auto_skip_recap BOOLEAN DEFAULT false,
|
|
auto_play_next_preview BOOLEAN DEFAULT false,
|
|
show_forced_subtitles BOOLEAN NOT NULL DEFAULT true,
|
|
library_restrictions_enabled BOOLEAN DEFAULT false,
|
|
max_playback_quality TEXT DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS profile_allowed_libraries (
|
|
profile_id TEXT NOT NULL,
|
|
library_id INTEGER NOT NULL,
|
|
PRIMARY KEY (profile_id, library_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS watch_progress (
|
|
profile_id TEXT NOT NULL,
|
|
media_item_id TEXT NOT NULL,
|
|
position_seconds REAL NOT NULL,
|
|
duration_seconds REAL NOT NULL,
|
|
completed BOOLEAN DEFAULT false,
|
|
updated_at TEXT NOT NULL,
|
|
event_at TEXT,
|
|
synced_seq INTEGER,
|
|
last_file_id INTEGER,
|
|
last_resolution TEXT,
|
|
last_hdr BOOLEAN,
|
|
last_codec_video TEXT,
|
|
last_edition_key TEXT,
|
|
PRIMARY KEY (profile_id, media_item_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS watch_history (
|
|
id TEXT PRIMARY KEY,
|
|
profile_id TEXT NOT NULL,
|
|
media_item_id TEXT NOT NULL,
|
|
watched_at TEXT NOT NULL,
|
|
duration_seconds REAL,
|
|
completed BOOLEAN DEFAULT false,
|
|
source TEXT NOT NULL DEFAULT 'legacy',
|
|
watch_identity TEXT NOT NULL DEFAULT '{}'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS hidden_history_items (
|
|
profile_id TEXT NOT NULL,
|
|
media_item_id TEXT NOT NULL,
|
|
hidden_before TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, media_item_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS playback_sessions (
|
|
session_id TEXT PRIMARY KEY,
|
|
profile_id TEXT NOT NULL,
|
|
media_file_id INTEGER NOT NULL,
|
|
play_method TEXT NOT NULL,
|
|
position_seconds REAL DEFAULT 0,
|
|
is_paused BOOLEAN DEFAULT false,
|
|
started_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS favorites (
|
|
profile_id TEXT NOT NULL,
|
|
media_item_id TEXT NOT NULL,
|
|
added_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, media_item_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS watchlist (
|
|
profile_id TEXT NOT NULL,
|
|
media_item_id TEXT NOT NULL,
|
|
added_at TEXT NOT NULL,
|
|
sort_index INTEGER,
|
|
PRIMARY KEY (profile_id, media_item_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS home_item_dismissals (
|
|
profile_id TEXT NOT NULL,
|
|
surface TEXT NOT NULL,
|
|
media_item_id TEXT NOT NULL,
|
|
series_id TEXT,
|
|
progress_updated_at TEXT,
|
|
dismissed_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, surface, media_item_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS personal_collections (
|
|
id TEXT PRIMARY KEY,
|
|
profile_id TEXT NOT NULL,
|
|
creator_profile_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
collection_type TEXT NOT NULL DEFAULT 'manual',
|
|
is_shared BOOLEAN DEFAULT false,
|
|
query_definition TEXT NOT NULL DEFAULT '{}',
|
|
sort_config TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS personal_collection_items (
|
|
collection_id TEXT NOT NULL,
|
|
media_item_id TEXT NOT NULL,
|
|
position INTEGER,
|
|
added_at TEXT NOT NULL,
|
|
PRIMARY KEY (collection_id, media_item_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS personal_collection_profiles (
|
|
collection_id TEXT NOT NULL,
|
|
profile_id TEXT NOT NULL,
|
|
PRIMARY KEY (collection_id, profile_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS subtitle_preferences (
|
|
profile_id TEXT NOT NULL,
|
|
series_id TEXT NOT NULL,
|
|
subtitle_language TEXT,
|
|
subtitle_track_index INT,
|
|
external_subtitle_path TEXT,
|
|
subtitle_mode TEXT,
|
|
subtitle_track_signature TEXT NOT NULL DEFAULT '{}',
|
|
show_forced_subtitles BOOLEAN,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, series_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audio_preferences (
|
|
profile_id TEXT NOT NULL,
|
|
series_id TEXT NOT NULL,
|
|
audio_track_index INT,
|
|
audio_language TEXT,
|
|
audio_track_signature TEXT NOT NULL DEFAULT '{}',
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, series_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS series_playback_preferences (
|
|
profile_id TEXT NOT NULL,
|
|
series_id TEXT NOT NULL,
|
|
resolution TEXT,
|
|
hdr BOOLEAN NOT NULL DEFAULT false,
|
|
codec_video TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, series_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS library_playback_preferences (
|
|
profile_id TEXT NOT NULL,
|
|
library_id INTEGER NOT NULL,
|
|
audio_language TEXT,
|
|
subtitle_language TEXT,
|
|
subtitle_mode TEXT,
|
|
show_forced_subtitles BOOLEAN,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, library_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS profile_onboarding (
|
|
profile_id TEXT NOT NULL,
|
|
tour_id TEXT NOT NULL,
|
|
last_step TEXT NOT NULL DEFAULT '',
|
|
completed_at TEXT,
|
|
skipped_at TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, tour_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_device_settings (
|
|
profile_id TEXT NOT NULL,
|
|
device_id TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
device_name TEXT NOT NULL DEFAULT '',
|
|
device_platform TEXT NOT NULL DEFAULT '',
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, device_id, key)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS user_devices (
|
|
profile_id TEXT NOT NULL,
|
|
device_id TEXT NOT NULL,
|
|
device_name TEXT NOT NULL DEFAULT '',
|
|
device_platform TEXT NOT NULL DEFAULT '',
|
|
last_seen_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, device_id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS downloads (
|
|
id TEXT PRIMARY KEY,
|
|
profile_id TEXT NOT NULL,
|
|
media_item_id TEXT NOT NULL,
|
|
media_file_id INTEGER NOT NULL,
|
|
quality TEXT,
|
|
transcoded BOOLEAN DEFAULT false,
|
|
file_size INTEGER,
|
|
expires_at TEXT,
|
|
downloaded_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS profile_section_overrides (
|
|
id TEXT PRIMARY KEY,
|
|
profile_id TEXT NOT NULL,
|
|
scope TEXT NOT NULL CHECK (scope IN ('home', 'library')),
|
|
library_id TEXT,
|
|
section_id TEXT,
|
|
position INTEGER,
|
|
hidden INTEGER NOT NULL DEFAULT 0,
|
|
removed INTEGER NOT NULL DEFAULT 0,
|
|
section_type TEXT,
|
|
title TEXT,
|
|
featured INTEGER,
|
|
item_limit INTEGER,
|
|
config TEXT,
|
|
is_user_added INTEGER NOT NULL DEFAULT 0,
|
|
user_section_type TEXT,
|
|
user_config TEXT,
|
|
user_title TEXT,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_profile_section_overrides_lookup
|
|
ON profile_section_overrides(profile_id, scope, library_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_profile_allowed_libraries_lookup
|
|
ON profile_allowed_libraries(profile_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_personal_collection_profiles_lookup
|
|
ON personal_collection_profiles(profile_id, collection_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_home_item_dismissals_lookup
|
|
ON home_item_dismissals(profile_id, surface);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_hidden_history_items_lookup
|
|
ON hidden_history_items(profile_id, hidden_before);
|
|
`
|
|
|
|
// InitSchema creates all tables in the given SQLite database.
|
|
func InitSchema(db *sql.DB) error {
|
|
_, err := db.Exec(Schema)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ensureProfileSectionOverridesRemovedColumn(db); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureProfileSectionOverridesUserAddedColumns(db); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureWatchHistorySourceColumn(db); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureShowForcedSubtitleColumns(db); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureProfileIsPrimaryColumn(db); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureDeviceSettingsProfileColumn(db); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureAutoSkipRecapPreviewColumns(db); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureWatchHistoryIdentityColumn(db); err != nil {
|
|
return err
|
|
}
|
|
if err := ensureWatchProgressSyncColumns(db); err != nil {
|
|
return err
|
|
}
|
|
if err := migratePlaybackSettingsToDeviceScope(db); err != nil {
|
|
return err
|
|
}
|
|
return backfillUserDevices(db)
|
|
}
|
|
|
|
// watchProgressSyncTriggers stamps the server-owned cursor (synced_seq) on every
|
|
// watch_progress write and owns the LWW key: event_at defaults to the write
|
|
// time, and an UPDATE that changed updated_at without explicitly changing
|
|
// event_at advances it too (else a queued offline event older than that write
|
|
// would win SetProgressIfNewer's comparison and resurrect stale progress).
|
|
// Writes that DO set event_at — offline sync's clamped client event time —
|
|
// keep their value. With recursive_triggers OFF (the userdb default) the
|
|
// trigger's own UPDATE does not re-fire it. See the offline-sync design
|
|
// (invariant 1). CREATE TRIGGER IF NOT EXISTS never replaces an existing
|
|
// trigger, so changing a body requires a migrate.go step dropping the old one
|
|
// (see migrateToV13).
|
|
const watchProgressSyncTriggers = `
|
|
CREATE TRIGGER IF NOT EXISTS watch_progress_stamp_ins AFTER INSERT ON watch_progress
|
|
BEGIN
|
|
UPDATE watch_progress
|
|
SET synced_seq = (SELECT COALESCE(MAX(synced_seq), 0) + 1 FROM watch_progress),
|
|
event_at = COALESCE(event_at, updated_at)
|
|
WHERE rowid = NEW.rowid;
|
|
END;
|
|
CREATE TRIGGER IF NOT EXISTS watch_progress_stamp_upd AFTER UPDATE ON watch_progress
|
|
BEGIN
|
|
UPDATE watch_progress
|
|
SET synced_seq = (SELECT COALESCE(MAX(synced_seq), 0) + 1 FROM watch_progress),
|
|
event_at = CASE
|
|
WHEN NEW.event_at IS NULL THEN NEW.updated_at
|
|
WHEN NEW.event_at IS OLD.event_at AND NEW.updated_at IS NOT OLD.updated_at THEN NEW.updated_at
|
|
ELSE NEW.event_at
|
|
END
|
|
WHERE rowid = NEW.rowid;
|
|
END;
|
|
CREATE INDEX IF NOT EXISTS watch_progress_synced_idx ON watch_progress (profile_id, synced_seq);
|
|
`
|
|
|
|
// ensureWatchProgressSyncColumns adds the event_at (LWW key) and synced_seq
|
|
// (server cursor) columns, backfills them, and installs the stamping triggers.
|
|
// Idempotent: safe to run on every open for both fresh and existing databases.
|
|
func ensureWatchProgressSyncColumns(db *sql.DB) error {
|
|
columns := []struct{ name, definition string }{
|
|
{name: "event_at", definition: "TEXT"},
|
|
{name: "synced_seq", definition: "INTEGER"},
|
|
}
|
|
for _, column := range columns {
|
|
var count int
|
|
if err := db.QueryRow(
|
|
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
|
"watch_progress", column.name,
|
|
).Scan(&count); err != nil {
|
|
return fmt.Errorf("checking watch_progress.%s column: %w", column.name, err)
|
|
}
|
|
if count > 0 {
|
|
continue
|
|
}
|
|
if _, err := db.Exec(
|
|
fmt.Sprintf("ALTER TABLE watch_progress ADD COLUMN %s %s", column.name, column.definition),
|
|
); err != nil {
|
|
return fmt.Errorf("adding watch_progress.%s column: %w", column.name, err)
|
|
}
|
|
}
|
|
|
|
// Backfill before the triggers exist so these one-off writes don't trip them.
|
|
if _, err := db.Exec(`UPDATE watch_progress SET event_at = updated_at WHERE event_at IS NULL`); err != nil {
|
|
return fmt.Errorf("backfilling watch_progress.event_at: %w", err)
|
|
}
|
|
if _, err := db.Exec(`
|
|
UPDATE watch_progress SET synced_seq = (
|
|
SELECT COUNT(*) FROM watch_progress w2
|
|
WHERE w2.updated_at < watch_progress.updated_at
|
|
OR (w2.updated_at = watch_progress.updated_at AND w2.rowid <= watch_progress.rowid)
|
|
) WHERE synced_seq IS NULL`); err != nil {
|
|
return fmt.Errorf("backfilling watch_progress.synced_seq: %w", err)
|
|
}
|
|
|
|
if _, err := db.Exec(watchProgressSyncTriggers); err != nil {
|
|
return fmt.Errorf("installing watch_progress sync triggers: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureAutoSkipRecapPreviewColumns(db *sql.DB) error {
|
|
columns := []struct {
|
|
name string
|
|
definition string
|
|
}{
|
|
{name: "auto_skip_recap", definition: "BOOLEAN DEFAULT false"},
|
|
{name: "auto_play_next_preview", definition: "BOOLEAN DEFAULT false"},
|
|
}
|
|
|
|
for _, column := range columns {
|
|
var count int
|
|
if err := db.QueryRow(
|
|
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
|
"profiles",
|
|
column.name,
|
|
).Scan(&count); err != nil {
|
|
return fmt.Errorf("checking profiles.%s column: %w", column.name, err)
|
|
}
|
|
if count > 0 {
|
|
continue
|
|
}
|
|
if _, err := db.Exec(
|
|
fmt.Sprintf("ALTER TABLE profiles ADD COLUMN %s %s", column.name, column.definition),
|
|
); err != nil {
|
|
return fmt.Errorf("adding profiles.%s column: %w", column.name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureWatchHistoryIdentityColumn(db *sql.DB) error {
|
|
var count int
|
|
if err := db.QueryRow(
|
|
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
|
"watch_history",
|
|
"watch_identity",
|
|
).Scan(&count); err != nil {
|
|
return fmt.Errorf("checking watch_history.watch_identity column: %w", err)
|
|
}
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
if _, err := db.Exec("ALTER TABLE watch_history ADD COLUMN watch_identity TEXT NOT NULL DEFAULT '{}'"); err != nil {
|
|
return fmt.Errorf("adding watch_history.watch_identity: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureProfileIsPrimaryColumn(db *sql.DB) error {
|
|
var count int
|
|
if err := db.QueryRow(
|
|
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
|
"profiles",
|
|
"is_primary",
|
|
).Scan(&count); err != nil {
|
|
return fmt.Errorf("checking profiles.is_primary column: %w", err)
|
|
}
|
|
if count == 0 {
|
|
if _, err := db.Exec(
|
|
"ALTER TABLE profiles ADD COLUMN is_primary BOOLEAN NOT NULL DEFAULT false",
|
|
); err != nil {
|
|
return fmt.Errorf("adding profiles.is_primary column: %w", err)
|
|
}
|
|
}
|
|
// Backfill: the oldest existing profile (per-user sqlite file, so just the
|
|
// oldest row overall) is the primary. No-op if a primary already exists.
|
|
if _, err := db.Exec(`
|
|
UPDATE profiles
|
|
SET is_primary = 1
|
|
WHERE id = (
|
|
SELECT id FROM profiles
|
|
WHERE NOT EXISTS (SELECT 1 FROM profiles WHERE is_primary)
|
|
ORDER BY created_at ASC, id ASC
|
|
LIMIT 1
|
|
)
|
|
`); err != nil {
|
|
return fmt.Errorf("backfilling profiles.is_primary: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureDeviceSettingsProfileColumn(db *sql.DB) error {
|
|
var count int
|
|
if err := db.QueryRow(
|
|
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
|
"user_device_settings",
|
|
"profile_id",
|
|
).Scan(&count); err != nil {
|
|
return fmt.Errorf("checking user_device_settings.profile_id column: %w", err)
|
|
}
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("beginning user_device_settings profile migration: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
if _, err := tx.Exec(`
|
|
CREATE TABLE user_device_settings_new (
|
|
profile_id TEXT NOT NULL,
|
|
device_id TEXT NOT NULL,
|
|
key TEXT NOT NULL,
|
|
value TEXT NOT NULL,
|
|
device_name TEXT NOT NULL DEFAULT '',
|
|
device_platform TEXT NOT NULL DEFAULT '',
|
|
updated_at TEXT NOT NULL,
|
|
PRIMARY KEY (profile_id, device_id, key)
|
|
)
|
|
`); err != nil {
|
|
return fmt.Errorf("creating user_device_settings_new: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(`
|
|
INSERT INTO profiles (id, name, is_primary, created_at, updated_at)
|
|
SELECT 'default', 'Default', 1, datetime('now'), datetime('now')
|
|
WHERE NOT EXISTS (SELECT 1 FROM profiles)
|
|
`); err != nil {
|
|
return fmt.Errorf("ensuring default profile for user_device_settings migration: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(`
|
|
INSERT INTO user_device_settings_new (
|
|
profile_id, device_id, key, value, device_name, device_platform, updated_at
|
|
)
|
|
SELECT p.id, uds.device_id, uds.key, uds.value, uds.device_name, uds.device_platform, uds.updated_at
|
|
FROM user_device_settings uds
|
|
JOIN (
|
|
SELECT id
|
|
FROM profiles
|
|
ORDER BY is_primary DESC, created_at ASC, id ASC
|
|
LIMIT 1
|
|
) p ON 1 = 1
|
|
`); err != nil {
|
|
return fmt.Errorf("backfilling profile-aware user_device_settings: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(`DROP TABLE user_device_settings`); err != nil {
|
|
return fmt.Errorf("dropping legacy user_device_settings: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`ALTER TABLE user_device_settings_new RENAME TO user_device_settings`); err != nil {
|
|
return fmt.Errorf("renaming profile-aware user_device_settings: %w", err)
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
func backfillUserDevices(db *sql.DB) error {
|
|
_, err := db.Exec(`
|
|
INSERT INTO user_devices (
|
|
profile_id, device_id, device_name, device_platform, last_seen_at
|
|
)
|
|
SELECT
|
|
profile_id,
|
|
device_id,
|
|
COALESCE(MAX(device_name), ''),
|
|
COALESCE(MAX(device_platform), ''),
|
|
MAX(updated_at)
|
|
FROM user_device_settings
|
|
WHERE TRIM(device_id) <> ''
|
|
GROUP BY profile_id, device_id
|
|
ON CONFLICT(profile_id, device_id) DO UPDATE SET
|
|
device_name = CASE
|
|
WHEN excluded.device_name <> '' THEN excluded.device_name
|
|
ELSE user_devices.device_name
|
|
END,
|
|
device_platform = CASE
|
|
WHEN excluded.device_platform <> '' THEN excluded.device_platform
|
|
ELSE user_devices.device_platform
|
|
END,
|
|
last_seen_at = CASE
|
|
WHEN excluded.last_seen_at > user_devices.last_seen_at THEN excluded.last_seen_at
|
|
ELSE user_devices.last_seen_at
|
|
END
|
|
`)
|
|
if err != nil {
|
|
return fmt.Errorf("backfilling user_devices: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureProfileSectionOverridesRemovedColumn(db *sql.DB) error {
|
|
var count int
|
|
if err := db.QueryRow(
|
|
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
|
"profile_section_overrides",
|
|
"removed",
|
|
).Scan(&count); err != nil {
|
|
return fmt.Errorf("checking profile_section_overrides.removed column: %w", err)
|
|
}
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
if _, err := db.Exec("ALTER TABLE profile_section_overrides ADD COLUMN removed INTEGER NOT NULL DEFAULT 0"); err != nil {
|
|
return fmt.Errorf("adding profile_section_overrides.removed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ensureProfileSectionOverridesUserAddedColumns adds the four columns that back
|
|
// user-added (profile-built) sections so SaveSectionOverrides can round-trip
|
|
// IsUserAdded / UserSectionType / UserConfig / UserTitle. Without these columns
|
|
// those fields are silently dropped on save and lost on subsequent reads.
|
|
func ensureProfileSectionOverridesUserAddedColumns(db *sql.DB) error {
|
|
columns := []struct {
|
|
name string
|
|
definition string
|
|
}{
|
|
{name: "is_user_added", definition: "INTEGER NOT NULL DEFAULT 0"},
|
|
{name: "user_section_type", definition: "TEXT"},
|
|
{name: "user_config", definition: "TEXT"},
|
|
{name: "user_title", definition: "TEXT"},
|
|
}
|
|
for _, c := range columns {
|
|
var count int
|
|
if err := db.QueryRow(
|
|
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
|
"profile_section_overrides",
|
|
c.name,
|
|
).Scan(&count); err != nil {
|
|
return fmt.Errorf("checking profile_section_overrides.%s column: %w", c.name, err)
|
|
}
|
|
if count > 0 {
|
|
continue
|
|
}
|
|
if _, err := db.Exec(fmt.Sprintf(
|
|
"ALTER TABLE profile_section_overrides ADD COLUMN %s %s", c.name, c.definition,
|
|
)); err != nil {
|
|
return fmt.Errorf("adding profile_section_overrides.%s: %w", c.name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureWatchHistorySourceColumn(db *sql.DB) error {
|
|
var count int
|
|
if err := db.QueryRow(
|
|
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
|
"watch_history",
|
|
"source",
|
|
).Scan(&count); err != nil {
|
|
return fmt.Errorf("checking watch_history.source column: %w", err)
|
|
}
|
|
if count > 0 {
|
|
return nil
|
|
}
|
|
if _, err := db.Exec("ALTER TABLE watch_history ADD COLUMN source TEXT NOT NULL DEFAULT 'legacy'"); err != nil {
|
|
return fmt.Errorf("adding watch_history.source: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ensureShowForcedSubtitleColumns(db *sql.DB) error {
|
|
columns := []struct {
|
|
table string
|
|
column string
|
|
definition string
|
|
}{
|
|
{table: "profiles", column: "show_forced_subtitles", definition: "BOOLEAN NOT NULL DEFAULT true"},
|
|
{table: "library_playback_preferences", column: "show_forced_subtitles", definition: "BOOLEAN"},
|
|
{table: "subtitle_preferences", column: "show_forced_subtitles", definition: "BOOLEAN"},
|
|
}
|
|
|
|
for _, column := range columns {
|
|
var count int
|
|
if err := db.QueryRow(
|
|
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
|
column.table,
|
|
column.column,
|
|
).Scan(&count); err != nil {
|
|
return fmt.Errorf("checking %s.%s column: %w", column.table, column.column, err)
|
|
}
|
|
if count > 0 {
|
|
continue
|
|
}
|
|
if _, err := db.Exec(
|
|
fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", column.table, column.column, column.definition),
|
|
); err != nil {
|
|
return fmt.Errorf("adding %s.%s column: %w", column.table, column.column, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func migratePlaybackSettingsToDeviceScope(db *sql.DB) error {
|
|
version, err := userVersion(db)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if version == 0 || version >= 9 {
|
|
return nil
|
|
}
|
|
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
return fmt.Errorf("beginning playback settings device-scope migration: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
if _, err := tx.Exec(`
|
|
INSERT OR IGNORE INTO user_device_settings (
|
|
profile_id, device_id, key, value, device_name, device_platform, updated_at
|
|
)
|
|
SELECT
|
|
devices.profile_id,
|
|
devices.device_id,
|
|
settings.key,
|
|
settings.value,
|
|
devices.device_name,
|
|
devices.device_platform,
|
|
devices.updated_at
|
|
FROM user_settings AS settings
|
|
JOIN (
|
|
SELECT DISTINCT profile_id, device_id, device_name, device_platform, updated_at
|
|
FROM user_device_settings
|
|
) AS devices ON 1 = 1
|
|
WHERE settings.key IN (
|
|
'playback.preferred_quality',
|
|
'playback.audio_language',
|
|
'playback.auto_skip_intro',
|
|
'playback.auto_skip_credits',
|
|
'playback.auto_play_next'
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM user_device_settings AS existing
|
|
WHERE existing.profile_id = devices.profile_id
|
|
AND existing.device_id = devices.device_id
|
|
AND existing.key = settings.key
|
|
)
|
|
`); err != nil {
|
|
return fmt.Errorf("backfilling playback settings into user_device_settings: %w", err)
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|