Files
proton-stream/crates/pstr-app/src/engine.rs
T
nvrl 85bc975f5f fix(core,android,app): say why a crawl failed, not only which URL
A transport failure's text is reqwest's "error sending request for url
(…)", and the cause sits further down its source chain. Error::describe
appends the causes and drops the URL, so the reason fits on a share row.
2026-10-13 20:45:14 +02:00

3324 lines
128 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<f64> {
(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<DownloadCommand>,
}
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<DownloadCommand> {
self.command.subscribe()
}
}
struct DownloadJob {
control: Arc<DownloadControl>,
handle: tokio::task::JoinHandle<()>,
live: Arc<AtomicBool>,
}
#[derive(Clone, Copy)]
enum DownloadEnd {
Completed,
Cancelled,
}
#[derive(Default)]
struct Downloads {
items: HashMap<DownloadKey, DownloadItem>,
jobs: HashMap<DownloadKey, DownloadJob>,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct PartialMarker {
revision_id: String,
block_sizes: Vec<u64>,
#[serde(default)]
target: Option<PlaybackTarget>,
}
/// 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<Share>),
/// 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<String, i64>),
/// 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<String, pstr_core::catalog::CrawlRecord>),
/// 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<String, MetadataRecord>),
/// What the provider said about individual episodes, keyed by title key.
EpisodeMetadata(HashMap<String, EpisodeGuide>),
/// 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<pstr_core::metadata::TitleMetadata>,
},
/// 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<PlaybackTarget>,
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<DownloadItem>),
/// 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<DownloadItem>),
/// Completed local copies, for badges and online-only actions.
OfflineFiles(HashSet<DownloadKey>),
/// 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<DrivePlace>),
/// One folder of the account, for the Drive browser.
Folder {
uid: NodeUid,
entries: Vec<DriveEntry>,
},
/// 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<SharedLibrary>,
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<Runtime>,
dirs: AppDirs,
store: Arc<ShareStore>,
/// 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<Mutex<Option<Connection>>>,
/// 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<tokio::sync::Mutex<()>>,
/// `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<Mutex<Catalog>>,
/// 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<tokio::sync::Semaphore>,
/// 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<tokio::sync::Semaphore>,
/// 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<Mutex<Enrichment>>,
/// 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<tokio::sync::watch::Sender<bool>>,
/// 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<Mutex<PlaybackPrefs>>,
/// 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<Mutex<Appearance>>,
/// Preloaded so clicking Play never runs SQLite on egui's thread.
track_prefs: Arc<Mutex<HashMap<String, TitleTrackPrefs>>>,
downloads: Arc<Mutex<Downloads>>,
download_permits: Arc<tokio::sync::Semaphore>,
/// Whether the player page is on screen. See [`Engine::wake`].
picture_shown: Arc<AtomicBool>,
/// Settings only the desktop has, and the file they are kept in.
desktop: Arc<Mutex<DesktopPrefs>>,
desktop_file: Arc<DesktopPrefsFile>,
/// Bumped to stop the crawls in flight; each watches it from its start.
crawl_cancel: Arc<tokio::sync::watch::Sender<u64>>,
accounts: AccountStore,
/// The signed-in Proton account, if any.
account: Arc<Mutex<Option<Account>>>,
/// 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<tokio::sync::watch::Sender<bool>>,
/// A sign-in waiting for a code or the mailbox password.
pending_sign_in: Arc<Mutex<Option<PendingSignIn>>>,
/// `None` when this installation's sync identity could not be written.
watch_sync: Option<Arc<WatchSync>>,
events: Sender<Event>,
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<MetadataService>,
}
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<Runtime>,
dirs: AppDirs,
ctx: egui::Context,
) -> anyhow::Result<(Self, Receiver<Event>)> {
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<Runtime> {
&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<String>) {
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<String>) {
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<String> = 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<SharedLibrary>, 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<SharedLibrary>, 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<HashMap<DownloadKey, PlaybackTarget>>) {
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<String>) {
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::<Vec<_>>()
};
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<Vec<String>>, quiet: bool) {
let Some((library, _)) = self.ensure_connected().await else {
self.emit(Event::CrawlFinished);
return;
};
let targets: Vec<String> = 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<u64>,
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<TitleTrackPrefs> {
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::<u64>())
.unwrap_or(false)
{
let blocks: std::sync::Arc<dyn BlockSource> = 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<f64> {
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<PlaybackTarget>) {
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<Arc<DownloadControl>> {
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::<PartialMarker>(&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<DownloadControl>,
) -> anyhow::Result<DownloadEnd> {
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::<PartialMarker>(&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<u64>,
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<u64> {
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<MetadataService> {
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<Title>, 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");
}
}