mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
init
This commit is contained in:
commit
a1b345a029
80 files changed
+28033
No files matched your search
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user