mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
2148 lines
82 KiB
Rust
2148 lines
82 KiB
Rust
//! 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};
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
|
|
|
use parking_lot::Mutex;
|
|
use pstr_core::appearance::Appearance;
|
|
use pstr_core::catalog::{
|
|
Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows,
|
|
};
|
|
use pstr_core::config::AppDirs;
|
|
use pstr_core::library::{Library, Title, TitleKind};
|
|
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
|
|
use pstr_core::prefs::PlaybackPrefs;
|
|
use pstr_core::proton_drive_rs::ThumbnailType;
|
|
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
|
use pstr_core::{Share, ShareStore, SharedLibrary};
|
|
use pstr_meta::MetadataService;
|
|
use pstr_stream::{
|
|
BlockSource, DiskCacheConfig, FileBlocks, LibraryOpener, StreamConfig, StreamSource,
|
|
VideoStream,
|
|
};
|
|
use tokio::runtime::Runtime;
|
|
|
|
use crate::playback::PlaybackTarget;
|
|
|
|
/// Stable identity for an episode download. No share secret is included.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
|
pub struct DownloadKey {
|
|
pub share_id: String,
|
|
pub link_id: String,
|
|
}
|
|
|
|
impl From<&PlaybackTarget> for DownloadKey {
|
|
fn from(target: &PlaybackTarget) -> Self {
|
|
Self {
|
|
share_id: target.share_id.clone(),
|
|
link_id: target.link_id.clone(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A download state suitable for presenting directly to the viewer.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum DownloadState {
|
|
Queued,
|
|
Running,
|
|
Paused,
|
|
Completed,
|
|
Failed(String),
|
|
/// Work stopped, but its complete-block partial is retained for Resume.
|
|
Cancelled,
|
|
}
|
|
|
|
/// Current, inspectable state for one episode in a download batch.
|
|
#[derive(Debug, Clone)]
|
|
pub struct DownloadItem {
|
|
pub key: DownloadKey,
|
|
pub target: PlaybackTarget,
|
|
pub state: DownloadState,
|
|
pub downloaded: u64,
|
|
pub total: u64,
|
|
}
|
|
|
|
impl DownloadItem {
|
|
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;
|
|
|
|
/// What the background side tells the UI.
|
|
pub enum Event {
|
|
/// The share list changed on disk.
|
|
Shares(Vec<Share>),
|
|
/// Shares opened; playback is possible from here.
|
|
Connected {
|
|
/// Shares that did not open, with why.
|
|
failures: Vec<(String, String)>,
|
|
},
|
|
/// Shares could not be opened at all.
|
|
ConnectFailed(String),
|
|
/// The catalog was re-read.
|
|
LibraryLoaded(Library),
|
|
/// A crawl finished for one share.
|
|
Crawled {
|
|
share_id: String,
|
|
nodes: usize,
|
|
files: usize,
|
|
seconds: f64,
|
|
},
|
|
/// Every requested crawl is done.
|
|
CrawlFinished,
|
|
/// 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),
|
|
/// A matching run finished, with how it went.
|
|
Matched {
|
|
matched: usize,
|
|
unmatched: usize,
|
|
failed: usize,
|
|
},
|
|
/// Every requested match is done.
|
|
MatchFinished,
|
|
/// What the provider answered a hand search with, unscored and in its own
|
|
/// order. `title_key` says which title asked, because the viewer can close
|
|
/// the search and open another one while a request is still out.
|
|
MatchOptions {
|
|
title_key: String,
|
|
options: Vec<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 },
|
|
/// 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>),
|
|
/// Completed local copies, for badges and online-only actions.
|
|
OfflineFiles(HashSet<DownloadKey>),
|
|
}
|
|
|
|
/// 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>>,
|
|
/// 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>,
|
|
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;
|
|
|
|
/// 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();
|
|
|
|
// An unreadable settings file must not stop the app starting: the
|
|
// library is the point and enrichment is decoration. It is reported and
|
|
// treated as "off", which is also the safe reading — never as "on".
|
|
let config = pstr_meta::settings::load(&dirs).unwrap_or_else(|error| {
|
|
tracing::warn!("read the metadata settings: {error}");
|
|
MetadataConfig::default()
|
|
});
|
|
|
|
// Same reading as the metadata settings above: a preferences file that
|
|
// will not parse is worth a line in the log and nothing more. It is
|
|
// volume and a language, and the defaults play the film.
|
|
let prefs = pstr_core::prefs::load(&dirs).unwrap_or_else(|error| {
|
|
tracing::warn!("read the playback preferences: {error}");
|
|
PlaybackPrefs::default()
|
|
});
|
|
|
|
// And the same again for the theme, which is the least load-bearing
|
|
// file of the three: an unreadable one costs the viewer their colours
|
|
// until they pick them again.
|
|
let appearance = pstr_core::appearance::load(&dirs).unwrap_or_else(|error| {
|
|
tracing::warn!("read the appearance: {error}");
|
|
Appearance::default()
|
|
});
|
|
|
|
let track_prefs = catalog.all_title_track_prefs().unwrap_or_else(|error| {
|
|
tracing::warn!("read show track preferences: {error}");
|
|
HashMap::new()
|
|
});
|
|
let engine = Self {
|
|
runtime,
|
|
store: Arc::new(ShareStore::new(dirs.clone())),
|
|
dirs,
|
|
connection: Arc::new(Mutex::new(None)),
|
|
connecting: Arc::new(tokio::sync::Mutex::new(())),
|
|
catalog: Arc::new(Mutex::new(catalog)),
|
|
thumbnails: Arc::new(tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY)),
|
|
lookups: Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)),
|
|
metadata: Arc::new(Mutex::new(Enrichment::build(config))),
|
|
prefs: Arc::new(Mutex::new(prefs)),
|
|
appearance: Arc::new(Mutex::new(appearance)),
|
|
track_prefs: Arc::new(Mutex::new(track_prefs)),
|
|
downloads: Arc::new(Mutex::new(Downloads::default())),
|
|
download_permits: Arc::new(tokio::sync::Semaphore::new(DOWNLOAD_CONCURRENCY)),
|
|
events,
|
|
ctx,
|
|
};
|
|
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) {
|
|
if self.events.send(event).is_ok() {
|
|
self.ctx.request_repaint();
|
|
}
|
|
}
|
|
|
|
fn fail(&self, context: &str, error: impl std::fmt::Display) {
|
|
tracing::warn!("{context}: {error}");
|
|
self.emit(Event::Error(format!("{context}: {error}")));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- shares
|
|
|
|
/// Re-read the share list.
|
|
pub fn load_shares(&self) {
|
|
match self.store.list() {
|
|
Ok(shares) => self.emit(Event::Shares(shares)),
|
|
Err(error) => self.fail("read the share list", error),
|
|
}
|
|
}
|
|
|
|
/// Add a share, then open and crawl it — which is what "add" means to
|
|
/// someone who just pasted a link.
|
|
pub fn add_share(&self, name: String, url: String, password: Option<String>) {
|
|
let engine = self.clone();
|
|
self.runtime.spawn(async move {
|
|
let share = match engine.store.add(&name, &url, password.as_deref()) {
|
|
Ok(share) => share,
|
|
Err(error) => return engine.fail("add that share", error),
|
|
};
|
|
engine.emit(Event::Status(format!("added {}", share.name)));
|
|
engine.load_shares();
|
|
engine.connect_and_crawl(Some(share.id)).await;
|
|
});
|
|
}
|
|
|
|
/// Forget a share and its stored secrets.
|
|
pub fn remove_share(&self, id: String) {
|
|
let engine = self.clone();
|
|
self.runtime.spawn(async move {
|
|
let keys: Vec<_> = engine
|
|
.downloads
|
|
.lock()
|
|
.jobs
|
|
.keys()
|
|
.filter(|key| key.share_id == id)
|
|
.cloned()
|
|
.collect();
|
|
for key in &keys {
|
|
engine.cancel_and_join(key).await;
|
|
}
|
|
|
|
let offline = match engine.catalog.lock().offline_files_for_share(&id) {
|
|
Ok(files) => files,
|
|
Err(error) => return engine.fail("inspect that share's downloads", error),
|
|
};
|
|
for (link_id, file) in &offline {
|
|
let path = engine.dirs.offline_file(&id, link_id, &file.revision_id);
|
|
if let Err(error) = remove_if_present(&path).await {
|
|
return engine.fail("delete that share's offline bytes", error);
|
|
}
|
|
}
|
|
let item_keys: Vec<_> = engine
|
|
.downloads
|
|
.lock()
|
|
.items
|
|
.keys()
|
|
.filter(|key| key.share_id == id)
|
|
.cloned()
|
|
.collect();
|
|
for key in &item_keys {
|
|
let (partial, marker) = engine.partial_paths(key);
|
|
if let Err(error) = remove_if_present(&partial).await {
|
|
return engine.fail("delete that share's partial download", error);
|
|
}
|
|
if let Err(error) = remove_if_present(&marker).await {
|
|
return engine.fail("delete that share's partial marker", error);
|
|
}
|
|
}
|
|
if let Err(error) = engine.catalog.lock().remove_share(&id) {
|
|
return engine.fail("drop that share's catalog rows", error);
|
|
}
|
|
if let Err(error) = engine.store.remove(&id) {
|
|
return engine.fail("remove that share", error);
|
|
}
|
|
{
|
|
let mut downloads = engine.downloads.lock();
|
|
downloads.items.retain(|key, _| key.share_id != id);
|
|
downloads.jobs.retain(|key, _| key.share_id != id);
|
|
}
|
|
engine.emit_downloads();
|
|
engine.load_shares();
|
|
// Its clients are stale now; the next action reopens what is left.
|
|
engine.connection.lock().take();
|
|
engine.load_library();
|
|
engine.emit(Event::Status("removed".into()));
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------ connection
|
|
|
|
/// The open connection, if there is one. Never held across an await — the
|
|
/// guard is `parking_lot`'s.
|
|
fn opened(&self) -> Option<(Arc<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 (library, failures) = match SharedLibrary::open_all(&self.store).await {
|
|
Ok(opened) => opened,
|
|
Err(error) => {
|
|
self.emit(Event::ConnectFailed(error.to_string()));
|
|
return None;
|
|
}
|
|
};
|
|
let library = Arc::new(library);
|
|
|
|
let opener = Arc::new(LibraryOpener::new(Arc::clone(&library)));
|
|
let config = StreamConfig::default()
|
|
// mpv brings its own read-ahead; a second one under it only competes
|
|
// for the bandwidth mpv is blocked on. See `pstr_player::READAHEAD_BLOCKS`.
|
|
.with_readahead(pstr_player::READAHEAD_BLOCKS)
|
|
.with_disk_cache(DiskCacheConfig::new(self.dirs.block_cache()));
|
|
|
|
let source = match StreamSource::new(opener, config).await {
|
|
Ok(source) => source,
|
|
Err(error) => {
|
|
self.emit(Event::ConnectFailed(error.to_string()));
|
|
return None;
|
|
}
|
|
};
|
|
|
|
*self.connection.lock() = Some(Connection {
|
|
library: Arc::clone(&library),
|
|
source: source.clone(),
|
|
});
|
|
|
|
self.emit(Event::Connected {
|
|
failures: failures
|
|
.into_iter()
|
|
.map(|(share, error)| (share.name, error.to_string()))
|
|
.collect(),
|
|
});
|
|
Some((library, source))
|
|
}
|
|
|
|
// ------------------------------------------------------------- catalogue
|
|
|
|
/// Re-read the catalog into a [`Library`].
|
|
pub fn load_library(&self) {
|
|
let engine = self.clone();
|
|
self.runtime.spawn_blocking(move || {
|
|
let result = {
|
|
let catalog = engine.catalog.lock();
|
|
catalog
|
|
.all_files()
|
|
.and_then(|files| Ok((files, catalog.all_watch_states()?)))
|
|
};
|
|
match result {
|
|
Ok((files, watch)) => {
|
|
engine.emit(Event::LibraryLoaded(Library::build(files, &watch)));
|
|
engine.load_offline_files();
|
|
}
|
|
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 || {
|
|
let result = (|| {
|
|
let catalog = engine.catalog.lock();
|
|
let files = catalog.all_offline_files()?;
|
|
let library = Library::build(catalog.all_files()?, &catalog.all_watch_states()?);
|
|
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);
|
|
}
|
|
}
|
|
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 =
|
|
engine
|
|
.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,
|
|
});
|
|
}
|
|
} 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 = engine.downloads.lock();
|
|
for item in hydrated {
|
|
downloads.items.entry(item.key.clone()).or_insert(item);
|
|
}
|
|
}
|
|
engine.hydrate_partial_downloads();
|
|
engine.emit_downloads();
|
|
engine.emit(Event::OfflineFiles(files));
|
|
}
|
|
Err(error) => engine.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).await;
|
|
});
|
|
}
|
|
|
|
async fn connect_and_crawl(&self, share: Option<String>) {
|
|
let Some((library, _)) = self.ensure_connected().await else {
|
|
self.emit(Event::CrawlFinished);
|
|
return;
|
|
};
|
|
|
|
let targets: Vec<String> = match share {
|
|
Some(id) => vec![id],
|
|
None => library.share_ids().map(str::to_string).collect(),
|
|
};
|
|
|
|
for share_id in targets {
|
|
let started = std::time::Instant::now();
|
|
let nodes = match library.crawl(&share_id).await {
|
|
Ok(nodes) => nodes,
|
|
Err(error) => {
|
|
self.fail(&format!("crawl {share_id}"), error);
|
|
continue;
|
|
}
|
|
};
|
|
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);
|
|
continue;
|
|
}
|
|
};
|
|
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 {
|
|
continue;
|
|
}
|
|
|
|
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);
|
|
continue;
|
|
}
|
|
|
|
self.emit(Event::Crawled {
|
|
share_id,
|
|
nodes: nodes.len(),
|
|
files,
|
|
seconds: started.elapsed().as_secs_f64(),
|
|
});
|
|
self.load_library();
|
|
}
|
|
self.emit(Event::CrawlFinished);
|
|
}
|
|
|
|
/// 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}");
|
|
}
|
|
}
|
|
|
|
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((_, source)) = engine.ensure_connected().await else {
|
|
return;
|
|
};
|
|
let uid = node_uid(&target.volume_id, &target.link_id);
|
|
match source.open(&target.share_id, &uid).await {
|
|
Ok(stream) if stream.size() == 0 => {
|
|
engine.emit(Event::Error(format!("{} has no content", target.name)));
|
|
}
|
|
Ok(stream) => engine.emit(Event::PlaybackReady {
|
|
target: Box::new(target),
|
|
stream,
|
|
}),
|
|
Err(error) => engine.fail(&format!("open {}", target.name), error),
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 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),
|
|
},
|
|
);
|
|
}
|
|
self.emit_downloads();
|
|
let engine = self.clone();
|
|
let task_live = Arc::clone(&live);
|
|
let task_target = target.clone();
|
|
let task_control = Arc::clone(&control);
|
|
let handle = self.runtime.spawn(async move {
|
|
let result = engine.download_one(task_target.clone(), task_control).await;
|
|
task_live.store(false, Ordering::Release);
|
|
let task_key = DownloadKey::from(&task_target);
|
|
match result {
|
|
Ok(DownloadEnd::Completed) => {
|
|
engine.update_download(&task_key, |item| {
|
|
item.downloaded = item.total;
|
|
item.state = DownloadState::Completed;
|
|
});
|
|
engine.load_offline_files();
|
|
engine.emit(Event::Status(format!(
|
|
"{} is available offline",
|
|
task_target.name
|
|
)));
|
|
}
|
|
Ok(DownloadEnd::Cancelled) => {
|
|
engine.update_download(&task_key, |item| item.state = DownloadState::Cancelled);
|
|
}
|
|
Err(error) => {
|
|
engine.update_download(&task_key, |item| {
|
|
item.state = DownloadState::Failed(error.to_string());
|
|
});
|
|
}
|
|
}
|
|
});
|
|
self.downloads.lock().jobs.insert(
|
|
key,
|
|
DownloadJob {
|
|
control,
|
|
handle,
|
|
live,
|
|
},
|
|
);
|
|
}
|
|
|
|
pub fn pause_download(&self, key: &DownloadKey) {
|
|
if let Some(control) = self.live_control(key) {
|
|
control.set(DownloadCommand::Pause);
|
|
self.update_download(key, |item| item.state = DownloadState::Paused);
|
|
}
|
|
}
|
|
|
|
pub fn resume_download(&self, key: &DownloadKey) {
|
|
let control = self.live_control(key);
|
|
if let Some(control) = control {
|
|
control.set(DownloadCommand::Run);
|
|
self.update_download(key, |item| item.state = DownloadState::Running);
|
|
return;
|
|
}
|
|
if let Some(target) = self
|
|
.downloads
|
|
.lock()
|
|
.items
|
|
.get(key)
|
|
.map(|item| item.target.clone())
|
|
{
|
|
self.make_offline(vec![target]);
|
|
}
|
|
}
|
|
|
|
/// Stop network work after the current complete block. The partial remains
|
|
/// valid and Resume starts at its next block.
|
|
pub fn cancel_download(&self, key: &DownloadKey) {
|
|
if let Some(control) = self.live_control(key) {
|
|
control.set(DownloadCommand::Cancel);
|
|
}
|
|
}
|
|
|
|
fn live_control(&self, key: &DownloadKey) -> Option<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();
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
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((_, source)) = self.ensure_connected().await else {
|
|
anyhow::bail!("share is not connected");
|
|
};
|
|
let stream = source
|
|
.open(
|
|
&target.share_id,
|
|
&node_uid(&target.volume_id, &target.link_id),
|
|
)
|
|
.await?;
|
|
let revision_id = stream.revision_id().to_owned();
|
|
let block_sizes = stream.block_sizes().to_vec();
|
|
let total = block_sizes
|
|
.iter()
|
|
.try_fold(0_u64, |sum, size| sum.checked_add(*size))
|
|
.ok_or_else(|| anyhow::anyhow!("offline file size overflow"))?;
|
|
self.update_download(&key, |item| item.total = total);
|
|
|
|
let destination = self
|
|
.dirs
|
|
.offline_file(&target.share_id, &target.link_id, &revision_id);
|
|
let parent = destination
|
|
.parent()
|
|
.ok_or_else(|| anyhow::anyhow!("offline file has no parent"))?;
|
|
tokio::fs::create_dir_all(parent).await?;
|
|
if tokio::fs::metadata(&destination)
|
|
.await
|
|
.is_ok_and(|metadata| metadata.len() == total)
|
|
{
|
|
let (partial, marker) = self.partial_paths(&key);
|
|
remove_if_present(&partial).await?;
|
|
self.finish_download(&key, &target, revision_id, block_sizes, total, &marker)
|
|
.await?;
|
|
return Ok(DownloadEnd::Completed);
|
|
}
|
|
|
|
let (partial, marker_path) = self.partial_paths(&key);
|
|
let marker = PartialMarker {
|
|
revision_id: revision_id.clone(),
|
|
block_sizes: block_sizes.clone(),
|
|
target: Some(target.clone()),
|
|
};
|
|
let matches = tokio::fs::read(&marker_path)
|
|
.await
|
|
.ok()
|
|
.and_then(|bytes| serde_json::from_slice::<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);
|
|
|
|
let mut last_snapshot = std::time::Instant::now();
|
|
while block_index < block_sizes.len() {
|
|
if !wait_until_runnable(&mut command).await {
|
|
output.sync_all().await?;
|
|
return Ok(DownloadEnd::Cancelled);
|
|
}
|
|
let size = block_sizes[block_index];
|
|
let bytes = stream.read_range(offset, size).await?;
|
|
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()
|
|
{
|
|
self.update_download(&key, |item| {
|
|
item.state = DownloadState::Running;
|
|
item.downloaded = offset;
|
|
});
|
|
last_snapshot = std::time::Instant::now();
|
|
}
|
|
}
|
|
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(image) => return engine.emit(Event::Thumbnail { key, 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 });
|
|
};
|
|
|
|
if let Some(parent) = path.parent() {
|
|
let _ = tokio::fs::create_dir_all(parent).await;
|
|
}
|
|
let _ = tokio::fs::write(&path, &bytes).await;
|
|
|
|
match decode_thumbnail(&bytes) {
|
|
Ok(image) => engine.emit(Event::Thumbnail { key, image }),
|
|
Err(error) => {
|
|
tracing::debug!("decode thumbnail {key}: {error}");
|
|
engine.emit(Event::ThumbnailMissing { key });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- enrichment
|
|
|
|
impl Engine {
|
|
/// The enrichment settings as they stand.
|
|
pub fn metadata_config(&self) -> MetadataConfig {
|
|
self.metadata.lock().config.clone()
|
|
}
|
|
|
|
/// Whether a provider is configured *and* usable right now.
|
|
///
|
|
/// False for "switched off" and for "TMDB with no API key stored" alike —
|
|
/// the UI distinguishes them from the config, not from here.
|
|
pub fn enrichment_ready(&self) -> bool {
|
|
self.metadata.lock().service.is_some()
|
|
}
|
|
|
|
/// Change the settings, write them, and rebuild the provider.
|
|
///
|
|
/// Turning enrichment *off* clears the stored answers as well. That is the
|
|
/// point of the switch: someone who turns this off has decided they would
|
|
/// rather the third party's answers were not on their disk either, and
|
|
/// leaving them there would make "off" mean only "stop asking".
|
|
pub fn set_metadata_config(&self, config: MetadataConfig) {
|
|
let engine = self.clone();
|
|
self.runtime.spawn_blocking(move || {
|
|
let previous = engine.metadata_config();
|
|
if let Err(error) = pstr_meta::settings::save(&engine.dirs, &config) {
|
|
return engine.fail("save the metadata settings", error);
|
|
}
|
|
|
|
let forget = !config.enabled || config.provider != previous.provider;
|
|
if forget && let Err(error) = engine.catalog.lock().clear_metadata() {
|
|
tracing::warn!("clear stored metadata: {error}");
|
|
}
|
|
|
|
*engine.metadata.lock() = Enrichment::build(config.clone());
|
|
engine.emit(Event::MetadataConfig(config));
|
|
engine.load_metadata();
|
|
});
|
|
}
|
|
|
|
/// Store an API key for a provider and rebuild against it.
|
|
pub fn set_api_key(&self, provider: ProviderId, key: String) {
|
|
let engine = self.clone();
|
|
self.runtime.spawn_blocking(move || {
|
|
if let Err(error) = pstr_meta::settings::set_api_key(provider, &key) {
|
|
return engine.fail("store the API key", error);
|
|
}
|
|
let config = engine.metadata_config();
|
|
*engine.metadata.lock() = Enrichment::build(config);
|
|
engine.emit(Event::Status(if key.trim().is_empty() {
|
|
format!("cleared the {} key", provider.label())
|
|
} else {
|
|
format!("saved the {} key", provider.label())
|
|
}));
|
|
});
|
|
}
|
|
|
|
/// Re-read every stored provider answer.
|
|
///
|
|
/// Read whole rather than per tile, for the same reason watch state is —
|
|
/// see [`Catalog::all_metadata`].
|
|
pub fn load_metadata(&self) {
|
|
let engine = self.clone();
|
|
self.runtime.spawn_blocking(move || {
|
|
// One lock, both reads: they are drawn together, and a UI that has
|
|
// the titles but not their episodes for a frame flickers the rows.
|
|
let answers = {
|
|
let catalog = engine.catalog.lock();
|
|
catalog
|
|
.all_metadata()
|
|
.and_then(|records| Ok((records, catalog.all_episode_metadata()?)))
|
|
};
|
|
match answers {
|
|
Ok((records, episodes)) => {
|
|
engine.emit(Event::Metadata(records));
|
|
engine.emit(Event::EpisodeMetadata(episodes));
|
|
}
|
|
Err(error) => engine.fail("read stored metadata", error),
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Look up every title that has no fresh answer.
|
|
///
|
|
/// `force` re-asks about titles that already have one, which is what
|
|
/// "match again" means. Otherwise a run over an already-matched library
|
|
/// makes no requests at all, which is what makes it safe to call whenever
|
|
/// the library changes.
|
|
pub fn match_titles(&self, titles: Vec<Title>, force: bool) {
|
|
let engine = self.clone();
|
|
self.runtime.spawn(async move {
|
|
engine.run_match(titles, force).await;
|
|
engine.emit(Event::MatchFinished);
|
|
});
|
|
}
|
|
|
|
async fn run_match(&self, titles: Vec<Title>, force: bool) {
|
|
let Some(service) = self.metadata.lock().service.clone() else {
|
|
let config = self.metadata_config();
|
|
return self.emit(Event::Error(if !config.enabled {
|
|
"turn on metadata enrichment first".into()
|
|
} else {
|
|
format!("{} needs an API key", config.provider.label())
|
|
}));
|
|
};
|
|
let provider = service.provider();
|
|
|
|
let (stored, listed) = {
|
|
let catalog = self.catalog.lock();
|
|
match catalog
|
|
.all_metadata()
|
|
.and_then(|stored| Ok((stored, catalog.episode_metadata_ages()?)))
|
|
{
|
|
Ok(pair) => pair,
|
|
Err(error) => return self.fail("read stored metadata", error),
|
|
}
|
|
};
|
|
|
|
// Two kinds of work, and the difference is a request saved: a title
|
|
// that already has a good match but no episode list needs the episode
|
|
// request only — searching for it again would ask the provider
|
|
// something it has already answered.
|
|
let mut pending: Vec<Work> = Vec::new();
|
|
for title in titles {
|
|
let record = stored.get(&title.key);
|
|
// A hand-picked entry is never searched for again, not even by
|
|
// "match again" — that button means "the automatic answers are
|
|
// wrong", and re-deciding the one title the viewer already fixed by
|
|
// hand is the opposite of what they asked for. Its episode list is
|
|
// still fetched below if it is missing.
|
|
let pinned = record.is_some_and(|record| record.manual && record.provider == provider);
|
|
if !pinned && (force || !pstr_meta::service::is_usable(record, provider)) {
|
|
pending.push(Work::Match(title));
|
|
continue;
|
|
}
|
|
let has_episodes = listed
|
|
.get(&title.key)
|
|
.is_some_and(|(asked, _)| *asked == provider);
|
|
if let Some(found) = record.and_then(|record| record.metadata.clone())
|
|
&& !has_episodes
|
|
{
|
|
pending.push(Work::Episodes(title, Box::new(found)));
|
|
}
|
|
}
|
|
|
|
if pending.is_empty() {
|
|
return self.emit(Event::Status("everything is already matched".into()));
|
|
}
|
|
self.emit(Event::Status(format!(
|
|
"matching {} titles against {}…",
|
|
pending.len(),
|
|
provider.label()
|
|
)));
|
|
|
|
let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize);
|
|
let mut lookups = futures::stream::FuturesUnordered::new();
|
|
for work in &pending {
|
|
let service = service.clone();
|
|
let permits = Arc::clone(&self.lookups);
|
|
lookups.push(async move {
|
|
let Ok(_permit) = permits.acquire().await else {
|
|
return None;
|
|
};
|
|
Some(match work {
|
|
Work::Match(title) => match service.record(title).await {
|
|
Ok(record) => {
|
|
// Only for a title that matched: there is no id to
|
|
// ask about otherwise.
|
|
let episodes = match &record.metadata {
|
|
Some(found) => service.title_episodes(title, found).await,
|
|
None => Vec::new(),
|
|
};
|
|
Ok((record.title_key.clone(), Some(record), episodes))
|
|
}
|
|
Err(error) => Err(error),
|
|
},
|
|
Work::Episodes(title, found) => Ok((
|
|
title.key.clone(),
|
|
None,
|
|
service.title_episodes(title, found).await,
|
|
)),
|
|
})
|
|
});
|
|
}
|
|
|
|
use futures::StreamExt as _;
|
|
let mut episodes_found = 0usize;
|
|
while let Some(result) = lookups.next().await {
|
|
match result {
|
|
Some(Ok((title_key, record, episodes))) => {
|
|
if let Some(record) = &record {
|
|
if record.metadata.is_some() {
|
|
matched += 1;
|
|
} else {
|
|
unmatched += 1;
|
|
}
|
|
// Misses are stored on purpose; failures below are not.
|
|
// See `pstr_meta::service`.
|
|
if let Err(error) = self.catalog.lock().set_metadata(record) {
|
|
tracing::warn!("store metadata for {}: {error}", record.title_key);
|
|
}
|
|
}
|
|
if !episodes.is_empty() {
|
|
episodes_found += episodes.len();
|
|
let stored = self.catalog.lock().set_episode_metadata(
|
|
&title_key,
|
|
provider,
|
|
now(),
|
|
&episodes,
|
|
);
|
|
if let Err(error) = stored {
|
|
tracing::warn!("store episodes for {title_key}: {error}");
|
|
}
|
|
}
|
|
}
|
|
Some(Err(error)) => {
|
|
failed += 1;
|
|
tracing::warn!("metadata lookup: {error}");
|
|
}
|
|
None => {}
|
|
}
|
|
}
|
|
|
|
if episodes_found > 0 {
|
|
self.emit(Event::Status(format!("{episodes_found} episodes named")));
|
|
}
|
|
self.emit(Event::Matched {
|
|
matched,
|
|
unmatched,
|
|
failed,
|
|
});
|
|
self.load_metadata();
|
|
}
|
|
|
|
/// Ask the provider what `term` might be, for a viewer choosing by hand.
|
|
///
|
|
/// Unscored and unfiltered — see [`MetadataService::search`]. Nothing is
|
|
/// stored: this is a look, and only [`Engine::choose_match`] writes.
|
|
pub fn search_matches(&self, title_key: String, term: String, kind: TitleKind) {
|
|
let Some(service) = self.metadata.lock().service.clone() else {
|
|
let config = self.metadata_config();
|
|
return self.emit(Event::MatchSearchFailed {
|
|
title_key,
|
|
error: if !config.enabled {
|
|
"turn on metadata enrichment first".into()
|
|
} else {
|
|
format!("{} needs an API key", config.provider.label())
|
|
},
|
|
});
|
|
};
|
|
|
|
let engine = self.clone();
|
|
let permits = Arc::clone(&self.lookups);
|
|
self.runtime.spawn(async move {
|
|
// The same permit the batch run takes: a viewer typing in the search
|
|
// box while a match run is going must not be what earns the 429.
|
|
let Ok(_permit) = permits.acquire().await else {
|
|
return;
|
|
};
|
|
match service.search(&term, kind).await {
|
|
Ok(options) => engine.emit(Event::MatchOptions { title_key, options }),
|
|
Err(error) => {
|
|
tracing::warn!("search {term:?}: {error}");
|
|
engine.emit(Event::MatchSearchFailed {
|
|
title_key,
|
|
error: error.to_string(),
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Pin one title to the entry the viewer picked, and take its episodes.
|
|
///
|
|
/// Stored as [`MetadataRecord::manual`], so it outlives every TTL and
|
|
/// survives "match again". The episode list is fetched here rather than left
|
|
/// to the next match run, because the point of choosing an entry by hand is
|
|
/// usually that its episode names were wrong too.
|
|
pub fn choose_match(&self, title: Title, found: pstr_core::metadata::TitleMetadata) {
|
|
let Some(service) = self.metadata.lock().service.clone() else {
|
|
return self.emit(Event::Error("turn on metadata enrichment first".into()));
|
|
};
|
|
|
|
let engine = self.clone();
|
|
self.runtime.spawn(async move {
|
|
let name = found.name.clone();
|
|
let record = service.chosen(title.key.clone(), found.clone());
|
|
if let Err(error) = engine.catalog.lock().set_metadata(&record) {
|
|
return engine.fail("store the match", error);
|
|
}
|
|
|
|
let episodes = service.title_episodes(&title, &found).await;
|
|
if !episodes.is_empty() {
|
|
let stored = engine.catalog.lock().set_episode_metadata(
|
|
&title.key,
|
|
service.provider(),
|
|
now(),
|
|
&episodes,
|
|
);
|
|
if let Err(error) = stored {
|
|
tracing::warn!("store episodes for {}: {error}", title.key);
|
|
}
|
|
}
|
|
|
|
engine.emit(Event::Status(format!("{} is now {name}", title.name)));
|
|
engine.load_metadata();
|
|
});
|
|
}
|
|
|
|
/// Drop what is stored for one title, so it is matched from scratch again.
|
|
///
|
|
/// The undo for [`Engine::choose_match`], and the repair for a bad automatic
|
|
/// match: the row goes entirely rather than becoming a stored miss, which is
|
|
/// what makes the next match run treat the title as one it has never asked
|
|
/// about.
|
|
pub fn forget_match(&self, title_key: String) {
|
|
let engine = self.clone();
|
|
self.runtime.spawn_blocking(move || {
|
|
if let Err(error) = engine.catalog.lock().forget_metadata(&title_key) {
|
|
return engine.fail("forget the match", error);
|
|
}
|
|
engine.emit(Event::Status("match cleared".into()));
|
|
engine.load_metadata();
|
|
});
|
|
}
|
|
|
|
/// Fetch, decode and post one title's artwork.
|
|
///
|
|
/// Cached on disk under a hash of the URL, so a provider that moves its CDN
|
|
/// paths invalidates by itself and the same picture shared by two titles is
|
|
/// fetched once.
|
|
pub fn request_poster(&self, title_key: String, url: String) {
|
|
let engine = self.clone();
|
|
let path = self
|
|
.dirs
|
|
.poster_cache()
|
|
.join(format!("{}.img", digest(&url)));
|
|
|
|
self.runtime.spawn(async move {
|
|
if let Ok(bytes) = tokio::fs::read(&path).await {
|
|
match decode_thumbnail(&bytes) {
|
|
Ok(image) => {
|
|
return engine.emit(Event::Poster {
|
|
key: title_key,
|
|
image,
|
|
});
|
|
}
|
|
Err(error) => tracing::debug!("cached poster {title_key}: {error}"),
|
|
}
|
|
}
|
|
|
|
let Some(service) = engine.metadata.lock().service.clone() else {
|
|
// Enrichment was turned off between the request and now.
|
|
return engine.emit(Event::PosterMissing { key: title_key });
|
|
};
|
|
|
|
let bytes = match service.artwork(&url).await {
|
|
Ok(bytes) => bytes,
|
|
Err(error) => {
|
|
tracing::debug!("fetch poster for {title_key}: {error}");
|
|
return engine.emit(Event::PosterMissing { key: title_key });
|
|
}
|
|
};
|
|
|
|
if let Some(parent) = path.parent() {
|
|
let _ = tokio::fs::create_dir_all(parent).await;
|
|
}
|
|
let _ = tokio::fs::write(&path, &bytes).await;
|
|
|
|
match decode_thumbnail(&bytes) {
|
|
Ok(image) => engine.emit(Event::Poster {
|
|
key: title_key,
|
|
image,
|
|
}),
|
|
Err(error) => {
|
|
tracing::debug!("decode poster for {title_key}: {error}");
|
|
engine.emit(Event::PosterMissing { key: title_key })
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// A short, filesystem-safe name for a URL.
|
|
fn digest(url: &str) -> String {
|
|
use sha2::{Digest as _, Sha256};
|
|
let hash = Sha256::digest(url.as_bytes());
|
|
hash.iter()
|
|
.take(16)
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect()
|
|
}
|
|
|
|
/// The cache and texture key for a file's poster.
|
|
/// One title's worth of work in a match run.
|
|
enum Work {
|
|
/// Search for it, and take its episodes if it matches.
|
|
Match(Title),
|
|
/// It is already matched; only the episode list is missing.
|
|
Episodes(Title, Box<pstr_core::metadata::TitleMetadata>),
|
|
}
|
|
|
|
/// Unix seconds, for the "when was this asked" columns.
|
|
fn now() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|since| since.as_secs() as i64)
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
pub fn thumbnail_key(node: &CatalogNode) -> String {
|
|
format!("{}-{}", node.share_id, node.link_id)
|
|
}
|
|
|
|
fn node_uid(volume_id: &str, link_id: &str) -> NodeUid {
|
|
NodeUid::new(
|
|
VolumeId::new(volume_id.to_string()),
|
|
LinkId::new(link_id.to_string()),
|
|
)
|
|
}
|
|
|
|
/// Resume only after the last completely durable Proton block. A process may
|
|
/// die during a write, so trailing bytes inside a block are deliberately
|
|
/// discarded rather than trusted.
|
|
fn resume_position(existing: u64, block_sizes: &[u64]) -> (usize, u64) {
|
|
let mut offset = 0_u64;
|
|
for (index, size) in block_sizes.iter().copied().enumerate() {
|
|
let Some(next) = offset.checked_add(size) else {
|
|
break;
|
|
};
|
|
if next > existing {
|
|
return (index, offset);
|
|
}
|
|
offset = next;
|
|
}
|
|
(block_sizes.len(), offset)
|
|
}
|
|
|
|
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(())
|
|
}
|
|
|
|
/// 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.
|
|
fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<egui::ColorImage> {
|
|
let decoded = image::load_from_memory(bytes)?;
|
|
let decoded = if decoded.width().max(decoded.height()) > THUMBNAIL_MAX_EDGE {
|
|
decoded.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE)
|
|
} else {
|
|
decoded
|
|
};
|
|
let rgba = decoded.to_rgba8();
|
|
let size = [rgba.width() as usize, rgba.height() as usize];
|
|
Ok(egui::ColorImage::from_rgba_unmultiplied(
|
|
size,
|
|
rgba.as_flat_samples().as_slice(),
|
|
))
|
|
}
|
|
|
|
/// 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.
|
|
#[derive(Default)]
|
|
pub struct ImageCache {
|
|
textures: HashMap<String, egui::TextureHandle>,
|
|
requested: std::collections::HashSet<String>,
|
|
missing: std::collections::HashSet<String>,
|
|
}
|
|
|
|
impl ImageCache {
|
|
/// 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.
|
|
pub fn texture(&mut self, key: String, fetch: impl FnOnce()) -> Option<egui::TextureHandle> {
|
|
if let Some(texture) = self.textures.get(&key) {
|
|
return Some(texture.clone());
|
|
}
|
|
if !self.missing.contains(&key) && self.requested.insert(key) {
|
|
fetch();
|
|
}
|
|
None
|
|
}
|
|
|
|
pub fn insert(&mut self, ctx: &egui::Context, key: String, image: egui::ColorImage) {
|
|
let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR);
|
|
self.textures.insert(key, texture);
|
|
}
|
|
|
|
pub fn mark_missing(&mut self, key: String) {
|
|
self.missing.insert(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();
|
|
}
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|