Files
proton-stream/crates/pstr-core/src/catalog.rs
T
nvrl 0b2813cd42 feat(core,stream,meta,app,android,cli): sign in to Proton and sync watch history
A Proton account is now optional alongside public links. Signing in (password,
then a second-factor code and a mailbox password where the account has them;
a CAPTCHA gate is solved in the Android app) stores one session blob in the OS
credential store under `proton-account`, writing back every refreshed token
since refresh tokens are single-use. With an account, folders of the viewer's
own Drive — My Files, other devices, shared with me — can be added as shares
through the same `ShareClient` surface as a public link.

Watch history syncs through a `proton-stream` device in that Drive: one
`watch-history/<installation>.json` per installation, so no two devices ever
write the same file. Each sync reads the others and merges per episode, newer
`updated_at` wins, with a deterministic tie-break. It runs at launch, after
sign-in, whenever the player closes and every five minutes. A session that can
no longer refresh signs the app out instead of failing on every launch.

The CLI gains `login`, `logout`, `account`, `browse` and `sync`.

Also in this change:
- Android offline downloads no longer look stuck (B64): two transfer at once,
  through a copy stream with no read-ahead, with a smoothed transfer rate.
- Choosing a metadata match by hand now enriches the title too, instead of
  keeping the old match's episodes and artwork.

The catalog schema is unchanged.
2026-10-06 13:06:54 +02:00

2595 lines
97 KiB
Rust

//! 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 = 10;
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;
"#;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfflineFile {
pub revision_id: String,
pub block_sizes: Vec<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct TitleTrackPrefs {
pub audio_language: Option<String>,
pub subtitle_language: Option<String>,
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<String>,
#[serde(default)]
pub subtitle_title: Option<String>,
}
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<TitleTrackPrefs> {
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;
/// 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<String>,
pub name: String,
pub is_folder: bool,
pub media_type: Option<String>,
/// Plaintext size as claimed by the uploader, when the share reports one.
pub size: Option<i64>,
/// 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<String>,
/// What [`naming::parse`] made of the file name.
pub parsed: ParsedName,
}
/// Where playback left off.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WatchState {
pub position_secs: f64,
pub duration_secs: Option<f64>,
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<Self> {
let conn = Connection::open(path)?;
Self::from_connection(conn)
}
/// An in-memory catalog, for tests.
pub fn in_memory() -> Result<Self> {
Self::from_connection(Connection::open_in_memory()?)
}
fn from_connection(conn: Connection) -> Result<Self> {
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()?;
}
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.
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, unix_now()],
)?;
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(())
}
/// Every file in a share, folders excluded, ordered for display.
pub fn files(&self, share_id: &str) -> Result<Vec<CatalogNode>> {
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::<std::result::Result<Vec<_>, _>>()?)
}
/// Every file across every share, grouped by parsed title.
pub fn all_files(&self) -> Result<Vec<CatalogNode>> {
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::<std::result::Result<Vec<_>, _>>()?)
}
/// 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<HashMap<(String, String), i64>> {
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::<std::result::Result<_, _>>()?)
}
/// Drop everything a share contributed, watch positions included.
///
/// Unlike a recrawl — which replaces rows and deliberately leaves watch
/// state alone — this is the viewer saying they are done with the share.
/// Keeping positions for files they can no longer reach would only leave
/// the "continue watching" row pointing at nothing.
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
let tx = self.conn.transaction()?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
tx.execute(
"DELETE FROM watch_state 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.commit()?;
Ok(())
}
pub fn offline_file(&self, share_id: &str, link_id: &str) -> Result<Option<OfflineFile>> {
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<HashMap<(String, String), OfflineFile>> {
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::<std::result::Result<HashMap<_, _>, _>>()?)
}
/// 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<HashMap<String, OfflineFile>> {
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::<std::result::Result<HashMap<_, _>, _>>()?)
}
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<Option<TitleTrackPrefs>> {
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<HashMap<String, TitleTrackPrefs>> {
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::<std::result::Result<HashMap<_, _>, _>>()?)
}
/// 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<HashMap<(String, String), WatchState>> {
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::<std::result::Result<HashMap<_, _>, _>>()?)
}
/// 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<usize> {
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<Option<WatchState>> {
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<HashMap<String, MetadataRecord>> {
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<Option<MetadataRecord>> {
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<HashMap<String, EpisodeGuide>> {
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<String, Vec<EpisodeMetadata>> = 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, or its match has
/// changed since.
pub fn enrichment_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> {
let mut statement = self.conn.prepare(
"SELECT title_key, provider, enriched_at FROM title_metadata
WHERE enriched_at IS NOT NULL",
)?;
let rows = statement.query_map([], |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));
}
}
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<String>, chain: Option<String>) -> Option<TitleDetails> {
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<Option<MetadataRecord>> {
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<String> = row.get(13)?;
Some(TitleMetadata {
provider,
remote_id: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
name: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
original_name: row.get(6)?,
overview: row.get(7)?,
year: row.get(8)?,
kind: parse_kind(row.get::<_, Option<String>>(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<CatalogNode> {
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.
///
/// 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<CatalogNode> {
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();
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);
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()
}
/// What a file's ancestors say about it.
#[derive(Debug, Default)]
struct Ancestry {
title: Option<String>,
season: Option<u32>,
year: Option<u32>,
}
/// Walk up from `node`, taking the nearest season and the nearest naming folder.
///
/// The share root is skipped: it is the folder that was shared (`anime`), and
/// using it as a title would label every top-level file identically.
fn ancestry(
node: &proton_drive_rs::Node,
by_link: &std::collections::HashMap<&str, &proton_drive_rs::Node>,
) -> Ancestry {
use crate::naming::FolderRole;
let mut found = Ancestry::default();
let mut cursor = node.parent_uid.as_ref().map(|uid| uid.link_id.to_string());
while let Some(link_id) = cursor {
let Some(ancestor) = by_link.get(link_id.as_str()) else {
// Off the top of the share: the parent is outside what we can see,
// so this was the root.
break;
};
// The root itself names the share, not a series.
if ancestor.parent_uid.is_none()
|| !by_link.contains_key(
ancestor
.parent_uid
.as_ref()
.map(|uid| uid.link_id.as_str())
.unwrap_or_default(),
)
{
break;
}
match crate::naming::classify_folder(&ancestor.name) {
FolderRole::Season(season) => found.season = found.season.or(Some(season)),
FolderRole::Container => {}
FolderRole::Title(parsed) => {
if found.title.is_none() && !parsed.title.is_empty() {
found.title = Some(parsed.title.clone());
found.year = found.year.or(parsed.year);
// `Game of Thrones - Season 1` names both; the season it
// states belongs to the files under it.
found.season = found.season.or(parsed.season);
}
}
}
cursor = ancestor
.parent_uid
.as_ref()
.map(|uid| uid.link_id.to_string());
}
found
}
/// Drop a redundant series prefix from an episode name.
///
/// `Neon Genesis Evangelion - Death & Rebirth` under a folder called
/// `Neon Genesis Evangelion` should read as `Death & Rebirth`.
///
/// Case-insensitively, because the two names come from different places: the
/// folder is typed by whoever organised the share and the prefix is whatever a
/// release group capitalised. `[Anime Time] Attack On Titan - 38.mkv` under a
/// folder called `Attack on Titan` differs in exactly one letter, and a
/// case-sensitive strip leaves the whole series name behind as the episode's.
fn strip_prefix_title(episode: &str, series: &str) -> String {
let stripped = episode
.get(..series.len())
.filter(|head| head.eq_ignore_ascii_case(series))
.map(|_| episode[series.len()..].trim_start_matches([' ', '-', '_', '.', ':']))
.unwrap_or(episode)
.trim();
if stripped.is_empty() {
episode.to_string()
} else {
stripped.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn file(share: &str, link: &str, name: &str) -> CatalogNode {
CatalogNode {
share_id: share.into(),
link_id: link.into(),
volume_id: "vol".into(),
parent_link_id: Some("root".into()),
name: name.into(),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1 << 30),
active_revision_id: Some("rev-1".into()),
parsed: naming::parse(name),
}
}
fn found() -> TitleMetadata {
TitleMetadata {
provider: ProviderId::AniList,
remote_id: "16498".into(),
name: "Attack on Titan".into(),
original_name: Some("進撃の巨人".into()),
overview: Some("Several hundred years ago…".into()),
year: Some(2013),
kind: TitleKind::Series,
poster_url: Some("https://example.invalid/p.jpg".into()),
backdrop_url: None,
rating: Some(8.4),
genres: vec!["Action".into(), "Drama".into()],
episodes: Some(25),
url: Some("https://anilist.co/anime/16498".into()),
details: None,
}
}
#[test]
fn metadata_round_trips_through_the_catalog() {
let catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "attack on titan".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1_700_000_000,
manual: false,
};
catalog.set_metadata(&record).expect("store");
assert_eq!(
catalog.metadata("attack on titan").expect("read"),
Some(record.clone())
);
assert_eq!(catalog.all_metadata().expect("read all").len(), 1);
// Second write is an update, not a duplicate row.
catalog.set_metadata(&record).expect("store again");
assert_eq!(catalog.all_metadata().expect("read all").len(), 1);
}
/// A hand-picked match has to read back as hand-picked, or the next match
/// run overwrites the one thing the viewer corrected themselves.
#[test]
fn a_hand_picked_match_says_so_when_it_is_read_back() {
let catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "fate stay night heavens feel".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1_700_000_000,
manual: true,
};
catalog.set_metadata(&record).expect("store");
let read = catalog.metadata(&record.title_key).expect("read");
assert_eq!(read.as_ref().map(|read| read.manual), Some(true));
assert_eq!(read, Some(record.clone()));
// And an automatic write over the top says so in turn: the flag is the
// row's, not the title's.
catalog
.set_metadata(&MetadataRecord {
manual: false,
..record.clone()
})
.expect("store again");
assert_eq!(
catalog
.metadata(&record.title_key)
.expect("read")
.map(|read| read.manual),
Some(false)
);
}
/// Forgetting is not the same as storing a miss: it must leave no row at
/// all, so the next match run treats the title as one it never asked about.
#[test]
fn forgetting_a_title_leaves_neither_its_match_nor_its_episodes() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.set_metadata(&MetadataRecord {
title_key: "attack on titan".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1,
manual: true,
})
.expect("store");
catalog
.set_episode_metadata(
"attack on titan",
ProviderId::AniList,
1,
&[EpisodeMetadata {
season: None,
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("To You, in 2000 Years".into()),
overview: None,
still_url: None,
air_date: None,
}],
)
.expect("store episodes");
catalog.forget_metadata("attack on titan").expect("forget");
assert!(catalog.metadata("attack on titan").expect("read").is_none());
assert!(catalog.all_episode_metadata().expect("read").is_empty());
}
/// The negative answer is the one that has to survive a round trip: without
/// it every render re-asks about every unmatched title.
#[test]
fn a_stored_miss_reads_back_as_a_miss_and_not_as_absence() {
let catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "something obscure".into(),
provider: ProviderId::AniList,
metadata: None,
fetched_at: 1_700_000_000,
manual: false,
};
catalog.set_metadata(&record).expect("store");
let read = catalog.metadata("something obscure").expect("read");
assert_eq!(read, Some(record));
assert!(
catalog
.metadata("never asked about")
.expect("read")
.is_none()
);
}
/// A row written by a build that knows a provider this one does not must
/// read as "unmatched", never as a failure that takes the whole grid down.
#[test]
fn a_row_from_an_unknown_provider_is_skipped_rather_than_fatal() {
let catalog = Catalog::in_memory().expect("open");
catalog
.conn
.execute(
"INSERT INTO title_metadata (title_key, provider, matched, fetched_at, name)
VALUES ('x', 'letterboxd', 1, 1, 'Something')",
[],
)
.expect("insert");
assert!(catalog.all_metadata().expect("read all").is_empty());
assert!(catalog.metadata("x").expect("read").is_none());
}
/// Metadata is about titles, not shares — a recrawl replaces node rows and
/// must leave the answers alone, exactly as it does watch state.
#[test]
fn replacing_a_share_does_not_drop_stored_metadata() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.set_metadata(&MetadataRecord {
title_key: "attack on titan".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1,
manual: false,
})
.expect("store");
catalog
.replace_share(
"share-a",
&[file("share-a", "l1", "Attack on Titan S01E01.mkv")],
)
.expect("replace");
assert_eq!(catalog.all_metadata().expect("read all").len(), 1);
catalog.clear_metadata().expect("clear");
assert!(catalog.all_metadata().expect("read all").is_empty());
}
#[test]
fn episode_answers_round_trip_and_replace_rather_than_accumulate() {
let mut catalog = Catalog::in_memory().expect("open");
let episodes = vec![
EpisodeMetadata {
season: None,
number: 57,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("The Immortal Legion".into()),
overview: Some("Ed and Al…".into()),
still_url: None,
air_date: Some("2010-11-14".into()),
},
EpisodeMetadata {
season: Some(1),
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("Fullmetal Alchemist".into()),
overview: None,
still_url: Some("still.jpg".into()),
air_date: None,
},
];
catalog
.set_episode_metadata("fma", ProviderId::AniList, 100, &episodes)
.expect("store");
let stored = catalog.all_episode_metadata().expect("read");
let guide = stored.get("fma").expect("a guide for the title");
assert_eq!(guide.len(), 2);
assert_eq!(
guide.get(None, 57).and_then(|e| e.name.clone()),
Some("The Immortal Legion".to_string())
);
// The absolute/season distinction has to survive the round trip, or an
// absolutely-numbered file stops matching after a restart.
assert_eq!(guide.get(None, 57).map(|e| e.season), Some(None));
assert_eq!(guide.get(Some(1), 1).map(|e| e.season), Some(Some(1)));
// A shorter list replaces rather than merges: an episode the provider
// has dropped must not linger.
catalog
.set_episode_metadata("fma", ProviderId::AniList, 200, &episodes[..1])
.expect("store again");
let stored = catalog.all_episode_metadata().expect("read");
assert_eq!(stored["fma"].len(), 1);
}
/// The three numberings a release might use have to survive a restart, or
/// an absolutely-numbered season folder stops finding its names.
#[test]
fn every_numbering_of_an_episode_round_trips() {
let mut catalog = Catalog::in_memory().expect("open");
let episode = EpisodeMetadata {
season: Some(2),
number: 11,
absolute: Some(39),
entry_season: Some(3),
entry_number: Some(1),
name: Some("Lightning's End".into()),
overview: None,
still_url: None,
air_date: None,
};
catalog
.set_episode_metadata(
"mushishi",
ProviderId::AniList,
1,
std::slice::from_ref(&episode),
)
.expect("store");
let stored = catalog.all_episode_metadata().expect("read");
assert_eq!(stored["mushishi"].get(Some(2), 11), Some(&episode));
assert_eq!(stored["mushishi"].get(Some(3), 1), Some(&episode));
}
/// **What left Jujutsu Kaisen 0 with the TV series' episode names.** The
/// title was re-pinned to the film, the film had no episodes, and an empty
/// list was never written — so the series' list stayed.
#[test]
fn an_empty_enrichment_clears_the_episodes_the_last_match_left() {
let mut catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "jujutsu kaisen 0 movie".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1,
manual: true,
};
catalog.set_metadata(&record).expect("store");
catalog
.set_episode_metadata(
&record.title_key,
ProviderId::AniList,
1,
&[EpisodeMetadata {
season: Some(1),
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("Ryomen Sukuna".into()),
overview: None,
still_url: None,
air_date: None,
}],
)
.expect("store episodes");
// Storing the same entry again keeps them; it is the same entry.
catalog.set_metadata(&record).expect("store again");
assert_eq!(catalog.all_episode_metadata().expect("read").len(), 1);
catalog
.set_enrichment(
&record.title_key,
ProviderId::AniList,
5,
&Enrichment::default(),
)
.expect("enrich");
assert!(catalog.all_episode_metadata().expect("read").is_empty());
// Asked, and the answer was nothing: that is recorded, or a film is
// asked again on every run.
assert_eq!(
catalog
.enrichment_ages()
.expect("ages")
.get(&record.title_key),
Some(&(ProviderId::AniList, 5))
);
}
/// Android pins a match without enriching it; until the next match run,
/// the title must show no episode names rather than the old entry's.
#[test]
fn pinning_a_different_entry_drops_the_old_entrys_episodes_at_once() {
let mut catalog = Catalog::in_memory().expect("open");
let series = MetadataRecord {
title_key: "jujutsu kaisen 0 movie".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1,
manual: false,
};
catalog.set_metadata(&series).expect("store");
catalog
.set_episode_metadata(
&series.title_key,
ProviderId::AniList,
1,
&[EpisodeMetadata {
season: Some(1),
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("Ryomen Sukuna".into()),
overview: None,
still_url: None,
air_date: None,
}],
)
.expect("store episodes");
let film = MetadataRecord {
metadata: Some(TitleMetadata {
remote_id: "131573".into(),
..found()
}),
manual: true,
..series
};
catalog.set_metadata(&film).expect("pin");
assert!(catalog.all_episode_metadata().expect("read").is_empty());
}
/// Enrichment adds a backdrop when it found one and otherwise leaves the
/// match's own alone; a new match makes the title un-enriched again.
#[test]
fn enrichment_keeps_the_backdrop_it_did_not_replace_and_a_different_match_resets_it() {
let mut catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "attack on titan".into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
backdrop_url: Some("old.jpg".into()),
..found()
}),
fetched_at: 1,
manual: false,
};
catalog.set_metadata(&record).expect("store");
let backdrop = |catalog: &Catalog| {
catalog
.metadata("attack on titan")
.expect("read")
.and_then(|record| record.metadata)
.and_then(|found| found.backdrop_url)
};
catalog
.set_enrichment(
"attack on titan",
ProviderId::AniList,
2,
&Enrichment::default(),
)
.expect("enrich");
assert_eq!(backdrop(&catalog).as_deref(), Some("old.jpg"));
let enrichment = Enrichment {
backdrop_url: Some("fanart.jpg".into()),
..Enrichment::default()
};
catalog
.set_enrichment("attack on titan", ProviderId::AniList, 3, &enrichment)
.expect("enrich again");
assert_eq!(backdrop(&catalog).as_deref(), Some("fanart.jpg"));
catalog
.set_metadata(&MetadataRecord {
metadata: Some(TitleMetadata {
remote_id: "99147".into(),
..found()
}),
..record
})
.expect("match something else");
assert!(catalog.enrichment_ages().expect("ages").is_empty());
}
/// Details round-trip, and refreshing a match's details — the same entry
/// stored again — keeps what enrichment found for it.
#[test]
fn refreshing_the_same_match_keeps_its_enrichment_and_a_new_one_does_not() {
let mut catalog = Catalog::in_memory().expect("open");
let record = MetadataRecord {
title_key: "mushishi".into(),
provider: ProviderId::AniList,
metadata: Some(found()),
fetched_at: 1,
manual: true,
};
catalog.set_metadata(&record).expect("store");
let enrichment = Enrichment {
episodes: vec![EpisodeMetadata {
season: Some(1),
number: 1,
absolute: Some(1),
entry_season: Some(1),
entry_number: Some(1),
name: Some("The Green Seat".into()),
overview: None,
still_url: None,
air_date: None,
}],
backdrop_url: Some("fanart.jpg".into()),
chain: vec!["457".into(), "20595".into()],
};
catalog
.set_enrichment("mushishi", ProviderId::AniList, 2, &enrichment)
.expect("enrich");
let refreshed = MetadataRecord {
metadata: Some(TitleMetadata {
details: Some(TitleDetails {
studios: vec!["Artland".into()],
..TitleDetails::default()
}),
..found()
}),
fetched_at: 3,
..record.clone()
};
catalog.set_metadata(&refreshed).expect("refresh");
let read = catalog.metadata("mushishi").expect("read").expect("a row");
let data = read.metadata.expect("matched");
assert_eq!(data.backdrop_url.as_deref(), Some("fanart.jpg"));
let details = data.details.expect("details");
assert_eq!(details.studios, vec!["Artland"]);
assert_eq!(details.chain, vec!["457", "20595"]);
assert!(
catalog
.enrichment_ages()
.expect("ages")
.contains_key("mushishi")
);
assert_eq!(
catalog.all_episode_metadata().expect("read")["mushishi"].len(),
1
);
// A different entry is a different answer: nothing carries over.
catalog
.set_metadata(&MetadataRecord {
metadata: Some(TitleMetadata {
remote_id: "20526".into(),
..found()
}),
..record
})
.expect("repin");
let read = catalog.metadata("mushishi").expect("read").expect("a row");
assert_eq!(read.metadata.and_then(|data| data.backdrop_url), None);
assert!(catalog.enrichment_ages().expect("ages").is_empty());
}
/// A file keeps the moment it first appeared across recrawls, and one
/// that was there before this was recorded reads as 0.
#[test]
fn a_file_remembers_when_it_first_appeared() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share("s", &[file("s", "a", "Show S01E01.mkv")])
.expect("crawl");
let first = catalog.first_seen().expect("read")[&("s".to_string(), "a".to_string())];
assert!(first > 0);
catalog
.conn
.execute("UPDATE node_first_seen SET seen_at = 5", [])
.expect("age it");
catalog
.replace_share(
"s",
&[
file("s", "a", "Show S01E01.mkv"),
file("s", "b", "Show S01E02.mkv"),
],
)
.expect("recrawl");
let seen = catalog.first_seen().expect("read");
assert_eq!(seen[&("s".to_string(), "a".to_string())], 5);
assert!(seen[&("s".to_string(), "b".to_string())] > 5);
}
/// Schema v9 repairs what AniList's first episode source wrote: its
/// episode rows and its banners go, and the matches — hand-picked ones
/// included — stay.
#[test]
fn upgrading_drops_anilists_episode_rows_and_banners_but_keeps_its_matches() {
let conn = Connection::open_in_memory().expect("open");
for migration in [
MIGRATION_V1,
MIGRATION_V2,
MIGRATION_V3,
MIGRATION_V4,
MIGRATION_V5,
MIGRATION_V6,
MIGRATION_V7,
MIGRATION_V8,
] {
conn.execute_batch(migration).expect("migrate");
}
conn.pragma_update(None, "user_version", 8)
.expect("set version");
conn.execute_batch(
"INSERT INTO title_metadata (title_key, provider, matched, fetched_at, remote_id,
name, kind, backdrop_url, manual)
VALUES ('aot', 'anilist', 1, 1, '16498', 'Attack on Titan', 'series',
'banner.jpg', 1),
('got', 'tmdb', 1, 1, '1399', 'Game of Thrones', 'series',
'backdrop.jpg', 0);
INSERT INTO episode_metadata (title_key, season, number, provider, fetched_at, name)
VALUES ('aot', 2, 1, 'anilist', 1, 'Season one''s first, wrongly'),
('got', 1, 1, 'tmdb', 1, 'Winter Is Coming');",
)
.expect("seed");
let catalog = Catalog::from_connection(conn).expect("upgrade");
let records = catalog.all_metadata().expect("read");
let aot = records["aot"].metadata.as_ref().expect("still matched");
assert!(records["aot"].manual);
assert_eq!(aot.backdrop_url, None);
assert_eq!(
records["got"]
.metadata
.as_ref()
.and_then(|got| got.backdrop_url.as_deref()),
Some("backdrop.jpg")
);
let episodes = catalog.all_episode_metadata().expect("read");
assert!(!episodes.contains_key("aot"));
assert_eq!(episodes["got"].len(), 1);
assert!(catalog.enrichment_ages().expect("ages").is_empty());
}
#[test]
fn clearing_metadata_takes_the_episode_answers_with_it() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.set_episode_metadata(
"fma",
ProviderId::AniList,
100,
&[EpisodeMetadata {
season: None,
number: 1,
absolute: None,
entry_season: None,
entry_number: None,
name: Some("Fullmetal Alchemist".into()),
overview: None,
still_url: None,
air_date: None,
}],
)
.expect("store");
catalog.clear_metadata().expect("clear");
assert!(catalog.all_episode_metadata().expect("read").is_empty());
}
#[test]
fn a_fresh_catalog_is_at_the_current_schema_version() {
let catalog = Catalog::in_memory().expect("open");
let version: i64 = catalog
.conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.expect("read version");
assert_eq!(version, SCHEMA_VERSION);
}
#[test]
fn a_titles_track_choice_round_trips_with_the_track_names() {
let catalog = Catalog::in_memory().expect("open");
assert_eq!(catalog.title_track_prefs("show").expect("read"), None);
let prefs = TitleTrackPrefs {
audio_language: Some("jpn".into()),
subtitle_language: Some("eng".into()),
subtitles: true,
audio_title: None,
subtitle_title: Some("Full Subtitles".into()),
};
catalog
.set_title_track_prefs("show", &prefs)
.expect("write");
assert_eq!(
catalog.title_track_prefs("show").expect("read"),
Some(prefs.clone())
);
let changed = TitleTrackPrefs {
subtitle_title: Some("Signs & Songs".into()),
..prefs
};
catalog
.set_title_track_prefs("show", &changed)
.expect("overwrite");
let all = catalog.all_title_track_prefs().expect("read all");
assert_eq!(all.get("show"), Some(&changed));
}
/// A newer schema is refused, not downgraded — reinterpreting it would
/// silently mangle whatever the newer build recorded.
#[test]
fn a_newer_schema_refuses_to_open() {
let conn = Connection::open_in_memory().expect("open");
conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1)
.expect("set version");
let error = match Catalog::from_connection(conn) {
Err(error) => error,
Ok(_) => panic!("a newer schema must refuse to open"),
};
assert!(
error.to_string().contains("newer than this build supports"),
"says why: {error}"
);
}
#[test]
fn nodes_round_trip_through_a_share_replace() {
let mut catalog = Catalog::in_memory().expect("open");
let nodes = vec![
file("s1", "a", "Show.Name.S01E01.1080p.mkv"),
file("s1", "b", "Show.Name.S01E02.1080p.mkv"),
];
catalog.replace_share("s1", &nodes).expect("replace");
let read = catalog.files("s1").expect("read");
assert_eq!(read.len(), 2);
assert_eq!(read[0].parsed.title, "Show Name");
assert_eq!(read[0].parsed.episode, Some(1));
assert_eq!(read[1].parsed.episode, Some(2));
}
/// A recrawl replaces the share's rows wholesale, so files deleted upstream
/// disappear rather than lingering as unplayable ghosts.
#[test]
fn replacing_a_share_drops_rows_that_are_no_longer_there() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share(
"s1",
&[
file("s1", "a", "One.S01E01.mkv"),
file("s1", "b", "One.S01E02.mkv"),
],
)
.expect("first crawl");
catalog
.replace_share("s1", &[file("s1", "a", "One.S01E01.mkv")])
.expect("second crawl");
let read = catalog.files("s1").expect("read");
assert_eq!(read.len(), 1);
assert_eq!(read[0].link_id, "a");
}
/// One share's crawl must not touch another's rows.
#[test]
fn replacing_one_share_leaves_the_others_alone() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share("s1", &[file("s1", "a", "One.S01E01.mkv")])
.expect("s1");
catalog
.replace_share("s2", &[file("s2", "c", "Two.S01E01.mkv")])
.expect("s2");
catalog.replace_share("s1", &[]).expect("s1 emptied");
assert!(catalog.files("s1").expect("read s1").is_empty());
assert_eq!(catalog.files("s2").expect("read s2").len(), 1);
}
/// **The one thing here that is not rebuildable.** A recrawl must not lose
/// where you were in an episode.
#[test]
fn a_recrawl_preserves_watch_state() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share("s1", &[file("s1", "a", "One.S01E01.mkv")])
.expect("crawl");
let state = WatchState {
position_secs: 942.5,
duration_secs: Some(1440.0),
watched: false,
updated_at: 1_700_000_000,
};
catalog.set_watch_state("s1", "a", &state).expect("set");
catalog
.replace_share("s1", &[file("s1", "a", "One.S01E01.mkv")])
.expect("recrawl");
assert_eq!(
catalog.watch_state("s1", "a").expect("read"),
Some(state),
"the recrawl must not have cleared the resume position"
);
}
/// Resuming overwrites rather than accumulating rows.
#[test]
fn watch_state_updates_in_place() {
let catalog = Catalog::in_memory().expect("open");
for position in [10.0, 200.0, 3000.0] {
catalog
.set_watch_state(
"s1",
"a",
&WatchState {
position_secs: position,
duration_secs: Some(3600.0),
watched: false,
updated_at: 1,
},
)
.expect("set");
}
let state = catalog.watch_state("s1", "a").expect("read").expect("some");
assert_eq!(state.position_secs, 3000.0);
}
// ---- build_rows: titles from the folder path -------------------------
use proton_drive_rs::{Node, NodeKind};
use proton_sdk::ids::NodeUid;
fn uid(link: &str) -> NodeUid {
NodeUid::new("vol".into(), link.into())
}
fn folder(link: &str, parent: Option<&str>, name: &str) -> Node {
Node {
uid: uid(link),
parent_uid: parent.map(uid),
kind: NodeKind::Folder,
name: name.into(),
creation_time: 0,
modification_time: 0,
trashed: false,
is_shared: true,
is_shared_publicly: true,
signature_email: None,
membership: None,
photo: None,
album: None,
verification: Default::default(),
direct_role: None,
share_id: None,
}
}
fn video(link: &str, parent: &str, name: &str) -> Node {
Node {
kind: NodeKind::File {
media_type: "video/x-matroska".into(),
total_size_on_storage: 0,
active_revision_state: None,
active_revision_id: Some("rev".into()),
claimed_size: Some(1 << 30),
claimed_modification_time: None,
content_sha1: None,
},
..folder(link, Some(parent), name)
}
}
/// The share root, whose parent is outside what a visitor can see.
fn root() -> Node {
folder("root", Some("outside-the-share"), "anime")
}
fn row<'a>(rows: &'a [CatalogNode], link: &str) -> &'a CatalogNode {
rows.iter()
.find(|row| row.link_id == link)
.unwrap_or_else(|| panic!("no row for {link}"))
}
/// **The layout nearly every real library uses.** `S01E01.mkv` names no
/// series at all; without walking the folder path the whole catalog would be
/// untitled.
#[test]
fn a_files_title_comes_from_its_series_folder() {
let nodes = vec![
root(),
folder("frieren", Some("root"), "Sousou no Frieren"),
folder("s1", Some("frieren"), "Season 01"),
video("ep1", "s1", "S01E01.mkv"),
];
let rows = build_rows("share", &nodes);
assert_eq!(rows.len(), 1, "folders are not catalog entries");
let episode = row(&rows, "ep1");
assert_eq!(episode.parsed.title, "Sousou no Frieren");
assert_eq!(episode.parsed.season, Some(1));
assert_eq!(episode.parsed.episode, Some(1));
}
/// A season folder supplies the season when the filename states only an
/// episode marker.
#[test]
fn a_season_folder_supplies_the_season() {
let nodes = vec![
root(),
folder("kaijuu", Some("root"), "Kaijuu 8-gou"),
folder("s2", Some("kaijuu"), "Season 02"),
video("e4", "s2", "E04.mkv"),
];
let episode = build_rows("share", &nodes);
let episode = row(&episode, "e4");
assert_eq!(episode.parsed.title, "Kaijuu 8-gou");
assert_eq!(episode.parsed.season, Some(2), "from the folder");
assert_eq!(episode.parsed.episode, Some(4), "from the filename");
}
/// A grouping folder is stepped over, so its contents group under the series
/// above it rather than under a folder called `Movies`.
#[test]
fn a_grouping_folder_is_stepped_over_to_reach_the_series() {
let nodes = vec![
root(),
folder("eva", Some("root"), "Neon Genesis Evangelion"),
folder("movies", Some("eva"), "Movies"),
video(
"film",
"movies",
"[Anime Time] Neon Genesis Evangelion - The End of Evangelion-003.mkv",
),
];
let film = build_rows("share", &nodes);
let film = row(&film, "film");
assert_eq!(film.parsed.title, "Neon Genesis Evangelion");
assert_eq!(
film.parsed.episode_title.as_deref(),
Some("The End of Evangelion"),
"the filename's own title is the film's name, minus the series prefix"
);
}
/// **What every episode of Oshi no Ko season three was called.** The
/// filename states the season in words, the folder states the series, and
/// the difference between the two is a season marker — not the name of the
/// episode.
#[test]
fn a_season_stated_in_the_filename_does_not_become_the_episodes_name() {
let nodes = vec![
root(),
folder("onk", Some("root"), "Oshi no Ko"),
video(
"e7",
"onk",
"[DB]Oshi no Ko 3rd Season_-_07_(Dual Audio_10bit_1080p_x265).mkv",
),
];
let rows = build_rows("share", &nodes);
let episode = row(&rows, "e7");
assert_eq!(episode.parsed.title, "Oshi no Ko");
assert_eq!(episode.parsed.season, Some(3));
assert_eq!(episode.parsed.episode, Some(7));
assert_eq!(episode.parsed.episode_title, None);
}
/// **What every episode of Attack on Titan was called.** The folder and the
/// release group capitalised the same series two ways, and a case-sensitive
/// prefix strip left the whole series name behind — so all fifty-nine rows
/// read `Attack On Titan`, naming nothing and telling the viewer apart from
/// nothing.
#[test]
fn a_series_name_cased_differently_is_not_the_episodes_name() {
let nodes = vec![
root(),
folder("aot", Some("root"), "Attack on Titan"),
video("e38", "aot", "[Anime Time] Attack On Titan - 38.mkv"),
];
let rows = build_rows("share", &nodes);
let episode = row(&rows, "e38");
assert_eq!(episode.parsed.title, "Attack on Titan", "the folder's");
assert_eq!(episode.parsed.episode, Some(38));
assert_eq!(episode.parsed.episode_title, None);
}
/// The prefix strip is case-insensitive for the same reason: what follows
/// the series name is the episode's, however the group capitalised it.
#[test]
fn a_prefix_cased_differently_is_still_stripped() {
let nodes = vec![
root(),
folder("eva", Some("root"), "Neon Genesis Evangelion"),
video(
"film",
"eva",
"NEON GENESIS EVANGELION - Death & Rebirth.mkv",
),
];
let rows = build_rows("share", &nodes);
assert_eq!(
row(&rows, "film").parsed.episode_title.as_deref(),
Some("Death & Rebirth"),
);
}
/// The deepest naming folder wins, so films inside a collection folder are
/// titled individually rather than by the collection.
#[test]
fn the_deepest_naming_folder_wins() {
let nodes = vec![
root(),
folder("kon", Some("root"), "Satoshi Kon Movies"),
folder("millennium", Some("kon"), "Millennium Actress"),
video("film", "millennium", "Millennium.Actress.1080p.mkv"),
];
let film = build_rows("share", &nodes);
assert_eq!(row(&film, "film").parsed.title, "Millennium Actress");
}
/// The share root names the *share*, not a series — using it would label
/// every top-level file identically.
#[test]
fn the_share_root_is_not_used_as_a_title() {
let nodes = vec![root(), video("loose", "root", "Some.Film.2019.mkv")];
let film = build_rows("share", &nodes);
assert_eq!(
row(&film, "loose").parsed.title,
"Some Film",
"the filename's title stands, rather than the root folder's name"
);
}
/// A folder year reaches the files under it.
#[test]
fn a_year_in_the_folder_name_reaches_its_files() {
let nodes = vec![
root(),
folder("gits", Some("root"), "Ghost in the Shell (1995)"),
video("film", "gits", "gits.bd.1080p.mkv"),
];
let film = build_rows("share", &nodes);
let film = row(&film, "film");
assert_eq!(film.parsed.title, "Ghost in the Shell");
assert_eq!(film.parsed.year, Some(1995));
}
/// Non-video files are dropped: a share holds `.nfo` and `.jpg` next to the
/// video, and offering those as episodes is worse than omitting them.
#[test]
fn non_video_files_do_not_enter_the_catalog() {
let nodes = vec![
root(),
folder("gits", Some("root"), "Ghost in the Shell (1995)"),
video("film", "gits", "gits.mkv"),
video("info", "gits", "torrent.info.nfo"),
video("art", "gits", "poster.jpg"),
];
let rows = build_rows("share", &nodes);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].link_id, "film");
}
#[test]
fn merging_takes_only_states_newer_than_the_recorded_ones() {
let mut catalog = Catalog::in_memory().expect("open");
let at = |position_secs: f64, updated_at: i64| WatchState {
position_secs,
duration_secs: Some(1440.0),
watched: false,
updated_at,
};
catalog
.set_watch_state("s1", "kept", &at(100.0, 50))
.unwrap();
catalog
.set_watch_state("s1", "taken", &at(100.0, 50))
.unwrap();
let taken = catalog
.merge_watch_states(&[
("s1".to_owned(), "kept".to_owned(), at(900.0, 40)),
("s1".to_owned(), "taken".to_owned(), at(900.0, 60)),
("s1".to_owned(), "new".to_owned(), at(5.0, 10)),
])
.expect("merge");
assert_eq!(taken, 2);
assert_eq!(
catalog.watch_state("s1", "kept").unwrap(),
Some(at(100.0, 50))
);
assert_eq!(
catalog.watch_state("s1", "taken").unwrap(),
Some(at(900.0, 60))
);
assert_eq!(catalog.watch_state("s1", "new").unwrap(), Some(at(5.0, 10)));
}
#[test]
fn a_merge_tie_settles_the_same_way_whichever_side_holds_it() {
let state = |position_secs: f64, watched: bool| WatchState {
position_secs,
duration_secs: Some(1440.0),
watched,
updated_at: 70,
};
for (local, remote) in [
(state(300.0, false), state(1440.0, true)),
(state(1440.0, true), state(300.0, false)),
(state(300.0, false), state(600.0, false)),
(state(600.0, false), state(300.0, false)),
] {
let mut catalog = Catalog::in_memory().expect("open");
catalog.set_watch_state("s1", "a", &local).unwrap();
catalog
.merge_watch_states(&[("s1".to_owned(), "a".to_owned(), remote)])
.unwrap();
let winner =
if (local.watched, local.position_secs) > (remote.watched, remote.position_secs) {
local
} else {
remote
};
assert_eq!(catalog.watch_state("s1", "a").unwrap(), Some(winner));
}
}
#[test]
fn an_unwatched_file_has_no_watch_state() {
let catalog = Catalog::in_memory().expect("open");
assert_eq!(
catalog.watch_state("s1", "never-played").expect("read"),
None
);
}
#[test]
fn removing_an_offline_record_keeps_the_online_catalog_and_other_downloads() {
let catalog = Catalog::in_memory().expect("open");
let first = OfflineFile {
revision_id: "revision-1".to_owned(),
block_sizes: vec![3, 5],
};
let second = OfflineFile {
revision_id: "revision-2".to_owned(),
block_sizes: vec![7],
};
catalog
.set_offline_file("share", "first", &first)
.expect("record first");
catalog
.set_offline_file("share", "second", &second)
.expect("record second");
catalog
.remove_offline_file("share", "first")
.expect("remove first");
assert_eq!(
catalog.offline_file("share", "first").expect("read first"),
None
);
assert_eq!(
catalog
.offline_file("share", "second")
.expect("read second"),
Some(second)
);
}
#[test]
fn recrawl_retains_stale_offline_index_until_bytes_can_be_removed() {
let mut catalog = Catalog::in_memory().expect("open");
let mut old = file("share", "episode", "Show.S01E01.mkv");
old.active_revision_id = Some("old".to_owned());
catalog.replace_share("share", &[old]).expect("first crawl");
let offline = OfflineFile {
revision_id: "old".to_owned(),
block_sizes: vec![4, 7],
};
catalog
.set_offline_file("share", "episode", &offline)
.expect("record offline revision");
let mut new = file("share", "episode", "Show.S01E01.mkv");
new.active_revision_id = Some("new".to_owned());
catalog
.replace_share_retaining_offline("share", &[new])
.expect("recrawl");
assert_eq!(
catalog
.offline_file("share", "episode")
.expect("read retained path key"),
Some(offline),
"filesystem owner must delete old bytes before dropping this path key"
);
}
#[test]
fn ordinary_recrawl_prunes_stale_offline_index_for_legacy_callers() {
let mut catalog = Catalog::in_memory().expect("open");
let mut old = file("share", "episode", "Show.S01E01.mkv");
old.active_revision_id = Some("old".to_owned());
catalog.replace_share("share", &[old]).expect("first crawl");
catalog
.set_offline_file(
"share",
"episode",
&OfflineFile {
revision_id: "old".to_owned(),
block_sizes: vec![4],
},
)
.expect("offline row");
let mut new = file("share", "episode", "Show.S01E01.mkv");
new.active_revision_id = Some("new".to_owned());
catalog.replace_share("share", &[new]).expect("recrawl");
assert_eq!(
catalog
.offline_file("share", "episode")
.expect("read offline row"),
None
);
}
}