//! Everything the UI is not allowed to do on its own thread. //! //! egui redraws at the display's rate; opening a share takes seconds, a crawl //! takes minutes, and a thumbnail is a network round-trip plus a PGP decrypt. //! So the UI holds an [`Engine`], calls a method that returns immediately, and //! learns what happened from an [`Event`] on a channel. Nothing here blocks, and //! nothing here draws. //! //! ```text //! ui thread tokio runtime worker threads //! ───────── ───────────── ────────────── //! engine.crawl() ──spawn──▶ SharedLibrary::crawl ──▶ block decrypt //! ▲ │ //! └──── Event::LibraryLoaded ◀────┘ + ctx.request_repaint() //! ``` //! //! The repaint call is the part that is easy to forget: without it an event //! lands in the channel and sits there until the viewer happens to move the //! mouse. use std::collections::{HashMap, HashSet, VecDeque}; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; use parking_lot::Mutex; use pstr_core::appearance::Appearance; use pstr_core::catalog::{ Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, }; use pstr_core::config::AppDirs; use pstr_core::library::{Library, Title, TitleKind}; use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId}; use pstr_core::prefs::PlaybackPrefs; use pstr_core::proton_drive_rs::ThumbnailType; use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use pstr_core::{Share, ShareStore, SharedLibrary}; use pstr_meta::MetadataService; use pstr_stream::{ BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig, StreamSource, VideoStream, }; use tokio::runtime::Runtime; use crate::playback::PlaybackTarget; /// Stable identity for an episode download. No share secret is included. #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct DownloadKey { pub share_id: String, pub link_id: String, } impl From<&PlaybackTarget> for DownloadKey { fn from(target: &PlaybackTarget) -> Self { Self { share_id: target.share_id.clone(), link_id: target.link_id.clone(), } } } /// A download state suitable for presenting directly to the viewer. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DownloadState { Queued, Running, Paused, Completed, Failed(String), /// Work stopped, but its complete-block partial is retained for Resume. Cancelled, } /// Current, inspectable state for one episode in a download batch. #[derive(Debug, Clone)] pub struct DownloadItem { pub key: DownloadKey, pub target: PlaybackTarget, pub state: DownloadState, pub downloaded: u64, pub total: u64, /// Bytes a second, smoothed. Only meaningful while it is running. pub rate: f64, } impl DownloadItem { /// Seconds until done at the current rate, while that is knowable. pub fn eta(&self) -> Option { (self.state == DownloadState::Running && self.rate > 0.0 && self.total > self.downloaded) .then(|| (self.total - self.downloaded) as f64 / self.rate) } pub fn percent(&self) -> f32 { if self.total == 0 { 0.0 } else { (self.downloaded as f64 / self.total as f64).clamp(0.0, 1.0) as f32 } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum DownloadCommand { Run, Pause, Cancel, } struct DownloadControl { command: tokio::sync::watch::Sender, } impl DownloadControl { fn new() -> Self { let (command, _) = tokio::sync::watch::channel(DownloadCommand::Run); Self { command } } fn set(&self, command: DownloadCommand) { self.command.send_replace(command); } fn subscribe(&self) -> tokio::sync::watch::Receiver { self.command.subscribe() } } struct DownloadJob { control: Arc, handle: tokio::task::JoinHandle<()>, live: Arc, } #[derive(Clone, Copy)] enum DownloadEnd { Completed, Cancelled, } #[derive(Default)] struct Downloads { items: HashMap, jobs: HashMap, } #[derive(serde::Serialize, serde::Deserialize)] struct PartialMarker { revision_id: String, block_sizes: Vec, #[serde(default)] target: Option, } /// Poster thumbnails are decoded to at most this edge before they become a /// texture. Proton's preview is larger than any tile this UI draws, and the /// difference is VRAM held for every title in the library. const THUMBNAIL_MAX_EDGE: u32 = 640; /// What the background side tells the UI. pub enum Event { /// The share list changed on disk. Shares(Vec), /// The share the form asked for is stored; its crawl is starting. ShareAdded(String), /// The share the form asked for was refused, with why. Nothing was crawled. ShareRejected(String), /// Shares opened; playback is possible from here. Connected { /// Shares that did not open, with why. failures: Vec<(String, String)>, }, /// Shares could not be opened at all. ConnectFailed(String), /// The catalog was re-read. LibraryLoaded(Library), /// A crawl finished for one share. Crawled { share_id: String, nodes: usize, files: usize, seconds: f64, }, /// A crawl is under way: which share, and how many nodes so far. CrawlProgress { share: String, found: usize }, /// Every requested crawl is done. CrawlFinished, /// That share's crawl was stopped before it stored anything. CrawlStopped { share_id: String }, /// A decoded Proton thumbnail, keyed as [`thumbnail_key`]. Thumbnail { key: String, image: egui::ColorImage, }, /// No thumbnail for this file — remembered, so the grid stops asking. ThumbnailMissing { key: String }, /// Everything the catalog knows about titles, keyed by title key. Metadata(HashMap), /// What the provider said about individual episodes, keyed by title key. EpisodeMetadata(HashMap), /// The enrichment settings changed, on disk. MetadataConfig(MetadataConfig), /// A matching run finished, with how it went. Matched { matched: usize, unmatched: usize, failed: usize, }, /// Every requested match is done. MatchFinished, /// What the provider answered a hand search with, unscored and in its own /// order. `title_key` says which title asked, because the viewer can close /// the search and open another one while a request is still out. MatchOptions { title_key: String, options: Vec, }, /// A hand search did not get an answer. Distinct from an empty one: nothing /// found is a result, and a rate limit is not. MatchSearchFailed { title_key: String, error: String }, /// A decoded piece of provider artwork, keyed by title key. Poster { key: String, image: egui::ColorImage, }, /// The artwork for this title could not be had. PosterMissing { key: String }, /// A stream opened and is ready to hand to mpv. PlaybackReady { target: Box, stream: VideoStream, }, /// mpv said something about what is playing. `id` is the player it came /// from — a player being replaced goes on talking for a moment after the /// next one has started. Player { id: u64, event: pstr_player::PlayerEvent, }, /// That player is gone: its window was closed, or playback ended. PlayerStopped { id: u64 }, /// One file's watch state was saved, for the library to patch in place. Watched { share_id: String, link_id: String, state: WatchState, }, /// Something the viewer should see, phrased for them. Error(String), /// Something the viewer might like to see, briefly. Status(String), /// Full download-manager snapshot. Replacing rather than patching avoids /// stale rows when a task is removed. Downloads(Vec), /// One running download moved on. Sent instead of a snapshot for byte /// progress, which arrives several times a second per download, while the /// list it would copy and sort holds every file ever kept offline. DownloadProgress(Box), /// Completed local copies, for badges and online-only actions. OfflineFiles(HashSet), } /// The share clients and the block layer, once they exist. struct Connection { library: Arc, source: StreamSource, } /// The background half of the app. /// /// Cheap to clone: everything inside is shared. The UI keeps one, the tasks it /// spawns keep clones. #[derive(Clone)] pub struct Engine { runtime: Arc, dirs: AppDirs, store: Arc, /// One connection, guarded rather than owned by the UI, because a task that /// finishes long after the click that started it still needs it. connection: Arc>>, /// Held across the handshake, which a `parking_lot` guard cannot be. /// /// Without it the first frame is a stampede: `connect`, the click that /// wanted a stream and every visible poster all find no connection and open /// every share at once. One winner, everyone else waits and takes its /// result. connecting: Arc>, /// `rusqlite::Connection` is `Send` but not `Sync`, and both the UI (watch /// state) and crawls (rows) write. One lock, held for the length of a /// statement. catalog: Arc>, /// Thumbnail fetches in flight. A fast scroll can ask for a hundred, and /// each is a round-trip plus a decrypt — unbounded, they crowd out the reads /// the player is blocked on. thumbnails: Arc, /// Provider lookups in flight. Far tighter than the thumbnail limit, and /// not for our benefit: both providers rate-limit by the minute, and a /// library of three hundred titles fired off at once earns a 429 for the /// whole run rather than for the tail of it. lookups: Arc, /// The enrichment settings and the provider they resolve to. Rebuilt /// whenever the settings change, so nothing holds a client for a provider /// the viewer has switched away from. metadata: Arc>, /// Volume, mute and language preferences. Held here rather than in the UI /// because a new player is built from them, and the player is started from /// a background event. prefs: Arc>, /// Which theme the window wears. Kept beside the playback preferences for /// the same reason: it is read from the UI and written to disk off it. appearance: Arc>, /// Preloaded so clicking Play never runs SQLite on egui's thread. track_prefs: Arc>>, downloads: Arc>, download_permits: Arc, /// Whether the player page is on screen. See [`Engine::wake`]. picture_shown: Arc, /// Bumped to stop the crawls in flight; each watches it from its start. crawl_cancel: Arc>, events: Sender, ctx: egui::Context, } /// How many posters may be in flight at once. const THUMBNAIL_CONCURRENCY: usize = 6; /// How many provider lookups may be in flight at once. See [`Engine::lookups`]. const LOOKUP_CONCURRENCY: usize = 2; const DOWNLOAD_CONCURRENCY: usize = 3; /// How many blocks of one download are fetched at once. Four is what the /// stream layer itself allows a single read, for the same reason: enough to /// hide a round trip, few enough that three downloads do not swamp a player. const DOWNLOAD_BLOCKS_IN_FLIGHT: usize = 4; /// Enrichment as currently configured. struct Enrichment { config: MetadataConfig, /// `None` when enrichment is off, or when the provider needs an API key /// that is not stored. Both are ordinary states, not errors: the difference /// only matters when the viewer asks for a match, which is where it is /// reported. service: Option, } impl Enrichment { /// Resolve a service for `config`, if one can be had. fn build(config: MetadataConfig) -> Self { let service = config.enabled.then(|| { let key = pstr_meta::settings::api_key(config.provider); MetadataService::new(&config, key) .inspect_err(|error| tracing::debug!("metadata provider unavailable: {error}")) .ok() }); Self { config, service: service.flatten(), } } } impl Engine { /// Build the engine and the channel the UI drains. pub fn new( runtime: Arc, dirs: AppDirs, ctx: egui::Context, ) -> anyhow::Result<(Self, Receiver)> { let catalog = Catalog::open(&dirs.catalog_db())?; let (events, receiver) = channel(); // An unreadable settings file must not stop the app starting: the // library is the point and enrichment is decoration. It is reported and // treated as "off", which is also the safe reading — never as "on". let config = pstr_meta::settings::load(&dirs).unwrap_or_else(|error| { tracing::warn!("read the metadata settings: {error}"); MetadataConfig::default() }); // Same reading as the metadata settings above: a preferences file that // will not parse is worth a line in the log and nothing more. It is // volume and a language, and the defaults play the film. let prefs = pstr_core::prefs::load(&dirs).unwrap_or_else(|error| { tracing::warn!("read the playback preferences: {error}"); PlaybackPrefs::default() }); // And the same again for the theme, which is the least load-bearing // file of the three: an unreadable one costs the viewer their colours // until they pick them again. let appearance = pstr_core::appearance::load(&dirs).unwrap_or_else(|error| { tracing::warn!("read the appearance: {error}"); Appearance::default() }); let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| { tracing::warn!("read show track preferences: {error}"); HashMap::new() }); let engine = Self { runtime, store: Arc::new(ShareStore::new(dirs.clone())), dirs, connection: Arc::new(Mutex::new(None)), connecting: Arc::new(tokio::sync::Mutex::new(())), catalog: Arc::new(Mutex::new(catalog)), thumbnails: Arc::new(tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY)), lookups: Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)), metadata: Arc::new(Mutex::new(Enrichment::build(config))), prefs: Arc::new(Mutex::new(prefs)), appearance: Arc::new(Mutex::new(appearance)), track_prefs: Arc::new(Mutex::new(track_prefs)), downloads: Arc::new(Mutex::new(Downloads::default())), download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)), picture_shown: Arc::new(AtomicBool::new(false)), crawl_cancel: Arc::new(tokio::sync::watch::channel(0).0), events, ctx, }; Ok((engine, receiver)) } pub fn runtime(&self) -> &Arc { &self.runtime } /// The playback preferences as they stand. pub fn playback_prefs(&self) -> PlaybackPrefs { self.prefs.lock().clone() } /// Change the preferences, and write them if asked. /// /// `commit` is what keeps a dragged volume slider from being a file write /// per frame: every change is kept in memory, so a player started mid-drag /// is built from the current value, and the disk is touched when the drag /// ends. Losing the last few points of a drag to a crash costs nothing. pub fn set_playback_prefs(&self, prefs: PlaybackPrefs, commit: bool) { let prefs = prefs.sanitized(); *self.prefs.lock() = prefs.clone(); if !commit { return; } let engine = self.clone(); self.runtime.spawn_blocking(move || { if let Err(error) = pstr_core::prefs::save(&engine.dirs, &prefs) { // Not `fail`: the viewer asked to change the volume, not to // save a file, and the change did happen. Telling them their // config directory is unwritable in a red line under the film // helps nobody mid-episode. tracing::warn!("save the playback preferences: {error}"); } }); } /// Which theme the window wears. pub fn appearance(&self) -> Appearance { *self.appearance.lock() } /// Change the theme and write it. /// /// Always committed, unlike the volume: a theme is picked with a click and /// there is no drag to coalesce. pub fn set_appearance(&self, appearance: Appearance) { *self.appearance.lock() = appearance; let engine = self.clone(); self.runtime.spawn_blocking(move || { if let Err(error) = pstr_core::appearance::save(&engine.dirs, &appearance) { // Not `fail`, for the same reason the playback preferences are // not: the change happened, and only the remembering did not. tracing::warn!("save the appearance: {error}"); } }); } /// Send an event and wake the UI. A closed channel means the window is /// gone, which is not an error worth propagating anywhere. pub(crate) fn emit(&self, event: Event) { // The two that arrive on mpv's clock rather than on anything happening. let ticking = matches!( event, Event::Player { event: pstr_player::PlayerEvent::Position(_) | pstr_player::PlayerEvent::Buffered(_), .. } ); if self.events.send(event).is_ok() { if ticking { self.wake(); } else { self.ctx.request_repaint(); } } } /// Say whether the player page is the one on screen. pub fn set_picture_shown(&self, shown: bool) { self.picture_shown.store(shown, Ordering::Relaxed); } /// Ask for a frame because playback moved on. /// /// mpv reports the position and offers a new picture once per video frame. /// On the player page that is exactly the rate to draw at. Anywhere else /// the film is a line of text and a bar in the transport, and redrawing a /// whole library page at the film's frame rate to move them is most of the /// app's CPU while something plays in the background. Four times a second /// keeps the clock honest. pub fn wake(&self) { /// How often the transport bar moves while the picture is hidden. const BACKGROUND_TICK: std::time::Duration = std::time::Duration::from_millis(250); if self.picture_shown.load(Ordering::Relaxed) { self.ctx.request_repaint(); } else { self.ctx.request_repaint_after(BACKGROUND_TICK); } } fn fail(&self, context: &str, error: impl std::fmt::Display) { tracing::warn!("{context}: {error}"); self.emit(Event::Error(format!("{context}: {error}"))); } // ---------------------------------------------------------------- shares /// Re-read the share list. pub fn load_shares(&self) { match self.store.list() { Ok(shares) => self.emit(Event::Shares(shares)), Err(error) => self.fail("read the share list", error), } } /// Add a share, then open and crawl it — which is what "add" means to /// someone who just pasted a link. pub fn add_share(&self, name: String, url: String, password: Option) { let engine = self.clone(); self.runtime.spawn(async move { let share = match engine.store.add(&name, &url, password.as_deref()) { Ok(share) => share, Err(error) => return engine.emit(Event::ShareRejected(error.to_string())), }; engine.emit(Event::ShareAdded(share.name.clone())); engine.load_shares(); engine.connect_and_crawl(Some(share.id)).await; }); } /// Forget a share and its stored secrets. pub fn remove_share(&self, id: String) { let engine = self.clone(); self.runtime.spawn(async move { let keys: Vec<_> = engine .downloads .lock() .jobs .keys() .filter(|key| key.share_id == id) .cloned() .collect(); for key in &keys { engine.cancel_and_join(key).await; } let offline = match engine.catalog.lock().offline_files_for_share(&id) { Ok(files) => files, Err(error) => return engine.fail("inspect that share's downloads", error), }; for (link_id, file) in &offline { let path = engine.dirs.offline_file(&id, link_id, &file.revision_id); if let Err(error) = remove_if_present(&path).await { return engine.fail("delete that share's offline bytes", error); } } let item_keys: Vec<_> = engine .downloads .lock() .items .keys() .filter(|key| key.share_id == id) .cloned() .collect(); for key in &item_keys { let (partial, marker) = engine.partial_paths(key); if let Err(error) = remove_if_present(&partial).await { return engine.fail("delete that share's partial download", error); } if let Err(error) = remove_if_present(&marker).await { return engine.fail("delete that share's partial marker", error); } } if let Err(error) = engine.catalog.lock().remove_share(&id) { return engine.fail("drop that share's catalog rows", error); } if let Err(error) = engine.store.remove(&id) { return engine.fail("remove that share", error); } { let mut downloads = engine.downloads.lock(); downloads.items.retain(|key, _| key.share_id != id); downloads.jobs.retain(|key, _| key.share_id != id); } engine.emit_downloads(); engine.load_shares(); // Its clients are stale now; the next action reopens what is left. engine.connection.lock().take(); engine.load_library(); engine.emit(Event::Status("removed".into())); }); } // ------------------------------------------------------------ connection /// The open connection, if there is one. Never held across an await — the /// guard is `parking_lot`'s. fn opened(&self) -> Option<(Arc, StreamSource)> { let connection = self.connection.lock(); let connection = connection.as_ref()?; Some((Arc::clone(&connection.library), connection.source.clone())) } /// Open every configured share and build the block layer. /// /// Idempotent: a second call while one connection exists is a no-op, so the /// UI can call it whenever it needs the network without tracking whether it /// already has it. pub fn connect(&self) { if self.opened().is_some() { return; } let engine = self.clone(); self.runtime.spawn(async move { engine.ensure_connected().await; }); } /// Open the shares if they are not open. Returns the pieces playback needs. async fn ensure_connected(&self) -> Option<(Arc, StreamSource)> { if let Some(opened) = self.opened() { return Some(opened); } // One handshake at a time, and re-check after waiting: whoever was // ahead in the queue has almost certainly just done it. let _connecting = self.connecting.lock().await; if let Some(opened) = self.opened() { return Some(opened); } let (library, failures) = match SharedLibrary::open_all(&self.store).await { Ok(opened) => opened, Err(error) => { self.emit(Event::ConnectFailed(error.to_string())); return None; } }; let library = Arc::new(library); let opener = Arc::new(LibraryOpener::new(Arc::clone(&library))); let config = StreamConfig::default() // mpv brings its own read-ahead; a second one under it only competes // for the bandwidth mpv is blocked on. See `pstr_player::READAHEAD_BLOCKS`. .with_readahead(pstr_player::READAHEAD_BLOCKS) .with_disk_cache(DiskCacheConfig::new(self.dirs.block_cache())); let source = match StreamSource::new(opener, config).await { Ok(source) => source, Err(error) => { self.emit(Event::ConnectFailed(error.to_string())); return None; } }; *self.connection.lock() = Some(Connection { library: Arc::clone(&library), source: source.clone(), }); self.emit(Event::Connected { failures: failures .into_iter() .map(|(share, error)| (share.name, error.to_string())) .collect(), }); Some((library, source)) } // ------------------------------------------------------------- catalogue /// Re-read the catalog into a [`Library`]. pub fn load_library(&self) { let engine = self.clone(); self.runtime.spawn_blocking(move || { let result = { let catalog = engine.catalog.lock(); catalog .all_files() .and_then(|files| Ok((files, catalog.all_watch_states()?))) }; match result { Ok((files, watch)) => { let library = Library::build(files, &watch); let targets = download_targets(&library); engine.emit(Event::LibraryLoaded(library)); engine.refresh_offline(Some(targets)); } Err(error) => engine.fail("read the catalog", error), } }); } /// Refresh the lightweight offline index used by title badges and actions. pub fn load_offline_files(&self) { let engine = self.clone(); self.runtime .spawn_blocking(move || engine.refresh_offline(None)); } /// The offline index, against `targets` when the caller has just built a /// library to take them from — otherwise one is built here, which on a /// large catalog is most of what this costs. fn refresh_offline(&self, targets: Option>) { let result = (|| { let catalog = self.catalog.lock(); let files = catalog.all_offline_files()?; let targets = match targets { Some(targets) => targets, None => download_targets(&Library::build( catalog.all_files()?, &catalog.all_watch_states()?, )), }; let mut valid = HashSet::new(); let mut hydrated = Vec::new(); for ((share_id, link_id), file) in files { let key = DownloadKey { share_id, link_id }; let path = self .dirs .offline_file(&key.share_id, &key.link_id, &file.revision_id); let Some(expected) = file .block_sizes .iter() .try_fold(0_u64, |sum, size| sum.checked_add(*size)) else { catalog.remove_offline_file(&key.share_id, &key.link_id)?; continue; }; if std::fs::metadata(path).is_ok_and(|metadata| metadata.len() == expected) { valid.insert(key.clone()); if let Some(target) = targets.get(&key).cloned() { hydrated.push(DownloadItem { key, target, state: DownloadState::Completed, downloaded: expected, total: expected, rate: 0.0, }); } } else { // The catalog is an index, not the bytes. A manually // removed or truncated file must stop wearing an // offline badge and remain playable from the share. catalog.remove_offline_file(&key.share_id, &key.link_id)?; } } Ok::<_, pstr_core::Error>((valid, hydrated)) })(); match result { Ok((files, hydrated)) => { { let mut downloads = self.downloads.lock(); for item in hydrated { downloads.items.entry(item.key.clone()).or_insert(item); } } self.hydrate_partial_downloads(); self.emit_downloads(); self.emit(Event::OfflineFiles(files)); } Err(error) => self.fail("read offline downloads", error), } } /// Crawl one share, or all of them, and reload the library. pub fn crawl(&self, share: Option) { let engine = self.clone(); self.runtime.spawn(async move { engine.connect_and_crawl(share).await; }); } async fn connect_and_crawl(&self, share: Option) { let Some((library, _)) = self.ensure_connected().await else { self.emit(Event::CrawlFinished); return; }; let targets: Vec = match share { Some(id) => vec![id], None => library.share_ids().map(str::to_string).collect(), }; // Every share at once: each is its own session against its own // tree, and one large share no longer holds the rest back behind it. let cancel = self.crawl_cancel.subscribe(); futures::future::join_all( targets .into_iter() .map(|share_id| self.crawl_share(&library, share_id, cancel.clone())), ) .await; self.emit(Event::CrawlFinished); } /// Stop every crawl still listing. Shares already past their listing /// finish storing what they found. pub fn stop_crawl(&self) { self.crawl_cancel.send_modify(|generation| *generation += 1); } /// Crawl one share and store what it holds. async fn crawl_share( &self, library: &SharedLibrary, share_id: String, mut cancel: tokio::sync::watch::Receiver, ) { let started = std::time::Instant::now(); let name = self .store .list() .ok() .and_then(|shares| shares.into_iter().find(|share| share.id == share_id)) .map_or_else(|| share_id.clone(), |share| share.name); self.emit(Event::CrawlProgress { share: name.clone(), found: 0, }); // At most a few times a second: a wide level comes back as a burst // of listings, and a frame per listing is a frame per folder. let last = parking_lot::Mutex::new(std::time::Instant::now()); let report = |found: usize| { let mut last = last.lock(); if last.elapsed() >= std::time::Duration::from_millis(250) { *last = std::time::Instant::now(); self.emit(Event::CrawlProgress { share: name.clone(), found, }); } }; // Only the listing can be stopped. Past it the crawl is replacing the // share's rows and its stale downloads, and stopping halfway through // that would leave the two disagreeing. let nodes = tokio::select! { result = library.crawl_reporting(&share_id, report) => match result { Ok(nodes) => nodes, Err(error) => { self.fail(&format!("crawl {share_id}"), error); return; } }, Ok(()) = cancel.changed() => { self.emit(Event::CrawlStopped { share_id }); return; } }; let rows = build_rows(&share_id, &nodes); let files = rows.len(); // A writer opened before the crawl may be producing the revision // this crawl is about to supersede. Quiesce all writers for the // share before inspecting or deleting any revision paths. let active_downloads: Vec<_> = self .downloads .lock() .jobs .keys() .filter(|key| key.share_id == share_id) .cloned() .collect(); for key in &active_downloads { self.cancel_and_join(key).await; } // Paths are derived from the old revision id, so inspect and // remove stale bytes before changing or dropping their index rows. let retained = match self.catalog.lock().offline_files_for_share(&share_id) { Ok(files) => files, Err(error) => { self.fail(&format!("inspect downloads for {share_id}"), error); return; } }; let revisions: HashMap<&str, Option<&str>> = rows .iter() .map(|row| (row.link_id.as_str(), row.active_revision_id.as_deref())) .collect(); let stale: Vec<_> = retained .iter() .filter(|(link_id, file)| { revisions.get(link_id.as_str()).copied().flatten() != Some(file.revision_id.as_str()) }) .map(|(link_id, file)| (link_id.clone(), file.clone())) .collect(); let mut cleanup_failed = false; for (link_id, file) in &stale { let path = self .dirs .offline_file(&share_id, link_id, &file.revision_id); if let Err(error) = remove_if_present(&path).await { self.fail(&format!("delete stale download for {share_id}"), error); cleanup_failed = true; break; } } if cleanup_failed { return; } let stored = { let mut catalog = self.catalog.lock(); catalog .replace_share_retaining_offline(&share_id, &rows) .and_then(|()| { for (link_id, _) in &stale { catalog.remove_offline_file(&share_id, link_id)?; } Ok(()) }) }; if let Err(error) = stored { self.fail(&format!("store {share_id}"), error); return; } self.emit(Event::Crawled { share_id, nodes: nodes.len(), files, seconds: started.elapsed().as_secs_f64(), }); self.load_library(); } /// Record where playback got to. /// /// Fire and forget: a watch position that fails to save is worth a log line, /// not an interruption to what the viewer is watching. pub fn save_watch_state(&self, share_id: String, link_id: String, state: WatchState) { let engine = self.clone(); self.runtime.spawn_blocking(move || { engine.save_watch_state_now(&share_id, &link_id, &state); }); } /// The same write, on the calling thread. /// /// What the player thread uses for its last save: a task spawned as the /// window is closing may never be polled, and the position at the moment /// the viewer quit is exactly the one worth keeping. pub fn save_watch_state_now(&self, share_id: &str, link_id: &str, state: &WatchState) { if let Err(error) = self .catalog .lock() .set_watch_state(share_id, link_id, state) { tracing::warn!("save watch state for {link_id}: {error}"); return; } self.emit(Event::Watched { share_id: share_id.to_owned(), link_id: link_id.to_owned(), state: *state, }); } pub fn title_track_prefs(&self, key: &str) -> Option { self.track_prefs.lock().get(key).cloned() } pub fn set_title_track_prefs(&self, key: String, prefs: TitleTrackPrefs) { self.track_prefs.lock().insert(key.clone(), prefs.clone()); let engine = self.clone(); self.runtime.spawn_blocking(move || { if let Err(e) = engine.catalog.lock().set_title_track_prefs(&key, &prefs) { tracing::warn!("save show track preferences: {e}"); } }); } // ------------------------------------------------------------- playback /// Open a stream on `node` and hand it back through /// [`Event::PlaybackReady`]. /// /// Opening is done here rather than in mpv's open callback for the reason /// `pstr-player` documents: opening can take seconds and fail for reasons a /// person needs to read, and mpv's callback can only say "loading failed". pub fn play(&self, target: PlaybackTarget) { let engine = self.clone(); self.runtime.spawn(async move { // A completed local copy is authoritative until the catalog sees a // new revision; opening it never needs a public-link session. let offline = { engine .catalog .lock() .offline_file(&target.share_id, &target.link_id) .ok() .flatten() }; if let Some(file) = offline { let path = engine .dirs .offline_file(&target.share_id, &target.link_id, &file.revision_id); if tokio::fs::metadata(&path) .await .map(|m| m.len() == file.block_sizes.iter().sum::()) .unwrap_or(false) { let blocks: std::sync::Arc = std::sync::Arc::new( FileBlocks::new(file.revision_id, path, file.block_sizes), ); let uid = node_uid(&target.volume_id, &target.link_id); engine.emit(Event::PlaybackReady { target: Box::new(target), stream: VideoStream::offline(uid, blocks, pstr_stream::DEFAULT_RING_BYTES), }); return; } } let Some((_, source)) = engine.ensure_connected().await else { return; }; let uid = node_uid(&target.volume_id, &target.link_id); match source.open(&target.share_id, &uid).await { Ok(stream) if stream.size() == 0 => { engine.emit(Event::Error(format!("{} has no content", target.name))); } Ok(stream) => { // Before mpv is even built: building it takes the UI thread // a moment, and the network can spend that moment on the // two reads every file opens with. stream.warm_edges(); engine.emit(Event::PlaybackReady { target: Box::new(target), stream, }) } Err(error) => engine.fail(&format!("open {}", target.name), error), } }); } /// Fetch the blocks a file opens with into the cache, without playing it. /// /// For the episode after the one playing, so that autoplay — or a click on /// "Play now" — starts from the cache rather than from two round trips. A /// local copy needs none of this, and a failure here costs nothing but the /// head start, so both are quietly skipped. pub fn warm(&self, target: &PlaybackTarget) { let engine = self.clone(); let share_id = target.share_id.clone(); let link_id = target.link_id.clone(); let uid = node_uid(&target.volume_id, &target.link_id); self.runtime.spawn(async move { let offline = engine .catalog .lock() .offline_file(&share_id, &link_id) .ok() .flatten() .is_some(); if offline { return; } let Some(source) = engine.opened().map(|(_, source)| source) else { return; }; match source.open(&share_id, &uid).await { Ok(stream) => stream.warm_edges(), Err(error) => tracing::debug!(%error, "warming the next episode failed"), } }); } /// Queue complete plaintext copies. Jobs for the same episode are /// de-duplicated; a failed or cancelled row is a resumable retry. pub fn make_offline(&self, targets: Vec) { for target in targets { let key = DownloadKey::from(&target); if !self.job_is_live(&key) { self.spawn_download(target); } } } fn job_is_live(&self, key: &DownloadKey) -> bool { self.downloads .lock() .jobs .get(key) .is_some_and(|job| job.live.load(Ordering::Acquire)) } fn spawn_download(&self, target: PlaybackTarget) { let key = DownloadKey::from(&target); let control = Arc::new(DownloadControl::new()); let live = Arc::new(AtomicBool::new(true)); { let mut downloads = self.downloads.lock(); let previous = downloads.items.get(&key).cloned(); downloads.items.insert( key.clone(), DownloadItem { key: key.clone(), target: target.clone(), state: DownloadState::Queued, downloaded: previous.as_ref().map_or(0, |item| item.downloaded), total: previous.as_ref().map_or(0, |item| item.total), rate: 0.0, }, ); } self.emit_downloads(); let engine = self.clone(); let task_live = Arc::clone(&live); let task_target = target.clone(); let task_control = Arc::clone(&control); let handle = self.runtime.spawn(async move { let result = engine.download_one(task_target.clone(), task_control).await; task_live.store(false, Ordering::Release); let task_key = DownloadKey::from(&task_target); match result { Ok(DownloadEnd::Completed) => { engine.update_download(&task_key, |item| { item.downloaded = item.total; item.state = DownloadState::Completed; }); engine.load_offline_files(); engine.emit(Event::Status(format!( "{} is available offline", task_target.name ))); } Ok(DownloadEnd::Cancelled) => { engine.update_download(&task_key, |item| item.state = DownloadState::Cancelled); } Err(error) => { engine.update_download(&task_key, |item| { item.state = DownloadState::Failed(error.to_string()); }); } } }); self.downloads.lock().jobs.insert( key, DownloadJob { control, handle, live, }, ); } pub fn pause_download(&self, key: &DownloadKey) { if let Some(control) = self.live_control(key) { control.set(DownloadCommand::Pause); self.update_download(key, |item| item.state = DownloadState::Paused); } } pub fn resume_download(&self, key: &DownloadKey) { let control = self.live_control(key); if let Some(control) = control { control.set(DownloadCommand::Run); self.update_download(key, |item| item.state = DownloadState::Running); return; } if let Some(target) = self .downloads .lock() .items .get(key) .map(|item| item.target.clone()) { self.make_offline(vec![target]); } } /// Stop network work after the current complete block. The partial remains /// valid and Resume starts at its next block. pub fn cancel_download(&self, key: &DownloadKey) { if let Some(control) = self.live_control(key) { control.set(DownloadCommand::Cancel); } } fn live_control(&self, key: &DownloadKey) -> Option> { self.downloads .lock() .jobs .get(key) .filter(|job| job.live.load(Ordering::Acquire)) .map(|job| Arc::clone(&job.control)) } /// Remove completed offline bytes and the catalog record. With /// `remove_partial`, also discard resumable work after cancelling it. pub fn remove_download(&self, key: DownloadKey, remove_partial: bool) { self.cancel_download(&key); let engine = self.clone(); self.runtime.spawn(async move { engine.cancel_and_join(&key).await; let offline = { engine .catalog .lock() .offline_file(&key.share_id, &key.link_id) .ok() .flatten() }; if let Some(file) = offline { let path = engine .dirs .offline_file(&key.share_id, &key.link_id, &file.revision_id); if let Err(error) = remove_if_present(&path).await { return engine.fail("delete offline bytes", error); } } if remove_partial { let (partial, marker) = engine.partial_paths(&key); if let Err(error) = remove_if_present(&partial).await { return engine.fail("delete partial download", error); } if let Err(error) = remove_if_present(&marker).await { return engine.fail("delete partial marker", error); } } if let Err(error) = engine .catalog .lock() .remove_offline_file(&key.share_id, &key.link_id) { return engine.fail("make online-only", error); } { let mut downloads = engine.downloads.lock(); downloads.jobs.remove(&key); downloads.items.remove(&key); } engine.emit_downloads(); engine.load_offline_files(); engine.emit(Event::Status( "download removed; online source was kept".to_owned(), )); }); } async fn cancel_and_join(&self, key: &DownloadKey) { let job = self.downloads.lock().jobs.remove(key); if let Some(job) = job { job.control.set(DownloadCommand::Cancel); if let Err(error) = job.handle.await && !error.is_cancelled() { tracing::warn!("join offline writer: {error}"); } } } /// Ask every writer to stop at its next durable block boundary and wait a /// bounded time. If a network read outlives the bound, the marker and last /// `sync_data` boundary remain a coherent handoff for the next launch. pub fn shutdown_downloads(&self, timeout: std::time::Duration) { let jobs: Vec<_> = { let mut downloads = self.downloads.lock(); downloads .jobs .drain() .map(|(_, job)| { job.control.set(DownloadCommand::Cancel); job.handle }) .collect() }; self.runtime.block_on(async move { let wait = async move { for handle in jobs { let _ = handle.await; } }; let _ = tokio::time::timeout(timeout, wait).await; }); } fn emit_downloads(&self) { let mut items: Vec<_> = self.downloads.lock().items.values().cloned().collect(); items.sort_by(|a, b| { a.target .title_key .cmp(&b.target.title_key) .then(a.target.season.cmp(&b.target.season)) .then(a.target.number.cmp(&b.target.number)) }); self.emit(Event::Downloads(items)); } fn update_download(&self, key: &DownloadKey, update: impl FnOnce(&mut DownloadItem)) { if let Some(item) = self.downloads.lock().items.get_mut(key) { update(item); } self.emit_downloads(); } /// [`Self::update_download`] for a change to one row only: the row goes /// to the UI on its own rather than in a fresh snapshot of every row. fn progress_download(&self, key: &DownloadKey, update: impl FnOnce(&mut DownloadItem)) { let item = { let mut downloads = self.downloads.lock(); let Some(item) = downloads.items.get_mut(key) else { return; }; update(item); item.clone() }; self.emit(Event::DownloadProgress(Box::new(item))); } fn partial_paths(&self, key: &DownloadKey) -> (std::path::PathBuf, std::path::PathBuf) { let partial = self .dirs .offline_file(&key.share_id, &key.link_id, "partial"); let marker = partial.with_extension("partial.json"); (partial, marker) } fn hydrate_partial_downloads(&self) { let Ok(entries) = std::fs::read_dir(self.dirs.offline_content()) else { return; }; let mut found = Vec::new(); for entry in entries.flatten() { let path = entry.path(); if !path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.ends_with(".partial.json")) { continue; } let Some(marker) = std::fs::read(&path) .ok() .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) else { continue; }; let Some(target) = marker.target else { continue; }; let key = DownloadKey::from(&target); let (partial, expected_marker) = self.partial_paths(&key); if expected_marker != path { continue; } let existing = std::fs::metadata(partial).map_or(0, |metadata| metadata.len()); let (_, downloaded) = resume_position(existing, &marker.block_sizes); let Some(total) = marker .block_sizes .iter() .try_fold(0_u64, |sum, size| sum.checked_add(*size)) else { continue; }; found.push(DownloadItem { key, target, state: DownloadState::Cancelled, downloaded, total, rate: 0.0, }); } let mut downloads = self.downloads.lock(); for item in found { downloads.items.entry(item.key.clone()).or_insert(item); } } async fn download_one( &self, target: PlaybackTarget, control: Arc, ) -> anyhow::Result { use tokio::io::{AsyncSeekExt, AsyncWriteExt}; let key = DownloadKey::from(&target); let mut command = control.subscribe(); if !wait_until_runnable(&mut command).await { return Ok(DownloadEnd::Cancelled); } let _permit = loop { self.update_download(&key, |item| item.state = DownloadState::Queued); let permits = Arc::clone(&self.download_permits); tokio::select! { permit = permits.acquire_owned() => break permit?, changed = command.changed() => { if changed.is_err() || !wait_until_runnable(&mut command).await { return Ok(DownloadEnd::Cancelled); } } } }; self.update_download(&key, |item| item.state = DownloadState::Running); let Some((_, source)) = self.ensure_connected().await else { anyhow::bail!("share is not connected"); }; let stream = source .open_for_copy( &target.share_id, &node_uid(&target.volume_id, &target.link_id), ) .await?; let revision_id = stream.revision_id().to_owned(); let block_sizes = stream.block_sizes().to_vec(); let total = block_sizes .iter() .try_fold(0_u64, |sum, size| sum.checked_add(*size)) .ok_or_else(|| anyhow::anyhow!("offline file size overflow"))?; self.update_download(&key, |item| item.total = total); let destination = self .dirs .offline_file(&target.share_id, &target.link_id, &revision_id); let parent = destination .parent() .ok_or_else(|| anyhow::anyhow!("offline file has no parent"))?; tokio::fs::create_dir_all(parent).await?; if tokio::fs::metadata(&destination) .await .is_ok_and(|metadata| metadata.len() == total) { let (partial, marker) = self.partial_paths(&key); remove_if_present(&partial).await?; self.finish_download(&key, &target, revision_id, block_sizes, total, &marker) .await?; return Ok(DownloadEnd::Completed); } let (partial, marker_path) = self.partial_paths(&key); let marker = PartialMarker { revision_id: revision_id.clone(), block_sizes: block_sizes.clone(), target: Some(target.clone()), }; let matches = tokio::fs::read(&marker_path) .await .ok() .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) .is_some_and(|stored| { stored.revision_id == revision_id && stored.block_sizes == block_sizes }); if !matches { remove_if_present(&partial).await?; // The old marker describes bytes we just discarded. Remove it // before publishing the new journal so rename never relies on // platform-specific replace-existing behaviour. remove_if_present(&marker_path).await?; write_marker_atomically(&marker_path, &marker).await?; } let existing = tokio::fs::metadata(&partial) .await .map(|metadata| metadata.len()) .unwrap_or(0); let (mut block_index, mut offset) = resume_position(existing, &block_sizes); let mut output = tokio::fs::OpenOptions::new() .create(true) .truncate(false) .read(true) .write(true) .open(&partial) .await?; output.set_len(offset).await?; output.seek(std::io::SeekFrom::Start(offset)).await?; self.update_download(&key, |item| item.downloaded = offset); use futures::TryStreamExt as _; let mut fetches = None; let mut last_snapshot = std::time::Instant::now(); let mut snapshot_bytes = offset; while block_index < block_sizes.len() { // Dropped before a pause rather than held across it: a fetch left // half-done for as long as the viewer stays paused comes back as a // timed-out request, and a timed-out request fails the download. if matches!(*command.borrow(), DownloadCommand::Pause) { fetches = None; } if !wait_until_runnable(&mut command).await { output.sync_all().await?; return Ok(DownloadEnd::Cancelled); } let size = block_sizes[block_index]; let pending = fetches.get_or_insert_with(|| fetch_in_order(&stream, &block_sizes, block_index)); let Some(bytes) = pending.try_next().await? else { anyhow::bail!("block {block_index} never arrived"); }; if bytes.len() as u64 != size { anyhow::bail!( "short block {block_index}: received {}, expected {size}", bytes.len() ); } output.write_all(&bytes).await?; // The reported boundary must survive a crash; otherwise the next // run could trust a file length whose trailing block never reached // storage. output.sync_data().await?; offset += size; block_index += 1; if last_snapshot.elapsed() >= std::time::Duration::from_millis(100) || block_index == block_sizes.len() { let seconds = last_snapshot.elapsed().as_secs_f64(); let instant = (offset - snapshot_bytes) as f64 / seconds.max(f64::EPSILON); self.progress_download(&key, |item| { item.state = DownloadState::Running; item.downloaded = offset; // Smoothed, or the time left jumps about with every block // that took a little longer than the last. item.rate = if item.rate > 0.0 { 0.8 * item.rate + 0.2 * instant } else { instant }; }); last_snapshot = std::time::Instant::now(); snapshot_bytes = offset; } } output.sync_all().await?; drop(output); if !wait_until_runnable(&mut command).await { return Ok(DownloadEnd::Cancelled); } remove_if_present(&destination).await?; tokio::fs::rename(&partial, &destination).await?; sync_parent(&destination).await?; self.finish_download(&key, &target, revision_id, block_sizes, total, &marker_path) .await?; Ok(DownloadEnd::Completed) } async fn finish_download( &self, key: &DownloadKey, target: &PlaybackTarget, revision_id: String, block_sizes: Vec, total: u64, marker_path: &std::path::Path, ) -> anyhow::Result<()> { self.catalog.lock().set_offline_file_durable( &target.share_id, &target.link_id, &OfflineFile { revision_id, block_sizes, }, )?; remove_if_present(marker_path).await?; sync_parent(marker_path).await?; self.update_download(key, |item| item.total = total); Ok(()) } /// Drop a stream the player is done with, so its reader and blocks are not /// held for the rest of the session. pub fn release(&self, share_id: String, volume_id: String, link_id: String) { if let Some(connection) = self.connection.lock().as_ref() { connection .source .close(&share_id, &node_uid(&volume_id, &link_id)); } } // ------------------------------------------------------------ thumbnails /// Fetch, decode and post a poster for `node`. /// /// The disk cache is checked first and written on success, so a restart /// paints the grid without touching the network. A file with no thumbnail /// answers [`Event::ThumbnailMissing`] — the grid must remember that, or /// every render pays a round-trip per unmatched title. pub fn request_thumbnail(&self, node: &CatalogNode) { let engine = self.clone(); let key = thumbnail_key(node); let share_id = node.share_id.clone(); let uid = node_uid(&node.volume_id, &node.link_id); let path = self.dirs.thumbnail_cache().join(format!("{key}.bin")); self.runtime.spawn(async move { if let Ok(bytes) = tokio::fs::read(&path).await { match decode_thumbnail(&bytes) { Ok(decoded) => { // A cache written before entries were stored shrunk // holds the full preview; shrink it once, on its way out. if let Some(smaller) = &decoded.smaller { store_image(&path, smaller).await; } return engine.emit(Event::Thumbnail { key, image: decoded.image, }); } // A truncated or corrupt cache entry is not worth reporting; // fall through and fetch it again. Err(error) => tracing::debug!("cached thumbnail {key}: {error}"), } } // Past the cache, so this one costs the network. Wait for a permit // before taking any of the bandwidth playback might want. let permits = Arc::clone(&engine.thumbnails); let Ok(_permit) = permits.acquire().await else { return; }; let Some((library, _)) = engine.ensure_connected().await else { return; }; let Some(client) = library.client(&share_id) else { return engine.emit(Event::ThumbnailMissing { key }); }; // Preview first: it is the one sized for a card. Not every file has // one, and the small thumbnail is better than a grey rectangle. let mut bytes = None; for kind in [ThumbnailType::Preview, ThumbnailType::Thumbnail] { match client.download_thumbnail(&uid, kind).await { Ok(Some(found)) => { bytes = Some(found); break; } Ok(None) => {} Err(error) => tracing::debug!("thumbnail {key} ({kind:?}): {error}"), } } let Some(bytes) = bytes else { // Common, not exceptional: Proton renders thumbnails on upload, // and a share filled by a client that does not attach any — the // FUSE client among them — has none at all. The tile falls back // to initials until a metadata provider gives it a poster. tracing::debug!("no thumbnail for {key}"); return engine.emit(Event::ThumbnailMissing { key }); }; match decode_thumbnail(&bytes) { Ok(decoded) => { store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await; engine.emit(Event::Thumbnail { key, image: decoded.image, }) } Err(error) => { tracing::debug!("decode thumbnail {key}: {error}"); engine.emit(Event::ThumbnailMissing { key }); } } }); } } // ---------------------------------------------------------------- enrichment impl Engine { /// The enrichment settings as they stand. pub fn metadata_config(&self) -> MetadataConfig { self.metadata.lock().config.clone() } /// Whether a provider is configured *and* usable right now. /// /// False for "switched off" and for "TMDB with no API key stored" alike — /// the UI distinguishes them from the config, not from here. pub fn enrichment_ready(&self) -> bool { self.metadata.lock().service.is_some() } /// Change the settings, write them, and rebuild the provider. /// /// Turning enrichment *off* clears the stored answers as well. That is the /// point of the switch: someone who turns this off has decided they would /// rather the third party's answers were not on their disk either, and /// leaving them there would make "off" mean only "stop asking". pub fn set_metadata_config(&self, config: MetadataConfig) { let engine = self.clone(); self.runtime.spawn_blocking(move || { let previous = engine.metadata_config(); if let Err(error) = pstr_meta::settings::save(&engine.dirs, &config) { return engine.fail("save the metadata settings", error); } let forget = !config.enabled || config.provider != previous.provider; if forget && let Err(error) = engine.catalog.lock().clear_metadata() { tracing::warn!("clear stored metadata: {error}"); } *engine.metadata.lock() = Enrichment::build(config.clone()); engine.emit(Event::MetadataConfig(config)); engine.load_metadata(); }); } /// Store an API key for a provider and rebuild against it. pub fn set_api_key(&self, provider: ProviderId, key: String) { let engine = self.clone(); self.runtime.spawn_blocking(move || { if let Err(error) = pstr_meta::settings::set_api_key(provider, &key) { return engine.fail("store the API key", error); } let config = engine.metadata_config(); *engine.metadata.lock() = Enrichment::build(config); engine.emit(Event::Status(if key.trim().is_empty() { format!("cleared the {} key", provider.label()) } else { format!("saved the {} key", provider.label()) })); }); } /// Re-read every stored provider answer. /// /// Read whole rather than per tile, for the same reason watch state is — /// see [`Catalog::all_metadata`]. pub fn load_metadata(&self) { let engine = self.clone(); self.runtime.spawn_blocking(move || { // One lock, both reads: they are drawn together, and a UI that has // the titles but not their episodes for a frame flickers the rows. let answers = { let catalog = engine.catalog.lock(); catalog .all_metadata() .and_then(|records| Ok((records, catalog.all_episode_metadata()?))) }; match answers { Ok((records, episodes)) => { engine.emit(Event::Metadata(records)); engine.emit(Event::EpisodeMetadata(episodes)); } Err(error) => engine.fail("read stored metadata", error), } }); } /// Look up every title that has no fresh answer. /// /// `force` re-asks about titles that already have one, which is what /// "match again" means. Otherwise a run over an already-matched library /// makes no requests at all, which is what makes it safe to call whenever /// the library changes. pub fn match_titles(&self, titles: Vec, force: bool) { let engine = self.clone(); self.runtime.spawn(async move { engine.run_match(titles, force).await; engine.emit(Event::MatchFinished); }); } async fn run_match(&self, titles: Vec<Title>, force: bool) { let Some(service) = self.metadata.lock().service.clone() else { let config = self.metadata_config(); return self.emit(Event::Error(if !config.enabled { "turn on metadata enrichment first".into() } else { format!("{} needs an API key", config.provider.label()) })); }; let provider = service.provider(); let (stored, listed) = { let catalog = self.catalog.lock(); match catalog .all_metadata() .and_then(|stored| Ok((stored, catalog.episode_metadata_ages()?))) { Ok(pair) => pair, Err(error) => return self.fail("read stored metadata", error), } }; // Two kinds of work, and the difference is a request saved: a title // that already has a good match but no episode list needs the episode // request only — searching for it again would ask the provider // something it has already answered. let mut pending: Vec<Work> = Vec::new(); for title in titles { let record = stored.get(&title.key); // A hand-picked entry is never searched for again, not even by // "match again" — that button means "the automatic answers are // wrong", and re-deciding the one title the viewer already fixed by // hand is the opposite of what they asked for. Its episode list is // still fetched below if it is missing. let pinned = record.is_some_and(|record| record.manual && record.provider == provider); if !pinned && (force || !pstr_meta::service::is_usable(record, provider)) { pending.push(Work::Match(title)); continue; } let has_episodes = listed .get(&title.key) .is_some_and(|(asked, _)| *asked == provider); if let Some(found) = record.and_then(|record| record.metadata.clone()) && !has_episodes { pending.push(Work::Episodes(title, Box::new(found))); } } if pending.is_empty() { return self.emit(Event::Status("everything is already matched".into())); } self.emit(Event::Status(format!( "matching {} titles against {}…", pending.len(), provider.label() ))); let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize); let mut lookups = futures::stream::FuturesUnordered::new(); for work in &pending { let service = service.clone(); let permits = Arc::clone(&self.lookups); lookups.push(async move { let Ok(_permit) = permits.acquire().await else { return None; }; Some(match work { Work::Match(title) => match service.record(title).await { Ok(record) => { // Only for a title that matched: there is no id to // ask about otherwise. let episodes = match &record.metadata { Some(found) => service.title_episodes(title, found).await, None => Vec::new(), }; Ok((record.title_key.clone(), Some(record), episodes)) } Err(error) => Err(error), }, Work::Episodes(title, found) => Ok(( title.key.clone(), None, service.title_episodes(title, found).await, )), }) }); } use futures::StreamExt as _; let mut episodes_found = 0usize; while let Some(result) = lookups.next().await { match result { Some(Ok((title_key, record, episodes))) => { if let Some(record) = &record { if record.metadata.is_some() { matched += 1; } else { unmatched += 1; } // Misses are stored on purpose; failures below are not. // See `pstr_meta::service`. if let Err(error) = self.catalog.lock().set_metadata(record) { tracing::warn!("store metadata for {}: {error}", record.title_key); } } if !episodes.is_empty() { episodes_found += episodes.len(); let stored = self.catalog.lock().set_episode_metadata( &title_key, provider, now(), &episodes, ); if let Err(error) = stored { tracing::warn!("store episodes for {title_key}: {error}"); } } } Some(Err(error)) => { failed += 1; tracing::warn!("metadata lookup: {error}"); } None => {} } } if episodes_found > 0 { self.emit(Event::Status(format!("{episodes_found} episodes named"))); } self.emit(Event::Matched { matched, unmatched, failed, }); self.load_metadata(); } /// Ask the provider what `term` might be, for a viewer choosing by hand. /// /// Unscored and unfiltered — see [`MetadataService::search`]. Nothing is /// stored: this is a look, and only [`Engine::choose_match`] writes. pub fn search_matches(&self, title_key: String, term: String, kind: TitleKind) { let Some(service) = self.metadata.lock().service.clone() else { let config = self.metadata_config(); return self.emit(Event::MatchSearchFailed { title_key, error: if !config.enabled { "turn on metadata enrichment first".into() } else { format!("{} needs an API key", config.provider.label()) }, }); }; let engine = self.clone(); let permits = Arc::clone(&self.lookups); self.runtime.spawn(async move { // The same permit the batch run takes: a viewer typing in the search // box while a match run is going must not be what earns the 429. let Ok(_permit) = permits.acquire().await else { return; }; match service.search(&term, kind).await { Ok(options) => engine.emit(Event::MatchOptions { title_key, options }), Err(error) => { tracing::warn!("search {term:?}: {error}"); engine.emit(Event::MatchSearchFailed { title_key, error: error.to_string(), }); } } }); } /// Pin one title to the entry the viewer picked, and take its episodes. /// /// Stored as [`MetadataRecord::manual`], so it outlives every TTL and /// survives "match again". The episode list is fetched here rather than left /// to the next match run, because the point of choosing an entry by hand is /// usually that its episode names were wrong too. pub fn choose_match(&self, title: Title, found: pstr_core::metadata::TitleMetadata) { let Some(service) = self.metadata.lock().service.clone() else { return self.emit(Event::Error("turn on metadata enrichment first".into())); }; let engine = self.clone(); self.runtime.spawn(async move { let name = found.name.clone(); let record = service.chosen(title.key.clone(), found.clone()); if let Err(error) = engine.catalog.lock().set_metadata(&record) { return engine.fail("store the match", error); } let episodes = service.title_episodes(&title, &found).await; if !episodes.is_empty() { let stored = engine.catalog.lock().set_episode_metadata( &title.key, service.provider(), now(), &episodes, ); if let Err(error) = stored { tracing::warn!("store episodes for {}: {error}", title.key); } } engine.emit(Event::Status(format!("{} is now {name}", title.name))); engine.load_metadata(); }); } /// Drop what is stored for one title, so it is matched from scratch again. /// /// The undo for [`Engine::choose_match`], and the repair for a bad automatic /// match: the row goes entirely rather than becoming a stored miss, which is /// what makes the next match run treat the title as one it has never asked /// about. pub fn forget_match(&self, title_key: String) { let engine = self.clone(); self.runtime.spawn_blocking(move || { if let Err(error) = engine.catalog.lock().forget_metadata(&title_key) { return engine.fail("forget the match", error); } engine.emit(Event::Status("match cleared".into())); engine.load_metadata(); }); } /// Fetch, decode and post one title's artwork. /// /// Cached on disk under a hash of the URL, so a provider that moves its CDN /// paths invalidates by itself and the same picture shared by two titles is /// fetched once. pub fn request_poster(&self, title_key: String, url: String) { let engine = self.clone(); let path = self .dirs .poster_cache() .join(format!("{}.img", digest(&url))); self.runtime.spawn(async move { if let Ok(bytes) = tokio::fs::read(&path).await { match decode_thumbnail(&bytes) { Ok(decoded) => { if let Some(smaller) = &decoded.smaller { store_image(&path, smaller).await; } return engine.emit(Event::Poster { key: title_key, image: decoded.image, }); } Err(error) => tracing::debug!("cached poster {title_key}: {error}"), } } let Some(service) = engine.metadata.lock().service.clone() else { // Enrichment was turned off between the request and now. return engine.emit(Event::PosterMissing { key: title_key }); }; let bytes = match service.artwork(&url).await { Ok(bytes) => bytes, Err(error) => { tracing::debug!("fetch poster for {title_key}: {error}"); return engine.emit(Event::PosterMissing { key: title_key }); } }; match decode_thumbnail(&bytes) { Ok(decoded) => { store_image(&path, decoded.smaller.as_deref().unwrap_or(&bytes)).await; engine.emit(Event::Poster { key: title_key, image: decoded.image, }) } Err(error) => { tracing::debug!("decode poster for {title_key}: {error}"); engine.emit(Event::PosterMissing { key: title_key }) } } }); } } /// Every file in `library`, by the key its download is filed under. fn download_targets(library: &Library) -> HashMap<DownloadKey, PlaybackTarget> { let mut targets = HashMap::new(); for title in &library.titles { for episode in title.episodes() { let target = PlaybackTarget::new(title, episode); targets.insert(DownloadKey::from(&target), target); } } targets } /// A short, filesystem-safe name for a URL. fn digest(url: &str) -> String { use sha2::{Digest as _, Sha256}; let hash = Sha256::digest(url.as_bytes()); hash.iter() .take(16) .map(|byte| format!("{byte:02x}")) .collect() } /// The cache and texture key for a file's poster. /// One title's worth of work in a match run. enum Work { /// Search for it, and take its episodes if it matches. Match(Title), /// It is already matched; only the episode list is missing. Episodes(Title, Box<pstr_core::metadata::TitleMetadata>), } /// Unix seconds, for the "when was this asked" columns. fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|since| since.as_secs() as i64) .unwrap_or_default() } pub fn thumbnail_key(node: &CatalogNode) -> String { format!("{}-{}", node.share_id, node.link_id) } fn node_uid(volume_id: &str, link_id: &str) -> NodeUid { NodeUid::new( VolumeId::new(volume_id.to_string()), LinkId::new(link_id.to_string()), ) } /// Resume only after the last completely durable Proton block. A process may /// die during a write, so trailing bytes inside a block are deliberately /// discarded rather than trusted. fn resume_position(existing: u64, block_sizes: &[u64]) -> (usize, u64) { let mut offset = 0_u64; for (index, size) in block_sizes.iter().copied().enumerate() { let Some(next) = offset.checked_add(size) else { break; }; if next > existing { return (index, offset); } offset = next; } (block_sizes.len(), offset) } /// A file's blocks from `first` on, fetched several at a time and yielded in /// file order — which is what the resume marker's "the length is the /// boundary" rule needs. One at a time, a download runs at one block per round /// trip whatever the link can carry. fn fetch_in_order( stream: &VideoStream, block_sizes: &[u64], first: usize, ) -> futures::stream::BoxStream<'static, pstr_stream::Result<Vec<u8>>> { use futures::StreamExt as _; let start: u64 = block_sizes[..first].iter().sum(); let ranges: Vec<(u64, u64)> = block_sizes[first..] .iter() .scan(start, |offset, size| { let range = (*offset, *size); *offset += size; Some(range) }) .collect(); let stream = stream.clone(); futures::stream::iter(ranges) .map(move |(offset, size)| { let stream = stream.clone(); async move { stream.read_range(offset, size).await } }) .buffered(DOWNLOAD_BLOCKS_IN_FLIGHT) .boxed() } async fn wait_until_runnable(command: &mut tokio::sync::watch::Receiver<DownloadCommand>) -> bool { loop { let current = *command.borrow_and_update(); match current { DownloadCommand::Run => return true, DownloadCommand::Cancel => return false, DownloadCommand::Pause => { if command.changed().await.is_err() { return false; } } } } } async fn remove_if_present(path: &std::path::Path) -> std::io::Result<()> { match tokio::fs::remove_file(path).await { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => Err(error), } } #[cfg(unix)] async fn sync_parent(path: &std::path::Path) -> std::io::Result<()> { let Some(parent) = path.parent() else { return Ok(()); }; tokio::fs::File::open(parent).await?.sync_all().await } #[cfg(not(unix))] async fn sync_parent(_path: &std::path::Path) -> std::io::Result<()> { // Windows does not expose directory handles through std/tokio. The file // itself is synced before publication and rename is still atomic. Ok(()) } async fn write_marker_atomically( path: &std::path::Path, marker: &PartialMarker, ) -> anyhow::Result<()> { use tokio::io::AsyncWriteExt; let temporary = path.with_extension("partial.json.tmp"); let bytes = serde_json::to_vec(marker)?; let mut output = tokio::fs::File::create(&temporary).await?; output.write_all(&bytes).await?; output.sync_all().await?; drop(output); tokio::fs::rename(temporary, path).await?; sync_parent(path).await?; Ok(()) } /// A picture ready to upload, and — when it had to be scaled down — the /// scaled copy encoded again, for the disk cache to keep instead. struct Decoded { image: egui::ColorImage, smaller: Option<Vec<u8>>, } /// Decode image bytes into something egui can upload, scaled down to /// [`THUMBNAIL_MAX_EDGE`]. /// /// Runs on the runtime rather than the UI thread: a JPEG decode per tile during /// the first paint of a large library is visible as dropped frames. /// /// A provider's backdrop is often 1920 px or more, and decoding and resizing /// that every launch was most of what painting a cached grid cost. The scaled /// copy is handed back so the cache holds that instead, and the next launch /// decodes a picture already the size it is drawn at. fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<Decoded> { let decoded = image::load_from_memory(bytes)?; let (decoded, smaller) = if decoded.width().max(decoded.height()) > THUMBNAIL_MAX_EDGE { let scaled = decoded.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE); let encoded = encode_for_cache(&scaled); (scaled, encoded) } else { (decoded, None) }; let rgba = decoded.to_rgba8(); let size = [rgba.width() as usize, rgba.height() as usize]; Ok(Decoded { image: egui::ColorImage::from_rgba_unmultiplied(size, rgba.as_flat_samples().as_slice()), smaller, }) } /// Encode a scaled picture for the cache: PNG when it has transparency to keep /// (a logo), JPEG otherwise. `None` if encoding failed, which leaves the cache /// holding the original — slower to load, never wrong. fn encode_for_cache(image: &image::DynamicImage) -> Option<Vec<u8>> { let mut bytes = std::io::Cursor::new(Vec::new()); let encoded = if image.color().has_alpha() { image.write_to(&mut bytes, image::ImageFormat::Png) } else { image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 88) .encode_image(&image.to_rgb8()) }; encoded.ok().map(|()| bytes.into_inner()) } /// Write a cached picture. Best effort: the cache is a convenience, and a /// write that fails only means the next launch fetches it again. async fn store_image(path: &Path, bytes: &[u8]) { if let Some(parent) = path.parent() { let _ = tokio::fs::create_dir_all(parent).await; } let _ = tokio::fs::write(path, bytes).await; } /// Watch state as of now, for a position in a file. pub fn watch_state(position: f64, duration: Option<f64>, watched: bool) -> WatchState { WatchState { position_secs: position, duration_secs: duration, watched, updated_at: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|since| since.as_secs() as i64) .unwrap_or(0), } } /// Shares that failed to open, formatted for one status line. pub fn describe_failures(failures: &[(String, String)]) -> Option<String> { if failures.is_empty() { return None; } let names: Vec<&str> = failures.iter().map(|(name, _)| name.as_str()).collect(); Some(format!("could not open: {}", names.join(", "))) } /// Pictures the UI has, plus the ones it has already asked for. /// /// Two instances exist and they hold different things: one keyed by file for /// Proton's own thumbnails, one keyed by title for a provider's artwork. What /// they share is the part that matters — a fast scroll must not re-ask for the /// same picture sixty times a second, and a picture that turned out not to exist /// must be remembered as not existing. Without that second half, every render /// pays a round-trip per unmatched tile, which is exactly what /// `proton-drive-linux`'s photo grid had to learn. /// /// It is also bounded. Scrolling a large library once used to keep a texture /// for every title in it for the rest of the session; past [`IMAGE_BUDGET`] /// bytes the picture asked for longest ago goes, and comes back from the disk /// cache if it is asked for again. pub struct ImageCache { textures: HashMap<String, Entry>, requested: HashSet<String>, missing: HashSet<String>, /// Decoded, waiting for a frame with upload budget left. pending: VecDeque<(String, egui::ColorImage)>, /// A logical clock: one tick per lookup, so "asked for longest ago" needs /// no wall time. clock: u64, bytes: usize, budget: usize, } struct Entry { texture: egui::TextureHandle, used: u64, bytes: usize, } /// Texture memory one [`ImageCache`] may hold. A 640 px card picture is about /// a megabyte, so this is a few hundred tiles — several screens of grid. const IMAGE_BUDGET: usize = 256 << 20; /// Textures uploaded per frame. A cached grid answers dozens of pictures at /// once, and uploading them all in one frame is a visible hitch; spread over a /// few frames they arrive faster than the eye follows. const UPLOADS_PER_FRAME: usize = 6; impl Default for ImageCache { fn default() -> Self { Self::with_budget(IMAGE_BUDGET) } } impl ImageCache { fn with_budget(budget: usize) -> Self { Self { textures: HashMap::new(), requested: HashSet::new(), missing: HashSet::new(), pending: VecDeque::new(), clock: 0, bytes: 0, budget, } } /// The texture for `key`, calling `fetch` the first time it is asked for. /// /// `None` while a fetch is in flight, and forever for a picture that turned /// out not to exist — the negative answer is remembered deliberately. /// /// Borrows the key: this is asked once per tile per frame, and a hit — the /// usual answer — should not cost an allocation. pub fn texture(&mut self, key: &str, fetch: impl FnOnce()) -> Option<egui::TextureHandle> { self.clock += 1; if let Some(entry) = self.textures.get_mut(key) { entry.used = self.clock; return Some(entry.texture.clone()); } if !self.missing.contains(key) && !self.requested.contains(key) { self.requested.insert(key.to_owned()); fetch(); } None } /// Queue a decoded picture. It becomes a texture in [`Self::upload`]. pub fn insert(&mut self, key: String, image: egui::ColorImage) { // Forgotten while it was being fetched: the answer is for a question // nobody is asking any more. if self.requested.contains(&key) { self.pending.push_back((key, image)); } } /// Upload up to this frame's share of queued pictures. Returns whether any /// are still waiting, so the caller can ask for another frame. pub fn upload(&mut self, ctx: &egui::Context) -> bool { for _ in 0..UPLOADS_PER_FRAME { let Some((key, image)) = self.pending.pop_front() else { break; }; let bytes = image.pixels.len() * 4; let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR); self.clock += 1; let entry = Entry { texture, used: self.clock, bytes, }; if let Some(old) = self.textures.insert(key, entry) { self.bytes -= old.bytes; } self.bytes += bytes; self.evict(); } !self.pending.is_empty() } /// Drop the pictures asked for longest ago until the rest fit the budget. fn evict(&mut self) { while self.bytes > self.budget && self.textures.len() > 1 { let Some(oldest) = self .textures .iter() .min_by_key(|(_, entry)| entry.used) .map(|(key, _)| key.clone()) else { break; }; if let Some(entry) = self.textures.remove(&oldest) { self.bytes -= entry.bytes; } // Asked for again, it is fetched again — from the disk cache. self.requested.remove(&oldest); } } pub fn mark_missing(&mut self, key: String) { self.missing.insert(key); } /// Forget one picture, so the next ask fetches it again. pub fn forget(&mut self, key: &str) { if let Some(entry) = self.textures.remove(key) { self.bytes -= entry.bytes; } self.requested.remove(key); self.missing.remove(key); self.pending.retain(|(pending, _)| pending != key); } /// Forget everything. Used when the catalog is replaced, so a recrawl that /// changed link ids does not leave stale pictures behind, and when the /// metadata provider changes, so the old provider's artwork goes with it. pub fn clear(&mut self) { self.textures.clear(); self.requested.clear(); self.missing.clear(); self.pending.clear(); self.bytes = 0; } } #[cfg(test)] mod image_cache_tests { use super::*; fn picture() -> egui::ColorImage { egui::ColorImage::filled([4, 4], egui::Color32::WHITE) } #[test] fn a_full_cache_drops_the_picture_asked_for_longest_ago() { let ctx = egui::Context::default(); // Room for two 4×4 pictures. let mut cache = ImageCache::with_budget(2 * 64); for key in ["a", "b"] { cache.texture(key, || {}); cache.insert(key.into(), picture()); } cache.upload(&ctx); // "a" is looked at again, so "b" is now the stale one. assert!(cache.texture("a", || {}).is_some()); cache.texture("c", || {}); cache.insert("c".into(), picture()); cache.upload(&ctx); assert!(cache.texture("a", || {}).is_some()); assert!(cache.texture("c", || {}).is_some()); let mut fetched = false; assert!(cache.texture("b", || fetched = true).is_none()); assert!( fetched, "an evicted picture is fetched again when asked for" ); } #[test] fn uploads_are_spread_over_frames() { let ctx = egui::Context::default(); let mut cache = ImageCache::default(); for index in 0..UPLOADS_PER_FRAME + 1 { let key = format!("{index}"); cache.texture(&key, || {}); cache.insert(key, picture()); } assert!(cache.upload(&ctx), "one picture is left for the next frame"); assert!(!cache.upload(&ctx)); } #[test] fn an_answer_for_a_forgotten_picture_is_dropped() { let ctx = egui::Context::default(); let mut cache = ImageCache::default(); cache.texture("a", || {}); cache.forget("a"); cache.insert("a".into(), picture()); cache.upload(&ctx); assert!(cache.textures.is_empty()); } } #[cfg(test)] mod download_tests { use super::*; #[test] fn a_partial_download_resumes_only_after_a_complete_block() { let blocks = [3, 5, 2]; assert_eq!(resume_position(0, &blocks), (0, 0)); assert_eq!(resume_position(3, &blocks), (1, 3)); assert_eq!(resume_position(7, &blocks), (1, 3)); assert_eq!(resume_position(8, &blocks), (2, 8)); assert_eq!(resume_position(99, &blocks), (3, 10)); } #[test] fn pause_resume_and_cancel_controls_are_distinct() { let control = DownloadControl::new(); let command = control.subscribe(); assert_eq!(*command.borrow(), DownloadCommand::Run); control.set(DownloadCommand::Pause); assert_eq!(*command.borrow(), DownloadCommand::Pause); control.set(DownloadCommand::Run); assert_eq!(*command.borrow(), DownloadCommand::Run); control.set(DownloadCommand::Cancel); assert_eq!(*command.borrow(), DownloadCommand::Cancel); } #[tokio::test] async fn resume_before_wait_cannot_be_lost() { let control = DownloadControl::new(); let mut command = control.subscribe(); control.set(DownloadCommand::Pause); control.set(DownloadCommand::Run); assert!(wait_until_runnable(&mut command).await); } #[tokio::test] async fn cancellation_wakes_a_paused_writer_deterministically() { let control = Arc::new(DownloadControl::new()); let mut command = control.subscribe(); control.set(DownloadCommand::Pause); let waiter = tokio::spawn(async move { wait_until_runnable(&mut command).await }); tokio::task::yield_now().await; control.set(DownloadCommand::Cancel); assert!( !tokio::time::timeout(std::time::Duration::from_secs(1), waiter) .await .expect("the paused waiter woke") .expect("waiter did not panic") ); } #[test] fn startup_hydrates_a_durable_partial_as_resumable() { let unique = format!( "pstr-download-hydration-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock") .as_nanos() ); let root = std::env::temp_dir().join(unique); let dirs = AppDirs::from_paths(root.join("config"), root.join("data"), root.join("cache")) .expect("temporary app dirs"); let runtime = Arc::new(Runtime::new().expect("runtime")); let (engine, _) = Engine::new(Arc::clone(&runtime), dirs, egui::Context::default()).expect("engine"); let target = PlaybackTarget { share_id: "share".to_owned(), volume_id: "volume".to_owned(), link_id: "episode".to_owned(), name: "Show.S01E01.mkv".to_owned(), title_key: "show".to_owned(), title_name: "Show".to_owned(), subtitle: "S01E01".to_owned(), season: Some(1), number: Some(1), episode_name: None, resume_at: None, track_prefs: None, }; let key = DownloadKey::from(&target); let (partial, marker_path) = engine.partial_paths(&key); std::fs::create_dir_all(partial.parent().expect("offline parent")) .expect("create offline parent"); std::fs::write(&partial, [0_u8; 7]).expect("write one block and a fragment"); runtime .block_on(write_marker_atomically( &marker_path, &PartialMarker { revision_id: "revision".to_owned(), block_sizes: vec![4, 5], target: Some(target), }, )) .expect("durable marker"); engine.hydrate_partial_downloads(); let downloads = engine.downloads.lock(); let item = downloads.items.get(&key).expect("hydrated row"); assert_eq!(item.state, DownloadState::Cancelled); assert_eq!(item.downloaded, 4, "only a complete block is resumable"); assert_eq!(item.total, 9); drop(downloads); std::fs::remove_dir_all(root).expect("remove temporary app dirs"); } }