//! 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::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn}; 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::profile::ProfileChanges; use pstr_core::proton_drive_rs::ThumbnailType; use pstr_core::proton_sdk::api::HumanVerificationCredential; use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use pstr_core::shares::AccountFolder; use pstr_core::sync::{SyncReport, WatchSync}; use pstr_core::{Account, AccountStore, KeyringSecretStore, Share, ShareStore, SharedLibrary}; use pstr_meta::MetadataService; use crate::desktop_prefs::{DesktopPrefs, DesktopPrefsFile}; use pstr_stream::{ BlockSource, COPY_BLOCKS_IN_FLIGHT, 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; /// The longest edge of a picture drawn across the whole window. 1920 is what /// a provider's fanart is, so nothing is scaled up and a 4K screen gets the /// best there is. pub const WIDE_MAX_EDGE: u32 = 1920; /// 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 { error: String, /// The link has a password of its own and none was given. needs_password: bool, }, /// 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: the library, and when each title last /// gained a file. LibraryLoaded(Library, HashMap), /// A crawl finished for one share. Crawled { share_id: String, nodes: usize, files: usize, /// Files no earlier crawl had seen. added: u64, seconds: f64, /// Started by the app rather than the viewer, so only news is worth /// saying. quiet: bool, }, /// What the last crawl of each share came to, by share id. Crawls(HashMap), /// A crawl is under way: which share, and how many nodes so far. CrawlProgress { share: String, found: usize }, /// Every requested crawl is done. CrawlFinished, /// A download finished; this is what it was. Downloaded(String), /// 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), /// Another device's appearance arrived through sync. Appearance(Appearance), /// A matching run finished, with how it went. Matched { matched: usize, unmatched: usize, failed: usize, }, /// A matching run has work to do — one asked for, or one after a crawl. MatchStarted, /// 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 Proton account signed in or out, or a sign-in wants more. Account(AccountStatus), /// A sign-in step was refused, with why. SignInFailed(String), /// Watch history synced with the account's Drive. Synced(SyncReport), /// Sync did not run, with why. Not a toast: it is retried on a /// timer, and an offline evening would be a stack of them. SyncFailed(String), /// The account's top-level places, for the Drive browser. Places(Vec), /// One folder of the account, for the Drive browser. Folder { uid: NodeUid, entries: Vec, }, /// The Drive browser's last request failed. BrowseFailed(String), } impl Event { /// The add form's refusal of `error`. fn rejected(error: &pstr_core::Error) -> Self { Self::ShareRejected { error: error.to_string(), needs_password: matches!(error, pstr_core::Error::PasswordNeeded), } } } /// Where the Proton account stands. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum AccountStatus { /// The stored session has not been read yet. #[default] Unknown, SignedOut, SignedIn(String), /// A sign-in is waiting for a second-factor code. SecondFactor, /// A sign-in is waiting for the mailbox password. MailboxPassword, } /// 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>, /// False until [`Engine::metadata`] holds what the settings resolve to. /// Only ever false at launch, while the API key is read off the UI /// thread; see [`Engine::new`]. enrichment_settled: 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, /// Settings only the desktop has, and the file they are kept in. desktop: Arc>, desktop_file: Arc, /// Bumped to stop the crawls in flight; each watches it from its start. crawl_cancel: Arc>, accounts: AccountStore, /// The signed-in Proton account, if any. account: Arc>>, /// False until the stored session has been read. Opening the shares waits /// on it, or a launch would open the account's folders before the account /// and report every one of them as failing to. account_settled: Arc>, /// A sign-in waiting for a code or the mailbox password. pending_sign_in: Arc>>, /// `None` when this installation's sync identity could not be written. watch_sync: Option>, 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 often watch history is synced while the app is open, on top of at /// launch and whenever a player stops. const SYNC_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60); /// Unix seconds, now. pub fn unix_now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|since| since.as_secs() as i64) .unwrap_or(0) } /// 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(); let (desktop_file, desktop) = DesktopPrefsFile::load(dirs.desktop_prefs_file()); // 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() }); // A provider with an API key needs it out of the OS credential store, // and that can be a D-Bus round trip to a keyring that has to unlock // first — not something to hold the first frame for. Built on the // blocking pool instead; until it lands, everything that needs the // provider waits for it rather than concluding there is none. let deferred = config.enabled && config.provider.needs_api_key(); let enrichment = if deferred { Enrichment { config: config.clone(), service: None, } } else { Enrichment::build(config.clone()) }; let watch_sync = WatchSync::open(&dirs) .inspect_err(|error| tracing::warn!("watch-history sync unavailable: {error}")) .ok() .map(Arc::new); 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)), enrichment_settled: Arc::new(tokio::sync::watch::channel(!deferred).0), 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), accounts: AccountStore::new(Arc::new(KeyringSecretStore)), account: Arc::new(Mutex::new(None)), account_settled: Arc::new(tokio::sync::watch::channel(false).0), pending_sign_in: Arc::new(Mutex::new(None)), watch_sync, desktop: Arc::new(Mutex::new(desktop)), desktop_file: Arc::new(desktop_file), events, ctx, }; if deferred { let deferred = engine.clone(); engine.runtime.spawn_blocking(move || { deferred.install_enrichment(Enrichment::build(config), true); }); } engine.load_account(); engine.sync_periodically(); 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, off the UI thread: it is a file read, and at /// launch the first frame should not wait on the disk for it. pub fn load_shares(&self) { let engine = self.clone(); self.runtime .spawn_blocking(move || match engine.store.list() { Ok(shares) => engine.emit(Event::Shares(shares)), Err(error) => engine.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. A link that will not open is refused /// before anything is stored, so trying again is not "already configured". 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_verified(&name, &url, password.as_deref()) .await { Ok((share, _)) => share, Err(error) => return engine.emit(Event::rejected(&error)), }; engine.emit(Event::ShareAdded(share.name.clone())); engine.load_shares(); // The open connection predates the share, and crawling it through // that would find it "not open". engine.connection.lock().take(); engine.connect_and_crawl(Some(vec![share.id]), false).await; }); } /// Call a share something else. See [`ShareStore::rename`]. pub fn rename_share(&self, id: String, name: String) { let engine = self.clone(); self.runtime .spawn_blocking(move || match engine.store.rename(&id, &name) { Ok(_) => engine.load_shares(), Err(error) => engine.fail("rename the share", error), }); } /// Re-supply the link behind a share whose stored secret is unreadable — /// a keyring that was reset — or whose custom password changed, keeping /// its catalog, watch state and downloads. See /// [`ShareStore::replace_secrets`]. Then crawl it, which is what proves /// the new secret works. pub fn repair_share(&self, id: String, url: String, password: Option) { let engine = self.clone(); self.runtime.spawn(async move { let share = match engine.store.replace_secrets(&id, &url, password.as_deref()) { Ok(share) => share, Err(error) => { // The add form is not where this was asked, and nothing // will be crawled to say the crawl is over. engine.emit(Event::CrawlFinished); return engine.fail("update the link", error); } }; engine.emit(Event::Status(format!( "updated the link for {}", share.name ))); engine.load_shares(); // Its client was opened with the old secret. engine.connection.lock().take(); engine.connect_and_crawl(Some(vec![share.id]), false).await; }); } /// Add a folder of the signed-in account as a share, then crawl it. pub fn add_account_folder(&self, name: String, folder: NodeUid) { let engine = self.clone(); self.runtime.spawn(async move { let folder = AccountFolder { volume_id: folder.volume_id.as_str().to_owned(), link_id: folder.link_id.as_str().to_owned(), }; let share = match engine.store.add_folder(&name, folder) { Ok(share) => share, Err(error) => return engine.emit(Event::rejected(&error)), }; engine.emit(Event::ShareAdded(share.name.clone())); engine.load_shares(); engine.connection.lock().take(); engine.connect_and_crawl(Some(vec![share.id]), false).await; }); } // --------------------------------------------------------------- account /// Read the stored session, off the UI thread — a keyring read can be a /// D-Bus round trip to a keyring that has to unlock first. fn load_account(&self) { let engine = self.clone(); self.runtime.spawn_blocking(move || { match engine.accounts.resume() { Ok(Some(account)) => { let username = account.username().to_owned(); *engine.account.lock() = Some(account); engine.emit(Event::Account(AccountStatus::SignedIn(username))); engine.sync_watch_history(); } Ok(None) => engine.emit(Event::Account(AccountStatus::SignedOut)), Err(error) => { engine.emit(Event::Account(AccountStatus::SignedOut)); engine.fail("read the Proton session", error); } } engine.account_settled.send_replace(true); }); } /// Start signing in. What happens next arrives as [`Event::Account`] or /// [`Event::SignInFailed`]. pub fn sign_in(&self, username: String, password: String) { let engine = self.clone(); self.runtime.spawn(async move { let mut verification = None; // Twice at most: once as asked, and once more with a solved // CAPTCHA if Proton wanted one. A second gate after that is // Proton refusing the answer, not a puzzle worth repeating. for _ in 0..2 { match engine .accounts .sign_in(&username, &password, verification.take()) .await { Ok(step) => return engine.sign_in_step(step), Err(pstr_core::Error::HumanVerification(challenge)) => { let url = challenge.verification_url(); let token = tokio::task::spawn_blocking(move || crate::verify::solve(&url)) .await .ok() .flatten(); let Some(token) = token else { return engine.emit(Event::SignInFailed( "Proton wants you to confirm you are human before it accepts \ this sign-in. Sign in again to solve the puzzle." .to_owned(), )); }; verification = Some(HumanVerificationCredential::captcha(token)); } Err(error) => return engine.emit(Event::SignInFailed(error.to_string())), } } engine.emit(Event::SignInFailed( "Proton did not accept the verification. Try again in a while.".to_owned(), )); }); } pub fn submit_second_factor(&self, code: String) { let Some(pending) = self.pending_sign_in.lock().take() else { return self.emit(Event::Account(AccountStatus::SignedOut)); }; let engine = self.clone(); self.runtime.spawn(async move { match pending.second_factor(&code).await { Ok(step) => engine.sign_in_step(step), Err(error) => { // The half-made session is spent; the viewer starts over. engine.emit(Event::Account(AccountStatus::SignedOut)); engine.emit(Event::SignInFailed(error.to_string())); } } }); } pub fn submit_mailbox_password(&self, password: String) { let engine = self.clone(); self.runtime.spawn(async move { let Some(pending) = engine.pending_sign_in.lock().take() else { return engine.emit(Event::Account(AccountStatus::SignedOut)); }; match pending.mailbox_password(&password).await { Ok(account) => engine.signed_in(account), Err(error) => { // Kept: a mistyped mailbox password can be typed again. *engine.pending_sign_in.lock() = Some(pending); engine.emit(Event::SignInFailed(error.to_string())); } } }); } pub fn cancel_sign_in(&self) { self.pending_sign_in.lock().take(); self.emit(Event::Account(AccountStatus::SignedOut)); } fn sign_in_step(&self, step: SignIn) { match step { SignIn::Done(account) => self.signed_in(account), SignIn::SecondFactor(pending) => { *self.pending_sign_in.lock() = Some(pending); self.emit(Event::Account(AccountStatus::SecondFactor)); } SignIn::MailboxPassword(pending) => { *self.pending_sign_in.lock() = Some(pending); self.emit(Event::Account(AccountStatus::MailboxPassword)); } } } fn signed_in(&self, account: Account) { let username = account.username().to_owned(); *self.account.lock() = Some(account); if let Some(sync) = &self.watch_sync { sync.reset(); } // Folders of the account that failed to open before are openable now. self.connection.lock().take(); self.emit(Event::Account(AccountStatus::SignedIn(username))); self.connect(); self.sync_watch_history(); } /// Sign out and forget the stored session. The library keeps everything /// it has; the account's folders stop opening until the next sign-in. pub fn sign_out(&self) { let engine = self.clone(); self.runtime.spawn(async move { let account = engine.account.lock().take(); if let Err(error) = engine.accounts.sign_out(account.as_ref()).await { engine.fail("forget the Proton session", error); } engine.signed_out(); }); } fn signed_out(&self) { self.account.lock().take(); if let Some(sync) = &self.watch_sync { sync.reset(); } self.connection.lock().take(); self.emit(Event::Account(AccountStatus::SignedOut)); } /// Sync watch history now, if an account is signed in. pub fn sync_watch_history(&self) { let (Some(account), Some(sync)) = (self.account.lock().clone(), self.watch_sync.clone()) else { return; }; let engine = self.clone(); self.runtime.spawn(async move { match sync.sync(&account, &engine.catalog, &engine.store).await { Ok(report) => { engine.take_synced_profile(&report.profile).await; if report.applied > 0 || report.profile.titles > 0 { engine.load_library(); } engine.emit(Event::Synced(report)); } Err(error) if error.is_signed_out() => { // The refresh token died — revoked from another device, or // unused for too long. Signing in again is the only fix, // and a stored session that can never resume only makes // every later launch fail the same way. let _ = engine.accounts.sign_out(None).await; engine.signed_out(); engine.emit(Event::Error( "Your Proton session ended. Sign in again to keep syncing watch history." .to_owned(), )); } Err(error) => { tracing::warn!("sync watch history: {error}"); engine.emit(Event::SyncFailed(error.to_string())); } } }); } /// Sync once more before the app exits, waiting at most `timeout`. /// /// Where the viewer stopped is what another device wants next, and the /// timer would not get to it. Bounded, because quitting must not hang on /// a slow network; a sync cut short loses nothing, the next launch sends /// the same table. pub fn sync_before_exit(&self, timeout: std::time::Duration) { let (Some(account), Some(sync)) = (self.account.lock().clone(), self.watch_sync.clone()) else { return; }; let catalog = Arc::clone(&self.catalog); let store = Arc::clone(&self.store); self.runtime.block_on(async move { match tokio::time::timeout(timeout, sync.sync(&account, &catalog, &store)).await { Ok(Ok(_)) => {} Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"), Err(_) => tracing::warn!("sync watch history on exit: timed out"), } }); } /// Act on what sync brought from other devices: reread the settings it /// rewrote, crawl the shares it added, drop the ones it removed. async fn take_synced_profile(&self, profile: &ProfileChanges) { if profile.settings { match pstr_core::prefs::load(&self.dirs) { // The volume is this device's and was not synced; the file // still holds it. Ok(prefs) => *self.prefs.lock() = prefs, Err(error) => tracing::warn!("reread the playback preferences: {error}"), } match pstr_core::appearance::load(&self.dirs) { Ok(appearance) => { *self.appearance.lock() = appearance; self.emit(Event::Appearance(appearance)); } Err(error) => tracing::warn!("reread the appearance: {error}"), } match pstr_meta::settings::load(&self.dirs) { Ok(config) => { let engine = self.clone(); let _ = self .runtime .spawn_blocking(move || { engine.install_enrichment(Enrichment::build(config.clone()), false); engine.emit(Event::MetadataConfig(config)); }) .await; } Err(error) => tracing::warn!("reread the metadata settings: {error}"), } } if profile.titles > 0 { match self.catalog.lock().all_title_track_prefs() { Ok(prefs) => *self.track_prefs.lock() = prefs, Err(error) => tracing::warn!("reread show track preferences: {error}"), } self.load_metadata(); } for id in &profile.shares_removed { self.forget_share(id.clone(), false).await; } if !profile.shares() { return; } self.connection.lock().take(); self.load_shares(); self.load_library(); for id in profile.shares_added.iter().chain(&profile.shares_changed) { self.connect_and_crawl(Some(vec![id.clone()]), false).await; } } fn sync_periodically(&self) { let engine = self.clone(); self.runtime.spawn(async move { let mut ticks = tokio::time::interval(SYNC_INTERVAL); // The first tick is immediate, and launch already syncs. ticks.tick().await; loop { ticks.tick().await; engine.sync_watch_history(); } }); } /// List the account's top-level places, for the Drive browser. pub fn browse_places(&self) { let Some(account) = self.account.lock().clone() else { return self.emit(Event::BrowseFailed("not signed in".to_owned())); }; let engine = self.clone(); self.runtime.spawn(async move { match account.places().await { Ok(places) => engine.emit(Event::Places(places)), Err(error) => engine.emit(Event::BrowseFailed(error.to_string())), } }); } /// List one folder of the account, for the Drive browser. pub fn browse_folder(&self, uid: NodeUid) { let Some(account) = self.account.lock().clone() else { return self.emit(Event::BrowseFailed("not signed in".to_owned())); }; let engine = self.clone(); self.runtime.spawn(async move { match account.folder_children(&uid).await { Ok(entries) => engine.emit(Event::Folder { uid, entries }), Err(error) => engine.emit(Event::BrowseFailed(error.to_string())), } }); } /// Forget a share and its stored secrets. pub fn remove_share(&self, id: String) { let engine = self.clone(); self.runtime.spawn(async move { engine.forget_share(id, true).await; engine.connection.lock().take(); engine.load_library(); engine.emit(Event::Status("removed".into())); }); } /// Forget every share the catalog holds files for that the share list no /// longer names — what an older `pstr remove` left behind. Its files would /// otherwise sit in the library beside whatever replaced it, every episode /// twice. pub fn forget_orphaned_shares(&self) { let engine = self.clone(); self.runtime.spawn(async move { let configured = match engine.store.list() { Ok(shares) => shares, Err(error) => return engine.fail("read the share list", error), }; let orphans: Vec = match engine.catalog.lock().share_ids() { Ok(ids) => ids .into_iter() .filter(|id| !configured.iter().any(|share| &share.id == id)) .collect(), Err(error) => return engine.fail("read the catalog", error), }; if orphans.is_empty() { return; } for id in orphans { tracing::info!(share = %id, "forgetting the catalog rows of a removed share"); engine.forget_share(id, false).await; } engine.load_library(); }); } /// Stop a share's downloads and delete its files and catalog rows, and, /// when it is still `configured` — not already removed by sync — its /// entry and secrets. async fn forget_share(&self, id: String, configured: bool) { let engine = self; { 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 configured && 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(); } } // ------------------------------------------------------------ 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 mut settled = self.account_settled.subscribe(); let _ = settled.wait_for(|settled| *settled).await; let account = self.account.lock().clone(); let (library, failures) = match SharedLibrary::open_all(&self.store, account.as_ref()).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()) .with_budget(self.desktop_prefs().cache_bytes()), ); 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()?, catalog.first_seen()?)) }) }; match result { Ok((files, watch, first_seen)) => { let library = Library::build(files, &watch); let targets = download_targets(&library); let added = library.added_at(&first_seen); engine.emit(Event::LibraryLoaded(library, added)); 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.map(|id| vec![id]), false) .await; }); } /// Crawl, quietly, every share no crawl has looked at for /// [`pstr_core::catalog::CRAWL_STALE_AFTER_SECS`] — or ever. /// /// Without this a share only grew when someone thought to press Refresh, /// so the episode that came out yesterday was not in the library until /// then. Quiet: a failure is logged and recorded on the share's row rather /// than toasted, and a crawl that found nothing new says nothing. pub fn crawl_stale(&self) { let engine = self.clone(); self.runtime.spawn(async move { let stale = { let crawls = match engine.catalog.lock().crawls() { Ok(crawls) => crawls, Err(error) => return tracing::warn!(%error, "read crawl records"), }; let Ok(shares) = engine.store.list() else { return; }; let now = unix_now(); shares .into_iter() .filter(|share| { crawls.get(&share.id).is_none_or(|record| { record.is_stale(now, pstr_core::catalog::CRAWL_STALE_AFTER_SECS) }) }) .map(|share| share.id) .collect::>() }; if !stale.is_empty() { engine.connect_and_crawl(Some(stale), true).await; } }); } /// Read what the last crawl of each share came to, for the shares page. pub fn load_crawls(&self) { let engine = self.clone(); self.runtime .spawn_blocking(move || match engine.catalog.lock().crawls() { Ok(crawls) => engine.emit(Event::Crawls(crawls)), Err(error) => tracing::warn!(%error, "read crawl records"), }); } async fn connect_and_crawl(&self, shares: Option>, quiet: bool) { let Some((library, _)) = self.ensure_connected().await else { self.emit(Event::CrawlFinished); return; }; let targets: Vec = match shares { Some(ids) => ids, 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(), quiet)), ) .await; self.emit(Event::CrawlFinished); self.match_after_crawl().await; } /// Look up whatever the crawl brought that has no answer yet — a new show, /// or a new episode the stored guide cannot name. Silent when there is /// nothing to ask, which is almost every time: `plan` makes no requests for /// a library that is already matched. async fn match_after_crawl(&self) { if !self.metadata_config().enabled { return; } let titles = { let catalog = self.catalog.lock(); match catalog .all_files() .and_then(|files| Ok(Library::build(files, &catalog.all_watch_states()?))) { Ok(library) => library.titles, Err(error) => return self.fail("read the catalog", error), } }; if titles.is_empty() { return; } self.run_match(titles, false, true).await; self.emit(Event::MatchFinished); } /// 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, quiet: bool, ) { 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.reauthenticating(&share_id, || { library.crawl_reporting(&share_id, &report) }) => match result { Ok(nodes) => nodes, Err(error) => { if let Err(error) = self .catalog .lock() .record_crawl_failure(&share_id, &error.describe()) { tracing::warn!(%error, "record a failed crawl"); } self.load_crawls(); if quiet { tracing::warn!(%error, "crawl {name}"); } else { self.fail(&format!("crawl {name}"), 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; } let added = self .catalog .lock() .crawls() .ok() .and_then(|crawls| crawls.get(&share_id).map(|record| record.added)) .unwrap_or(0); self.emit(Event::Crawled { share_id, nodes: nodes.len(), files, added, seconds: started.elapsed().as_secs_f64(), quiet, }); self.load_crawls(); 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((library, source)) = engine.ensure_connected().await else { return; }; let uid = node_uid(&target.volume_id, &target.link_id); let opened = library .reauthenticating(&target.share_id, || source.open(&target.share_id, &uid)) .await; match opened { 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 — and, resuming, on the // one it is about to seek to. match engine.resume_fraction(&target) { Some(fraction) => stream.warm_resume(fraction), None => stream.warm_edges(), } engine.emit(Event::PlaybackReady { target: Box::new(target), stream, }) } Err(error) => engine.fail(&format!("open {}", target.name), error), } }); } /// How far into the file `target` resumes, as a fraction of its running /// time — known from the last time it played. fn resume_fraction(&self, target: &PlaybackTarget) -> Option { let at = target.resume_at?; let state = self .catalog .lock() .watch_state(&target.share_id, &target.link_id) .ok() .flatten()?; let duration = state.duration_secs.filter(|duration| *duration > 0.0)?; Some(at / duration) } /// 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((library, source)) = engine.opened() else { return; }; let opened = library .reauthenticating(&share_id, || source.open(&share_id, &uid)) .await; match opened { 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::Downloaded(task_target.caption_or_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((library, source)) = self.ensure_connected().await else { anyhow::bail!("share is not connected"); }; let uid = node_uid(&target.volume_id, &target.link_id); let stream = library .reauthenticating(&target.share_id, || { source.open_for_copy(&target.share_id, &uid) }) .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(|| stream.blocks_in_order(block_index, COPY_BLOCKS_IN_FLIGHT)); 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 { pub fn desktop_prefs(&self) -> DesktopPrefs { *self.desktop.lock() } /// Change the desktop-only settings, write them, and apply what can be /// applied now. Hardware decoding takes effect from the next file. pub fn set_desktop_prefs(&self, prefs: DesktopPrefs) { *self.desktop.lock() = prefs; let engine = self.clone(); self.runtime.spawn(async move { let file = Arc::clone(&engine.desktop_file); let saved = tokio::task::spawn_blocking(move || file.save(&prefs)).await; if let Ok(Err(error)) = saved { engine.fail("save the desktop settings", error); } if let Some((_, source)) = engine.opened() { source.set_disk_budget(prefs.cache_bytes()).await; } }); } /// How much the streaming cache holds, once the shares are open. pub fn cache_used(&self) -> Option { let (_, source) = self.opened()?; source.disk_stats().map(|stats| stats.stored_bytes) } /// Empty the streaming cache. pub fn clear_stream_cache(&self) { let engine = self.clone(); self.runtime.spawn(async move { match engine.opened() { Some((_, source)) => source.clear_disk_cache().await, // Not open, so nothing holds an index of it to fall out of step // with: the directory can simply go. None => { let root = engine.dirs.block_cache(); if let Err(error) = tokio::fs::remove_dir_all(&root).await && error.kind() != std::io::ErrorKind::NotFound { return engine.fail("clear the streaming cache", error); } } } engine.emit(Event::Status("cleared the streaming cache".into())); }); } /// Put `built` in place and mark the provider settled. /// /// `only_if_unsettled` is for the launch-time build: a settings change /// that landed while it was reading the keyring is newer, and wins. The /// check and the write share the lock, so the two cannot interleave. fn install_enrichment(&self, built: Enrichment, only_if_unsettled: bool) { let mut slot = self.metadata.lock(); if only_if_unsettled && *self.enrichment_settled.borrow() { return; } *slot = built; self.enrichment_settled.send_replace(true); } /// The provider, once the launch-time build has settled. `None` when /// enrichment is off or has no usable key. async fn metadata_service(&self) -> Option { let mut settled = self.enrichment_settled.subscribe(); let _ = settled.wait_for(|settled| *settled).await; self.metadata.lock().service.clone() } /// Why there is no provider, for the viewer who asked for one. fn no_service_reason(&self) -> String { let config = self.metadata_config(); if !config.enabled { "turn on metadata enrichment first".into() } else { format!("{} needs an API key", config.provider.label()) } } /// 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.install_enrichment(Enrichment::build(config.clone()), false); 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.install_enrichment(Enrichment::build(config), false); 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, false).await; engine.emit(Event::MatchFinished); }); } /// `automatic` is a run nobody asked for, after a crawl: it says nothing /// when there is nothing to do, or no provider to do it with. async fn run_match(&self, titles: Vec<Title>, force: bool, automatic: bool) { let Some(service) = self.metadata_service().await else { if automatic { return; } return self.emit(Event::Error(self.no_service_reason())); }; let provider = service.provider(); let (stored, enriched) = { let catalog = self.catalog.lock(); match catalog .all_metadata() .and_then(|stored| Ok((stored, catalog.enrichment_ages()?))) { Ok(pair) => pair, Err(error) => return self.fail("read stored metadata", error), } }; let pending = pstr_meta::service::plan( titles, &stored, &enriched, provider, service.has_details(), force, ); if pending.is_empty() { if automatic { return; } return self.emit(Event::Status("everything is already matched".into())); } self.emit(Event::MatchStarted); 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(service.work(work).await) }); } use futures::StreamExt as _; let mut episodes_found = 0usize; while let Some(result) = lookups.next().await { match result { Some(Ok(outcome)) => { if let Some(record) = &outcome.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 let Some(enrichment) = &outcome.enrichment { episodes_found += enrichment.episodes.len(); let stored = self.catalog.lock().set_enrichment( &outcome.title_key, provider, now(), enrichment, ); if let Err(error) = stored { tracing::warn!("store enrichment for {}: {error}", outcome.title_key); } } } 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 engine = self.clone(); let permits = Arc::clone(&self.lookups); self.runtime.spawn(async move { let Some(service) = engine.metadata_service().await else { let error = engine.no_service_reason(); return engine.emit(Event::MatchSearchFailed { title_key, error }); }; // 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 engine = self.clone(); self.runtime.spawn(async move { let Some(service) = engine.metadata_service().await else { return engine.emit(Event::Error(engine.no_service_reason())); }; let name = found.name.clone(); let outcome = service.choose(&title, found).await; if let Some(record) = &outcome.record && let Err(error) = engine.catalog.lock().set_metadata(record) { return engine.fail("store the match", error); } // A failure leaves the title un-enriched, and the next match run // asks again. if let Some(enrichment) = &outcome.enrichment { let stored = engine.catalog.lock().set_enrichment( &title.key, service.provider(), now(), enrichment, ); if let Err(error) = stored { tracing::warn!("store enrichment 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(); }); } /// Where a provider's picture is cached, if it is. pub fn poster_file(&self, url: &str) -> Option<std::path::PathBuf> { let path = self .dirs .poster_cache() .join(format!("{}.img", digest(url))); path.is_file().then_some(path) } /// 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) { self.request_art(title_key, url, THUMBNAIL_MAX_EDGE); } /// [`Engine::request_poster`], for a picture drawn across the window — /// the library's hero, the title page's band — kept at up to `max_edge` /// pixels rather than a tile's 640. A 640 px still stretched to 1600 was /// most of why those looked soft. pub fn request_art(&self, title_key: String, url: String, max_edge: u32) { let engine = self.clone(); // A size of its own on disk: the tile-sized copy of the same URL is // already there under the plain name, scaled down. let name = match max_edge { THUMBNAIL_MAX_EDGE => format!("{}.img", digest(&url)), edge => format!("{}-{edge}.img", digest(&url)), }; let path = self.dirs.poster_cache().join(name); self.runtime.spawn(async move { if let Ok(bytes) = tokio::fs::read(&path).await { match decode_scaled(&bytes, max_edge) { 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_service().await 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_scaled(&bytes, max_edge) { 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() } /// 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) } 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> { decode_scaled(bytes, THUMBNAIL_MAX_EDGE) } /// [`decode_thumbnail`], to `max_edge` rather than a tile's size. fn decode_scaled(bytes: &[u8], max_edge: u32) -> anyhow::Result<Decoded> { let decoded = image::load_from_memory(bytes)?; let (decoded, smaller) = if decoded.width().max(decoded.height()) > max_edge { let scaled = decoded.thumbnail(max_edge, 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"); } }