This commit is contained in:
nvrl committed 2026-08-02 19:37:03 +02:00
commit a1b345a029
80 files changed
+28033

No files matched your search

+23
View File
@@ -0,0 +1,23 @@
[package]
name = "pstr-core"
description = "Shares, catalog model, filename parsing and persistence for proton-stream."
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
proton-sdk.workspace = true
proton-drive-rs.workspace = true
tokio.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
directories.workspace = true
rusqlite.workspace = true
keyring.workspace = true
sha2.workspace = true
File diff suppressed because it is too large. Load diff
+225
View File
@@ -0,0 +1,225 @@
//! Where the app keeps its state, and how it writes there safely.
//!
//! Two rules carried over from `proton-drive-linux`'s config handling, both
//! learned the hard way:
//!
//! 1. **Write atomically.** Temp file → `sync_all` → rename → fsync the parent
//! directory. A crash mid-write then leaves either the old file or the new
//! one, never a truncated one.
//! 2. **Never overwrite a config that would not parse.** An unreadable file is
//! far more likely to be a bug of ours, or a half-finished hand edit, than
//! something to be silently replaced — and replacing it destroys the only
//! record of the user's shares.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
/// The app's directories, resolved once.
#[derive(Debug, Clone)]
pub struct AppDirs {
/// Config: the share list. Small, hand-editable, worth backing up.
pub config: PathBuf,
/// State: the catalog database and metadata cache. Rebuildable.
pub data: PathBuf,
/// Cache: downloaded content blocks and poster images. Disposable — the app
/// must work correctly after this is deleted while it is not running.
pub cache: PathBuf,
}
impl AppDirs {
/// Resolve the platform directories and create them.
pub fn ensure() -> Result<Self> {
let dirs =
directories::ProjectDirs::from("io", "narl", "proton-stream").ok_or_else(|| {
Error::Config("no home directory to resolve app directories from".into())
})?;
let dirs = Self {
config: dirs.config_dir().to_path_buf(),
data: dirs.data_dir().to_path_buf(),
cache: dirs.cache_dir().to_path_buf(),
};
for path in [&dirs.config, &dirs.data, &dirs.cache] {
fs::create_dir_all(path)?;
}
Ok(dirs)
}
/// The share list.
pub fn shares_file(&self) -> PathBuf {
self.config.join("shares.json")
}
/// The catalog database.
pub fn catalog_db(&self) -> PathBuf {
self.data.join("catalog.db")
}
/// The content-block cache root.
pub fn block_cache(&self) -> PathBuf {
self.cache.join("blocks")
}
/// Where decrypted poster thumbnails are kept.
///
/// Separate from the block cache because it is cheap, tiny and wanted on
/// every render, where a block is expensive, large and wanted once. Clearing
/// either must leave the app correct.
pub fn thumbnail_cache(&self) -> PathBuf {
self.cache.join("thumbs")
}
/// Where artwork downloaded from a metadata provider is kept.
///
/// Separate from the Proton thumbnails next to it because the two are
/// invalidated by different things — a recrawl for one, a provider switch
/// for the other — and because deleting all of one must not touch the other.
pub fn poster_cache(&self) -> PathBuf {
self.cache.join("posters")
}
}
/// Read and deserialize a JSON config, or `None` when it does not exist yet.
///
/// A file that exists but will not parse is an error, never a `None` — see the
/// module note on why that distinction is load-bearing.
pub fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
serde_json::from_str(&text)
.map(Some)
.map_err(|e| Error::Config(format!("{} is not valid config: {e}", path.display())))
}
/// Serialize and write a JSON config atomically.
pub fn write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
let text = serde_json::to_string_pretty(value)
.map_err(|e| Error::Config(format!("serialize config: {e}")))?;
let parent = path
.parent()
.ok_or_else(|| Error::Config(format!("{} has no parent directory", path.display())))?;
fs::create_dir_all(parent)?;
// Named for the target so two concurrent writers of *different* configs
// cannot collide on one temp path.
let temp = parent.join(format!(
".{}.tmp",
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "config".into())
));
{
let mut file = fs::File::create(&temp)?;
file.write_all(text.as_bytes())?;
// Before the rename, so the rename cannot publish an empty file.
file.sync_all()?;
}
fs::rename(&temp, path)?;
// The rename itself needs durability, or a crash can lose the whole file
// even though its contents were synced.
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
struct Sample {
name: String,
count: u32,
}
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"pstr-config-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn a_config_round_trips_through_an_atomic_write() {
let dir = temp_dir("roundtrip");
let path = dir.join("sample.json");
let value = Sample {
name: "anime".into(),
count: 42,
};
write_json(&path, &value).expect("write");
let read: Sample = read_json(&path).expect("read").expect("present");
assert_eq!(read, value);
fs::remove_dir_all(&dir).ok();
}
/// A missing config is the first-run case, not a failure.
#[test]
fn a_missing_config_reads_as_none() {
let dir = temp_dir("missing");
let read: Option<Sample> = read_json(&dir.join("absent.json")).expect("read");
assert!(read.is_none());
fs::remove_dir_all(&dir).ok();
}
/// The distinction that protects the share list: unparseable is an error,
/// so a caller doing read-modify-write cannot mistake it for "empty" and
/// overwrite the user's shares with nothing.
#[test]
fn an_unparseable_config_is_an_error_and_not_an_empty_one() {
let dir = temp_dir("corrupt");
let path = dir.join("broken.json");
fs::write(&path, b"{ this is not json").expect("write garbage");
let read: Result<Option<Sample>> = read_json(&path);
assert!(read.is_err(), "must refuse rather than report absence");
// And the bad file is still there to be inspected or repaired.
assert!(path.exists());
fs::remove_dir_all(&dir).ok();
}
/// The temp file must not survive a successful write.
#[test]
fn an_atomic_write_leaves_no_temp_file_behind() {
let dir = temp_dir("notemp");
let path = dir.join("sample.json");
write_json(
&path,
&Sample {
name: "x".into(),
count: 1,
},
)
.expect("write");
let leftovers: Vec<_> = fs::read_dir(&dir)
.expect("read dir")
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "temp files left: {leftovers:?}");
fs::remove_dir_all(&dir).ok();
}
}
+40
View File
@@ -0,0 +1,40 @@
//! One error type for everything this crate does.
/// What can go wrong opening a share, crawling it, or persisting what was found.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The Drive SDK failed — a network error, an API refusal, or a decrypt.
#[error(transparent)]
Drive(#[from] proton_sdk::error::ProtonError),
/// The catalog database failed.
#[error("catalog database: {0}")]
Db(#[from] rusqlite::Error),
/// The OS credential store failed.
///
/// Boxed because `keyring::Error` is large and this variant is rare; an
/// un-boxed one would inflate every `Result` in the crate.
#[error("credential store: {0}")]
Keyring(#[from] Box<keyring::Error>),
/// A config file could not be read, written or parsed.
#[error("config: {0}")]
Config(String),
/// Filesystem failure outside of config handling.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// The caller asked for something that does not exist or does not apply.
#[error("{0}")]
NotFound(String),
}
impl From<keyring::Error> for Error {
fn from(error: keyring::Error) -> Self {
Self::Keyring(Box::new(error))
}
}
pub type Result<T> = std::result::Result<T, Error>;
+21
View File
@@ -0,0 +1,21 @@
//! Shares, catalog, naming and persistence for `proton-stream`.
//!
//! Everything that is not the player or the UI. Front ends depend on this crate
//! rather than on `proton-drive-rs` directly, so there is one place that knows
//! how a share is opened and how its contents are modelled.
#![forbid(unsafe_code)]
pub mod catalog;
pub mod config;
pub mod error;
pub mod library;
pub mod metadata;
pub mod naming;
pub mod prefs;
pub mod shares;
pub use error::{Error, Result};
pub use shares::{Share, ShareStore, SharedLibrary};
pub use proton_drive_rs;
pub use proton_sdk;
+645
View File
@@ -0,0 +1,645 @@
//! Catalog rows, grouped into what a poster wall shows.
//!
//! The catalog is flat — one row per file, with whatever [`crate::naming`] made
//! of its path. A UI wants the other shape: titles, each with seasons, each with
//! episodes, and the one episode the viewer should land on when they click. That
//! transform is here rather than in the app because it is pure, table-testable,
//! and the CLI wants the same view of the same data.
//!
//! Two decisions worth stating:
//!
//! * **Titles merge across shares.** Two shares holding the same series are one
//! poster, not two. The grouping key is the normalised title, so a share added
//! later slots into the title that is already there.
//! * **Watch state is joined in, not looked up per draw.** A grid render must
//! not become one SQLite round-trip per tile — see the negative-caching trap
//! in `docs/DEVELOPMENT.md`, which is the same lesson one layer up.
use std::collections::HashMap;
use crate::catalog::{CatalogNode, WatchState};
/// A file has been watched once playback passed this fraction of it. Short of
/// 1.0 because nobody sits through the credits, and a title that never reaches
/// "finished" keeps offering an episode the viewer is done with.
pub const WATCHED_FRACTION: f64 = 0.9;
/// Playback below this many seconds is not a resume point — it is a file the
/// viewer opened and immediately left.
pub const RESUME_FLOOR_SECS: f64 = 30.0;
/// Whether a title is a series or a one-off.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TitleKind {
Series,
Film,
}
/// One playable file, with where the viewer left off in it.
#[derive(Debug, Clone, PartialEq)]
pub struct Episode {
pub node: CatalogNode,
pub watch: Option<WatchState>,
}
impl Episode {
/// `S01E02`, `#014`, or nothing when the name numbers nothing.
pub fn numbering(&self) -> Option<String> {
match (self.node.parsed.season, self.node.parsed.episode) {
(Some(season), Some(episode)) => Some(format!("S{season:02}E{episode:02}")),
(None, Some(episode)) => Some(format!("#{episode:03}")),
_ => None,
}
}
/// What to put on the row: the episode's own name when the filename stated
/// one, else the numbering, else the filename.
pub fn label(&self) -> String {
if let Some(title) = &self.node.parsed.episode_title {
return title.clone();
}
self.numbering().unwrap_or_else(|| self.node.name.clone())
}
/// What to put on a row that already shows the numbering beside it: the
/// episode's own name when the filename stated one, else the filename.
///
/// Deliberately not [`Self::label`] — that falls back to the numbering,
/// which on an episode row prints it twice.
pub fn detail(&self) -> &str {
self.node
.parsed
.episode_title
.as_deref()
.unwrap_or(&self.node.name)
}
/// Fraction of the file played, when both a position and a duration are
/// known. `None` for a file that was never opened.
pub fn progress(&self) -> Option<f64> {
let watch = self.watch?;
let duration = watch.duration_secs?;
if duration <= 0.0 {
return None;
}
Some((watch.position_secs / duration).clamp(0.0, 1.0))
}
/// Whether this counts as seen. Either the flag was set explicitly, or
/// playback got far enough that setting it is what the viewer meant.
pub fn is_watched(&self) -> bool {
self.watch.is_some_and(|watch| watch.watched)
|| self.progress().is_some_and(|p| p >= WATCHED_FRACTION)
}
/// Where to resume, or `None` to start from the beginning. A file barely
/// started, or effectively finished, starts over.
pub fn resume_at(&self) -> Option<f64> {
let watch = self.watch?;
if self.is_watched() || watch.position_secs < RESUME_FLOOR_SECS {
return None;
}
Some(watch.position_secs)
}
/// When playback last touched this file. `0` for one never opened, so it
/// sorts below everything that was.
pub fn last_played(&self) -> i64 {
self.watch.map_or(0, |watch| watch.updated_at)
}
}
/// The episodes of one season, or the unnumbered pile when a library uses
/// absolute numbering.
#[derive(Debug, Clone, PartialEq)]
pub struct Season {
/// `None` when the names state no season. That is not season 1: an
/// absolute-numbered anime and a film both land here.
pub number: Option<u32>,
pub episodes: Vec<Episode>,
}
impl Season {
pub fn label(&self) -> String {
match self.number {
Some(number) => format!("Season {number}"),
None => "Episodes".to_string(),
}
}
}
/// A series or a film: everything under one title, across every share.
#[derive(Debug, Clone, PartialEq)]
pub struct Title {
/// Stable identity for the UI to route on and for caches to key on.
pub key: String,
/// What to print.
pub name: String,
pub year: Option<u32>,
pub kind: TitleKind,
pub seasons: Vec<Season>,
/// Every share contributing files, in first-seen order.
pub share_ids: Vec<String>,
}
impl Title {
/// Every episode, in display order.
pub fn episodes(&self) -> impl Iterator<Item = &Episode> {
self.seasons
.iter()
.flat_map(|season| season.episodes.iter())
}
pub fn episode_count(&self) -> usize {
self.seasons
.iter()
.map(|season| season.episodes.len())
.sum()
}
pub fn watched_count(&self) -> usize {
self.episodes().filter(|e| e.is_watched()).count()
}
/// The file to pull a poster thumbnail from: the first episode there is.
/// Proton renders a thumbnail per file, so any of them is a frame of the
/// right show; the first is the one most likely already cached.
pub fn poster_node(&self) -> Option<&CatalogNode> {
self.episodes().next().map(|episode| &episode.node)
}
/// The episode that is part-watched, most recently played first.
pub fn resume(&self) -> Option<&Episode> {
self.episodes()
.filter(|episode| episode.resume_at().is_some())
.max_by_key(|episode| episode.last_played())
}
/// What a click on the poster should play: whatever is part-watched, else
/// the first unwatched episode, else the first episode.
pub fn next_up(&self) -> Option<&Episode> {
self.resume()
.or_else(|| self.episodes().find(|episode| !episode.is_watched()))
.or_else(|| self.episodes().next())
}
/// Where a file sits in display order, if it is under this title at all.
///
/// Identity is `(share_id, link_id)`, not the name: a title merged across
/// two shares can hold two files called `E01.mkv`, and "play the next one"
/// must not be able to jump between shares by accident.
pub fn position_of(&self, share_id: &str, link_id: &str) -> Option<usize> {
self.episodes().position(|episode| {
episode.node.share_id == share_id && episode.node.link_id == link_id
})
}
/// What comes after a file, in display order. `None` at the end of the
/// title, and for a file that is not part of it.
pub fn following(&self, share_id: &str, link_id: &str) -> Option<&Episode> {
let position = self.position_of(share_id, link_id)?;
self.episodes().nth(position + 1)
}
/// What comes before it.
pub fn preceding(&self, share_id: &str, link_id: &str) -> Option<&Episode> {
let position = self.position_of(share_id, link_id)?;
self.episodes().nth(position.checked_sub(1)?)
}
/// When this title was last played at all.
pub fn last_played(&self) -> i64 {
self.episodes().map(Episode::last_played).max().unwrap_or(0)
}
}
/// Every title the catalog holds.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Library {
pub titles: Vec<Title>,
}
impl Library {
/// Group catalog rows into titles, joining in watch state keyed by
/// `(share_id, link_id)`.
///
/// Folder rows are ignored: a poster wall shows playable files, and the
/// folder structure has already been consumed by the parser that produced
/// these titles.
pub fn build(nodes: Vec<CatalogNode>, watch: &HashMap<(String, String), WatchState>) -> Self {
// Insertion-ordered grouping: key -> index into `titles`, so the output
// is deterministic before it is sorted.
let mut index: HashMap<String, usize> = HashMap::new();
let mut titles: Vec<Title> = Vec::new();
for node in nodes {
if node.is_folder {
continue;
}
let key = title_key(&node.parsed.title);
let position = match index.get(&key) {
Some(position) => *position,
None => {
titles.push(Title {
key: key.clone(),
name: node.parsed.title.clone(),
year: node.parsed.year,
// Provisional: settled once every file is in.
kind: TitleKind::Film,
seasons: Vec::new(),
share_ids: Vec::new(),
});
index.insert(key, titles.len() - 1);
titles.len() - 1
}
};
let title = &mut titles[position];
if title.year.is_none() {
title.year = node.parsed.year;
}
if !title.share_ids.contains(&node.share_id) {
title.share_ids.push(node.share_id.clone());
}
let watch_state = watch
.get(&(node.share_id.clone(), node.link_id.clone()))
.copied();
let season_number = node.parsed.season;
let episode = Episode {
node,
watch: watch_state,
};
match title
.seasons
.iter_mut()
.find(|season| season.number == season_number)
{
Some(season) => season.episodes.push(episode),
None => title.seasons.push(Season {
number: season_number,
episodes: vec![episode],
}),
}
}
for title in &mut titles {
// A season number, an episode number or more than one file all mean
// series. A lone unnumbered file is a film.
let numbered = title.episodes().any(|episode| {
episode.node.parsed.is_episode() || episode.node.parsed.season.is_some()
});
title.kind = if numbered || title.episode_count() > 1 {
TitleKind::Series
} else {
TitleKind::Film
};
// Unnumbered seasons last: they are the specials pile, not season 0.
title
.seasons
.sort_by_key(|season| (season.number.is_none(), season.number));
for season in &mut title.seasons {
season.episodes.sort_by(|left, right| {
left.node
.parsed
.episode
.cmp(&right.node.parsed.episode)
.then_with(|| left.node.name.cmp(&right.node.name))
});
}
}
titles.sort_by_key(|title| sort_name(&title.name));
Self { titles }
}
pub fn is_empty(&self) -> bool {
self.titles.is_empty()
}
pub fn get(&self, key: &str) -> Option<&Title> {
self.titles.iter().find(|title| title.key == key)
}
/// Titles with something part-watched, most recent first. The top row of
/// the library.
pub fn continue_watching(&self) -> Vec<&Title> {
let mut titles: Vec<&Title> = self
.titles
.iter()
.filter(|title| title.resume().is_some())
.collect();
titles.sort_by_key(|title| std::cmp::Reverse(title.last_played()));
titles
}
/// Substring match over the display name and every filename under it, so
/// searching for a release group or an episode name finds its title.
pub fn search(&self, query: &str) -> Vec<&Title> {
let needle = query.trim().to_lowercase();
if needle.is_empty() {
return self.titles.iter().collect();
}
self.titles
.iter()
.filter(|title| {
title.name.to_lowercase().contains(&needle)
|| title
.episodes()
.any(|episode| episode.node.name.to_lowercase().contains(&needle))
})
.collect()
}
}
/// The grouping key: case- and punctuation-insensitive, so `Fullmetal
/// Alchemist: Brotherhood` and `Fullmetal Alchemist Brotherhood` are one title.
pub fn title_key(title: &str) -> String {
let mut key = String::with_capacity(title.len());
let mut pending_space = false;
for character in title.chars() {
if character.is_alphanumeric() {
if pending_space && !key.is_empty() {
key.push(' ');
}
pending_space = false;
key.extend(character.to_lowercase());
} else {
pending_space = true;
}
}
key
}
/// Sort key for the shelf: case-insensitive, and a leading article ignored, so
/// `The Expanse` files under E where a viewer looks for it.
fn sort_name(name: &str) -> String {
let lower = name.to_lowercase();
for article in ["the ", "a ", "an "] {
if let Some(rest) = lower.strip_prefix(article) {
return rest.to_string();
}
}
lower
}
#[cfg(test)]
mod tests {
use super::*;
use crate::naming::ParsedName;
fn node(
share: &str,
link: &str,
name: &str,
season: Option<u32>,
episode: Option<u32>,
) -> CatalogNode {
CatalogNode {
share_id: share.into(),
link_id: link.into(),
volume_id: "vol".into(),
parent_link_id: None,
name: name.into(),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1024),
active_revision_id: Some("rev".into()),
parsed: ParsedName {
title: name.split(" - ").next().unwrap_or(name).to_string(),
season,
episode,
year: None,
episode_title: None,
},
}
}
#[test]
fn the_next_episode_is_the_next_one_in_display_order_across_seasons() {
let library = Library::build(
vec![
node("s1", "a", "Show - S01E01.mkv", Some(1), Some(1)),
node("s1", "b", "Show - S01E02.mkv", Some(1), Some(2)),
node("s1", "c", "Show - S02E01.mkv", Some(2), Some(1)),
],
&HashMap::new(),
);
let title = &library.titles[0];
assert_eq!(
title.following("s1", "a").map(|e| e.node.link_id.clone()),
Some("b".to_string())
);
// Autoplay has to walk off the end of a season into the next one, or it
// stops two thirds of the way through a series for no reason a viewer
// can see.
assert_eq!(
title.following("s1", "b").map(|e| e.node.link_id.clone()),
Some("c".to_string())
);
assert_eq!(title.following("s1", "c"), None);
assert_eq!(
title.preceding("s1", "c").map(|e| e.node.link_id.clone()),
Some("b".to_string())
);
assert_eq!(title.preceding("s1", "a"), None);
}
#[test]
fn a_file_from_another_share_is_not_this_titles_neighbour() {
let library = Library::build(
vec![
node("s1", "a", "Show - S01E01.mkv", Some(1), Some(1)),
node("s1", "b", "Show - S01E02.mkv", Some(1), Some(2)),
],
&HashMap::new(),
);
let title = &library.titles[0];
// Same link id, different share: two shares of the same series really
// do collide on names, and "next" must not cross over on a false match.
assert_eq!(title.position_of("other", "a"), None);
assert_eq!(title.following("other", "a"), None);
}
fn watch(position: f64, duration: f64, watched: bool, at: i64) -> WatchState {
WatchState {
position_secs: position,
duration_secs: Some(duration),
watched,
updated_at: at,
}
}
#[test]
fn groups_episodes_into_seasons() {
let nodes = vec![
node("s", "1", "Frieren - S01E02", Some(1), Some(2)),
node("s", "2", "Frieren - S01E01", Some(1), Some(1)),
node("s", "3", "Frieren - S02E01", Some(2), Some(1)),
];
let library = Library::build(nodes, &HashMap::new());
assert_eq!(library.titles.len(), 1);
let title = &library.titles[0];
assert_eq!(title.kind, TitleKind::Series);
assert_eq!(title.seasons.len(), 2);
assert_eq!(title.seasons[0].number, Some(1));
// Sorted by episode number, not by insertion order.
assert_eq!(title.seasons[0].episodes[0].node.link_id, "2");
assert_eq!(title.episode_count(), 3);
}
#[test]
fn a_row_detail_never_repeats_the_numbering() {
let mut file = node("s", "1", "Show - S01E01", Some(1), Some(1));
file.name = "[Group] Show - 01 (1080p).mkv".into();
let library = Library::build(vec![file], &HashMap::new());
let episode = &library.titles[0].seasons[0].episodes[0];
// No episode name in the filename: the row shows the filename, while
// the card — which has no numbering column — shows the numbering.
assert_eq!(episode.detail(), "[Group] Show - 01 (1080p).mkv");
assert_eq!(episode.label(), "S01E01");
}
#[test]
fn a_stated_episode_name_wins_on_both() {
let mut file = node("s", "1", "Show - S01E01", Some(1), Some(1));
file.parsed.episode_title = Some("Mother and Children".into());
let library = Library::build(vec![file], &HashMap::new());
let episode = &library.titles[0].seasons[0].episodes[0];
assert_eq!(episode.detail(), "Mother and Children");
assert_eq!(episode.label(), "Mother and Children");
}
#[test]
fn unnumbered_seasons_sort_last() {
let nodes = vec![
node("s", "1", "Show - OVA", None, None),
node("s", "2", "Show - S01E01", Some(1), Some(1)),
];
let library = Library::build(nodes, &HashMap::new());
let seasons = &library.titles[0].seasons;
assert_eq!(seasons[0].number, Some(1));
assert_eq!(seasons[1].number, None);
assert_eq!(seasons[1].label(), "Episodes");
}
#[test]
fn a_lone_unnumbered_file_is_a_film() {
let nodes = vec![node("s", "1", "Spirited Away", None, None)];
let library = Library::build(nodes, &HashMap::new());
assert_eq!(library.titles[0].kind, TitleKind::Film);
}
#[test]
fn titles_merge_across_shares() {
let nodes = vec![
node("a", "1", "Frieren - S01E01", Some(1), Some(1)),
node("b", "2", "frieren! - S01E02", Some(1), Some(2)),
];
let library = Library::build(nodes, &HashMap::new());
assert_eq!(library.titles.len(), 1);
assert_eq!(library.titles[0].share_ids, vec!["a", "b"]);
}
#[test]
fn next_up_prefers_a_resume_over_an_unwatched_episode() {
let nodes = vec![
node("s", "1", "Show - S01E01", Some(1), Some(1)),
node("s", "2", "Show - S01E02", Some(1), Some(2)),
node("s", "3", "Show - S01E03", Some(1), Some(3)),
];
let mut states = HashMap::new();
// Episode 1 finished, episode 2 half-watched.
states.insert(("s".into(), "1".into()), watch(1400.0, 1440.0, false, 10));
states.insert(("s".into(), "2".into()), watch(700.0, 1440.0, false, 20));
let library = Library::build(nodes, &states);
let title = &library.titles[0];
assert_eq!(title.watched_count(), 1);
assert_eq!(title.next_up().unwrap().node.link_id, "2");
assert_eq!(title.resume().unwrap().resume_at(), Some(700.0));
assert_eq!(library.continue_watching().len(), 1);
}
#[test]
fn next_up_falls_through_to_the_first_unwatched() {
let nodes = vec![
node("s", "1", "Show - S01E01", Some(1), Some(1)),
node("s", "2", "Show - S01E02", Some(1), Some(2)),
];
let mut states = HashMap::new();
states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10));
let library = Library::build(nodes, &states);
assert_eq!(library.titles[0].next_up().unwrap().node.link_id, "2");
}
#[test]
fn a_barely_started_file_starts_over() {
let nodes = vec![node("s", "1", "Show - S01E01", Some(1), Some(1))];
let mut states = HashMap::new();
states.insert(("s".into(), "1".into()), watch(4.0, 1440.0, false, 10));
let library = Library::build(nodes, &states);
let episode = &library.titles[0].seasons[0].episodes[0];
assert_eq!(episode.resume_at(), None);
assert!(library.continue_watching().is_empty());
}
#[test]
fn a_nearly_finished_file_counts_as_watched() {
let nodes = vec![node("s", "1", "Show - S01E01", Some(1), Some(1))];
let mut states = HashMap::new();
states.insert(("s".into(), "1".into()), watch(1400.0, 1440.0, false, 10));
let library = Library::build(nodes, &states);
assert!(library.titles[0].seasons[0].episodes[0].is_watched());
}
#[test]
fn search_matches_filenames_as_well_as_titles() {
let mut first = node("s", "1", "Frieren - S01E01", Some(1), Some(1));
first.name = "[SubsPlease] Frieren - 01 (1080p).mkv".into();
let library = Library::build(vec![first], &HashMap::new());
assert_eq!(library.search("subsplease").len(), 1);
assert_eq!(library.search("frieren").len(), 1);
assert_eq!(library.search("bebop").len(), 0);
assert_eq!(library.search(" ").len(), 1);
}
#[test]
fn sorting_ignores_a_leading_article() {
let nodes = vec![
node("s", "1", "Zoo", None, Some(1)),
node("s", "2", "The Expanse", None, Some(1)),
node("s", "3", "Akira", None, Some(1)),
];
let library = Library::build(nodes, &HashMap::new());
let names: Vec<&str> = library.titles.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["Akira", "The Expanse", "Zoo"]);
}
#[test]
fn title_key_ignores_punctuation_and_case() {
assert_eq!(
title_key("Fullmetal Alchemist: Brotherhood"),
title_key("fullmetal alchemist brotherhood")
);
assert_eq!(title_key("K-On!!"), "k on");
}
#[test]
fn folders_are_not_episodes() {
let mut folder = node("s", "1", "Show", None, None);
folder.is_folder = true;
let library = Library::build(vec![folder], &HashMap::new());
assert!(library.is_empty());
}
}
+450
View File
@@ -0,0 +1,450 @@
//! What a metadata provider knows about a title, and whether to ask one.
//!
//! The *model* is here rather than in `pstr-meta` because the catalog stores it
//! and the UI draws it, and neither of those should have to depend on the crate
//! that talks HTTP to AniList. `pstr-meta` fills these in; everything else only
//! reads them.
//!
//! ## Enrichment is off by default, and says why
//!
//! Matching a library against a provider means sending that provider the titles
//! in it. For a library of public-domain films that is nothing; for a library of
//! anything else it is a list of what someone watches, attached to an IP
//! address, held by a third party under their retention policy and not ours.
//! That is a real cost and the viewer is the only one who can weigh it, so
//! [`MetadataConfig::enabled`] starts `false`, the UI states the cost in the
//! same breath as the switch, and nothing here is requested until it is on.
//!
//! With it off, posters fall back to Proton's own thumbnails — which for a share
//! filled by the Linux client means no posters at all, because Proton renders
//! thumbnails at upload time and that client attaches none. See
//! `docs/DEVELOPMENT.md`.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::library::TitleKind;
/// Which provider a match came from.
///
/// Stored as a string in the catalog rather than an integer: a row whose
/// provider this build does not recognise should read as "some other provider",
/// which is a re-match, not a parse failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ProviderId {
/// AniList. Anime only, no API key, no account.
#[default]
AniList,
/// The Movie Database. Film and television, needs a free API key.
Tmdb,
}
impl ProviderId {
pub const ALL: [Self; 2] = [Self::AniList, Self::Tmdb];
pub fn as_str(self) -> &'static str {
match self {
Self::AniList => "anilist",
Self::Tmdb => "tmdb",
}
}
/// What to call it in the UI.
pub fn label(self) -> &'static str {
match self {
Self::AniList => "AniList",
Self::Tmdb => "TMDB",
}
}
/// One line on what it covers and what it costs to use.
pub fn description(self) -> &'static str {
match self {
Self::AniList => "Anime, with no API key and no account.",
Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.",
}
}
/// Whether this provider cannot be used without a key.
pub fn needs_api_key(self) -> bool {
matches!(self, Self::Tmdb)
}
pub fn parse(text: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|provider| provider.as_str() == text)
}
}
/// What a provider said about one title.
///
/// Every field past the identity is optional, because every field is optional at
/// some provider: AniList has no backdrop for most shows, TMDB has no episode
/// count on a film, and a brand-new entry may be nothing but a name.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TitleMetadata {
pub provider: ProviderId,
/// The provider's own id, so a re-fetch does not re-search.
pub remote_id: String,
/// The canonical title, in the viewer's language where the provider has one.
pub name: String,
/// The title in its original language, when it differs.
pub original_name: Option<String>,
pub overview: Option<String>,
pub year: Option<u32>,
pub kind: TitleKind,
/// Portrait art, around 2:3.
pub poster_url: Option<String>,
/// Landscape art, around 16:9. Much rarer than a poster, and the reason the
/// grid cannot simply prefer one shape.
pub backdrop_url: Option<String>,
/// Out of 10, however the provider scores.
pub rating: Option<f32>,
pub genres: Vec<String>,
/// How many episodes the provider thinks there are — useful next to how
/// many the library actually holds.
pub episodes: Option<u32>,
/// The title's page, for a viewer who wants the rest of it.
pub url: Option<String>,
}
impl TitleMetadata {
/// The art to put on a 16:9 tile, most appropriate first.
///
/// A backdrop is the right shape; a poster is the wrong shape but the right
/// picture, and is drawn letterboxed rather than cropped — cropping a 2:3
/// poster to 16:9 removes most of what makes it recognisable.
pub fn tile_art(&self) -> Option<(&str, ArtShape)> {
if let Some(url) = &self.backdrop_url {
return Some((url, ArtShape::Landscape));
}
self.poster_url
.as_deref()
.map(|url| (url, ArtShape::Portrait))
}
}
/// What a provider said about one episode.
///
/// Thin on purpose. The point of this is the two lines a viewer reads before
/// picking an episode — what it is called and what happens in it — and every
/// extra field is another column to migrate for something no row shows.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EpisodeMetadata {
/// The season the provider files it under, or `None` when it numbers
/// episodes straight through — which is how AniList counts, and how most
/// anime releases name their files.
pub season: Option<u32>,
pub number: u32,
pub name: Option<String>,
pub overview: Option<String>,
/// A frame from the episode, around 16:9.
pub still_url: Option<String>,
/// As the provider writes it — `2009-10-10`. Not parsed: it is shown, not
/// sorted on, and a provider that answers `2009` should not be a parse
/// error.
pub air_date: Option<String>,
}
/// Every episode a provider listed for one title, ready to look up.
///
/// The lookup is the whole reason this is a type rather than a `Vec`: a file
/// numbered `#057` and a provider that files episodes under seasons have to
/// meet somewhere, and that reconciliation should happen in one tested place
/// rather than in each caller.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct EpisodeGuide {
/// Keyed `(season, number)`, where `None` is the provider's own absolute
/// numbering.
entries: HashMap<(Option<u32>, u32), EpisodeMetadata>,
}
impl EpisodeGuide {
pub fn new(episodes: Vec<EpisodeMetadata>) -> Self {
Self {
entries: episodes
.into_iter()
.map(|episode| ((episode.season, episode.number), episode))
.collect(),
}
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
/// What the provider says about the file numbered like this.
///
/// Tried in order: the season and number the filename states; the
/// provider's absolute numbering; and, for a file with no season at all,
/// season one. That second fallback is what makes an absolutely-numbered
/// release line up with a provider that files everything under a single
/// season — the common case for anime on TMDB.
///
/// **The absolute fallback stops at season one.** A provider that numbers
/// straight through is one that split nothing, so its episode seven is the
/// seventh of the show — which is season one's, not season two's. Answering
/// `S02E07` with it captions every episode of every later season with the
/// wrong name, and a wrong caption does not look like a bug: it looks like
/// the library is wrong. Season two upwards is answered from an entry that
/// states that season or not at all — see
/// `Provider::seasons_are_separate_entries`.
pub fn get(&self, season: Option<u32>, number: u32) -> Option<&EpisodeMetadata> {
if let Some(found) = self.entries.get(&(season, number)) {
return Some(found);
}
if matches!(season, None | Some(1))
&& let Some(found) = self.entries.get(&(None, number))
{
return Some(found);
}
season
.is_none()
.then(|| self.entries.get(&(Some(1), number)))?
}
/// Which seasons this guide actually has answers for. `None` is the
/// provider's own absolute numbering.
pub fn seasons(&self) -> std::collections::HashSet<Option<u32>> {
self.entries
.values()
.map(|episode| episode.season)
.collect()
}
/// Every entry, for storing.
pub fn episodes(&self) -> impl Iterator<Item = &EpisodeMetadata> {
self.entries.values()
}
}
/// How a piece of art should be fitted into a tile.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtShape {
/// Roughly the tile's shape: crop to fill.
Landscape,
/// Taller than the tile: fit inside it, and leave the sides.
Portrait,
}
/// What the catalog remembers about one title's metadata, including that there
/// was none.
#[derive(Debug, Clone, PartialEq)]
pub struct MetadataRecord {
/// [`crate::library::title_key`] of the title this describes.
pub title_key: String,
/// The provider that was asked. Kept even on a miss, so switching provider
/// re-asks rather than trusting the other one's silence.
pub provider: ProviderId,
/// `None` means the provider was asked and had nothing. That is a real
/// answer and it is cached: without it every grid render pays a round-trip
/// per unmatched title, which is the trap `proton-drive-linux`'s photo grid
/// hit and this one is not going to.
pub metadata: Option<TitleMetadata>,
/// Unix seconds. What makes a miss expire rather than being forever.
pub fetched_at: i64,
}
/// How long a stored answer is trusted.
///
/// A match is effectively permanent — a show does not stop being that show — but
/// artwork and ratings move, and more to the point a *miss* has to expire, or a
/// title the provider had not indexed yet stays blank for the life of the
/// install. Misses are retried far sooner than matches are refreshed.
pub const MATCH_TTL_SECS: i64 = 60 * 60 * 24 * 30;
pub const MISS_TTL_SECS: i64 = 60 * 60 * 24 * 3;
impl MetadataRecord {
/// Whether this answer is still worth using rather than asking again.
pub fn is_fresh(&self, now: i64, provider: ProviderId) -> bool {
if self.provider != provider {
return false;
}
let ttl = if self.metadata.is_some() {
MATCH_TTL_SECS
} else {
MISS_TTL_SECS
};
now.saturating_sub(self.fetched_at) < ttl
}
}
/// Whether to enrich, with what.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetadataConfig {
/// Off until the viewer turns it on. See the module note.
pub enabled: bool,
pub provider: ProviderId,
/// BCP-47-ish, as the provider wants it. Only TMDB uses it; AniList
/// answers in romaji and English and the match picks between them.
pub language: String,
}
impl Default for MetadataConfig {
fn default() -> Self {
Self {
enabled: false,
provider: ProviderId::AniList,
language: "en".into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn metadata() -> TitleMetadata {
TitleMetadata {
provider: ProviderId::AniList,
remote_id: "1".into(),
name: "Cowboy Bebop".into(),
original_name: None,
overview: None,
year: Some(1998),
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: Some(26),
url: None,
}
}
fn episode(season: Option<u32>, number: u32, name: &str) -> EpisodeMetadata {
EpisodeMetadata {
season,
number,
name: Some(name.into()),
overview: None,
still_url: None,
air_date: None,
}
}
#[test]
fn a_file_that_states_its_season_is_answered_from_that_season() {
let guide = EpisodeGuide::new(vec![
episode(Some(1), 2, "Stray Dog Strut"),
episode(Some(2), 2, "Somewhere else entirely"),
]);
assert_eq!(
guide.get(Some(1), 2).and_then(|e| e.name.clone()),
Some("Stray Dog Strut".to_string())
);
// The fallback to season one must never reach a file that said it was
// in season two — that would caption every episode with the wrong one.
assert_eq!(guide.get(Some(3), 2), None);
}
#[test]
fn an_absolutely_numbered_file_finds_an_absolutely_numbered_answer() {
let guide = EpisodeGuide::new(vec![episode(None, 57, "The Immortal Legion")]);
assert_eq!(
guide.get(None, 57).and_then(|e| e.name.clone()),
Some("The Immortal Legion".to_string())
);
// A provider that numbers straight through answers a file that states
// season *one*: the release split nothing there.
assert_eq!(guide.get(Some(1), 57).map(|e| e.number), Some(57));
}
/// **What captioned every episode of Oshi no Ko season two with season
/// one's names.** AniList files a sequel as its own entry numbered from
/// one, so its "episode 1" is season one's — and a file that says it is in
/// season two must not be answered with it.
#[test]
fn an_absolutely_numbered_answer_never_reaches_a_later_season() {
let guide = EpisodeGuide::new(vec![episode(None, 1, "Mother and Children")]);
assert_eq!(guide.get(Some(2), 1), None);
assert_eq!(guide.get(Some(3), 1), None);
// And once the season's own entry has been fetched, it answers.
let guide = EpisodeGuide::new(vec![
episode(None, 1, "Mother and Children"),
episode(Some(2), 1, "The Franchise"),
]);
assert_eq!(
guide.get(Some(2), 1).and_then(|e| e.name.clone()),
Some("The Franchise".to_string())
);
assert_eq!(
guide.get(None, 1).and_then(|e| e.name.clone()),
Some("Mother and Children".to_string())
);
}
#[test]
fn an_absolutely_numbered_file_falls_back_to_the_only_season_there_is() {
// TMDB files a 64-episode anime under one season; the release numbers
// its files `#001`–`#064`. Without this they never meet.
let guide = EpisodeGuide::new(vec![episode(Some(1), 57, "The Immortal Legion")]);
assert_eq!(guide.get(None, 57).map(|e| e.number), Some(57));
assert_eq!(guide.get(None, 99), None);
}
#[test]
fn enrichment_is_off_until_it_is_asked_for() {
assert!(!MetadataConfig::default().enabled);
}
#[test]
fn provider_names_round_trip_through_the_catalog_column() {
for provider in ProviderId::ALL {
assert_eq!(ProviderId::parse(provider.as_str()), Some(provider));
}
assert_eq!(ProviderId::parse("letterboxd"), None);
}
/// A backdrop is the tile's shape and wins; a poster still gets used, but
/// says it needs fitting rather than cropping.
#[test]
fn tile_art_prefers_the_landscape_picture() {
let mut data = metadata();
data.poster_url = Some("poster".into());
assert_eq!(data.tile_art(), Some(("poster", ArtShape::Portrait)));
data.backdrop_url = Some("backdrop".into());
assert_eq!(data.tile_art(), Some(("backdrop", ArtShape::Landscape)));
}
/// A miss goes stale far sooner than a match: a show the provider had not
/// indexed yet must not stay blank forever.
#[test]
fn a_miss_expires_sooner_than_a_match() {
let now = 1_000_000_000;
let miss = MetadataRecord {
title_key: "k".into(),
provider: ProviderId::AniList,
metadata: None,
fetched_at: now - MISS_TTL_SECS - 1,
};
assert!(!miss.is_fresh(now, ProviderId::AniList));
let matched = MetadataRecord {
metadata: Some(metadata()),
..miss.clone()
};
assert!(matched.is_fresh(now, ProviderId::AniList));
}
/// Switching provider must re-ask rather than trust the other one's answer —
/// especially its silence.
#[test]
fn a_record_from_another_provider_is_never_fresh() {
let record = MetadataRecord {
title_key: "k".into(),
provider: ProviderId::AniList,
metadata: Some(metadata()),
fetched_at: 1_000_000_000,
};
assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb));
}
}
File diff suppressed because it is too large. Load diff
+161
View File
@@ -0,0 +1,161 @@
//! Playback preferences that outlive one file.
//!
//! Volume, mute, and which language of audio and subtitles to prefer. All four
//! are things a viewer sets once and expects to still hold for the next
//! episode, the next film and the next launch — a player that starts every file
//! at full volume in the container's default language is one that has to be
//! corrected every time.
//!
//! Stored beside the share list and written the same way: atomically, and never
//! overwritten when it will not parse. Losing this file costs nothing more than
//! a re-set, so unlike `shares.json` an unreadable one is reported and *stepped
//! over* rather than being allowed to stop anything.
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
/// The loudest this app plays. Matches `pstr_player::MAX_VOLUME`, which is not
/// referenced here because `pstr-core` does not depend on the player.
const MAX_VOLUME: f64 = 100.0;
/// How playback should sound, and in which language.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
// A file written by an older version, or hand-edited, is missing fields rather
// than invalid: every one of them has a sensible default.
#[serde(default)]
pub struct PlaybackPrefs {
/// 0–100.
pub volume: f64,
pub muted: bool,
/// The language tag of the audio track to prefer — "jpn", "eng". `None`
/// leaves the choice to the container's default.
pub audio_language: Option<String>,
pub subtitle_language: Option<String>,
/// Whether subtitles are wanted at all.
///
/// Separate from the language: "off" is a decision, and an absent language
/// cannot express it — that means "no preference", which is what a viewer
/// who has never touched the menu has.
pub subtitles: bool,
/// Whether reaching the end of an episode starts the next one.
///
/// On by default, because that is what every service this is modelled on
/// does and what a season of anything is watched like. Off is one checkbox
/// away for someone who falls asleep to it.
pub autoplay_next: bool,
}
impl Default for PlaybackPrefs {
fn default() -> Self {
Self {
volume: MAX_VOLUME,
muted: false,
audio_language: None,
subtitle_language: None,
subtitles: true,
autoplay_next: true,
}
}
}
impl PlaybackPrefs {
/// The same preferences with anything out of range brought back into it.
///
/// A hand-edited or older file can hold a volume of 900 or of NaN, and both
/// would otherwise reach mpv.
pub fn sanitized(mut self) -> Self {
if !self.volume.is_finite() {
self.volume = MAX_VOLUME;
}
self.volume = self.volume.clamp(0.0, MAX_VOLUME);
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
self
}
}
fn prefs_file(dirs: &AppDirs) -> std::path::PathBuf {
dirs.config.join("playback.json")
}
/// The stored preferences, or the defaults on a first run.
pub fn load(dirs: &AppDirs) -> Result<PlaybackPrefs> {
Ok(read_json::<PlaybackPrefs>(&prefs_file(dirs))?
.unwrap_or_default()
.sanitized())
}
/// Write the preferences.
pub fn save(dirs: &AppDirs, prefs: &PlaybackPrefs) -> Result<()> {
write_json(&prefs_file(dirs), prefs)
}
#[cfg(test)]
mod tests {
use super::*;
fn dirs(root: &std::path::Path) -> AppDirs {
AppDirs {
config: root.to_path_buf(),
data: root.to_path_buf(),
cache: root.to_path_buf(),
}
}
#[test]
fn a_first_run_gets_full_volume_and_no_language_preference() {
let prefs = PlaybackPrefs::default();
assert_eq!(prefs.volume, MAX_VOLUME);
assert!(!prefs.muted);
assert!(prefs.subtitles);
assert_eq!(prefs.audio_language, None);
}
#[test]
fn what_was_saved_is_what_loads() {
let temp = std::env::temp_dir().join(format!("pstr-prefs-{}", std::process::id()));
std::fs::create_dir_all(&temp).unwrap();
let dirs = dirs(&temp);
let prefs = PlaybackPrefs {
volume: 42.0,
muted: true,
audio_language: Some("jpn".into()),
subtitle_language: Some("eng".into()),
subtitles: true,
autoplay_next: false,
};
save(&dirs, &prefs).unwrap();
assert_eq!(load(&dirs).unwrap(), prefs);
std::fs::remove_dir_all(&temp).ok();
}
#[test]
fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() {
let prefs = PlaybackPrefs {
volume: 900.0,
..PlaybackPrefs::default()
}
.sanitized();
assert_eq!(prefs.volume, MAX_VOLUME);
let prefs = PlaybackPrefs {
volume: f64::NAN,
..PlaybackPrefs::default()
}
.sanitized();
// NaN clamps to NaN, which reaches mpv as a volume of nothing at all.
assert_eq!(prefs.volume, MAX_VOLUME);
}
#[test]
fn a_language_set_to_blank_is_no_preference_rather_than_a_language_named_nothing() {
let prefs = PlaybackPrefs {
audio_language: Some(" ".into()),
..PlaybackPrefs::default()
}
.sanitized();
assert_eq!(prefs.audio_language, None);
}
}
+333
View File
@@ -0,0 +1,333 @@
//! The links this app knows about, and opening them.
//!
//! ## Where the secrets live
//!
//! A Proton public-link URL carries its decryption password in its **fragment**
//! (`https://drive.proton.me/urls/TOKEN#s3cr3t`), and a custom-password link
//! needs a second secret on top. Both are, in full, the ability to read the
//! share — so neither goes in the config file. The config holds the share's id,
//! its display name and its token; the URL and custom password go in the OS
//! credential store (Secret Service on Linux, Credential Manager on Windows).
//!
//! ## Why several shares
//!
//! A library is not one folder. [`SharedLibrary`] opens every configured share
//! and merges them into one catalog, so the app has a single browsable view
//! regardless of how many links it was given.
use std::collections::BTreeMap;
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient};
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result};
/// The credential-store service name. One entry per share, keyed by share id.
const KEYRING_SERVICE: &str = "proton-stream";
/// What the app records about a share, minus its secrets.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Share {
/// Stable id, used as the credential-store key and the catalog's share
/// column. Assigned once and never reused.
pub id: String,
/// What to call this share in the UI.
pub name: String,
/// The link token — the path segment of the share URL. Not a secret on its
/// own: without the fragment password it decrypts nothing.
pub token: String,
/// Whether opening this share needs a custom password in addition to the
/// URL fragment. Recorded so the UI knows to prompt before the handshake
/// rather than after it fails.
#[serde(default)]
pub has_custom_password: bool,
}
/// The secrets for one share, as held in the credential store.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ShareSecrets {
/// The full share URL, fragment included.
url: String,
/// The custom password, when the link has one.
#[serde(default)]
custom_password: Option<String>,
}
/// The configured shares, and their secrets.
pub struct ShareStore {
dirs: AppDirs,
}
impl ShareStore {
pub fn new(dirs: AppDirs) -> Self {
Self { dirs }
}
/// Every configured share, in the order they were added.
pub fn list(&self) -> Result<Vec<Share>> {
Ok(read_json(&self.dirs.shares_file())?.unwrap_or_default())
}
/// Record a share and stash its secrets.
///
/// The secrets are written **before** the config entry: a credential-store
/// failure then leaves no config row pointing at credentials that do not
/// exist, which would read as a share that exists but can never be opened.
pub fn add(&self, name: &str, url: &str, custom_password: Option<&str>) -> Result<Share> {
let token = token_from_url(url)?;
let id = format!("share-{token}");
let mut shares = self.list()?;
if shares.iter().any(|share| share.id == id) {
return Err(Error::Config(format!(
"a share for token {token} is already configured"
)));
}
let secrets = ShareSecrets {
url: url.to_string(),
custom_password: custom_password
.filter(|password| !password.is_empty())
.map(str::to_string),
};
self.store_secrets(&id, &secrets)?;
let share = Share {
id,
name: name.to_string(),
token,
has_custom_password: secrets.custom_password.is_some(),
};
shares.push(share.clone());
write_json(&self.dirs.shares_file(), &shares)?;
Ok(share)
}
/// Forget a share and its secrets.
///
/// The config entry goes first here, for the mirror-image reason: if the
/// credential deletion fails, what is left is an orphaned secret rather than
/// a listed share whose password is gone.
pub fn remove(&self, id: &str) -> Result<()> {
let mut shares = self.list()?;
let before = shares.len();
shares.retain(|share| share.id != id);
if shares.len() == before {
return Err(Error::NotFound(format!("no share with id {id}")));
}
write_json(&self.dirs.shares_file(), &shares)?;
// A missing entry is the desired end state, not a failure.
match self.entry(id)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Open a share as a visitor.
pub async fn open(&self, share: &Share) -> Result<ProtonDrivePublicLinkClient> {
let secrets = self.load_secrets(&share.id)?;
let client = ProtonDrivePublicLinkClient::open(
client_configuration(),
&secrets.url,
secrets.custom_password.as_deref(),
)
.await?
// The SDK default is sized for a background sync daemon. A player that
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
// ceiling, which is unremarkable for a desktop app.
.with_max_inflight_blocks(48);
Ok(client)
}
fn entry(&self, id: &str) -> Result<keyring::Entry> {
Ok(keyring::Entry::new(KEYRING_SERVICE, id)?)
}
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
let encoded = serde_json::to_string(secrets)
.map_err(|e| Error::Config(format!("serialize share secrets: {e}")))?;
self.entry(id)?.set_password(&encoded)?;
Ok(())
}
fn load_secrets(&self, id: &str) -> Result<ShareSecrets> {
let encoded = match self.entry(id)?.get_password() {
Ok(encoded) => encoded,
Err(keyring::Error::NoEntry) => {
return Err(Error::NotFound(format!(
"share {id} has no stored credentials; remove and re-add it"
)));
}
Err(e) => return Err(e.into()),
};
serde_json::from_str(&encoded)
.map_err(|e| Error::Config(format!("stored share secrets are unreadable: {e}")))
}
}
/// Several opened shares, presented as one library.
pub struct SharedLibrary {
clients: BTreeMap<String, ProtonDrivePublicLinkClient>,
}
impl SharedLibrary {
/// Open every configured share.
///
/// A share that fails to open is reported and skipped rather than failing
/// the whole library — one revoked link should not make the other three
/// unwatchable.
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> {
let mut clients = BTreeMap::new();
let mut failures = Vec::new();
for share in store.list()? {
match store.open(&share).await {
Ok(client) => {
clients.insert(share.id.clone(), client);
}
Err(e) => failures.push((share, e)),
}
}
Ok((Self { clients }, failures))
}
/// The client for one share.
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> {
self.clients.get(share_id)
}
/// Every opened share id.
pub fn share_ids(&self) -> impl Iterator<Item = &str> {
self.clients.keys().map(String::as_str)
}
/// Walk one share's whole subtree, depth first, yielding every node.
///
/// There is no recursion helper on the visitor path — the authenticated
/// client's `get_node_hierarchy` / `get_node_by_path` have no counterpart
/// there — so the walk is here.
pub async fn crawl(&self, share_id: &str) -> Result<Vec<Node>> {
let client = self
.client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
let root = client.get_root_node().await?;
let mut found = Vec::new();
let mut folders = vec![root.uid.clone()];
found.push(root);
while let Some(folder) = folders.pop() {
let child_uids: Vec<NodeUid> =
client.enumerate_folder_children_node_uids(&folder).await?;
if child_uids.is_empty() {
continue;
}
// Chunked and fanned out inside the SDK, and the node keys it
// unlocks along the way are cached there, so a deep tree does not
// re-derive the same ancestors per child.
let children = client.enumerate_nodes(&child_uids).await?;
for child in children {
if child.is_folder() {
folders.push(child.uid.clone());
}
found.push(child);
}
}
Ok(found)
}
}
/// The app-version string Proton identifies this client by.
///
/// The `external-drive-` prefix is **not** decoration: the API parses the part
/// before the first `-` as a platform and rejects anything it does not know
/// with `400 Platform \`…\` is not valid`. A bare `proton-stream@0.1.0` fails
/// every request, including the public-link handshake.
const APP_VERSION: &str = concat!("external-drive-stream@", env!("CARGO_PKG_VERSION"));
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
/// The API configuration every visitor client is built from.
fn client_configuration() -> ProtonClientConfiguration {
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
}
/// The token out of a share URL: the path segment after `/urls/`.
///
/// Parsed here as well as in the SDK because the store needs a stable id for a
/// share *before* it has opened it — and the id must not be derived from the
/// password, which would put a secret in the config file by the back door.
fn token_from_url(url: &str) -> Result<String> {
let (_, tail) = url
.split_once("/urls/")
.ok_or_else(|| Error::Config(format!("{url} is not a Proton share URL")))?;
let token = tail
.split(['#', '?'])
.next()
.unwrap_or_default()
.trim_end_matches('/');
if token.is_empty() {
return Err(Error::Config(format!("{url} carries no share token")));
}
if !url.contains('#') {
return Err(Error::Config(format!(
"{url} has no #password fragment; copy the full share link"
)));
}
Ok(token.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_share_url_yields_its_token() {
assert_eq!(
token_from_url("https://drive.proton.me/urls/ABC123#s3cr3t").unwrap(),
"ABC123"
);
}
#[test]
fn a_trailing_slash_before_the_fragment_is_tolerated() {
assert_eq!(
token_from_url("https://drive.proton.me/urls/ABC123/#s3cr3t").unwrap(),
"ABC123"
);
}
/// The fragment is the password. A URL without one cannot open anything, and
/// saying so at add time beats a confusing failure at open time.
#[test]
fn a_url_without_a_fragment_is_refused_at_add_time() {
let error = token_from_url("https://drive.proton.me/urls/ABC123").unwrap_err();
assert!(
error.to_string().contains("#password fragment"),
"says what is missing: {error}"
);
}
#[test]
fn a_url_that_is_not_a_share_link_is_refused() {
assert!(token_from_url("https://example.com/thing#x").is_err());
assert!(token_from_url("https://drive.proton.me/urls/#s3cr3t").is_err());
}
/// The id must be derivable from the token alone — deriving it from the URL
/// would mix a secret into the config file.
#[test]
fn the_share_id_carries_no_secret() {
let token = token_from_url("https://drive.proton.me/urls/ABC123#s3cr3t").unwrap();
let id = format!("share-{token}");
assert!(!id.contains("s3cr3t"), "id must not embed the password");
}
}