Files
jellytau/src-tauri/src/storage/schema.rs
T
dtourolleandClaude Opus 5 9e2278080d feat(storage): remember which server generation wrote the cached catalog
The cache was version-blind: nothing recorded which Jellyfin generation produced
a row, so a server upgraded underneath the app kept serving rows parsed under the
previous generation's assumptions.

Migration 026 adds servers.catalog_generation and deliberately does NOT clear
synced_at the way migration 025 did. The column starts NULL, which reads as "no
generation recorded yet" rather than "changed", so the first connection after
upgrading simply records what it finds. Invalidation happens only when the
recorded generation actually changes.

That distinction is the point. Treating absent information as a change would
charge every existing user a full catalog re-fetch to defend against a server
upgrade that has not happened — and at the time of writing, 12.0 is hours old, so
essentially no installed server is on the newer generation at all.

Capabilities are also wired at repository creation: the version storage already
holds is read once, resolved, and handed to the online repository. A missing or
unparseable version is not an error — it resolves to the older generation, whose
request shapes work on both.

TRACES: UR-085 | IR-035, DR-280, DR-284

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:09:53 +02:00

1115 lines
42 KiB
Rust

//! Database schema and migrations
//!
//! TRACES: UR-002 | DR-012 | IR-013
/// List of migrations to apply in order.
/// Each migration is a tuple of (name, sql).
pub const MIGRATIONS: &[(&str, &str)] = &[
("001_initial_schema", MIGRATION_001),
("002_remove_access_token", MIGRATION_002),
("003_relax_user_data_constraints", MIGRATION_003),
("004_enhance_downloads", MIGRATION_004),
("005_relax_downloads_fk", MIGRATION_005),
("006_downloads_metadata", MIGRATION_006),
("007_cache_metadata", MIGRATION_007),
("008_video_downloads", MIGRATION_008),
("009_people_tables", MIGRATION_009),
("010_playback_context", MIGRATION_010),
("011_user_player_settings", MIGRATION_011),
("012_download_source", MIGRATION_012),
("013_downloads_item_status_index", MIGRATION_013),
("014_series_audio_preferences", MIGRATION_014),
("015_device_id", MIGRATION_015),
("016_autoplay_max_episodes", MIGRATION_016),
("017_downloads_resume_url", MIGRATION_017),
("018_items_is_folder", MIGRATION_018),
("019_genres_cache", MIGRATION_019),
("020_items_season_index", MIGRATION_020),
("021_rebuild_items_fts", MIGRATION_021),
("022_people_fts", MIGRATION_022),
("023_downloads_expiry", MIGRATION_023),
("024_multi_user_profiles", MIGRATION_024),
("025_backfill_item_library_id", MIGRATION_025),
("026_server_catalog_generation", MIGRATION_026),
];
/// Initial schema migration
const MIGRATION_001: &str = r#"
-- Jellyfin servers the user has connected to
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
version TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
last_connected_at TEXT
);
-- User accounts on Jellyfin servers
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
username TEXT NOT NULL,
access_token TEXT,
is_active INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
last_login_at TEXT,
UNIQUE(server_id, username)
);
-- Libraries/views from Jellyfin
CREATE TABLE IF NOT EXISTS libraries (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
collection_type TEXT,
image_tag TEXT,
sort_order INTEGER DEFAULT 0,
synced_at TEXT,
UNIQUE(server_id, id)
);
-- Media items (movies, shows, episodes, albums, songs, artists)
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
library_id TEXT REFERENCES libraries(id) ON DELETE SET NULL,
parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
-- Core metadata
name TEXT NOT NULL,
sort_name TEXT,
original_title TEXT,
item_type TEXT NOT NULL, -- Movie, Series, Episode, MusicAlbum, Audio, MusicArtist, etc.
-- Media info
overview TEXT,
tagline TEXT,
genres TEXT, -- JSON array
tags TEXT, -- JSON array
studios TEXT, -- JSON array
-- For episodes
series_id TEXT,
series_name TEXT,
season_id TEXT,
season_name TEXT,
index_number INTEGER, -- Episode number
parent_index_number INTEGER, -- Season number
-- For music
album_id TEXT,
album_name TEXT,
album_artist TEXT,
artists TEXT, -- JSON array
-- Dates
premiere_date TEXT,
production_year INTEGER,
date_created TEXT,
-- Runtime (ticks)
runtime_ticks INTEGER,
-- Images
primary_image_tag TEXT,
backdrop_image_tags TEXT, -- JSON array
-- Ratings
community_rating REAL,
official_rating TEXT,
-- Sync metadata
synced_at TEXT,
etag TEXT,
UNIQUE(server_id, id)
);
-- Full-text search index for items
CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
name,
overview,
album_name,
album_artist,
artists,
series_name,
content='items',
content_rowid='rowid'
);
-- Triggers to keep FTS index in sync
CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
END;
CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
END;
CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
END;
-- Media streams (audio/subtitle tracks)
CREATE TABLE IF NOT EXISTS media_streams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
stream_index INTEGER NOT NULL,
stream_type TEXT NOT NULL, -- Audio, Subtitle, Video
codec TEXT,
language TEXT,
display_title TEXT,
is_default INTEGER DEFAULT 0,
is_forced INTEGER DEFAULT 0,
is_external INTEGER DEFAULT 0,
path TEXT, -- For external subtitles
UNIQUE(item_id, stream_index)
);
-- User-specific data (watch progress, favorites)
CREATE TABLE IF NOT EXISTS user_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
-- Playback state
playback_position_ticks INTEGER DEFAULT 0,
play_count INTEGER DEFAULT 0,
is_played INTEGER DEFAULT 0,
is_favorite INTEGER DEFAULT 0,
-- Timestamps
last_played_at TEXT,
-- Sync status
synced_at TEXT,
pending_sync INTEGER DEFAULT 0, -- 1 if local changes need sync
UNIQUE(user_id, item_id)
);
-- Downloaded media files
CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- File info
file_path TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
-- Download state
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
progress REAL DEFAULT 0, -- 0.0 to 1.0
-- Transcoding options used
bitrate INTEGER,
container TEXT,
-- Timestamps
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
started_at TEXT,
completed_at TEXT,
-- Error tracking
error_message TEXT,
retry_count INTEGER DEFAULT 0,
UNIQUE(item_id, user_id)
);
-- Offline mutation queue (changes to sync back to server)
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Operation details
operation TEXT NOT NULL, -- mark_played, mark_favorite, update_progress, etc.
item_id TEXT,
payload TEXT, -- JSON data for the operation
-- Queue state
status TEXT DEFAULT 'pending', -- pending, processing, completed, failed
retry_count INTEGER DEFAULT 0,
-- Timestamps
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
processed_at TEXT,
-- Error tracking
error_message TEXT
);
-- Cached thumbnails
CREATE TABLE IF NOT EXISTS thumbnails (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
image_type TEXT NOT NULL, -- Primary, Backdrop, Thumb, Logo, etc.
image_tag TEXT NOT NULL,
file_path TEXT NOT NULL,
width INTEGER,
height INTEGER,
cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(item_id, image_type, image_tag)
);
-- User playlists (local + synced)
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
is_local INTEGER DEFAULT 0, -- 1 for local-only playlists
jellyfin_id TEXT, -- NULL for local-only
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
-- Playlist items
CREATE TABLE IF NOT EXISTS playlist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL,
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(playlist_id, item_id)
);
-- Indexes for common queries
CREATE INDEX IF NOT EXISTS idx_items_server ON items(server_id);
CREATE INDEX IF NOT EXISTS idx_items_library ON items(library_id);
CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
"#;
/// Migration to remove access_token column from users table
/// Tokens are now stored in the system keyring (or encrypted file fallback)
const MIGRATION_002: &str = r#"
-- Remove access_token column from users table
-- Tokens are now stored in secure storage (system keyring)
-- SQLite doesn't support DROP COLUMN in older versions, so we recreate the table
CREATE TABLE IF NOT EXISTS users_new (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
username TEXT NOT NULL,
is_active INTEGER DEFAULT 0,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
last_login_at TEXT,
UNIQUE(server_id, username)
);
-- Copy existing data (excluding access_token)
INSERT OR IGNORE INTO users_new (id, server_id, username, is_active, created_at, last_login_at)
SELECT id, server_id, username, is_active, created_at, last_login_at FROM users;
-- Drop old table and rename new one
DROP TABLE IF EXISTS users;
ALTER TABLE users_new RENAME TO users;
"#;
/// Migration to relax foreign key constraints on user_data table
/// Allows tracking playback progress for items not yet synced to local database
const MIGRATION_003: &str = r#"
-- Recreate user_data table without foreign key constraint on item_id
-- This allows tracking playback progress for items that haven't been synced locally yet
CREATE TABLE IF NOT EXISTS user_data_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
-- Playback state
playback_position_ticks INTEGER DEFAULT 0,
play_count INTEGER DEFAULT 0,
is_played INTEGER DEFAULT 0,
is_favorite INTEGER DEFAULT 0,
-- Timestamps
last_played_at TEXT,
-- Sync status
synced_at TEXT,
pending_sync INTEGER DEFAULT 0, -- 1 if local changes need sync
UNIQUE(user_id, item_id)
);
-- Copy existing data
INSERT OR IGNORE INTO user_data_new (id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync)
SELECT id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync FROM user_data;
-- Drop old table and rename new one
DROP TABLE IF EXISTS user_data;
ALTER TABLE user_data_new RENAME TO user_data;
-- Recreate index
CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
"#;
/// Migration to enhance downloads table with priority and bytes_downloaded
const MIGRATION_004: &str = r#"
-- Add priority column for queue ordering
ALTER TABLE downloads ADD COLUMN priority INTEGER DEFAULT 0;
-- Add bytes_downloaded for resume support
ALTER TABLE downloads ADD COLUMN bytes_downloaded INTEGER DEFAULT 0;
-- Create index for efficient queue processing (priority DESC, FIFO within same priority)
CREATE INDEX IF NOT EXISTS idx_downloads_queue
ON downloads(status, priority DESC, queued_at ASC)
WHERE status IN ('pending', 'downloading');
"#;
/// Migration to relax foreign key constraint on downloads.item_id
/// Allows downloading items that haven't been synced to local database yet
const MIGRATION_005: &str = r#"
-- Recreate downloads table without foreign key constraint on item_id
-- This allows downloading items that haven't been synced locally yet
CREATE TABLE IF NOT EXISTS downloads_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- File info
file_path TEXT NOT NULL,
file_size INTEGER,
mime_type TEXT,
-- Download state
status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
progress REAL DEFAULT 0, -- 0.0 to 1.0
-- Transcoding options used
bitrate INTEGER,
container TEXT,
-- Timestamps
queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
started_at TEXT,
completed_at TEXT,
-- Error tracking
error_message TEXT,
retry_count INTEGER DEFAULT 0,
-- Priority and progress tracking (from migration 004)
priority INTEGER DEFAULT 0,
bytes_downloaded INTEGER DEFAULT 0,
UNIQUE(item_id, user_id)
);
-- Copy existing data
INSERT OR IGNORE INTO downloads_new (
id, item_id, user_id, file_path, file_size, mime_type, status, progress,
bitrate, container, queued_at, started_at, completed_at, error_message,
retry_count, priority, bytes_downloaded
)
SELECT
id, item_id, user_id, file_path, file_size, mime_type, status, progress,
bitrate, container, queued_at, started_at, completed_at, error_message,
retry_count, priority, bytes_downloaded
FROM downloads;
-- Drop old table and rename new one
DROP TABLE IF EXISTS downloads;
ALTER TABLE downloads_new RENAME TO downloads;
-- Recreate indexes
CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
CREATE INDEX IF NOT EXISTS idx_downloads_queue
ON downloads(status, priority DESC, queued_at ASC)
WHERE status IN ('pending', 'downloading');
"#;
/// Migration to store item metadata directly in downloads table
/// This eliminates dependency on items table being synced and fixes UUID display issues
const MIGRATION_006: &str = r#"
-- Add columns to store item metadata directly in downloads
-- This ensures correct display even when items aren't synced locally
ALTER TABLE downloads ADD COLUMN item_name TEXT;
ALTER TABLE downloads ADD COLUMN artist_name TEXT;
ALTER TABLE downloads ADD COLUMN album_name TEXT;
"#;
/// Migration to enhance thumbnail caching with LRU eviction support
/// - Relaxes foreign key constraint on item_id (allows caching for items not yet synced)
/// - Adds last_accessed for LRU eviction
/// - Adds file_size for cache limit tracking
/// - Creates cache_settings table for configurable limits
const MIGRATION_007: &str = r#"
-- Recreate thumbnails table without foreign key constraint on item_id
-- and add LRU eviction support columns
CREATE TABLE IF NOT EXISTS thumbnails_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
image_type TEXT NOT NULL, -- Primary, Backdrop, Thumb, Logo, etc.
image_tag TEXT NOT NULL,
file_path TEXT NOT NULL,
width INTEGER,
height INTEGER,
file_size INTEGER DEFAULT 0, -- Size in bytes for cache limit tracking
cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
last_accessed TEXT DEFAULT CURRENT_TIMESTAMP, -- For LRU eviction
UNIQUE(item_id, image_type, image_tag)
);
-- Copy existing data (if any)
INSERT OR IGNORE INTO thumbnails_new (id, item_id, image_type, image_tag, file_path, width, height, cached_at)
SELECT id, item_id, image_type, image_tag, file_path, width, height, cached_at FROM thumbnails;
-- Drop old table and rename new one
DROP TABLE IF EXISTS thumbnails;
ALTER TABLE thumbnails_new RENAME TO thumbnails;
-- Create indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
CREATE INDEX IF NOT EXISTS idx_thumbnails_lru ON thumbnails(last_accessed ASC);
-- Cache settings table for configurable limits
CREATE TABLE IF NOT EXISTS cache_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- Insert default settings
INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_limit_bytes', '1073741824'); -- 1GB default
INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_enabled', 'true');
"#;
/// Migration to add video download support and item pinning
/// - Adds video-specific metadata columns to downloads (series/episode info, quality preset)
/// - Adds media_type to distinguish audio vs video downloads
/// - Adds is_pinned column to items table for protecting metadata from cache clear
const MIGRATION_008: &str = r#"
-- Add video-specific metadata columns to downloads
ALTER TABLE downloads ADD COLUMN series_name TEXT;
ALTER TABLE downloads ADD COLUMN season_name TEXT;
ALTER TABLE downloads ADD COLUMN episode_number INTEGER;
ALTER TABLE downloads ADD COLUMN season_number INTEGER;
ALTER TABLE downloads ADD COLUMN quality_preset TEXT DEFAULT 'original';
ALTER TABLE downloads ADD COLUMN media_type TEXT DEFAULT 'audio';
-- Add pinning support to items table
-- Pinned items are protected from cache clear operations
ALTER TABLE items ADD COLUMN is_pinned INTEGER DEFAULT 0;
-- Index for efficiently finding pinned items
CREATE INDEX IF NOT EXISTS idx_items_pinned ON items(is_pinned) WHERE is_pinned = 1;
-- Index for efficiently querying downloads by series
CREATE INDEX IF NOT EXISTS idx_downloads_series ON downloads(series_name) WHERE series_name IS NOT NULL;
-- Index for filtering by media type
CREATE INDEX IF NOT EXISTS idx_downloads_media_type ON downloads(media_type);
"#;
/// Migration to add people/cast caching support
/// - Creates people table for caching actor/director/writer/etc info
/// - Creates item_people junction table for many-to-many relationships
/// - Adds indexes for efficient queries
const MIGRATION_009: &str = r#"
-- People table for caching cast/crew members
CREATE TABLE IF NOT EXISTS people (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL,
name TEXT NOT NULL,
overview TEXT,
primary_image_tag TEXT,
premiere_date TEXT, -- Birth date
end_date TEXT, -- Death date
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(server_id, id)
);
-- Item-Person association table (many-to-many)
-- Stores which people appear in which items, along with role info
CREATE TABLE IF NOT EXISTS item_people (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id TEXT NOT NULL,
person_id TEXT NOT NULL,
server_id TEXT NOT NULL,
person_type TEXT NOT NULL, -- Actor, Director, Writer, Producer, Composer, etc.
role TEXT, -- Character name for actors
sort_order INTEGER DEFAULT 0,
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(item_id, person_id, person_type)
);
-- Indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_people_server ON people(server_id);
CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
CREATE INDEX IF NOT EXISTS idx_item_people_item ON item_people(item_id);
CREATE INDEX IF NOT EXISTS idx_item_people_person ON item_people(person_id);
CREATE INDEX IF NOT EXISTS idx_item_people_type ON item_people(person_type);
"#;
/// Migration to add playback context tracking
/// - Adds playback_context_type column to track if user played a container or single item
/// - Adds playback_context_id column to store the container ID (album/playlist)
/// - Adds index for efficient recently played queries
const MIGRATION_010: &str = r#"
-- Add playback context tracking to user_data
-- Tracks whether user played a container (album/playlist) or single item
ALTER TABLE user_data ADD COLUMN playback_context_type TEXT;
ALTER TABLE user_data ADD COLUMN playback_context_id TEXT;
-- Index for efficient recently played queries
CREATE INDEX IF NOT EXISTS idx_user_data_last_played
ON user_data(user_id, last_played_at DESC)
WHERE last_played_at IS NOT NULL;
"#;
/// Migration to add user-specific player settings
/// - Creates user_player_settings table for autoplay and audio preferences
/// - Note: Sleep timer state is NOT persisted (cancelled on app close)
/// - Autoplay settings control next episode behavior
/// - Audio settings for crossfade, gapless playback, and volume normalization
const MIGRATION_011: &str = r#"
-- User-specific player settings (autoplay and audio settings)
-- Sleep timer is NOT persisted here (maintained in-memory only)
CREATE TABLE IF NOT EXISTS user_player_settings (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
-- Autoplay settings
autoplay_next_episode INTEGER DEFAULT 1, -- 1 = enabled, 0 = disabled
autoplay_countdown_seconds INTEGER DEFAULT 10, -- 5-30 seconds
-- Audio settings (crossfade, normalization)
crossfade_duration REAL DEFAULT 0.0, -- 0-12 seconds
gapless_playback INTEGER DEFAULT 1,
normalize_volume INTEGER DEFAULT 0,
volume_level TEXT DEFAULT 'normal', -- 'loud', 'normal', 'quiet'
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- Index for efficient user settings lookup
CREATE INDEX IF NOT EXISTS idx_user_player_settings_user ON user_player_settings(user_id);
"#;
/// Migration to track download source (user-initiated vs auto-cached)
/// - Adds download_source column to distinguish manual downloads from auto-caching
/// - Enables color-coded UI display
const MIGRATION_012: &str = r#"
-- Add download source tracking
-- Values: 'user' (explicit download), 'auto' (smart cache/queue precache)
ALTER TABLE downloads ADD COLUMN download_source TEXT DEFAULT 'user';
-- Index for filtering by source
CREATE INDEX IF NOT EXISTS idx_downloads_source ON downloads(download_source);
"#;
/// Migration to add composite index for offline mode filtering
/// - Adds index on (item_id, status) for efficient JOIN queries in OfflineRepository
/// - Significantly improves performance when filtering items by download status
const MIGRATION_013: &str = r#"
-- Add composite index for offline mode filtering
-- This speeds up queries that join items with downloads to show only downloaded content
CREATE INDEX IF NOT EXISTS idx_downloads_item_status ON downloads(item_id, status);
"#;
/// Migration to add series audio track preferences
/// - Stores user's preferred audio track per series
/// - Matches tracks by display title and language across episodes
/// - Falls back to default track if preferred track not found
const MIGRATION_014: &str = r#"
-- Series-specific audio track preferences
-- When user changes audio track for an episode, remember preference for the series
CREATE TABLE IF NOT EXISTS series_audio_preferences (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
series_id TEXT NOT NULL,
server_id TEXT NOT NULL,
-- Audio track info for matching across episodes
audio_track_display_title TEXT,
audio_track_language TEXT,
audio_track_index INTEGER,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, series_id, server_id)
);
-- Index for efficient lookups
CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
ON series_audio_preferences(user_id, series_id);
"#;
/// Migration to add device ID storage
/// - Creates app_settings table for app-wide configuration (device ID, etc.)
/// - Device ID is generated once and persisted for Jellyfin server identification
const MIGRATION_015: &str = r#"
-- App-wide settings table for device ID and other app-level configuration
-- Device ID is a unique identifier for this app installation
-- Required for Jellyfin server communication and session tracking
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
-- Create index for efficient lookups (though key is already primary key)
CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(key);
"#;
/// Migration to add autoplay episode limit setting
/// - Adds autoplay_max_episodes column to user_player_settings
/// - 0 = unlimited (default), any positive value limits consecutive auto-plays
const MIGRATION_016: &str = r#"
ALTER TABLE user_player_settings
ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
"#;
/// Migration to persist the resolved stream URL and target directory on each
/// download row. This lets the backend queue pump start a pending download by
/// itself (replaying the stored URL) once a concurrency slot frees up, instead
/// of relying on the frontend to re-issue every queued item.
const MIGRATION_017: &str = r#"
-- Resolved download source URL and on-disk target directory, captured when the
-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a
-- URL simply won't be auto-started by the pump.
ALTER TABLE downloads ADD COLUMN stream_url TEXT;
ALTER TABLE downloads ADD COLUMN target_dir TEXT;
"#;
/// Migration to record whether a cached item is a folder/container vs a playable
/// leaf. Needed so channel items (which can be either) route to the player or to
/// a browse list correctly. Existing cached rows predate the column and have an
/// unknown folder flag, so we force a refresh by clearing their `synced_at`,
/// causing the hybrid repository to re-fetch them from the server on next browse.
const MIGRATION_018: &str = r#"
ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0;
-- Force re-fetch of all cached items so is_folder is populated from the server.
UPDATE items SET synced_at = NULL;
"#;
/// Migration to cache the full server genre catalog. Previously genres were
/// derived on the fly from cached albums, which meant offline (and the hybrid
/// cache-first race) only ever saw genres for the handful of locally-cached
/// albums — collapsing the variety on the music/TV/movie landing pages. This
/// table stores the complete genre list per library so offline has the real
/// catalog and cache-first routing returns the right thing.
const MIGRATION_019: &str = r#"
CREATE TABLE IF NOT EXISTS genres (
id TEXT NOT NULL,
server_id TEXT NOT NULL,
library_id TEXT,
name TEXT NOT NULL,
album_count INTEGER,
synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (server_id, library_id, name)
);
CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
"#;
/// Migration to index `items.season_id`.
///
/// Episodes link to their season via `season_id` (parent_id is NULL in the
/// cache). The container-rollup queries used by the Downloaded browse and the
/// disk-usage aggregation join `children.season_id = c.id`, which without this
/// index degrades to an unindexable scan — a large synced catalog then makes
/// the Downloaded page hang ("Loading your downloads…"). `parent_id`,
/// `album_id`, and `series_id` were already indexed; this closes the gap.
const MIGRATION_020: &str = r#"
CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
"#;
/// Discard and rebuild the FTS index from the `items` table.
///
/// Until DR-110, `save_to_cache` used `INSERT OR REPLACE INTO items`. REPLACE
/// deletes the conflicting row and inserts a new one, but SQLite only fires
/// `AFTER DELETE` triggers on that implicit delete when `recursive_triggers` is
/// enabled — it is not (storage/mod.rs sets only `foreign_keys` and
/// `journal_mode`), so `items_ad` never ran and the old index row was orphaned.
/// Worse, `items.id` is a `TEXT PRIMARY KEY`, so the replacement row also took a
/// *fresh rowid* and `items_ai` appended a second entry. Every catalog pass
/// therefore left another duplicate behind, and existing installs carry one
/// stale entry per item per sync since the database was created.
///
/// This was invisible in results — the `JOIN items_fts fts ON fts.rowid =
/// i.rowid` drops rowids that no longer exist — but it degrades `MATCH`
/// permanently, and it becomes a *correctness* problem the moment rowids are
/// freed and reused: a new item landing on a freed rowid inherits the orphan's
/// index entry and matches queries for the deleted item's title. The DR-110
/// deletion sweep frees rowids, so this rebuild must run before it.
///
/// `'rebuild'` is the FTS5 command for exactly this: it truncates the index and
/// repopulates it from the external content table.
///
/// TRACES: UR-065 | DR-110
const MIGRATION_021: &str = r#"
INSERT INTO items_fts(items_fts) VALUES('rebuild');
"#;
/// Full-text index over `people`, mirroring `items_fts`.
///
/// People live in their own table (migration 009) rather than in `items`, and
/// had no FTS index at all — so the People group UR-060 requires could only ever
/// be filled by the server leg of search. With the local index now answering
/// first, an actor's name has to be findable offline too.
///
/// TRACES: UR-065, UR-060 | DR-111
const MIGRATION_022: &str = r#"
CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
name,
overview,
content='people',
content_rowid='rowid'
);
CREATE TRIGGER IF NOT EXISTS people_ai AFTER INSERT ON people BEGIN
INSERT INTO people_fts(rowid, name, overview)
VALUES (new.rowid, new.name, new.overview);
END;
CREATE TRIGGER IF NOT EXISTS people_ad AFTER DELETE ON people BEGIN
INSERT INTO people_fts(people_fts, rowid, name, overview)
VALUES('delete', old.rowid, old.name, old.overview);
END;
CREATE TRIGGER IF NOT EXISTS people_au AFTER UPDATE ON people BEGIN
INSERT INTO people_fts(people_fts, rowid, name, overview)
VALUES('delete', old.rowid, old.name, old.overview);
INSERT INTO people_fts(rowid, name, overview)
VALUES (new.rowid, new.name, new.overview);
END;
-- Backfill for rows cached before this index existed.
INSERT INTO people_fts(people_fts) VALUES('rebuild');
"#;
/// Give temporary downloads a life limit.
///
/// A cache entry is not a different kind of object from a download — it is a
/// download with a shorter life. Modelling it as one `downloads` row with an
/// expiry (rather than a parallel cache store) means there is a single storage
/// accounting, a single eviction path, and no way for a cache and a download
/// library to disagree about what is on disk.
///
/// `expires_at` is NULL for permanent rows, which is every row that exists
/// today: `download_source` defaults to `'user'`, and a user's own download
/// never expires. Only `'auto'` rows get a timestamp, and they are reclaimed by
/// whichever comes first — the expiry passing, or LRU eviction under space
/// pressure (DR-126).
///
/// TRACES: UR-071 | DR-127
const MIGRATION_023: &str = r#"
ALTER TABLE downloads ADD COLUMN expires_at TEXT;
-- Reclaim scans filter on expiry among 'auto' rows; index both so the sweep
-- stays cheap as the cache tier grows.
CREATE INDEX IF NOT EXISTS idx_downloads_expiry
ON downloads(download_source, expires_at);
"#;
/// Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
///
/// Three tables, one purpose each:
///
/// - `user_pins` holds the switching gate. The PIN hash lives here rather than
/// wrapping the access token, because a wrapped token would leave a locked
/// profile unable to resume its own downloads or drain its own sync queue
/// until someone typed the code. See DR-268 for why that trade was taken.
/// - `user_item_visibility` records what the server has actually shown to each
/// user. It is written as a byproduct of the cache write path, never rebuilt,
/// so it cannot disagree with what the server returned.
/// - `download_grants` separates the bytes from the claim on them, so one file
/// can serve several profiles and is unlinked only when the last claim goes.
///
/// The backfill is not optional. Every existing cache row and download predates
/// the concept of a user; without it an upgrading install's library goes blank.
/// It grants the *active* user only — other pre-existing rows re-populate from
/// the server on next browse, which is strictly safer than handing every
/// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an
/// install whose single user somehow has `is_active = 0`.
///
/// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272
const MIGRATION_024: &str = r#"
CREATE TABLE IF NOT EXISTS user_pins (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
pin_hash TEXT NOT NULL,
failed_count INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_item_visibility (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_id TEXT NOT NULL,
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, item_id)
);
CREATE INDEX IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
CREATE TABLE IF NOT EXISTS user_libraries (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id TEXT NOT NULL,
seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, library_id)
);
CREATE TABLE IF NOT EXISTS download_grants (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, download_id)
);
CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
-- Backfill: the active user has seen everything already cached on this device.
INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
SELECT u.id, i.id
FROM users u CROSS JOIN items i
WHERE u.is_active = 1
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
INSERT OR IGNORE INTO user_libraries (user_id, library_id)
SELECT u.id, l.id
FROM users u CROSS JOIN libraries l
WHERE u.is_active = 1
OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
-- Downloads already record who asked for them, so every existing row becomes
-- exactly one grant held by its original requester.
INSERT OR IGNORE INTO download_grants (user_id, download_id)
SELECT d.user_id, d.id FROM downloads d;
"#;
/// Force cached items to be re-fetched so `library_id` is populated.
///
/// `save_to_cache` bound `library_id` NULL for every row it wrote, so nothing in
/// the cache knew which library it came from. The only available association was
/// the `collection_type` ↔ `item_type` taxonomy, which cannot tell two libraries
/// of the same type apart — a server with "TV" and "Shows" served both the same
/// contents — and says nothing at all about a library whose type it does not map
/// (Books, Photos, Collections, or a mixed library where Jellyfin sends no
/// collection type).
///
/// The write path now records the library. Existing rows cannot be repaired
/// locally — the association was never stored — so they are marked stale and
/// re-fetched on next browse, exactly as MIGRATION_018 did for `is_folder`.
///
/// Deliberately does not delete anything: downloads, favourites and playback
/// positions live in other tables and are untouched, and a cleared `synced_at`
/// only means "ask the server again", so an offline user keeps browsing what
/// they already had until the next successful fetch.
///
/// TRACES: UR-007 | DR-278
const MIGRATION_025: &str = r#"
UPDATE items SET synced_at = NULL;
"#;
/// Remember which server generation wrote the cached catalog.
///
/// The cache was version-blind: nothing recorded which Jellyfin generation
/// produced a row, so a server upgraded underneath the app kept serving rows
/// parsed under the previous generation's assumptions.
///
/// This deliberately does **not** clear `synced_at` the way MIGRATION_025 did.
/// The column starts NULL, which reads as "no generation recorded yet", and the
/// first connection after upgrading simply records what it finds. Invalidation
/// happens only when the recorded generation actually *changes* — punishing
/// every existing user with a full re-fetch for a server upgrade that has not
/// happened would cost real bandwidth to defend against nothing. At the time of
/// writing no installed server is on the newer generation at all.
///
/// TRACES: UR-085 | DR-284
const MIGRATION_026: &str = r#"
ALTER TABLE servers ADD COLUMN catalog_generation TEXT;
"#;
#[cfg(test)]
mod migration_024_tests {
use super::*;
use rusqlite::{params, Connection};
/// Build a database at the schema version *before* multi-user profiles, so
/// the backfill is exercised against rows that predate it — which is the
/// only state that matters, and the one an in-memory database created from
/// the full migration list can never reproduce.
fn pre_024_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
let upto = MIGRATIONS
.iter()
.position(|(name, _)| *name == "024_multi_user_profiles")
.expect("migration 024 must be registered");
for (_, sql) in &MIGRATIONS[..upto] {
conn.execute_batch(sql).unwrap();
}
conn
}
fn seed(conn: &Connection, active_user: &str) {
conn.execute(
"INSERT INTO servers (id, name, url) VALUES ('s1', 'Test', 'http://localhost:8096')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO users (id, server_id, username, is_active) VALUES (?1, 's1', 'dad', 1)",
params![active_user],
)
.unwrap();
conn.execute(
"INSERT INTO libraries (id, server_id, name) VALUES ('lib1', 's1', 'Movies')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO items (id, server_id, name, item_type) VALUES ('i1', 's1', 'A Movie', 'Movie')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO downloads (item_id, user_id, file_path, status)
VALUES ('i1', ?1, 'downloads/a.mp4', 'completed')",
params![active_user],
)
.unwrap();
}
fn apply_024(conn: &Connection) {
let (_, sql) = MIGRATIONS
.iter()
.find(|(name, _)| *name == "024_multi_user_profiles")
.unwrap();
conn.execute_batch(sql).unwrap();
}
fn count(conn: &Connection, sql: &str) -> i64 {
conn.query_row(sql, [], |r| r.get(0)).unwrap()
}
/// UT: upgrading an existing install does not blank its library. Without the
/// backfill every cached item becomes invisible to the only user there is.
#[test]
fn backfill_keeps_the_existing_library_visible() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad' AND item_id = 'i1'"
),
1,
"the active user must still see what was already cached"
);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_libraries WHERE user_id = 'dad' AND library_id = 'lib1'"
),
1
);
}
/// UT: an existing download becomes exactly one grant, held by whoever asked
/// for it — the file is not orphaned and is not handed to anyone else.
#[test]
fn backfill_grants_downloads_to_their_requester() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'dad'"
),
1
);
}
/// UT: a second profile added later starts with an empty view. The cache was
/// filled by someone else's browsing and the server never showed it to them,
/// so inheriting it is the leak this whole table exists to close.
#[test]
fn a_later_profile_inherits_nothing() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
conn.execute(
"INSERT INTO users (id, server_id, username, is_active) VALUES ('kid', 's1', 'kid', 0)",
[],
)
.unwrap();
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'kid'"
),
0,
"a profile added after the upgrade must not inherit another's cache"
);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM download_grants WHERE user_id = 'kid'"
),
0
);
}
/// UT: an install whose sole user somehow has `is_active = 0` still gets its
/// library back — the fallback arm of the backfill.
#[test]
fn backfill_covers_an_install_with_no_active_flag() {
let conn = pre_024_db();
seed(&conn, "dad");
conn.execute("UPDATE users SET is_active = 0", []).unwrap();
apply_024(&conn);
assert_eq!(
count(
&conn,
"SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad'"
),
1
);
}
/// UT: removing a profile takes its per-user rows with it, so a re-added
/// account starts clean rather than resuming someone's stale view.
#[test]
fn removing_a_profile_cascades_its_rows() {
let conn = pre_024_db();
seed(&conn, "dad");
apply_024(&conn);
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
conn.execute("DELETE FROM users WHERE id = 'dad'", [])
.unwrap();
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_item_visibility"), 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_libraries"), 0);
assert_eq!(count(&conn, "SELECT COUNT(*) FROM download_grants"), 0);
}
}