A shared device can hold several accounts from the same server and switch between them in a couple of taps. A profile can be locked behind a 4-8 digit PIN; one without a PIN is one tap away. Forgetting a PIN falls through to the account's own Jellyfin password, so there is no reset flow and no recovery secret to store. Opt-in by construction: a single account with no PIN starts, plays and downloads exactly as before, and never sees a picker. Two decisions worth keeping: - Switching is not logging out. auth_logout invalidates the token server-side, which is precisely what a switch must not do, or every switch back would cost a password. The switch runs as a plan (profiles/switch.rs) so the teardown *ordering* is unit-testable with no player and no server -- a straggler reporting after the active user flips would attribute one account's viewing to another, silently. - The PIN gates switching, not the token at rest. Wrapping each token with its PIN would leave a locked profile unable to resume its own downloads or drain its own sync queue until somebody typed the code, which on a device that reboots nightly costs more than it defends against a four-digit secret. auth_initialize does refuse to restore a PIN-protected session, so the gate is on the session rather than on which screen is shown. "Child account" is not modelled anywhere -- a child's profile is simply one with no PIN. The frontend renders an opaque unlockMethod and never compares a PIN, counts an attempt or infers a role. Migration 024 adds user_pins, user_item_visibility, user_libraries and download_grants, and backfills the existing user so an upgrade does not blank its library. The visibility and grant tables are the schema half of the cache-scoping and shared-download work; the read-path enforcement is still to come (see docs/specs/multi-user-profiles.md).
1070 lines
40 KiB
Rust
1070 lines
40 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),
|
|
];
|
|
|
|
/// 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;
|
|
"#;
|
|
|
|
#[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);
|
|
}
|
|
}
|