//! The catalog: what a crawl found, and where playback left off. //! //! SQLite, and — following `proton-drive-linux` — **forward-only**: never edit a //! shipped `MIGRATION_V*`, add a new one and bump [`SCHEMA_VERSION`]. A database //! newer than this build understands is a hard refuse-to-open rather than a //! downgrade, because silently reinterpreting a newer schema is how watch state //! gets corrupted. //! //! Unlike the daemon's cache, nothing here is irreplaceable: the catalog is //! rebuilt by crawling again. Watch positions are the exception, which is why //! they live in their own table keyed by `(share_id, link_id)` — stable across //! recrawls, and untouched when the node rows are replaced. use std::collections::HashMap; use rusqlite::{Connection, OptionalExtension, params}; use crate::error::Result; use crate::library::TitleKind; use crate::metadata::{ Enrichment, EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId, TitleDetails, TitleMetadata, }; use crate::naming::{self, ParsedName}; /// Bump when adding a migration. pub const SCHEMA_VERSION: i64 = 12; const MIGRATION_V1: &str = r#" CREATE TABLE nodes ( share_id TEXT NOT NULL, link_id TEXT NOT NULL, volume_id TEXT NOT NULL, parent_link_id TEXT, name TEXT NOT NULL, is_folder INTEGER NOT NULL, media_type TEXT, size INTEGER, active_revision_id TEXT, title TEXT, season INTEGER, episode INTEGER, year INTEGER, PRIMARY KEY (share_id, link_id) ); CREATE INDEX nodes_by_parent ON nodes (share_id, parent_link_id); CREATE INDEX nodes_by_title ON nodes (title, season, episode); CREATE TABLE watch_state ( share_id TEXT NOT NULL, link_id TEXT NOT NULL, position_secs REAL NOT NULL, duration_secs REAL, watched INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY (share_id, link_id) ); "#; /// Episode names, which the first schema had nowhere to put: a library that /// numbers its files (`S01E01-Mother and Children.mkv`) has the episode's own /// name in the filename, and a poster-wall UI wants it. const MIGRATION_V2: &str = r#" ALTER TABLE nodes ADD COLUMN episode_title TEXT; "#; /// What a metadata provider said about a title, including that it said nothing. /// /// Keyed by [`crate::library::title_key`] rather than by anything from a share: /// a title merges across shares, and the answer is about the *title*. That also /// means these rows survive a recrawl, like watch state and unlike node rows — /// re-matching a whole library because one share was refreshed would be a few /// hundred needless requests to a third party. /// /// `matched = 0` is a real, cached answer: the provider was asked and had /// nothing. `genres` is a newline-separated list, which is enough for something /// never queried by genre and avoids a table for it. const MIGRATION_V3: &str = r#" CREATE TABLE title_metadata ( title_key TEXT PRIMARY KEY, provider TEXT NOT NULL, matched INTEGER NOT NULL, fetched_at INTEGER NOT NULL, remote_id TEXT, name TEXT, original_name TEXT, overview TEXT, year INTEGER, kind TEXT, poster_url TEXT, backdrop_url TEXT, rating REAL, genres TEXT, episodes INTEGER, url TEXT ); "#; /// What a provider said about the individual episodes of a title. /// /// Separate from `title_metadata` rather than a JSON blob in it, because this /// is looked up per row while a season is drawn — once per episode, hundreds of /// times a scroll — and because a title's answer and its episode list are /// fetched on different requests and can each exist without the other. /// /// `season = -1` stands for the provider numbering episodes straight through /// rather than by season; SQLite has no nullable primary-key column that would /// compare equal, and a sentinel here is cheaper than a second index. const MIGRATION_V4: &str = r#" CREATE TABLE episode_metadata ( title_key TEXT NOT NULL, season INTEGER NOT NULL, number INTEGER NOT NULL, provider TEXT NOT NULL, fetched_at INTEGER NOT NULL, name TEXT, overview TEXT, still_url TEXT, air_date TEXT, PRIMARY KEY (title_key, season, number) ); "#; /// Which rows the viewer chose themselves. /// /// A column rather than a separate table of overrides, because it changes /// nothing about what the row *holds* — only about who put it there, and /// therefore whether a match run may replace it. See /// [`MetadataRecord::manual`]. const MIGRATION_V5: &str = r#" ALTER TABLE title_metadata ADD COLUMN manual INTEGER NOT NULL DEFAULT 0; "#; /// Content kept explicitly by the viewer. Unlike the block cache, these rows /// describe complete local files and therefore survive a disconnected launch. const MIGRATION_V6: &str = r#" CREATE TABLE offline_files ( share_id TEXT NOT NULL, link_id TEXT NOT NULL, revision_id TEXT NOT NULL, block_sizes TEXT NOT NULL, PRIMARY KEY (share_id, link_id) ); "#; /// Language choices are a property of a show, not of an episode's muxing. const MIGRATION_V7: &str = r#" CREATE TABLE title_track_prefs ( title_key TEXT PRIMARY KEY, audio_language TEXT, subtitle_language TEXT, subtitles INTEGER NOT NULL DEFAULT 1 ); "#; /// A language cannot tell two English subtitle tracks apart — "Signs & Songs" /// and "Full Subtitles" — so the track's title is remembered beside it. const MIGRATION_V8: &str = r#" ALTER TABLE title_track_prefs ADD COLUMN audio_title TEXT; ALTER TABLE title_track_prefs ADD COLUMN subtitle_title TEXT; "#; /// Episodes numbered three ways, and a record of which titles were enriched. /// /// A release numbers its files by TVDB season, by absolute number inside a /// season folder, or by AniList entry; an episode row now carries all three so /// `EpisodeGuide::get` can meet whichever the filename used. `enriched_at` is /// set once a matched title's episodes and art were asked for — empty answers /// included, which is what stops a film being re-asked on every run. /// /// The deletes are a repair, not a reset. Every AniList episode row came from /// `streamingEpisodes` — partial, numbered however a streaming site numbered /// it, and in places a later season captioned with season one's names — and /// every AniList backdrop was a 4.75:1 banner drawn into a 16:9 tile. Both are /// wrong rather than stale, so they go; the matches stay, hand-picked ones /// included, and the next match run enriches them again. See /// `docs/METADATA.md`. const MIGRATION_V9: &str = r#" ALTER TABLE episode_metadata ADD COLUMN absolute INTEGER; ALTER TABLE episode_metadata ADD COLUMN entry_season INTEGER; ALTER TABLE episode_metadata ADD COLUMN entry_number INTEGER; ALTER TABLE title_metadata ADD COLUMN enriched_at INTEGER; DELETE FROM episode_metadata WHERE provider = 'anilist'; UPDATE title_metadata SET backdrop_url = NULL WHERE provider = 'anilist'; "#; /// What the library is sorted, grouped and shelved by, and when each file /// first appeared. /// /// `details` is [`TitleDetails`] as JSON — see there for why one column. /// `NULL` means it was never fetched, which a match run reads as "fetch it" /// rather than as "there is none"; every existing match starts that way. /// `chain` is the provider ids enrichment walked, newline-separated; every /// title is marked un-enriched so the next run records it. /// /// `node_first_seen` is kept apart from `nodes` because a recrawl replaces /// every node row and "recently added" has to survive that. Files already in /// the catalog are recorded as seen at 0 — when they arrived is not known, and /// pretending it was today would put the whole library at the top. const MIGRATION_V10: &str = r#" ALTER TABLE title_metadata ADD COLUMN details TEXT; ALTER TABLE title_metadata ADD COLUMN chain TEXT; CREATE TABLE node_first_seen ( share_id TEXT NOT NULL, link_id TEXT NOT NULL, seen_at INTEGER NOT NULL, PRIMARY KEY (share_id, link_id) ); INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at) SELECT share_id, link_id, 0 FROM nodes WHERE is_folder = 0; UPDATE title_metadata SET enriched_at = NULL; "#; /// What the last crawl of each share came to. /// /// Per installation, like the rest of the catalog: when this device last /// looked at a share is nothing another device needs. `crawled_at` is the last /// crawl that finished, `attempted_at` the last that was tried, so a share that /// keeps failing is not retried on every launch. `added` is how many files the /// last finished crawl found that had never been seen before. const MIGRATION_V11: &str = r#" CREATE TABLE share_crawls ( share_id TEXT PRIMARY KEY, crawled_at INTEGER NOT NULL, attempted_at INTEGER NOT NULL, files INTEGER NOT NULL, added INTEGER NOT NULL, error TEXT ); "#; /// Subtitle files beside a video, which the crawl used to drop with the /// `.nfo` files. Rebuilt by every crawl, like `nodes`; a subtitle belongs to /// one video, the one it was paired with by name. const MIGRATION_V12: &str = r#" CREATE TABLE sidecars ( share_id TEXT NOT NULL, link_id TEXT NOT NULL, volume_id TEXT NOT NULL, video_link_id TEXT NOT NULL, name TEXT NOT NULL, language TEXT, title TEXT, PRIMARY KEY (share_id, link_id) ); CREATE INDEX sidecars_by_video ON sidecars (share_id, video_link_id); "#; /// How old a share's last crawl may be before an app looks again on its own. /// Long enough that a launch rarely costs a crawl; short enough that a new /// episode turns up the same day. pub const CRAWL_STALE_AFTER_SECS: i64 = 6 * 60 * 60; /// One share's row of `share_crawls`. See [`MIGRATION_V11`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CrawlRecord { /// Unix seconds; `0` for a share no crawl of has finished. pub crawled_at: i64, pub attempted_at: i64, /// Playable files the last finished crawl stored. pub files: u64, /// Of those, how many no earlier crawl had seen. pub added: u64, /// Why the last attempt failed, when it did. pub error: Option, } impl CrawlRecord { /// Whether the share is due another look: nothing has been tried within /// `age_secs` of `now`. pub fn is_stale(&self, now: i64, age_secs: i64) -> bool { now - self.crawled_at.max(self.attempted_at) >= age_secs } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct OfflineFile { pub revision_id: String, pub block_sizes: Vec, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct TitleTrackPrefs { pub audio_language: Option, pub subtitle_language: Option, pub subtitles: bool, /// The name the muxer gave the chosen audio track, when it had one. What /// picks between two tracks in the same language on the next episode. #[serde(default)] pub audio_title: Option, #[serde(default)] pub subtitle_title: Option, } impl Default for TitleTrackPrefs { fn default() -> Self { Self { audio_language: None, subtitle_language: None, subtitles: true, audio_title: None, subtitle_title: None, } } } /// One `title_track_prefs` row, its columns starting at `first`. fn title_track_prefs_row( row: &rusqlite::Row<'_>, first: usize, ) -> rusqlite::Result { Ok(TitleTrackPrefs { audio_language: row.get(first)?, subtitle_language: row.get(first + 1)?, subtitles: row.get::<_, i64>(first + 2)? != 0, audio_title: row.get(first + 3)?, subtitle_title: row.get(first + 4)?, }) } /// What `season = -1` means in `episode_metadata`: no season at all. const ABSOLUTE_SEASON: i64 = -1; /// How long after an episode airs its guide keeps being asked about daily /// while the provider has no name for it. /// /// A guide is otherwise trusted as long as a match, a month, and one fetched /// the week before an episode aired lists it unnamed — "Episode 2" with a /// date — until the month is up. A name usually follows the air date by a day /// or so; one that has not come in two weeks is not coming, and asking every /// day for the rest of time would not change that. const GUIDE_WAIT_DAYS: i64 = 14; /// One catalog row: a file or folder inside a share. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CatalogNode { pub share_id: String, pub link_id: String, pub volume_id: String, pub parent_link_id: Option, pub name: String, pub is_folder: bool, pub media_type: Option, /// Plaintext size as claimed by the uploader, when the share reports one. pub size: Option, /// The revision the row describes. This is the cache-validity key: it /// advances if and only if a new revision was sealed, which `(mtime, size)` /// only approximates. pub active_revision_id: Option, /// What [`naming::parse`] made of the file name. pub parsed: ParsedName, } /// A subtitle file paired with the video beside it. See [`build_sidecars`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Sidecar { pub share_id: String, pub link_id: String, pub volume_id: String, /// The video this subtitle is for. pub video_link_id: String, pub name: String, /// The [`crate::language::LANGUAGES`] tag its name states, if any. pub language: Option, /// What else its name says about it — "forced", "SDH" — for the picker. pub title: Option, } /// Where playback left off. #[derive(Debug, Clone, Copy, PartialEq)] pub struct WatchState { pub position_secs: f64, pub duration_secs: Option, pub watched: bool, pub updated_at: i64, } /// The catalog database. pub struct Catalog { conn: Connection, } impl Catalog { /// Open (or create) the catalog at `path`. pub fn open(path: &std::path::Path) -> Result { let conn = Connection::open(path)?; Self::from_connection(conn) } /// An in-memory catalog, for tests. pub fn in_memory() -> Result { Self::from_connection(Connection::open_in_memory()?) } fn from_connection(conn: Connection) -> Result { conn.pragma_update(None, "journal_mode", "WAL")?; conn.pragma_update(None, "synchronous", "NORMAL")?; conn.pragma_update(None, "foreign_keys", "ON")?; let mut catalog = Self { conn }; catalog.migrate()?; Ok(catalog) } /// How many rows this connection has written since it was opened. /// /// A cache key, not a statistic: anything derived from the catalog stays /// valid exactly as long as this does not move. SQLite counts it for us, so /// no write path has to remember to invalidate anything — which is the /// failure mode a hand-maintained counter has. pub fn writes(&self) -> u64 { self.conn.total_changes() } fn migrate(&mut self) -> Result<()> { let version: i64 = self .conn .query_row("PRAGMA user_version", [], |row| row.get(0))?; if version > SCHEMA_VERSION { // Refuse rather than guess. A newer build wrote this; reinterpreting // it under older rules would silently mangle watch state. return Err(crate::error::Error::Config(format!( "catalog schema is version {version}, newer than this build supports \ ({SCHEMA_VERSION}); upgrade proton-stream or delete the catalog" ))); } if version < 1 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V1)?; tx.pragma_update(None, "user_version", 1)?; tx.commit()?; } if version < 2 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V2)?; tx.pragma_update(None, "user_version", 2)?; tx.commit()?; } if version < 3 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V3)?; tx.pragma_update(None, "user_version", 3)?; tx.commit()?; } if version < 4 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V4)?; tx.pragma_update(None, "user_version", 4)?; tx.commit()?; } if version < 5 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V5)?; tx.pragma_update(None, "user_version", 5)?; tx.commit()?; } if version < 6 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V6)?; tx.pragma_update(None, "user_version", 6)?; tx.commit()?; } if version < 7 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V7)?; tx.pragma_update(None, "user_version", 7)?; tx.commit()?; } if version < 8 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V8)?; tx.pragma_update(None, "user_version", 8)?; tx.commit()?; } if version < 9 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V9)?; tx.pragma_update(None, "user_version", 9)?; tx.commit()?; } if version < 10 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V10)?; tx.pragma_update(None, "user_version", 10)?; tx.commit()?; } if version < 11 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V11)?; tx.pragma_update(None, "user_version", 11)?; tx.commit()?; } if version < 12 { let tx = self.conn.transaction()?; tx.execute_batch(MIGRATION_V12)?; tx.pragma_update(None, "user_version", 12)?; tx.commit()?; } Ok(()) } /// Replace everything known about one share with `nodes`. /// /// Done as one transaction so a failed crawl leaves the previous catalog /// intact rather than a half-replaced one. Watch state is keyed separately /// and deliberately untouched — a recrawl must not lose where you were. pub fn replace_share(&mut self, share_id: &str, nodes: &[CatalogNode]) -> Result<()> { self.replace_share_inner(share_id, nodes, false) } /// Replace nodes while retaining stale offline path keys for explicit /// filesystem cleanup by a front end. pub fn replace_share_retaining_offline( &mut self, share_id: &str, nodes: &[CatalogNode], ) -> Result<()> { self.replace_share_inner(share_id, nodes, true) } fn replace_share_inner( &mut self, share_id: &str, nodes: &[CatalogNode], retain_offline: bool, ) -> Result<()> { let tx = self.conn.transaction()?; tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; { let mut insert = tx.prepare( "INSERT INTO nodes ( share_id, link_id, volume_id, parent_link_id, name, is_folder, media_type, size, active_revision_id, title, season, episode, year, episode_title ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", )?; for node in nodes { insert.execute(params![ node.share_id, node.link_id, node.volume_id, node.parent_link_id, node.name, node.is_folder as i64, node.media_type, node.size, node.active_revision_id, node.parsed.title, node.parsed.season, node.parsed.episode, node.parsed.year, node.parsed.episode_title, ])?; } } // Only files: a folder appearing is not something anyone watches. let now = unix_now(); let added = tx.execute( "INSERT OR IGNORE INTO node_first_seen (share_id, link_id, seen_at) SELECT share_id, link_id, ?2 FROM nodes WHERE share_id = ?1 AND is_folder = 0", params![share_id, now], )?; let files = nodes.iter().filter(|node| !node.is_folder).count(); tx.execute( "INSERT INTO share_crawls (share_id, crawled_at, attempted_at, files, added, error) VALUES (?1, ?2, ?2, ?3, ?4, NULL) ON CONFLICT (share_id) DO UPDATE SET crawled_at = excluded.crawled_at, attempted_at = excluded.attempted_at, files = excluded.files, added = excluded.added, error = NULL", params![share_id, now, files as i64, added as i64], )?; if !retain_offline { tx.execute("DELETE FROM offline_files WHERE share_id = ?1 AND NOT EXISTS (SELECT 1 FROM nodes WHERE nodes.share_id = offline_files.share_id AND nodes.link_id = offline_files.link_id AND nodes.active_revision_id = offline_files.revision_id)", params![share_id])?; } tx.commit()?; Ok(()) } /// Replace one share's subtitle files with `sidecars`. /// /// Apart from [`Self::replace_share`] rather than inside it: a subtitle /// left over from the crawl before is one that fails to open, quietly, /// which is no reason to make every caller of that pass these along. pub fn replace_sidecars(&mut self, share_id: &str, sidecars: &[Sidecar]) -> Result<()> { let tx = self.conn.transaction()?; tx.execute( "DELETE FROM sidecars WHERE share_id = ?1", params![share_id], )?; { let mut insert = tx.prepare( "INSERT OR REPLACE INTO sidecars ( share_id, link_id, volume_id, video_link_id, name, language, title ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", )?; for sidecar in sidecars { insert.execute(params![ share_id, sidecar.link_id, sidecar.volume_id, sidecar.video_link_id, sidecar.name, sidecar.language, sidecar.title, ])?; } } tx.commit()?; Ok(()) } /// The subtitle files paired with one video, in name order. pub fn sidecars(&self, share_id: &str, video_link_id: &str) -> Result> { let mut select = self.conn.prepare_cached( "SELECT share_id, link_id, volume_id, video_link_id, name, language, title FROM sidecars WHERE share_id = ?1 AND video_link_id = ?2 ORDER BY name", )?; let rows = select.query_map(params![share_id, video_link_id], |row| { Ok(Sidecar { share_id: row.get(0)?, link_id: row.get(1)?, volume_id: row.get(2)?, video_link_id: row.get(3)?, name: row.get(4)?, language: row.get(5)?, title: row.get(6)?, }) })?; Ok(rows.collect::>()?) } /// Record that a crawl of `share_id` failed, keeping what the last /// finished one stored. pub fn record_crawl_failure(&self, share_id: &str, error: &str) -> Result<()> { self.conn.execute( "INSERT INTO share_crawls (share_id, crawled_at, attempted_at, files, added, error) VALUES (?1, 0, ?2, 0, 0, ?3) ON CONFLICT (share_id) DO UPDATE SET attempted_at = excluded.attempted_at, error = excluded.error", params![share_id, unix_now(), error], )?; Ok(()) } /// What the last crawl of each share came to, by share id. A share never /// crawled has no entry. pub fn crawls(&self) -> Result> { let mut statement = self.conn.prepare( "SELECT share_id, crawled_at, attempted_at, files, added, error FROM share_crawls", )?; let rows = statement.query_map([], |row| { Ok(( row.get::<_, String>(0)?, CrawlRecord { crawled_at: row.get(1)?, attempted_at: row.get(2)?, files: row.get::<_, i64>(3)?.max(0) as u64, added: row.get::<_, i64>(4)?.max(0) as u64, error: row.get(5)?, }, )) })?; Ok(rows.collect::>()?) } /// Every file in a share, folders excluded, ordered for display. pub fn files(&self, share_id: &str) -> Result> { let mut statement = self.conn.prepare( "SELECT share_id, link_id, volume_id, parent_link_id, name, is_folder, media_type, size, active_revision_id, title, season, episode, year, episode_title FROM nodes WHERE share_id = ?1 AND is_folder = 0 ORDER BY title, season, episode, name", )?; let rows = statement.query_map(params![share_id], row_to_node)?; Ok(rows.collect::, _>>()?) } /// Every file across every share, grouped by parsed title. pub fn all_files(&self) -> Result> { let mut statement = self.conn.prepare( "SELECT share_id, link_id, volume_id, parent_link_id, name, is_folder, media_type, size, active_revision_id, title, season, episode, year, episode_title FROM nodes WHERE is_folder = 0 ORDER BY title, season, episode, name", )?; let rows = statement.query_map([], row_to_node)?; Ok(rows.collect::, _>>()?) } /// When each file first appeared in a crawl, keyed `(share_id, link_id)`. /// `0` for a file that was already there before this was recorded. pub fn first_seen(&self) -> Result> { let mut statement = self .conn .prepare("SELECT share_id, link_id, seen_at FROM node_first_seen")?; let rows = statement.query_map([], |row| { Ok(((row.get(0)?, row.get(1)?), row.get::<_, i64>(2)?)) })?; Ok(rows.collect::>()?) } /// Every share the catalog holds files for. One no longer configured was /// forgotten without [`Catalog::remove_share`] — by an older `pstr remove` /// — and its rows still show up in the library beside whatever replaced it. pub fn share_ids(&self) -> Result> { let mut statement = self .conn .prepare("SELECT DISTINCT share_id FROM nodes ORDER BY share_id")?; let rows = statement.query_map([], |row| row.get(0))?; Ok(rows.collect::>()?) } /// Drop everything a share contributed except where it was watched. /// /// Watch state stays, as through a recrawl. It is history, and not only /// this device's: with an account signed in, the next sync would bring it /// straight back from the other devices' files, so deleting it here would /// only make it flicker. Nothing shows it — the library pairs positions /// with files, and the files are gone — until the share is added again, /// when it picks up where it left off. /// /// It is also copied, newer wins as in a merge, onto every other share that /// holds the same file. A public link swapped for the account's own folder /// reaches the very same links under a new share id, and without the copy /// the swap reads as a library nobody has watched. pub fn remove_share(&mut self, share_id: &str) -> Result<()> { let tx = self.conn.transaction()?; tx.execute( "INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at) SELECT other.share_id, w.link_id, w.position_secs, w.duration_secs, w.watched, w.updated_at FROM watch_state w JOIN nodes own ON own.share_id = w.share_id AND own.link_id = w.link_id JOIN nodes other ON other.volume_id = own.volume_id AND other.link_id = own.link_id AND other.share_id <> own.share_id WHERE w.share_id = ?1 ON CONFLICT (share_id, link_id) DO UPDATE SET position_secs = excluded.position_secs, duration_secs = excluded.duration_secs, watched = excluded.watched, updated_at = excluded.updated_at WHERE excluded.updated_at > watch_state.updated_at OR (excluded.updated_at = watch_state.updated_at AND (excluded.watched > watch_state.watched OR (excluded.watched = watch_state.watched AND excluded.position_secs > watch_state.position_secs)))", params![share_id], )?; tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; tx.execute( "DELETE FROM sidecars WHERE share_id = ?1", params![share_id], )?; tx.execute( "DELETE FROM offline_files WHERE share_id = ?1", params![share_id], )?; tx.execute( "DELETE FROM node_first_seen WHERE share_id = ?1", params![share_id], )?; tx.execute( "DELETE FROM share_crawls WHERE share_id = ?1", params![share_id], )?; tx.commit()?; Ok(()) } pub fn offline_file(&self, share_id: &str, link_id: &str) -> Result> { self.conn.query_row("SELECT revision_id, block_sizes FROM offline_files WHERE share_id=?1 AND link_id=?2", params![share_id, link_id], |row| { let sizes: String = row.get(1)?; Ok(OfflineFile { revision_id: row.get(0)?, block_sizes: serde_json::from_str(&sizes).map_err(|e| rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e)))? }) }).optional().map_err(Into::into) } pub fn set_offline_file( &self, share_id: &str, link_id: &str, file: &OfflineFile, ) -> Result<()> { let sizes = serde_json::to_string(&file.block_sizes) .map_err(|e| crate::error::Error::Config(e.to_string()))?; self.conn.execute("INSERT INTO offline_files (share_id,link_id,revision_id,block_sizes) VALUES (?1,?2,?3,?4) ON CONFLICT(share_id,link_id) DO UPDATE SET revision_id=excluded.revision_id,block_sizes=excluded.block_sizes", params![share_id,link_id,file.revision_id,sizes])?; Ok(()) } /// Record a completed download and force its WAL transaction to stable /// storage before the caller discards its recovery journal. pub fn set_offline_file_durable( &self, share_id: &str, link_id: &str, file: &OfflineFile, ) -> Result<()> { self.set_offline_file(share_id, link_id, file)?; self.conn .query_row("PRAGMA wal_checkpoint(FULL)", [], |_| Ok(()))?; Ok(()) } /// Every completed local revision, keyed like watch state. pub fn all_offline_files(&self) -> Result> { let mut statement = self .conn .prepare("SELECT share_id, link_id, revision_id, block_sizes FROM offline_files")?; let rows = statement.query_map([], |row| { let sizes: String = row.get(3)?; let block_sizes = serde_json::from_str(&sizes).map_err(|error| { rusqlite::Error::FromSqlConversionFailure( 3, rusqlite::types::Type::Text, Box::new(error), ) })?; Ok(( (row.get(0)?, row.get(1)?), OfflineFile { revision_id: row.get(2)?, block_sizes, }, )) })?; Ok(rows.collect::, _>>()?) } /// Completed local revisions belonging to one share. /// /// Callers that replace or remove a share need the paths before deleting /// the index rows. Keeping this query explicit prevents an SQL cascade /// from orphaning opaque media files on disk. pub fn offline_files_for_share(&self, share_id: &str) -> Result> { let mut statement = self.conn.prepare( "SELECT link_id, revision_id, block_sizes FROM offline_files WHERE share_id=?1", )?; let rows = statement.query_map(params![share_id], |row| { let sizes: String = row.get(2)?; let block_sizes = serde_json::from_str(&sizes).map_err(|error| { rusqlite::Error::FromSqlConversionFailure( 2, rusqlite::types::Type::Text, Box::new(error), ) })?; Ok(( row.get(0)?, OfflineFile { revision_id: row.get(1)?, block_sizes, }, )) })?; Ok(rows.collect::, _>>()?) } pub fn remove_offline_file(&self, share_id: &str, link_id: &str) -> Result<()> { self.conn.execute( "DELETE FROM offline_files WHERE share_id=?1 AND link_id=?2", params![share_id, link_id], )?; Ok(()) } pub fn title_track_prefs(&self, title_key: &str) -> Result> { self.conn .query_row( "SELECT audio_language, subtitle_language, subtitles, audio_title, subtitle_title \ FROM title_track_prefs WHERE title_key=?1", params![title_key], |row| title_track_prefs_row(row, 0), ) .optional() .map_err(Into::into) } pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> { self.conn.execute( "INSERT INTO title_track_prefs \ (title_key,audio_language,subtitle_language,subtitles,audio_title,subtitle_title) \ VALUES (?1,?2,?3,?4,?5,?6) ON CONFLICT(title_key) DO UPDATE SET \ audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,\ subtitles=excluded.subtitles,audio_title=excluded.audio_title,\ subtitle_title=excluded.subtitle_title", params![ title_key, prefs.audio_language, prefs.subtitle_language, prefs.subtitles as i64, prefs.audio_title, prefs.subtitle_title ], )?; Ok(()) } pub fn all_title_track_prefs(&self) -> Result> { let mut statement = self.conn.prepare( "SELECT title_key, audio_language, subtitle_language, subtitles, audio_title, \ subtitle_title FROM title_track_prefs", )?; let rows = statement.query_map([], |row| Ok((row.get(0)?, title_track_prefs_row(row, 1)?)))?; Ok(rows.collect::, _>>()?) } /// Every watch position there is, keyed by `(share_id, link_id)`. /// /// What [`crate::library::Library::build`] joins against. A grid that asked /// per tile would pay a round-trip per poster on every render. pub fn all_watch_states(&self) -> Result> { let mut statement = self.conn.prepare( "SELECT share_id, link_id, position_secs, duration_secs, watched, updated_at FROM watch_state", )?; let rows = statement.query_map([], |row| { Ok(( (row.get(0)?, row.get(1)?), WatchState { position_secs: row.get(2)?, duration_secs: row.get(3)?, watched: row.get::<_, i64>(4)? != 0, updated_at: row.get(5)?, }, )) })?; Ok(rows.collect::, _>>()?) } /// Record where playback is. pub fn set_watch_state(&self, share_id: &str, link_id: &str, state: &WatchState) -> Result<()> { // Cached: playback saves its position every few seconds, and this is // the one write that runs on a clock rather than on a click. let mut upsert = self.conn.prepare_cached( "INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT (share_id, link_id) DO UPDATE SET position_secs = excluded.position_secs, duration_secs = excluded.duration_secs, watched = excluded.watched, updated_at = excluded.updated_at", )?; upsert.execute(params![ share_id, link_id, state.position_secs, state.duration_secs, state.watched as i64, state.updated_at, ])?; Ok(()) } /// Take each of `states` that is newer than what is recorded, in one /// transaction, returning how many were taken. /// /// Newer is a later `updated_at`. A tie is broken by the state itself — /// watched over not, then the further position — rather than by who is /// asking, so two devices holding the same tie settle on the same answer /// instead of each keeping its own forever. See [`crate::sync`]. pub fn merge_watch_states(&mut self, states: &[(String, String, WatchState)]) -> Result { let tx = self.conn.transaction()?; let mut taken = 0; { let mut upsert = tx.prepare_cached( "INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT (share_id, link_id) DO UPDATE SET position_secs = excluded.position_secs, duration_secs = excluded.duration_secs, watched = excluded.watched, updated_at = excluded.updated_at WHERE excluded.updated_at > watch_state.updated_at OR (excluded.updated_at = watch_state.updated_at AND (excluded.watched > watch_state.watched OR (excluded.watched = watch_state.watched AND excluded.position_secs > watch_state.position_secs)))", )?; for (share_id, link_id, state) in states { taken += upsert.execute(params![ share_id, link_id, state.position_secs, state.duration_secs, state.watched as i64, state.updated_at, ])?; } } tx.commit()?; Ok(taken) } /// Where playback left off, if it ever started. pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result> { let state = self .conn .query_row( "SELECT position_secs, duration_secs, watched, updated_at FROM watch_state WHERE share_id = ?1 AND link_id = ?2", params![share_id, link_id], |row| { Ok(WatchState { position_secs: row.get(0)?, duration_secs: row.get(1)?, watched: row.get::<_, i64>(2)? != 0, updated_at: row.get(3)?, }) }, ) .optional()?; Ok(state) } // -------------------------------------------------------------- metadata /// Every stored provider answer, keyed by title key. /// /// Read whole, like watch state and for the same reason: the poster wall /// wants all of it at once, and asking per tile is a round-trip per tile. /// Rows naming a provider this build does not know are dropped rather than /// failing the read — the title simply looks unmatched and gets re-asked. pub fn all_metadata(&self) -> Result> { let mut statement = self.conn.prepare( "SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name, overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url, manual, details, chain FROM title_metadata", )?; let rows = statement.query_map([], row_to_metadata)?; let mut records = HashMap::new(); for row in rows { if let Some(record) = row? { records.insert(record.title_key.clone(), record); } } Ok(records) } /// One title's stored answer. pub fn metadata(&self, title_key: &str) -> Result> { let record = self .conn .query_row( "SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name, overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url, manual, details, chain FROM title_metadata WHERE title_key = ?1", params![title_key], row_to_metadata, ) .optional()?; Ok(record.flatten()) } /// Store what a provider said, replacing whatever was there. /// /// A miss is stored too — see [`MetadataRecord::metadata`]. Storing only /// matches would leave every unmatched title asking again on every render. /// /// Clears the title's enrichment record, so the answer is enriched again; /// see [`Catalog::set_enrichment`]. And when the answer is a different /// entry from the one stored — or none — the old entry's episode rows go /// at once: they are not about this one, and a front end that pins a match /// without enriching it must not show them in the meantime. pub fn set_metadata(&self, record: &MetadataRecord) -> Result<()> { let data = record.metadata.as_ref(); self.conn.execute( "DELETE FROM episode_metadata WHERE title_key = ?1 AND NOT EXISTS ( SELECT 1 FROM title_metadata WHERE title_key = ?1 AND provider = ?2 AND remote_id IS ?3 )", params![ record.title_key, record.provider.as_str(), data.map(|data| data.remote_id.as_str()), ], )?; let details = data .and_then(|data| data.details.as_ref()) .map(serde_json::to_string) .transpose() .map_err(|error| crate::error::Error::Config(error.to_string()))?; // `same` below is whether the row already describes this entry. When // it does, what enrichment added — the fanart, the chain, the record // that it was asked — still describes it and is kept: refreshing a // match's details must not cost the library its episode lists. self.conn.execute( &"INSERT INTO title_metadata ( title_key, provider, matched, fetched_at, remote_id, name, original_name, overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url, manual, details ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18) ON CONFLICT (title_key) DO UPDATE SET provider = excluded.provider, matched = excluded.matched, fetched_at = excluded.fetched_at, remote_id = excluded.remote_id, name = excluded.name, original_name = excluded.original_name, overview = excluded.overview, year = excluded.year, kind = excluded.kind, poster_url = excluded.poster_url, backdrop_url = CASE WHEN same THEN COALESCE(excluded.backdrop_url, title_metadata.backdrop_url) ELSE excluded.backdrop_url END, rating = excluded.rating, genres = excluded.genres, episodes = excluded.episodes, url = excluded.url, manual = excluded.manual, details = excluded.details, chain = CASE WHEN same THEN title_metadata.chain END, enriched_at = CASE WHEN same THEN title_metadata.enriched_at END" .replace( "same", "(title_metadata.provider = excluded.provider AND title_metadata.remote_id IS excluded.remote_id AND excluded.matched = 1)", ), params![ record.title_key, record.provider.as_str(), data.is_some() as i64, record.fetched_at, data.map(|data| data.remote_id.as_str()), data.map(|data| data.name.as_str()), data.and_then(|data| data.original_name.as_deref()), data.and_then(|data| data.overview.as_deref()), data.and_then(|data| data.year), data.map(|data| kind_name(data.kind)), data.and_then(|data| data.poster_url.as_deref()), data.and_then(|data| data.backdrop_url.as_deref()), data.and_then(|data| data.rating), data.map(|data| data.genres.join("\n")), data.and_then(|data| data.episodes), data.and_then(|data| data.url.as_deref()), record.manual as i64, details, ], )?; Ok(()) } /// Forget one title's answer entirely, episodes included. /// /// Different from storing a miss: a miss says "asked, nothing there" and is /// trusted for a while, where this leaves no row at all and the next match /// run treats the title as one it has never seen. That is what "match this /// one automatically again" has to mean after a hand-picked match, since a /// hand-picked one never expires. pub fn forget_metadata(&self, title_key: &str) -> Result<()> { self.conn.execute( "DELETE FROM title_metadata WHERE title_key = ?1", params![title_key], )?; self.conn.execute( "DELETE FROM episode_metadata WHERE title_key = ?1", params![title_key], )?; Ok(()) } /// Forget every stored answer. /// /// What "match again" and "turn enrichment off" both do. Deliberately not /// called on a recrawl: the answers are about titles, not about shares. pub fn clear_metadata(&self) -> Result<()> { self.conn.execute("DELETE FROM title_metadata", [])?; self.conn.execute("DELETE FROM episode_metadata", [])?; Ok(()) } /// Replace everything stored about one title's episodes, and nothing else. pub fn set_episode_metadata( &mut self, title_key: &str, provider: ProviderId, fetched_at: i64, episodes: &[EpisodeMetadata], ) -> Result<()> { let tx = self.conn.transaction()?; replace_episodes(&tx, title_key, provider, fetched_at, episodes)?; tx.commit()?; Ok(()) } /// Store what enriching a matched title found, and that it was asked. /// /// The episode list is replaced even when the new one is empty — a title /// re-pinned from a series to its film must not keep the series' episode /// names. The backdrop only changes when one was found. And the title is /// marked enriched either way, so a film with no episodes is not asked /// about again on every run; [`Catalog::set_metadata`] clears the mark when /// the match itself changes. pub fn set_enrichment( &mut self, title_key: &str, provider: ProviderId, fetched_at: i64, enrichment: &Enrichment, ) -> Result<()> { let tx = self.conn.transaction()?; replace_episodes(&tx, title_key, provider, fetched_at, &enrichment.episodes)?; tx.execute( "UPDATE title_metadata SET enriched_at = ?3, backdrop_url = COALESCE(?4, backdrop_url), chain = ?5 WHERE title_key = ?1 AND provider = ?2", params![ title_key, provider.as_str(), fetched_at, enrichment.backdrop_url.as_deref(), enrichment.chain.join("\n"), ], )?; tx.commit()?; Ok(()) } /// Every episode answer there is, keyed by title. /// /// Read whole, for the same reason watch state and title metadata are: a /// season drawn a row at a time must not be a SQLite round-trip per row. pub fn all_episode_metadata(&self) -> Result> { let mut statement = self.conn.prepare( "SELECT title_key, season, number, name, overview, still_url, air_date, absolute, entry_season, entry_number FROM episode_metadata", )?; let rows = statement.query_map([], |row| { let title_key: String = row.get(0)?; let season: i64 = row.get(1)?; Ok(( title_key, EpisodeMetadata { season: (season != ABSOLUTE_SEASON).then_some(season as u32), number: row.get::<_, i64>(2)? as u32, absolute: row.get(7)?, entry_season: row.get(8)?, entry_number: row.get(9)?, name: row.get(3)?, overview: row.get(4)?, still_url: row.get(5)?, air_date: row.get(6)?, }, )) })?; let mut by_title: HashMap> = HashMap::new(); for row in rows { let (title_key, episode) = row?; by_title.entry(title_key).or_default().push(episode); } Ok(by_title .into_iter() .map(|(title_key, episodes)| (title_key, EpisodeGuide::new(episodes))) .collect()) } /// When each matched title was last enriched, for the provider it is /// matched against. A title missing here has never been, its match has /// changed since, or its guide is waiting on the name of an episode that /// has aired since — see `GUIDE_WAIT_DAYS`. pub fn enrichment_ages(&self) -> Result> { self.enrichment_ages_at(unix_now()) } fn enrichment_ages_at(&self, now: i64) -> Result> { let mut statement = self.conn.prepare( "SELECT title_key, provider, enriched_at FROM title_metadata t WHERE enriched_at IS NOT NULL AND NOT (enriched_at < ?1 - 86400 AND EXISTS ( SELECT 1 FROM episode_metadata e WHERE e.title_key = t.title_key AND e.name IS NULL AND e.air_date <= date(?1, 'unixepoch') AND e.air_date >= date(t.enriched_at, 'unixepoch', '-' || ?2 || ' days')))", )?; let rows = statement.query_map(params![now, GUIDE_WAIT_DAYS], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, )) })?; let mut ages = HashMap::new(); for row in rows { let (title_key, provider, enriched_at) = row?; if let Some(provider) = ProviderId::parse(&provider) { ages.insert(title_key, (provider, enriched_at)); } } drop(statement); // A file that arrived after the guide was fetched, and that the guide // has no name for, is an episode the guide predates: the next one of a // running show. Asked again once — the refetch is newer than the file, // so a name the provider never gives does not make it ask forever. if !ages.is_empty() { let first_seen = self.first_seen()?; let guides = self.all_episode_metadata()?; for node in self.all_files()? { let Some(number) = node.parsed.episode else { continue; }; let key = crate::library::title_key(&node.parsed.title); let Some((_, enriched_at)) = ages.get(&key) else { continue; }; let seen = first_seen .get(&(node.share_id, node.link_id)) .copied() .unwrap_or(0); let named = guides .get(&key) .and_then(|guide| guide.get(node.parsed.season, number)) .is_some_and(|episode| episode.name.is_some()); if seen > *enriched_at && !named { ages.remove(&key); } } } Ok(ages) } } /// Replace every episode row of one title with `episodes`, inside `tx`. /// /// A replace rather than an upsert per row: a provider that has *dropped* an /// episode — a special that was recounted, a season renumbered — should leave /// nothing behind, and an episode list is small enough that the difference is a /// few hundred microseconds. fn replace_episodes( tx: &rusqlite::Transaction<'_>, title_key: &str, provider: ProviderId, fetched_at: i64, episodes: &[EpisodeMetadata], ) -> Result<()> { tx.execute( "DELETE FROM episode_metadata WHERE title_key = ?1", params![title_key], )?; let mut insert = tx.prepare( "INSERT OR REPLACE INTO episode_metadata ( title_key, season, number, provider, fetched_at, name, overview, still_url, air_date, absolute, entry_season, entry_number ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", )?; for episode in episodes { insert.execute(params![ title_key, episode.season.map_or(ABSOLUTE_SEASON, i64::from), episode.number, provider.as_str(), fetched_at, episode.name.as_deref(), episode.overview.as_deref(), episode.still_url.as_deref(), episode.air_date.as_deref(), episode.absolute, episode.entry_season, episode.entry_number, ])?; } Ok(()) } /// The `details` and `chain` columns, read back. /// /// JSON that will not parse is treated as never fetched rather than failing /// the read: the next match run fetches it again, where an error here would /// take the whole grid down over one row. fn details_column(details: Option, chain: Option) -> Option { let mut details: TitleDetails = serde_json::from_str(&details?).ok()?; details.chain = chain .unwrap_or_default() .lines() .filter(|id| !id.is_empty()) .map(str::to_string) .collect(); Some(details) } fn unix_now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |since| since.as_secs() as i64) } /// `None` for a row whose provider column this build does not recognise. fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result> { let title_key: String = row.get(0)?; let Some(provider) = ProviderId::parse(&row.get::<_, String>(1)?) else { return Ok(None); }; let matched: i64 = row.get(2)?; let fetched_at: i64 = row.get(3)?; let metadata = if matched == 0 { None } else { let genres: Option = row.get(13)?; Some(TitleMetadata { provider, remote_id: row.get::<_, Option>(4)?.unwrap_or_default(), name: row.get::<_, Option>(5)?.unwrap_or_default(), original_name: row.get(6)?, overview: row.get(7)?, year: row.get(8)?, kind: parse_kind(row.get::<_, Option>(9)?.as_deref()), poster_url: row.get(10)?, backdrop_url: row.get(11)?, rating: row.get(12)?, genres: genres .unwrap_or_default() .lines() .filter(|genre| !genre.is_empty()) .map(str::to_string) .collect(), episodes: row.get(14)?, url: row.get(15)?, details: details_column(row.get(17)?, row.get(18)?), }) }; Ok(Some(MetadataRecord { title_key, provider, metadata, fetched_at, manual: row.get::<_, i64>(16)? != 0, })) } fn kind_name(kind: TitleKind) -> &'static str { match kind { TitleKind::Series => "series", TitleKind::Film => "film", } } /// Anything but `series` reads as a film, including a value from a newer build. /// The kind only chooses a badge; guessing wrong is a cosmetic error, and a /// hard failure here would take the whole grid down with it. fn parse_kind(text: Option<&str>) -> TitleKind { match text { Some("series") => TitleKind::Series, _ => TitleKind::Film, } } fn row_to_node(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(CatalogNode { share_id: row.get(0)?, link_id: row.get(1)?, volume_id: row.get(2)?, parent_link_id: row.get(3)?, name: row.get(4)?, is_folder: row.get::<_, i64>(5)? != 0, media_type: row.get(6)?, size: row.get(7)?, active_revision_id: row.get(8)?, parsed: ParsedName { title: row.get(9)?, season: row.get(10)?, episode: row.get(11)?, year: row.get(12)?, episode_title: row.get(13)?, }, }) } /// Build a catalog row from a crawled [`Node`](proton_drive_rs::Node), from the /// filename alone. /// /// [`build_rows`] is what callers want: a filename in isolation usually does not /// say what series it belongs to. pub fn from_node(share_id: &str, node: &proton_drive_rs::Node) -> CatalogNode { use proton_drive_rs::NodeKind; let (is_folder, media_type, size, active_revision_id) = match &node.kind { NodeKind::Folder => (true, None, None, None), NodeKind::File { media_type, claimed_size, active_revision_id, .. } => ( false, Some(media_type.clone()), *claimed_size, active_revision_id.clone(), ), }; CatalogNode { share_id: share_id.to_string(), link_id: node.uid.link_id.to_string(), volume_id: node.uid.volume_id.to_string(), parent_link_id: node.parent_uid.as_ref().map(|uid| uid.link_id.to_string()), name: node.name.clone(), is_folder, media_type, size, active_revision_id, parsed: naming::parse(&node.name), } } /// Turn a whole crawl into catalog rows, reading each file's title from its /// **folder path** rather than from its name alone. /// /// This is the difference between a browsable catalog and an unusable one. A /// real library is laid out as /// /// ```text /// Sousou no Frieren/Season 01/S01E01.mkv /// Neon Genesis Evangelion/Movies/[Anime Time] … - Death & Rebirth-004.mkv /// Fullmetal Alchemist Brotherhood/OVA/[Reaktor] … OVA - E1 v2 [1080p].mkv /// ``` /// /// — where `S01E01.mkv` states no title at all, and the two below it state one /// that is really the *episode's* name. So: /// /// - **the folder names the series**, because that is how the user organised it; /// - **the filename names the numbering**, falling back to a `Season NN` folder; /// - grouping folders (`Movies`, `OVA`, `Specials`) are stepped over, so their /// contents group under the series above them; /// - whatever the filename claimed as a title, when it differs, is kept as the /// episode's own name — with the redundant series prefix stripped; /// - **a folder of films is one title per film**, not one title: see /// [`split_films`]. /// /// Folders and non-video files are dropped: a share holds `.nfo` and `.jpg` /// alongside the video, and a catalog that offers those as episodes is worse /// than one that omits an exotic container. pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec { use std::collections::HashMap; let by_link: HashMap<&str, &proton_drive_rs::Node> = nodes .iter() .map(|node| (node.uid.link_id.as_str(), node)) .collect(); let mut origins = Vec::new(); let mut rows: Vec = nodes .iter() .filter(|node| !node.is_folder() && naming::is_video_file(&node.name)) .map(|node| { let mut row = from_node(share_id, node); let context = ancestry(node, &by_link); origins.push(Origin { own: row.parsed.clone(), folder: context.title_folder.clone(), through_container: context.through_container, }); if let Some(title) = context.title { // The filename's own title, if it had one and it differs, is the // episode's name rather than the series'. if row.parsed.episode_title.is_none() && !row.parsed.title.is_empty() && !row.parsed.title.eq_ignore_ascii_case(&title) { let rest = strip_prefix_title(&row.parsed.title, &title); // …unless what is left is a season marker. `Oshi no Ko 3rd // Season - 07.mkv` under a folder called `Oshi no Ko` leaves // `3rd Season`, and calling every episode of season three // that is worse than calling none of them anything. if naming::is_season_marker(&rest) { row.parsed.season = row.parsed.season.or(naming::parse(&rest).season); } else if !rest.eq_ignore_ascii_case(&title) { // Nor when the strip found nothing to remove: a filename // whose title only *differs* from the folder's — spacing, // punctuation, a stray article — names the series, not the // episode, and taking it hands every row the same text. row.parsed.episode_title = Some(rest); } } row.parsed.title = title; } if row.parsed.season.is_none() { row.parsed.season = context.season; } if row.parsed.year.is_none() { row.parsed.year = context.year; } row }) .collect(); split_films(&mut rows, &origins); rows } /// Where a row's title came from, kept for [`split_films`]. struct Origin { /// What the filename alone parsed as, before the folder overrode it. own: ParsedName, /// The link id of the folder that named the row. folder: Option, through_container: bool, } /// Give each film in a folder of films its own title. /// /// `Rebuild of Evangelion/` holding `Evangelion - 1.11 You Are (Not) /// Alone.mkv` and three more is four films, each its own entry at every /// provider. Titled by the folder they are one series of four unnumbered /// files, matched as whichever single film the folder's name reads most like — /// and no single film is right. /// /// Only when every file in the folder says so, because the same shape with one /// file out of place is a series: /// /// - none is numbered, by its name or by a season folder; /// - none is under a grouping folder, because `Neon Genesis Evangelion/Movies/` /// is the series' films, and they belong on its page; /// - each names itself, differently from the others, and not by the folder's /// name — `Mushishi - The Green Seat.mkv` under `Mushishi/` is an episode /// of that show whose number is missing, not a film. fn split_films(rows: &mut [CatalogNode], origins: &[Origin]) { use crate::library::title_key; use std::collections::HashSet; let mut by_folder: HashMap<&str, Vec> = HashMap::new(); for (index, origin) in origins.iter().enumerate() { if let Some(folder) = &origin.folder { by_folder.entry(folder.as_str()).or_default().push(index); } } for members in by_folder.into_values() { if members.len() < 2 { continue; } let mut keys = HashSet::new(); let films = members.iter().all(|&index| { let (row, origin) = (&rows[index], &origins[index]); let own = &origin.own.title; !origin.through_container && !origin.own.is_episode() && origin.own.season.is_none() && row.parsed.season.is_none() && !own.is_empty() && strip_prefix_title(own, &row.parsed.title) == *own && !own.eq_ignore_ascii_case(&row.parsed.title) && keys.insert(title_key(own)) }); if !films { continue; } for index in members { let row = &mut rows[index]; let own = &origins[index].own; row.parsed.title = own.title.clone(); row.parsed.episode_title = own.episode_title.clone(); } } } /// Pair each subtitle file in a crawl with the video it is for. /// /// By name, the way every player looks for one, in the three layouts shares /// actually have: /// /// ```text /// Show/Season 01/S01E01.mkv + S01E01.srt, S01E01.en.srt, S01E01.en.forced.ass /// Show/Season 01/Subs/S01E01.en.srt a subtitles folder beside the videos /// Film (2020)/Subs/Film (2020)/2_English.srt a folder per video inside that /// ``` /// /// A subtitle whose name starts with no video's is still paired when its /// folder holds one video only — `Film/film.mkv` beside `English.srt`. One /// that matches nothing is left out: offering it under the wrong episode is /// worse than not offering it. pub fn build_sidecars(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec { use std::collections::HashMap; let by_link: HashMap<&str, &proton_drive_rs::Node> = nodes .iter() .map(|node| (node.uid.link_id.as_str(), node)) .collect(); let parent_of = |node: &proton_drive_rs::Node| { node.parent_uid .as_ref() .and_then(|uid| by_link.get(uid.link_id.as_str()).copied()) }; let mut videos: HashMap<&str, Vec<(&proton_drive_rs::Node, &str)>> = HashMap::new(); for node in nodes { if !node.is_folder() && naming::is_video_file(&node.name) && let Some(parent) = &node.parent_uid { videos .entry(parent.link_id.as_str()) .or_default() .push((node, stem(&node.name))); } } let mut sidecars: Vec = nodes .iter() .filter(|node| !node.is_folder() && naming::is_subtitle_file(&node.name)) .filter_map(|node| { let own = stem(&node.name); let parent = parent_of(node)?; let (video, rest) = if is_subtitles_folder(&parent.name) { // `Subs/S01E01.en.srt`, for the videos beside `Subs`. beside( videos.get(parent.parent_uid.as_ref()?.link_id.as_str())?, own, )? } else if let Some(holder) = parent_of(parent) && is_subtitles_folder(&holder.name) { // `Subs/