This commit is contained in:
nvrl committed 2026-08-02 19:37:03 +02:00
commit a1b345a029
80 files changed
+28033

No files matched your search

+29
View File
@@ -0,0 +1,29 @@
[package]
name = "pstr-app"
description = "The proton-stream desktop application."
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
authors.workspace = true
[[bin]]
name = "proton-stream"
path = "src/main.rs"
[dependencies]
pstr-core.workspace = true
pstr-meta.workspace = true
pstr-stream.workspace = true
pstr-player.workspace = true
tokio.workspace = true
futures.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
eframe.workspace = true
egui.workspace = true
image.workspace = true
sha2.workspace = true
parking_lot.workspace = true
+178
View File
@@ -0,0 +1,178 @@
//! Smoke test for the embedded video path, with no Proton account in the way.
//!
//! Everything from `mpv_render_context_create` to the `painter.image` call is
//! exercised here — the GL entry-point loader, the framebuffer, the orientation
//! of the texture and the fact that egui can draw over the result. What is *not*
//! exercised is anything to do with shares, blocks or decryption, which is the
//! point: when the picture is wrong, this says whether the problem is in the
//! render path or above it.
//!
//! ```bash
//! cargo run -p pstr-app --example embedded_video # a test pattern
//! cargo run -p pstr-app --example embedded_video -- /path.mkv # a real file
//! ```
//!
//! What to look for: a moving picture inside the bordered rectangle, right way
//! up, with the chrome around it drawn normally and the frame counter climbing.
//! A stuck counter means the update callback is not firing; an upside-down
//! picture means the `flip` argument in `VideoRenderer::render` is wrong; a
//! black rectangle with a healthy counter means the framebuffer is not the one
//! being sampled.
use std::sync::Arc;
use anyhow::Context as _;
use pstr_app::video::VideoSurface;
use pstr_player::{Player, PlayerConfig, PlayerEvent, VideoOutput};
/// mpv's own test source: a moving pattern, generated with no file on disk.
const TEST_PATTERN: &str = "av://lavfi:testsrc=size=1280x720:rate=30";
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "pstr_app=debug,pstr_player=debug".into()),
)
.init();
let url = std::env::args()
.nth(1)
.unwrap_or_else(|| TEST_PATTERN.into());
let runtime = Arc::new(
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?,
);
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("embedded video smoke test")
.with_inner_size([960.0, 640.0]),
..Default::default()
};
eframe::run_native(
"embedded_video",
options,
Box::new(move |cc| Ok(Box::new(Harness::new(cc, runtime, url)?) as Box<dyn eframe::App>)),
)
.map_err(|error| anyhow::anyhow!("run the window: {error}"))
}
struct Harness {
player: Arc<Player>,
video: Option<VideoSurface>,
url: String,
/// Set once, after the first frame: the render context has to exist before
/// the file is loaded, and it can only be built with the GL context current.
started: bool,
frames: u64,
last_event: String,
}
impl Harness {
fn new(
cc: &eframe::CreationContext<'_>,
runtime: Arc<tokio::runtime::Runtime>,
url: String,
) -> anyhow::Result<Self> {
let config = PlayerConfig {
video: VideoOutput::Embedded,
on_screen_controller: false,
default_keybindings: false,
..PlayerConfig::default()
};
let player = Arc::new(Player::new(runtime, config)?);
let gl = cc
.gl
.clone()
.context("eframe was built without a glow context")?;
// SAFETY: `CreationContext` is handed to us on the UI thread with the
// context current, which is the same guarantee `App::ui` gives.
let video = unsafe { VideoSurface::new(Arc::clone(&player), gl, cc.egui_ctx.clone()) }?;
Ok(Self {
player,
video: Some(video),
url,
started: false,
frames: 0,
last_event: "—".into(),
})
}
}
impl eframe::App for Harness {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
if !self.started {
self.started = true;
if let Err(error) = self.player.play_url(&self.url) {
self.last_event = format!("load failed: {error}");
}
}
// Polled from the UI thread rather than a player thread: this harness is
// about the picture, and a zero timeout keeps the draw loop honest.
while let Some(event) = self.player.poll_event(0.0) {
if !matches!(event, PlayerEvent::Position(_) | PlayerEvent::Other) {
self.last_event = format!("{event:?}");
}
}
egui::CentralPanel::default().show(ui, |ui| {
ui.heading("embedded video");
ui.label(&self.url);
ui.horizontal(|ui| {
ui.label(format!("frames drawn: {}", self.frames));
ui.separator();
ui.label(format!("last event: {}", self.last_event));
ui.separator();
ui.label(match self.player.position() {
Some(position) => format!("at {position:.1}s"),
None => "no position yet".into(),
});
});
ui.add_space(8.0);
// Deliberately inset and bordered: a full-window picture would hide
// whether the rectangle is respected, which is the thing most likely
// to be wrong.
let rect = ui.available_rect_before_wrap().shrink(24.0);
ui.painter()
.rect_filled(rect, egui::CornerRadius::same(4), egui::Color32::DARK_GRAY);
let pixels_per_point = ui.ctx().pixels_per_point();
let size = [
(rect.width() * pixels_per_point).round() as i32,
(rect.height() * pixels_per_point).round() as i32,
];
if let Some(video) = self.video.as_mut()
&& let Some(texture) = video.texture(frame, size)
{
self.frames += 1;
ui.painter().image(
texture,
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
ui.painter().rect_stroke(
rect,
egui::CornerRadius::same(4),
egui::Stroke::new(2.0, egui::Color32::from_rgb(0x7d, 0x4d, 0xff)),
egui::StrokeKind::Outside,
);
});
}
/// The surface holds GL objects and mpv's render context; both have to go
/// while the context is still current.
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
self.video = None;
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
//! The `proton-stream` desktop application, as a library.
//!
//! A window over one or more Proton Drive public links: crawl them once, and
//! what is behind them becomes a library that plays without downloading first.
//!
//! Three threads' worth of work, kept apart on purpose:
//!
//! * **The UI thread** draws. It never opens a share, reads the catalog or
//! decodes an image — see [`engine`]. It is also the only thread that may
//! touch OpenGL, which is why [`video`] and the construction of a
//! [`playback::Playback`] both live on it.
//! * **The tokio runtime** does everything with a network or a disk in it. It is
//! built by the binary rather than with `#[tokio::main]`, because
//! [`pstr_player`] needs an `Arc<Runtime>` it can block on from mpv's demuxer
//! thread, which is outside tokio entirely.
//! * **A player thread** drives mpv for the length of a file — see [`playback`].
//!
//! This is a library as well as a binary so the parts that can only be checked
//! against a real GPU are reachable from `examples/` — see
//! `examples/embedded_video.rs`, which is the smoke test for [`video`].
pub mod app;
pub mod engine;
pub mod playback;
pub mod theme;
pub mod ui;
pub mod video;
+46
View File
@@ -0,0 +1,46 @@
//! `proton-stream` — the desktop application.
//!
//! Everything is in the library beside this file; what is here is the process:
//! logging, the tokio runtime, the window, and the app inside it. See
//! [`pstr_app`] for how the three threads divide the work.
use anyhow::Context;
use pstr_core::config::AppDirs;
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "pstr_app=info,pstr_core=info,pstr_stream=info".into()),
)
.init();
let runtime = std::sync::Arc::new(
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("build the tokio runtime")?,
);
let dirs = AppDirs::ensure().context("resolve app directories")?;
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("proton-stream")
.with_inner_size([1180.0, 780.0])
.with_min_inner_size([720.0, 480.0])
.with_app_id("io.narl.proton-stream"),
..Default::default()
};
eframe::run_native(
"proton-stream",
options,
Box::new(move |cc| {
pstr_app::app::App::new(cc, runtime, dirs)
.map(|app| Box::new(app) as Box<dyn eframe::App>)
.map_err(|error| error.into())
}),
)
// `eframe::Error` is not `Send + Sync`, which `anyhow` wants of a source.
.map_err(|error| anyhow::anyhow!("run the window: {error}"))
}
+628
View File
@@ -0,0 +1,628 @@
//! The player, driven from the UI without blocking it.
//!
//! [`pstr_player::Player`] is polled, not callback-driven: something has to sit
//! in `poll_event` for the life of the file. That cannot be the UI thread, so a
//! dedicated thread owns the `Player` outright — it is the only thing that ever
//! touches mpv — and the UI talks to it through a command channel and hears back
//! as [`Event::Player`].
//!
//! ```text
//! ui thread ──Command::Seek──▶ player thread ──▶ mpv
//! ▲ │ │
//! │ └─ VideoSurface ───┤ frames, on the UI thread's GL context
//! └──── Event::Player(Position) ──┘ + watch state, every few seconds
//! ```
//!
//! The picture is drawn inside this window, which is why the `Player` is built
//! *here* rather than on the player thread: `mpv_render_context_create` has to
//! run on the thread whose OpenGL context it renders into, and it has to run
//! before the file is loaded, or mpv's video output has nowhere to send its
//! first frames. So the UI thread creates the player and its
//! [`crate::video::VideoSurface`], and only then hands the player to the thread
//! that will poll it. Both hold an `Arc`; mpv is destroyed when the last one
//! goes, which is always the surface, on the UI thread.
//!
//! Without a usable OpenGL context — which should not happen under `eframe`'s
//! glow backend, but is cheap to survive — the player falls back to letting mpv
//! open a window of its own, exactly as `pstr play` does.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Sender, TryRecvError, channel};
use eframe::glow;
use pstr_core::catalog::CatalogNode;
use pstr_core::library::{Episode, Title};
use pstr_core::prefs::PlaybackPrefs;
use pstr_player::{
Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, Track, TrackKind,
VideoOutput,
};
use pstr_stream::VideoStream;
use crate::engine::{Engine, Event, watch_state};
use crate::video::VideoSurface;
/// How often playback position is written to the catalog.
///
/// Every position event would be a write per frame-ish; only on shutdown would
/// lose the position when mpv is killed. Five seconds is the compromise, and it
/// is what the viewer loses at worst.
const SAVE_EVERY: std::time::Duration = std::time::Duration::from_secs(5);
/// How long the player thread waits for mpv before checking for a command.
/// Short enough that a click on pause feels immediate.
const POLL_TIMEOUT: f64 = 0.05;
/// What is being played, and where it came from.
#[derive(Debug, Clone)]
pub struct PlaybackTarget {
pub share_id: String,
pub volume_id: String,
pub link_id: String,
/// The file name, for mpv's window title.
pub name: String,
/// The title this belongs to, for routing back to its page.
pub title_key: String,
/// What the transport bar shows.
pub title_name: String,
pub subtitle: String,
/// The numbering the filename states, for looking the episode up.
pub season: Option<u32>,
pub number: Option<u32>,
/// What the provider calls this episode, once someone has looked. Filled in
/// by the app rather than here: this type is built in the pages, and the
/// answers live next to the library.
pub episode_name: Option<String>,
/// Where to resume, when the viewer had been here before.
pub resume_at: Option<f64>,
}
impl PlaybackTarget {
/// The target for one episode of a title, resuming where it left off.
pub fn new(title: &Title, episode: &Episode) -> Self {
Self::from_node(title, &episode.node, episode.resume_at())
}
/// The target for one file, starting at `resume_at`.
pub fn from_node(title: &Title, node: &CatalogNode, resume_at: Option<f64>) -> Self {
Self {
share_id: node.share_id.clone(),
volume_id: node.volume_id.clone(),
link_id: node.link_id.clone(),
name: node.name.clone(),
title_key: title.key.clone(),
title_name: title.name.clone(),
subtitle: Episode {
node: node.clone(),
watch: None,
}
.label(),
season: node.parsed.season,
number: node.parsed.episode,
episode_name: None,
resume_at,
}
}
/// The line under the title while this plays: the numbering, and the
/// episode's name when a provider has given one.
pub fn caption(&self) -> String {
match &self.episode_name {
Some(name) if !self.subtitle.is_empty() => format!("{} · {name}", self.subtitle),
Some(name) => name.clone(),
None => self.subtitle.clone(),
}
}
}
/// What the UI can ask the player to do.
#[derive(Debug, Clone, Copy)]
pub enum Command {
TogglePause,
SeekBy(f64),
SeekTo(f64),
/// 0–100.
SetVolume(f64),
SetMuted(bool),
/// Play this track of that kind, or `None` for none of it.
///
/// The player also takes the chosen track's language as the preference for
/// every file it loads afterwards, so picking Japanese audio for one
/// episode picks it for the next one too.
SelectTrack(TrackKind, Option<i64>),
Stop,
}
/// Identifies one player instance for the length of its file.
///
/// Starting a file while another is playing leaves two players alive for a
/// moment — the old one is still shutting down. Without an id its parting
/// events would land on the new one's transport bar and its `PlayerStopped`
/// would clear it.
static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// A running player, from the UI's side.
pub struct Playback {
pub id: u64,
commands: Sender<Command>,
/// Set by the player thread once it has stopped mpv and written the final
/// watch position.
finished: Arc<AtomicBool>,
/// Where mpv's frames land, when the picture is inside this window. `None`
/// means mpv has a window of its own.
pub video: Option<VideoSurface>,
pub target: PlaybackTarget,
pub position: f64,
pub duration: Option<f64>,
pub paused: bool,
/// 0–100, as mpv has it.
pub volume: f64,
pub muted: bool,
/// What the file contains. Empty until mpv has demuxed it.
pub tracks: Vec<Track>,
/// The file's chapters, in order. Empty for a file muxed without them.
pub chapters: Vec<Chapter>,
/// What each of those chapters is, resolved against the rest of the file —
/// see [`pstr_player::roles`]. Same length as `chapters`, and recomputed
/// whenever either the chapters or the duration change.
pub roles: Vec<ChapterRole>,
/// Where the run of credits and preview that ends the file begins, when it
/// has one. What the "up next" countdown waits for.
pub credits_at: Option<f64>,
/// Set once mpv has demuxed the file. Before that there is nothing to seek
/// within and no duration to show.
pub loaded: bool,
/// A seek was issued and the picture has not come back yet — the thing
/// worth showing a spinner for on a link this slow.
pub seeking: bool,
}
impl Playback {
/// Build a player for `stream` and start it on its own thread.
///
/// Returns immediately; the first frame lands when mpv gets to it. `gl` is
/// eframe's context — with it the picture is drawn in this window, without
/// it mpv opens its own.
///
/// Must be called from the UI thread, inside the draw function: creating the
/// render context needs `gl` to be current, and eframe only guarantees that
/// around [`eframe::App::ui`].
pub fn start(
engine: &Engine,
target: PlaybackTarget,
stream: VideoStream,
gl: Option<&Arc<glow::Context>>,
ctx: &egui::Context,
) -> Result<Self, String> {
let prefs = engine.playback_prefs();
let (player, video) = build_player(engine, &target, &prefs, gl, ctx)?;
let (commands, orders) = channel::<Command>();
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
let finished = Arc::new(AtomicBool::new(false));
let thread_engine = engine.clone();
let thread_target = target.clone();
let thread_finished = Arc::clone(&finished);
let thread_player = Arc::clone(&player);
std::thread::Builder::new()
.name("pstr-player".into())
.spawn(move || {
run(
id,
thread_engine,
thread_target,
stream,
thread_player,
orders,
thread_finished,
);
})
// Only fails when the OS refuses a thread, at which point the app has
// larger problems than this file.
.expect("spawn the player thread");
Ok(Self {
id,
commands,
finished,
video,
paused: false,
volume: prefs.volume,
muted: prefs.muted,
tracks: Vec::new(),
chapters: Vec::new(),
roles: Vec::new(),
credits_at: None,
position: target.resume_at.unwrap_or(0.0),
duration: None,
loaded: false,
seeking: false,
target,
})
}
/// Whether the picture is drawn in this window rather than mpv's own.
pub fn is_embedded(&self) -> bool {
self.video.is_some()
}
/// Ask the player to do something. A dead player is not an error — the
/// window may simply have been closed a frame ago.
pub fn send(&self, command: Command) {
let _ = self.commands.send(command);
}
/// The tracks of one kind, in the order the file lists them.
pub fn tracks_of(&self, kind: TrackKind) -> impl Iterator<Item = &Track> {
self.tracks.iter().filter(move |track| track.kind == kind)
}
/// Which track of a kind is playing, if any. `None` for subtitles that are
/// off, which is an ordinary state rather than a missing answer.
pub fn selected_track(&self, kind: TrackKind) -> Option<&Track> {
self.tracks_of(kind).find(|track| track.selected)
}
/// The chapter the playhead is in, if the file has any.
pub fn chapter(&self) -> Option<&Chapter> {
let index = pstr_player::chapter_at(&self.chapters, self.position)?;
self.chapters.get(index)
}
/// The one thing worth offering to skip right now: the opening, the ending
/// or a next-episode preview the viewer is sitting in.
///
/// Returns what the button should say and where it should seek to. `None`
/// most of the time, which is what keeps it from being another permanent
/// control on top of the picture. The role comes from
/// [`pstr_player::roles`], not from the chapter's name alone, so a chapter
/// called `Intro` that is ten minutes of story offers nothing.
pub fn skippable(&self) -> Option<(&'static str, f64)> {
let index = pstr_player::chapter_at(&self.chapters, self.position)?;
let label = self.roles.get(index)?.skip_label()?;
let end = pstr_player::chapter_end(&self.chapters, index, self.duration)?;
// Nothing to skip once the end of it is behind us, which happens for a
// last chapter whose "end" is the duration.
(end > self.position + 0.5).then_some((label, end))
}
/// Whether the playhead is inside the run of credits and preview that ends
/// the file — what "the episode is over" means when a file states its
/// chapters.
pub fn in_credits(&self) -> bool {
self.credits_at
.is_some_and(|start| self.position + f64::EPSILON >= start)
}
/// Re-read what the chapters are, now that either they or the duration have
/// changed. Both are needed: the length of a chapter is what settles a name
/// that could mean two things.
fn reclassify(&mut self) {
self.roles = pstr_player::roles(&self.chapters, self.duration);
self.credits_at = pstr_player::credits_start(&self.chapters, &self.roles);
}
/// Fold an mpv event into what the transport bar shows.
pub fn apply(&mut self, event: &PlayerEvent) {
match event {
PlayerEvent::FileLoaded => self.loaded = true,
PlayerEvent::Volume(volume) => self.volume = *volume,
PlayerEvent::Muted(muted) => self.muted = *muted,
PlayerEvent::Tracks(tracks) => self.tracks = tracks.clone(),
PlayerEvent::Chapters(chapters) => {
self.chapters = chapters.clone();
self.reclassify();
}
PlayerEvent::Position(position) => {
self.position = *position;
self.seeking = false;
}
PlayerEvent::Duration(duration) => {
self.duration = Some(*duration);
self.reclassify();
}
PlayerEvent::Paused(paused) => self.paused = *paused,
PlayerEvent::Seek => self.seeking = true,
PlayerEvent::PlaybackRestart => self.seeking = false,
_ => {}
}
}
/// Stop the player and wait, briefly, for it to write the final position.
///
/// Called as the window closes: the runtime is about to go away with the
/// process, so a save that has only been *spawned* may never run. The wait
/// is bounded — a wedged mpv must not hold the app open.
pub fn stop_and_wait(&self, timeout: std::time::Duration) {
self.send(Command::Stop);
let deadline = std::time::Instant::now() + timeout;
while !self.finished.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
/// Fraction played, for the seek bar. `None` until mpv reports a duration.
pub fn progress(&self) -> Option<f32> {
let duration = self.duration.filter(|duration| *duration > 0.0)?;
Some((self.position / duration).clamp(0.0, 1.0) as f32)
}
}
/// Create the mpv instance, and the surface it draws into when there is one.
///
/// The fallback is deliberate rather than defensive: if the render context
/// cannot be created — a driver without the GL version mpv needs, a context
/// eframe built in a way `pstr_player::VideoRenderer` cannot resolve entry
/// points for — the player is rebuilt for a window of its own. A film in the
/// wrong window beats an error message.
fn build_player(
engine: &Engine,
target: &PlaybackTarget,
prefs: &PlaybackPrefs,
gl: Option<&Arc<glow::Context>>,
ctx: &egui::Context,
) -> Result<(Arc<Player>, Option<VideoSurface>), String> {
let title = format!("proton-stream — {}", target.name);
if let Some(gl) = gl {
let config = PlayerConfig {
video: VideoOutput::Embedded,
window_title: title.clone(),
// This window draws the transport and owns the keyboard; mpv's own
// controller would be a second set of controls over the same file.
on_screen_controller: false,
default_keybindings: false,
..from_prefs(prefs)
};
match Player::new(engine.runtime().clone(), config) {
Ok(player) => {
let player = Arc::new(player);
// SAFETY: called from `App::ui`, where eframe has made this
// context current, and the surface is dropped from the same
// place — see `video.rs`.
match unsafe { VideoSurface::new(Arc::clone(&player), Arc::clone(gl), ctx.clone()) }
{
Ok(video) => return Ok((player, Some(video))),
Err(error) => {
tracing::warn!("embed the video: {error}; falling back to a window");
// Dropping the player here destroys the mpv core that
// was configured for `vo=libmpv`, which would show
// nothing at all now that there is no render context.
drop(player);
}
}
}
Err(error) => {
tracing::warn!("start an embedded mpv: {error}; falling back to a window");
}
}
}
let config = PlayerConfig {
window_title: title,
..from_prefs(prefs)
};
let player = Player::new(engine.runtime().clone(), config)
.map_err(|error| format!("start mpv: {error}"))?;
Ok((Arc::new(player), None))
}
/// The player defaults with the viewer's preferences folded in.
fn from_prefs(prefs: &PlaybackPrefs) -> PlayerConfig {
PlayerConfig {
volume: prefs.volume,
muted: prefs.muted,
audio_language: prefs.audio_language.clone(),
subtitle_language: prefs.subtitle_language.clone(),
subtitles: prefs.subtitles,
..PlayerConfig::default()
}
}
/// The player thread: drives mpv, forwards its events, saves watch state.
///
/// It does not own the player — the UI thread's [`VideoSurface`] holds the other
/// `Arc`, and mpv is destroyed when that one goes. What this thread owns is the
/// *event loop*: `wait_event` has to be called from one place, for the length of
/// the file, and that place cannot be the thread that draws.
fn run(
id: u64,
engine: Engine,
target: PlaybackTarget,
stream: VideoStream,
player: Arc<Player>,
orders: std::sync::mpsc::Receiver<Command>,
finished: Arc<AtomicBool>,
) {
let handle = match player.play(stream) {
Ok(handle) => handle,
Err(error) => {
engine.emit(Event::Error(format!("load {}: {error}", target.name)));
finished.store(true, Ordering::Release);
engine.emit(Event::PlayerStopped { id });
return;
}
};
let mut position = target.resume_at.unwrap_or(0.0);
let mut duration: Option<f64> = None;
let mut watched = false;
let mut resumed = target.resume_at.is_none();
let mut last_save = std::time::Instant::now();
'playback: loop {
match orders.try_recv() {
Ok(command) => {
if !apply_command(&engine, id, &player, command, position) {
break 'playback;
}
continue;
}
Err(TryRecvError::Empty) => {}
// The UI dropped its end: it is not going to ask for anything else.
// Not a reason to stop — mpv's own window may still be up, and if
// the picture is embedded the surface is about to drop the player
// anyway, which ends this loop through `Shutdown`.
Err(TryRecvError::Disconnected) => {}
}
let Some(event) = player.poll_event(POLL_TIMEOUT) else {
continue;
};
match &event {
PlayerEvent::FileLoaded => {
duration = player.duration();
// The first moment there is a track list to read: mpv has
// demuxed the file and made its own default selection, which
// is what the menus have to open showing.
emit_tracks(&engine, id, &player);
// Only now is there a timeline to seek within.
if !resumed && let Some(at) = target.resume_at {
resumed = true;
if let Err(error) = player.seek_to(at) {
tracing::warn!("resume at {at}: {error}");
}
}
}
PlayerEvent::Duration(value) => duration = Some(*value),
PlayerEvent::Position(value) => {
position = *value;
if last_save.elapsed() >= SAVE_EVERY {
last_save = std::time::Instant::now();
save(&engine, &target, position, duration, watched);
}
}
// Played to the end: mark it seen, and store the duration as the
// position so nothing offers to resume the last ten seconds.
PlayerEvent::EndFile(EndReason::Eof) => {
watched = true;
position = duration.unwrap_or(position);
}
_ => {}
}
let stop = matches!(event, PlayerEvent::Shutdown)
|| matches!(
event,
PlayerEvent::EndFile(EndReason::Eof | EndReason::Failed)
);
engine.emit(Event::Player { id, event });
if stop {
break;
}
}
// Synchronously, not spawned: the runtime may be going away with the
// process a moment from now.
engine.save_watch_state_now(
&target.share_id,
&target.link_id,
&watch_state(position, duration, watched),
);
// The stream can go now. The player cannot: the UI thread's surface holds
// the other `Arc`, and mpv must be destroyed there, on the thread whose
// OpenGL context its render context was built against.
drop(handle);
drop(player);
engine.release(
target.share_id.clone(),
target.volume_id.clone(),
target.link_id.clone(),
);
finished.store(true, Ordering::Release);
engine.emit(Event::PlayerStopped { id });
}
/// Returns whether playback should continue.
fn apply_command(
engine: &Engine,
id: u64,
player: &Player,
command: Command,
position: f64,
) -> bool {
let result = match command {
Command::TogglePause => player
.is_paused()
.and_then(|paused| player.set_paused(!paused)),
Command::SeekBy(delta) => player.seek_by(delta),
Command::SeekTo(seconds) => player.seek_to(seconds),
Command::SetVolume(volume) => player.set_volume(volume),
Command::SetMuted(muted) => player.set_muted(muted),
Command::SelectTrack(kind, track) => select_track(engine, id, player, kind, track),
Command::Stop => {
let _ = player.quit();
return false;
}
};
if let Err(error) = result {
tracing::warn!("player command at {position:.0}s: {error}");
}
true
}
/// Switch track, then carry the choice forward to the files after this one.
///
/// The language is read back from mpv rather than taken from what the UI knew,
/// because between the click and here the selection is mpv's to interpret — and
/// a track with no language tag has to clear the preference rather than leave
/// the old one standing over a choice that contradicts it.
fn select_track(
engine: &Engine,
id: u64,
player: &Player,
kind: TrackKind,
track: Option<i64>,
) -> pstr_player::Result<()> {
player.select_track(kind, track)?;
let tracks = player.tracks();
let language = track
.and_then(|track| {
tracks
.iter()
.find(|candidate| candidate.kind == kind && candidate.id == track)
})
.and_then(|track| track.language.clone());
if let Err(error) = player.prefer_language(kind, language.as_deref()) {
tracing::warn!("prefer {} {language:?}: {error}", kind.label());
}
engine.emit(Event::Player {
id,
event: PlayerEvent::Tracks(tracks),
});
Ok(())
}
/// Tell the UI what the file contains and what of it is playing.
fn emit_tracks(engine: &Engine, id: u64, player: &Player) {
engine.emit(Event::Player {
id,
event: PlayerEvent::Tracks(player.tracks()),
});
engine.emit(Event::Player {
id,
event: PlayerEvent::Chapters(player.chapters()),
});
}
fn save(
engine: &Engine,
target: &PlaybackTarget,
position: f64,
duration: Option<f64>,
watched: bool,
) {
engine.save_watch_state(
target.share_id.clone(),
target.link_id.clone(),
watch_state(position, duration, watched),
);
}
+81
View File
@@ -0,0 +1,81 @@
//! The look: one dark palette, applied once at startup.
//!
//! Deliberately dark and low-contrast around the edges — this is a window full
//! of video stills, and every bit of chrome brightness competes with them. The
//! accent is Proton's purple, which is also the only strong colour in the app,
//! so anything wearing it is something to click.
use egui::{Color32, CornerRadius, Stroke, Visuals};
pub const BACKGROUND: Color32 = Color32::from_rgb(0x0e, 0x0e, 0x12);
pub const SURFACE: Color32 = Color32::from_rgb(0x17, 0x17, 0x1d);
pub const CARD: Color32 = Color32::from_rgb(0x1e, 0x1e, 0x26);
pub const CARD_HOVER: Color32 = Color32::from_rgb(0x2a, 0x2a, 0x35);
pub const TEXT: Color32 = Color32::from_rgb(0xea, 0xea, 0xf0);
pub const MUTED: Color32 = Color32::from_rgb(0x8e, 0x8e, 0x9c);
pub const ACCENT: Color32 = Color32::from_rgb(0x7d, 0x4d, 0xff);
pub const ACCENT_DIM: Color32 = Color32::from_rgb(0x53, 0x33, 0xad);
pub const DANGER: Color32 = Color32::from_rgb(0xe0, 0x55, 0x61);
/// Card geometry. The grid is built from these, so a change here moves
/// everything together.
pub const CARD_WIDTH: f32 = 232.0;
/// Video stills are 16:9. A poster shape would letterbox every one of them.
pub const CARD_ASPECT: f32 = 9.0 / 16.0;
pub const CARD_GAP: f32 = 14.0;
pub fn apply(ctx: &egui::Context) {
let mut visuals = Visuals::dark();
visuals.panel_fill = BACKGROUND;
visuals.window_fill = SURFACE;
visuals.extreme_bg_color = Color32::from_rgb(0x0a, 0x0a, 0x0d);
visuals.faint_bg_color = SURFACE;
visuals.override_text_color = Some(TEXT);
visuals.selection.bg_fill = ACCENT_DIM;
visuals.selection.stroke = Stroke::new(1.0, TEXT);
visuals.hyperlink_color = ACCENT;
let radius = CornerRadius::same(8);
visuals.widgets.noninteractive.bg_fill = SURFACE;
visuals.widgets.noninteractive.corner_radius = radius;
visuals.widgets.noninteractive.bg_stroke =
Stroke::new(1.0, Color32::from_rgb(0x26, 0x26, 0x30));
visuals.widgets.inactive.bg_fill = CARD;
visuals.widgets.inactive.weak_bg_fill = CARD;
visuals.widgets.inactive.corner_radius = radius;
visuals.widgets.inactive.bg_stroke = Stroke::NONE;
visuals.widgets.hovered.bg_fill = CARD_HOVER;
visuals.widgets.hovered.weak_bg_fill = CARD_HOVER;
visuals.widgets.hovered.corner_radius = radius;
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, ACCENT);
visuals.widgets.active.bg_fill = ACCENT_DIM;
visuals.widgets.active.weak_bg_fill = ACCENT_DIM;
visuals.widgets.active.corner_radius = radius;
ctx.set_visuals(visuals);
ctx.all_styles_mut(|style| {
style.spacing.item_spacing = egui::vec2(10.0, 10.0);
style.spacing.button_padding = egui::vec2(12.0, 7.0);
style.spacing.interact_size.y = 30.0;
style.spacing.scroll.bar_width = 10.0;
use egui::FontFamily::Proportional;
use egui::TextStyle::{Body, Button, Heading, Monospace, Small};
style.text_styles = [
(Heading, egui::FontId::new(26.0, Proportional)),
(Body, egui::FontId::new(14.0, Proportional)),
(Button, egui::FontId::new(14.0, Proportional)),
(Small, egui::FontId::new(11.5, Proportional)),
(
Monospace,
egui::FontId::new(12.5, egui::FontFamily::Monospace),
),
]
.into();
});
}
+191
View File
@@ -0,0 +1,191 @@
//! The library page: what to watch, as a wall of stills.
use pstr_core::library::{Library, Title, TitleKind};
use crate::app::{Action, Page};
use crate::theme;
use crate::ui::Art;
use crate::ui::{self, Card};
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
search: &str,
actions: &mut Vec<Action>,
) {
if library.is_empty() {
return empty_state(ui, actions);
}
let matches = library.search(search);
let searching = !search.trim().is_empty();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if !searching {
let resumable = library.continue_watching();
if !resumable.is_empty() {
ui::section(ui, "Continue watching");
continue_row(ui, art, &resumable, actions);
ui.add_space(16.0);
}
}
ui::section(
ui,
&if searching {
format!(
"{} matching {:?}",
plural(matches.len(), "title"),
search.trim()
)
} else {
plural(matches.len(), "title")
},
);
if matches.is_empty() {
ui.add_space(8.0);
ui.label(ui::muted("Nothing here by that name."));
return;
}
grid(ui, art, &matches, actions);
});
}
/// The top shelf: one card per part-watched title, scrolling sideways.
fn continue_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
titles: &[&Title],
actions: &mut Vec<Action>,
) {
egui::ScrollArea::horizontal()
.id_salt("continue")
.auto_shrink([false, true])
.show(ui, |ui| {
ui.horizontal(|ui| {
for title in titles {
let Some(episode) = title.resume() else {
continue;
};
let remaining = episode
.watch
.and_then(|watch| watch.duration_secs)
.map(|duration| {
format!(
"{} left",
ui::format_time(
duration - episode.watch.map_or(0.0, |w| w.position_secs)
)
)
})
.unwrap_or_else(|| episode.label());
let clicked = ui::card(
ui,
Card {
art: art.of(title),
name: &title.name,
subtitle: remaining,
progress: episode.progress().map(|value| value as f32),
badge: episode.numbering(),
},
)
.clicked();
if clicked {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
}
});
});
}
/// Every title, wrapped to the window.
fn grid(ui: &mut egui::Ui, art: &mut Art<'_>, titles: &[&Title], actions: &mut Vec<Action>) {
let columns = ui::columns(ui.available_width());
for row in titles.chunks(columns) {
ui.horizontal(|ui| {
for title in row {
let clicked = ui::card(
ui,
Card {
art: art.of(title),
name: &title.name,
subtitle: subtitle(title),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: (title.kind == TitleKind::Film).then(|| "Film".to_string()),
},
)
.clicked();
if clicked {
actions.push(Action::Goto(Page::Title(title.key.clone())));
}
}
});
ui.add_space(theme::CARD_GAP);
}
}
/// The grey line under a card: how much there is, and how much is left.
fn subtitle(title: &Title) -> String {
if title.kind == TitleKind::Film {
return title
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "Film".into());
}
let total = title.episode_count();
let watched = title.watched_count();
if watched == 0 {
plural(total, "episode")
} else if watched >= total {
format!("{} · watched", plural(total, "episode"))
} else {
format!("{watched} of {total} watched")
}
}
fn empty_state(ui: &mut egui::Ui, actions: &mut Vec<Action>) {
ui.vertical_centered(|ui| {
ui.add_space(120.0);
ui.label(
egui::RichText::new("Nothing in the library yet")
.size(20.0)
.strong(),
);
ui.add_space(6.0);
ui.label(ui::muted(
"Add a Proton Drive share link, and everything playable behind it shows up here.",
));
ui.add_space(18.0);
if ui::accent_button(ui, "Add a share").clicked() {
actions.push(Action::Goto(Page::Shares));
}
});
}
pub fn plural(count: usize, noun: &str) -> String {
if count == 1 {
format!("1 {noun}")
} else {
format!("{count} {noun}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plurals_read_naturally() {
assert_eq!(plural(1, "title"), "1 title");
assert_eq!(plural(0, "title"), "0 titles");
assert_eq!(plural(12, "episode"), "12 episodes");
}
}
+376
View File
@@ -0,0 +1,376 @@
//! Drawing. Every function here takes what it needs explicitly rather than an
//! `&mut App`, because the borrow checker is what keeps a click handler from
//! mutating the library it is iterating: pages collect [`Action`]s and the app
//! applies them after the frame is drawn.
pub mod library;
pub mod player;
pub mod shares;
pub mod title;
pub mod transport;
use std::collections::HashMap;
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Vec2};
use pstr_core::library::{Episode, Title};
use pstr_core::metadata::{ArtShape, EpisodeGuide, EpisodeMetadata, MetadataRecord};
use crate::engine::{Engine, ImageCache, thumbnail_key};
use crate::theme;
/// Everything the pages need to draw one title's picture.
///
/// Bundled because there are now three places a tile's art can come from and
/// four call sites that need all three; passing them separately made every
/// signature in this module four arguments longer.
pub struct Art<'a> {
pub engine: &'a Engine,
/// Proton's own per-file thumbnails.
pub thumbs: &'a mut ImageCache,
/// Artwork from a metadata provider, keyed by title key.
pub posters: &'a mut ImageCache,
/// What the providers have said, keyed by title key.
pub metadata: &'a HashMap<String, MetadataRecord>,
/// What they said about the episodes under those titles.
pub episodes: &'a HashMap<String, EpisodeGuide>,
}
impl Art<'_> {
/// What the provider says about one file of a title, matched on the
/// numbering its name states.
pub fn episode(&self, title_key: &str, episode: &Episode) -> Option<&EpisodeMetadata> {
let number = episode.node.parsed.episode?;
self.episodes
.get(title_key)?
.get(episode.node.parsed.season, number)
}
/// The picture for a title, and how to fit it.
///
/// Provider artwork first, then Proton's still, then nothing. That order is
/// deliberate: a provider's poster is *of the title*, where a Proton
/// thumbnail is whatever frame happened to be at the start of the first
/// episode — often a black frame or a studio logo. Both beat initials.
pub fn of(&mut self, title: &Title) -> Option<(egui::TextureHandle, ArtShape)> {
if let Some((url, shape)) = self
.metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
.and_then(|metadata| metadata.tile_art())
{
let engine = self.engine;
let key = title.key.clone();
let url = url.to_string();
if let Some(texture) = self
.posters
.texture(title.key.clone(), || engine.request_poster(key, url))
{
return Some((texture, shape));
}
}
let node = title.poster_node()?;
let engine = self.engine;
let texture = self
.thumbs
.texture(thumbnail_key(node), || engine.request_thumbnail(node))?;
Some((texture, ArtShape::Landscape))
}
}
/// `1:03:47`, or `4:12` for anything under an hour.
pub fn format_time(seconds: f64) -> String {
if !seconds.is_finite() || seconds < 0.0 {
return "--:--".into();
}
let total = seconds.round() as u64;
let (hours, minutes, secs) = (total / 3600, (total / 60) % 60, total % 60);
if hours > 0 {
format!("{hours}:{minutes:02}:{secs:02}")
} else {
format!("{minutes}:{secs:02}")
}
}
/// `1.4 GiB`, for what a file costs to watch.
pub fn format_size(bytes: i64) -> String {
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
let mut value = bytes.max(0) as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{value:.0} {}", UNITS[unit])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
/// A section title with a rule under it.
pub fn section(ui: &mut egui::Ui, text: &str) {
ui.add_space(6.0);
ui.label(
egui::RichText::new(text)
.size(17.0)
.strong()
.color(theme::TEXT),
);
ui.add_space(2.0);
}
/// Small grey text, for everything that is context rather than content.
pub fn muted(text: impl Into<String>) -> egui::RichText {
egui::RichText::new(text).size(12.0).color(theme::MUTED)
}
/// The one button style that means "this is the action".
pub fn accent_button(ui: &mut egui::Ui, text: &str) -> egui::Response {
ui.add(
egui::Button::new(egui::RichText::new(text).strong().color(Color32::WHITE))
.fill(theme::ACCENT)
.corner_radius(CornerRadius::same(8)),
)
}
/// What a tile shows.
pub struct Card<'a> {
/// The picture and how to fit it. `None` draws the placeholder.
pub art: Option<(egui::TextureHandle, ArtShape)>,
pub name: &'a str,
pub subtitle: String,
/// Fraction watched, drawn as a bar across the bottom of the still.
pub progress: Option<f32>,
/// A short label in the corner — `S01E04`, `Film`.
pub badge: Option<String>,
}
/// Draw one tile and report whether it was clicked.
///
/// A landscape picture is cropped to fill: a grid of differently-shaped black
/// bars reads as broken, and a video still is not a composition anyone framed,
/// so nothing is lost by trimming it. A *poster* is fitted instead, because
/// cropping a 2:3 poster to 16:9 removes most of what makes it recognisable —
/// the title, usually. The gap it leaves is filled with the card colour, which
/// reads as deliberate in a way a stretched poster does not.
pub fn card(ui: &mut egui::Ui, card: Card<'_>) -> egui::Response {
let width = theme::CARD_WIDTH;
let image_height = (width * theme::CARD_ASPECT).round();
// Two lines of name plus one of subtitle. Fixed, because the tile is
// allocated before the name is laid out — and because a grid whose rows are
// each a different height by title length reads as broken.
let text_height = 54.0;
let radius = CornerRadius::same(8);
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, image_height + text_height), Sense::click());
if !ui.is_rect_visible(rect) {
return response;
}
let image_rect = Rect::from_min_size(rect.min, Vec2::new(width, image_height));
let hovered = response.hovered();
let painter = ui.painter();
painter.rect_filled(image_rect, radius, theme::CARD);
match &card.art {
Some((texture, ArtShape::Landscape)) => {
let size = texture.size_vec2();
painter.image(
texture.id(),
image_rect,
cover_uv(size, image_rect.size()),
Color32::WHITE,
);
}
Some((texture, ArtShape::Portrait)) => {
painter.image(
texture.id(),
contain_rect(texture.size_vec2(), image_rect),
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
}
None => {
painter.text(
image_rect.center(),
Align2::CENTER_CENTER,
initials(card.name),
egui::FontId::proportional(28.0),
theme::MUTED,
);
}
}
if let Some(badge) = &card.badge {
let anchor = image_rect.right_top() + Vec2::new(-8.0, 8.0);
let galley = painter.layout_no_wrap(
badge.clone(),
egui::FontId::proportional(11.0),
Color32::WHITE,
);
let background = Rect::from_min_size(
anchor - Vec2::new(galley.size().x + 10.0, 0.0),
galley.size() + Vec2::new(10.0, 4.0),
);
painter.rect_filled(
background,
CornerRadius::same(4),
Color32::from_black_alpha(180),
);
painter.galley(background.min + Vec2::new(5.0, 2.0), galley, Color32::WHITE);
}
if let Some(progress) = card.progress {
let track = Rect::from_min_size(
image_rect.left_bottom() - Vec2::new(0.0, 4.0),
Vec2::new(width, 4.0),
);
painter.rect_filled(track, CornerRadius::ZERO, Color32::from_black_alpha(150));
let mut played = track;
played.set_width(track.width() * progress.clamp(0.0, 1.0));
painter.rect_filled(played, CornerRadius::ZERO, theme::ACCENT);
}
if hovered {
painter.rect_stroke(
image_rect,
radius,
Stroke::new(2.0, theme::ACCENT),
egui::StrokeKind::Inside,
);
}
let text_rect = Rect::from_min_size(
image_rect.left_bottom() + Vec2::new(0.0, 6.0),
Vec2::new(width, text_height - 6.0),
);
let painter = painter.with_clip_rect(text_rect);
// Long titles are the normal case in a release-named library, so the name
// is capped at two lines with an ellipsis rather than allowed to grow into
// the line below it.
let mut job = egui::text::LayoutJob::simple(
card.name.to_string(),
egui::FontId::proportional(14.0),
if hovered { Color32::WHITE } else { theme::TEXT },
width,
);
job.wrap.max_rows = 2;
job.wrap.overflow_character = Some('…');
let name = painter.layout_job(job);
let name_height = name.size().y;
painter.galley(text_rect.min, name, theme::TEXT);
painter.text(
text_rect.min + Vec2::new(0.0, name_height + 2.0),
Align2::LEFT_TOP,
card.subtitle,
egui::FontId::proportional(11.5),
theme::MUTED,
);
response.on_hover_cursor(egui::CursorIcon::PointingHand)
}
/// The largest centred rectangle inside `target` with `image`'s aspect ratio.
///
/// The other half of [`cover_uv`]: where that one keeps the whole rectangle and
/// trims the picture, this keeps the whole picture and gives back part of the
/// rectangle.
fn contain_rect(image: Vec2, target: Rect) -> Rect {
if image.x <= 0.0 || image.y <= 0.0 {
return target;
}
let scale = (target.width() / image.x).min(target.height() / image.y);
Rect::from_center_size(target.center(), image * scale)
}
/// UV rectangle that crops `image` to fill `target` without distorting it.
fn cover_uv(image: Vec2, target: Vec2) -> Rect {
if image.x <= 0.0 || image.y <= 0.0 || target.x <= 0.0 || target.y <= 0.0 {
return Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
}
let image_aspect = image.x / image.y;
let target_aspect = target.x / target.y;
let (width, height) = if image_aspect > target_aspect {
// Too wide: keep the full height, trim the sides.
(target_aspect / image_aspect, 1.0)
} else {
(1.0, image_aspect / target_aspect)
};
Rect::from_center_size(egui::pos2(0.5, 0.5), Vec2::new(width, height))
}
/// Up to two letters, for a tile with no picture.
fn initials(name: &str) -> String {
name.split_whitespace()
.filter_map(|word| word.chars().next())
.filter(|character| character.is_alphanumeric())
.take(2)
.flat_map(char::to_uppercase)
.collect()
}
/// How many cards fit, and the gap that leaves them evenly spread.
pub fn columns(available: f32) -> usize {
(((available + theme::CARD_GAP) / (theme::CARD_WIDTH + theme::CARD_GAP)).floor() as usize)
.max(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn times_are_formatted_by_length() {
assert_eq!(format_time(0.0), "0:00");
assert_eq!(format_time(72.4), "1:12");
assert_eq!(format_time(3805.0), "1:03:25");
assert_eq!(format_time(f64::NAN), "--:--");
}
#[test]
fn sizes_use_binary_units() {
assert_eq!(format_size(512), "512 B");
assert_eq!(format_size(1024 * 1024), "1.0 MiB");
assert_eq!(format_size(3 * 1024 * 1024 * 1024 / 2), "1.5 GiB");
}
#[test]
fn a_wide_image_is_cropped_at_the_sides() {
let uv = cover_uv(Vec2::new(200.0, 50.0), Vec2::new(100.0, 50.0));
assert!(uv.width() < 1.0);
assert!((uv.height() - 1.0).abs() < f32::EPSILON);
assert!((uv.center().x - 0.5).abs() < f32::EPSILON);
}
#[test]
fn a_tall_image_is_cropped_top_and_bottom() {
let uv = cover_uv(Vec2::new(50.0, 200.0), Vec2::new(100.0, 50.0));
assert!((uv.width() - 1.0).abs() < f32::EPSILON);
assert!(uv.height() < 1.0);
}
#[test]
fn a_degenerate_image_falls_back_to_the_whole_texture() {
let uv = cover_uv(Vec2::ZERO, Vec2::new(100.0, 50.0));
assert_eq!(
uv,
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0))
);
}
#[test]
fn initials_take_two_letters() {
assert_eq!(initials("Cowboy Bebop"), "CB");
assert_eq!(initials("Akira"), "A");
assert_eq!(initials(""), "");
}
#[test]
fn column_count_never_reaches_zero() {
assert_eq!(columns(0.0), 1);
assert!(columns(2000.0) > 1);
}
}
+668
View File
@@ -0,0 +1,668 @@
//! The player page: the picture, filling the window, with controls over it.
//!
//! The video is one `painter.image` call — everything that made it a texture is
//! in [`crate::video`]. What is left here is the part a viewer notices: the
//! controls fade out of the way while something is playing and come back on the
//! first movement of the mouse, which is the behaviour every player has and the
//! only reason a full-bleed picture is usable at all.
//!
//! Two things are deliberately *not* subject to that fade. The skip-opening
//! button, which is only up for the ninety seconds it applies to and is useless
//! if you have to wake the controls to reach it; and the whole page while
//! nothing is playing — between two episodes there is no picture to keep clear
//! of, only a black screen that has to say what it is doing.
//!
//! Nothing in this module mutates. Like every other page it collects
//! [`Action`]s, so a click on "back" can change the page it was drawn on.
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Vec2};
use crate::app::{Action, Adjacent};
use crate::playback::{Command, Playback};
use crate::theme;
use crate::ui::{self, transport::Neighbours};
/// How long the controls stay up after the pointer stops moving.
///
/// Long enough to reach for them again after glancing away, short enough that
/// they are gone by the time anyone is annoyed by them.
const IDLE_SECONDS: f64 = 2.5;
/// The pointer has to move at least this far, in points, to count as movement.
/// A trackpad reports jitter of a pixel or so while nobody is touching it, and
/// without a floor the controls never hide.
const MOVEMENT: f32 = 1.5;
/// Padding around the strip of controls along the bottom.
///
/// The height is *not* a constant. The controls are two rows of buttons whose
/// size follows the theme and the platform's text scaling, and a fixed strip
/// that guesses low puts the buttons past the bottom edge of the window — which
/// is what a 132 px constant did here.
const CHROME_PAD_X: f32 = 26.0;
const CHROME_PAD_BOTTOM: f32 = 18.0;
const CHROME_PAD_TOP: f32 = 16.0;
/// What to assume the controls are worth before they have been drawn once.
/// Only ever wrong for a single frame, and only on the first one.
const CHROME_HEIGHT_GUESS: f32 = 116.0;
/// Height of the title strip along the top.
const TITLE_HEIGHT: f32 = 68.0;
/// Gap between the controls and whatever floats above them.
const FLOAT_GAP: f32 = 12.0;
/// The skip button: how tall, and how much room the label gets either side.
/// The height doubles as its corner radius, which is what makes it a pill.
const SKIP_HEIGHT: f32 = 40.0;
const SKIP_PAD_X: f32 = 22.0;
/// How dark the scrim gets behind the controls, and how far above them it takes
/// to get there.
///
/// The fade lives in the empty picture *above* the controls rather than across
/// them, so every row of them — the seek bar and the timecodes included — sits
/// on the full wash. See [`scrim_bottom`].
const SCRIM_ALPHA: f32 = 170.0;
const SCRIM_FADE: f32 = 88.0;
/// State the page keeps between frames.
///
/// Held by the app and passed in by reference, because one thing here *is*
/// written while drawing: how tall the controls turned out. Everything the
/// viewer can see still comes from the app's state.
#[derive(Debug, Clone, Copy)]
pub struct Overlay {
/// When the pointer last moved, on egui's clock. `None` until the page has
/// seen a frame, which is what keeps the controls up on arrival — a viewer
/// who has just clicked "play" should see what the controls are before they
/// fade.
last_movement: Option<f64>,
/// Where the pointer was, to tell movement from jitter.
last_position: Option<egui::Pos2>,
/// How tall the controls were the last time they were drawn, padding
/// included. Measured rather than assumed — see [`CHROME_PAD_X`].
chrome_height: f32,
}
impl Default for Overlay {
fn default() -> Self {
Self {
last_movement: None,
last_position: None,
chrome_height: CHROME_HEIGHT_GUESS + CHROME_PAD_TOP + CHROME_PAD_BOTTOM,
}
}
}
/// Everything the page draws *over* the picture this frame.
///
/// Bundled because they arrive together and mean one thing between them — what
/// the controls are doing — and because passing them one at a time made the
/// page's entry point eight arguments long.
pub struct Chrome<'a> {
/// Whether the controls are up, and how tall they were last time. The one
/// piece of state drawing is allowed to write to.
pub overlay: &'a mut Overlay,
/// The end-of-episode card, when the app has decided there is one.
pub up_next: Option<UpNextCard>,
/// Whether stepping to another episode is possible in either direction.
pub neighbours: Neighbours,
}
/// What the end-of-episode card says, when there is one.
///
/// The app decides *whether* to show it and counts it down; this is only what
/// to draw. See [`crate::app::UpNext`].
pub struct UpNextCard {
/// Seconds until the next episode starts.
pub seconds: f64,
/// Which episode that is.
pub caption: String,
}
impl Overlay {
/// Fold this frame's pointer into the overlay's timer.
pub fn observe(&mut self, ctx: &egui::Context) {
let (now, pointer) = ctx.input(|input| (input.time, input.pointer.latest_pos()));
let last_movement = *self.last_movement.get_or_insert(now);
let moved = match (pointer, self.last_position) {
(Some(current), Some(previous)) => current.distance(previous) > MOVEMENT,
(Some(_), None) => true,
(None, _) => false,
};
self.last_movement = Some(if moved { now } else { last_movement });
if let Some(current) = pointer {
self.last_position = Some(current);
}
}
/// Whether the controls should be on screen.
///
/// Always, while paused or still opening: a still picture with no controls
/// looks like a crash, and there is nothing to be distracted from.
fn visible(&self, ctx: &egui::Context, playback: &Playback) -> bool {
if playback.paused || !playback.loaded {
return true;
}
// A track or chapter menu is open. Hiding the controls would take the
// button the menu hangs off the screen with it, which closes the menu —
// so the viewer who stops to read a list of chapters loses the list.
if egui::Popup::is_any_open(ctx) {
return true;
}
let Some(last_movement) = self.last_movement else {
return true;
};
ctx.input(|input| input.time) - last_movement < IDLE_SECONDS
}
}
/// Draw the picture and its controls.
///
/// `playback` is `None` in the gap between one episode ending and the next
/// having opened — the page stays, because that is where the viewer means to
/// be, and `opening` is what it says while they wait.
pub fn show(
ui: &mut egui::Ui,
frame: &mut eframe::Frame,
playback: Option<&mut Playback>,
opening: Option<&str>,
chrome_state: Chrome<'_>,
actions: &mut Vec<Action>,
) {
let Chrome {
overlay,
up_next,
neighbours,
} = chrome_state;
let ctx = ui.ctx().clone();
let rect = ui.available_rect_before_wrap();
if rect.width() < 1.0 || rect.height() < 1.0 {
return;
}
// Black rather than the app background: this is a cinema, and the letterbox
// bars mpv paints inside the texture have to be the same colour as what
// surrounds them.
ui.painter()
.rect_filled(rect, CornerRadius::ZERO, Color32::BLACK);
let Some(playback) = playback else {
between(ui, opening, rect, actions);
return;
};
let response = ui.allocate_rect(rect, Sense::click());
let picture = paint_video(ui, frame, playback, rect);
if !picture {
waiting(ui, playback, rect);
}
// Click anywhere on the picture to pause, which is the one control that
// should not require aiming at anything.
if response.clicked() {
actions.push(Action::Player(Command::TogglePause));
}
if response.double_clicked() {
actions.push(Action::ToggleFullscreen);
}
let visible = overlay.visible(&ctx, playback);
// Above the chrome when it is up, at the bottom corner when it is not —
// and clear of it either way, which is what the measured height buys.
let float_above = if visible {
overlay.chrome_height + FLOAT_GAP
} else {
28.0
};
match up_next {
// The card supersedes the skip button: both live in the same corner,
// and both offer a way out of the same ninety seconds.
Some(card) => up_next_card(ui, &card, rect, float_above, actions),
None => skip(ui, playback, rect, float_above, actions),
}
if visible {
overlay.chrome_height = chrome(
ui,
playback,
neighbours,
rect,
overlay.chrome_height,
actions,
);
} else {
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
// The controls have to disappear on their own, and nothing else is
// going to cause a frame while a film plays without the mouse moving.
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(250));
}
}
/// Render this frame's picture into the rectangle. Reports whether there was
/// one — a player whose first frame has not arrived has nothing to draw.
fn paint_video(
ui: &mut egui::Ui,
frame: &mut eframe::Frame,
playback: &mut Playback,
rect: Rect,
) -> bool {
let pixels_per_point = ui.ctx().pixels_per_point();
let Some(video) = playback.video.as_mut() else {
// mpv has a window of its own; there is nothing to composite here.
return false;
};
let size = [
(rect.width() * pixels_per_point).round() as i32,
(rect.height() * pixels_per_point).round() as i32,
];
let Some(texture) = video.texture(frame, size) else {
return false;
};
if !video.has_picture() {
return false;
}
ui.painter().image(
texture,
rect,
// The whole texture: mpv has already scaled the picture into it and
// letterboxed the remainder, so there is nothing to crop.
Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
true
}
/// What fills the screen before the first frame.
fn waiting(ui: &mut egui::Ui, playback: &Playback, rect: Rect) {
let painter = ui.painter();
painter.text(
rect.center() - Vec2::new(0.0, 18.0),
Align2::CENTER_CENTER,
&playback.target.title_name,
egui::FontId::proportional(20.0),
theme::TEXT,
);
painter.text(
rect.center() + Vec2::new(0.0, 10.0),
Align2::CENTER_CENTER,
if playback.is_embedded() {
"opening…"
} else {
"playing in mpv's own window"
},
egui::FontId::proportional(13.0),
theme::MUTED,
);
}
/// The screen between two episodes: one has ended and the next is opening.
///
/// It keeps a way out, because opening a file over a public link can take
/// seconds and a black screen with no controls is indistinguishable from a
/// hang.
fn between(ui: &mut egui::Ui, opening: Option<&str>, rect: Rect, actions: &mut Vec<Action>) {
ui.painter().text(
rect.center() - Vec2::new(0.0, 16.0),
Align2::CENTER_CENTER,
"Up next",
egui::FontId::proportional(14.0),
theme::MUTED,
);
ui.painter().text(
rect.center() + Vec2::new(0.0, 12.0),
Align2::CENTER_CENTER,
opening.unwrap_or("opening…"),
egui::FontId::proportional(20.0),
theme::TEXT,
);
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(Rect::from_min_size(
rect.min + Vec2::new(16.0, 14.0),
Vec2::new(240.0, 34.0),
))
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|ui| {
if ui.button("Back").clicked() {
actions.push(Action::LeavePlayer);
}
ui.add_space(8.0);
ui.add(egui::Spinner::new().size(14.0));
},
);
}
/// "Skip opening", when the playhead is inside a chapter that says it is one.
///
/// Bottom right, the corner every service puts it in, and drawn whether or not
/// the rest of the controls are up: an opening lasts ninety seconds, and a
/// button you have to wiggle the mouse to find is one you skip by hand instead.
fn skip(
ui: &mut egui::Ui,
playback: &Playback,
rect: Rect,
bottom_margin: f32,
actions: &mut Vec<Action>,
) {
let Some((label, to)) = playback.skippable() else {
return;
};
// Measured from the label rather than fixed: "Skip opening" and "Skip
// recap" are different lengths, and one constant leaves the shorter of them
// adrift in a box of its own whitespace, which is what made this read as a
// rectangle with some text in it instead of a button.
let font = egui::FontId::proportional(14.0);
let text_width = ui
.painter()
.layout_no_wrap(label.to_owned(), font.clone(), Color32::WHITE)
.size()
.x;
let size = Vec2::new((text_width + SKIP_PAD_X * 2.0).round(), SKIP_HEIGHT);
floating(ui, rect, size, bottom_margin, |ui| {
// Buttons given an explicit fill keep it in every state, so hover has
// to be answered by hand — and a control that does not answer the
// pointer at all is the other half of looking dead.
let hovered = ui.rect_contains_pointer(ui.max_rect());
let (fill, stroke) = if hovered {
(Color32::WHITE, Color32::WHITE)
} else {
(
Color32::from_black_alpha(170),
Color32::from_white_alpha(110),
)
};
let text = if hovered {
Color32::from_rgb(0x14, 0x14, 0x18)
} else {
Color32::WHITE
};
// Centred *and* justified. A button padded out by `min_size` puts its
// label wherever the surrounding layout aligns to, and `floating` aligns
// right — which left the label against the right edge with the box
// reaching past it. This fills the area and centres the label in it, so
// the padding lands evenly on both sides.
let clicked = ui
.centered_and_justified(|ui| {
ui.add(
egui::Button::new(egui::RichText::new(label).font(font).strong().color(text))
.fill(fill)
.stroke(egui::Stroke::new(1.0, stroke))
// A pill, like every other floating control here. The
// sharp 6 px corner was the one square thing on the
// picture.
.corner_radius(CornerRadius::same((SKIP_HEIGHT / 2.0) as u8)),
)
.on_hover_text(format!("Jump to {}", ui::format_time(to)))
.clicked()
})
.inner;
if clicked {
actions.push(Action::Player(Command::SeekTo(to)));
}
});
}
/// "Up next", counting down, with the two ways out of it.
///
/// The countdown itself belongs to the app — see [`crate::app::UpNext`] — so
/// this only draws what it was told and reports what was clicked. Both buttons
/// are real buttons rather than one button and a timer: a viewer who wants the
/// next episode *now* should not have to wait out a countdown that exists to
/// give them time to say no.
fn up_next_card(
ui: &mut egui::Ui,
card: &UpNextCard,
rect: Rect,
bottom_margin: f32,
actions: &mut Vec<Action>,
) {
let size = Vec2::new(330.0, 104.0);
floating(ui, rect, size, bottom_margin, |ui| {
egui::Frame::new()
.fill(Color32::from_black_alpha(215))
.stroke(egui::Stroke::new(1.0, Color32::from_white_alpha(120)))
.corner_radius(CornerRadius::same(8))
.inner_margin(egui::Margin::symmetric(14, 12))
.show(ui, |ui| {
ui.set_width(size.x - 28.0);
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Up next")
.size(12.0)
.strong()
.color(Color32::from_white_alpha(200)),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// Rounded up, so a card that says "1s" is never
// followed by a second of nothing happening.
ui.label(
egui::RichText::new(format!("in {}s", card.seconds.ceil() as u32))
.size(12.0)
.color(theme::ACCENT),
);
});
});
ui.add(
egui::Label::new(
egui::RichText::new(&card.caption)
.size(14.0)
.strong()
.color(Color32::WHITE),
)
.truncate(),
);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Play now").clicked() {
actions.push(Action::PlayAdjacent(Adjacent::Next));
}
ui.add_space(6.0);
if ui
.button("Watch till the end")
.on_hover_text("Play this one out — nothing will be skipped")
.clicked()
{
actions.push(Action::WatchToEnd);
}
});
});
});
}
/// Put something in the bottom-right corner, `bottom_margin` above the edge.
///
/// The inset matches the controls' own padding rather than being its own
/// number, so a floating button lines up with the volume slider under it
/// instead of sitting four pixels off it.
fn floating(
ui: &mut egui::Ui,
rect: Rect,
size: Vec2,
bottom_margin: f32,
add: impl FnOnce(&mut egui::Ui),
) {
let area = Rect::from_min_size(
egui::pos2(
rect.right() - CHROME_PAD_X - size.x,
rect.bottom() - bottom_margin - size.y,
),
size,
);
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(area)
.layout(egui::Layout::top_down(egui::Align::Max)),
add,
);
}
/// The controls: a title strip at the top, the transport at the bottom.
///
/// Returns how tall the bottom strip came out, padding included, so the next
/// frame can put its scrim behind it and float the skip button clear of it. It
/// is measured because it is not knowable: the transport is two rows of themed
/// buttons, and on a display that scales text they are taller than they are
/// here. Guessing low is what put the buttons through the bottom of the window.
fn chrome(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
rect: Rect,
previous_height: f32,
actions: &mut Vec<Action>,
) -> f32 {
let top = Rect::from_min_size(rect.min, Vec2::new(rect.width(), TITLE_HEIGHT));
scrim_top(ui, top);
ui.scope_builder(
egui::UiBuilder::new()
.max_rect(top.shrink2(Vec2::new(18.0, 14.0)))
.layout(egui::Layout::left_to_right(egui::Align::Center)),
|ui| {
if ui
.add(
egui::Button::new(egui::RichText::new("Library").size(13.0))
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(8))
.min_size(Vec2::new(96.0, 32.0)),
)
.on_hover_text("Keep playing, and go back to the library (Esc)")
.clicked()
{
actions.push(Action::LeavePlayer);
}
ui.add_space(14.0);
ui.vertical(|ui| {
ui.label(
egui::RichText::new(&playback.target.title_name)
.size(18.0)
.strong()
.color(Color32::WHITE),
);
ui.label(
egui::RichText::new(playback.target.caption())
.size(13.0)
.color(Color32::from_white_alpha(190)),
);
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(egui::RichText::new("⛶").size(15.0))
.fill(Color32::from_black_alpha(160))
.corner_radius(CornerRadius::same(8))
.min_size(Vec2::new(38.0, 32.0)),
)
.on_hover_text("Fullscreen (F)")
.clicked()
{
actions.push(Action::ToggleFullscreen);
}
});
},
);
// Last frame's measurement places the scrim and the first row of controls;
// this frame's is returned for the next one. That is one frame of lag, and
// only while the window is being resized.
let strip = Rect::from_min_size(
egui::pos2(rect.left(), rect.bottom() - previous_height),
Vec2::new(rect.width(), previous_height),
);
scrim_bottom(ui, strip);
// Anchored to the *bottom* of the window rather than laid out from the top
// of the strip: whatever the controls turn out to be worth, the last row of
// them ends a fixed distance above the edge instead of running off it.
let content = Rect::from_min_max(
egui::pos2(rect.left() + CHROME_PAD_X, strip.top() + CHROME_PAD_TOP),
egui::pos2(
rect.right() - CHROME_PAD_X,
rect.bottom() - CHROME_PAD_BOTTOM,
),
);
let measured = ui
.scope_builder(
egui::UiBuilder::new()
.max_rect(content)
.layout(egui::Layout::top_down(egui::Align::Min)),
|ui| {
ui::transport::full(ui, playback, neighbours, actions);
ui.min_rect().height()
},
)
.inner;
measured + CHROME_PAD_TOP + CHROME_PAD_BOTTOM
}
/// The gradient behind the title strip, fading downwards out of the top edge.
///
/// Drawn as a few bands rather than a real gradient — egui has no mesh gradient
/// primitive, and at this size the banding is invisible.
fn scrim_top(ui: &egui::Ui, rect: Rect) {
const BANDS: usize = 10;
let painter = ui.painter();
let band = rect.height() / BANDS as f32;
for index in 0..BANDS {
let fraction = 1.0 - index as f32 / (BANDS - 1) as f32;
let strip = Rect::from_min_size(
egui::pos2(rect.left(), rect.top() + band * index as f32),
Vec2::new(rect.width(), band + 1.0),
);
painter.rect_filled(
strip,
CornerRadius::ZERO,
// Squared, so the fade is dark where the text is and gone well
// before the middle of the picture — a linear ramp over a strip
// this tall greys the whole top third of the frame.
Color32::from_black_alpha((fraction * fraction * 225.0) as u8),
);
}
}
/// The wash behind the controls: flat over the whole strip, fading in above it.
///
/// A gradient that starts fading only where the controls start puts the top of
/// them — the seek bar, the position and the chapter name — over what is very
/// nearly bare picture, and over a bright frame that text disappears. So the
/// strip itself is one flat wash, and the ramp happens in the empty picture
/// above it, where there is nothing to obscure.
fn scrim_bottom(ui: &egui::Ui, strip: Rect) {
const BANDS: usize = 12;
let painter = ui.painter();
painter.rect_filled(
strip,
CornerRadius::ZERO,
Color32::from_black_alpha(SCRIM_ALPHA as u8),
);
let band = SCRIM_FADE / BANDS as f32;
for index in 0..BANDS {
let fraction = (index + 1) as f32 / BANDS as f32;
let rect = Rect::from_min_size(
egui::pos2(strip.left(), strip.top() - SCRIM_FADE + band * index as f32),
Vec2::new(strip.width(), band + 1.0),
);
painter.rect_filled(
rect,
CornerRadius::ZERO,
// Squared again: the wash should meet the strip at full strength
// and be gone within a finger's width of picture above it.
Color32::from_black_alpha((fraction * fraction * SCRIM_ALPHA) as u8),
);
}
}
+283
View File
@@ -0,0 +1,283 @@
//! The shares page: which links this app knows, and how to add one.
//!
//! The URL fragment of a Proton share link **is** its decryption password, so
//! this page never prints a URL back — `shares.json` holds only the id, the name
//! and the token, and the secrets live in the OS credential store. The form
//! below is the only place a link is ever visible, and only while it is being
//! typed.
use pstr_core::Share;
use pstr_core::metadata::{MetadataConfig, ProviderId};
use crate::app::{Action, ShareForm};
use crate::theme;
use crate::ui;
pub fn show(
ui: &mut egui::Ui,
shares: &[Share],
form: &mut ShareForm,
settings: &mut MetadataConfig,
autoplay: bool,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
ui::section(ui, "Shares");
if shares.is_empty() {
ui.label(ui::muted("No shares yet."));
}
for share in shares {
share_row(ui, share, actions);
}
ui.add_space(22.0);
add_form(ui, form, actions);
ui.add_space(28.0);
playback_form(ui, autoplay, actions);
ui.add_space(28.0);
metadata_form(ui, settings, api_key, actions);
});
}
/// Enrichment: the switch, the provider, and the cost of turning it on.
///
/// The privacy note is not decoration and it is not in a tooltip. Turning this
/// on sends the titles in someone's library to a third party, which is a thing
/// they can only agree to if they are told — so it is stated where the switch
/// is, before the switch, in the same size as everything else.
/// The handful of playback settings that are not per-file.
///
/// Volume, audio language and subtitle language are all set from the player
/// itself, where a viewer can hear the result — only autoplay has no natural
/// home there, because by the time it matters the episode is over.
fn playback_form(ui: &mut egui::Ui, autoplay: bool, actions: &mut Vec<Action>) {
ui::section(ui, "Playback");
egui::Frame::new()
.fill(theme::CARD)
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
let mut enabled = autoplay;
if ui
.checkbox(&mut enabled, "Play the next episode automatically")
.on_hover_text("Only when an episode reaches its end, never after a failure")
.changed()
{
actions.push(Action::SetAutoplay(enabled));
}
ui.label(ui::muted(
"Volume, audio language and subtitles are set from the player, and remembered \
from there.",
));
});
}
fn metadata_form(
ui: &mut egui::Ui,
settings: &mut MetadataConfig,
api_key: &mut String,
actions: &mut Vec<Action>,
) {
ui::section(ui, "Posters and descriptions");
egui::Frame::new()
.fill(theme::CARD)
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted(
"A share filled by the Proton Drive desktop client carries no thumbnails, so \
without this every tile is a pair of initials. Turning it on sends the titles \
in your library — not your files, and nothing about what you have watched — to \
the provider you choose, over HTTPS, each time a new one appears.",
));
ui.add_space(10.0);
let mut changed = false;
let mut enabled = settings.enabled;
if ui
.checkbox(&mut enabled, "Look up posters and descriptions")
.changed()
{
settings.enabled = enabled;
changed = true;
}
if settings.enabled {
ui.add_space(10.0);
ui.label(ui::muted("Provider"));
for provider in ProviderId::ALL {
if ui
.radio(settings.provider == provider, provider.label())
.on_hover_text(provider.description())
.clicked()
&& settings.provider != provider
{
settings.provider = provider;
changed = true;
}
}
if settings.provider.needs_api_key() {
ui.add_space(10.0);
ui.label(ui::muted(format!("{} API key", settings.provider.label())));
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(api_key)
.password(true)
.hint_text("v3 API key")
.desired_width(300.0),
);
if ui.button("Save key").clicked() {
actions.push(Action::SetApiKey {
provider: settings.provider,
key: std::mem::take(api_key),
});
}
});
ui.label(ui::muted(
"Stored in your system keyring, never in a config file. Leave the box \
empty and save to forget it.",
));
}
ui.add_space(12.0);
ui.horizontal(|ui| {
if ui::accent_button(ui, "Match the library").clicked() {
actions.push(Action::MatchTitles { force: false });
}
if ui
.button("Match everything again")
.on_hover_text("Ask about every title, including ones already matched")
.clicked()
{
actions.push(Action::MatchTitles { force: true });
}
});
} else {
ui.add_space(6.0);
ui.label(ui::muted(
"Off. Nothing is sent anywhere, and turning it off also deletes the answers \
already stored.",
));
}
if changed {
actions.push(Action::SetMetadataConfig(settings.clone()));
}
});
}
fn share_row(ui: &mut egui::Ui, share: &Share, actions: &mut Vec<Action>) {
egui::Frame::new()
.fill(theme::CARD)
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::symmetric(12, 10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.label(egui::RichText::new(&share.name).strong());
let detail = if share.has_custom_password {
format!("{} · custom password", share.id)
} else {
share.id.clone()
};
ui.label(ui::muted(detail));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new("Remove").fill(theme::CARD_HOVER))
.on_hover_text("Forget this share, its catalog rows and its stored secrets")
.clicked()
{
actions.push(Action::RemoveShare(share.id.clone()));
}
if ui.button("Crawl").clicked() {
actions.push(Action::Crawl(Some(share.id.clone())));
}
});
});
});
ui.add_space(6.0);
}
fn add_form(ui: &mut egui::Ui, form: &mut ShareForm, actions: &mut Vec<Action>) {
ui::section(ui, "Add a share");
ui.label(ui::muted(
"Paste the whole link, including everything after the # — that part is the key that \
decrypts it, and it is stored in your system keyring rather than on disk.",
));
ui.add_space(10.0);
egui::Frame::new()
.fill(theme::CARD)
.corner_radius(egui::CornerRadius::same(8))
.inner_margin(egui::Margin::same(14))
.show(ui, |ui| {
ui.set_width(ui.available_width().min(640.0));
ui.label(ui::muted("Name"));
ui.add(
egui::TextEdit::singleline(&mut form.name)
.hint_text("Anime")
.desired_width(f32::INFINITY),
);
ui.add_space(8.0);
ui.label(ui::muted("Link"));
ui.add(
egui::TextEdit::singleline(&mut form.url)
.hint_text("https://drive.proton.me/urls/…#…")
.desired_width(f32::INFINITY),
);
ui.add_space(8.0);
ui.checkbox(&mut form.has_password, "The link asks for a password");
if form.has_password {
ui.add_space(6.0);
ui.add(
egui::TextEdit::singleline(&mut form.password)
.password(true)
.hint_text("Link password")
.desired_width(f32::INFINITY),
);
}
ui.add_space(12.0);
let ready = !form.name.trim().is_empty()
&& !form.url.trim().is_empty()
&& (!form.has_password || !form.password.is_empty());
ui.horizontal(|ui| {
if ui
.add_enabled_ui(ready, |ui| ui::accent_button(ui, "Add and crawl"))
.inner
.clicked()
{
actions.push(Action::AddShare {
name: form.name.trim().to_string(),
url: form.url.trim().to_string(),
password: form
.has_password
.then(|| form.password.clone())
.filter(|password| !password.is_empty()),
});
}
if !ready {
ui.label(ui::muted("A name and a link are needed."));
}
});
});
}
+331
View File
@@ -0,0 +1,331 @@
//! One title: the still, what it is, and every episode under it.
use pstr_core::library::{Episode, Library, Season, Title, TitleKind};
use pstr_core::metadata::TitleMetadata;
use crate::app::{Action, Page};
use crate::playback::PlaybackTarget;
use crate::theme;
use crate::ui::{self, Art, Card};
pub fn show(
ui: &mut egui::Ui,
art: &mut Art<'_>,
library: &Library,
key: &str,
actions: &mut Vec<Action>,
) {
let Some(title) = library.get(key) else {
// The catalog was replaced under the page — a recrawl that dropped this
// title. Nothing to show, so go back rather than draw an empty shell.
actions.push(Action::Goto(Page::Library));
return;
};
if ui.button("Library").clicked() {
actions.push(Action::Goto(Page::Library));
}
ui.add_space(10.0);
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
header(ui, art, title, actions);
ui.add_space(18.0);
for (index, season) in title.seasons.iter().enumerate() {
let open = index == 0 || title.seasons.len() == 1;
egui::CollapsingHeader::new(
egui::RichText::new(format!(
"{} · {}",
season.label(),
ui::library::plural(season.episodes.len(), "episode")
))
.size(15.0)
.strong(),
)
.id_salt(("season", index))
.default_open(open)
.show(ui, |ui| {
for episode in &season.episodes {
episode_row(ui, art, title, season, episode, actions);
}
});
ui.add_space(6.0);
}
});
}
/// The still, the name and the one button that matters.
fn header(ui: &mut egui::Ui, art: &mut Art<'_>, title: &Title, actions: &mut Vec<Action>) {
// Cloned out of the borrow: `art` is held mutably for the card below, and
// the description is drawn beside it.
let found = art
.metadata
.get(&title.key)
.and_then(|record| record.metadata.clone());
ui.horizontal_top(|ui| {
ui::card(
ui,
Card {
art: art.of(title),
name: &title.name,
subtitle: String::new(),
progress: title.resume().and_then(|e| e.progress()).map(|v| v as f32),
badge: None,
},
);
ui.add_space(18.0);
ui.vertical(|ui| {
ui.label(egui::RichText::new(&title.name).size(26.0).strong());
// The provider's name for it, when it is not the one the files use.
// Worth showing rather than replacing the filename's: a viewer
// should be able to tell what the match actually matched.
if let Some(found) = &found
&& found.name != title.name
{
ui.label(ui::muted(format!("also known as {}", found.name)));
}
ui.add_space(4.0);
ui.label(ui::muted(meta_line(title, found.as_ref())));
ui.add_space(14.0);
ui.horizontal(|ui| {
if let Some(episode) = title.next_up() {
let resuming = episode.resume_at().is_some();
let label = match (resuming, episode.numbering()) {
(true, Some(numbering)) => format!("Resume {numbering}"),
(true, None) => "Resume".to_string(),
(false, Some(numbering)) if title.kind == TitleKind::Series => {
format!("Play {numbering}")
}
_ => "Play".to_string(),
};
if ui::accent_button(ui, &label).clicked() {
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
if resuming && let Some(at) = episode.resume_at() {
ui.label(ui::muted(format!("at {}", ui::format_time(at))));
if ui
.button("Start over")
.on_hover_text("Play from the beginning")
.clicked()
{
actions.push(Action::Play(PlaybackTarget::from_node(
title,
&episode.node,
None,
)));
}
}
}
});
if let Some(found) = &found {
ui.add_space(14.0);
description(ui, found);
}
});
});
}
/// What the provider had to say. Only ever drawn under a match.
fn description(ui: &mut egui::Ui, found: &TitleMetadata) {
if !found.genres.is_empty() {
ui.label(ui::muted(found.genres.join(" · ")));
ui.add_space(6.0);
}
if let Some(overview) = &found.overview {
ui.add(
egui::Label::new(egui::RichText::new(overview).size(13.0).color(theme::TEXT)).wrap(),
);
}
if let Some(url) = &found.url {
ui.add_space(8.0);
ui.hyperlink_to(
ui::muted(format!("More on {}", found.provider.label())),
url,
);
}
}
/// The grey line under the name: what the library knows, plus what the provider
/// added. The library's own counts come first — they describe the files that are
/// actually there, which is what a viewer is deciding about.
fn meta_line(title: &Title, found: Option<&TitleMetadata>) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(
match title.kind {
TitleKind::Series => "Series",
TitleKind::Film => "Film",
}
.to_string(),
);
if let Some(year) = title.year {
parts.push(year.to_string());
}
if title.kind == TitleKind::Series {
if title.seasons.len() > 1 {
parts.push(ui::library::plural(title.seasons.len(), "season"));
}
parts.push(ui::library::plural(title.episode_count(), "episode"));
}
let watched = title.watched_count();
if watched > 0 {
parts.push(format!("{watched} watched"));
}
if let Some(found) = found {
if let Some(rating) = found.rating {
parts.push(format!("★ {rating:.1}"));
}
// Only when it disagrees with what is on disk: "25 episodes · 25
// episodes" tells nobody anything, but "12 episodes · 25 on AniList"
// says the share is missing half a season.
if let Some(total) = found.episodes
&& title.kind == TitleKind::Series
&& total as usize != title.episode_count()
{
parts.push(format!("{total} on {}", found.provider.label()));
}
}
parts.join(" · ")
}
/// One line per file: click anywhere on it to play.
fn episode_row(
ui: &mut egui::Ui,
art: &mut Art<'_>,
title: &Title,
season: &Season,
episode: &Episode,
actions: &mut Vec<Action>,
) {
// Cloned out before `ui` borrows: the row draws while `art` is held.
let found = art.episode(&title.key, episode).map(|found| {
(
found.name.clone(),
found.overview.clone(),
found.air_date.clone(),
)
});
let watched = episode.is_watched();
egui::Frame::new()
.fill(if watched {
theme::BACKGROUND
} else {
theme::CARD
})
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::symmetric(10, 8))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
// An explicit target rather than a clickable row: the row also
// carries a checkbox, and a click that means "seen" must never
// be one that starts a 1.4 GiB stream instead.
if ui
.add(
egui::Button::new(egui::RichText::new("▶").size(13.0))
.fill(theme::CARD_HOVER),
)
.on_hover_text("Play")
.clicked()
{
actions.push(Action::Play(PlaybackTarget::new(title, episode)));
}
let numbering = episode
.numbering()
.or_else(|| season.number.map(|number| format!("S{number:02}")))
.unwrap_or_default();
ui.add_sized(
[66.0, 18.0],
egui::Label::new(
egui::RichText::new(numbering)
.monospace()
.color(theme::MUTED),
),
);
// The provider's name for the episode, when there is one:
// "The Immortal Legion" reads as an episode, and
// "[Reaktor] … E57 v2 [1080p][x265].mkv" reads as a filename.
let name = found
.as_ref()
.and_then(|(name, _, _)| name.clone())
.unwrap_or_else(|| episode.detail().to_string());
let label = egui::RichText::new(name).color(if watched {
theme::MUTED
} else {
theme::TEXT
});
let hover = match &found {
// The synopsis, where the provider has one — AniList does
// not, and the filename is worth showing either way.
Some((_, Some(overview), _)) => {
format!("{}\n\n{overview}", episode.node.name)
}
_ => episode.node.name.clone(),
};
ui.add(egui::Label::new(label).truncate())
.on_hover_text(hover);
if let Some((_, _, Some(air_date))) = &found {
ui.label(ui::muted(air_date.clone()));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let mut checked = watched;
if ui
.checkbox(&mut checked, "")
.on_hover_text(if watched {
"Mark unwatched"
} else {
"Mark watched"
})
.changed()
{
actions.push(Action::SetWatched {
share_id: episode.node.share_id.clone(),
link_id: episode.node.link_id.clone(),
watched: checked,
duration: episode.watch.and_then(|watch| watch.duration_secs),
});
}
match episode.node.size {
// A file the share says is empty is one that will not
// play — an upload that never finished, usually. Worth
// saying so on the row rather than only when a click on
// it comes back with "has no content".
Some(0) => {
ui.label(
egui::RichText::new("empty")
.size(12.0)
.color(theme::DANGER),
)
.on_hover_text(
"The share reports no content for this file; it cannot be played.",
);
}
Some(size) => {
ui.label(ui::muted(ui::format_size(size)));
}
None => {}
}
if let Some(at) = episode.resume_at() {
ui.label(
egui::RichText::new(format!("resume {}", ui::format_time(at)))
.size(12.0)
.color(theme::ACCENT),
);
}
});
});
});
ui.add_space(4.0);
}
+474
View File
@@ -0,0 +1,474 @@
//! The controls for whatever is playing, in two densities.
//!
//! [`full`] is the overlay over the picture: everything, laid out to be aimed at
//! from a sofa — a wide seek bar with the file's chapters marked on it, a
//! centred transport cluster, and the pickers off to the right. [`mini`] is the
//! strip along the bottom of the library, where the picture is *not*: it keeps
//! only what someone browsing needs, plus the one control that page has to have
//! — the way back to the video.
//!
//! Both push the same [`Action`]s, so there is one behaviour and two layouts,
//! and neither mutates: this module draws.
use egui::{Color32, CornerRadius, Rect, Sense, Vec2};
use pstr_player::{MAX_VOLUME, Track, TrackKind};
use crate::app::{Action, Adjacent, Page};
use crate::playback::{Command, Playback};
use crate::theme;
use crate::ui;
/// Height of the clickable seek bar. Comfortably larger than the few pixels it
/// draws, because a seek bar you have to aim at is a seek bar nobody uses.
const SEEK_HIT_HEIGHT: f32 = 18.0;
/// How wide the volume slider draws. Wide enough to land on a value, narrow
/// enough that it does not read as a second seek bar.
const VOLUME_WIDTH: f32 = 90.0;
/// Whether there is a file before and after this one in the same title.
#[derive(Debug, Clone, Copy, Default)]
pub struct Neighbours {
pub previous: bool,
pub next: bool,
}
/// The overlay's controls: the bottom half of the player page.
pub fn full(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
actions: &mut Vec<Action>,
) {
seek_bar(ui, playback, actions, 6.0);
times(ui, playback, true);
ui.add_space(6.0);
ui.horizontal(|ui| {
// Left cluster: everything that moves the playhead, in the order a
// hand reaches for it.
step_button(
ui,
"⏮",
"Previous (P)",
neighbours.previous,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Previous));
},
);
skip_button(ui, "−10s", "Back ten seconds (←)", -10.0, actions);
play_pause(ui, playback, actions, 44.0);
skip_button(ui, "+30s", "Forward thirty seconds (→)", 30.0, actions);
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
});
ui.add_space(12.0);
if playback.seeking || !playback.loaded {
ui.add(egui::Spinner::new().size(16.0));
ui.label(ui::muted(if playback.loaded {
"seeking"
} else {
"buffering"
}));
}
// Right cluster: everything that is a choice rather than a move.
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
track_menus(ui, playback, actions);
ui.add_space(8.0);
chapter_menu(ui, playback, actions);
ui.add_space(8.0);
volume(ui, playback, actions);
});
});
}
/// The strip under the library: what is playing, and back to it.
pub fn mini(
ui: &mut egui::Ui,
playback: &Playback,
neighbours: Neighbours,
actions: &mut Vec<Action>,
) {
ui.horizontal(|ui| {
// The way back to the picture. First, and a real button rather than a
// label, because leaving the player page with Esc is otherwise a
// one-way trip: playback carries on with nowhere to watch it.
if ui
.add(
egui::Button::new(egui::RichText::new("Back to video").size(13.0))
.fill(theme::CARD_HOVER)
.corner_radius(CornerRadius::same(8)),
)
.on_hover_text("Show the picture again")
.clicked()
{
actions.push(Action::Goto(Page::Player));
}
ui.add_space(10.0);
ui.vertical(|ui| {
// Back to what is playing: after ten minutes of browsing, the bar
// is the only thing that still knows which title this came from.
if ui
.add(
egui::Label::new(
egui::RichText::new(&playback.target.title_name)
.size(14.0)
.strong(),
)
.sense(Sense::click()),
)
.on_hover_text("Show this title")
.clicked()
{
actions.push(Action::Goto(Page::Title(playback.target.title_key.clone())));
}
ui.label(ui::muted(playback.target.caption()));
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Stop").clicked() {
actions.push(Action::Player(Command::Stop));
}
ui.add_space(6.0);
step_button(ui, "⏭", "Next (N)", neighbours.next, actions, |a| {
a.push(Action::PlayAdjacent(Adjacent::Next));
});
play_pause(ui, playback, actions, 34.0);
step_button(
ui,
"⏮",
"Previous (P)",
neighbours.previous,
actions,
|a| {
a.push(Action::PlayAdjacent(Adjacent::Previous));
},
);
ui.add_space(8.0);
track_menus(ui, playback, actions);
ui.add_space(6.0);
volume(ui, playback, actions);
if playback.seeking || !playback.loaded {
ui.add_space(6.0);
ui.add(egui::Spinner::new().size(14.0));
}
});
});
ui.add_space(4.0);
seek_bar(ui, playback, actions, 5.0);
times(ui, playback, false);
}
/// The one button whose position should never move.
fn play_pause(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, size: f32) {
let symbol = if playback.paused { "▶" } else { "⏸" };
if ui
.add(
egui::Button::new(egui::RichText::new(symbol).size(size * 0.42))
.fill(theme::ACCENT)
.corner_radius(CornerRadius::same((size / 2.0) as u8))
.min_size(Vec2::splat(size)),
)
.on_hover_text(if playback.paused {
"Play (Space)"
} else {
"Pause (Space)"
})
.clicked()
{
actions.push(Action::Player(Command::TogglePause));
}
}
fn skip_button(
ui: &mut egui::Ui,
label: &str,
hover: &str,
seconds: f64,
actions: &mut Vec<Action>,
) {
if ui
.add(egui::Button::new(label).min_size(Vec2::new(52.0, 30.0)))
.on_hover_text(hover)
.clicked()
{
actions.push(Action::Player(Command::SeekBy(seconds)));
}
}
/// Previous/next episode. Drawn even at the ends of a title, disabled: a
/// control that comes and goes is harder to find than one that is greyed.
fn step_button(
ui: &mut egui::Ui,
label: &str,
hover: &str,
enabled: bool,
actions: &mut Vec<Action>,
push: impl FnOnce(&mut Vec<Action>),
) {
let response = ui.add_enabled(
enabled,
egui::Button::new(egui::RichText::new(label).size(15.0)).min_size(Vec2::new(38.0, 30.0)),
);
if response.on_hover_text(hover).clicked() {
push(actions);
}
}
/// Position and duration, at the ends of the seek bar they belong to.
///
/// `over_picture` picks the ink. Muted grey is right on the library's own dark
/// surface, and unreadable over a bright frame — a pale grey at 12 px against
/// a sunlit shot is gone whatever the scrim does, so over the picture this goes
/// to near-white and a point larger.
fn times(ui: &mut egui::Ui, playback: &Playback, over_picture: bool) {
let (size, ink, dim) = if over_picture {
(13.0, Color32::WHITE, Color32::from_white_alpha(215))
} else {
(12.0, theme::MUTED, theme::MUTED)
};
let time = |text: String| egui::RichText::new(text).size(size).color(ink);
ui.horizontal(|ui| {
ui.label(time(ui::format_time(playback.position)));
if let Some(chapter) = playback.chapter().filter(|_| playback.chapters.len() > 1) {
ui.label(
egui::RichText::new(format!("· {}", chapter.label()))
.size(size)
.color(dim),
);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
match playback.duration {
Some(duration) => ui.label(time(ui::format_time(duration))),
None => ui.label(time("—:—".to_owned())),
};
});
});
}
/// Mute, and how loud.
///
/// Drawn into a right-to-left layout, so the slider is added first and ends up
/// to the *right* of the speaker it belongs to.
fn volume(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
// A muted player shows an empty slider rather than the level it will come
// back to: "muted" and "turned down" look the same on a bar, and only one
// of them is undone by clicking the speaker.
let mut value = if playback.muted { 0.0 } else { playback.volume };
let slider = ui
.scope(|ui| {
ui.spacing_mut().slider_width = VOLUME_WIDTH;
ui.add(
egui::Slider::new(&mut value, 0.0..=MAX_VOLUME)
.show_value(false)
.trailing_fill(true),
)
})
.inner;
if slider.changed() {
// Committed on release, not on every frame of the drag: this is a file
// write, and a slider dragged across the bar would be one per frame.
actions.push(Action::SetVolume {
volume: value,
commit: !slider.dragged(),
});
}
if slider.drag_stopped() {
actions.push(Action::SetVolume {
volume: value,
commit: true,
});
}
let icon = if playback.muted || playback.volume <= 0.0 {
"🔇"
} else {
"🔊"
};
if ui
.button(icon)
.on_hover_text(format!("Mute (M) — {:.0}%", playback.volume))
.clicked()
{
actions.push(Action::ToggleMute);
}
}
/// The audio and subtitle pickers.
///
/// Each is shown only when there is a choice to make: one audio track is not a
/// menu, and a file with no subtitles should not offer a subtitle button that
/// opens onto nothing. Added subtitles-first so that, in this right-to-left
/// layout, audio reads to the left of subtitles.
fn track_menus(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
let subtitles: Vec<&Track> = playback.tracks_of(TrackKind::Subtitle).collect();
if !subtitles.is_empty() {
let selected = playback.selected_track(TrackKind::Subtitle);
let label = match selected {
Some(track) => format!("Subs · {}", short(track)),
None => "Subs · Off".to_string(),
};
ui.menu_button(label, |ui| {
// "Off" first, and always: it is the entry most often wanted, and
// it is the only one that is not in the file.
if ui.selectable_label(selected.is_none(), "Off").clicked() {
actions.push(Action::SelectTrack {
kind: TrackKind::Subtitle,
id: None,
});
ui.close();
}
ui.separator();
track_options(ui, &subtitles, TrackKind::Subtitle, actions);
})
.response
.on_hover_text("Subtitle track");
}
let audio: Vec<&Track> = playback.tracks_of(TrackKind::Audio).collect();
if audio.len() > 1 {
let label = match playback.selected_track(TrackKind::Audio) {
Some(track) => format!("Audio · {}", short(track)),
None => "Audio · Off".to_string(),
};
ui.menu_button(label, |ui| {
track_options(ui, &audio, TrackKind::Audio, actions);
})
.response
.on_hover_text("Audio track");
}
}
fn track_options(ui: &mut egui::Ui, tracks: &[&Track], kind: TrackKind, actions: &mut Vec<Action>) {
for track in tracks {
if ui.selectable_label(track.selected, track.label()).clicked() {
actions.push(Action::SelectTrack {
kind,
id: Some(track.id),
});
ui.close();
}
}
}
/// Jump to a chapter. Only drawn for a file that has more than one, which for
/// most releases means anime and not much else.
fn chapter_menu(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>) {
if playback.chapters.len() < 2 {
return;
}
let current = playback.chapter().map(|chapter| chapter.index);
ui.menu_button("Chapters", |ui| {
for chapter in &playback.chapters {
let label = format!("{} {}", ui::format_time(chapter.start), chapter.label());
if ui
.selectable_label(current == Some(chapter.index), label)
.clicked()
{
actions.push(Action::Player(Command::SeekTo(chapter.start)));
ui.close();
}
}
})
.response
.on_hover_text("Chapters");
}
/// A track in as few words as fit on a button: what distinguishes it from the
/// others, which is nearly always its language.
fn short(track: &Track) -> String {
track
.language
.as_deref()
.map(pstr_player::language_name)
.or_else(|| track.title.clone())
.unwrap_or_else(|| format!("Track {}", track.id))
}
/// A bar that shows where playback is, marks the chapters, and seeks where it
/// is clicked.
fn seek_bar(ui: &mut egui::Ui, playback: &Playback, actions: &mut Vec<Action>, thickness: f32) {
let width = ui.available_width();
let (rect, response) =
ui.allocate_exact_size(Vec2::new(width, SEEK_HIT_HEIGHT), Sense::click_and_drag());
let hovered = response.hovered() || response.dragged();
let track = Rect::from_center_size(
rect.center(),
Vec2::new(width, if hovered { thickness + 2.0 } else { thickness }),
);
let painter = ui.painter();
painter.rect_filled(track, CornerRadius::same(3), Color32::from_gray(52));
if let Some(progress) = playback.progress() {
let mut played = track;
played.set_width(track.width() * progress);
painter.rect_filled(played, CornerRadius::same(3), theme::ACCENT);
painter.circle_filled(
egui::pos2(played.right(), track.center().y),
if hovered { 8.0 } else { 6.0 },
Color32::WHITE,
);
}
// Seeking needs a duration: without one there is nothing for a fraction of
// the bar to mean. Before `FileLoaded` mpv has no timeline anyway.
let Some(duration) = playback.duration.filter(|duration| *duration > 0.0) else {
return;
};
// Chapter marks, over the bar rather than under it: an opening is two
// minutes of a twenty-four minute file, and the point of the mark is to be
// able to aim just past it.
for chapter in playback.chapters.iter().skip(1) {
let fraction = (chapter.start / duration).clamp(0.0, 1.0) as f32;
let x = track.left() + track.width() * fraction;
painter.rect_filled(
Rect::from_min_size(
egui::pos2(x - 1.0, track.top() - 1.0),
Vec2::new(2.0, track.height() + 2.0),
),
CornerRadius::ZERO,
Color32::from_black_alpha(180),
);
}
// On release, not on every frame of the drag: each seek cancels outstanding
// read-ahead and refetches, so a dragged bar that seeks continuously spends
// the whole drag throwing away blocks it just paid for.
if let Some(position) = response.interact_pointer_pos()
&& (response.clicked() || response.drag_stopped())
{
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0);
actions.push(Action::Player(Command::SeekTo(fraction as f64 * duration)));
}
if response.hovered()
&& let Some(position) = response.hover_pos()
{
let fraction = ((position.x - track.left()) / track.width()).clamp(0.0, 1.0) as f64;
let at = fraction * duration;
// The chapter under the pointer, when there is one: "42:10 · OP" tells
// a viewer what they are about to land in, which is the whole reason to
// aim at a particular part of the bar.
let hint = match pstr_player::chapter_at(&playback.chapters, at) {
Some(index) => format!(
"{} · {}",
ui::format_time(at),
playback.chapters[index].label()
),
None => ui::format_time(at),
};
response.clone().on_hover_text_at_pointer(hint);
}
}
+274
View File
@@ -0,0 +1,274 @@
//! The picture, inside this window.
//!
//! mpv draws into a framebuffer we own; the colour texture behind it is handed
//! to egui and painted like any other image. That indirection — rather than
//! letting mpv draw straight into the window's back buffer — is what lets the
//! video sit in a *rectangle*: `mpv_render_context_render` always fills the
//! framebuffer it is given from its origin, with no way to offset it, so a
//! direct draw could only ever be full-window.
//!
//! ```text
//! App::ui (GL current)
//! │ size in physical pixels
//! ▼
//! VideoSurface::texture ──▶ mpv render ──▶ FBO ──▶ colour texture
//! │
//! egui::TextureId ◀───┘ painter.image(…)
//! ```
//!
//! Three things here are less obvious than they look:
//!
//! * **The texture is registered once and resized in place.**
//! [`eframe::Frame::register_native_glow_texture`] takes ownership of the
//! texture and there is no matching unregister, so registering a fresh one per
//! resize would leak one texture per drag of the window edge. Reallocating the
//! *same* GL name with `tex_image_2d` keeps the `TextureId` valid and leaks
//! nothing.
//! * **A frame is rendered only when mpv says there is one.** Otherwise the
//! texture from last frame is still correct, and egui repaints far more often
//! than a 24 fps film changes.
//! * **mpv's GL state churn is safe here by contract, not by luck.** mpv
//! restores everything except the viewport, the scissor box, the blend
//! function and the clear colour — all four of which `egui_glow` sets itself
//! at the start of every paint.
use std::sync::Arc;
use eframe::glow::{self, HasContext as _};
use pstr_player::{Player, VideoRenderer};
/// Smallest framebuffer worth allocating. A window dragged to nothing still has
/// to leave mpv something valid to render into.
const MIN_EDGE: i32 = 16;
/// Largest, in either direction. A 4K display at 200% scaling asks for 7680 px
/// of framebuffer for a rectangle nothing can resolve; this is the ceiling that
/// keeps a stray `pixels_per_point` from asking the driver for half a gigabyte.
const MAX_EDGE: i32 = 4096;
/// mpv's render context plus the framebuffer it draws into.
///
/// Not `Send`: every method here needs the OpenGL context that eframe makes
/// current around [`eframe::App::ui`], so this lives on the UI thread while the
/// [`Player`] it renders is shared with the thread polling mpv's events.
pub struct VideoSurface {
gl: Arc<glow::Context>,
renderer: VideoRenderer,
/// Owned by eframe once registered — deliberately never deleted here.
texture: Option<glow::Texture>,
id: Option<egui::TextureId>,
fbo: Option<glow::Framebuffer>,
size: [i32; 2],
/// Set until the first frame actually lands, so the caller can say
/// "buffering" rather than show a black rectangle it cannot explain.
painted: bool,
}
impl VideoSurface {
/// Attach a renderer to `player` on the current OpenGL context.
///
/// `ctx` is woken whenever mpv has a new frame; without that the picture
/// would only advance when the viewer moved the mouse.
///
/// # Safety
///
/// An OpenGL context must be current on the calling thread — inside
/// [`eframe::App::ui`] it is — and this value must be dropped on that same
/// thread with the context still current.
pub unsafe fn new(
player: Arc<Player>,
gl: Arc<glow::Context>,
ctx: egui::Context,
) -> pstr_player::Result<Self> {
// SAFETY: forwarded from this function's own contract.
let mut renderer = unsafe { VideoRenderer::new(player)? };
renderer.on_update(move || ctx.request_repaint());
Ok(Self {
gl,
renderer,
texture: None,
id: None,
fbo: None,
size: [0, 0],
painted: false,
})
}
/// Whether a frame has ever reached the screen.
pub fn has_picture(&self) -> bool {
self.painted
}
/// Render at `size` physical pixels if there is anything new, and give back
/// the texture to paint.
///
/// `None` means there is nothing to draw yet — the framebuffer could not be
/// built, or the file has no video track.
pub fn texture(
&mut self,
frame: &mut eframe::Frame,
size: [i32; 2],
) -> Option<egui::TextureId> {
let size = [
size[0].clamp(MIN_EDGE, MAX_EDGE),
size[1].clamp(MIN_EDGE, MAX_EDGE),
];
// A resize invalidates the picture as well as the storage: the old
// contents are gone, so redraw even if mpv has no new frame.
let resized = self.ensure(frame, size)?;
if !resized && !self.renderer.has_new_frame() {
return self.id;
}
let fbo = self.fbo?;
// SAFETY: the context is current for the whole of `App::ui`, and `fbo`
// was checked complete when it was built.
unsafe {
let previous = self.gl.get_parameter_i32(glow::DRAW_FRAMEBUFFER_BINDING);
self.gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo));
// No flip: mpv writes the top of the picture into the first row of
// the texture, which is where egui's V axis starts. `FLIP_Y` is for
// drawing into the *default* framebuffer, and here it would stand
// the picture on its head. See `VideoRenderer::render`.
let rendered = self
.renderer
.render(fbo.0.get() as i32, size[0], size[1], false);
self.gl
.bind_framebuffer(glow::FRAMEBUFFER, restore(previous));
if let Err(error) = rendered {
tracing::warn!("render a video frame: {error}");
return self.id;
}
}
self.painted = true;
self.id
}
/// Make sure there is a framebuffer of `size`, reporting whether it changed.
///
/// `None` if one could not be built at all, which is the only failure that
/// makes the surface useless rather than merely late.
fn ensure(&mut self, frame: &mut eframe::Frame, size: [i32; 2]) -> Option<bool> {
if self.texture.is_none() {
// SAFETY: the context is current, and every object created here is
// either handed to eframe (the texture) or deleted in `Drop` (the
// framebuffer).
unsafe {
let texture = self
.gl
.create_texture()
.inspect_err(|error| tracing::error!("create the video texture: {error}"))
.ok()?;
self.gl.bind_texture(glow::TEXTURE_2D, Some(texture));
// Linear, and clamped: the video is scaled to whatever rectangle
// the window leaves it, and a repeating wrap would fringe the
// letterbox edges with the opposite side of the picture.
for (parameter, value) in [
(glow::TEXTURE_MIN_FILTER, glow::LINEAR),
(glow::TEXTURE_MAG_FILTER, glow::LINEAR),
(glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE),
(glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE),
] {
self.gl
.tex_parameter_i32(glow::TEXTURE_2D, parameter, value as i32);
}
self.gl.bind_texture(glow::TEXTURE_2D, None);
let fbo = self
.gl
.create_framebuffer()
.inspect_err(|error| tracing::error!("create the video framebuffer: {error}"))
.ok()?;
self.texture = Some(texture);
self.fbo = Some(fbo);
}
// Once: the `TextureId` outlives every resize, because the resize
// reallocates this same GL name rather than making a new one.
self.id = Some(frame.register_native_glow_texture(self.texture?));
}
if self.size == size {
return Some(false);
}
let (texture, fbo) = (self.texture?, self.fbo?);
// SAFETY: both objects were created above on this context, which is
// still current.
unsafe {
self.gl.bind_texture(glow::TEXTURE_2D, Some(texture));
self.gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA8 as i32,
size[0],
size[1],
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice(None),
);
self.gl.bind_texture(glow::TEXTURE_2D, None);
let previous = self.gl.get_parameter_i32(glow::DRAW_FRAMEBUFFER_BINDING);
self.gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo));
self.gl.framebuffer_texture_2d(
glow::FRAMEBUFFER,
glow::COLOR_ATTACHMENT0,
glow::TEXTURE_2D,
Some(texture),
0,
);
let status = self.gl.check_framebuffer_status(glow::FRAMEBUFFER);
self.gl
.bind_framebuffer(glow::FRAMEBUFFER, restore(previous));
if status != glow::FRAMEBUFFER_COMPLETE {
tracing::error!("video framebuffer is incomplete: 0x{status:x}");
return None;
}
}
self.size = size;
Some(true)
}
}
impl Drop for VideoSurface {
/// Runs on the UI thread with the context current — see the note on
/// [`VideoRenderer`]'s destructor, which has the same requirement and is
/// the reason this type is dropped from the draw loop rather than wherever
/// playback happens to end.
fn drop(&mut self) {
if let Some(fbo) = self.fbo.take() {
// SAFETY: created on this context, deleted once.
unsafe { self.gl.delete_framebuffer(fbo) };
}
// The texture is deliberately not deleted: `register_native_glow_texture`
// took ownership of it, and eframe frees it with the painter.
}
}
/// The framebuffer name a `GL_DRAW_FRAMEBUFFER_BINDING` query returned, as
/// something `bind_framebuffer` accepts. Zero is the default framebuffer, which
/// glow spells `None`.
fn restore(binding: i32) -> Option<glow::Framebuffer> {
std::num::NonZeroU32::new(binding as u32).map(glow::NativeFramebuffer)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_zero_binding_restores_the_default_framebuffer() {
assert!(restore(0).is_none());
assert!(restore(7).is_some());
}
}
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "pstr-cli"
description = "Command-line front end for proton-stream: add shares, crawl them, benchmark reads."
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
authors.workspace = true
[[bin]]
name = "pstr"
path = "src/main.rs"
[dependencies]
pstr-core.workspace = true
pstr-meta.workspace = true
pstr-stream.workspace = true
pstr-player.workspace = true
proton-sdk.workspace = true
proton-drive-rs.workspace = true
tokio.workspace = true
anyhow.workspace = true
clap.workspace = true
rpassword.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
+928
View File
@@ -0,0 +1,928 @@
//! `pstr` — the command-line front end.
//!
//! Exists mainly to prove the pieces below it work without a window: add a
//! share, crawl it, look at what the catalog made of the names. The GUI is the
//! product; this is the thing that tells you *which layer* is broken when it
//! misbehaves.
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
use pstr_core::catalog::{Catalog, build_rows};
use pstr_core::config::AppDirs;
use pstr_core::{ShareStore, SharedLibrary};
use pstr_player::{EndReason, Player, PlayerConfig, PlayerEvent};
use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource};
#[derive(Parser)]
#[command(name = "pstr", version, about = "proton-stream command line")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum MetadataCommand {
/// Show the current settings.
Status,
/// Turn enrichment on or off, and choose a provider.
///
/// Off by default and deliberately: matching sends the titles in the
/// catalog to a third party. See `pstr_core::metadata`.
Set {
/// Turn lookups on.
#[arg(long, conflicts_with = "off")]
on: bool,
/// Turn lookups off, and forget every stored answer.
#[arg(long)]
off: bool,
/// `anilist` or `tmdb`.
#[arg(long)]
provider: Option<String>,
/// Store an API key for the chosen provider. Prompted for, never taken
/// on the command line, where it would land in the shell history.
#[arg(long)]
api_key: bool,
},
/// Look up every title that has no fresh answer.
Match {
/// Ask about every title, including ones already matched.
#[arg(long)]
force: bool,
/// Look up at most this many. Useful for a first look at how the
/// matching is doing without spending a whole library on it.
#[arg(long)]
limit: Option<usize>,
},
}
#[derive(Subcommand)]
enum Command {
/// Add a share by its full URL, including the `#password` fragment.
Add {
/// What to call it in the UI.
#[arg(long)]
name: String,
/// The full share URL.
url: String,
/// Prompt for the link's custom password, if it has one.
#[arg(long)]
custom_password: bool,
},
/// List configured shares.
Shares,
/// Forget a share and its stored credentials.
Remove {
/// The share id, as printed by `shares`.
id: String,
},
/// Crawl one share (or all of them) into the catalog.
Crawl {
/// Crawl only this share.
#[arg(long)]
share: Option<String>,
},
/// Print what the catalog holds.
List {
/// Restrict to one share.
#[arg(long)]
share: Option<String>,
},
/// Match the catalog against a metadata provider.
///
/// Prints what matched and what did not, and stores the answers where the
/// GUI reads them. This is the layer to reach for when the poster wall looks
/// wrong: it shows the *matching* in isolation, with no network beyond the
/// provider and no window in the way.
Metadata {
#[command(subcommand)]
command: MetadataCommand,
},
/// Measure what playback would feel like, without a player.
///
/// Reports the three numbers that decide whether this app is usable at all:
/// how long a cold seek takes, how fast a sustained read runs, and how much
/// memory the block ring actually holds.
Bench {
/// Substring of a file name or parsed title to read.
#[arg(long, value_name = "TEXT")]
file: String,
/// Restrict the search to one share.
#[arg(long)]
share: Option<String>,
/// Blocks to pull ahead of the reader; 0 disables read-ahead.
#[arg(long, default_value_t = pstr_stream::DEFAULT_READAHEAD_BLOCKS)]
readahead: usize,
/// Resident block budget, in MiB.
#[arg(long, default_value_t = 128)]
ring_mib: u64,
/// Also exercise the on-disk block cache.
#[arg(long)]
disk_cache: bool,
/// How much to read for the throughput measurement, in MiB.
#[arg(long, default_value_t = 32)]
throughput_mib: u64,
/// Cross-check a mid-file read against the SDK's own range download.
#[arg(long)]
verify: bool,
},
/// Play a catalogued file in an mpv window.
Play {
/// Substring of a file name or parsed title to play.
#[arg(long, value_name = "TEXT")]
file: String,
/// Restrict the search to one share.
#[arg(long)]
share: Option<String>,
/// Blocks to pull ahead of the player. Defaults to none: mpv's own
/// demuxer cache is the read-ahead, and a second one under it only
/// competes with the reads mpv is waiting on.
#[arg(long, default_value_t = pstr_player::READAHEAD_BLOCKS)]
readahead: usize,
/// Resident block budget, in MiB.
#[arg(long, default_value_t = 128)]
ring_mib: u64,
/// Keep decrypted blocks on disk, so a rewatch needs no network.
#[arg(long)]
disk_cache: bool,
/// Start at this position, in seconds.
#[arg(long, value_name = "SECONDS")]
start: Option<f64>,
/// Decode without a window or sound. For checking the pipeline where
/// there is no display to open one on.
#[arg(long)]
headless: bool,
/// Stop after this many seconds of wall-clock playback. Smoke tests
/// only — it is how an unattended run ends without a window to close.
#[arg(long, value_name = "SECONDS")]
for_seconds: Option<f64>,
},
}
/// Prompt for a secret, or read one line of it from stdin when there is no
/// terminal to prompt on.
///
/// `rpassword` opens `/dev/tty` and fails outright without one, which makes the
/// command unusable from a script or a pipe. Neither path echoes the secret, and
/// neither puts it in `argv` where every other process on the machine could read
/// it — which is why there is no `--password <value>` flag.
fn read_password(prompt: &str) -> Result<String> {
use std::io::{BufRead, IsTerminal};
if std::io::stdin().is_terminal() {
return Ok(rpassword::prompt_password(prompt)?);
}
let mut line = String::new();
std::io::stdin()
.lock()
.read_line(&mut line)
.context("read password from stdin")?;
let password = line.trim_end_matches(['\r', '\n']).to_string();
if password.is_empty() {
anyhow::bail!("no password on stdin, and no terminal to prompt on");
}
Ok(password)
}
/// The runtime is built by hand rather than by `#[tokio::main]` because
/// `pstr-player` needs an `Arc<Runtime>`, not a `Handle`: mpv's demuxer thread
/// blocks on it from outside tokio, so it has to be kept alive explicitly.
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "pstr=info,pstr_core=info".into()),
)
.init();
let runtime = std::sync::Arc::new(
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("build the tokio runtime")?,
);
runtime.clone().block_on(run(runtime))
}
async fn run(runtime: std::sync::Arc<tokio::runtime::Runtime>) -> Result<()> {
let cli = Cli::parse();
let dirs = AppDirs::ensure().context("resolve app directories")?;
let store = ShareStore::new(dirs.clone());
match cli.command {
Command::Add {
name,
url,
custom_password,
} => {
let password = if custom_password {
Some(read_password("Custom link password: ")?)
} else {
None
};
let share = store
.add(&name, &url, password.as_deref())
.context("add share")?;
println!("added {} ({})", share.name, share.id);
}
Command::Shares => {
let shares = store.list().context("list shares")?;
if shares.is_empty() {
println!("no shares configured — add one with `pstr add --name <name> <url>`");
}
for share in shares {
let custom = if share.has_custom_password {
" [custom password]"
} else {
""
};
println!("{}\t{}{}", share.id, share.name, custom);
}
}
Command::Remove { id } => {
store.remove(&id).context("remove share")?;
println!("removed {id}");
}
Command::Crawl { share } => {
let (library, failures) = SharedLibrary::open_all(&store)
.await
.context("open shares")?;
for (share, error) in &failures {
eprintln!("[warn] share {} did not open: {error}", share.id);
}
let mut catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let targets: Vec<String> = match &share {
Some(id) => vec![id.clone()],
None => library.share_ids().map(str::to_string).collect(),
};
for share_id in targets {
let started = std::time::Instant::now();
let nodes = library
.crawl(&share_id)
.await
.with_context(|| format!("crawl {share_id}"))?;
let rows = build_rows(&share_id, &nodes);
catalog
.replace_share(&share_id, &rows)
.with_context(|| format!("store {share_id}"))?;
println!(
"{share_id}: {} nodes crawled, {} playable, in {:.1}s",
nodes.len(),
rows.len(),
started.elapsed().as_secs_f64()
);
}
}
Command::List { share } => {
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let files = match &share {
Some(id) => catalog.files(id)?,
None => catalog.all_files()?,
};
if files.is_empty() {
println!("catalog is empty — run `pstr crawl` first");
}
for file in files {
let numbering = match (file.parsed.season, file.parsed.episode) {
(Some(s), Some(e)) => format!("S{s:02}E{e:02}"),
(None, Some(e)) => format!("#{e:03}"),
(Some(s), None) => format!("S{s:02}"),
(None, None) => file
.parsed
.year
.map(|y| format!("({y})"))
.unwrap_or_else(|| "-".into()),
};
let episode_title = file.parsed.episode_title.as_deref().unwrap_or("-");
println!(
"{:<40} {:<8} {}",
file.parsed.title, numbering, episode_title
);
}
}
Command::Metadata { command } => metadata(&dirs, command).await?,
Command::Bench {
file,
share,
readahead,
ring_mib,
disk_cache,
throughput_mib,
verify,
} => {
bench(
&dirs,
&store,
BenchArgs {
file,
share,
readahead,
ring_mib,
disk_cache,
throughput_mib,
verify,
},
)
.await?;
}
Command::Play {
file,
share,
readahead,
ring_mib,
disk_cache,
start,
headless,
for_seconds,
} => {
play(
&dirs,
&store,
runtime,
PlayArgs {
file,
share,
readahead,
ring_mib,
disk_cache,
start,
headless,
for_seconds,
},
)
.await?;
}
}
Ok(())
}
/// `pstr metadata …` — the enrichment settings, and matching the catalog.
///
/// Nothing here touches Proton: it reads titles out of the local catalog and
/// talks to the provider. That separation is the point — when the poster wall is
/// wrong, this says whether the matching or the crawl is to blame.
async fn metadata(dirs: &AppDirs, command: MetadataCommand) -> Result<()> {
use pstr_core::library::Library;
use pstr_core::metadata::ProviderId;
let mut settings = pstr_meta::settings::load(dirs).context("read metadata settings")?;
match command {
MetadataCommand::Status => {
println!("lookups: {}", if settings.enabled { "on" } else { "off" });
println!(
"provider: {} — {}",
settings.provider.label(),
settings.provider.description()
);
if settings.provider.needs_api_key() {
println!(
"api key: {}",
match pstr_meta::settings::api_key(settings.provider) {
Some(_) => "stored in the credential store",
None => "not set — run `pstr metadata set --api-key`",
}
);
}
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let stored = catalog.all_metadata()?;
let matched = stored
.values()
.filter(|record| record.metadata.is_some())
.count();
println!(
"stored: {matched} matched, {} with no match",
stored.len() - matched
);
}
MetadataCommand::Set {
on,
off,
provider,
api_key,
} => {
if let Some(name) = provider {
settings.provider = ProviderId::parse(&name).ok_or_else(|| {
anyhow::anyhow!(
"unknown provider {name:?}; try one of: {}",
ProviderId::ALL.map(|provider| provider.as_str()).join(", ")
)
})?;
}
if on {
settings.enabled = true;
}
if off {
settings.enabled = false;
}
if api_key {
let key = rpassword::prompt_password(format!(
"{} API key (empty to forget it): ",
settings.provider.label()
))
.context("read the API key")?;
pstr_meta::settings::set_api_key(settings.provider, &key)
.context("store the API key")?;
}
pstr_meta::settings::save(dirs, &settings).context("save metadata settings")?;
// Turning it off, or switching provider, drops the stored answers —
// the same thing the GUI does, for the same reason. See
// `Engine::set_metadata_config`.
if off || settings.enabled {
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let stale = catalog
.all_metadata()?
.values()
.any(|record| record.provider != settings.provider);
if off || stale {
catalog.clear_metadata()?;
println!("cleared the stored answers");
}
}
println!(
"lookups {} · provider {}",
if settings.enabled { "on" } else { "off" },
settings.provider.label()
);
}
MetadataCommand::Match { force, limit } => {
if !settings.enabled {
anyhow::bail!(
"lookups are off — run `pstr metadata set --on` first.\n\
Matching sends the titles in your catalog to {}.",
settings.provider.label()
);
}
let service = pstr_meta::MetadataService::new(
&settings,
pstr_meta::settings::api_key(settings.provider),
)
.context("build the metadata provider")?;
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let library = Library::build(catalog.all_files()?, &catalog.all_watch_states()?);
let stored = catalog.all_metadata()?;
let mut pending: Vec<_> = library
.titles
.iter()
.filter(|title| {
force
|| !pstr_meta::service::is_usable(stored.get(&title.key), settings.provider)
})
.collect();
if let Some(limit) = limit {
pending.truncate(limit);
}
if pending.is_empty() {
println!("nothing to do — every title already has a fresh answer");
return Ok(());
}
println!(
"matching {} titles against {}…",
pending.len(),
settings.provider.label()
);
// Serial, not concurrent: both providers rate-limit by the minute,
// and the GUI already has the parallel path. What this command is
// for is reading the output.
let (mut matched, mut failed) = (0usize, 0usize);
for title in pending {
match service.record(title).await {
Ok(record) => {
match &record.metadata {
Some(found) => {
matched += 1;
println!(
" {:<40} → {} ({})",
title.name,
found.name,
found
.year
.map(|year| year.to_string())
.unwrap_or_else(|| "-".into())
);
}
None => println!(" {:<40} → no match", title.name),
}
catalog.set_metadata(&record)?;
}
Err(error) => {
failed += 1;
// Not stored: a failure to ask is not an answer, and the
// title has to stay askable. See `pstr_meta::service`.
println!(" {:<40} ! {error}", title.name);
}
}
}
println!("{matched} matched, {failed} could not be looked up");
}
}
Ok(())
}
struct BenchArgs {
file: String,
share: Option<String>,
readahead: usize,
ring_mib: u64,
disk_cache: bool,
throughput_mib: u64,
verify: bool,
}
const MIB: u64 = 1024 * 1024;
/// Read chunk size. Small on purpose: mpv's demuxer reads in tens of kilobytes,
/// so a benchmark using 4 MiB reads would flatter the block layer by accident.
const CHUNK: u64 = 64 * 1024;
/// Everything `bench` and `play` both need before they can read a byte.
struct Opened {
name: String,
share_id: String,
/// Held so the shares stay open for the life of the stream.
library: std::sync::Arc<SharedLibrary>,
source: StreamSource,
uid: NodeUid,
stream: pstr_stream::VideoStream,
/// How long the cold open took: link details, the ancestor-key unlock, the
/// revision listing. Everything before the first byte.
open_ms: f64,
}
/// Resolve a name fragment to a catalogued file and open a stream on it.
async fn open_target(
dirs: &AppDirs,
store: &ShareStore,
file: &str,
share: Option<&str>,
config: StreamConfig,
) -> Result<Opened> {
let catalog = Catalog::open(&dirs.catalog_db()).context("open catalog")?;
let files = match share {
Some(id) => catalog.files(id)?,
None => catalog.all_files()?,
};
let needle = file.to_lowercase();
let target = files
.into_iter()
.find(|node| {
node.name.to_lowercase().contains(&needle)
|| node.parsed.title.to_lowercase().contains(&needle)
})
.ok_or_else(|| anyhow::anyhow!("no catalogued file matches {file:?}"))?;
println!("file: {} ({})", target.name, target.share_id);
let (library, failures) = SharedLibrary::open_all(store)
.await
.context("open shares")?;
for (share, error) in &failures {
eprintln!("[warn] share {} did not open: {error}", share.id);
}
let library = std::sync::Arc::new(library);
let opener = std::sync::Arc::new(LibraryOpener::new(std::sync::Arc::clone(&library)));
let source = StreamSource::new(opener, config).await?;
let uid = NodeUid::new(
VolumeId::new(target.volume_id.clone()),
LinkId::new(target.link_id.clone()),
);
let started = std::time::Instant::now();
let stream = source.open(&target.share_id, &uid).await?;
let open_ms = started.elapsed().as_secs_f64() * 1000.0;
Ok(Opened {
name: target.name,
share_id: target.share_id,
library,
source,
uid,
stream,
open_ms,
})
}
/// Build a stream config from the flags `bench` and `play` share.
fn stream_config(
dirs: &AppDirs,
readahead: usize,
ring_mib: u64,
disk_cache: bool,
) -> StreamConfig {
let config = StreamConfig::default()
.with_readahead(readahead)
.with_ring_bytes(ring_mib * MIB);
if disk_cache {
config.with_disk_cache(DiskCacheConfig::new(dirs.block_cache()))
} else {
config
}
}
async fn bench(dirs: &AppDirs, store: &ShareStore, args: BenchArgs) -> Result<()> {
let config = stream_config(dirs, args.readahead, args.ring_mib, args.disk_cache);
// 1. Cold open.
let opened = open_target(dirs, store, &args.file, args.share.as_deref(), config).await?;
let Opened {
library,
source,
uid,
stream,
open_ms,
share_id,
..
} = opened;
let size = stream.size();
println!(
"size: {:.1} MiB in {} blocks",
size as f64 / MIB as f64,
stream.block_sizes().len()
);
println!("open: {open_ms:.0} ms");
if size == 0 {
anyhow::bail!("that revision has no content to read");
}
// 2. Time to first byte, from the position a player actually starts at.
let started = std::time::Instant::now();
let head = stream.read_range(0, CHUNK).await?;
println!(
"first byte: {:.0} ms ({} bytes)",
started.elapsed().as_secs_f64() * 1000.0,
head.len()
);
// 3. Cold seeks. Distinct fractions of the file, so each lands in a block
// nothing has touched — this is the seek-bar drag.
println!("cold seeks:");
let mut worst = 0.0_f64;
for percent in [10, 25, 50, 75, 90] {
let offset = size * percent / 100;
let started = std::time::Instant::now();
let bytes = stream.read_range(offset, CHUNK).await?;
let ms = started.elapsed().as_secs_f64() * 1000.0;
worst = worst.max(ms);
println!(" {percent:>3}% {ms:>7.0} ms {} bytes", bytes.len());
}
println!(" worst {worst:.0} ms");
// 4. Sustained sequential throughput, in player-sized reads. Started past
// everything the seeks warmed.
let start_at = (size * 60 / 100).min(size.saturating_sub(1));
let want = (args.throughput_mib * MIB).min(size - start_at);
let started = std::time::Instant::now();
let mut read = 0_u64;
let mut peak_resident = 0_u64;
let mut buf = vec![0_u8; CHUNK as usize];
while read < want {
let got = stream.read_at(start_at + read, &mut buf).await? as u64;
if got == 0 {
break;
}
read += got;
peak_resident = peak_resident.max(source.ring_stats().resident_bytes);
}
let seconds = started.elapsed().as_secs_f64();
println!(
"throughput: {:.1} MiB/s ({:.1} MiB in {seconds:.1}s)",
read as f64 / MIB as f64 / seconds,
read as f64 / MIB as f64
);
// 5. What it cost to hold.
let stats = stream.stats();
println!(
"memory: {:.0} MiB peak resident, {} blocks, budget {} MiB",
peak_resident as f64 / MIB as f64,
stats.ring.blocks,
args.ring_mib
);
println!(
"blocks: {} fetched ({:.1} MiB), ring {} hit / {} miss, {} evicted",
stats.blocks_fetched,
stats.bytes_fetched as f64 / MIB as f64,
stats.ring.hits,
stats.ring.misses,
stats.ring.evictions
);
println!(
"seeks: {} detected, {} read-ahead started, {} cancelled",
stats.seeks, stats.readahead_blocks, stats.readahead_cancelled
);
if let Some(disk) = stats.disk {
println!(
"disk: {} hit / {} miss, {} rejected, {:.0} MiB stored in {} entries",
disk.hits,
disk.misses,
disk.rejected,
disk.stored_bytes as f64 / MIB as f64,
disk.entries
);
}
// 6. Correctness, not speed: the same range straight from the SDK.
if args.verify {
let client = library
.client(&share_id)
.ok_or_else(|| anyhow::anyhow!("share {} is not open", share_id))?;
let offset = size / 2;
let length = CHUNK.min(size - offset);
let expected = client.download_range(&uid, offset, length).await?;
let actual = stream.read_range(offset, length).await?;
anyhow::ensure!(
expected == actual,
"stream returned {} bytes that differ from the SDK's own range read at {offset}",
actual.len()
);
println!("verify: {length} bytes at {offset} match the SDK's range download");
}
Ok(())
}
struct PlayArgs {
file: String,
share: Option<String>,
readahead: usize,
ring_mib: u64,
disk_cache: bool,
start: Option<f64>,
headless: bool,
for_seconds: Option<f64>,
}
/// Play a catalogued file through mpv.
///
/// This is the whole stack end to end: catalog → share client → block layer →
/// `pstr://` → mpv's demuxer → decode. Nothing is downloaded first; mpv reads
/// the file where it lies.
async fn play(
dirs: &AppDirs,
store: &ShareStore,
runtime: std::sync::Arc<tokio::runtime::Runtime>,
args: PlayArgs,
) -> Result<()> {
let config = stream_config(dirs, args.readahead, args.ring_mib, args.disk_cache);
let opened = open_target(dirs, store, &args.file, args.share.as_deref(), config).await?;
let size = opened.stream.size();
if size == 0 {
anyhow::bail!("that revision has no content to play");
}
println!(
"size: {:.1} MiB in {} blocks",
size as f64 / MIB as f64,
opened.stream.block_sizes().len()
);
println!("open: {:.0} ms", opened.open_ms);
let mut player_config = PlayerConfig {
window_title: format!("proton-stream — {}", opened.name),
..PlayerConfig::default()
};
if args.headless {
// Still demuxes and decodes — the whole chain runs, the frames and
// samples are just discarded. That is the point: it exercises playback
// somewhere there is no display to open a window on.
for option in [("vo", "null"), ("ao", "null"), ("force-window", "no")] {
player_config
.options
.push((option.0.into(), option.1.into()));
}
}
let player = Player::new(runtime, player_config).context("start mpv")?;
let _handle = player.play(opened.stream.clone()).context("load stream")?;
let mut seeked = args.start.is_none();
let mut last_line = std::time::Instant::now();
let started = std::time::Instant::now();
let mut position = 0.0_f64;
let mut duration = 0.0_f64;
// Seek issued → picture back. The number the seek bar is judged on, and the
// one worth watching while dragging it in the window.
let mut seek_started: Option<std::time::Instant> = None;
let mut first_frame: Option<f64> = None;
loop {
if let Some(limit) = args.for_seconds
&& started.elapsed().as_secs_f64() >= limit
{
println!("\nstopped after {limit:.0}s at {position:.0}s");
break;
}
let Some(event) = player.poll_event(0.1) else {
continue;
};
match event {
PlayerEvent::FileLoaded => {
duration = player.duration().unwrap_or(0.0);
println!("loaded: {duration:.0}s");
// Seek only once demuxing has succeeded — before that mpv has
// no timeline to seek within.
if let (false, Some(start)) = (seeked, args.start) {
player.seek_to(start).context("seek to --start")?;
seeked = true;
}
}
PlayerEvent::Duration(value) => duration = value,
PlayerEvent::Seek => seek_started = Some(std::time::Instant::now()),
PlayerEvent::PlaybackRestart => {
if let Some(at) = seek_started.take() {
println!("\nseek: resumed in {:.0} ms", at.elapsed().as_millis());
}
}
PlayerEvent::Position(value) => {
if first_frame.is_none() {
first_frame = Some(started.elapsed().as_secs_f64());
println!("first frame: {:.1}s after launch", first_frame.unwrap());
}
position = value;
// Overwrite one line rather than scrolling: this runs at
// whatever rate mpv reports, which is often.
if last_line.elapsed().as_secs_f64() >= 1.0 {
last_line = std::time::Instant::now();
print!("\r {position:.0}s / {duration:.0}s ");
let _ = std::io::Write::flush(&mut std::io::stdout());
}
}
PlayerEvent::EndFile(reason) => {
println!("\nended: {reason:?} at {position:.0}s");
if reason != EndReason::Quit {
break;
}
}
PlayerEvent::Shutdown => {
println!("\nclosed at {position:.0}s");
break;
}
_ => {}
}
}
let stats = opened.stream.stats();
println!(
"blocks: {} fetched ({:.1} MiB), ring {} hit / {} miss, {} evicted",
stats.blocks_fetched,
stats.bytes_fetched as f64 / MIB as f64,
stats.ring.hits,
stats.ring.misses,
stats.ring.evictions
);
println!(
"seeks: {} detected, {} read-ahead started, {} cancelled",
stats.seeks, stats.readahead_blocks, stats.readahead_cancelled
);
if let Some(disk) = stats.disk {
println!(
"disk: {} hit / {} miss, {} rejected, {:.0} MiB stored in {} entries",
disk.hits,
disk.misses,
disk.rejected,
disk.stored_bytes as f64 / MIB as f64,
disk.entries
);
}
opened.source.close(&opened.share_id, &opened.uid);
drop(opened.library);
Ok(())
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "pstr-core"
description = "Shares, catalog model, filename parsing and persistence for proton-stream."
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
proton-sdk.workspace = true
proton-drive-rs.workspace = true
tokio.workspace = true
futures.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
directories.workspace = true
rusqlite.workspace = true
keyring.workspace = true
sha2.workspace = true
File diff suppressed because it is too large. Load diff
+225
View File
@@ -0,0 +1,225 @@
//! Where the app keeps its state, and how it writes there safely.
//!
//! Two rules carried over from `proton-drive-linux`'s config handling, both
//! learned the hard way:
//!
//! 1. **Write atomically.** Temp file → `sync_all` → rename → fsync the parent
//! directory. A crash mid-write then leaves either the old file or the new
//! one, never a truncated one.
//! 2. **Never overwrite a config that would not parse.** An unreadable file is
//! far more likely to be a bug of ours, or a half-finished hand edit, than
//! something to be silently replaced — and replacing it destroys the only
//! record of the user's shares.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
/// The app's directories, resolved once.
#[derive(Debug, Clone)]
pub struct AppDirs {
/// Config: the share list. Small, hand-editable, worth backing up.
pub config: PathBuf,
/// State: the catalog database and metadata cache. Rebuildable.
pub data: PathBuf,
/// Cache: downloaded content blocks and poster images. Disposable — the app
/// must work correctly after this is deleted while it is not running.
pub cache: PathBuf,
}
impl AppDirs {
/// Resolve the platform directories and create them.
pub fn ensure() -> Result<Self> {
let dirs =
directories::ProjectDirs::from("io", "narl", "proton-stream").ok_or_else(|| {
Error::Config("no home directory to resolve app directories from".into())
})?;
let dirs = Self {
config: dirs.config_dir().to_path_buf(),
data: dirs.data_dir().to_path_buf(),
cache: dirs.cache_dir().to_path_buf(),
};
for path in [&dirs.config, &dirs.data, &dirs.cache] {
fs::create_dir_all(path)?;
}
Ok(dirs)
}
/// The share list.
pub fn shares_file(&self) -> PathBuf {
self.config.join("shares.json")
}
/// The catalog database.
pub fn catalog_db(&self) -> PathBuf {
self.data.join("catalog.db")
}
/// The content-block cache root.
pub fn block_cache(&self) -> PathBuf {
self.cache.join("blocks")
}
/// Where decrypted poster thumbnails are kept.
///
/// Separate from the block cache because it is cheap, tiny and wanted on
/// every render, where a block is expensive, large and wanted once. Clearing
/// either must leave the app correct.
pub fn thumbnail_cache(&self) -> PathBuf {
self.cache.join("thumbs")
}
/// Where artwork downloaded from a metadata provider is kept.
///
/// Separate from the Proton thumbnails next to it because the two are
/// invalidated by different things — a recrawl for one, a provider switch
/// for the other — and because deleting all of one must not touch the other.
pub fn poster_cache(&self) -> PathBuf {
self.cache.join("posters")
}
}
/// Read and deserialize a JSON config, or `None` when it does not exist yet.
///
/// A file that exists but will not parse is an error, never a `None` — see the
/// module note on why that distinction is load-bearing.
pub fn read_json<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
serde_json::from_str(&text)
.map(Some)
.map_err(|e| Error::Config(format!("{} is not valid config: {e}", path.display())))
}
/// Serialize and write a JSON config atomically.
pub fn write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
let text = serde_json::to_string_pretty(value)
.map_err(|e| Error::Config(format!("serialize config: {e}")))?;
let parent = path
.parent()
.ok_or_else(|| Error::Config(format!("{} has no parent directory", path.display())))?;
fs::create_dir_all(parent)?;
// Named for the target so two concurrent writers of *different* configs
// cannot collide on one temp path.
let temp = parent.join(format!(
".{}.tmp",
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "config".into())
));
{
let mut file = fs::File::create(&temp)?;
file.write_all(text.as_bytes())?;
// Before the rename, so the rename cannot publish an empty file.
file.sync_all()?;
}
fs::rename(&temp, path)?;
// The rename itself needs durability, or a crash can lose the whole file
// even though its contents were synced.
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)]
struct Sample {
name: String,
count: u32,
}
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"pstr-config-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn a_config_round_trips_through_an_atomic_write() {
let dir = temp_dir("roundtrip");
let path = dir.join("sample.json");
let value = Sample {
name: "anime".into(),
count: 42,
};
write_json(&path, &value).expect("write");
let read: Sample = read_json(&path).expect("read").expect("present");
assert_eq!(read, value);
fs::remove_dir_all(&dir).ok();
}
/// A missing config is the first-run case, not a failure.
#[test]
fn a_missing_config_reads_as_none() {
let dir = temp_dir("missing");
let read: Option<Sample> = read_json(&dir.join("absent.json")).expect("read");
assert!(read.is_none());
fs::remove_dir_all(&dir).ok();
}
/// The distinction that protects the share list: unparseable is an error,
/// so a caller doing read-modify-write cannot mistake it for "empty" and
/// overwrite the user's shares with nothing.
#[test]
fn an_unparseable_config_is_an_error_and_not_an_empty_one() {
let dir = temp_dir("corrupt");
let path = dir.join("broken.json");
fs::write(&path, b"{ this is not json").expect("write garbage");
let read: Result<Option<Sample>> = read_json(&path);
assert!(read.is_err(), "must refuse rather than report absence");
// And the bad file is still there to be inspected or repaired.
assert!(path.exists());
fs::remove_dir_all(&dir).ok();
}
/// The temp file must not survive a successful write.
#[test]
fn an_atomic_write_leaves_no_temp_file_behind() {
let dir = temp_dir("notemp");
let path = dir.join("sample.json");
write_json(
&path,
&Sample {
name: "x".into(),
count: 1,
},
)
.expect("write");
let leftovers: Vec<_> = fs::read_dir(&dir)
.expect("read dir")
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".tmp"))
.collect();
assert!(leftovers.is_empty(), "temp files left: {leftovers:?}");
fs::remove_dir_all(&dir).ok();
}
}
+40
View File
@@ -0,0 +1,40 @@
//! One error type for everything this crate does.
/// What can go wrong opening a share, crawling it, or persisting what was found.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The Drive SDK failed — a network error, an API refusal, or a decrypt.
#[error(transparent)]
Drive(#[from] proton_sdk::error::ProtonError),
/// The catalog database failed.
#[error("catalog database: {0}")]
Db(#[from] rusqlite::Error),
/// The OS credential store failed.
///
/// Boxed because `keyring::Error` is large and this variant is rare; an
/// un-boxed one would inflate every `Result` in the crate.
#[error("credential store: {0}")]
Keyring(#[from] Box<keyring::Error>),
/// A config file could not be read, written or parsed.
#[error("config: {0}")]
Config(String),
/// Filesystem failure outside of config handling.
#[error("io: {0}")]
Io(#[from] std::io::Error),
/// The caller asked for something that does not exist or does not apply.
#[error("{0}")]
NotFound(String),
}
impl From<keyring::Error> for Error {
fn from(error: keyring::Error) -> Self {
Self::Keyring(Box::new(error))
}
}
pub type Result<T> = std::result::Result<T, Error>;
+21
View File
@@ -0,0 +1,21 @@
//! Shares, catalog, naming and persistence for `proton-stream`.
//!
//! Everything that is not the player or the UI. Front ends depend on this crate
//! rather than on `proton-drive-rs` directly, so there is one place that knows
//! how a share is opened and how its contents are modelled.
#![forbid(unsafe_code)]
pub mod catalog;
pub mod config;
pub mod error;
pub mod library;
pub mod metadata;
pub mod naming;
pub mod prefs;
pub mod shares;
pub use error::{Error, Result};
pub use shares::{Share, ShareStore, SharedLibrary};
pub use proton_drive_rs;
pub use proton_sdk;
+645
View File
@@ -0,0 +1,645 @@
//! Catalog rows, grouped into what a poster wall shows.
//!
//! The catalog is flat — one row per file, with whatever [`crate::naming`] made
//! of its path. A UI wants the other shape: titles, each with seasons, each with
//! episodes, and the one episode the viewer should land on when they click. That
//! transform is here rather than in the app because it is pure, table-testable,
//! and the CLI wants the same view of the same data.
//!
//! Two decisions worth stating:
//!
//! * **Titles merge across shares.** Two shares holding the same series are one
//! poster, not two. The grouping key is the normalised title, so a share added
//! later slots into the title that is already there.
//! * **Watch state is joined in, not looked up per draw.** A grid render must
//! not become one SQLite round-trip per tile — see the negative-caching trap
//! in `docs/DEVELOPMENT.md`, which is the same lesson one layer up.
use std::collections::HashMap;
use crate::catalog::{CatalogNode, WatchState};
/// A file has been watched once playback passed this fraction of it. Short of
/// 1.0 because nobody sits through the credits, and a title that never reaches
/// "finished" keeps offering an episode the viewer is done with.
pub const WATCHED_FRACTION: f64 = 0.9;
/// Playback below this many seconds is not a resume point — it is a file the
/// viewer opened and immediately left.
pub const RESUME_FLOOR_SECS: f64 = 30.0;
/// Whether a title is a series or a one-off.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TitleKind {
Series,
Film,
}
/// One playable file, with where the viewer left off in it.
#[derive(Debug, Clone, PartialEq)]
pub struct Episode {
pub node: CatalogNode,
pub watch: Option<WatchState>,
}
impl Episode {
/// `S01E02`, `#014`, or nothing when the name numbers nothing.
pub fn numbering(&self) -> Option<String> {
match (self.node.parsed.season, self.node.parsed.episode) {
(Some(season), Some(episode)) => Some(format!("S{season:02}E{episode:02}")),
(None, Some(episode)) => Some(format!("#{episode:03}")),
_ => None,
}
}
/// What to put on the row: the episode's own name when the filename stated
/// one, else the numbering, else the filename.
pub fn label(&self) -> String {
if let Some(title) = &self.node.parsed.episode_title {
return title.clone();
}
self.numbering().unwrap_or_else(|| self.node.name.clone())
}
/// What to put on a row that already shows the numbering beside it: the
/// episode's own name when the filename stated one, else the filename.
///
/// Deliberately not [`Self::label`] — that falls back to the numbering,
/// which on an episode row prints it twice.
pub fn detail(&self) -> &str {
self.node
.parsed
.episode_title
.as_deref()
.unwrap_or(&self.node.name)
}
/// Fraction of the file played, when both a position and a duration are
/// known. `None` for a file that was never opened.
pub fn progress(&self) -> Option<f64> {
let watch = self.watch?;
let duration = watch.duration_secs?;
if duration <= 0.0 {
return None;
}
Some((watch.position_secs / duration).clamp(0.0, 1.0))
}
/// Whether this counts as seen. Either the flag was set explicitly, or
/// playback got far enough that setting it is what the viewer meant.
pub fn is_watched(&self) -> bool {
self.watch.is_some_and(|watch| watch.watched)
|| self.progress().is_some_and(|p| p >= WATCHED_FRACTION)
}
/// Where to resume, or `None` to start from the beginning. A file barely
/// started, or effectively finished, starts over.
pub fn resume_at(&self) -> Option<f64> {
let watch = self.watch?;
if self.is_watched() || watch.position_secs < RESUME_FLOOR_SECS {
return None;
}
Some(watch.position_secs)
}
/// When playback last touched this file. `0` for one never opened, so it
/// sorts below everything that was.
pub fn last_played(&self) -> i64 {
self.watch.map_or(0, |watch| watch.updated_at)
}
}
/// The episodes of one season, or the unnumbered pile when a library uses
/// absolute numbering.
#[derive(Debug, Clone, PartialEq)]
pub struct Season {
/// `None` when the names state no season. That is not season 1: an
/// absolute-numbered anime and a film both land here.
pub number: Option<u32>,
pub episodes: Vec<Episode>,
}
impl Season {
pub fn label(&self) -> String {
match self.number {
Some(number) => format!("Season {number}"),
None => "Episodes".to_string(),
}
}
}
/// A series or a film: everything under one title, across every share.
#[derive(Debug, Clone, PartialEq)]
pub struct Title {
/// Stable identity for the UI to route on and for caches to key on.
pub key: String,
/// What to print.
pub name: String,
pub year: Option<u32>,
pub kind: TitleKind,
pub seasons: Vec<Season>,
/// Every share contributing files, in first-seen order.
pub share_ids: Vec<String>,
}
impl Title {
/// Every episode, in display order.
pub fn episodes(&self) -> impl Iterator<Item = &Episode> {
self.seasons
.iter()
.flat_map(|season| season.episodes.iter())
}
pub fn episode_count(&self) -> usize {
self.seasons
.iter()
.map(|season| season.episodes.len())
.sum()
}
pub fn watched_count(&self) -> usize {
self.episodes().filter(|e| e.is_watched()).count()
}
/// The file to pull a poster thumbnail from: the first episode there is.
/// Proton renders a thumbnail per file, so any of them is a frame of the
/// right show; the first is the one most likely already cached.
pub fn poster_node(&self) -> Option<&CatalogNode> {
self.episodes().next().map(|episode| &episode.node)
}
/// The episode that is part-watched, most recently played first.
pub fn resume(&self) -> Option<&Episode> {
self.episodes()
.filter(|episode| episode.resume_at().is_some())
.max_by_key(|episode| episode.last_played())
}
/// What a click on the poster should play: whatever is part-watched, else
/// the first unwatched episode, else the first episode.
pub fn next_up(&self) -> Option<&Episode> {
self.resume()
.or_else(|| self.episodes().find(|episode| !episode.is_watched()))
.or_else(|| self.episodes().next())
}
/// Where a file sits in display order, if it is under this title at all.
///
/// Identity is `(share_id, link_id)`, not the name: a title merged across
/// two shares can hold two files called `E01.mkv`, and "play the next one"
/// must not be able to jump between shares by accident.
pub fn position_of(&self, share_id: &str, link_id: &str) -> Option<usize> {
self.episodes().position(|episode| {
episode.node.share_id == share_id && episode.node.link_id == link_id
})
}
/// What comes after a file, in display order. `None` at the end of the
/// title, and for a file that is not part of it.
pub fn following(&self, share_id: &str, link_id: &str) -> Option<&Episode> {
let position = self.position_of(share_id, link_id)?;
self.episodes().nth(position + 1)
}
/// What comes before it.
pub fn preceding(&self, share_id: &str, link_id: &str) -> Option<&Episode> {
let position = self.position_of(share_id, link_id)?;
self.episodes().nth(position.checked_sub(1)?)
}
/// When this title was last played at all.
pub fn last_played(&self) -> i64 {
self.episodes().map(Episode::last_played).max().unwrap_or(0)
}
}
/// Every title the catalog holds.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Library {
pub titles: Vec<Title>,
}
impl Library {
/// Group catalog rows into titles, joining in watch state keyed by
/// `(share_id, link_id)`.
///
/// Folder rows are ignored: a poster wall shows playable files, and the
/// folder structure has already been consumed by the parser that produced
/// these titles.
pub fn build(nodes: Vec<CatalogNode>, watch: &HashMap<(String, String), WatchState>) -> Self {
// Insertion-ordered grouping: key -> index into `titles`, so the output
// is deterministic before it is sorted.
let mut index: HashMap<String, usize> = HashMap::new();
let mut titles: Vec<Title> = Vec::new();
for node in nodes {
if node.is_folder {
continue;
}
let key = title_key(&node.parsed.title);
let position = match index.get(&key) {
Some(position) => *position,
None => {
titles.push(Title {
key: key.clone(),
name: node.parsed.title.clone(),
year: node.parsed.year,
// Provisional: settled once every file is in.
kind: TitleKind::Film,
seasons: Vec::new(),
share_ids: Vec::new(),
});
index.insert(key, titles.len() - 1);
titles.len() - 1
}
};
let title = &mut titles[position];
if title.year.is_none() {
title.year = node.parsed.year;
}
if !title.share_ids.contains(&node.share_id) {
title.share_ids.push(node.share_id.clone());
}
let watch_state = watch
.get(&(node.share_id.clone(), node.link_id.clone()))
.copied();
let season_number = node.parsed.season;
let episode = Episode {
node,
watch: watch_state,
};
match title
.seasons
.iter_mut()
.find(|season| season.number == season_number)
{
Some(season) => season.episodes.push(episode),
None => title.seasons.push(Season {
number: season_number,
episodes: vec![episode],
}),
}
}
for title in &mut titles {
// A season number, an episode number or more than one file all mean
// series. A lone unnumbered file is a film.
let numbered = title.episodes().any(|episode| {
episode.node.parsed.is_episode() || episode.node.parsed.season.is_some()
});
title.kind = if numbered || title.episode_count() > 1 {
TitleKind::Series
} else {
TitleKind::Film
};
// Unnumbered seasons last: they are the specials pile, not season 0.
title
.seasons
.sort_by_key(|season| (season.number.is_none(), season.number));
for season in &mut title.seasons {
season.episodes.sort_by(|left, right| {
left.node
.parsed
.episode
.cmp(&right.node.parsed.episode)
.then_with(|| left.node.name.cmp(&right.node.name))
});
}
}
titles.sort_by_key(|title| sort_name(&title.name));
Self { titles }
}
pub fn is_empty(&self) -> bool {
self.titles.is_empty()
}
pub fn get(&self, key: &str) -> Option<&Title> {
self.titles.iter().find(|title| title.key == key)
}
/// Titles with something part-watched, most recent first. The top row of
/// the library.
pub fn continue_watching(&self) -> Vec<&Title> {
let mut titles: Vec<&Title> = self
.titles
.iter()
.filter(|title| title.resume().is_some())
.collect();
titles.sort_by_key(|title| std::cmp::Reverse(title.last_played()));
titles
}
/// Substring match over the display name and every filename under it, so
/// searching for a release group or an episode name finds its title.
pub fn search(&self, query: &str) -> Vec<&Title> {
let needle = query.trim().to_lowercase();
if needle.is_empty() {
return self.titles.iter().collect();
}
self.titles
.iter()
.filter(|title| {
title.name.to_lowercase().contains(&needle)
|| title
.episodes()
.any(|episode| episode.node.name.to_lowercase().contains(&needle))
})
.collect()
}
}
/// The grouping key: case- and punctuation-insensitive, so `Fullmetal
/// Alchemist: Brotherhood` and `Fullmetal Alchemist Brotherhood` are one title.
pub fn title_key(title: &str) -> String {
let mut key = String::with_capacity(title.len());
let mut pending_space = false;
for character in title.chars() {
if character.is_alphanumeric() {
if pending_space && !key.is_empty() {
key.push(' ');
}
pending_space = false;
key.extend(character.to_lowercase());
} else {
pending_space = true;
}
}
key
}
/// Sort key for the shelf: case-insensitive, and a leading article ignored, so
/// `The Expanse` files under E where a viewer looks for it.
fn sort_name(name: &str) -> String {
let lower = name.to_lowercase();
for article in ["the ", "a ", "an "] {
if let Some(rest) = lower.strip_prefix(article) {
return rest.to_string();
}
}
lower
}
#[cfg(test)]
mod tests {
use super::*;
use crate::naming::ParsedName;
fn node(
share: &str,
link: &str,
name: &str,
season: Option<u32>,
episode: Option<u32>,
) -> CatalogNode {
CatalogNode {
share_id: share.into(),
link_id: link.into(),
volume_id: "vol".into(),
parent_link_id: None,
name: name.into(),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1024),
active_revision_id: Some("rev".into()),
parsed: ParsedName {
title: name.split(" - ").next().unwrap_or(name).to_string(),
season,
episode,
year: None,
episode_title: None,
},
}
}
#[test]
fn the_next_episode_is_the_next_one_in_display_order_across_seasons() {
let library = Library::build(
vec![
node("s1", "a", "Show - S01E01.mkv", Some(1), Some(1)),
node("s1", "b", "Show - S01E02.mkv", Some(1), Some(2)),
node("s1", "c", "Show - S02E01.mkv", Some(2), Some(1)),
],
&HashMap::new(),
);
let title = &library.titles[0];
assert_eq!(
title.following("s1", "a").map(|e| e.node.link_id.clone()),
Some("b".to_string())
);
// Autoplay has to walk off the end of a season into the next one, or it
// stops two thirds of the way through a series for no reason a viewer
// can see.
assert_eq!(
title.following("s1", "b").map(|e| e.node.link_id.clone()),
Some("c".to_string())
);
assert_eq!(title.following("s1", "c"), None);
assert_eq!(
title.preceding("s1", "c").map(|e| e.node.link_id.clone()),
Some("b".to_string())
);
assert_eq!(title.preceding("s1", "a"), None);
}
#[test]
fn a_file_from_another_share_is_not_this_titles_neighbour() {
let library = Library::build(
vec![
node("s1", "a", "Show - S01E01.mkv", Some(1), Some(1)),
node("s1", "b", "Show - S01E02.mkv", Some(1), Some(2)),
],
&HashMap::new(),
);
let title = &library.titles[0];
// Same link id, different share: two shares of the same series really
// do collide on names, and "next" must not cross over on a false match.
assert_eq!(title.position_of("other", "a"), None);
assert_eq!(title.following("other", "a"), None);
}
fn watch(position: f64, duration: f64, watched: bool, at: i64) -> WatchState {
WatchState {
position_secs: position,
duration_secs: Some(duration),
watched,
updated_at: at,
}
}
#[test]
fn groups_episodes_into_seasons() {
let nodes = vec![
node("s", "1", "Frieren - S01E02", Some(1), Some(2)),
node("s", "2", "Frieren - S01E01", Some(1), Some(1)),
node("s", "3", "Frieren - S02E01", Some(2), Some(1)),
];
let library = Library::build(nodes, &HashMap::new());
assert_eq!(library.titles.len(), 1);
let title = &library.titles[0];
assert_eq!(title.kind, TitleKind::Series);
assert_eq!(title.seasons.len(), 2);
assert_eq!(title.seasons[0].number, Some(1));
// Sorted by episode number, not by insertion order.
assert_eq!(title.seasons[0].episodes[0].node.link_id, "2");
assert_eq!(title.episode_count(), 3);
}
#[test]
fn a_row_detail_never_repeats_the_numbering() {
let mut file = node("s", "1", "Show - S01E01", Some(1), Some(1));
file.name = "[Group] Show - 01 (1080p).mkv".into();
let library = Library::build(vec![file], &HashMap::new());
let episode = &library.titles[0].seasons[0].episodes[0];
// No episode name in the filename: the row shows the filename, while
// the card — which has no numbering column — shows the numbering.
assert_eq!(episode.detail(), "[Group] Show - 01 (1080p).mkv");
assert_eq!(episode.label(), "S01E01");
}
#[test]
fn a_stated_episode_name_wins_on_both() {
let mut file = node("s", "1", "Show - S01E01", Some(1), Some(1));
file.parsed.episode_title = Some("Mother and Children".into());
let library = Library::build(vec![file], &HashMap::new());
let episode = &library.titles[0].seasons[0].episodes[0];
assert_eq!(episode.detail(), "Mother and Children");
assert_eq!(episode.label(), "Mother and Children");
}
#[test]
fn unnumbered_seasons_sort_last() {
let nodes = vec![
node("s", "1", "Show - OVA", None, None),
node("s", "2", "Show - S01E01", Some(1), Some(1)),
];
let library = Library::build(nodes, &HashMap::new());
let seasons = &library.titles[0].seasons;
assert_eq!(seasons[0].number, Some(1));
assert_eq!(seasons[1].number, None);
assert_eq!(seasons[1].label(), "Episodes");
}
#[test]
fn a_lone_unnumbered_file_is_a_film() {
let nodes = vec![node("s", "1", "Spirited Away", None, None)];
let library = Library::build(nodes, &HashMap::new());
assert_eq!(library.titles[0].kind, TitleKind::Film);
}
#[test]
fn titles_merge_across_shares() {
let nodes = vec![
node("a", "1", "Frieren - S01E01", Some(1), Some(1)),
node("b", "2", "frieren! - S01E02", Some(1), Some(2)),
];
let library = Library::build(nodes, &HashMap::new());
assert_eq!(library.titles.len(), 1);
assert_eq!(library.titles[0].share_ids, vec!["a", "b"]);
}
#[test]
fn next_up_prefers_a_resume_over_an_unwatched_episode() {
let nodes = vec![
node("s", "1", "Show - S01E01", Some(1), Some(1)),
node("s", "2", "Show - S01E02", Some(1), Some(2)),
node("s", "3", "Show - S01E03", Some(1), Some(3)),
];
let mut states = HashMap::new();
// Episode 1 finished, episode 2 half-watched.
states.insert(("s".into(), "1".into()), watch(1400.0, 1440.0, false, 10));
states.insert(("s".into(), "2".into()), watch(700.0, 1440.0, false, 20));
let library = Library::build(nodes, &states);
let title = &library.titles[0];
assert_eq!(title.watched_count(), 1);
assert_eq!(title.next_up().unwrap().node.link_id, "2");
assert_eq!(title.resume().unwrap().resume_at(), Some(700.0));
assert_eq!(library.continue_watching().len(), 1);
}
#[test]
fn next_up_falls_through_to_the_first_unwatched() {
let nodes = vec![
node("s", "1", "Show - S01E01", Some(1), Some(1)),
node("s", "2", "Show - S01E02", Some(1), Some(2)),
];
let mut states = HashMap::new();
states.insert(("s".into(), "1".into()), watch(1440.0, 1440.0, true, 10));
let library = Library::build(nodes, &states);
assert_eq!(library.titles[0].next_up().unwrap().node.link_id, "2");
}
#[test]
fn a_barely_started_file_starts_over() {
let nodes = vec![node("s", "1", "Show - S01E01", Some(1), Some(1))];
let mut states = HashMap::new();
states.insert(("s".into(), "1".into()), watch(4.0, 1440.0, false, 10));
let library = Library::build(nodes, &states);
let episode = &library.titles[0].seasons[0].episodes[0];
assert_eq!(episode.resume_at(), None);
assert!(library.continue_watching().is_empty());
}
#[test]
fn a_nearly_finished_file_counts_as_watched() {
let nodes = vec![node("s", "1", "Show - S01E01", Some(1), Some(1))];
let mut states = HashMap::new();
states.insert(("s".into(), "1".into()), watch(1400.0, 1440.0, false, 10));
let library = Library::build(nodes, &states);
assert!(library.titles[0].seasons[0].episodes[0].is_watched());
}
#[test]
fn search_matches_filenames_as_well_as_titles() {
let mut first = node("s", "1", "Frieren - S01E01", Some(1), Some(1));
first.name = "[SubsPlease] Frieren - 01 (1080p).mkv".into();
let library = Library::build(vec![first], &HashMap::new());
assert_eq!(library.search("subsplease").len(), 1);
assert_eq!(library.search("frieren").len(), 1);
assert_eq!(library.search("bebop").len(), 0);
assert_eq!(library.search(" ").len(), 1);
}
#[test]
fn sorting_ignores_a_leading_article() {
let nodes = vec![
node("s", "1", "Zoo", None, Some(1)),
node("s", "2", "The Expanse", None, Some(1)),
node("s", "3", "Akira", None, Some(1)),
];
let library = Library::build(nodes, &HashMap::new());
let names: Vec<&str> = library.titles.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["Akira", "The Expanse", "Zoo"]);
}
#[test]
fn title_key_ignores_punctuation_and_case() {
assert_eq!(
title_key("Fullmetal Alchemist: Brotherhood"),
title_key("fullmetal alchemist brotherhood")
);
assert_eq!(title_key("K-On!!"), "k on");
}
#[test]
fn folders_are_not_episodes() {
let mut folder = node("s", "1", "Show", None, None);
folder.is_folder = true;
let library = Library::build(vec![folder], &HashMap::new());
assert!(library.is_empty());
}
}
+450
View File
@@ -0,0 +1,450 @@
//! What a metadata provider knows about a title, and whether to ask one.
//!
//! The *model* is here rather than in `pstr-meta` because the catalog stores it
//! and the UI draws it, and neither of those should have to depend on the crate
//! that talks HTTP to AniList. `pstr-meta` fills these in; everything else only
//! reads them.
//!
//! ## Enrichment is off by default, and says why
//!
//! Matching a library against a provider means sending that provider the titles
//! in it. For a library of public-domain films that is nothing; for a library of
//! anything else it is a list of what someone watches, attached to an IP
//! address, held by a third party under their retention policy and not ours.
//! That is a real cost and the viewer is the only one who can weigh it, so
//! [`MetadataConfig::enabled`] starts `false`, the UI states the cost in the
//! same breath as the switch, and nothing here is requested until it is on.
//!
//! With it off, posters fall back to Proton's own thumbnails — which for a share
//! filled by the Linux client means no posters at all, because Proton renders
//! thumbnails at upload time and that client attaches none. See
//! `docs/DEVELOPMENT.md`.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::library::TitleKind;
/// Which provider a match came from.
///
/// Stored as a string in the catalog rather than an integer: a row whose
/// provider this build does not recognise should read as "some other provider",
/// which is a re-match, not a parse failure.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ProviderId {
/// AniList. Anime only, no API key, no account.
#[default]
AniList,
/// The Movie Database. Film and television, needs a free API key.
Tmdb,
}
impl ProviderId {
pub const ALL: [Self; 2] = [Self::AniList, Self::Tmdb];
pub fn as_str(self) -> &'static str {
match self {
Self::AniList => "anilist",
Self::Tmdb => "tmdb",
}
}
/// What to call it in the UI.
pub fn label(self) -> &'static str {
match self {
Self::AniList => "AniList",
Self::Tmdb => "TMDB",
}
}
/// One line on what it covers and what it costs to use.
pub fn description(self) -> &'static str {
match self {
Self::AniList => "Anime, with no API key and no account.",
Self::Tmdb => "Film and television. Needs a free API key from themoviedb.org.",
}
}
/// Whether this provider cannot be used without a key.
pub fn needs_api_key(self) -> bool {
matches!(self, Self::Tmdb)
}
pub fn parse(text: &str) -> Option<Self> {
Self::ALL
.into_iter()
.find(|provider| provider.as_str() == text)
}
}
/// What a provider said about one title.
///
/// Every field past the identity is optional, because every field is optional at
/// some provider: AniList has no backdrop for most shows, TMDB has no episode
/// count on a film, and a brand-new entry may be nothing but a name.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TitleMetadata {
pub provider: ProviderId,
/// The provider's own id, so a re-fetch does not re-search.
pub remote_id: String,
/// The canonical title, in the viewer's language where the provider has one.
pub name: String,
/// The title in its original language, when it differs.
pub original_name: Option<String>,
pub overview: Option<String>,
pub year: Option<u32>,
pub kind: TitleKind,
/// Portrait art, around 2:3.
pub poster_url: Option<String>,
/// Landscape art, around 16:9. Much rarer than a poster, and the reason the
/// grid cannot simply prefer one shape.
pub backdrop_url: Option<String>,
/// Out of 10, however the provider scores.
pub rating: Option<f32>,
pub genres: Vec<String>,
/// How many episodes the provider thinks there are — useful next to how
/// many the library actually holds.
pub episodes: Option<u32>,
/// The title's page, for a viewer who wants the rest of it.
pub url: Option<String>,
}
impl TitleMetadata {
/// The art to put on a 16:9 tile, most appropriate first.
///
/// A backdrop is the right shape; a poster is the wrong shape but the right
/// picture, and is drawn letterboxed rather than cropped — cropping a 2:3
/// poster to 16:9 removes most of what makes it recognisable.
pub fn tile_art(&self) -> Option<(&str, ArtShape)> {
if let Some(url) = &self.backdrop_url {
return Some((url, ArtShape::Landscape));
}
self.poster_url
.as_deref()
.map(|url| (url, ArtShape::Portrait))
}
}
/// What a provider said about one episode.
///
/// Thin on purpose. The point of this is the two lines a viewer reads before
/// picking an episode — what it is called and what happens in it — and every
/// extra field is another column to migrate for something no row shows.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EpisodeMetadata {
/// The season the provider files it under, or `None` when it numbers
/// episodes straight through — which is how AniList counts, and how most
/// anime releases name their files.
pub season: Option<u32>,
pub number: u32,
pub name: Option<String>,
pub overview: Option<String>,
/// A frame from the episode, around 16:9.
pub still_url: Option<String>,
/// As the provider writes it — `2009-10-10`. Not parsed: it is shown, not
/// sorted on, and a provider that answers `2009` should not be a parse
/// error.
pub air_date: Option<String>,
}
/// Every episode a provider listed for one title, ready to look up.
///
/// The lookup is the whole reason this is a type rather than a `Vec`: a file
/// numbered `#057` and a provider that files episodes under seasons have to
/// meet somewhere, and that reconciliation should happen in one tested place
/// rather than in each caller.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct EpisodeGuide {
/// Keyed `(season, number)`, where `None` is the provider's own absolute
/// numbering.
entries: HashMap<(Option<u32>, u32), EpisodeMetadata>,
}
impl EpisodeGuide {
pub fn new(episodes: Vec<EpisodeMetadata>) -> Self {
Self {
entries: episodes
.into_iter()
.map(|episode| ((episode.season, episode.number), episode))
.collect(),
}
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
/// What the provider says about the file numbered like this.
///
/// Tried in order: the season and number the filename states; the
/// provider's absolute numbering; and, for a file with no season at all,
/// season one. That second fallback is what makes an absolutely-numbered
/// release line up with a provider that files everything under a single
/// season — the common case for anime on TMDB.
///
/// **The absolute fallback stops at season one.** A provider that numbers
/// straight through is one that split nothing, so its episode seven is the
/// seventh of the show — which is season one's, not season two's. Answering
/// `S02E07` with it captions every episode of every later season with the
/// wrong name, and a wrong caption does not look like a bug: it looks like
/// the library is wrong. Season two upwards is answered from an entry that
/// states that season or not at all — see
/// `Provider::seasons_are_separate_entries`.
pub fn get(&self, season: Option<u32>, number: u32) -> Option<&EpisodeMetadata> {
if let Some(found) = self.entries.get(&(season, number)) {
return Some(found);
}
if matches!(season, None | Some(1))
&& let Some(found) = self.entries.get(&(None, number))
{
return Some(found);
}
season
.is_none()
.then(|| self.entries.get(&(Some(1), number)))?
}
/// Which seasons this guide actually has answers for. `None` is the
/// provider's own absolute numbering.
pub fn seasons(&self) -> std::collections::HashSet<Option<u32>> {
self.entries
.values()
.map(|episode| episode.season)
.collect()
}
/// Every entry, for storing.
pub fn episodes(&self) -> impl Iterator<Item = &EpisodeMetadata> {
self.entries.values()
}
}
/// How a piece of art should be fitted into a tile.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtShape {
/// Roughly the tile's shape: crop to fill.
Landscape,
/// Taller than the tile: fit inside it, and leave the sides.
Portrait,
}
/// What the catalog remembers about one title's metadata, including that there
/// was none.
#[derive(Debug, Clone, PartialEq)]
pub struct MetadataRecord {
/// [`crate::library::title_key`] of the title this describes.
pub title_key: String,
/// The provider that was asked. Kept even on a miss, so switching provider
/// re-asks rather than trusting the other one's silence.
pub provider: ProviderId,
/// `None` means the provider was asked and had nothing. That is a real
/// answer and it is cached: without it every grid render pays a round-trip
/// per unmatched title, which is the trap `proton-drive-linux`'s photo grid
/// hit and this one is not going to.
pub metadata: Option<TitleMetadata>,
/// Unix seconds. What makes a miss expire rather than being forever.
pub fetched_at: i64,
}
/// How long a stored answer is trusted.
///
/// A match is effectively permanent — a show does not stop being that show — but
/// artwork and ratings move, and more to the point a *miss* has to expire, or a
/// title the provider had not indexed yet stays blank for the life of the
/// install. Misses are retried far sooner than matches are refreshed.
pub const MATCH_TTL_SECS: i64 = 60 * 60 * 24 * 30;
pub const MISS_TTL_SECS: i64 = 60 * 60 * 24 * 3;
impl MetadataRecord {
/// Whether this answer is still worth using rather than asking again.
pub fn is_fresh(&self, now: i64, provider: ProviderId) -> bool {
if self.provider != provider {
return false;
}
let ttl = if self.metadata.is_some() {
MATCH_TTL_SECS
} else {
MISS_TTL_SECS
};
now.saturating_sub(self.fetched_at) < ttl
}
}
/// Whether to enrich, with what.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MetadataConfig {
/// Off until the viewer turns it on. See the module note.
pub enabled: bool,
pub provider: ProviderId,
/// BCP-47-ish, as the provider wants it. Only TMDB uses it; AniList
/// answers in romaji and English and the match picks between them.
pub language: String,
}
impl Default for MetadataConfig {
fn default() -> Self {
Self {
enabled: false,
provider: ProviderId::AniList,
language: "en".into(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn metadata() -> TitleMetadata {
TitleMetadata {
provider: ProviderId::AniList,
remote_id: "1".into(),
name: "Cowboy Bebop".into(),
original_name: None,
overview: None,
year: Some(1998),
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: Some(26),
url: None,
}
}
fn episode(season: Option<u32>, number: u32, name: &str) -> EpisodeMetadata {
EpisodeMetadata {
season,
number,
name: Some(name.into()),
overview: None,
still_url: None,
air_date: None,
}
}
#[test]
fn a_file_that_states_its_season_is_answered_from_that_season() {
let guide = EpisodeGuide::new(vec![
episode(Some(1), 2, "Stray Dog Strut"),
episode(Some(2), 2, "Somewhere else entirely"),
]);
assert_eq!(
guide.get(Some(1), 2).and_then(|e| e.name.clone()),
Some("Stray Dog Strut".to_string())
);
// The fallback to season one must never reach a file that said it was
// in season two — that would caption every episode with the wrong one.
assert_eq!(guide.get(Some(3), 2), None);
}
#[test]
fn an_absolutely_numbered_file_finds_an_absolutely_numbered_answer() {
let guide = EpisodeGuide::new(vec![episode(None, 57, "The Immortal Legion")]);
assert_eq!(
guide.get(None, 57).and_then(|e| e.name.clone()),
Some("The Immortal Legion".to_string())
);
// A provider that numbers straight through answers a file that states
// season *one*: the release split nothing there.
assert_eq!(guide.get(Some(1), 57).map(|e| e.number), Some(57));
}
/// **What captioned every episode of Oshi no Ko season two with season
/// one's names.** AniList files a sequel as its own entry numbered from
/// one, so its "episode 1" is season one's — and a file that says it is in
/// season two must not be answered with it.
#[test]
fn an_absolutely_numbered_answer_never_reaches_a_later_season() {
let guide = EpisodeGuide::new(vec![episode(None, 1, "Mother and Children")]);
assert_eq!(guide.get(Some(2), 1), None);
assert_eq!(guide.get(Some(3), 1), None);
// And once the season's own entry has been fetched, it answers.
let guide = EpisodeGuide::new(vec![
episode(None, 1, "Mother and Children"),
episode(Some(2), 1, "The Franchise"),
]);
assert_eq!(
guide.get(Some(2), 1).and_then(|e| e.name.clone()),
Some("The Franchise".to_string())
);
assert_eq!(
guide.get(None, 1).and_then(|e| e.name.clone()),
Some("Mother and Children".to_string())
);
}
#[test]
fn an_absolutely_numbered_file_falls_back_to_the_only_season_there_is() {
// TMDB files a 64-episode anime under one season; the release numbers
// its files `#001`–`#064`. Without this they never meet.
let guide = EpisodeGuide::new(vec![episode(Some(1), 57, "The Immortal Legion")]);
assert_eq!(guide.get(None, 57).map(|e| e.number), Some(57));
assert_eq!(guide.get(None, 99), None);
}
#[test]
fn enrichment_is_off_until_it_is_asked_for() {
assert!(!MetadataConfig::default().enabled);
}
#[test]
fn provider_names_round_trip_through_the_catalog_column() {
for provider in ProviderId::ALL {
assert_eq!(ProviderId::parse(provider.as_str()), Some(provider));
}
assert_eq!(ProviderId::parse("letterboxd"), None);
}
/// A backdrop is the tile's shape and wins; a poster still gets used, but
/// says it needs fitting rather than cropping.
#[test]
fn tile_art_prefers_the_landscape_picture() {
let mut data = metadata();
data.poster_url = Some("poster".into());
assert_eq!(data.tile_art(), Some(("poster", ArtShape::Portrait)));
data.backdrop_url = Some("backdrop".into());
assert_eq!(data.tile_art(), Some(("backdrop", ArtShape::Landscape)));
}
/// A miss goes stale far sooner than a match: a show the provider had not
/// indexed yet must not stay blank forever.
#[test]
fn a_miss_expires_sooner_than_a_match() {
let now = 1_000_000_000;
let miss = MetadataRecord {
title_key: "k".into(),
provider: ProviderId::AniList,
metadata: None,
fetched_at: now - MISS_TTL_SECS - 1,
};
assert!(!miss.is_fresh(now, ProviderId::AniList));
let matched = MetadataRecord {
metadata: Some(metadata()),
..miss.clone()
};
assert!(matched.is_fresh(now, ProviderId::AniList));
}
/// Switching provider must re-ask rather than trust the other one's answer —
/// especially its silence.
#[test]
fn a_record_from_another_provider_is_never_fresh() {
let record = MetadataRecord {
title_key: "k".into(),
provider: ProviderId::AniList,
metadata: Some(metadata()),
fetched_at: 1_000_000_000,
};
assert!(!record.is_fresh(1_000_000_000, ProviderId::Tmdb));
}
}
File diff suppressed because it is too large. Load diff
+161
View File
@@ -0,0 +1,161 @@
//! Playback preferences that outlive one file.
//!
//! Volume, mute, and which language of audio and subtitles to prefer. All four
//! are things a viewer sets once and expects to still hold for the next
//! episode, the next film and the next launch — a player that starts every file
//! at full volume in the container's default language is one that has to be
//! corrected every time.
//!
//! Stored beside the share list and written the same way: atomically, and never
//! overwritten when it will not parse. Losing this file costs nothing more than
//! a re-set, so unlike `shares.json` an unreadable one is reported and *stepped
//! over* rather than being allowed to stop anything.
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
/// The loudest this app plays. Matches `pstr_player::MAX_VOLUME`, which is not
/// referenced here because `pstr-core` does not depend on the player.
const MAX_VOLUME: f64 = 100.0;
/// How playback should sound, and in which language.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
// A file written by an older version, or hand-edited, is missing fields rather
// than invalid: every one of them has a sensible default.
#[serde(default)]
pub struct PlaybackPrefs {
/// 0–100.
pub volume: f64,
pub muted: bool,
/// The language tag of the audio track to prefer — "jpn", "eng". `None`
/// leaves the choice to the container's default.
pub audio_language: Option<String>,
pub subtitle_language: Option<String>,
/// Whether subtitles are wanted at all.
///
/// Separate from the language: "off" is a decision, and an absent language
/// cannot express it — that means "no preference", which is what a viewer
/// who has never touched the menu has.
pub subtitles: bool,
/// Whether reaching the end of an episode starts the next one.
///
/// On by default, because that is what every service this is modelled on
/// does and what a season of anything is watched like. Off is one checkbox
/// away for someone who falls asleep to it.
pub autoplay_next: bool,
}
impl Default for PlaybackPrefs {
fn default() -> Self {
Self {
volume: MAX_VOLUME,
muted: false,
audio_language: None,
subtitle_language: None,
subtitles: true,
autoplay_next: true,
}
}
}
impl PlaybackPrefs {
/// The same preferences with anything out of range brought back into it.
///
/// A hand-edited or older file can hold a volume of 900 or of NaN, and both
/// would otherwise reach mpv.
pub fn sanitized(mut self) -> Self {
if !self.volume.is_finite() {
self.volume = MAX_VOLUME;
}
self.volume = self.volume.clamp(0.0, MAX_VOLUME);
self.audio_language = self.audio_language.filter(|tag| !tag.trim().is_empty());
self.subtitle_language = self.subtitle_language.filter(|tag| !tag.trim().is_empty());
self
}
}
fn prefs_file(dirs: &AppDirs) -> std::path::PathBuf {
dirs.config.join("playback.json")
}
/// The stored preferences, or the defaults on a first run.
pub fn load(dirs: &AppDirs) -> Result<PlaybackPrefs> {
Ok(read_json::<PlaybackPrefs>(&prefs_file(dirs))?
.unwrap_or_default()
.sanitized())
}
/// Write the preferences.
pub fn save(dirs: &AppDirs, prefs: &PlaybackPrefs) -> Result<()> {
write_json(&prefs_file(dirs), prefs)
}
#[cfg(test)]
mod tests {
use super::*;
fn dirs(root: &std::path::Path) -> AppDirs {
AppDirs {
config: root.to_path_buf(),
data: root.to_path_buf(),
cache: root.to_path_buf(),
}
}
#[test]
fn a_first_run_gets_full_volume_and_no_language_preference() {
let prefs = PlaybackPrefs::default();
assert_eq!(prefs.volume, MAX_VOLUME);
assert!(!prefs.muted);
assert!(prefs.subtitles);
assert_eq!(prefs.audio_language, None);
}
#[test]
fn what_was_saved_is_what_loads() {
let temp = std::env::temp_dir().join(format!("pstr-prefs-{}", std::process::id()));
std::fs::create_dir_all(&temp).unwrap();
let dirs = dirs(&temp);
let prefs = PlaybackPrefs {
volume: 42.0,
muted: true,
audio_language: Some("jpn".into()),
subtitle_language: Some("eng".into()),
subtitles: true,
autoplay_next: false,
};
save(&dirs, &prefs).unwrap();
assert_eq!(load(&dirs).unwrap(), prefs);
std::fs::remove_dir_all(&temp).ok();
}
#[test]
fn a_volume_no_player_could_honour_is_clamped_rather_than_passed_on() {
let prefs = PlaybackPrefs {
volume: 900.0,
..PlaybackPrefs::default()
}
.sanitized();
assert_eq!(prefs.volume, MAX_VOLUME);
let prefs = PlaybackPrefs {
volume: f64::NAN,
..PlaybackPrefs::default()
}
.sanitized();
// NaN clamps to NaN, which reaches mpv as a volume of nothing at all.
assert_eq!(prefs.volume, MAX_VOLUME);
}
#[test]
fn a_language_set_to_blank_is_no_preference_rather_than_a_language_named_nothing() {
let prefs = PlaybackPrefs {
audio_language: Some(" ".into()),
..PlaybackPrefs::default()
}
.sanitized();
assert_eq!(prefs.audio_language, None);
}
}
+333
View File
@@ -0,0 +1,333 @@
//! The links this app knows about, and opening them.
//!
//! ## Where the secrets live
//!
//! A Proton public-link URL carries its decryption password in its **fragment**
//! (`https://drive.proton.me/urls/TOKEN#s3cr3t`), and a custom-password link
//! needs a second secret on top. Both are, in full, the ability to read the
//! share — so neither goes in the config file. The config holds the share's id,
//! its display name and its token; the URL and custom password go in the OS
//! credential store (Secret Service on Linux, Credential Manager on Windows).
//!
//! ## Why several shares
//!
//! A library is not one folder. [`SharedLibrary`] opens every configured share
//! and merges them into one catalog, so the app has a single browsable view
//! regardless of how many links it was given.
use std::collections::BTreeMap;
use proton_drive_rs::{Node, ProtonDrivePublicLinkClient};
use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::{Error, Result};
/// The credential-store service name. One entry per share, keyed by share id.
const KEYRING_SERVICE: &str = "proton-stream";
/// What the app records about a share, minus its secrets.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Share {
/// Stable id, used as the credential-store key and the catalog's share
/// column. Assigned once and never reused.
pub id: String,
/// What to call this share in the UI.
pub name: String,
/// The link token — the path segment of the share URL. Not a secret on its
/// own: without the fragment password it decrypts nothing.
pub token: String,
/// Whether opening this share needs a custom password in addition to the
/// URL fragment. Recorded so the UI knows to prompt before the handshake
/// rather than after it fails.
#[serde(default)]
pub has_custom_password: bool,
}
/// The secrets for one share, as held in the credential store.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ShareSecrets {
/// The full share URL, fragment included.
url: String,
/// The custom password, when the link has one.
#[serde(default)]
custom_password: Option<String>,
}
/// The configured shares, and their secrets.
pub struct ShareStore {
dirs: AppDirs,
}
impl ShareStore {
pub fn new(dirs: AppDirs) -> Self {
Self { dirs }
}
/// Every configured share, in the order they were added.
pub fn list(&self) -> Result<Vec<Share>> {
Ok(read_json(&self.dirs.shares_file())?.unwrap_or_default())
}
/// Record a share and stash its secrets.
///
/// The secrets are written **before** the config entry: a credential-store
/// failure then leaves no config row pointing at credentials that do not
/// exist, which would read as a share that exists but can never be opened.
pub fn add(&self, name: &str, url: &str, custom_password: Option<&str>) -> Result<Share> {
let token = token_from_url(url)?;
let id = format!("share-{token}");
let mut shares = self.list()?;
if shares.iter().any(|share| share.id == id) {
return Err(Error::Config(format!(
"a share for token {token} is already configured"
)));
}
let secrets = ShareSecrets {
url: url.to_string(),
custom_password: custom_password
.filter(|password| !password.is_empty())
.map(str::to_string),
};
self.store_secrets(&id, &secrets)?;
let share = Share {
id,
name: name.to_string(),
token,
has_custom_password: secrets.custom_password.is_some(),
};
shares.push(share.clone());
write_json(&self.dirs.shares_file(), &shares)?;
Ok(share)
}
/// Forget a share and its secrets.
///
/// The config entry goes first here, for the mirror-image reason: if the
/// credential deletion fails, what is left is an orphaned secret rather than
/// a listed share whose password is gone.
pub fn remove(&self, id: &str) -> Result<()> {
let mut shares = self.list()?;
let before = shares.len();
shares.retain(|share| share.id != id);
if shares.len() == before {
return Err(Error::NotFound(format!("no share with id {id}")));
}
write_json(&self.dirs.shares_file(), &shares)?;
// A missing entry is the desired end state, not a failure.
match self.entry(id)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Open a share as a visitor.
pub async fn open(&self, share: &Share) -> Result<ProtonDrivePublicLinkClient> {
let secrets = self.load_secrets(&share.id)?;
let client = ProtonDrivePublicLinkClient::open(
client_configuration(),
&secrets.url,
secrets.custom_password.as_deref(),
)
.await?
// The SDK default is sized for a background sync daemon. A player that
// seeks wants more blocks in flight; at 4 MiB each this is a 192 MiB
// ceiling, which is unremarkable for a desktop app.
.with_max_inflight_blocks(48);
Ok(client)
}
fn entry(&self, id: &str) -> Result<keyring::Entry> {
Ok(keyring::Entry::new(KEYRING_SERVICE, id)?)
}
fn store_secrets(&self, id: &str, secrets: &ShareSecrets) -> Result<()> {
let encoded = serde_json::to_string(secrets)
.map_err(|e| Error::Config(format!("serialize share secrets: {e}")))?;
self.entry(id)?.set_password(&encoded)?;
Ok(())
}
fn load_secrets(&self, id: &str) -> Result<ShareSecrets> {
let encoded = match self.entry(id)?.get_password() {
Ok(encoded) => encoded,
Err(keyring::Error::NoEntry) => {
return Err(Error::NotFound(format!(
"share {id} has no stored credentials; remove and re-add it"
)));
}
Err(e) => return Err(e.into()),
};
serde_json::from_str(&encoded)
.map_err(|e| Error::Config(format!("stored share secrets are unreadable: {e}")))
}
}
/// Several opened shares, presented as one library.
pub struct SharedLibrary {
clients: BTreeMap<String, ProtonDrivePublicLinkClient>,
}
impl SharedLibrary {
/// Open every configured share.
///
/// A share that fails to open is reported and skipped rather than failing
/// the whole library — one revoked link should not make the other three
/// unwatchable.
pub async fn open_all(store: &ShareStore) -> Result<(Self, Vec<(Share, Error)>)> {
let mut clients = BTreeMap::new();
let mut failures = Vec::new();
for share in store.list()? {
match store.open(&share).await {
Ok(client) => {
clients.insert(share.id.clone(), client);
}
Err(e) => failures.push((share, e)),
}
}
Ok((Self { clients }, failures))
}
/// The client for one share.
pub fn client(&self, share_id: &str) -> Option<&ProtonDrivePublicLinkClient> {
self.clients.get(share_id)
}
/// Every opened share id.
pub fn share_ids(&self) -> impl Iterator<Item = &str> {
self.clients.keys().map(String::as_str)
}
/// Walk one share's whole subtree, depth first, yielding every node.
///
/// There is no recursion helper on the visitor path — the authenticated
/// client's `get_node_hierarchy` / `get_node_by_path` have no counterpart
/// there — so the walk is here.
pub async fn crawl(&self, share_id: &str) -> Result<Vec<Node>> {
let client = self
.client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))?;
let root = client.get_root_node().await?;
let mut found = Vec::new();
let mut folders = vec![root.uid.clone()];
found.push(root);
while let Some(folder) = folders.pop() {
let child_uids: Vec<NodeUid> =
client.enumerate_folder_children_node_uids(&folder).await?;
if child_uids.is_empty() {
continue;
}
// Chunked and fanned out inside the SDK, and the node keys it
// unlocks along the way are cached there, so a deep tree does not
// re-derive the same ancestors per child.
let children = client.enumerate_nodes(&child_uids).await?;
for child in children {
if child.is_folder() {
folders.push(child.uid.clone());
}
found.push(child);
}
}
Ok(found)
}
}
/// The app-version string Proton identifies this client by.
///
/// The `external-drive-` prefix is **not** decoration: the API parses the part
/// before the first `-` as a platform and rejects anything it does not know
/// with `400 Platform \`…\` is not valid`. A bare `proton-stream@0.1.0` fails
/// every request, including the public-link handshake.
const APP_VERSION: &str = concat!("external-drive-stream@", env!("CARGO_PKG_VERSION"));
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
/// The API configuration every visitor client is built from.
fn client_configuration() -> ProtonClientConfiguration {
ProtonClientConfiguration::new(APP_VERSION).with_user_agent(USER_AGENT)
}
/// The token out of a share URL: the path segment after `/urls/`.
///
/// Parsed here as well as in the SDK because the store needs a stable id for a
/// share *before* it has opened it — and the id must not be derived from the
/// password, which would put a secret in the config file by the back door.
fn token_from_url(url: &str) -> Result<String> {
let (_, tail) = url
.split_once("/urls/")
.ok_or_else(|| Error::Config(format!("{url} is not a Proton share URL")))?;
let token = tail
.split(['#', '?'])
.next()
.unwrap_or_default()
.trim_end_matches('/');
if token.is_empty() {
return Err(Error::Config(format!("{url} carries no share token")));
}
if !url.contains('#') {
return Err(Error::Config(format!(
"{url} has no #password fragment; copy the full share link"
)));
}
Ok(token.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_share_url_yields_its_token() {
assert_eq!(
token_from_url("https://drive.proton.me/urls/ABC123#s3cr3t").unwrap(),
"ABC123"
);
}
#[test]
fn a_trailing_slash_before_the_fragment_is_tolerated() {
assert_eq!(
token_from_url("https://drive.proton.me/urls/ABC123/#s3cr3t").unwrap(),
"ABC123"
);
}
/// The fragment is the password. A URL without one cannot open anything, and
/// saying so at add time beats a confusing failure at open time.
#[test]
fn a_url_without_a_fragment_is_refused_at_add_time() {
let error = token_from_url("https://drive.proton.me/urls/ABC123").unwrap_err();
assert!(
error.to_string().contains("#password fragment"),
"says what is missing: {error}"
);
}
#[test]
fn a_url_that_is_not_a_share_link_is_refused() {
assert!(token_from_url("https://example.com/thing#x").is_err());
assert!(token_from_url("https://drive.proton.me/urls/#s3cr3t").is_err());
}
/// The id must be derivable from the token alone — deriving it from the URL
/// would mix a secret into the config file.
#[test]
fn the_share_id_carries_no_secret() {
let token = token_from_url("https://drive.proton.me/urls/ABC123#s3cr3t").unwrap();
let id = format!("share-{token}");
assert!(!id.contains("s3cr3t"), "id must not embed the password");
}
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "pstr-meta"
description = "Metadata provider lookups for proton-stream."
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
pstr-core.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
keyring.workspace = true
# Same feature set as the SDK's, deliberately: two reqwest configurations in one
# binary means two TLS stacks linked into it.
reqwest.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+519
View File
@@ -0,0 +1,519 @@
//! AniList, over its public GraphQL API.
//!
//! No API key and no account, which is why it is the default: turning
//! enrichment on costs the viewer a decision about privacy, and it should not
//! also cost them a signup. The trade is coverage — AniList is anime and nothing
//! else, so a library of films wants [`crate::tmdb`].
//!
//! One query shape is used for everything: a `Page` of `media` matching the
//! search string, with every name AniList knows the show by. The alias list is
//! the whole reason this works — see [`crate::matching`].
use pstr_core::library::TitleKind;
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::matching::{Candidate, Query};
use crate::provider::Provider;
const ENDPOINT: &str = "https://graphql.anilist.co";
/// How many answers to score. AniList's own relevance ordering is good, and the
/// right title is essentially always in the first handful; asking for more costs
/// them bandwidth and buys nothing.
const PAGE_SIZE: u32 = 8;
/// AniList scores out of 100 and the rest of the app works out of 10.
const SCORE_SCALE: f32 = 10.0;
const SEARCH: &str = r#"
query ($search: String, $perPage: Int) {
Page(page: 1, perPage: $perPage) {
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
id
title { romaji english native }
synonyms
description(asHtml: false)
startDate { year }
coverImage { extraLarge large }
bannerImage
averageScore
genres
episodes
format
siteUrl
}
}
}
"#;
/// Episode titles, such as AniList has them.
///
/// `streamingEpisodes` is the only place AniList keeps them — there is no
/// per-episode overview in its API at all — and the entries are what the
/// streaming sites published: `"Episode 57 - The Immortal Legion"`, in airing
/// order, occasionally with a gap. So the number is read out of the title where
/// it says one, and only falls back to the position in the list.
const EPISODES: &str = r#"
query ($id: Int) {
Media(id: $id, type: ANIME) {
episodes
streamingEpisodes { title thumbnail }
}
}
"#;
pub struct AniList {
http: reqwest::Client,
}
impl AniList {
pub fn new(http: reqwest::Client) -> Self {
Self { http }
}
}
impl Provider for AniList {
fn id(&self) -> ProviderId {
ProviderId::AniList
}
/// A sequel is its own entry here, numbering its episodes from one.
fn seasons_are_separate_entries(&self) -> bool {
true
}
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
let data: Option<SearchData> = self
.query(serde_json::json!({
"query": SEARCH,
"variables": { "search": query.name, "perPage": PAGE_SIZE },
}))
.await?;
Ok(data
.map(|data| data.page.media)
.unwrap_or_default()
.into_iter()
.map(Media::into_candidate)
.collect())
}
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
// The id came from our own search response, so a non-numeric one is a
// record written by some other version — not worth an error, and there
// is nothing to ask about.
let Ok(id) = title.remote_id.parse::<i64>() else {
return Ok(Vec::new());
};
let data: Option<MediaData> = self
.query(serde_json::json!({
"query": EPISODES,
"variables": { "id": id },
}))
.await?;
Ok(data
.and_then(|data| data.media)
.map(|media| media.into_episodes())
.unwrap_or_default())
}
}
impl AniList {
/// One GraphQL request, with AniList's two ways of failing — an HTTP status
/// and an `errors` array under a 200 — folded into one.
async fn query<T: serde::de::DeserializeOwned>(
&self,
body: serde_json::Value,
) -> Result<Option<T>> {
let response = self.http.post(ENDPOINT).json(&body).send().await?;
let status = response.status();
if !status.is_success() {
// 429 is the one worth naming: AniList rate-limits by the minute,
// and a caller that knows it was throttled can back off rather than
// cache a miss for a title that would have matched.
return Err(if status.as_u16() == 429 {
Error::RateLimited
} else {
Error::Http(format!("AniList answered {status}"))
});
}
let payload: Response<T> = response.json().await?;
if let Some(errors) = payload.errors
&& !errors.is_empty()
{
let message = errors
.into_iter()
.map(|error| error.message)
.collect::<Vec<_>>()
.join("; ");
return Err(Error::Http(format!("AniList: {message}")));
}
Ok(payload.data)
}
}
#[derive(Deserialize)]
struct Response<T> {
data: Option<T>,
errors: Option<Vec<GraphQlError>>,
}
#[derive(Deserialize)]
struct GraphQlError {
message: String,
}
#[derive(Deserialize)]
struct SearchData {
#[serde(rename = "Page")]
page: Page,
}
#[derive(Deserialize)]
struct MediaData {
#[serde(rename = "Media")]
media: Option<EpisodeMedia>,
}
#[derive(Deserialize)]
struct EpisodeMedia {
#[serde(rename = "streamingEpisodes", default)]
streaming_episodes: Vec<StreamingEpisode>,
}
#[derive(Deserialize)]
struct StreamingEpisode {
title: Option<String>,
thumbnail: Option<String>,
}
impl EpisodeMedia {
fn into_episodes(self) -> Vec<EpisodeMetadata> {
self.streaming_episodes
.into_iter()
.enumerate()
.map(|(index, episode)| {
let raw = episode.title.unwrap_or_default();
let (number, name) = split_numbering(&raw);
EpisodeMetadata {
// AniList counts straight through, with no seasons: a
// sequel is a separate entry, not a second season.
season: None,
// The position is the fallback and not the answer: the
// lists have gaps, and an off-by-one here would caption
// every episode of a show with the next one's name.
number: number.unwrap_or(index as u32 + 1),
name: name.filter(|name| !name.is_empty()),
// AniList has no per-episode synopsis to give.
overview: None,
still_url: episode.thumbnail,
air_date: None,
}
})
.collect()
}
}
/// Split `"Episode 57 - The Immortal Legion"` into its number and its name.
///
/// Both parts are optional: plenty of entries are just `"Episode 12"`, and a
/// few are only a name.
fn split_numbering(title: &str) -> (Option<u32>, Option<String>) {
let trimmed = title.trim();
let rest = trimmed
.strip_prefix("Episode ")
.or_else(|| trimmed.strip_prefix("episode "))
.or_else(|| trimmed.strip_prefix("EP"))
.or_else(|| trimmed.strip_prefix("E"));
let Some(rest) = rest else {
return (None, Some(trimmed.to_string()).filter(|t| !t.is_empty()));
};
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if digits.is_empty() {
return (None, Some(trimmed.to_string()));
}
let name = rest[digits.len()..]
.trim_start()
.trim_start_matches(['-', '–', ':'])
.trim()
.to_string();
(digits.parse().ok(), Some(name).filter(|n| !n.is_empty()))
}
#[derive(Deserialize)]
struct Page {
#[serde(default)]
media: Vec<Media>,
}
#[derive(Deserialize)]
struct Media {
id: i64,
title: Title,
#[serde(default)]
synonyms: Vec<String>,
description: Option<String>,
#[serde(rename = "startDate")]
start_date: Option<FuzzyDate>,
#[serde(rename = "coverImage")]
cover_image: Option<CoverImage>,
#[serde(rename = "bannerImage")]
banner_image: Option<String>,
#[serde(rename = "averageScore")]
average_score: Option<f32>,
#[serde(default)]
genres: Vec<String>,
episodes: Option<u32>,
format: Option<String>,
#[serde(rename = "siteUrl")]
site_url: Option<String>,
}
#[derive(Deserialize)]
struct Title {
romaji: Option<String>,
english: Option<String>,
native: Option<String>,
}
#[derive(Deserialize)]
struct FuzzyDate {
year: Option<u32>,
}
#[derive(Deserialize)]
struct CoverImage {
#[serde(rename = "extraLarge")]
extra_large: Option<String>,
large: Option<String>,
}
impl Media {
fn into_candidate(self) -> Candidate {
let aliases: Vec<String> = [
self.title.english.clone(),
self.title.romaji.clone(),
self.title.native.clone(),
]
.into_iter()
.flatten()
.chain(self.synonyms)
.collect();
// English first when there is one: it is what the viewer's library is
// most likely named after, and romaji is the fallback AniList itself
// uses.
let name = self
.title
.english
.clone()
.or_else(|| self.title.romaji.clone())
.or_else(|| self.title.native.clone())
.unwrap_or_else(|| format!("AniList #{}", self.id));
let original_name = self
.title
.native
.clone()
.or_else(|| self.title.romaji.clone())
.filter(|original| *original != name);
let cover = self
.cover_image
.and_then(|cover| cover.extra_large.or(cover.large));
Candidate {
metadata: TitleMetadata {
provider: ProviderId::AniList,
remote_id: self.id.to_string(),
name,
original_name,
overview: self.description.map(|text| strip_markup(&text)),
year: self.start_date.and_then(|date| date.year),
kind: kind_of(self.format.as_deref()),
poster_url: cover,
// Usually `null`. AniList only has banners for the better-known
// shows, which is why a tile has to cope with a poster's shape.
backdrop_url: self.banner_image,
rating: self.average_score.map(|score| score / SCORE_SCALE),
genres: self.genres,
episodes: self.episodes,
url: self.site_url,
},
aliases,
// Null on anything AniList has not seen air yet, which is not the
// same as "it is a film". See `Candidate::kind_known`.
kind_known: self.format.is_some(),
}
}
}
/// AniList's formats, reduced to the two kinds a library has.
///
/// Anything episodic is a series; a film, a one-shot special or an unknown
/// format is not. An unknown format reading as a film costs a badge, and the
/// match itself only nudges on kind — see [`crate::matching::score`].
fn kind_of(format: Option<&str>) -> TitleKind {
match format {
Some("TV" | "TV_SHORT" | "ONA" | "OVA" | "SPECIAL") => TitleKind::Series,
_ => TitleKind::Film,
}
}
/// AniList descriptions carry a little HTML even with `asHtml: false` — `<br>`
/// mostly, and the occasional `<i>`. egui draws no markup, so tags become
/// nothing and `<br>` becomes the line break it stands for.
fn strip_markup(text: &str) -> String {
let text = text
.replace("<br>", "\n")
.replace("<br/>", "\n")
.replace("<br />", "\n");
let mut out = String::with_capacity(text.len());
let mut in_tag = false;
for character in text.chars() {
match character {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => out.push(character),
_ => {}
}
}
out.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn episodic_formats_are_series_and_everything_else_is_not() {
assert_eq!(kind_of(Some("TV")), TitleKind::Series);
assert_eq!(kind_of(Some("OVA")), TitleKind::Series);
assert_eq!(kind_of(Some("MOVIE")), TitleKind::Film);
assert_eq!(kind_of(None), TitleKind::Film);
}
#[test]
fn markup_becomes_text_and_breaks_become_newlines() {
assert_eq!(
strip_markup("A <i>story</i>.<br>Then more.<br />And more."),
"A story.\nThen more.\nAnd more."
);
assert_eq!(strip_markup(" plain "), "plain");
}
/// The alias list is what makes a romaji-named library match an
/// English-named entry, so it has to hold every name AniList gave.
#[test]
fn a_candidate_carries_every_name_the_show_is_known_by() {
let media: Media = serde_json::from_str(
r#"{
"id": 16498,
"title": {"romaji": "Shingeki no Kyojin", "english": "Attack on Titan",
"native": "進撃の巨人"},
"synonyms": ["AoT"],
"description": "Several hundred years ago…",
"startDate": {"year": 2013},
"coverImage": {"extraLarge": "big.jpg", "large": "small.jpg"},
"bannerImage": null,
"averageScore": 84,
"genres": ["Action"],
"episodes": 25,
"format": "TV",
"siteUrl": "https://anilist.co/anime/16498"
}"#,
)
.expect("parse");
let candidate = media.into_candidate();
assert_eq!(candidate.metadata.name, "Attack on Titan");
assert_eq!(
candidate.metadata.original_name.as_deref(),
Some("進撃の巨人")
);
assert!(
candidate
.aliases
.contains(&"Shingeki no Kyojin".to_string())
);
assert!(candidate.aliases.contains(&"AoT".to_string()));
// Out of 10, not out of 100.
assert_eq!(candidate.metadata.rating, Some(8.4));
assert_eq!(candidate.metadata.poster_url.as_deref(), Some("big.jpg"));
}
#[test]
fn an_episode_title_gives_up_its_number_and_its_name() {
assert_eq!(
split_numbering("Episode 57 - The Immortal Legion"),
(Some(57), Some("The Immortal Legion".to_string()))
);
assert_eq!(split_numbering("Episode 12"), (Some(12), None));
// Some entries are only a name, and some sites write it differently.
assert_eq!(
split_numbering("The Day of the Beginning"),
(None, Some("The Day of the Beginning".to_string()))
);
assert_eq!(
split_numbering("E3 – Whatever"),
(Some(3), Some("Whatever".to_string()))
);
assert_eq!(split_numbering(""), (None, None));
}
#[test]
fn episode_numbers_come_from_the_titles_and_not_from_the_order() {
// AniList lists what streaming sites published, and those lists have
// gaps. Numbering by position would caption episode 4 as episode 3.
let media: EpisodeMedia = serde_json::from_str(
r#"{"streamingEpisodes": [
{"title": "Episode 1 - Fullmetal Alchemist", "thumbnail": "one.jpg"},
{"title": "Episode 3 - City of Heresy", "thumbnail": null}
]}"#,
)
.expect("parse");
let episodes = media.into_episodes();
assert_eq!(episodes[0].number, 1);
assert_eq!(episodes[0].name.as_deref(), Some("Fullmetal Alchemist"));
assert_eq!(episodes[0].still_url.as_deref(), Some("one.jpg"));
assert_eq!(episodes[1].number, 3);
// AniList counts straight through; nothing here is a season.
assert!(episodes.iter().all(|episode| episode.season.is_none()));
}
#[test]
fn an_untitled_list_still_numbers_its_episodes() {
let media: EpisodeMedia = serde_json::from_str(
r#"{"streamingEpisodes": [{"title": null, "thumbnail": null},
{"title": "", "thumbnail": null}]}"#,
)
.expect("parse");
let episodes = media.into_episodes();
assert_eq!(episodes[0].number, 1);
assert_eq!(episodes[1].number, 2);
assert!(episodes.iter().all(|episode| episode.name.is_none()));
}
/// Missing fields are the normal case for a recently added entry, and must
/// not fail the whole page of results.
#[test]
fn an_entry_with_almost_nothing_in_it_still_parses() {
let media: Media = serde_json::from_str(
r#"{"id": 1, "title": {"romaji": null, "english": null, "native": null}}"#,
)
.expect("parse");
let candidate = media.into_candidate();
assert_eq!(candidate.metadata.name, "AniList #1");
assert!(candidate.aliases.is_empty());
}
}
+39
View File
@@ -0,0 +1,39 @@
//! One error type for looking a title up.
use pstr_core::metadata::ProviderId;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("network: {0}")]
Network(#[from] reqwest::Error),
/// The provider answered, and the answer was not one.
#[error("{0}")]
Http(String),
/// The provider is throttling.
///
/// Its own variant because the caller must treat it differently from every
/// other failure: a rate-limited lookup has to be retried later, never
/// cached as "this title has no match". Caching a throttle would blank a
/// title for days over a burst that lasted a minute.
#[error("the provider is rate-limiting; try again shortly")]
RateLimited,
/// The chosen provider needs an API key and none is stored.
#[error("{} needs an API key", .0.label())]
MissingApiKey(ProviderId),
/// Enrichment is off. Not a failure — the answer to "look this up" when the
/// viewer has not agreed to lookups is that there is nothing to do.
#[error("metadata lookups are turned off")]
Disabled,
#[error("config: {0}")]
Config(String),
#[error(transparent)]
Core(#[from] pstr_core::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
+41
View File
@@ -0,0 +1,41 @@
//! Matching a library's titles against a metadata provider.
//!
//! What this crate is for is the gap between what a filename says and what a
//! poster wall wants: a share filled by the Proton Drive Linux client carries no
//! thumbnails at all — Proton renders those at upload time and that client
//! attaches none — so without enrichment every tile in the grid is a pair of
//! initials on a grey rectangle. See `docs/DEVELOPMENT.md`.
//!
//! ```text
//! Title ──▶ Query ──▶ Provider::search ──▶ [Candidate] ──▶ matching::best
//! (anilist | tmdb) │
//! ▼
//! TitleMetadata
//! ```
//!
//! Three things are worth knowing before touching any of it:
//!
//! * **It is off by default and says why.** Enrichment sends the titles in
//! someone's library to a third party. The switch is theirs, the reasoning is
//! in `pstr_core::metadata`, and nothing here runs until it is on.
//! * **A wrong match is worse than no match.** The wrong poster does not look
//! like a bug, it looks like the library is wrong — so
//! [`matching::MATCH_FLOOR`] is high and a weak field yields nothing.
//! * **Misses are cached and failures are not.** See [`service`].
//!
//! The model — [`pstr_core::metadata::TitleMetadata`] and friends — deliberately
//! lives in `pstr-core`, so the catalog can store it and the UI can draw it
//! without either depending on this crate.
pub mod anilist;
pub mod error;
pub mod matching;
pub mod provider;
pub mod service;
pub mod settings;
pub mod tmdb;
pub use error::{Error, Result};
pub use matching::{Candidate, Query};
pub use provider::Provider;
pub use service::MetadataService;
+372
View File
@@ -0,0 +1,372 @@
//! Deciding which of a provider's answers is the title you actually have.
//!
//! A release-named library does not hand you clean titles. `[SubsGroup] Shingeki
//! no Kyojin S3` and `Attack on Titan (2013)` are the same show, and the
//! provider knows it under four names in three scripts. So matching is scoring,
//! not lookup, and the two things that make it work are:
//!
//! * **Every alias counts.** A candidate is scored on its best name, not its
//! canonical one. AniList's romaji, English, native and synonym lists exist
//! precisely because none of them is *the* title.
//! * **A bad match is worse than none.** Putting the poster of a different show
//! on a title is a mistake nobody can see is a mistake — it just looks like
//! the library is wrong. So the floor is high, and anything under it is a
//! cached miss rather than the best of a bad set.
//!
//! The similarity itself is Sørensen–Dice over character bigrams. It is not the
//! most sophisticated measure available, but it has the properties that matter
//! here: it is order-insensitive enough to survive `Bebop, Cowboy`, length-aware
//! enough not to match `Gintama` to `Gintama°: Enchousen`, and it needs no
//! tuning.
use pstr_core::library::{TitleKind, title_key};
use pstr_core::metadata::TitleMetadata;
/// A candidate's score has to clear this to be used.
///
/// Chosen from what the failure modes cost rather than from a curve: at 0.72 a
/// missing subtitle or a swapped season number still matches, while two
/// different shows sharing a franchise name — the common near-miss in an anime
/// library — do not. See the tests, which pin both sides.
pub const MATCH_FLOOR: f32 = 0.72;
/// The title being looked up.
#[derive(Debug, Clone)]
pub struct Query {
/// The title as the library has it, already stripped of release tags by
/// [`pstr_core::naming`].
pub name: String,
pub year: Option<u32>,
pub kind: TitleKind,
}
impl Query {
pub fn new(name: impl Into<String>, year: Option<u32>, kind: TitleKind) -> Self {
Self {
name: name.into(),
year,
kind,
}
}
}
/// One of a provider's answers, with every name it is known by.
#[derive(Debug, Clone)]
pub struct Candidate {
pub metadata: TitleMetadata,
/// Romaji, English, native, synonyms — whatever the provider had. The
/// canonical name is included by the providers that build these.
pub aliases: Vec<String>,
/// Whether the provider actually stated what kind of thing this is.
///
/// [`TitleMetadata::kind`] has no "unknown", because a badge has to say
/// something — but scoring must not read that fallback as a fact. AniList
/// leaves `format` null on anything unaired, and treating that as "film"
/// then *penalising* it against a series is how an announced-but-unreleased
/// entry loses to a worse match. Absence of evidence, not evidence.
pub kind_known: bool,
}
/// How well `candidate` answers `query`, from 0 to 1.
pub fn score(query: &Query, candidate: &Candidate) -> f32 {
let wanted = title_key(&query.name);
if wanted.is_empty() {
return 0.0;
}
let best = candidate
.aliases
.iter()
.map(|alias| similarity(&wanted, &title_key(alias)))
.fold(0.0_f32, f32::max);
// Adjustments, not tie-breakers: they move a score across the floor only
// when it was already close to it.
let mut score = best;
match (query.year, candidate.metadata.year) {
// Same year is strong evidence for two titles that already read alike.
(Some(wanted), Some(found)) if wanted == found => score += 0.06,
// A year apart is normal — a season airing across New Year, a provider
// dating by première rather than release. Further apart is not.
(Some(wanted), Some(found)) if wanted.abs_diff(found) > 1 => score -= 0.15,
_ => {}
}
if candidate.kind_known && query.kind != candidate.metadata.kind {
// A film and a series of the same name are usually genuinely related —
// an adaptation, a compilation — so this is a nudge, not a veto. And
// only when the provider said; see `Candidate::kind_known`.
score -= 0.05;
}
score.clamp(0.0, 1.0)
}
/// The best answer above [`MATCH_FLOOR`], if there is one.
///
/// Ties go to whichever the provider listed first, and that is load-bearing
/// rather than arbitrary. Franchises share synonyms — every *Evangelion* entry
/// on AniList carries `EVANGELION:30+;`, so a library folder named plainly
/// `Evangelion` scores several of them identically — and when the text cannot
/// separate them, the provider's own relevance ordering is the only evidence
/// left. Taking the last one instead, which is what `Iterator::max_by` does,
/// picks the *least* relevant of the tied set: it is how a folder of the 1995
/// series came back matched to a 2026 anniversary screening.
pub fn best(query: &Query, candidates: Vec<Candidate>) -> Option<TitleMetadata> {
let mut best: Option<(TitleMetadata, f32)> = None;
for candidate in candidates {
let score = score(query, &candidate);
// Strictly greater: an equal score leaves the earlier candidate in
// place, which is what keeps the provider's ordering as the tie-break.
if best.as_ref().is_none_or(|(_, best)| score > *best) {
best = Some((candidate.metadata, score));
}
}
let (metadata, score) = best?;
if score < MATCH_FLOOR {
tracing::debug!(
"no match for {:?}: best candidate scored {score:.2}, floor is {MATCH_FLOOR}",
query.name
);
return None;
}
Some(metadata)
}
/// Sørensen–Dice over character bigrams, on already-normalised strings.
///
/// Identical strings are 1.0 and a one-character string is compared directly,
/// since it has no bigrams to compare.
fn similarity(left: &str, right: &str) -> f32 {
if left == right {
return 1.0;
}
if left.is_empty() || right.is_empty() {
return 0.0;
}
let left: Vec<char> = left.chars().collect();
let right: Vec<char> = right.chars().collect();
if left.len() < 2 || right.len() < 2 {
return 0.0;
}
// Multiset intersection: a bigram appearing twice on the left and once on
// the right counts once, which is what keeps `aaaa` from matching `aa`.
let mut theirs: Vec<[char; 2]> = right.windows(2).map(|pair| [pair[0], pair[1]]).collect();
let mut shared = 0usize;
for pair in left.windows(2) {
let pair = [pair[0], pair[1]];
if let Some(position) = theirs.iter().position(|other| *other == pair) {
theirs.swap_remove(position);
shared += 1;
}
}
let total = (left.len() - 1) + (right.len() - 1);
(2.0 * shared as f32) / total as f32
}
#[cfg(test)]
mod tests {
use super::*;
use pstr_core::metadata::ProviderId;
fn candidate(name: &str, aliases: &[&str], year: Option<u32>, kind: TitleKind) -> Candidate {
Candidate {
metadata: TitleMetadata {
provider: ProviderId::AniList,
remote_id: "1".into(),
name: name.into(),
original_name: None,
overview: None,
year,
kind,
poster_url: None,
backdrop_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
},
aliases: std::iter::once(name)
.chain(aliases.iter().copied())
.map(str::to_string)
.collect(),
kind_known: true,
}
}
#[test]
fn an_identical_title_scores_perfectly() {
let query = Query::new("Cowboy Bebop", None, TitleKind::Series);
let candidate = candidate("Cowboy Bebop", &[], None, TitleKind::Series);
assert!(score(&query, &candidate) >= 1.0);
}
/// The case the alias list exists for: the library has the romaji name and
/// the provider's canonical name is the English one, or the reverse.
#[test]
fn a_match_on_an_alias_is_as_good_as_one_on_the_name() {
let query = Query::new("Shingeki no Kyojin", None, TitleKind::Series);
let candidate = candidate(
"Attack on Titan",
&["Shingeki no Kyojin"],
Some(2013),
TitleKind::Series,
);
assert!(score(&query, &candidate) >= MATCH_FLOOR);
}
/// Punctuation and case are the parser's business, not the matcher's — and
/// `title_key` has already removed them.
#[test]
fn punctuation_and_case_do_not_affect_the_score() {
let query = Query::new("FULLMETAL ALCHEMIST: BROTHERHOOD", None, TitleKind::Series);
let candidate = candidate(
"Fullmetal Alchemist: Brotherhood",
&[],
None,
TitleKind::Series,
);
assert!(score(&query, &candidate) >= 1.0);
}
/// The mistake worth preventing: two entries in one franchise, where the
/// wrong poster looks like a working feature rather than a bug.
#[test]
fn a_different_entry_in_the_same_franchise_does_not_match() {
let query = Query::new("Gintama", None, TitleKind::Series);
let candidate = candidate("Gintama°: Enchousen", &[], None, TitleKind::Series);
assert!(
score(&query, &candidate) < MATCH_FLOOR,
"scored {}",
score(&query, &candidate)
);
}
#[test]
fn a_wholly_unrelated_title_does_not_match() {
let query = Query::new("Cowboy Bebop", None, TitleKind::Series);
let candidate = candidate("Neon Genesis Evangelion", &[], None, TitleKind::Series);
assert!(score(&query, &candidate) < MATCH_FLOOR);
}
/// A decade apart is a different work with a reused name — a remake, or a
/// coincidence. One year apart is the same work dated differently.
#[test]
fn a_distant_year_is_penalised_and_a_near_one_is_not() {
let query = Query::new("Bebop", Some(1998), TitleKind::Series);
let near = candidate("Bebop", &[], Some(1999), TitleKind::Series);
let far = candidate("Bebop", &[], Some(2021), TitleKind::Series);
assert!(score(&query, &near) > score(&query, &far));
assert!(score(&query, &near) >= MATCH_FLOOR);
}
#[test]
fn the_best_candidate_wins_and_a_weak_field_yields_nothing() {
let query = Query::new("Cowboy Bebop", Some(1998), TitleKind::Series);
let found = best(
&query,
vec![
candidate(
"Neon Genesis Evangelion",
&[],
Some(1995),
TitleKind::Series,
),
candidate("Cowboy Bebop", &[], Some(1998), TitleKind::Series),
],
);
assert_eq!(found.map(|found| found.name), Some("Cowboy Bebop".into()));
assert!(
best(
&query,
vec![candidate(
"Serial Experiments Lain",
&[],
None,
TitleKind::Series
)]
)
.is_none()
);
assert!(best(&query, Vec::new()).is_none());
}
/// Repeated bigrams must not inflate a score: without multiset counting,
/// a short repetitive name matches a long repetitive one perfectly.
#[test]
fn repeated_bigrams_are_counted_once_each() {
assert!(similarity("aaaa", "aa") < 1.0);
}
/// Measured against the real thing: searching AniList for `Evangelion`
/// returns several entries that all carry the synonym `EVANGELION:30+;`,
/// so they score identically and nothing in the text can separate them.
/// The provider's own ordering is then the only evidence there is, and
/// taking the last of the tied set — which is what `max_by` does — picked
/// a 2026 anniversary screening over the series the folder was named for.
#[test]
fn tied_candidates_are_settled_by_the_providers_own_ordering() {
let query = Query::new("Evangelion", None, TitleKind::Series);
let tied = vec![
candidate(
"Evangelion (Shinsaku Series)",
&["EVANGELION:30+;"],
None,
TitleKind::Series,
),
candidate(
"Evangelion 30th Anniversary Special Screening",
&["EVANGELION:30+;"],
Some(2026),
TitleKind::Series,
),
];
// Both clear the floor on the shared synonym; the first one listed wins.
assert!(score(&query, &tied[0]) >= MATCH_FLOOR);
assert_eq!(
best(&query, tied).map(|found| found.name),
Some("Evangelion (Shinsaku Series)".into())
);
}
/// The other half of the Evangelion mismatch, and the more subtle half.
///
/// AniList leaves `format` null on anything unaired. [`TitleMetadata::kind`]
/// has to say *something*, so it falls back to `Film` — and scoring must not
/// then read that fallback as the provider having said "film" and dock the
/// candidate for it. Doing so cost the top-ranked entry 0.05 and handed the
/// match to a 2026 anniversary screening that happened to have a format.
#[test]
fn an_unstated_kind_is_not_penalised_the_way_a_wrong_one_is() {
let query = Query::new("Evangelion", None, TitleKind::Series);
let mut unstated = candidate("Evangelion", &[], None, TitleKind::Film);
unstated.kind_known = false;
let stated = candidate("Evangelion", &[], None, TitleKind::Film);
assert!(score(&query, &unstated) > score(&query, &stated));
assert_eq!(score(&query, &unstated), 1.0);
}
/// The tie-break must not override the score: a later candidate that
/// genuinely matches better still wins.
#[test]
fn a_better_later_candidate_still_beats_an_earlier_weaker_one() {
let query = Query::new("Cowboy Bebop", None, TitleKind::Series);
let found = best(
&query,
vec![
candidate("Cowboy Bebop: The Movie", &[], None, TitleKind::Film),
candidate("Cowboy Bebop", &[], None, TitleKind::Series),
],
);
assert_eq!(found.map(|found| found.name), Some("Cowboy Bebop".into()));
}
}
+52
View File
@@ -0,0 +1,52 @@
//! What every metadata source has to be able to do.
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
use crate::error::Result;
use crate::matching::{Candidate, Query};
/// A source of titles.
///
/// Deliberately narrow: search, and the episode list of something search
/// already matched. Both are requests to a third party, and neither happens
/// until the viewer has turned enrichment on — but note what the second one
/// does *not* need: it takes the id search returned, so it sends the provider
/// nothing about the library it did not already learn from the match.
///
/// Not `#[async_trait]`: this is only ever used through a concrete type or an
/// enum, never as `dyn Provider`, so the native `async fn` costs nothing here.
pub trait Provider: Send + Sync {
fn id(&self) -> ProviderId;
/// Whether a sequel is a *separate entry* rather than a second season of
/// the same one.
///
/// AniList works that way — `Oshi no Ko`, `Oshi no Ko 2nd Season` and
/// `Oshi no Ko 3rd Season` are three ids, each numbering its episodes from
/// one — so a library that files all three under one title has to search
/// again per season or seasons two upwards get no episode names at all (and,
/// worse, would be answered with season one's). TMDB files them as seasons
/// of one show and its episode list already carries season numbers, so
/// searching per season there would only find the wrong entry.
fn seasons_are_separate_entries(&self) -> bool;
/// Everything the provider thinks `query` might be, in its own order.
///
/// Scoring is not the provider's job — it returns candidates and
/// [`crate::matching::best`] decides. That is what keeps the "wrong poster"
/// threshold in one place rather than one per provider.
fn search(
&self,
query: &Query,
) -> impl std::future::Future<Output = Result<Vec<Candidate>>> + Send;
/// Every episode the provider lists for a title it already matched.
///
/// An empty list is a real answer — a film has no episodes, and plenty of
/// series entries have none listed — and the caller caches it rather than
/// asking again on the next render.
fn episodes(
&self,
title: &TitleMetadata,
) -> impl std::future::Future<Output = Result<Vec<EpisodeMetadata>>> + Send;
}
+347
View File
@@ -0,0 +1,347 @@
//! Looking a title up, once, and remembering the answer.
//!
//! ```text
//! Title ──▶ catalog cache ──hit──▶ MetadataRecord
//! │ miss / stale
//! ▼
//! provider.search ──▶ matching::best ──▶ store ──▶ MetadataRecord
//! ```
//!
//! Two rules govern what gets written back, and they are the difference between
//! a library that settles down and one that hammers a third party forever:
//!
//! * **A miss is stored.** "The provider looked and had nothing" is an answer.
//! Without storing it, every render of the grid re-asks for every unmatched
//! title — the trap `proton-drive-linux`'s photo grid hit.
//! * **A failure is not.** A timeout, a 500, a rate-limit or a missing API key
//! are failures to *ask*. Storing them as misses would blank a title for days
//! over a minute of trouble, so they propagate and leave the title askable.
//!
//! Nothing here is requested unless [`MetadataConfig::enabled`] is set. See the
//! note in `pstr_core::metadata` on why that is off by default.
use std::sync::Arc;
use pstr_core::library::{Title, title_key};
use pstr_core::metadata::{
EpisodeMetadata, MetadataConfig, MetadataRecord, ProviderId, TitleMetadata,
};
use crate::anilist::AniList;
use crate::error::{Error, Result};
use crate::matching::{self, Query};
use crate::provider::Provider;
use crate::tmdb::Tmdb;
/// How long to wait on a provider.
///
/// Short: this is decoration. A poster that takes fifteen seconds to arrive has
/// already lost its argument with the placeholder that is on screen.
const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Identifies this client to the providers, which both ask for it.
const USER_AGENT: &str = concat!("proton-stream/", env!("CARGO_PKG_VERSION"));
/// The configured provider, resolved.
enum Source {
AniList(AniList),
Tmdb(Tmdb),
}
impl Source {
fn id(&self) -> ProviderId {
match self {
Self::AniList(provider) => provider.id(),
Self::Tmdb(provider) => provider.id(),
}
}
async fn search(&self, query: &Query) -> Result<Vec<matching::Candidate>> {
match self {
Self::AniList(provider) => provider.search(query).await,
Self::Tmdb(provider) => provider.search(query).await,
}
}
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
match self {
Self::AniList(provider) => provider.episodes(title).await,
Self::Tmdb(provider) => provider.episodes(title).await,
}
}
fn seasons_are_separate_entries(&self) -> bool {
match self {
Self::AniList(provider) => provider.seasons_are_separate_entries(),
Self::Tmdb(provider) => provider.seasons_are_separate_entries(),
}
}
}
/// Metadata lookups, against one provider.
///
/// Cheap to clone — the HTTP client inside is a connection pool that wants
/// sharing, not duplicating.
#[derive(Clone)]
pub struct MetadataService {
source: Arc<Source>,
http: reqwest::Client,
}
impl MetadataService {
/// Build a service for `config`.
///
/// `api_key` is only consulted for a provider that needs one; for AniList it
/// is ignored, and passing `None` there is not an error.
pub fn new(config: &MetadataConfig, api_key: Option<String>) -> Result<Self> {
let http = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(TIMEOUT)
.build()
.map_err(Error::Network)?;
let source = match config.provider {
ProviderId::AniList => Source::AniList(AniList::new(http.clone())),
ProviderId::Tmdb => Source::Tmdb(Tmdb::new(
http.clone(),
api_key.ok_or(Error::MissingApiKey(ProviderId::Tmdb))?,
config.language.clone(),
)),
};
Ok(Self {
source: Arc::new(source),
http,
})
}
pub fn provider(&self) -> ProviderId {
self.source.id()
}
/// Ask the provider about one title.
///
/// `Ok(None)` is a real answer — nothing matched well enough — and the
/// caller should store it. An `Err` is not, and the caller should not.
pub async fn lookup(&self, title: &Title) -> Result<Option<TitleMetadata>> {
let query = Query::new(title.name.clone(), title.year, title.kind);
let candidates = self.source.search(&query).await?;
let found = candidates.len();
let best = matching::best(&query, candidates);
tracing::debug!(
"{}: {found} candidates from {}, {}",
title.name,
self.provider().label(),
match &best {
Some(found) => format!("matched {:?}", found.name),
None => "no match".to_string(),
}
);
Ok(best)
}
/// The same lookup, as a record ready to store — misses included.
pub async fn record(&self, title: &Title) -> Result<MetadataRecord> {
let metadata = self.lookup(title).await?;
Ok(MetadataRecord {
title_key: title_key(&title.name),
provider: self.provider(),
metadata,
fetched_at: now(),
})
}
/// What the provider lists as the episodes of a title it matched.
///
/// Only ever called for a title that already has a match, because the
/// provider's own id is what it takes — there is no second search here, and
/// nothing about the library goes out that the match did not already send.
/// An empty list is a real answer and belongs in the catalog: a film has no
/// episodes, and asking again per render is the trap `MetadataRecord`
/// exists to avoid.
pub async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
let episodes = self.source.episodes(title).await?;
tracing::debug!(
"{}: {} episodes from {}",
title.name,
episodes.len(),
self.provider().label()
);
Ok(episodes)
}
/// Whether this provider files a sequel as its own entry, so that a title
/// with several seasons has to be searched for once per season.
pub fn splits_seasons(&self) -> bool {
self.source.seasons_are_separate_entries()
}
/// The episodes of one season of `title`, for a provider that files each
/// season separately.
///
/// A second search, not a second guess: the title's own match is season
/// one's entry, and asking it about episode one of season two would answer
/// with episode one of season one. So the season is searched for by the
/// name the provider itself uses — `Oshi no Ko 2nd Season`, and `Oshi no Ko
/// Season 2` when that finds nothing, which are the two conventions between
/// them covering everything AniList carries.
///
/// A miss is an empty list rather than an error: a season the provider has
/// never heard of is ordinary, and it leaves those rows named by their
/// filenames exactly as they were before.
pub async fn season_episodes(
&self,
title: &Title,
season: u32,
) -> Result<Vec<EpisodeMetadata>> {
if !self.splits_seasons() {
return Ok(Vec::new());
}
let mut found = None;
for name in season_names(&title.name, season) {
// No year: a sequel airs years after the series the library named
// the folder for, so carrying the title's year over would penalise
// the very entry being looked for.
let query = Query::new(name, None, title.kind);
let candidates = self.source.search(&query).await?;
if let Some(matched) = matching::best(&query, candidates) {
found = Some(matched);
break;
}
}
let Some(found) = found else {
tracing::debug!("{}: no entry for season {season}", title.name);
return Ok(Vec::new());
};
let episodes = self.source.episodes(&found).await?;
tracing::debug!(
"{} season {season}: {} episodes from {:?}",
title.name,
episodes.len(),
found.name
);
// The entry numbers its own episodes from one; what makes them season
// two's is which entry they came from, and that has to be recorded here
// or nothing can look them up again.
Ok(episodes
.into_iter()
.map(|episode| EpisodeMetadata {
season: Some(season),
..episode
})
.collect())
}
/// Download one piece of artwork.
///
/// Separate from the lookup because artwork is fetched from a CDN on a
/// different schedule to the metadata that names it — a cached record still
/// needs its poster on a machine that has never had one.
pub async fn artwork(&self, url: &str) -> Result<Vec<u8>> {
let response = self.http.get(url).send().await?;
let status = response.status();
if !status.is_success() {
return Err(Error::Http(format!("artwork answered {status}")));
}
Ok(response.bytes().await?.to_vec())
}
}
/// What to search for when looking for one season of a series, best first.
///
/// `2nd Season` is AniList's own convention and matches its romaji titles
/// outright; `Season 2` is what its English titles and most western libraries
/// use. Season one is never searched for this way — that is the title's own
/// match.
fn season_names(title: &str, season: u32) -> Vec<String> {
vec![
format!("{title} {} Season", ordinal(season)),
format!("{title} Season {season}"),
]
}
/// `2` → `2nd`. English ordinals, because that is what the provider writes.
fn ordinal(number: u32) -> String {
let suffix = match (number % 10, number % 100) {
(_, 11..=13) => "th",
(1, _) => "st",
(2, _) => "nd",
(3, _) => "rd",
_ => "th",
};
format!("{number}{suffix}")
}
/// Whether a stored answer can be used as-is.
pub fn is_usable(record: Option<&MetadataRecord>, provider: ProviderId) -> bool {
record.is_some_and(|record| record.is_fresh(now(), provider))
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anilist_needs_no_api_key_and_tmdb_does() {
let anilist = MetadataConfig {
enabled: true,
provider: ProviderId::AniList,
..MetadataConfig::default()
};
assert!(MetadataService::new(&anilist, None).is_ok());
let tmdb = MetadataConfig {
provider: ProviderId::Tmdb,
..anilist
};
assert!(matches!(
MetadataService::new(&tmdb, None),
Err(Error::MissingApiKey(ProviderId::Tmdb))
));
assert!(MetadataService::new(&tmdb, Some("key".into())).is_ok());
}
/// The names a season is searched for by, in the order they are tried.
#[test]
fn a_season_is_searched_for_the_way_the_provider_names_it() {
assert_eq!(
season_names("Oshi no Ko", 2),
vec!["Oshi no Ko 2nd Season", "Oshi no Ko Season 2"]
);
assert_eq!(ordinal(1), "1st");
assert_eq!(ordinal(3), "3rd");
assert_eq!(ordinal(4), "4th");
// The teens are the ones a naive rule gets wrong, and a show does reach
// them: `11th` is not `11st`.
assert_eq!(ordinal(11), "11th");
assert_eq!(ordinal(12), "12th");
assert_eq!(ordinal(13), "13th");
assert_eq!(ordinal(21), "21st");
}
/// A record from the other provider is never usable — switching provider has
/// to re-ask, including about titles the previous one found nothing for.
#[test]
fn a_stored_answer_is_only_usable_for_the_provider_that_gave_it() {
let record = MetadataRecord {
title_key: "cowboy bebop".into(),
provider: ProviderId::AniList,
metadata: None,
fetched_at: now(),
};
assert!(is_usable(Some(&record), ProviderId::AniList));
assert!(!is_usable(Some(&record), ProviderId::Tmdb));
assert!(!is_usable(None, ProviderId::AniList));
}
}
+140
View File
@@ -0,0 +1,140 @@
//! Reading and writing the enrichment settings, and the API key.
//!
//! The settings file sits beside `shares.json` and is written the same way —
//! atomically, and never overwritten when it will not parse. The API key does
//! *not* go in it: it is a credential, it belongs in the OS credential store,
//! and the same rule that keeps share fragments out of the config keeps this out
//! too.
use pstr_core::config::{AppDirs, read_json, write_json};
use pstr_core::metadata::{MetadataConfig, ProviderId};
use crate::error::{Error, Result};
/// The credential-store service name, shared with [`pstr_core::shares`]. Keys
/// are namespaced by provider, so two providers' keys can coexist and switching
/// back does not mean typing one in again.
const KEYRING_SERVICE: &str = "proton-stream";
fn settings_file(dirs: &AppDirs) -> std::path::PathBuf {
dirs.config.join("metadata.json")
}
fn keyring_entry(provider: ProviderId) -> Result<keyring::Entry> {
keyring::Entry::new(KEYRING_SERVICE, &format!("metadata-{}", provider.as_str()))
.map_err(|error| Error::Config(format!("open the credential store: {error}")))
}
/// The stored settings, or the defaults on a first run.
pub fn load(dirs: &AppDirs) -> Result<MetadataConfig> {
Ok(read_json(&settings_file(dirs))?.unwrap_or_default())
}
/// Write the settings.
pub fn save(dirs: &AppDirs, config: &MetadataConfig) -> Result<()> {
write_json(&settings_file(dirs), config)?;
Ok(())
}
/// The stored API key for a provider, if there is one.
///
/// A credential store that is locked or absent is reported as *no key* rather
/// than as an error: the caller's next move is the same either way — tell the
/// viewer the provider needs a key — and a keyring failure phrased as one is a
/// worse message than that.
pub fn api_key(provider: ProviderId) -> Option<String> {
if !provider.needs_api_key() {
return None;
}
match keyring_entry(provider).and_then(|entry| {
entry
.get_password()
.map_err(|error| Error::Config(error.to_string()))
}) {
Ok(key) => Some(key),
Err(error) => {
tracing::debug!("no stored API key for {}: {error}", provider.label());
None
}
}
}
/// Store an API key, or forget it when `key` is empty.
pub fn set_api_key(provider: ProviderId, key: &str) -> Result<()> {
let entry = keyring_entry(provider)?;
let result = if key.trim().is_empty() {
match entry.delete_credential() {
// Clearing a key that was never set is what the viewer asked for.
Err(keyring::Error::NoEntry) => Ok(()),
other => other,
}
} else {
entry.set_password(key)
};
result.map_err(|error| Error::Config(format!("store the API key: {error}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_dirs(tag: &str) -> AppDirs {
let root = std::env::temp_dir().join(format!(
"pstr-meta-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create temp dir");
AppDirs {
config: root.clone(),
data: root.clone(),
cache: root,
}
}
#[test]
fn settings_round_trip_and_default_to_off() {
let dirs = temp_dirs("settings");
assert_eq!(load(&dirs).expect("load"), MetadataConfig::default());
assert!(!load(&dirs).expect("load").enabled);
let config = MetadataConfig {
enabled: true,
provider: ProviderId::Tmdb,
language: "de".into(),
};
save(&dirs, &config).expect("save");
assert_eq!(load(&dirs).expect("load"), config);
std::fs::remove_dir_all(&dirs.config).ok();
}
/// The key is a credential and must not be in the settings file, however
/// convenient that would be.
#[test]
fn the_settings_file_never_contains_an_api_key() {
let dirs = temp_dirs("nokey");
save(
&dirs,
&MetadataConfig {
enabled: true,
provider: ProviderId::Tmdb,
language: "en".into(),
},
)
.expect("save");
let written = std::fs::read_to_string(settings_file(&dirs)).expect("read");
assert!(!written.contains("key"), "{written}");
std::fs::remove_dir_all(&dirs.config).ok();
}
/// AniList has no key to look up, and asking for one must not reach the
/// credential store at all.
#[test]
fn a_provider_that_needs_no_key_never_has_one() {
assert_eq!(api_key(ProviderId::AniList), None);
}
}
+436
View File
@@ -0,0 +1,436 @@
//! The Movie Database, over its v3 REST API.
//!
//! Film and television, which is the coverage AniList does not have. The cost is
//! an API key: TMDB's are free but they require an account, so this is not the
//! default and the UI has somewhere to put the key before it can be chosen.
//!
//! `search/multi` rather than `search/tv` or `search/movie`: a library holds both
//! and the parser's guess at which one a title is comes from a filename, which is
//! not evidence enough to pick an endpoint. The `media_type` on each result is
//! better evidence than the guess, and [`crate::matching`] only nudges on kind
//! anyway.
use pstr_core::library::TitleKind;
use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata};
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::matching::{Candidate, Query};
use crate::provider::Provider;
const ENDPOINT: &str = "https://api.themoviedb.org/3/search/multi";
const API_BASE: &str = "https://api.themoviedb.org/3";
/// How many seasons of episodes to ask for in one go.
///
/// TMDB's `append_to_response` takes at most twenty sub-requests, which is more
/// seasons than anything in a library actually has; a show past that gets its
/// first twenty and no error.
const MAX_APPENDED_SEASONS: usize = 20;
/// A frame from an episode. `w300` is what the row draws it at, near enough.
const STILL_SIZE: &str = "w300";
/// Where TMDB serves artwork from, and the sizes worth asking for.
///
/// `w500` for a poster and `w780` for a backdrop are what the images are drawn
/// at here, give or take; `original` is several megabytes of print-resolution
/// artwork per title, which is bandwidth spent on pixels no tile can show.
const IMAGE_BASE: &str = "https://image.tmdb.org/t/p";
const POSTER_SIZE: &str = "w500";
const BACKDROP_SIZE: &str = "w780";
pub struct Tmdb {
http: reqwest::Client,
api_key: String,
language: String,
}
impl Tmdb {
pub fn new(http: reqwest::Client, api_key: String, language: String) -> Self {
Self {
http,
api_key,
language,
}
}
}
impl Provider for Tmdb {
fn id(&self) -> ProviderId {
ProviderId::Tmdb
}
/// One show, every season under it — and [`Self::episodes`] already returns
/// them with their season numbers.
fn seasons_are_separate_entries(&self) -> bool {
false
}
async fn search(&self, query: &Query) -> Result<Vec<Candidate>> {
if self.api_key.trim().is_empty() {
return Err(Error::MissingApiKey(ProviderId::Tmdb));
}
let response = self
.http
.get(ENDPOINT)
.query(&[
("api_key", self.api_key.as_str()),
("query", query.name.as_str()),
("language", self.language.as_str()),
("include_adult", "false"),
])
.send()
.await?;
let status = response.status();
if !status.is_success() {
return Err(match status.as_u16() {
401 => Error::MissingApiKey(ProviderId::Tmdb),
429 => Error::RateLimited,
_ => Error::Http(format!("TMDB answered {status}")),
});
}
let payload: SearchResponse = response.json().await?;
Ok(payload
.results
.into_iter()
// `search/multi` also returns people, who have neither a poster of a
// show nor a year — and whose `name` would match a title named after
// its lead.
.filter(|result| matches!(result.media_type.as_deref(), Some("tv" | "movie")))
.map(Result_::into_candidate)
.collect())
}
/// Two requests: the show, to learn which seasons exist, and then those
/// seasons appended onto one more.
///
/// `append_to_response` is why this is two rather than one-per-season — a
/// five-season show costs the same as a one-season show, which matters when
/// a library of thirty series is matched in one run.
async fn episodes(&self, title: &TitleMetadata) -> Result<Vec<EpisodeMetadata>> {
// Films have no episodes, and asking `/tv/{id}` about one answers 404
// for an id that belongs to a film.
if title.kind != TitleKind::Series {
return Ok(Vec::new());
}
let Ok(id) = title.remote_id.parse::<i64>() else {
return Ok(Vec::new());
};
let show: Show = self.get(&format!("{API_BASE}/tv/{id}"), &[]).await?;
let numbers: Vec<u32> = show
.seasons
.iter()
.map(|season| season.season_number)
// Season zero is TMDB's bin for specials and recaps, which are not
// numbered the way a release names them and would collide with the
// real season they sit beside.
.filter(|number| *number > 0)
.take(MAX_APPENDED_SEASONS)
.collect();
if numbers.is_empty() {
return Ok(Vec::new());
}
let append = numbers
.iter()
.map(|number| format!("season/{number}"))
.collect::<Vec<_>>()
.join(",");
let detail: SeasonsResponse = self
.get(
&format!("{API_BASE}/tv/{id}"),
&[("append_to_response", append.as_str())],
)
.await?;
let mut episodes = Vec::new();
for number in numbers {
let Some(season) = detail.season(number) else {
continue;
};
episodes.extend(season.episodes.iter().map(|episode| {
EpisodeMetadata {
season: Some(number),
number: episode.episode_number,
name: episode.name.clone().filter(|name| !name.is_empty()),
overview: episode.overview.clone().filter(|text| !text.is_empty()),
still_url: episode
.still_path
.as_deref()
.map(|path| image(STILL_SIZE, path)),
air_date: episode.air_date.clone().filter(|date| !date.is_empty()),
}
}));
}
Ok(episodes)
}
}
impl Tmdb {
/// One authenticated GET, with TMDB's failures named.
async fn get<T: serde::de::DeserializeOwned>(
&self,
url: &str,
extra: &[(&str, &str)],
) -> Result<T> {
if self.api_key.trim().is_empty() {
return Err(Error::MissingApiKey(ProviderId::Tmdb));
}
let mut request = self.http.get(url).query(&[
("api_key", self.api_key.as_str()),
("language", self.language.as_str()),
]);
for pair in extra {
request = request.query(&[pair]);
}
let response = request.send().await?;
let status = response.status();
if !status.is_success() {
return Err(match status.as_u16() {
401 => Error::MissingApiKey(ProviderId::Tmdb),
429 => Error::RateLimited,
_ => Error::Http(format!("TMDB answered {status}")),
});
}
Ok(response.json().await?)
}
}
/// `/tv/{id}`, for the season list alone.
#[derive(Deserialize)]
struct Show {
#[serde(default)]
seasons: Vec<SeasonSummary>,
}
#[derive(Deserialize)]
struct SeasonSummary {
season_number: u32,
}
/// The same endpoint with seasons appended. The appended objects arrive under
/// the key that asked for them — `"season/1"` — alongside every ordinary field
/// of the show, so the map is collected untyped and each season is parsed out
/// of it: typing the map as a season would make `"id": 1234` a parse error and
/// lose the whole response.
#[derive(Deserialize)]
struct SeasonsResponse {
#[serde(flatten)]
fields: std::collections::HashMap<String, serde_json::Value>,
}
impl SeasonsResponse {
fn season(&self, number: u32) -> Option<SeasonDetail> {
let value = self.fields.get(&format!("season/{number}"))?;
serde_json::from_value(value.clone())
.inspect_err(|error| tracing::debug!("TMDB season {number}: {error}"))
.ok()
}
}
#[derive(Deserialize)]
struct SeasonDetail {
#[serde(default)]
episodes: Vec<EpisodeRow>,
}
#[derive(Deserialize)]
struct EpisodeRow {
episode_number: u32,
name: Option<String>,
overview: Option<String>,
still_path: Option<String>,
air_date: Option<String>,
}
#[derive(Deserialize)]
struct SearchResponse {
#[serde(default)]
results: Vec<Result_>,
}
/// One `search/multi` hit. TMDB names the same field `name` on television and
/// `title` on film, and likewise for the dates, so both are optional here and
/// resolved after parsing.
#[derive(Deserialize)]
struct Result_ {
id: i64,
media_type: Option<String>,
name: Option<String>,
title: Option<String>,
original_name: Option<String>,
original_title: Option<String>,
overview: Option<String>,
first_air_date: Option<String>,
release_date: Option<String>,
poster_path: Option<String>,
backdrop_path: Option<String>,
vote_average: Option<f32>,
}
impl Result_ {
fn into_candidate(self) -> Candidate {
let kind = match self.media_type.as_deref() {
Some("tv") => TitleKind::Series,
_ => TitleKind::Film,
};
let name = self
.name
.clone()
.or_else(|| self.title.clone())
.unwrap_or_else(|| format!("TMDB #{}", self.id));
let original_name = self
.original_name
.clone()
.or_else(|| self.original_title.clone())
.filter(|original| *original != name);
let aliases = [
self.name,
self.title,
self.original_name,
self.original_title,
]
.into_iter()
.flatten()
.collect();
Candidate {
metadata: TitleMetadata {
provider: ProviderId::Tmdb,
remote_id: self.id.to_string(),
name,
original_name,
overview: self.overview.filter(|text| !text.is_empty()),
year: year_of(self.first_air_date.or(self.release_date).as_deref()),
kind,
poster_url: self.poster_path.map(|path| image(POSTER_SIZE, &path)),
backdrop_url: self.backdrop_path.map(|path| image(BACKDROP_SIZE, &path)),
rating: self.vote_average.filter(|rating| *rating > 0.0),
// `search/multi` carries neither, and a detail request per
// candidate would be a round-trip per *result* rather than per
// title. Not worth it for a genre list.
genres: Vec::new(),
episodes: None,
url: Some(format!(
"https://www.themoviedb.org/{}/{}",
match kind {
TitleKind::Series => "tv",
TitleKind::Film => "movie",
},
self.id
)),
},
aliases,
// Always: `search/multi` results are filtered on `media_type`
// before they get here, so it is never the fallback.
kind_known: true,
}
}
}
fn image(size: &str, path: &str) -> String {
format!("{IMAGE_BASE}/{size}{path}")
}
/// The year out of a TMDB `YYYY-MM-DD`, tolerating the empty string it uses for
/// "unknown" and anything else that is not a date.
fn year_of(date: Option<&str>) -> Option<u32> {
date?.get(..4)?.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_year_is_taken_from_the_front_of_a_date() {
assert_eq!(year_of(Some("1998-04-03")), Some(1998));
assert_eq!(year_of(Some("")), None);
assert_eq!(year_of(Some("soon")), None);
assert_eq!(year_of(None), None);
}
/// The appended-season response carries the show's own fields beside the
/// seasons, and those must not stop the seasons parsing.
#[test]
fn seasons_are_picked_out_of_a_response_full_of_other_fields() {
let response: SeasonsResponse = serde_json::from_str(
r#"{
"id": 31911,
"name": "Fullmetal Alchemist: Brotherhood",
"number_of_seasons": 1,
"genres": [{"id": 16, "name": "Animation"}],
"season/1": {
"episodes": [
{"episode_number": 1, "name": "Fullmetal Alchemist",
"overview": "Ed and Al…", "still_path": "/still.jpg",
"air_date": "2009-04-05"},
{"episode_number": 2, "name": "The First Day",
"overview": "", "still_path": null, "air_date": null}
]
}
}"#,
)
.expect("parse");
let season = response.season(1).expect("season one");
assert_eq!(season.episodes.len(), 2);
assert_eq!(
season.episodes[0].name.as_deref(),
Some("Fullmetal Alchemist")
);
assert_eq!(season.episodes[0].still_path.as_deref(), Some("/still.jpg"));
assert!(response.season(2).is_none());
}
/// Television and film name the same things differently; both have to land
/// in the same fields.
#[test]
fn television_and_film_results_normalise_to_the_same_shape() {
let series: Result_ = serde_json::from_str(
r#"{"id": 1, "media_type": "tv", "name": "Cowboy Bebop",
"original_name": "カウボーイビバップ", "first_air_date": "1998-04-03",
"poster_path": "/p.jpg", "backdrop_path": "/b.jpg", "vote_average": 8.6}"#,
)
.expect("parse");
let series = series.into_candidate();
assert_eq!(series.metadata.kind, TitleKind::Series);
assert_eq!(series.metadata.year, Some(1998));
assert_eq!(
series.metadata.poster_url.as_deref(),
Some("https://image.tmdb.org/t/p/w500/p.jpg")
);
assert_eq!(
series.metadata.backdrop_url.as_deref(),
Some("https://image.tmdb.org/t/p/w780/b.jpg")
);
let film: Result_ = serde_json::from_str(
r#"{"id": 2, "media_type": "movie", "title": "Akira",
"release_date": "1988-07-16"}"#,
)
.expect("parse");
let film = film.into_candidate();
assert_eq!(film.metadata.kind, TitleKind::Film);
assert_eq!(film.metadata.name, "Akira");
assert_eq!(film.metadata.year, Some(1988));
}
/// A rating of zero means "nobody has voted", not "everybody hated it", and
/// showing it as 0.0/10 would be a lie about the title.
#[test]
fn an_unrated_title_reports_no_rating() {
let result: Result_ = serde_json::from_str(
r#"{"id": 3, "media_type": "movie", "title": "New", "vote_average": 0.0}"#,
)
.expect("parse");
assert_eq!(result.into_candidate().metadata.rating, None);
}
}
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "pstr-player"
description = "Embedded libmpv playback for proton-stream."
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
pstr-stream.workspace = true
tokio.workspace = true
thiserror.workspace = true
tracing.workspace = true
# The safe wrapper covers the handle, options, commands, properties and the
# event queue. The custom protocol is registered against `-sys` directly rather
# than through `libmpv2::Protocol`, because that type borrows the `Mpv` it is
# registered on — which would make a struct owning both self-referential — and
# because its open callback reports failure by panicking, while a stream that
# cannot be opened is an ordinary outcome here.
libmpv2 = "6"
libmpv2-sys = "4"
# mpv resolves every GL function it uses through a callback we have to answer,
# and eframe exposes its context only as already-resolved `glow` pointers — so
# the entry points come out of the GL libraries the process has already loaded.
# See `gl.rs`.
libloading.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
# Only for the test fixture's `RevisionOpener`, which is a `dyn` async trait.
async-trait.workspace = true
+503
View File
@@ -0,0 +1,503 @@
//! The chapters a file was muxed with, and what they are for.
//!
//! Read the same way as the track list — `chapter-list/N/title`, one
//! sub-property at a time — for the same reason: those are plain strings and
//! doubles, and a node walk would buy nothing.
//!
//! Anime releases mark their opening and ending as chapters, which is the whole
//! reason this exists: with them a player can offer to skip the ninety seconds
//! everyone skips, and without them it cannot.
use libmpv2::Mpv;
/// One chapter of the file.
#[derive(Debug, Clone, PartialEq)]
pub struct Chapter {
/// Position in the list, which is what mpv's `chapter` property is set to.
pub index: i64,
/// The name the muxer gave it, if any.
pub title: Option<String>,
/// Where it starts, in seconds.
pub start: f64,
}
impl Chapter {
/// What to show in a list. A nameless chapter is still worth an entry —
/// it is a place in the file — so it gets its number.
pub fn label(&self) -> String {
match &self.title {
Some(title) => title.clone(),
None => format!("Chapter {}", self.index + 1),
}
}
/// What this chapter is, from its name alone.
///
/// Prefer [`roles`], which resolves the names that only mean one thing *in
/// context* — a chapter called `Intro` is an opening in one release and the
/// first ten minutes of the story in another.
pub fn role(&self) -> ChapterRole {
match Claim::of(self.title.as_deref().unwrap_or_default()) {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
// Unresolved without the rest of the file: assume it is content,
// which is the reading that never skips anything.
_ => ChapterRole::Content,
}
}
}
/// The kinds of chapter worth treating differently from "part of the episode".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChapterRole {
/// The opening titles.
Opening,
/// The ending titles.
Ending,
/// A preview of the next episode, after the ending.
Preview,
/// Anything else, including everything unnamed.
Content,
}
impl ChapterRole {
/// What a button offering to skip this should say. `None` for a chapter
/// nobody wants skipped.
pub fn skip_label(self) -> Option<&'static str> {
match self {
Self::Opening => Some("Skip opening"),
Self::Ending => Some("Skip ending"),
Self::Preview => Some("Skip preview"),
Self::Content => None,
}
}
/// Whether this is something the file could end on without the viewer
/// missing anything: credits and a trailer for next week.
///
/// An *opening* is not, even though it is skippable — a file does not end
/// with one, and treating it as tail would make a mis-tagged chapter
/// auto-advance out of the episode.
pub fn ends_the_episode(self) -> bool {
matches!(self, Self::Ending | Self::Preview)
}
}
/// What a chapter's *name* claims, before the rest of the file is consulted.
///
/// The two `Maybe` variants are the whole reason this is separate from
/// [`ChapterRole`]. `Intro` is the opening in one release and, in the first
/// episode of Oshi no Ko, a ten-minute introduction to the series that a viewer
/// must not be skipped out of. `Credits` and `Cast` are the ending in most
/// files and a mid-episode scene in a few. Neither can be settled by the name.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Claim {
Opening,
Ending,
Preview,
/// Reads like an opening, but only if the file has no real one and it is
/// the length of a theme song.
MaybeOpening,
/// Reads like credits, but only near the end of the file or at theme
/// length.
MaybeEnding,
Content,
}
/// How long an opening or ending theme runs.
///
/// Anime themes are ninety seconds by convention and rarely stray far. This is
/// the corroboration for a name that could go either way: a chapter called
/// `Intro` that runs eleven minutes is not a theme song, whatever it is called.
const THEME_SECONDS: std::ops::RangeInclusive<f64> = 45.0..=150.0;
/// Credits can run longer than a theme — a full cast roll over a still — so a
/// chapter that only *reads* like an ending gets this much room when it sits at
/// the end of the file.
const CREDITS_SECONDS: std::ops::RangeInclusive<f64> = 30.0..=300.0;
/// An opening that far into the file is not an opening. Cold opens run long in
/// a first episode, so this is generous.
const OPENING_BEFORE_SECONDS: f64 = 480.0;
/// Where the last part of a file starts, as a fraction of it. A chapter that
/// merely reads like credits is taken as credits past this point.
const TAIL_FRACTION: f64 = 0.75;
impl Claim {
/// Read a claim out of a chapter name.
///
/// Matched on whole words from the front, not on substrings: `Operation
/// Briefing` starts with `op` and is not an opening, `Ending Note` is not
/// an ending, and skipping a viewer out of either is a mistake they cannot
/// undo without finding the seek bar. Leading numbering (`01 - OP`, `1.
/// Opening`) is stepped over first, because plenty of muxes carry it.
fn of(title: &str) -> Self {
// Japanese names appear verbatim in releases from JP sources, and they
// are unambiguous where the English ones are not. Checked before the
// word split, which only knows about ASCII and would find no words at
// all in a name written entirely in kana.
if title.contains("オープニング") {
return Self::Opening;
}
if title.contains("エンディング") {
return Self::Ending;
}
if title.contains("予告") {
return Self::Preview;
}
let words = words(title);
let words: Vec<&str> = words.iter().map(String::as_str).collect();
let Some((head, rest)) = words.split_first() else {
return Self::Content;
};
let second = rest.first().copied().unwrap_or_default();
match (*head, second) {
// Two-word forms first: the second word is what tells `next
// episode` from `next`, and `end credits` from `end`.
("next", _) | ("nep", _) => Self::Preview,
("end" | "ending" | "closing", "credits") => Self::Ending,
("cast" | "staff", "roll" | "credits") => Self::Ending,
("opening" | "op", "theme" | "song" | "credits") => Self::Opening,
("ending" | "ed", "theme" | "song") => Self::Ending,
("op" | "opening" | "openning" | "ncop", _) => Self::Opening,
("ed" | "ending" | "nced" | "outro" | "endcard", _) => Self::Ending,
("preview" | "pv" | "trailer" | "teaser" | "yokoku", _) => Self::Preview,
("intro", _) => Self::MaybeOpening,
("credits" | "closing" | "cast" | "staff", _) => Self::MaybeEnding,
_ => Self::Content,
}
}
}
/// A chapter name as lowercase words, with any leading numbering dropped.
///
/// `OP1` and `ED2` keep their tag and lose their number — the digit is part of
/// the tag, not a word — while `02 - Opening` loses its leading `02` entirely.
fn words(title: &str) -> Vec<String> {
let mut words: Vec<String> = title
.trim()
.to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|word| !word.is_empty())
.map(|word| {
word.trim_end_matches(|c: char| c.is_ascii_digit())
.to_string()
})
.collect();
// A leading token that was *only* digits is numbering, not a name.
if words.first().is_some_and(String::is_empty) {
words.remove(0);
}
words.retain(|word| !word.is_empty());
words
}
/// The role of every chapter in a file, with the file's own shape taken into
/// account.
///
/// This is the function to use. [`Chapter::role`] reads a name in isolation and
/// cannot tell an opening called `Intro` from ten minutes of story called the
/// same thing; here the other chapters and the running time settle it:
///
/// * a chapter that merely *reads* like an opening is one only when the file
/// has no chapter that says so outright, it runs the length of a theme song,
/// and it is near the front;
/// * a chapter that reads like credits is the ending when it runs to about that
/// length, or when it sits in the last quarter of the file.
///
/// Both rules fail *towards content*: not offering a skip costs a button, and
/// skipping the viewer out of the story costs them the story.
pub fn roles(chapters: &[Chapter], duration: Option<f64>) -> Vec<ChapterRole> {
let claims: Vec<Claim> = chapters
.iter()
.map(|chapter| Claim::of(chapter.title.as_deref().unwrap_or_default()))
.collect();
let has_real_opening = claims.contains(&Claim::Opening);
claims
.iter()
.enumerate()
.map(|(index, claim)| {
let length = chapter_end(chapters, index, duration)
.map(|end| end - chapters[index].start)
.filter(|length| *length > 0.0);
let start = chapters[index].start;
match claim {
Claim::Opening => ChapterRole::Opening,
Claim::Ending => ChapterRole::Ending,
Claim::Preview => ChapterRole::Preview,
Claim::MaybeOpening
if !has_real_opening
&& start <= OPENING_BEFORE_SECONDS
&& length.is_some_and(|length| THEME_SECONDS.contains(&length)) =>
{
ChapterRole::Opening
}
Claim::MaybeEnding
if length.is_some_and(|length| CREDITS_SECONDS.contains(&length))
|| duration.is_some_and(|total| start >= total * TAIL_FRACTION) =>
{
ChapterRole::Ending
}
_ => ChapterRole::Content,
}
})
.collect()
}
/// Where the run of chapters that ends the file — credits, a preview, and
/// nothing the viewer would miss — begins.
///
/// **The suffix, not the last chapter.** A release that puts a Part C after the
/// ending and a preview after that (Fullmetal Alchemist Brotherhood does,
/// exactly once) must give up only the preview, or auto-advancing skips a scene
/// that matters. Walking back from the end and stopping at the first chapter
/// that is content is what expresses that: `… ED · Part C · Preview` yields the
/// preview alone, while the ordinary `… ED · Preview` yields both.
///
/// `None` when the file ends on content, has no chapters, or is *entirely*
/// skippable — the last of which is a mis-tagged file, not an episode that is
/// all credits.
pub fn credits_start(chapters: &[Chapter], roles: &[ChapterRole]) -> Option<f64> {
let first = roles
.iter()
.rposition(|role| !role.ends_the_episode())
.map(|last_content| last_content + 1)?;
if first >= chapters.len() {
return None;
}
Some(chapters[first].start)
}
/// Every chapter of the file mpv currently has open.
pub(crate) fn read(mpv: &Mpv) -> Vec<Chapter> {
let count: i64 = mpv.get_property("chapter-list/count").unwrap_or(0);
(0..count)
.map(|index| Chapter {
index,
title: mpv
.get_property::<String>(&format!("chapter-list/{index}/title"))
.ok()
.map(|title| title.trim().to_string())
.filter(|title| !title.is_empty()),
start: mpv
.get_property(&format!("chapter-list/{index}/time"))
.unwrap_or(0.0),
})
.collect()
}
/// Where the chapter starting at `index` ends: the next one's start, or the end
/// of the file.
pub fn chapter_end(chapters: &[Chapter], index: usize, duration: Option<f64>) -> Option<f64> {
chapters
.get(index + 1)
.map(|next| next.start)
.or(duration)
.filter(|end| *end > 0.0)
}
/// The chapter a position falls in.
///
/// Linear rather than a binary search: a file has a dozen chapters, and this is
/// called once a frame at most.
pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
chapters
.iter()
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
}
#[cfg(test)]
mod tests {
use super::*;
fn chapter(index: i64, title: &str, start: f64) -> Chapter {
Chapter {
index,
title: Some(title.to_string()),
start,
}
}
#[test]
fn the_names_releases_actually_use_are_recognised() {
for (name, claim) in [
("OP", Claim::Opening),
("Opening", Claim::Opening),
("OP1 - Again", Claim::Opening),
("02 - Opening", Claim::Opening),
("Opening Song", Claim::Opening),
("NCOP", Claim::Opening),
("オープニング", Claim::Opening),
("ED", Claim::Ending),
("Ending 2", Claim::Ending),
("End Credits", Claim::Ending),
("Cast Roll", Claim::Ending),
("Endcard", Claim::Ending),
("エンディング", Claim::Ending),
("Preview", Claim::Preview),
("Next Episode", Claim::Preview),
("Next Episode Preview", Claim::Preview),
("予告", Claim::Preview),
] {
assert_eq!(Claim::of(name), claim, "{name}");
}
}
#[test]
fn a_chapter_that_merely_starts_with_those_letters_is_not_one() {
// The reason this matches words and not prefixes: skipping the viewer
// out of "Operation Briefing" would be a bug they cannot undo without
// finding the seek bar.
for name in ["Operation Briefing", "Editor's cut", "Part A", "Part C", ""] {
assert_eq!(Claim::of(name), Claim::Content, "{name}");
}
}
/// The names that cannot be settled by reading them.
#[test]
fn an_ambiguous_name_is_not_decided_by_itself() {
assert_eq!(Claim::of("Intro"), Claim::MaybeOpening);
assert_eq!(Claim::of("Credits"), Claim::MaybeEnding);
assert_eq!(Claim::of("Cast"), Claim::MaybeEnding);
// And a chapter read in isolation never resolves to a skip.
assert_eq!(chapter(0, "Intro", 0.0).role(), ChapterRole::Content);
}
/// **The first episode of Oshi no Ko.** No opening, no ending — an eighty
/// minute episode that begins with a long `Intro` that is the story, and
/// ends with a cast scroll. Offering "skip opening" over the first ninety
/// seconds of *that* skips the beginning of the series.
#[test]
fn a_long_intro_is_the_episode_and_not_an_opening() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "Part A", 700.0),
chapter(2, "Part B", 2400.0),
chapter(3, "Cast", 4680.0),
];
let roles = roles(&chapters, Some(4800.0));
assert_eq!(
roles[0],
ChapterRole::Content,
"eleven minutes is not a theme"
);
assert_eq!(
roles[3],
ChapterRole::Ending,
"a cast roll at the end is one"
);
// Only the cast scroll is the tail; the episode is not skipped into.
assert_eq!(credits_start(&chapters, &roles), Some(4680.0));
}
/// The same name in the release that uses it for the theme song: ninety
/// seconds, near the front, and no other opening in the file.
#[test]
fn a_theme_length_intro_near_the_front_is_an_opening() {
let chapters = vec![
chapter(0, "Avant", 0.0),
chapter(1, "Intro", 90.0),
chapter(2, "Part A", 180.0),
];
assert_eq!(roles(&chapters, Some(1440.0))[1], ChapterRole::Opening);
// …but not when the file already says which chapter the opening is.
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 90.0),
chapter(2, "Part A", 180.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[0], ChapterRole::Content);
assert_eq!(roles[1], ChapterRole::Opening);
}
/// The ordinary shape: the credits and the trailer for next week are one
/// run at the end, and the countdown starts where the credits do.
#[test]
fn the_tail_is_the_whole_run_of_credits_and_preview() {
let chapters = vec![
chapter(0, "OP", 0.0),
chapter(1, "Part A", 90.0),
chapter(2, "Part B", 700.0),
chapter(3, "ED", 1320.0),
chapter(4, "Preview", 1410.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), Some(1320.0));
}
/// **Episode 46 of Fullmetal Alchemist Brotherhood.** A Part C *after* the
/// ending, and the preview after that. Only the preview may be skipped —
/// treating everything from the ending on as tail would drop a scene.
#[test]
fn content_after_the_ending_keeps_the_ending_out_of_the_tail() {
let chapters = vec![
chapter(0, "Part A", 0.0),
chapter(1, "Part B", 700.0),
chapter(2, "ED", 1260.0),
chapter(3, "Part C", 1350.0),
chapter(4, "Preview", 1400.0),
];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(roles[2], ChapterRole::Ending, "it is still an ending");
assert_eq!(credits_start(&chapters, &roles), Some(1400.0));
}
/// A file that ends on the story has no tail at all, and one with no
/// chapters has nothing to read.
#[test]
fn a_file_that_ends_on_content_has_no_tail() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
let roles = roles(&chapters, Some(1440.0));
assert_eq!(credits_start(&chapters, &roles), None);
assert_eq!(credits_start(&[], &[]), None);
// Everything skippable is a mis-tagged file, not an episode that is
// entirely credits: auto-advancing out of it at second zero would be
// worse than doing nothing.
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "ED", 90.0)];
let roles = vec![ChapterRole::Ending, ChapterRole::Ending];
assert_eq!(credits_start(&chapters, &roles), None);
}
#[test]
fn a_position_lands_in_the_chapter_that_contains_it() {
let chapters = vec![
chapter(0, "Intro", 0.0),
chapter(1, "OP", 24.0),
chapter(2, "Part A", 114.0),
];
assert_eq!(chapter_at(&chapters, 0.0), Some(0));
assert_eq!(chapter_at(&chapters, 23.9), Some(0));
// Exactly on a boundary belongs to the chapter that starts there,
// which is what makes "skip" land *out* of the opening rather than at
// its last frame.
assert_eq!(chapter_at(&chapters, 24.0), Some(1));
assert_eq!(chapter_at(&chapters, 3000.0), Some(2));
}
#[test]
fn a_chapter_before_the_first_one_starts_is_in_no_chapter() {
let chapters = vec![chapter(0, "OP", 24.0)];
assert_eq!(chapter_at(&chapters, 0.0), None);
}
#[test]
fn the_last_chapter_ends_where_the_file_does() {
let chapters = vec![chapter(0, "OP", 0.0), chapter(1, "Part A", 90.0)];
assert_eq!(chapter_end(&chapters, 0, Some(1440.0)), Some(90.0));
assert_eq!(chapter_end(&chapters, 1, Some(1440.0)), Some(1440.0));
// No duration yet and nothing after it: there is nowhere to skip to,
// and a button that seeks to zero would be worse than no button.
assert_eq!(chapter_end(&chapters, 1, None), None);
}
}
+283
View File
@@ -0,0 +1,283 @@
//! The read/seek state behind one open `pstr://` URL.
//!
//! Deliberately a plain safe type with no mpv in it. Everything mpv's stream
//! callbacks actually *do* — advance a position, clamp a seek, turn a short read
//! into an EOF, honour a cancellation — lives here and is unit-tested against an
//! in-memory block source. [`crate::protocol`] is then only pointer plumbing,
//! which is the part that has to be read rather than tested.
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use pstr_stream::VideoStream;
use tokio::runtime::Runtime;
use tokio::sync::Notify;
/// What mpv's callbacks return on failure.
///
/// `read_fn` documents -1; `seek_fn` and `size_fn` want an `MPV_ERROR_*`.
pub(crate) const READ_ERROR: i64 = -1;
pub(crate) const SEEK_ERROR: i64 = libmpv2::mpv_error::Generic as i64;
pub(crate) const SIZE_UNKNOWN: i64 = libmpv2::mpv_error::Unsupported as i64;
/// One-way "this stream is going away".
///
/// mpv calls `cancel_fn` from a thread other than the demuxer thread, precisely
/// so it can interrupt a read that is already blocked. Without it, tearing down
/// a player — closing the window, playing something else — waits out whatever
/// block fetch was in flight, which is seconds over a cold network.
///
/// It latches, and nothing un-latches it. That matches mpv's own model: the
/// cancellation is tied to the stream instance's lifetime, not to an individual
/// operation, and `close_fn` follows it. A cursor that tried to re-arm itself
/// would be guessing at a contract mpv does not offer.
#[derive(Default)]
pub(crate) struct CancelLatch {
flagged: AtomicBool,
notify: Notify,
}
impl CancelLatch {
pub(crate) fn cancel(&self) {
self.flagged.store(true, Ordering::Release);
// `notify_one`, not `notify_waiters`: it leaves a permit behind when no
// one is waiting yet, which closes the window between a reader checking
// the flag and actually parking on the notify.
self.notify.notify_one();
}
pub(crate) fn is_cancelled(&self) -> bool {
self.flagged.load(Ordering::Acquire)
}
async fn cancelled(&self) {
self.notify.notified().await;
}
}
/// A position inside one [`VideoStream`], plus the runtime its reads run on.
pub(crate) struct StreamCursor {
stream: VideoStream,
/// mpv's demuxer thread is not a tokio thread, so it can block on this. The
/// whole runtime is held rather than a `Handle` because a read arriving
/// after the runtime shut down would panic, and mpv may well outlive the
/// code that built it.
runtime: Arc<Runtime>,
size: u64,
pos: u64,
}
impl StreamCursor {
pub(crate) fn new(stream: VideoStream, runtime: Arc<Runtime>) -> Self {
let size = stream.size();
Self {
stream,
runtime,
size,
pos: 0,
}
}
/// Total size, or [`SIZE_UNKNOWN`] if it does not fit mpv's `i64`.
pub(crate) fn size(&self) -> i64 {
i64::try_from(self.size).unwrap_or(SIZE_UNKNOWN)
}
/// Where the next read starts. mpv tracks this itself, so nothing outside
/// the tests asks.
#[cfg(test)]
pub(crate) fn position(&self) -> u64 {
self.pos
}
/// Move to an absolute offset, returning the new one.
///
/// Seeking exactly to EOF is allowed — that is how a demuxer asks for the
/// end — and reads there return 0. Past it is an error rather than a clamp,
/// so a mistake upstream shows up as a failure instead of as bytes from the
/// wrong place.
pub(crate) fn seek(&mut self, offset: i64) -> i64 {
let Ok(target) = u64::try_from(offset) else {
return SEEK_ERROR;
};
if target > self.size {
return SEEK_ERROR;
}
self.pos = target;
offset
}
/// Fill `buf` from the current position, advancing it.
///
/// Returns the byte count, 0 at EOF, or [`READ_ERROR`]. A read is allowed to
/// come up short — mpv treats that as "call again", which is exactly right
/// when the range spanned a block boundary.
pub(crate) fn read(&mut self, buf: &mut [u8], cancel: &CancelLatch) -> i64 {
if cancel.is_cancelled() {
return READ_ERROR;
}
if buf.is_empty() || self.pos >= self.size {
return 0;
}
// Named up front so the future below borrows these and not `self`,
// which `buf` is already borrowed out of.
let stream = &self.stream;
let pos = self.pos;
let outcome = self.runtime.block_on(async {
tokio::select! {
// Biased so an already-cancelled stream loses no time to a
// fetch nobody will use.
biased;
() = cancel.cancelled() => None,
read = stream.read_at(pos, buf) => Some(read),
}
});
match outcome {
None => READ_ERROR,
Some(Err(error)) => {
tracing::warn!(%error, offset = pos, "stream read failed");
READ_ERROR
}
Some(Ok(read)) => {
self.pos += read as u64;
read as i64
}
}
}
/// [`Self::read`] with a panic in the layers below turned into a read
/// error.
///
/// `extern "C"` aborts the process on unwind, so a bug anywhere under
/// `read_at` would take the whole app down mid-episode. Failing the stream
/// instead lets the app say so and keep its window.
pub(crate) fn read_caught(&mut self, buf: &mut [u8], cancel: &CancelLatch) -> i64 {
std::panic::catch_unwind(AssertUnwindSafe(|| self.read(buf, cancel))).unwrap_or_else(|_| {
tracing::error!("panic while reading a stream; failing it");
READ_ERROR
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{CONTENT_BLOCKS, test_runtime, test_stream};
fn cursor() -> (StreamCursor, CancelLatch) {
let runtime = test_runtime();
let stream = test_stream(&runtime);
(StreamCursor::new(stream, runtime), CancelLatch::default())
}
fn total() -> u64 {
CONTENT_BLOCKS.iter().sum::<usize>() as u64
}
#[test]
fn the_cursor_reports_the_whole_revision_size() {
let (cursor, _) = cursor();
assert_eq!(cursor.size(), total() as i64);
}
#[test]
fn sequential_reads_walk_the_file_and_stop_at_eof() {
let (mut cursor, cancel) = cursor();
let mut got = Vec::new();
let mut buf = [0_u8; 700];
loop {
let read = cursor.read(&mut buf, &cancel);
assert!(read >= 0, "read failed at {}", cursor.position());
if read == 0 {
break;
}
got.extend_from_slice(&buf[..read as usize]);
}
assert_eq!(got.len() as u64, total());
assert!(got.iter().enumerate().all(|(i, b)| *b == (i % 251) as u8));
assert_eq!(cursor.position(), total());
}
#[test]
fn a_seek_moves_where_the_next_read_starts() {
let (mut cursor, cancel) = cursor();
let offset = 5_000_i64;
assert_eq!(cursor.seek(offset), offset);
let mut buf = [0_u8; 16];
let read = cursor.read(&mut buf, &cancel);
assert_eq!(read, 16);
assert_eq!(buf[0], (offset as usize % 251) as u8);
assert_eq!(cursor.position(), offset as u64 + 16);
}
#[test]
fn a_read_crossing_a_block_boundary_is_allowed_to_come_up_short() {
// mpv reads again rather than treating a short read as EOF, so this is
// not a bug to paper over — but the bytes it does return must be right.
let (mut cursor, cancel) = cursor();
let boundary = CONTENT_BLOCKS[0] as i64;
cursor.seek(boundary - 8);
let mut buf = [0_u8; 64];
let read = cursor.read(&mut buf, &cancel);
assert!(read > 0 && read <= 64);
let start = boundary as usize - 8;
assert!(
buf[..read as usize]
.iter()
.enumerate()
.all(|(i, b)| *b == ((start + i) % 251) as u8)
);
}
#[test]
fn seeking_exactly_to_eof_is_allowed_and_reads_zero() {
let (mut cursor, cancel) = cursor();
let end = total() as i64;
assert_eq!(cursor.seek(end), end);
let mut buf = [0_u8; 16];
assert_eq!(cursor.read(&mut buf, &cancel), 0);
}
#[test]
fn seeking_past_the_end_or_before_the_start_is_an_error() {
let (mut cursor, _) = cursor();
assert_eq!(cursor.seek(total() as i64 + 1), SEEK_ERROR);
assert_eq!(cursor.seek(-1), SEEK_ERROR);
// And the position is untouched by a refused seek.
assert_eq!(cursor.position(), 0);
}
#[test]
fn mpvs_seekability_probe_to_zero_succeeds() {
// mpv issues a seek to 0 immediately after open purely to find out
// whether the stream is seekable. Failing it would cost every seek bar.
let (mut cursor, _) = cursor();
assert_eq!(cursor.seek(0), 0);
}
#[test]
fn a_cancelled_stream_fails_its_reads_instead_of_serving_them() {
let (mut cursor, cancel) = cursor();
cancel.cancel();
let mut buf = [0_u8; 16];
assert_eq!(cursor.read(&mut buf, &cancel), READ_ERROR);
// And stays cancelled — the latch is the stream's lifetime, not one op.
assert_eq!(cursor.read(&mut buf, &cancel), READ_ERROR);
}
#[test]
fn an_empty_buffer_reads_nothing_rather_than_failing() {
let (mut cursor, cancel) = cursor();
assert_eq!(cursor.read(&mut [], &cancel), 0);
}
}
+27
View File
@@ -0,0 +1,27 @@
use thiserror::Error;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
/// Anything libmpv itself refused: a bad option, an unknown property, a
/// file it could not load.
#[error("mpv: {0}")]
Mpv(#[from] libmpv2::Error),
/// A string on its way into mpv contained an interior NUL. Only reachable
/// from a caller-supplied option or property name.
#[error("{0} contains a NUL byte")]
NulByte(&'static str),
/// `mpv_render_context_create` reported success and handed back nothing.
/// Not reachable through any documented path — it exists so the pointer
/// can be a `NonNull` rather than something later code has to re-check.
#[error("mpv returned no render context")]
RenderContext,
/// The block layer failed. Read errors reach the player as a short read
/// instead; this is for the paths that surface them directly.
#[error(transparent)]
Stream(#[from] pstr_stream::Error),
}
+204
View File
@@ -0,0 +1,204 @@
//! Finding OpenGL entry points for mpv's render API.
//!
//! mpv deliberately does not link to a GL library: it asks the embedder to
//! resolve every function it needs, through the `get_proc_address` callback in
//! `mpv_opengl_init_params`. The embedder is normally the one that created the
//! context, so it just forwards to `eglGetProcAddress` or `glXGetProcAddress`.
//!
//! We are not that embedder. eframe creates the context through glutin and
//! exposes it only as a [`glow::Context`] full of already-resolved pointers —
//! there is no way to ask it for one by name. So this module resolves them
//! itself, out of the GL libraries the process has *already* loaded.
//!
//! That works because of how the loaders are built rather than by luck:
//!
//! * On Linux the near-universal `libglvnd` makes `eglGetProcAddress`,
//! `glXGetProcAddress` and a plain `dlsym` of `libGL.so.1` all return dispatch
//! stubs that resolve against whatever context is current on the calling
//! thread. Which of the three answers is therefore not load-bearing, which is
//! what makes it safe not to know whether glutin picked EGL or GLX.
//! * On Windows `wglGetProcAddress` answers for everything past GL 1.1 and
//! `opengl32.dll` exports the rest, so the two together are complete — and the
//! order below tries them in exactly that order.
//!
//! A name that resolves nowhere comes back null, which is what mpv's callback
//! contract expects.
//!
//! **A non-null answer is not evidence the function exists.** Both
//! `glXGetProcAddressARB` and `eglGetProcAddress` are specified as free to hand
//! back a pointer for a name they have never heard of, and under `libglvnd` they
//! do exactly that: every plausible name gets a lazily generated dispatch stub,
//! including ones no driver implements. Measured on this repo's own machine,
//! *no* string resolves to null. So there is nothing to be gained by inspecting
//! the return value here, and nothing is: mpv checks what the driver actually
//! supports through `glGetString`, and refuses to create the render context with
//! its own message if something is missing.
use std::ffi::{CString, c_char, c_void};
use libloading::Library;
/// The libraries to look in, most likely first.
///
/// EGL leads on a Wayland session and GLX on an X11 one, matching what glutin
/// will have picked — though with `libglvnd` in the way, either order works.
#[cfg(unix)]
fn candidates() -> Vec<&'static str> {
let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some();
if wayland {
vec!["libEGL.so.1", "libGL.so.1"]
} else {
vec!["libGL.so.1", "libEGL.so.1"]
}
}
#[cfg(windows)]
fn candidates() -> Vec<&'static str> {
vec!["opengl32.dll"]
}
/// The `get_proc_address`-shaped entry points to try before falling back to a
/// plain symbol lookup.
///
/// Order matters on Windows: `wglGetProcAddress` returns null for the GL 1.1
/// functions that `opengl32.dll` exports directly, so the direct lookup has to
/// be the fallback rather than the other way round.
const GETTERS: [&[u8]; 4] = [
b"eglGetProcAddress\0",
b"glXGetProcAddressARB\0",
b"glXGetProcAddress\0",
b"wglGetProcAddress\0",
];
type GetProcAddress = unsafe extern "C" fn(*const c_char) -> *mut c_void;
/// Opened GL libraries, kept alive for as long as mpv might ask for a symbol.
///
/// mpv resolves most of what it needs while the render context is being
/// created, but it may resolve more later when the video chain reconfigures, so
/// this must outlive the context rather than just its construction.
pub struct GlLoader {
libraries: Vec<Library>,
}
impl GlLoader {
/// Open whatever GL libraries this platform has.
///
/// Never fails: a library that will not open is simply not searched, and a
/// loader with nothing in it resolves nothing — which surfaces as mpv
/// refusing to create the render context, with mpv's own message about
/// which function was missing. That is a better error than one from here.
pub fn new() -> Self {
let libraries = candidates()
.into_iter()
.filter_map(|name| {
// SAFETY: these are the process's own already-loaded GL
// libraries; `dlopen` of a loaded library bumps a refcount and
// runs no new initialisers.
match unsafe { Library::new(name) } {
Ok(library) => Some(library),
Err(error) => {
tracing::debug!("open {name}: {error}");
None
}
}
})
.collect();
Self { libraries }
}
/// Resolve one GL function, or null.
pub fn resolve(&self, name: &str) -> *mut c_void {
let Ok(symbol) = CString::new(name) else {
// Only reachable if mpv asked for a name with an interior NUL,
// which it does not do.
return std::ptr::null_mut();
};
for library in &self.libraries {
for getter in GETTERS {
// SAFETY: the signature matches all four of these entry points,
// and the symbol borrow does not outlive this block.
let found = unsafe {
library
.get::<GetProcAddress>(getter)
.ok()
.map(|get| get(symbol.as_ptr()))
};
if let Some(address) = found
&& !address.is_null()
{
return address;
}
}
}
// Past the dispatchers: the plain exported symbol. This is what answers
// for core functions on `opengl32.dll`, and it is a second chance on any
// driver whose `GetProcAddress` is narrower than its export table.
let with_nul = symbol.into_bytes_with_nul();
for library in &self.libraries {
// SAFETY: the address of a function symbol is wanted, not a call
// through it; the cast to a data pointer is why the type here is a
// bare `fn()` rather than the real signature.
let found = unsafe {
library
.get::<unsafe extern "C" fn()>(&with_nul)
.ok()
.map(|function| *function as *const () as *mut c_void)
};
if let Some(address) = found
&& !address.is_null()
{
return address;
}
}
tracing::debug!("no GL entry point named {name}");
std::ptr::null_mut()
}
}
impl Default for GlLoader {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for GlLoader {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GlLoader")
.field("libraries", &self.libraries.len())
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A loader must be constructible on a machine with no GL at all — a
/// headless CI runner is the normal case for this crate's test job — and a
/// name nothing has ever heard of must not panic on the way to an answer.
///
/// Note what is *not* asserted: that the answer is null. `libglvnd`'s
/// `glXGetProcAddress` manufactures a dispatch stub for any `gl`-prefixed
/// name it is handed, whether or not the driver implements it, so a non-null
/// pointer here is not evidence the function exists. That is fine and it is
/// GLX's documented behaviour — mpv checks the extensions it needs through
/// `glGetString`, not by seeing whether resolution succeeded.
#[test]
fn a_loader_is_always_constructible() {
let loader = GlLoader::new();
let _ = loader.resolve("glThisDoesNotExistAnywhere");
}
/// The name mpv passes is turned into a C string; a name that cannot be
/// must come back null rather than panic inside a callback mpv is calling.
#[test]
fn a_name_with_an_interior_nul_resolves_to_null() {
let loader = GlLoader::new();
assert!(loader.resolve("glGet\0String").is_null());
}
}
+55
View File
@@ -0,0 +1,55 @@
//! Playback, through libmpv, of streams that only exist behind a Proton link.
//!
//! mpv is not asked to download anything. A custom `pstr://` protocol is
//! registered with `mpv_stream_cb_add_ro`, and mpv's demuxer reads through it
//! exactly as it would read a local file — small reads, seeks, an EOF. Every one
//! of those lands in [`pstr_stream`], which serves it from the block ring, the
//! disk cache or Proton, in that order.
//!
//! ```text
//! Player::play(stream) registry: 7 → VideoStream
//! │ loadfile pstr://7 ▲
//! ▼ │ hash lookup, no I/O
//! mpv demuxer thread ── read/seek/size ──▶ StreamCursor ──▶ VideoStream
//! │ │
//! └── cancel (another thread) ──▶ CancelLatch ─┘
//! ```
//!
//! Three things about that shape are deliberate:
//!
//! * **mpv gets a token, not an identity.** The URL fragment of a Proton share
//! link *is* its decryption password, and mpv puts a stream's URL in its log,
//! its `path` property and its window title. A `u64` leaks nothing.
//! * **The open callback does no work.** Opening a revision can take seconds and
//! fail for reasons a person needs to read; mpv's open callback can only say
//! "loading failed". So the caller opens first and registers the result.
//! * **`stream_cb`, not a loopback HTTP server.** No port, no other local
//! process able to ask for the plaintext, no decrypted bytes leaving the
//! process.
//!
//! Where the picture ends up is a separate axis, and the streaming path above
//! is identical either way: [`VideoOutput::Window`] lets mpv make a window, and
//! [`VideoOutput::Embedded`] plus a [`VideoRenderer`] draws each frame into an
//! OpenGL framebuffer the caller owns. Only [`PlayerConfig`] and [`render`] know
//! the difference.
mod chapters;
mod cursor;
mod error;
mod gl;
mod player;
mod protocol;
mod registry;
mod render;
#[cfg(test)]
mod testing;
mod tracks;
pub use chapters::{Chapter, ChapterRole, chapter_at, chapter_end, credits_start, roles};
pub use error::{Error, Result};
pub use player::{
EndReason, MAX_VOLUME, Player, PlayerConfig, PlayerEvent, READAHEAD_BLOCKS, VideoOutput,
};
pub use registry::{PROTOCOL, StreamHandle, StreamRegistry};
pub use render::VideoRenderer;
pub use tracks::{Track, TrackKind, language_name};
+560
View File
@@ -0,0 +1,560 @@
//! An mpv instance that plays [`VideoStream`]s.
//!
//! Where the picture goes is one option — [`VideoOutput`] — and nothing else in
//! this file depends on it. mpv can put up a window of its own, which is what
//! `pstr play` still does and what validates the whole Proton → blocks →
//! decrypt → demux → decode chain with no UI in the way; or it can hand each
//! frame to a [`crate::VideoRenderer`] the caller draws inside its own OpenGL
//! surface, which is what the app does.
use std::ptr::NonNull;
use std::sync::Arc;
use libmpv2::events::{Event, PropertyData};
use libmpv2::{Format, Mpv};
use pstr_stream::VideoStream;
use tokio::runtime::Runtime;
use crate::chapters::{self, Chapter};
use crate::error::{Error, Result};
use crate::protocol;
use crate::registry::{StreamHandle, StreamRegistry};
use crate::tracks::{self, Track, TrackKind};
/// How many blocks the stream layer should read ahead when mpv is the consumer:
/// **none**.
///
/// [`pstr_stream::DEFAULT_READAHEAD_BLOCKS`] is tuned for a reader that has no
/// read-ahead of its own, which mpv is not — its demuxer cache already runs
/// [`PlayerConfig::readahead_seconds`] ahead of the picture, sequentially and
/// eagerly. Stacking a second speculative layer under that one does not add
/// buffer, it takes bandwidth away from the reads mpv is waiting on right now.
/// Measured cold, mid-file seek to 15:00 of a 761 MiB episode over a real link:
///
/// | blocks | first frame | seek resumed | blocks fetched |
/// |---:|---:|---:|---:|
/// | **0** | **2.5 s** | **1479 ms** | **12** |
/// | 6 | 3.7 s | 2225 ms | 21 |
/// | 12 | 5.5 s | 3581 ms | 31 |
/// | 24 | 7.7 s | 6122 ms | 42 |
///
/// Monotonic, and sustained playback holds realtime at 0 just as well. See
/// `docs/BUGS.md` B8.
pub const READAHEAD_BLOCKS: usize = 0;
/// Why playback of a file ended.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EndReason {
/// Played to the end.
Eof,
/// Stopped, or a different file was loaded.
Stopped,
/// The player is quitting.
Quit,
/// mpv could not play it.
Failed,
Other,
}
impl From<libmpv2::EndFileReason> for EndReason {
fn from(reason: libmpv2::EndFileReason) -> Self {
use libmpv2::mpv_end_file_reason as raw;
match reason {
raw::Eof => Self::Eof,
raw::Stop => Self::Stopped,
raw::Quit => Self::Quit,
raw::Error => Self::Failed,
_ => Self::Other,
}
}
}
/// What the UI needs from mpv, with mpv's borrowed types resolved away.
///
/// Owned on purpose: `libmpv2::Event` borrows out of the event queue slot, which
/// mpv reuses on the next `wait_event`, so it cannot cross a channel or outlive
/// a poll.
#[derive(Debug, Clone, PartialEq)]
pub enum PlayerEvent {
/// mpv is going away — the window was closed, or `quit` was issued. The
/// only event that must be acted on.
Shutdown,
StartFile,
/// Demuxing succeeded: duration and tracks are readable from here.
FileLoaded,
EndFile(EndReason),
/// A seek was issued. Playback has not resumed yet.
Seek,
/// Playback actually resumed, after a seek or after buffering. This, not
/// [`Self::Seek`], is when a seek is visibly done.
PlaybackRestart,
Position(f64),
Duration(f64),
Paused(bool),
/// Output volume, 0–100.
Volume(f64),
Muted(bool),
/// What the file contains and what is playing of it.
///
/// Not an mpv event: mpv reports a track list as a node property, and this
/// is emitted by whoever polls the player after a load or a selection. It
/// travels with the others because it lands in the same place.
Tracks(Vec<Track>),
/// The file's chapters. Emitted with the tracks, and for the same reason.
Chapters(Vec<Chapter>),
/// Anything not worth a variant. Ignorable.
Other,
}
/// Where mpv puts the picture.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VideoOutput {
/// mpv creates and owns a window, draws into it, and handles its own input.
/// Nothing else is needed to see a film.
#[default]
Window,
/// mpv draws nothing on its own. Frames go to a [`crate::VideoRenderer`]
/// the caller creates against its own OpenGL context and renders from its
/// own draw loop.
///
/// Two option changes come with this and neither is optional:
/// `vo=libmpv`, which is the video output the render API attaches to, and
/// `video-timing-offset=0`, without which
/// [`crate::VideoRenderer::render`] blocks until the frame's display time
/// and takes the caller's UI thread down to the video's frame rate.
Embedded,
}
/// How the mpv instance is set up. The defaults are the ones a person would
/// expect from the `mpv` command, which libmpv does *not* apply on its own.
#[derive(Debug, Clone)]
pub struct PlayerConfig {
/// Where the picture goes.
pub video: VideoOutput,
/// The window title, when mpv owns the window. Ignored when embedded.
pub window_title: String,
/// Hardware decode where the driver supports it. `auto-safe` rather than
/// `auto`, which is the setting that avoids the known-broken combinations.
pub hardware_decoding: bool,
/// mpv's built-in on-screen controller — the seek bar. Off in libmpv by
/// default; on here, because in step one it is the only UI there is.
pub on_screen_controller: bool,
/// mpv's usual keys: space, arrows, `q`. Also off in libmpv by default.
pub default_keybindings: bool,
/// How many seconds of media mpv buffers ahead of the playhead.
///
/// This is the one that actually governs how far mpv's reads run ahead of
/// what is on screen, and it is deliberately expressed in *seconds* rather
/// than bytes: a byte budget means two minutes of buffer on a 4 Mbit/s
/// episode and eight seconds of it on a 4K remux, which is exactly backwards
/// from what either wants.
pub readahead_seconds: f64,
/// Hard ceiling on that buffer.
///
/// Keep it comfortably under [`pstr_stream::DEFAULT_RING_BYTES`]. mpv's
/// buffer and our block read-ahead are stacked — mpv reads this far ahead of
/// the picture, and the block layer reads ahead of *that* — so the two
/// together are the resident working set. Sized larger than the ring, the
/// prefetch evicts its own blocks before the player reaches them, which is
/// the same trap `docs/BUGS.md` B7 records one layer down.
pub demuxer_max_bytes: Option<u64>,
/// Start paused. Useful when the caller wants to seek to a resume position
/// before the first frame.
pub start_paused: bool,
/// Output volume, 0–100. Clamped, because mpv will happily amplify past
/// 100 and nothing in this app offers that.
pub volume: f64,
pub muted: bool,
/// Which audio track to pick when the file offers a choice, as a language
/// tag ("jpn"). `None` leaves mpv's own default — the container's.
///
/// A preference rather than a selection on purpose: it is set once per
/// player and applies to every file it goes on to load, which is what makes
/// "Japanese audio" hold for the next episode as well as this one.
pub audio_language: Option<String>,
pub subtitle_language: Option<String>,
/// Whether subtitles are shown at all. Off is a choice a viewer makes once
/// and expects to keep, and no language preference can express it.
pub subtitles: bool,
/// Raw mpv options, applied last, so anything here wins.
pub options: Vec<(String, String)>,
}
/// The loudest this player goes. mpv's own ceiling is `volume-max`, which
/// defaults higher; amplifying past the source is a good way to find out what
/// a codec's clipping sounds like.
pub const MAX_VOLUME: f64 = 100.0;
impl Default for PlayerConfig {
fn default() -> Self {
Self {
video: VideoOutput::Window,
window_title: "proton-stream".into(),
hardware_decoding: true,
on_screen_controller: true,
default_keybindings: true,
readahead_seconds: 30.0,
demuxer_max_bytes: Some(48 * 1024 * 1024),
start_paused: false,
volume: MAX_VOLUME,
muted: false,
audio_language: None,
subtitle_language: None,
subtitles: true,
options: Vec::new(),
}
}
}
/// An mpv instance wired to the block layer.
pub struct Player {
/// Declared before `registry` so it is destroyed first: after this drops,
/// no stream callback can be running.
mpv: Mpv,
registry: Arc<StreamRegistry>,
}
impl Player {
/// Build an mpv instance with the `pstr://` protocol registered.
///
/// `runtime` is where every read mpv makes will run. It has to be
/// multi-threaded and it has to outlive the player, because mpv's demuxer
/// thread blocks on it from outside tokio.
pub fn new(runtime: Arc<Runtime>, config: PlayerConfig) -> Result<Self> {
let registry = StreamRegistry::new(runtime);
let mpv = Mpv::with_initializer(|init| {
init.set_property("title", config.window_title.as_str())?;
init.set_property(
"hwdec",
if config.hardware_decoding {
"auto-safe"
} else {
"no"
},
)?;
init.set_property("osc", config.on_screen_controller)?;
init.set_property("input-default-bindings", config.default_keybindings)?;
init.set_property("input-vo-keyboard", config.default_keybindings)?;
match config.video {
VideoOutput::Window => {
// Without a window there is nothing to see while audio-only
// or pre-roll frames are decoding, and the OSC has nowhere
// to draw.
init.set_property("force-window", true)?;
}
VideoOutput::Embedded => {
// The video output the render API attaches to. Set before
// initialisation, because mpv picks a vo once.
init.set_property("vo", "libmpv")?;
// Whatever the caller asked for, an embedded player must not
// conjure a second window.
init.set_property("force-window", false)?;
// Otherwise `mpv_render_context_render` blocks until the
// frame's display time — on the caller's UI thread, which
// would then run at the video's frame rate rather than the
// display's. See `render.rs`.
init.set_property("video-timing-offset", 0.0)?;
}
}
// The cache is what turns our 4 MiB blocks into smooth playback:
// without it every demuxer read would be a synchronous trip through
// the block layer at exactly the wrong moment.
init.set_property("cache", "yes")?;
init.set_property("cache-secs", config.readahead_seconds)?;
init.set_property("demuxer-readahead-secs", config.readahead_seconds)?;
if let Some(bytes) = config.demuxer_max_bytes {
init.set_property("demuxer-max-bytes", bytes as i64)?;
}
if config.start_paused {
init.set_property("pause", true)?;
}
init.set_property("volume", config.volume.clamp(0.0, MAX_VOLUME))?;
init.set_property("mute", config.muted)?;
// `alang`/`slang` are how mpv is asked to *prefer* a language: it
// picks the best match at load and falls back to the container's
// default when the file has nothing in that language, which is the
// behaviour that makes the preference survive a file that cannot
// honour it.
if let Some(language) = &config.audio_language {
init.set_property("alang", language.as_str())?;
}
if let Some(language) = &config.subtitle_language {
init.set_property("slang", language.as_str())?;
}
if !config.subtitles {
init.set_property("sid", "no")?;
}
for (name, value) in &config.options {
init.set_property(name.as_str(), value.as_str())?;
}
Ok(())
})?;
protocol::register(&mpv, &registry)?;
let player = Self { mpv, registry };
player.observe_playback_properties()?;
Ok(player)
}
/// Ask mpv to report the handful of properties the UI draws from.
fn observe_playback_properties(&self) -> Result<()> {
self.mpv.observe_property("time-pos", Format::Double, 0)?;
self.mpv.observe_property("duration", Format::Double, 0)?;
self.mpv.observe_property("pause", Format::Flag, 0)?;
// Volume and mute are observed rather than only written, because mpv
// changes them on its own: a keypress in its own window, or the volume
// it clamps a restored value to.
self.mpv.observe_property("volume", Format::Double, 0)?;
self.mpv.observe_property("mute", Format::Flag, 0)?;
Ok(())
}
/// Start playing a stream.
///
/// The returned handle keeps the URL resolvable; drop it once the file is
/// done with. Dropping it does not interrupt playback already underway.
pub fn play(&self, stream: VideoStream) -> Result<StreamHandle> {
let handle = self.registry.publish(stream);
self.mpv.command("loadfile", &[&handle.url()])?;
Ok(handle)
}
/// Play anything else mpv can open: a local path, or one of its own
/// pseudo-protocols.
///
/// Not how the app plays a film — that goes through [`Self::play`], because
/// the point of this crate is that mpv never learns a share's URL. What this
/// is for is checking the parts of the pipeline that have nothing to do with
/// Proton against something that always works: `av://lavfi:testsrc` renders
/// a moving pattern with no file, no account and no network, which is what
/// makes the render path testable at all.
pub fn play_url(&self, url: &str) -> Result<()> {
self.mpv.command("loadfile", &[url])?;
Ok(())
}
/// Take the next event, waiting up to `timeout` seconds. `0.0` polls.
///
/// Returns `None` when nothing happened. Errors from mpv are logged and
/// reported as [`PlayerEvent::Other`] rather than ending the loop — a
/// property that briefly has no value is normal, not fatal.
pub fn poll_event(&self, timeout: f64) -> Option<PlayerEvent> {
match self.mpv.wait_event(timeout)? {
Ok(event) => Some(translate(event)),
Err(error) => {
tracing::debug!(%error, "mpv event carried an error");
Some(PlayerEvent::Other)
}
}
}
pub fn set_paused(&self, paused: bool) -> Result<()> {
self.mpv.set_property("pause", paused)?;
Ok(())
}
pub fn is_paused(&self) -> Result<bool> {
Ok(self.mpv.get_property("pause")?)
}
/// Current playback position in seconds, or `None` before there is one.
pub fn position(&self) -> Option<f64> {
self.mpv.get_property("time-pos").ok()
}
pub fn duration(&self) -> Option<f64> {
self.mpv.get_property("duration").ok()
}
/// Seek to an absolute position, in seconds.
pub fn seek_to(&self, seconds: f64) -> Result<()> {
self.mpv
.command("seek", &[&format!("{seconds}"), "absolute"])?;
Ok(())
}
/// Seek by a signed number of seconds.
pub fn seek_by(&self, seconds: f64) -> Result<()> {
self.mpv
.command("seek", &[&format!("{seconds}"), "relative"])?;
Ok(())
}
/// Every track of the file that is open, with the playing ones marked.
///
/// Empty before [`PlayerEvent::FileLoaded`]: there is no file to have
/// tracks yet, which is not an error.
pub fn tracks(&self) -> Vec<Track> {
tracks::read(&self.mpv)
}
/// Every chapter of the file that is open, in order.
///
/// Empty for a file muxed without them, which most are and anime releases
/// are not.
pub fn chapters(&self) -> Vec<Chapter> {
chapters::read(&self.mpv)
}
/// Play the track with this id, or none of that kind at all.
///
/// `None` turns the kind off — the only sensible thing for subtitles, and
/// legal for audio too. The id is one of [`Self::tracks`]; mpv is told as a
/// string because "no" and "3" go to the same property.
pub fn select_track(&self, kind: TrackKind, id: Option<i64>) -> Result<()> {
let value = match id {
Some(id) => id.to_string(),
None => "no".to_string(),
};
self.mpv.set_property(kind.property(), value.as_str())?;
Ok(())
}
/// Prefer this language for a kind of track from the *next* file onwards.
///
/// It does not re-pick a track in what is already playing — that is
/// [`Self::select_track`] — which is exactly the split a viewer expects:
/// choosing Japanese here changes this episode, and the next one starts in
/// Japanese because of this.
pub fn prefer_language(&self, kind: TrackKind, language: Option<&str>) -> Result<()> {
let property = match kind {
TrackKind::Audio => "alang",
TrackKind::Subtitle => "slang",
// There is no `vlang`, and nobody picks a video track by language.
TrackKind::Video => return Ok(()),
};
self.mpv.set_property(property, language.unwrap_or(""))?;
Ok(())
}
/// Output volume, 0–100.
pub fn volume(&self) -> Option<f64> {
self.mpv.get_property("volume").ok()
}
pub fn set_volume(&self, volume: f64) -> Result<()> {
self.mpv
.set_property("volume", volume.clamp(0.0, MAX_VOLUME))?;
Ok(())
}
pub fn is_muted(&self) -> Result<bool> {
Ok(self.mpv.get_property("mute")?)
}
pub fn set_muted(&self, muted: bool) -> Result<()> {
self.mpv.set_property("mute", muted)?;
Ok(())
}
pub fn stop(&self) -> Result<()> {
self.mpv.command("stop", &[])?;
Ok(())
}
pub fn quit(&self) -> Result<()> {
self.mpv.command("quit", &[])?;
Ok(())
}
/// Escape hatch for options and commands this type does not wrap.
pub fn mpv(&self) -> &Mpv {
&self.mpv
}
/// The raw mpv handle, for the render API.
///
/// Only [`crate::VideoRenderer`] needs this, and only because
/// `mpv_render_context_create` takes the core rather than anything
/// libmpv2 wraps. The handle is valid for as long as this `Player` is.
pub(crate) fn raw_handle(&self) -> NonNull<libmpv2_sys::mpv_handle> {
self.mpv.ctx
}
/// The decoded video's size in pixels, once mpv has one.
///
/// `None` before the file is demuxed, and for a file with no video at all.
/// An embedded caller wants this to size the rectangle it gives mpv; a
/// windowed one has no use for it.
pub fn video_size(&self) -> Option<(i64, i64)> {
let width: i64 = self.mpv.get_property("dwidth").ok()?;
let height: i64 = self.mpv.get_property("dheight").ok()?;
(width > 0 && height > 0).then_some((width, height))
}
/// Set any mpv property. Same escape hatch, typed.
pub fn set_option(&self, name: &str, value: &str) -> Result<()> {
self.mpv.set_property(name, value).map_err(Error::from)
}
}
/// mpv's borrowed event to ours.
fn translate(event: Event<'_>) -> PlayerEvent {
match event {
Event::Shutdown => PlayerEvent::Shutdown,
Event::StartFile => PlayerEvent::StartFile,
Event::FileLoaded => PlayerEvent::FileLoaded,
Event::EndFile(reason) => PlayerEvent::EndFile(reason.into()),
Event::Seek => PlayerEvent::Seek,
Event::PlaybackRestart => PlayerEvent::PlaybackRestart,
Event::PropertyChange { name, change, .. } => match (name, change) {
("time-pos", PropertyData::Double(value)) => PlayerEvent::Position(value),
("duration", PropertyData::Double(value)) => PlayerEvent::Duration(value),
("pause", PropertyData::Flag(value)) => PlayerEvent::Paused(value),
("volume", PropertyData::Double(value)) => PlayerEvent::Volume(value),
("mute", PropertyData::Flag(value)) => PlayerEvent::Muted(value),
_ => PlayerEvent::Other,
},
_ => PlayerEvent::Other,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_end_reason_maps_to_something_the_ui_can_act_on() {
use libmpv2::mpv_end_file_reason as raw;
// Eof is "play the next episode"; the rest are not. Getting this
// backwards would auto-advance on a failed load.
assert_eq!(EndReason::from(raw::Eof), EndReason::Eof);
assert_eq!(EndReason::from(raw::Stop), EndReason::Stopped);
assert_eq!(EndReason::from(raw::Quit), EndReason::Quit);
assert_eq!(EndReason::from(raw::Error), EndReason::Failed);
assert_eq!(EndReason::from(raw::Redirect), EndReason::Other);
}
#[test]
fn observed_properties_translate_to_typed_events() {
assert_eq!(
translate(Event::PropertyChange {
name: "time-pos",
change: PropertyData::Double(12.5),
reply_userdata: 0,
}),
PlayerEvent::Position(12.5)
);
assert_eq!(
translate(Event::PropertyChange {
name: "pause",
change: PropertyData::Flag(true),
reply_userdata: 0,
}),
PlayerEvent::Paused(true)
);
// A property we observe arriving in an unexpected format must not be
// read as a position of zero.
assert_eq!(
translate(Event::PropertyChange {
name: "time-pos",
change: PropertyData::Int64(3),
reply_userdata: 0,
}),
PlayerEvent::Other
);
}
}
+154
View File
@@ -0,0 +1,154 @@
//! Registering `pstr://` with mpv.
//!
//! This is the only unsafe code in the crate, and it is deliberately thin: the
//! callbacks translate pointers and hand straight over to [`StreamCursor`],
//! which is a safe type with tests. Nothing here decides anything.
//!
//! Feeding mpv through `stream_cb` rather than a loopback HTTP server is a
//! security choice as much as a convenience one — there is no port to open, no
//! way for another local process to ask for the plaintext, and decrypted bytes
//! never leave the process.
use std::cell::UnsafeCell;
use std::ffi::{CStr, CString, c_char, c_int, c_void};
use std::sync::{Arc, Weak};
use libmpv2::Mpv;
use crate::cursor::{CancelLatch, StreamCursor};
use crate::error::{Error, Result};
use crate::registry::{PROTOCOL, StreamRegistry, parse_url};
const LOADING_FAILED: c_int = libmpv2::mpv_error::LoadingFailed;
/// Per-open-stream state, owned by mpv through a raw pointer.
///
/// The split matters. mpv serialises `read`/`seek`/`size`/`close` on one demuxer
/// thread, but calls `cancel` from another *while a read is blocked* — that is
/// the entire point of the callback. So the cursor lives behind an `UnsafeCell`
/// touched only by the serialised callbacks, the latch is a `Sync` type touched
/// by any of them, and no `&mut Cookie` is ever created. Taking `&mut Cookie` in
/// `read` and `&Cookie` in `cancel` would alias.
struct Cookie {
/// Read by whichever thread cancels; never exclusively borrowed.
cancel: CancelLatch,
/// Exclusively used, but only from mpv's demuxer thread.
cursor: UnsafeCell<StreamCursor>,
}
// SAFETY: see the note above — the `UnsafeCell` is only ever reached from the
// callbacks mpv serialises, and everything inside the cursor is `Send`.
unsafe impl Sync for Cookie {}
/// Register the `pstr://` protocol on an mpv handle.
///
/// The registration lasts until the mpv core is destroyed and cannot be undone,
/// which is what makes the `user_data` lifetime awkward: mpv may still call into
/// it while it is tearing itself down. Rather than reason about whether
/// `mpv_destroy` has joined every thread that could reach us, this leaks a
/// `Box<Weak<StreamRegistry>>` — a pointer and an `Arc` control block, tens of
/// bytes per player, permanently valid. The `Weak` is what keeps it honest: once
/// the player is dropped the upgrade fails and every `pstr://` URL cleanly stops
/// resolving, so the leak buys soundness without keeping any stream alive.
pub(crate) fn register(mpv: &Mpv, registry: &Arc<StreamRegistry>) -> Result<()> {
let name = CString::new(PROTOCOL).map_err(|_| Error::NulByte("the protocol name"))?;
let user_data = Box::into_raw(Box::new(Arc::downgrade(registry))).cast::<c_void>();
// SAFETY: `mpv` is a live handle, `name` outlives the call (mpv copies it),
// and `user_data` is valid forever by construction.
let status = unsafe {
libmpv2_sys::mpv_stream_cb_add_ro(mpv.ctx.as_ptr(), name.as_ptr(), user_data, Some(open))
};
if status < 0 {
return Err(Error::Mpv(libmpv2::Error::Raw(status)));
}
Ok(())
}
/// Resolve a `pstr://` URL to a cursor. Never blocks: the stream was opened,
/// and any failure reported, before this URL existed. See [`crate::registry`].
unsafe extern "C" fn open(
user_data: *mut c_void,
uri: *mut c_char,
info: *mut libmpv2_sys::mpv_stream_cb_info,
) -> c_int {
// SAFETY: the leaked `Weak` from `register`, valid for the process lifetime.
let registry = unsafe { &*user_data.cast::<Weak<StreamRegistry>>() };
let Some(registry) = registry.upgrade() else {
tracing::debug!("pstr:// opened after its player went away");
return LOADING_FAILED;
};
// SAFETY: mpv passes a NUL-terminated string valid for this call.
let uri = unsafe { CStr::from_ptr(uri) };
let Some(id) = uri.to_str().ok().and_then(parse_url) else {
tracing::warn!(uri = %uri.to_string_lossy(), "not a pstr:// URL");
return LOADING_FAILED;
};
let Some(stream) = registry.lookup(id) else {
tracing::warn!(id, "pstr:// token is not published");
return LOADING_FAILED;
};
let cookie = Box::into_raw(Box::new(Cookie {
cancel: CancelLatch::default(),
cursor: UnsafeCell::new(StreamCursor::new(stream, Arc::clone(registry.runtime()))),
}));
// SAFETY: `info` is ours to fill for the duration of this callback.
unsafe {
(*info).cookie = cookie.cast::<c_void>();
(*info).read_fn = Some(read);
(*info).seek_fn = Some(seek);
(*info).size_fn = Some(size);
(*info).close_fn = Some(close);
(*info).cancel_fn = Some(cancel);
}
0
}
unsafe extern "C" fn read(cookie: *mut c_void, buf: *mut c_char, nbytes: u64) -> i64 {
if nbytes == 0 {
return 0;
}
// SAFETY: our cookie, alive until `close`.
let cookie = unsafe { &*cookie.cast::<Cookie>() };
let len = usize::try_from(nbytes).unwrap_or(usize::MAX);
// SAFETY: mpv guarantees `nbytes` of writable buffer.
let buf = unsafe { std::slice::from_raw_parts_mut(buf.cast::<u8>(), len) };
// SAFETY: read/seek/size/close are serialised on one thread, so this is the
// only live borrow of the cursor.
let cursor = unsafe { &mut *cookie.cursor.get() };
cursor.read_caught(buf, &cookie.cancel)
}
unsafe extern "C" fn seek(cookie: *mut c_void, offset: i64) -> i64 {
// SAFETY: as in `read`.
let cookie = unsafe { &*cookie.cast::<Cookie>() };
let cursor = unsafe { &mut *cookie.cursor.get() };
cursor.seek(offset)
}
unsafe extern "C" fn size(cookie: *mut c_void) -> i64 {
// SAFETY: as in `read`.
let cookie = unsafe { &*cookie.cast::<Cookie>() };
let cursor = unsafe { &*cookie.cursor.get() };
cursor.size()
}
/// mpv calls this from another thread to interrupt a blocked read. Must not
/// block, and does not: it sets a flag and wakes the waiter.
unsafe extern "C" fn cancel(cookie: *mut c_void) {
// SAFETY: our cookie. Only the latch is touched, which is `Sync`.
let cookie = unsafe { &*cookie.cast::<Cookie>() };
cookie.cancel.cancel();
}
unsafe extern "C" fn close(cookie: *mut c_void) {
// SAFETY: mpv calls this exactly once per successful `open`, after which no
// callback for this stream runs again. Dropping releases the stream's
// reference to the ring and to the runtime.
drop(unsafe { Box::from_raw(cookie.cast::<Cookie>()) });
}
+185
View File
@@ -0,0 +1,185 @@
//! The table mpv looks a `pstr://` URL up in.
//!
//! mpv is given an opaque token, not an identity: a URL is `pstr://7`, never
//! `pstr://<share>/<link_id>`. Two reasons, both load-bearing.
//!
//! **The open callback must not do work.** Opening a Proton revision means link
//! details, an ancestor-key unlock and a revision listing — seconds, sometimes,
//! and it can fail for reasons a person needs to read (wrong password, revoked
//! link). mpv's open callback can only say *loading failed*. So the caller opens
//! the stream itself, in async code where the error survives, and registers the
//! result. What mpv's open callback then does is a hash lookup.
//!
//! **A share token is a secret.** The URL fragment of a Proton share link *is*
//! the decryption password. Putting it in a URL puts it in mpv's log at
//! `-v`, in the `path` property, and in the on-screen title. A `u64` leaks
//! nothing.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use pstr_stream::VideoStream;
use tokio::runtime::Runtime;
/// The protocol prefix registered with mpv.
pub const PROTOCOL: &str = "pstr";
/// Streams mpv can currently open, and the runtime their reads run on.
pub struct StreamRegistry {
/// Held as a whole runtime, not a `Handle`: mpv's demuxer thread blocks on
/// it from outside tokio, so it has to still exist when that thread runs. A
/// `Handle` would not keep it alive, and a read after shutdown panics.
runtime: Arc<Runtime>,
next: AtomicU64,
open: Mutex<HashMap<u64, VideoStream>>,
}
impl StreamRegistry {
pub fn new(runtime: Arc<Runtime>) -> Arc<Self> {
Arc::new(Self {
runtime,
next: AtomicU64::new(1),
open: Mutex::new(HashMap::new()),
})
}
/// Publish a stream and get back the handle that keeps it published.
pub fn publish(self: &Arc<Self>, stream: VideoStream) -> StreamHandle {
let id = self.next.fetch_add(1, Ordering::Relaxed);
self.lock().insert(id, stream);
StreamHandle {
registry: Arc::clone(self),
id,
}
}
/// The stream behind a token, cloned so the caller does not hold the lock
/// while mpv reads.
pub(crate) fn lookup(&self, id: u64) -> Option<VideoStream> {
self.lock().get(&id).cloned()
}
pub(crate) fn runtime(&self) -> &Arc<Runtime> {
&self.runtime
}
/// How many streams are published. Diagnostics only.
pub fn len(&self) -> usize {
self.lock().len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<u64, VideoStream>> {
// Non-poisoning: a panic in an unrelated handler must not make every
// later `pstr://` URL unopenable.
self.open.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// Keeps one stream openable by mpv. Dropping it revokes the URL.
///
/// Revoking does not disturb a playback already in progress — mpv holds a
/// cookie cloned at open time, and only a *re*-open would consult the registry.
/// It does mean the caller decides when a finished episode stops being
/// reachable, rather than leaving every stream it ever played addressable.
pub struct StreamHandle {
registry: Arc<StreamRegistry>,
id: u64,
}
impl StreamHandle {
/// The URL to hand mpv.
pub fn url(&self) -> String {
format!("{PROTOCOL}://{}", self.id)
}
pub fn id(&self) -> u64 {
self.id
}
}
impl Drop for StreamHandle {
fn drop(&mut self) {
self.registry.lock().remove(&self.id);
}
}
/// Pull the token out of a `pstr://` URL.
///
/// Deliberately strict — anything that is not exactly the shape we emit is a
/// miss, not a guess. mpv hands us whatever the user typed.
pub(crate) fn parse_url(uri: &str) -> Option<u64> {
let rest = uri.strip_prefix(PROTOCOL)?.strip_prefix("://")?;
// A trailing slash is what a URL parser would leave behind; nothing else.
let rest = rest.strip_suffix('/').unwrap_or(rest);
rest.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{test_runtime as runtime, test_stream};
#[test]
fn a_published_stream_is_reachable_through_its_url() {
let registry = StreamRegistry::new(runtime());
let handle = registry.publish(test_stream(registry.runtime()));
let id = parse_url(&handle.url()).expect("url parses");
assert_eq!(id, handle.id());
assert!(registry.lookup(id).is_some());
}
#[test]
fn dropping_the_handle_revokes_the_url() {
let registry = StreamRegistry::new(runtime());
let id = {
let handle = registry.publish(test_stream(registry.runtime()));
parse_url(&handle.url()).expect("url parses")
};
assert!(registry.lookup(id).is_none());
assert!(registry.is_empty());
}
#[test]
fn two_streams_get_distinct_urls() {
let registry = StreamRegistry::new(runtime());
let first = registry.publish(test_stream(registry.runtime()));
let second = registry.publish(test_stream(registry.runtime()));
assert_ne!(first.url(), second.url());
assert_eq!(registry.len(), 2);
}
#[test]
fn a_token_is_never_reused_after_the_stream_behind_it_is_gone() {
// Otherwise a stale URL — mpv retrying a load, a playlist entry — would
// silently start playing whatever took the number over.
let registry = StreamRegistry::new(runtime());
let first_url = {
let handle = registry.publish(test_stream(registry.runtime()));
handle.url()
};
let second = registry.publish(test_stream(registry.runtime()));
assert_ne!(first_url, second.url());
assert!(registry.lookup(parse_url(&first_url).unwrap()).is_none());
}
#[test]
fn only_well_formed_pstr_urls_parse() {
assert_eq!(parse_url("pstr://12"), Some(12));
assert_eq!(parse_url("pstr://12/"), Some(12));
assert_eq!(parse_url("pstr://"), None);
assert_eq!(parse_url("pstr://abc"), None);
assert_eq!(parse_url("pstr://12/extra"), None);
assert_eq!(parse_url("file:///tmp/a.mkv"), None);
assert_eq!(parse_url("pstr:12"), None);
}
}
+268
View File
@@ -0,0 +1,268 @@
//! Handing mpv an OpenGL context to draw into, instead of a window of its own.
//!
//! Step two. Everything about *getting the bytes* — the `pstr://` protocol, the
//! cursor, the block layer under it — is unchanged; this is only about where the
//! decoded picture lands. With a render context alive and
//! [`crate::VideoOutput::Embedded`] set, mpv's `libmpv` video output hands each
//! frame to [`VideoRenderer::render`] instead of putting it on screen.
//!
//! ```text
//! ui thread (GL current) player thread
//! ────────────────────── ─────────────
//! renderer.update() ─▶ new frame? poll_event / seek / pause
//! renderer.render(fbo, w, h) │
//! │ ▼
//! └──────────────▶ mpv core ◀───────────┘
//! ▲
//! └── demuxer thread ── pstr:// ── blocks
//! ```
//!
//! Three constraints run through the whole file, all of them mpv's:
//!
//! * **The GL context must be current on the calling thread** for
//! [`VideoRenderer::new`], [`VideoRenderer::render`] *and* the destructor.
//! That is why this type is not `Send`: it belongs to the UI thread, while the
//! [`Player`] it renders is shared with the thread polling events.
//! * **The render context must outlive nothing and predecease the core.** It is
//! freed in `Drop`, and the `Arc<Player>` held here is what guarantees the mpv
//! handle is still alive when that happens.
//! * **`render` must not block.** Left to itself mpv waits inside it until the
//! frame's display time, which would peg the UI thread at the video's frame
//! rate. [`crate::VideoOutput::Embedded`] sets `video-timing-offset` to 0 to
//! turn that off, and the update callback is what drives repaints instead.
//!
//! `MPV_RENDER_PARAM_ADVANCED_CONTROL` is deliberately *not* set. It buys
//! finer-grained scheduling in exchange for a contract about calling
//! `mpv_render_context_update` from the right thread at the right time, and the
//! thing it optimises — frame pacing on a local file — is not what limits
//! playback over a public link.
use std::ffi::{c_char, c_void};
use std::ptr::NonNull;
use std::sync::Arc;
use crate::error::{Error, Result};
use crate::gl::GlLoader;
use crate::player::Player;
/// mpv's callback when a new frame is ready, or the video needs redrawing.
///
/// Called from mpv's own threads, so it has to be `Send + Sync`. The one thing
/// it may do is wake the UI — mpv forbids calling back into its API from here.
type UpdateHook = Box<dyn Fn() + Send + Sync>;
/// mpv rendering into a framebuffer the caller owns.
pub struct VideoRenderer {
ctx: NonNull<libmpv2_sys::mpv_render_context>,
/// Keeps the mpv core alive: freeing the render context after the core is
/// gone is undefined, and this makes that unrepresentable.
_player: Arc<Player>,
/// Kept alive because mpv may resolve further GL entry points long after
/// construction, when the video chain reconfigures.
_loader: Box<GlLoader>,
/// Freed after the render context, so mpv cannot call a dropped closure.
hook: Option<*mut UpdateHook>,
}
impl VideoRenderer {
/// Create the render context on the current OpenGL context.
///
/// # Safety
///
/// An OpenGL context must be current on the calling thread, and must stay
/// current for every later [`Self::render`] call and for the drop. The
/// caller must also drop this on that same thread.
pub unsafe fn new(player: Arc<Player>) -> Result<Self> {
let loader = Box::new(GlLoader::new());
let mut init = libmpv2_sys::mpv_opengl_init_params {
get_proc_address: Some(get_proc_address),
// Borrowed by mpv for the life of the context; `loader` is boxed and
// moved into the returned value, so the address stays valid.
get_proc_address_ctx: (&raw const *loader) as *mut c_void,
};
let mut params = [
// `MPV_RENDER_PARAM_API_TYPE` is typed `char*`, so the data field is
// the string itself rather than a pointer to it — unlike every other
// parameter here.
libmpv2_sys::mpv_render_param {
type_: libmpv2_sys::mpv_render_param_type_MPV_RENDER_PARAM_API_TYPE,
data: libmpv2_sys::MPV_RENDER_API_TYPE_OPENGL.as_ptr() as *mut c_void,
},
libmpv2_sys::mpv_render_param {
type_: libmpv2_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_INIT_PARAMS,
data: (&raw mut init) as *mut c_void,
},
// The array is NUL-terminated by a zero type, exactly like a C
// string; without it mpv walks off the end.
libmpv2_sys::mpv_render_param {
type_: 0,
data: std::ptr::null_mut(),
},
];
let mut ctx: *mut libmpv2_sys::mpv_render_context = std::ptr::null_mut();
// SAFETY: the handle is live for as long as `player` is held, the
// parameter array is well-formed and only read during the call, and the
// caller has promised a current GL context.
let status = unsafe {
libmpv2_sys::mpv_render_context_create(
&raw mut ctx,
player.raw_handle().as_ptr(),
params.as_mut_ptr(),
)
};
if status < 0 {
return Err(Error::Mpv(libmpv2::Error::Raw(status)));
}
let ctx = NonNull::new(ctx).ok_or(Error::RenderContext)?;
Ok(Self {
ctx,
_player: player,
_loader: loader,
hook: None,
})
}
/// Ask to be told when there is a new frame to draw.
///
/// The callback runs on an mpv thread and must do nothing but wake the UI —
/// no mpv API calls, no locks the UI thread might already hold. Calling this
/// raises the callback once immediately, which is what gets the first frame
/// on screen.
pub fn on_update(&mut self, hook: impl Fn() + Send + Sync + 'static) {
let hook: *mut UpdateHook = Box::into_raw(Box::new(Box::new(hook)));
// SAFETY: `ctx` is live, and `hook` stays valid until `Drop` frees it —
// which it does only after `mpv_render_context_free`, so mpv cannot call
// a dangling closure.
unsafe {
libmpv2_sys::mpv_render_context_set_update_callback(
self.ctx.as_ptr(),
Some(on_update),
hook as *mut c_void,
);
}
// Replacing a previous hook: mpv has stopped calling the old one, so it
// is safe to reclaim now.
if let Some(previous) = self.hook.replace(hook) {
// SAFETY: created by this function's earlier call and no longer
// reachable from mpv.
drop(unsafe { Box::from_raw(previous) });
}
}
/// Whether mpv has a new frame waiting.
///
/// Cheap, and safe from any thread. A `false` means the last rendered
/// picture is still current — the caller should reuse its texture rather
/// than re-render, which is the whole point of asking.
pub fn has_new_frame(&self) -> bool {
// SAFETY: `ctx` is live, and this call is documented as thread-safe.
let flags = unsafe { libmpv2_sys::mpv_render_context_update(self.ctx.as_ptr()) };
flags & u64::from(libmpv2_sys::mpv_render_update_flag_MPV_RENDER_UPDATE_FRAME) != 0
}
/// Draw the current frame into `fbo`, which must be `width` × `height`.
///
/// mpv scales and letterboxes the picture to fill that rectangle itself, so
/// the caller does not have to know the video's aspect ratio — the bars are
/// painted in the same pass.
///
/// `flip` is mpv's `MPV_RENDER_PARAM_FLIP_Y`, for the case where the target
/// is scanned out bottom-up — the OpenGL default framebuffer. An FBO whose
/// colour texture is then *sampled* is not that case: unflipped, mpv puts
/// the top of the picture in the first row, which is where egui's V axis
/// starts. Passing `true` there stands the picture on its head.
///
/// # Safety
///
/// The same OpenGL context that was current for [`Self::new`] must be
/// current on the calling thread, and `fbo` must name a complete
/// framebuffer in it.
pub unsafe fn render(&self, fbo: i32, width: i32, height: i32, flip: bool) -> Result<()> {
let mut target = libmpv2_sys::mpv_opengl_fbo {
fbo,
w: width,
h: height,
internal_format: 0,
};
let mut flip_y: std::ffi::c_int = i32::from(flip);
let mut params = [
libmpv2_sys::mpv_render_param {
type_: libmpv2_sys::mpv_render_param_type_MPV_RENDER_PARAM_OPENGL_FBO,
data: (&raw mut target) as *mut c_void,
},
libmpv2_sys::mpv_render_param {
type_: libmpv2_sys::mpv_render_param_type_MPV_RENDER_PARAM_FLIP_Y,
data: (&raw mut flip_y) as *mut c_void,
},
libmpv2_sys::mpv_render_param {
type_: 0,
data: std::ptr::null_mut(),
},
];
// SAFETY: `ctx` is live, the parameters are only read during the call,
// and the caller has promised the right context is current.
let status = unsafe {
libmpv2_sys::mpv_render_context_render(self.ctx.as_ptr(), params.as_mut_ptr())
};
if status < 0 {
return Err(Error::Mpv(libmpv2::Error::Raw(status)));
}
Ok(())
}
}
impl Drop for VideoRenderer {
/// Free the render context.
///
/// Must run on the thread whose OpenGL context this was created against,
/// with that context still current — mpv frees GL objects here. The app
/// drops this from its draw function or from `on_exit`, both of which
/// satisfy that; dropping it anywhere else is a bug this type cannot catch.
fn drop(&mut self) {
// SAFETY: `ctx` was created here and is freed exactly once. mpv
// guarantees no further update callbacks after this returns, which is
// what makes reclaiming the hook below sound.
unsafe {
libmpv2_sys::mpv_render_context_free(self.ctx.as_ptr());
}
if let Some(hook) = self.hook.take() {
// SAFETY: allocated in `on_update`, and now unreachable from mpv.
drop(unsafe { Box::from_raw(hook) });
}
}
}
/// mpv asking for one GL entry point by name.
unsafe extern "C" fn get_proc_address(ctx: *mut c_void, name: *const c_char) -> *mut c_void {
if ctx.is_null() || name.is_null() {
return std::ptr::null_mut();
}
// SAFETY: `ctx` is the `GlLoader` this renderer boxed and keeps alive, and
// `name` is a NUL-terminated string owned by mpv for the length of the call.
let loader = unsafe { &*(ctx as *const GlLoader) };
let Ok(name) = (unsafe { std::ffi::CStr::from_ptr(name) }).to_str() else {
return std::ptr::null_mut();
};
loader.resolve(name)
}
/// mpv reporting that there is something new to draw.
///
/// Deliberately does nothing but call the hook: this runs on an mpv thread, and
/// mpv forbids re-entering its API from here.
unsafe extern "C" fn on_update(ctx: *mut c_void) {
if ctx.is_null() {
return;
}
// SAFETY: `ctx` is the boxed hook, alive until the render context is freed.
let hook = unsafe { &*(ctx as *const UpdateHook) };
hook();
}
+62
View File
@@ -0,0 +1,62 @@
//! Fixtures shared by this crate's tests.
//!
//! Everything below mpv runs against an in-memory block source, so the cursor's
//! behaviour — seeks, boundaries, EOF, cancellation — is pinned without an
//! account, a network or a window.
use std::sync::Arc;
use async_trait::async_trait;
use pstr_stream::{
MemoryBlocks, NodeUid, RevisionOpener, SharedBlocks, StreamConfig, StreamSource, VideoStream,
};
use tokio::runtime::Runtime;
/// Non-uniform on purpose, with a short tail: a uniform-block fixture would let
/// an off-by-one in block mapping pass.
pub(crate) const CONTENT_BLOCKS: [usize; 4] = [4096, 4096, 4096, 1000];
/// `byte[i] == (i % 251) as u8`. 251 is prime and coprime with every block
/// size here, so bytes served from the wrong block never coincidentally match.
fn content() -> Vec<Vec<u8>> {
let mut at = 0_usize;
CONTENT_BLOCKS
.iter()
.map(|len| {
let block = (at..at + len).map(|i| (i % 251) as u8).collect();
at += len;
block
})
.collect()
}
struct Fixed(SharedBlocks);
#[async_trait]
impl RevisionOpener for Fixed {
async fn open(&self, _share: &str, _uid: &NodeUid) -> pstr_stream::Result<SharedBlocks> {
Ok(Arc::clone(&self.0))
}
}
pub(crate) fn test_runtime() -> Arc<Runtime> {
Arc::new(
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("build a test runtime"),
)
}
pub(crate) fn test_stream(runtime: &Arc<Runtime>) -> VideoStream {
let blocks: SharedBlocks = Arc::new(MemoryBlocks::new("rev-1", content()));
runtime.block_on(async {
let source = StreamSource::new(Arc::new(Fixed(blocks)), StreamConfig::default())
.await
.expect("build a stream source");
source
.open("share-1", &NodeUid::new("volume-1".into(), "link-1".into()))
.await
.expect("open the fixture stream")
})
}
+254
View File
@@ -0,0 +1,254 @@
//! What is inside the file, and which of it is playing.
//!
//! mpv exposes this as `track-list`, a node property. It is read here one
//! sub-property at a time — `track-list/N/lang` and friends — rather than as a
//! whole node, because those are plain strings, ints and flags that
//! [`libmpv2::Mpv::get_property`] can already return, and a node walk would mean
//! reaching past the wrapper into `libmpv2_sys` for a shape that does not change
//! how any of this behaves.
//!
//! A missing sub-property is not an error: most tracks have no title, plenty
//! have no language, and mpv answers those with *unavailable* rather than with
//! an empty string.
use libmpv2::Mpv;
/// What a track carries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TrackKind {
Video,
Audio,
Subtitle,
}
impl TrackKind {
/// mpv's own name for the kind, as `track-list/N/type` reports it.
fn from_mpv(value: &str) -> Option<Self> {
match value {
"video" => Some(Self::Video),
"audio" => Some(Self::Audio),
"sub" => Some(Self::Subtitle),
// `sub2` is the secondary subtitle slot, which this player does not
// offer; anything else is a kind mpv grew after this was written.
_ => None,
}
}
/// The mpv property that selects a track of this kind.
pub fn property(self) -> &'static str {
match self {
Self::Video => "vid",
Self::Audio => "aid",
Self::Subtitle => "sid",
}
}
/// What to call this in a menu.
pub fn label(self) -> &'static str {
match self {
Self::Video => "Video",
Self::Audio => "Audio",
Self::Subtitle => "Subtitles",
}
}
}
/// One track of a file, as mpv sees it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Track {
/// mpv's id for the track — what `aid`/`sid` are set to. One-based, and
/// unique only within a kind.
pub id: i64,
pub kind: TrackKind,
/// The name the muxer gave it, if any: "Signs & Songs", "Commentary".
pub title: Option<String>,
/// The language tag, as it sits in the container: "eng", "jpn".
pub language: Option<String>,
pub codec: Option<String>,
/// Whether this is the track currently playing.
pub selected: bool,
/// Whether the container marks it as the default choice.
pub default: bool,
/// True for a track added from outside the file — a sidecar subtitle. None
/// of ours are, yet.
pub external: bool,
}
impl Track {
/// What the menu shows: language first, because that is what a viewer is
/// choosing between, then whatever distinguishes two tracks of the same
/// language.
pub fn label(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(language) = &self.language {
parts.push(language_name(language).to_string());
}
if let Some(title) = &self.title {
parts.push(title.clone());
}
if parts.is_empty() {
// Nothing named it. The id is at least stable and distinct, which
// is more than "Unknown" twice over would be.
parts.push(format!("{} {}", self.kind.label(), self.id));
}
let mut label = parts.join(" — ");
if let Some(codec) = &self.codec {
label.push_str(&format!(" [{codec}]"));
}
label
}
}
/// Every track of the file mpv currently has open.
pub(crate) fn read(mpv: &Mpv) -> Vec<Track> {
let count: i64 = mpv.get_property("track-list/count").unwrap_or(0);
(0..count)
.filter_map(|index| read_one(mpv, index))
.collect()
}
fn read_one(mpv: &Mpv, index: i64) -> Option<Track> {
let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?;
let id: i64 = mpv
.get_property(&format!("track-list/{index}/id"))
.ok()
.filter(|id| *id > 0)?;
Some(Track {
id,
kind,
title: text(mpv, index, "title"),
language: text(mpv, index, "lang"),
codec: text(mpv, index, "codec"),
selected: flag(mpv, index, "selected"),
default: flag(mpv, index, "default"),
external: flag(mpv, index, "external"),
})
}
/// A string sub-property, with "absent" and "present but empty" collapsed:
/// a track whose title is the empty string has no title.
fn text(mpv: &Mpv, index: i64, field: &str) -> Option<String> {
mpv.get_property::<String>(&format!("track-list/{index}/{field}"))
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn flag(mpv: &Mpv, index: i64, field: &str) -> bool {
mpv.get_property(&format!("track-list/{index}/{field}"))
.unwrap_or(false)
}
/// A readable name for a language tag, falling back to the tag itself.
///
/// Deliberately a short list rather than a full ISO 639 table: it covers what
/// the shares this app is pointed at actually contain, and an unrecognised tag
/// still shows as something a viewer can pick between — `"por-BR"` reads fine
/// on its own.
pub fn language_name(tag: &str) -> String {
// Container tags come in both the two- and three-letter forms, and often
// with a region suffix: "en", "eng", "en-US", "pt-BR".
let base = tag
.split(['-', '_'])
.next()
.unwrap_or(tag)
.to_ascii_lowercase();
let name = match base.as_str() {
"en" | "eng" => "English",
"ja" | "jpn" => "Japanese",
"de" | "ger" | "deu" => "German",
"fr" | "fre" | "fra" => "French",
"es" | "spa" => "Spanish",
"it" | "ita" => "Italian",
"pt" | "por" => "Portuguese",
"nl" | "dut" | "nld" => "Dutch",
"ru" | "rus" => "Russian",
"pl" | "pol" => "Polish",
"sv" | "swe" => "Swedish",
"da" | "dan" => "Danish",
"no" | "nor" => "Norwegian",
"fi" | "fin" => "Finnish",
"cs" | "cze" | "ces" => "Czech",
"hu" | "hun" => "Hungarian",
"tr" | "tur" => "Turkish",
"ar" | "ara" => "Arabic",
"he" | "heb" => "Hebrew",
"hi" | "hin" => "Hindi",
"ko" | "kor" => "Korean",
"zh" | "chi" | "zho" => "Chinese",
"th" | "tha" => "Thai",
"vi" | "vie" => "Vietnamese",
"uk" | "ukr" => "Ukrainian",
"ro" | "rum" | "ron" => "Romanian",
"el" | "gre" | "ell" => "Greek",
"id" | "ind" => "Indonesian",
"ms" | "may" | "msa" => "Malay",
_ => return tag.to_string(),
};
// The region matters when there is one — Brazilian Portuguese is not
// Portuguese to anyone choosing between the two.
match tag.split_once(['-', '_']) {
Some((_, region)) if !region.is_empty() => {
format!("{name} ({})", region.to_ascii_uppercase())
}
_ => name.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn track(kind: TrackKind, id: i64) -> Track {
Track {
id,
kind,
title: None,
language: None,
codec: None,
selected: false,
default: false,
external: false,
}
}
#[test]
fn a_track_with_nothing_but_an_id_still_has_a_distinct_label() {
// Two nameless audio tracks are a real container; a menu of two
// identical entries is not a choice.
assert_eq!(track(TrackKind::Audio, 1).label(), "Audio 1");
assert_eq!(track(TrackKind::Audio, 2).label(), "Audio 2");
}
#[test]
fn a_labelled_track_reads_language_first_then_what_distinguishes_it() {
let mut sub = track(TrackKind::Subtitle, 2);
sub.language = Some("eng".into());
sub.title = Some("Signs & Songs".into());
sub.codec = Some("ass".into());
assert_eq!(sub.label(), "English — Signs & Songs [ass]");
}
#[test]
fn language_tags_resolve_in_both_iso_forms_and_keep_their_region() {
assert_eq!(language_name("jpn"), "Japanese");
assert_eq!(language_name("ja"), "Japanese");
assert_eq!(language_name("pt-BR"), "Portuguese (BR)");
// An unknown tag is shown as it stands rather than as "Unknown", which
// would make two of them indistinguishable.
assert_eq!(language_name("mis"), "mis");
}
#[test]
fn only_the_kinds_the_player_can_select_are_tracks() {
assert_eq!(TrackKind::from_mpv("audio"), Some(TrackKind::Audio));
assert_eq!(TrackKind::from_mpv("sub"), Some(TrackKind::Subtitle));
// The secondary subtitle slot is a separate property this player does
// not drive; treating it as a subtitle track would put an entry in the
// menu that `sid` cannot select.
assert_eq!(TrackKind::from_mpv("sub2"), None);
}
}
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "pstr-stream"
description = "Seekable, cached byte streams over Proton Drive revisions."
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
pstr-core.workspace = true
proton-sdk.workspace = true
proton-drive-rs.workspace = true
tokio.workspace = true
futures.workspace = true
thiserror.workspace = true
tracing.workspace = true
lru.workspace = true
sha2.workspace = true
async-trait.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
+281
View File
@@ -0,0 +1,281 @@
//! What the stream layer needs from whatever is holding the bytes.
//!
//! A Proton revision is a list of independently-encrypted content blocks, and
//! every layer above this one — the memory ring, the disk cache, the read-ahead
//! — thinks purely in whole blocks. So the seam is a *block* source, not a byte
//! source: [`BlockSource`] hands over one decrypted block at a time, and
//! [`BlockMap`] turns a byte range into the blocks that overlap it.
//!
//! The trait exists for a second reason, and it is the one that pays: with it,
//! everything in this crate is testable against a deterministic fake, with no
//! account, no network and no share. Only [`crate::reader`] touches the SDK.
use std::sync::Arc;
use async_trait::async_trait;
use crate::error::Result;
/// A revision's decrypted blocks, addressed by index.
///
/// Implementors must be cheap to keep alive — one is held per open stream.
#[async_trait]
pub trait BlockSource: Send + Sync + 'static {
/// The revision these blocks belong to.
///
/// This is the cache validity key. Unlike a modification time it advances
/// **iff** a new revision was sealed, so bytes cached under one revision id
/// can never be served for another one's content.
fn revision_id(&self) -> &str;
/// Plaintext size of each block, in block order.
///
/// Never assume 4 MiB. Proton's block size is a property of the *uploader*,
/// the last block is short, and a padded size vector would silently serve
/// bytes from the wrong offset.
fn block_sizes(&self) -> &[u64];
/// Fetch and decrypt one block.
async fn read_block(&self, index: usize) -> Result<Vec<u8>>;
}
/// Where each block starts in the plaintext, so a byte range can be resolved to
/// blocks without a scan.
#[derive(Debug, Clone)]
pub struct BlockMap {
/// Plaintext offset of each block. `starts[i] + sizes[i] == starts[i + 1]`.
starts: Vec<u64>,
sizes: Vec<u64>,
size: u64,
}
impl BlockMap {
pub fn new(sizes: &[u64]) -> Self {
let mut starts = Vec::with_capacity(sizes.len());
let mut offset = 0_u64;
for &size in sizes {
starts.push(offset);
offset = offset.saturating_add(size);
}
Self {
starts,
sizes: sizes.to_vec(),
size: offset,
}
}
/// Total plaintext size of the revision.
pub fn size(&self) -> u64 {
self.size
}
/// How many blocks the revision has.
pub fn len(&self) -> usize {
self.sizes.len()
}
pub fn is_empty(&self) -> bool {
self.sizes.is_empty()
}
/// Plaintext offset where block `index` starts.
pub fn start_of(&self, index: usize) -> Option<u64> {
self.starts.get(index).copied()
}
/// Plaintext size of block `index`.
pub fn size_of(&self, index: usize) -> Option<u64> {
self.sizes.get(index).copied()
}
/// The block holding byte `offset`, or `None` at or past EOF.
///
/// Binary search rather than a divide: block sizes are uniform in practice
/// but nothing guarantees it, and a wrong division here is the exact bug the
/// SDK warns about — bytes served from the wrong offset, silently.
pub fn block_at(&self, offset: u64) -> Option<usize> {
if offset >= self.size {
return None;
}
match self.starts.binary_search(&offset) {
Ok(index) => Some(index),
// `starts` is sorted and starts at 0, so a miss is never at 0.
Err(index) => Some(index - 1),
}
}
/// Every block index overlapping `[offset, end)`, clamped to the revision.
pub fn blocks_in(&self, offset: u64, end: u64) -> std::ops::Range<usize> {
let end = end.min(self.size);
if offset >= end {
return 0..0;
}
let first = self.block_at(offset).unwrap_or(0);
// `end` is exclusive, so the last byte read is `end - 1`.
let last = self.block_at(end - 1).unwrap_or(first);
first..last + 1
}
}
/// Copy the part of `block` that falls inside `[offset, end)` into `out`.
///
/// `block_start` is where this block begins in the plaintext. Returns how many
/// bytes were written.
pub(crate) fn splice(
out: &mut [u8],
out_base: u64,
block: &[u8],
block_start: u64,
offset: u64,
end: u64,
) -> usize {
let block_end = block_start.saturating_add(block.len() as u64);
let from = offset.max(block_start);
let to = end.min(block_end);
if from >= to {
return 0;
}
let src = (from - block_start) as usize..(to - block_start) as usize;
let dst_start = (from - out_base) as usize;
let len = src.end - src.start;
out[dst_start..dst_start + len].copy_from_slice(&block[src]);
len
}
/// A `BlockSource` over bytes already in memory. Used by the crate's own tests
/// and by the benchmark's warm-up path.
#[derive(Debug)]
pub struct MemoryBlocks {
revision: String,
blocks: Vec<Vec<u8>>,
sizes: Vec<u64>,
}
impl MemoryBlocks {
pub fn new(revision: impl Into<String>, blocks: Vec<Vec<u8>>) -> Self {
let sizes = blocks.iter().map(|block| block.len() as u64).collect();
Self {
revision: revision.into(),
blocks,
sizes,
}
}
}
#[async_trait]
impl BlockSource for MemoryBlocks {
fn revision_id(&self) -> &str {
&self.revision
}
fn block_sizes(&self) -> &[u64] {
&self.sizes
}
async fn read_block(&self, index: usize) -> Result<Vec<u8>> {
self.blocks
.get(index)
.cloned()
.ok_or_else(|| crate::Error::NotFound(format!("block {index} is past the end")))
}
}
/// Boxed for storage in the stream.
pub type SharedBlocks = Arc<dyn BlockSource>;
#[cfg(test)]
mod tests {
use super::*;
/// The realistic shape: uniform blocks with a short tail.
fn map() -> BlockMap {
BlockMap::new(&[4096, 4096, 1000])
}
#[test]
fn a_block_map_sums_to_the_revision_size() {
assert_eq!(map().size(), 9192);
assert_eq!(map().len(), 3);
}
#[test]
fn an_offset_resolves_to_the_block_holding_it() {
let map = map();
assert_eq!(map.block_at(0), Some(0));
assert_eq!(map.block_at(4095), Some(0));
assert_eq!(map.block_at(4096), Some(1));
assert_eq!(map.block_at(8191), Some(1));
assert_eq!(map.block_at(8192), Some(2));
assert_eq!(map.block_at(9191), Some(2));
}
#[test]
fn an_offset_at_or_past_the_end_resolves_to_nothing() {
assert_eq!(map().block_at(9192), None);
assert_eq!(map().block_at(u64::MAX), None);
}
#[test]
fn a_range_covers_every_block_it_touches() {
let map = map();
assert_eq!(map.blocks_in(0, 1), 0..1);
assert_eq!(map.blocks_in(4095, 4097), 0..2);
assert_eq!(map.blocks_in(0, 9192), 0..3);
assert_eq!(map.blocks_in(8192, 9192), 2..3);
}
/// A read ending exactly on a boundary must not pull the next block — that
/// would be a 4 MiB fetch for zero bytes.
#[test]
fn a_range_ending_on_a_boundary_stops_short_of_the_next_block() {
assert_eq!(map().blocks_in(0, 4096), 0..1);
assert_eq!(map().blocks_in(4096, 8192), 1..2);
}
#[test]
fn an_empty_or_past_the_end_range_covers_nothing() {
let map = map();
assert!(map.blocks_in(100, 100).is_empty());
assert!(map.blocks_in(9192, 10000).is_empty());
assert!(map.blocks_in(500, 100).is_empty());
}
/// Non-uniform block sizes are the case a divide-by-block-size would get
/// wrong, so they are pinned explicitly.
#[test]
fn non_uniform_blocks_still_resolve_correctly() {
let map = BlockMap::new(&[10, 1000, 5]);
assert_eq!(map.block_at(9), Some(0));
assert_eq!(map.block_at(10), Some(1));
assert_eq!(map.block_at(1009), Some(1));
assert_eq!(map.block_at(1010), Some(2));
assert_eq!(map.size(), 1015);
}
#[test]
fn a_spliced_block_lands_at_the_right_place_in_the_output() {
// Reading bytes 4090..4106 of the map above: 6 bytes from block 0, 10
// from block 1.
let (offset, end) = (4090_u64, 4106_u64);
let mut out = vec![0_u8; (end - offset) as usize];
let block0 = vec![b'a'; 4096];
let block1 = vec![b'b'; 4096];
assert_eq!(splice(&mut out, offset, &block0, 0, offset, end), 6);
assert_eq!(splice(&mut out, offset, &block1, 4096, offset, end), 10);
assert_eq!(&out[..6], b"aaaaaa");
assert_eq!(&out[6..], b"bbbbbbbbbb");
}
/// A block wholly outside the range writes nothing rather than corrupting
/// the buffer.
#[test]
fn a_block_outside_the_range_writes_nothing() {
let mut out = vec![0_u8; 8];
let block = vec![b'x'; 4096];
assert_eq!(splice(&mut out, 0, &block, 8192, 0, 8), 0);
assert_eq!(out, vec![0_u8; 8]);
}
}
+571
View File
@@ -0,0 +1,571 @@
//! The on-disk block cache: re-watching an episode should not re-download it.
//!
//! ## Why a sidecar, and why it is written last
//!
//! An entry is two files — `b{index}` holding the decrypted block, and
//! `b{index}.meta` holding its length. The block is written and synced **first**,
//! the sidecar **last**. That ordering is the whole validation scheme: a crash
//! or a full disk mid-write leaves a block with no sidecar, or a sidecar whose
//! length disagrees with the file, and both read as a miss. There is no state in
//! which a truncated block is served as if it were whole — which for a video
//! stream would not error, it would just decode garbage.
//!
//! Lifted from `proton-drive-linux`'s `pdfs-core/src/cache.rs`, which learned
//! the ordering the hard way.
//!
//! ## Why the path is hashed
//!
//! `{sha256(uid + revision)}` keyed by revision, not by name or mtime. A file
//! that gains a new revision lands in an entirely different directory, so stale
//! bytes are unreachable rather than merely unlikely. Names never touch the
//! filesystem: they are the one part of a share that is genuinely private, and
//! writing them into cache paths would leak the library's contents to anyone who
//! can list a directory.
use std::path::{Path, PathBuf};
use std::sync::{Mutex, PoisonError};
use lru::LruCache;
use sha2::{Digest, Sha256};
use crate::error::Result;
use crate::ring::BlockKey;
/// How much disk the cache may use, and where.
#[derive(Debug, Clone)]
pub struct DiskCacheConfig {
pub root: PathBuf,
pub budget_bytes: u64,
}
impl DiskCacheConfig {
/// 4 GiB — roughly two 1080p episodes' worth of blocks, small enough to be
/// unremarkable on any machine that plays video.
pub const DEFAULT_BUDGET_BYTES: u64 = 4 * 1024 * 1024 * 1024;
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
budget_bytes: Self::DEFAULT_BUDGET_BYTES,
}
}
pub fn with_budget(mut self, bytes: u64) -> Self {
self.budget_bytes = bytes;
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct DiskStats {
pub hits: u64,
pub misses: u64,
/// Entries dropped to stay inside the budget.
pub evictions: u64,
/// Entries found unreadable or inconsistent, and therefore ignored.
pub rejected: u64,
pub stored_bytes: u64,
pub entries: usize,
}
pub(crate) struct DiskCache {
root: PathBuf,
budget: u64,
index: Mutex<Index>,
}
struct Index {
/// Keyed by the block file's path, because that is all a startup scan can
/// recover — the key it was written under is hashed away by design.
lru: LruCache<PathBuf, u64>,
bytes: u64,
stats: DiskStats,
}
impl DiskCache {
/// Open the cache, adopting whatever a previous run left behind.
///
/// The scan is ordered by modification time so the recovered LRU is at least
/// approximately right; within a session, real access order takes over.
pub(crate) async fn open(config: DiskCacheConfig) -> Result<Self> {
let root = config.root.clone();
let scanned = tokio::task::spawn_blocking(move || scan(&root)).await;
let entries = match scanned {
Ok(Ok(entries)) => entries,
// A cache that cannot be scanned is a cache that starts empty. It is
// disposable by definition; refusing to play a film over it would be
// absurd.
Ok(Err(e)) => {
tracing::warn!(error = %e, "block cache could not be scanned; starting empty");
Vec::new()
}
Err(e) => {
tracing::warn!(error = %e, "block cache scan failed to run; starting empty");
Vec::new()
}
};
let mut lru = LruCache::unbounded();
let mut bytes = 0_u64;
for (path, len) in entries {
bytes += len;
lru.put(path, len);
}
let cache = Self {
root: config.root,
budget: config.budget_bytes,
index: Mutex::new(Index {
lru,
bytes,
stats: DiskStats {
stored_bytes: bytes,
..DiskStats::default()
},
}),
};
cache.evict_to_budget().await;
Ok(cache)
}
/// Read a block back, or `None` when it is absent or fails validation.
pub(crate) async fn get(&self, key: &BlockKey) -> Option<Vec<u8>> {
let path = self.block_path(key);
match read_entry(&path).await {
Ok(Some(block)) => {
let mut index = self.lock();
index.stats.hits += 1;
// Promotes it, and adopts an entry a concurrent writer added.
let len = block.len() as u64;
if index.lru.get(&path).is_none() {
index.bytes += len;
index.lru.put(path, len);
}
Some(block)
}
Ok(None) => {
self.lock().stats.misses += 1;
None
}
Err(e) => {
tracing::debug!(path = %path.display(), error = %e, "cached block rejected");
// Scoped, not `drop`ped: a guard merely *moved* before an await
// is still captured by the generated future, which would make
// every read non-`Send` and unspawnable.
{
let mut index = self.lock();
index.stats.misses += 1;
index.stats.rejected += 1;
}
// A half-written entry is never going to become valid.
self.discard(&path).await;
None
}
}
}
/// Store a block. Failures are logged and swallowed: a cache that cannot be
/// written is a slower app, not a broken one.
pub(crate) async fn put(&self, key: &BlockKey, block: &[u8]) {
let len = block.len() as u64;
if len > self.budget {
return;
}
let path = self.block_path(key);
if let Err(e) = write_entry(&path, block).await {
tracing::warn!(path = %path.display(), error = %e, "could not cache block to disk");
self.discard(&path).await;
return;
}
{
let mut index = self.lock();
if let Some(previous) = index.lru.put(path, len) {
index.bytes -= previous;
}
index.bytes += len;
}
self.evict_to_budget().await;
}
pub(crate) fn stats(&self) -> DiskStats {
let index = self.lock();
DiskStats {
stored_bytes: index.bytes,
entries: index.lru.len(),
..index.stats
}
}
/// Drop entries, least-recently-used first, until inside the budget.
async fn evict_to_budget(&self) {
loop {
let doomed = {
let mut index = self.lock();
if index.bytes <= self.budget {
return;
}
match index.lru.pop_lru() {
Some((path, len)) => {
index.bytes -= len;
index.stats.evictions += 1;
path
}
None => {
index.bytes = 0;
return;
}
}
};
self.discard(&doomed).await;
}
}
/// Remove both halves of an entry. The sidecar goes first, so an
/// interrupted deletion leaves an entry that fails validation rather than
/// one that claims to be valid.
async fn discard(&self, path: &Path) {
let _ = tokio::fs::remove_file(meta_path(path)).await;
let _ = tokio::fs::remove_file(path).await;
}
/// `{root}/{aa}/{hash}/b{index}` — two levels of fan-out so a large library
/// does not put tens of thousands of entries in one directory.
fn block_path(&self, key: &BlockKey) -> PathBuf {
let mut hasher = Sha256::new();
hasher.update(key.uid.to_string().as_bytes());
hasher.update(b"\0");
hasher.update(key.revision.as_bytes());
let digest = hasher.finalize();
let hash = hex(&digest);
self.root
.join(&hash[..2])
.join(&hash)
.join(format!("b{}", key.index))
}
fn lock(&self) -> std::sync::MutexGuard<'_, Index> {
self.index.lock().unwrap_or_else(PoisonError::into_inner)
}
}
fn meta_path(block: &Path) -> PathBuf {
let mut name = block.as_os_str().to_os_string();
name.push(".meta");
PathBuf::from(name)
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write;
bytes.iter().fold(String::new(), |mut out, byte| {
let _ = write!(out, "{byte:02x}");
out
})
}
/// Read an entry, validating it against its sidecar.
///
/// `Ok(None)` is "not cached". `Err` is "cached, but wrong" — the caller deletes
/// those.
async fn read_entry(path: &Path) -> Result<Option<Vec<u8>>> {
let claimed = match tokio::fs::read_to_string(meta_path(path)).await {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
let claimed_len: u64 = claimed.trim().parse().map_err(|_| {
crate::Error::NotFound(format!(
"{} holds no usable length",
meta_path(path).display()
))
})?;
let block = match tokio::fs::read(path).await {
Ok(block) => block,
// Sidecar without a block: the block was evicted or half-deleted.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(crate::Error::NotFound("cached block is missing".into()));
}
Err(e) => return Err(e.into()),
};
if block.len() as u64 != claimed_len {
return Err(crate::Error::NotFound(format!(
"cached block is {} bytes, sidecar claims {claimed_len}",
block.len()
)));
}
Ok(Some(block))
}
/// Write an entry: block, synced, then sidecar. See the module note.
async fn write_entry(path: &Path, block: &[u8]) -> Result<()> {
use tokio::io::AsyncWriteExt;
let parent = path
.parent()
.ok_or_else(|| crate::Error::NotFound(format!("{} has no parent", path.display())))?;
tokio::fs::create_dir_all(parent).await?;
// Through a temp file so a concurrent reader never sees a partial block even
// momentarily.
let temp = meta_path(path).with_extension("part");
{
let mut file = tokio::fs::File::create(&temp).await?;
file.write_all(block).await?;
file.sync_all().await?;
}
tokio::fs::rename(&temp, path).await?;
let mut sidecar = tokio::fs::File::create(meta_path(path)).await?;
sidecar
.write_all(block.len().to_string().as_bytes())
.await?;
sidecar.sync_all().await?;
Ok(())
}
/// Walk the cache tree, returning `(block path, size)` oldest-first.
///
/// Blocking, so it runs on the blocking pool. Anything that is not a valid pair
/// is skipped rather than deleted — a cache directory the user pointed at by
/// mistake should not be eaten.
fn scan(root: &Path) -> std::io::Result<Vec<(PathBuf, u64)>> {
let mut found: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new();
let shards = match std::fs::read_dir(root) {
Ok(shards) => shards,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
for shard in shards.flatten() {
let Ok(revisions) = std::fs::read_dir(shard.path()) else {
continue;
};
for revision in revisions.flatten() {
let Ok(blocks) = std::fs::read_dir(revision.path()) else {
continue;
};
for block in blocks.flatten() {
let path = block.path();
// Sidecars and interrupted writes are accounted through their
// block, not on their own.
if path.extension().is_some() {
continue;
}
let Ok(metadata) = block.metadata() else {
continue;
};
if !metadata.is_file() || !meta_path(&path).exists() {
continue;
}
let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH);
found.push((path, metadata.len(), modified));
}
}
}
found.sort_by_key(|(_, _, modified)| *modified);
Ok(found
.into_iter()
.map(|(path, len, _)| (path, len))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use proton_sdk::ids::{LinkId, NodeUid, VolumeId};
fn temp_root(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"pstr-disk-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn key(link: &str, revision: &str, index: usize) -> BlockKey {
BlockKey::new(
&NodeUid::new(VolumeId::new("vol"), LinkId::new(link)),
revision,
index,
)
}
#[tokio::test]
async fn a_stored_block_reads_back() {
let root = temp_root("roundtrip");
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
let block = vec![9_u8; 4096];
cache.put(&key("a", "rev1", 3), &block).await;
assert_eq!(cache.get(&key("a", "rev1", 3)).await, Some(block));
assert_eq!(cache.stats().hits, 1);
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn an_absent_block_is_a_miss_and_not_an_error() {
let root = temp_root("absent");
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
assert!(cache.get(&key("a", "rev1", 0)).await.is_none());
assert_eq!(cache.stats().misses, 1);
assert_eq!(cache.stats().rejected, 0);
std::fs::remove_dir_all(&root).ok();
}
/// The reason the sidecar is written last. A truncated block must never be
/// served — for video it would not fail, it would decode as corruption.
#[tokio::test]
async fn a_block_that_disagrees_with_its_sidecar_is_rejected_and_removed() {
let root = temp_root("torn");
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
let k = key("a", "rev1", 0);
cache.put(&k, &[1_u8; 100]).await;
// Simulate the torn write the ordering is designed to catch.
let path = cache.block_path(&k);
std::fs::write(&path, vec![1_u8; 40]).unwrap();
assert!(cache.get(&k).await.is_none());
assert_eq!(cache.stats().rejected, 1);
assert!(!path.exists(), "invalid entry should be cleaned up");
std::fs::remove_dir_all(&root).ok();
}
/// A block with no sidecar at all — the crash-between-writes case.
#[tokio::test]
async fn a_block_without_a_sidecar_is_a_miss() {
let root = temp_root("nosidecar");
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
let k = key("a", "rev1", 0);
cache.put(&k, &[1_u8; 100]).await;
std::fs::remove_file(meta_path(&cache.block_path(&k))).unwrap();
assert!(cache.get(&k).await.is_none());
std::fs::remove_dir_all(&root).ok();
}
#[tokio::test]
async fn the_cache_stays_inside_its_budget() {
let root = temp_root("budget");
let cache = DiskCache::open(DiskCacheConfig::new(&root).with_budget(300))
.await
.unwrap();
for index in 0..10 {
cache.put(&key("a", "rev1", index), &[1_u8; 100]).await;
assert!(cache.stats().stored_bytes <= 300, "over budget at {index}");
}
assert_eq!(cache.stats().entries, 3);
assert!(cache.stats().evictions > 0);
std::fs::remove_dir_all(&root).ok();
}
/// Eviction must delete the files, not merely forget them, or the budget is
/// a fiction and the disk fills up anyway.
#[tokio::test]
async fn eviction_removes_the_files_from_disk() {
let root = temp_root("evictfiles");
let cache = DiskCache::open(DiskCacheConfig::new(&root).with_budget(150))
.await
.unwrap();
let first = key("a", "rev1", 0);
cache.put(&first, &[1_u8; 100]).await;
cache.put(&key("a", "rev1", 1), &[2_u8; 100]).await;
let path = cache.block_path(&first);
assert!(!path.exists(), "evicted block still on disk");
assert!(!meta_path(&path).exists(), "evicted sidecar still on disk");
std::fs::remove_dir_all(&root).ok();
}
/// The point of the cache: a second run reuses the first run's bytes.
#[tokio::test]
async fn a_reopened_cache_adopts_what_the_previous_run_wrote() {
let root = temp_root("reopen");
{
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
cache.put(&key("a", "rev1", 0), &[7_u8; 256]).await;
}
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
assert_eq!(cache.stats().entries, 1);
assert_eq!(cache.stats().stored_bytes, 256);
assert_eq!(cache.get(&key("a", "rev1", 0)).await, Some(vec![7_u8; 256]));
std::fs::remove_dir_all(&root).ok();
}
/// Reopening with a smaller budget must shrink the cache, not sit over it
/// until enough new blocks arrive to trigger eviction.
#[tokio::test]
async fn reopening_with_a_smaller_budget_evicts_immediately() {
let root = temp_root("shrink");
{
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
for index in 0..5 {
cache.put(&key("a", "rev1", index), &[1_u8; 100]).await;
}
}
let cache = DiskCache::open(DiskCacheConfig::new(&root).with_budget(250))
.await
.unwrap();
assert!(cache.stats().stored_bytes <= 250);
std::fs::remove_dir_all(&root).ok();
}
/// Two revisions of the same file are different entries, so stale bytes are
/// unreachable rather than merely unlikely.
#[tokio::test]
async fn a_new_revision_does_not_read_the_old_ones_blocks() {
let root = temp_root("revision");
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
cache.put(&key("a", "rev1", 0), &[1_u8; 64]).await;
assert!(cache.get(&key("a", "rev2", 0)).await.is_none());
std::fs::remove_dir_all(&root).ok();
}
/// Nothing about the library should be legible from the cache directory.
#[tokio::test]
async fn cache_paths_carry_no_names() {
let root = temp_root("opaque");
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
let path = cache.block_path(&key("Frieren-S01E01", "rev1", 0));
let rendered = path.display().to_string();
assert!(
!rendered.contains("Frieren"),
"path leaks content: {rendered}"
);
std::fs::remove_dir_all(&root).ok();
}
/// A cache root that does not exist yet is the first-run case.
#[tokio::test]
async fn a_missing_cache_root_opens_empty() {
let root = temp_root("firstrun");
let cache = DiskCache::open(DiskCacheConfig::new(&root)).await.unwrap();
assert_eq!(cache.stats().entries, 0);
}
}
+32
View File
@@ -0,0 +1,32 @@
//! One error type for the stream layer.
/// What can go wrong opening a stream or reading bytes out of it.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A share could not be opened, or its catalog could not be read.
#[error(transparent)]
Core(#[from] pstr_core::Error),
/// The Drive SDK failed — network, API refusal, or decrypt.
#[error(transparent)]
Drive(#[from] proton_sdk::error::ProtonError),
/// The block cache's filesystem failed.
#[error("block cache: {0}")]
Io(#[from] std::io::Error),
/// The caller asked for something that does not exist.
#[error("{0}")]
NotFound(String),
/// A failure that happened in *another* task.
///
/// Block fetches are deduplicated: when several readers want the same block
/// only one of them fetches it, and the rest wait. The waiters cannot be
/// handed the leader's typed error — errors are not `Clone` — so they get
/// its message. The leader itself always sees the real error.
#[error("{0}")]
Shared(String),
}
pub type Result<T> = std::result::Result<T, Error>;
+45
View File
@@ -0,0 +1,45 @@
//! Seekable, cached byte streams over Proton Drive revisions.
//!
//! Proton stores a file as a list of independently-encrypted 4 MiB blocks, each
//! a self-contained PGP packet under one session key. Nothing about that is
//! sequential — which is the fact this whole crate rests on. Seeking is not an
//! approximation of streaming here; block 400 costs exactly what block 0 costs.
//!
//! What sits between the SDK and the player:
//!
//! ```text
//! mpv demuxer VideoStream BlockRing → DiskCache → Proton
//! (small, forward, → byte range → → memory disk network
//! seeking reads) blocks (128 MiB) (4 GiB)
//! ```
//!
//! * [`StreamSource`] opens files and keeps the open ones in an LRU.
//! * [`VideoStream`] answers byte ranges, reading ahead of the player.
//! * The ring and the disk cache are byte-budgeted, shared across streams, and
//! keyed by revision id so a resealed file can never serve stale bytes.
//!
//! Only [`reader`] touches the SDK. Everything else runs against a
//! [`BlockSource`], which is what makes the caching and read-ahead testable
//! without an account, a network or a share.
#![forbid(unsafe_code)]
pub mod block;
pub mod disk;
pub mod error;
pub mod reader;
pub mod ring;
mod single_flight;
pub mod source;
pub mod stream;
pub use block::{BlockMap, BlockSource, MemoryBlocks, SharedBlocks};
pub use disk::{DiskCacheConfig, DiskStats};
pub use error::{Error, Result};
/// Re-exported because every caller of this crate names one — `VideoStream::uid`
/// returns it and `StreamSource::open` takes it — and taking a dependency on the
/// whole SDK for a newtype is not a reasonable price for that.
pub use proton_sdk::ids::NodeUid;
pub use reader::{LibraryOpener, RevisionBlocks};
pub use ring::{DEFAULT_RING_BYTES, RingStats};
pub use source::{RevisionOpener, StreamConfig, StreamSource};
pub use stream::{DEFAULT_READAHEAD_BLOCKS, StreamStats, VideoStream};
+107
View File
@@ -0,0 +1,107 @@
//! The bridge to the SDK: a real Proton revision as a [`BlockSource`].
//!
//! Everything else in this crate is toolkit-free and account-free. This is the
//! one module that knows a `RevisionReader` exists.
//!
//! Reads are issued **block-aligned**. `RevisionReader::read_at` fetches every
//! block a range overlaps, so an unaligned read would pull two blocks to serve
//! one, and the caching layer above would then hold neither of them whole.
//! Aligning here means each block is fetched, decrypted and cached exactly once.
use std::sync::Arc;
use async_trait::async_trait;
use proton_drive_rs::ProtonDrivePublicLinkClient;
use proton_sdk::ids::NodeUid;
use crate::block::{BlockMap, BlockSource, SharedBlocks};
use crate::error::{Error, Result};
use crate::source::RevisionOpener;
/// One open revision on a public link.
pub struct RevisionBlocks {
reader: proton_drive_rs::RevisionReader,
map: BlockMap,
}
impl RevisionBlocks {
pub fn new(reader: proton_drive_rs::RevisionReader) -> Self {
let map = BlockMap::new(reader.block_sizes());
Self { reader, map }
}
}
#[async_trait]
impl BlockSource for RevisionBlocks {
fn revision_id(&self) -> &str {
self.reader.revision_id()
}
fn block_sizes(&self) -> &[u64] {
self.reader.block_sizes()
}
async fn read_block(&self, index: usize) -> Result<Vec<u8>> {
let (Some(start), Some(size)) = (self.map.start_of(index), self.map.size_of(index)) else {
return Err(Error::NotFound(format!(
"block {index} is past the end of revision {}",
self.reader.revision_id()
)));
};
let block = self.reader.read_at(start, size).await?;
// A short block here is not a tail — the map says how long it is. It
// means the revision changed underneath us or the block table lied, and
// serving it would silently shift every later byte.
if block.len() as u64 != size {
return Err(Error::NotFound(format!(
"block {index} came back {} bytes, expected {size}",
block.len()
)));
}
Ok(block)
}
}
/// Opens revisions out of the configured shares.
pub struct LibraryOpener {
library: Arc<pstr_core::SharedLibrary>,
}
impl LibraryOpener {
pub fn new(library: Arc<pstr_core::SharedLibrary>) -> Self {
Self { library }
}
fn client(&self, share_id: &str) -> Result<&ProtonDrivePublicLinkClient> {
self.library
.client(share_id)
.ok_or_else(|| Error::NotFound(format!("share {share_id} is not open")))
}
}
#[async_trait]
impl RevisionOpener for LibraryOpener {
async fn open(&self, share_id: &str, uid: &NodeUid) -> Result<SharedBlocks> {
let reader = self.client(share_id)?.open_revision(uid).await?;
Ok(Arc::new(RevisionBlocks::new(reader)))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The player hands its stream to mpv's demuxer thread, so everything from
/// the opener down has to cross threads. A compile-time guard, because the
/// failure mode is an unhelpful `FnOnce is not general enough` error deep in
/// a caller rather than here.
#[test]
fn the_library_opener_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<LibraryOpener>();
assert_send_sync::<RevisionBlocks>();
assert_send_sync::<crate::StreamSource>();
assert_send_sync::<crate::VideoStream>();
}
}
+338
View File
@@ -0,0 +1,338 @@
//! The in-memory block cache: a byte-budgeted LRU shared by every open stream.
//!
//! Sized in **bytes**, not entries. A block is 4 MiB in practice but nothing
//! guarantees it, and a count-based bound would turn into a wildly different
//! memory ceiling on a library uploaded by a different client. The budget is the
//! app's actual resident-memory promise, so it is what gets counted.
//!
//! Shared across streams rather than one per stream: a viewer who abandons an
//! episode after ninety seconds should not keep 128 MiB of it resident while the
//! next one starves.
use std::num::NonZeroUsize;
use std::sync::{Arc, Mutex, PoisonError};
use lru::LruCache;
use proton_sdk::ids::NodeUid;
/// Identifies one decrypted block, globally.
///
/// The revision id is part of the key on purpose: a file that gains a new
/// revision must not serve a byte of the old one out of cache. It is a stable
/// identity that advances iff a new revision was sealed.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct BlockKey {
pub uid: NodeUid,
pub revision: String,
pub index: usize,
}
impl BlockKey {
pub(crate) fn new(uid: &NodeUid, revision: &str, index: usize) -> Self {
Self {
uid: uid.clone(),
revision: revision.to_string(),
index,
}
}
}
/// A decrypted block, shared by reference so a cache hit costs no copy.
pub(crate) type CachedBlock = Arc<[u8]>;
/// Hit/miss counters, for the benchmark and the settings screen.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RingStats {
pub hits: u64,
pub misses: u64,
/// Blocks dropped to stay inside the budget.
pub evictions: u64,
/// Bytes currently resident.
pub resident_bytes: u64,
pub blocks: usize,
}
pub(crate) struct BlockRing {
inner: Mutex<Inner>,
budget: u64,
}
struct Inner {
/// Unbounded by entry count — eviction is driven by `bytes` against the
/// budget, below.
lru: LruCache<BlockKey, CachedBlock>,
bytes: u64,
hits: u64,
misses: u64,
evictions: u64,
}
impl BlockRing {
pub(crate) fn new(budget_bytes: u64) -> Self {
Self {
inner: Mutex::new(Inner {
lru: LruCache::unbounded(),
bytes: 0,
hits: 0,
misses: 0,
evictions: 0,
}),
budget: budget_bytes,
}
}
/// Look a block up, marking it most-recently-used on a hit.
pub(crate) fn get(&self, key: &BlockKey) -> Option<CachedBlock> {
let mut inner = self.lock();
match inner.lru.get(key) {
Some(block) => {
let block = Arc::clone(block);
inner.hits += 1;
Some(block)
}
None => {
inner.misses += 1;
None
}
}
}
/// Insert a block, evicting until the budget is met.
///
/// A block larger than the whole budget is *not* stored — storing it would
/// evict everything else and then still be over. It is still returned to the
/// caller that fetched it; it simply is not cached.
pub(crate) fn insert(&self, key: BlockKey, block: CachedBlock) {
let len = block.len() as u64;
if len > self.budget {
return;
}
let mut inner = self.lock();
if let Some(previous) = inner.lru.put(key, block) {
inner.bytes -= previous.len() as u64;
}
inner.bytes += len;
while inner.bytes > self.budget {
match inner.lru.pop_lru() {
Some((_, evicted)) => {
inner.bytes -= evicted.len() as u64;
inner.evictions += 1;
}
// Unreachable while `bytes` and `lru` agree; not worth a panic.
None => {
inner.bytes = 0;
break;
}
}
}
}
/// Whether a block is resident, *without* counting a hit or touching its
/// recency. Read-ahead asks this to decide what to prefetch, and it must not
/// look like demand traffic in the stats or in the LRU order.
pub(crate) fn contains(&self, key: &BlockKey) -> bool {
self.lock().lru.contains(key)
}
/// The byte budget this ring was built with.
pub(crate) fn budget(&self) -> u64 {
self.budget
}
pub(crate) fn stats(&self) -> RingStats {
let inner = self.lock();
RingStats {
hits: inner.hits,
misses: inner.misses,
evictions: inner.evictions,
resident_bytes: inner.bytes,
blocks: inner.lru.len(),
}
}
/// Drop every block of one revision. Used when a stream is closed and its
/// bytes are known to be dead.
pub(crate) fn forget(&self, uid: &NodeUid, revision: &str) {
let mut inner = self.lock();
let doomed: Vec<BlockKey> = inner
.lru
.iter()
.filter(|(key, _)| &key.uid == uid && key.revision == revision)
.map(|(key, _)| key.clone())
.collect();
for key in doomed {
if let Some(block) = inner.lru.pop(&key) {
inner.bytes -= block.len() as u64;
}
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
// Non-poisoning for the same reason the rest of the stack is: a panic in
// one read must not make every later read fail. The cache holds no
// invariant a panic could leave half-updated.
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
}
}
/// The default in-memory budget: 128 MiB, matching the FUSE client's stream
/// ring. At 4 MiB a block that is 32 blocks — about two minutes of a 1080p
/// episode, enough to absorb a scrub backwards without refetching.
pub const DEFAULT_RING_BYTES: u64 = 128 * 1024 * 1024;
/// `lru` wants a non-zero capacity for bounded caches, and a configured zero
/// must clamp rather than panic.
pub(crate) fn non_zero(value: usize) -> NonZeroUsize {
NonZeroUsize::new(value.max(1)).expect("max(1) is non-zero")
}
#[cfg(test)]
mod tests {
use super::*;
use proton_sdk::ids::{LinkId, VolumeId};
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::new("vol"), LinkId::new(link))
}
fn block(byte: u8, len: usize) -> CachedBlock {
vec![byte; len].into()
}
#[test]
fn a_stored_block_reads_back() {
let ring = BlockRing::new(1024);
let key = BlockKey::new(&uid("a"), "rev1", 0);
ring.insert(key.clone(), block(7, 16));
assert_eq!(ring.get(&key).expect("hit").as_ref(), &[7_u8; 16]);
assert_eq!(ring.stats().hits, 1);
}
/// The budget is the app's memory promise. Exceeding it is the one thing
/// this type must not do.
#[test]
fn the_ring_never_exceeds_its_byte_budget() {
let ring = BlockRing::new(100);
for index in 0..10 {
ring.insert(BlockKey::new(&uid("a"), "rev1", index), block(1, 30));
assert!(
ring.stats().resident_bytes <= 100,
"over budget at block {index}"
);
}
assert_eq!(ring.stats().blocks, 3);
assert!(ring.stats().evictions > 0);
}
/// Eviction order must be least-recently-*used*, not insertion order, or a
/// viewer scrubbing back and forth loses the block they keep returning to.
#[test]
fn the_least_recently_used_block_is_evicted_first() {
let ring = BlockRing::new(60);
let (a, b) = (
BlockKey::new(&uid("x"), "rev1", 0),
BlockKey::new(&uid("x"), "rev1", 1),
);
ring.insert(a.clone(), block(1, 30));
ring.insert(b.clone(), block(2, 30));
// Touch `a`, making `b` the eviction candidate.
assert!(ring.get(&a).is_some());
ring.insert(BlockKey::new(&uid("x"), "rev1", 2), block(3, 30));
assert!(ring.get(&a).is_some(), "recently used block was evicted");
assert!(ring.get(&b).is_none(), "stale block survived");
}
/// The reason the revision id is in the key: a resealed file must never
/// serve a byte of its previous content.
#[test]
fn a_new_revision_does_not_hit_the_old_revisions_blocks() {
let ring = BlockRing::new(1024);
ring.insert(BlockKey::new(&uid("a"), "rev1", 0), block(1, 8));
assert!(ring.get(&BlockKey::new(&uid("a"), "rev2", 0)).is_none());
}
/// And neither must two different files at the same block index.
#[test]
fn two_files_do_not_collide_at_the_same_block_index() {
let ring = BlockRing::new(1024);
ring.insert(BlockKey::new(&uid("a"), "rev1", 0), block(1, 8));
ring.insert(BlockKey::new(&uid("b"), "rev1", 0), block(2, 8));
assert_eq!(
ring.get(&BlockKey::new(&uid("a"), "rev1", 0)).unwrap()[0],
1
);
assert_eq!(
ring.get(&BlockKey::new(&uid("b"), "rev1", 0)).unwrap()[0],
2
);
}
/// Read-ahead polls this constantly; it must not distort demand statistics
/// or promote a block it only *considered* fetching.
#[test]
fn a_containment_check_records_no_traffic_and_no_recency() {
let ring = BlockRing::new(60);
let (a, b) = (
BlockKey::new(&uid("x"), "rev1", 0),
BlockKey::new(&uid("x"), "rev1", 1),
);
ring.insert(a.clone(), block(1, 30));
ring.insert(b.clone(), block(2, 30));
assert!(ring.contains(&a));
assert_eq!(ring.stats().hits, 0);
assert_eq!(ring.stats().misses, 0);
// `a` is still the LRU victim despite having been asked about.
ring.insert(BlockKey::new(&uid("x"), "rev1", 2), block(3, 30));
assert!(!ring.contains(&a));
}
/// A block bigger than the whole budget must not flush the cache to make
/// room for something that cannot fit anyway.
#[test]
fn a_block_larger_than_the_budget_is_not_stored() {
let ring = BlockRing::new(50);
let keeper = BlockKey::new(&uid("a"), "rev1", 0);
ring.insert(keeper.clone(), block(1, 40));
ring.insert(BlockKey::new(&uid("a"), "rev1", 1), block(2, 500));
assert!(ring.get(&keeper).is_some(), "existing block was flushed");
assert_eq!(ring.stats().blocks, 1);
}
#[test]
fn forgetting_a_revision_reclaims_its_bytes() {
let ring = BlockRing::new(1024);
for index in 0..4 {
ring.insert(BlockKey::new(&uid("a"), "rev1", index), block(1, 32));
}
ring.insert(BlockKey::new(&uid("b"), "rev1", 0), block(2, 32));
ring.forget(&uid("a"), "rev1");
let stats = ring.stats();
assert_eq!(stats.blocks, 1);
assert_eq!(stats.resident_bytes, 32);
}
/// Re-inserting the same key must not double-count its bytes; a refetched
/// block would otherwise inflate the accounting until the ring evicted
/// everything.
#[test]
fn reinserting_a_block_does_not_double_count_its_bytes() {
let ring = BlockRing::new(1024);
let key = BlockKey::new(&uid("a"), "rev1", 0);
ring.insert(key.clone(), block(1, 64));
ring.insert(key.clone(), block(1, 64));
assert_eq!(ring.stats().resident_bytes, 64);
assert_eq!(ring.stats().blocks, 1);
}
}
+263
View File
@@ -0,0 +1,263 @@
//! One in-flight call per key, however many callers ask for it.
//!
//! Two places need this and both are hot:
//!
//! * **Opening a stream.** A cold open costs a link-details fetch, an S2K
//! ancestor-key unlock and a revision listing. The UI's "play" click and the
//! player's first read arrive within milliseconds of each other; without
//! deduplication they pay for that twice and race to insert into the LRU.
//! * **Fetching a block.** Read-ahead runs *ahead* of the demand read by
//! definition, so the two collide constantly on the block the player is about
//! to want. Fetching it twice doubles the bytes off the wire for no gain.
//!
//! The entry is removed by a guard rather than by the leader's happy path, so a
//! cancelled call (a dropped future — the player abandoning a seek, say) cannot
//! leave a key permanently marked as in flight.
use std::collections::HashMap;
use std::future::Future;
use std::hash::Hash;
use std::sync::{Arc, Mutex, PoisonError};
use tokio::sync::watch;
use crate::error::{Error, Result};
/// The result as a waiter sees it: the value, or the leader's error message.
type Shared<V> = Option<std::result::Result<V, String>>;
/// Deduplicates concurrent calls that share a key.
pub(crate) struct SingleFlight<K, V> {
calls: Mutex<HashMap<K, watch::Receiver<Shared<V>>>>,
}
impl<K, V> SingleFlight<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
pub(crate) fn new() -> Self {
Self {
calls: Mutex::new(HashMap::new()),
}
}
/// Run `work` for `key`, or wait for the call already running it.
///
/// Whoever arrives first is the leader and actually runs the future; the
/// rest wait on its result. The leader gets its own typed error, waiters get
/// [`Error::Shared`].
pub(crate) async fn run<F, Fut>(self: &Arc<Self>, key: K, work: F) -> Result<V>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<V>>,
{
let role = {
let mut calls = self.lock();
match calls.get(&key) {
Some(receiver) => Role::Waiter(receiver.clone()),
None => {
let (sender, receiver) = watch::channel(None);
calls.insert(key.clone(), receiver);
Role::Leader(sender)
}
}
};
match role {
Role::Leader(sender) => {
// Removes the map entry on *every* exit path, including the one
// where this future is dropped mid-await.
let _guard = LeaderGuard {
flight: Arc::clone(self),
key: Some(key),
};
let result = work().await;
let shared = match &result {
Ok(value) => Ok(value.clone()),
Err(e) => Err(e.to_string()),
};
// Waiters that already hold a receiver are woken here; ones that
// arrive after the guard drops start a fresh call.
let _ = sender.send(Some(shared));
result
}
Role::Waiter(mut receiver) => loop {
// Cloned out before the await: holding the borrow across it
// would deadlock the sender.
let seen = receiver.borrow_and_update().clone();
if let Some(result) = seen {
return result.map_err(Error::Shared);
}
if receiver.changed().await.is_err() {
return Err(Error::Shared(
"the task loading this was cancelled before it finished".into(),
));
}
},
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<K, watch::Receiver<Shared<V>>>> {
// A panicking caller must not wedge every future call on this key. The
// map is a plain index with no invariant a panic could break.
self.calls.lock().unwrap_or_else(PoisonError::into_inner)
}
}
enum Role<V> {
Leader(watch::Sender<Shared<V>>),
Waiter(watch::Receiver<Shared<V>>),
}
struct LeaderGuard<K: Eq + Hash + Clone, V: Clone> {
flight: Arc<SingleFlight<K, V>>,
key: Option<K>,
}
impl<K: Eq + Hash + Clone, V: Clone> Drop for LeaderGuard<K, V> {
fn drop(&mut self) {
if let Some(key) = self.key.take() {
self.flight.lock().remove(&key);
}
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
/// The point of the whole file: N callers, one execution.
#[tokio::test]
async fn concurrent_callers_of_one_key_run_the_work_once() {
let flight: Arc<SingleFlight<u32, u32>> = Arc::new(SingleFlight::new());
let runs = Arc::new(AtomicUsize::new(0));
let gate = Arc::new(tokio::sync::Notify::new());
let mut handles = Vec::new();
for _ in 0..8 {
let flight = Arc::clone(&flight);
let runs = Arc::clone(&runs);
let gate = Arc::clone(&gate);
handles.push(tokio::spawn(async move {
flight
.run(7, || async {
runs.fetch_add(1, Ordering::SeqCst);
// Held open so every caller is definitely waiting.
gate.notified().await;
Ok(42)
})
.await
}));
}
// Let all eight reach the map before the leader completes.
tokio::task::yield_now().await;
gate.notify_waiters();
for handle in handles {
assert_eq!(handle.await.expect("join").expect("value"), 42);
}
assert_eq!(runs.load(Ordering::SeqCst), 1, "work ran more than once");
}
/// Different keys must not serialize against each other.
#[tokio::test]
async fn different_keys_run_independently() {
let flight: Arc<SingleFlight<u32, u32>> = Arc::new(SingleFlight::new());
let runs = Arc::new(AtomicUsize::new(0));
for key in 0..4 {
let runs = Arc::clone(&runs);
let value = flight
.run(key, || async {
runs.fetch_add(1, Ordering::SeqCst);
Ok(key * 2)
})
.await
.expect("value");
assert_eq!(value, key * 2);
}
assert_eq!(runs.load(Ordering::SeqCst), 4);
}
/// A waiter gets the leader's message, not a success and not a hang.
#[tokio::test]
async fn a_waiter_sees_the_leaders_failure() {
let flight: Arc<SingleFlight<u32, u32>> = Arc::new(SingleFlight::new());
let gate = Arc::new(tokio::sync::Notify::new());
let leader = {
let flight = Arc::clone(&flight);
let gate = Arc::clone(&gate);
tokio::spawn(async move {
flight
.run(1, || async {
gate.notified().await;
Err(Error::NotFound("no such block".into()))
})
.await
})
};
tokio::task::yield_now().await;
let waiter = {
let flight = Arc::clone(&flight);
tokio::spawn(async move { flight.run(1, || async { Ok(0) }).await })
};
tokio::task::yield_now().await;
gate.notify_waiters();
assert!(leader.await.expect("join").is_err());
let error = waiter
.await
.expect("join")
.expect_err("waiter must fail too");
assert!(
error.to_string().contains("no such block"),
"waiter should carry the leader's reason: {error}"
);
}
/// A failed call must not poison the key — the next caller retries.
#[tokio::test]
async fn a_failed_call_leaves_the_key_retryable() {
let flight: Arc<SingleFlight<u32, u32>> = Arc::new(SingleFlight::new());
let first = flight
.run(1, || async { Err(Error::NotFound("transient".into())) })
.await;
assert!(first.is_err());
let second = flight.run(1, || async { Ok(9) }).await.expect("retry");
assert_eq!(second, 9);
}
/// A dropped leader must not leave the key marked in-flight forever — the
/// player cancels reads whenever the viewer seeks away.
#[tokio::test]
async fn a_cancelled_leader_does_not_wedge_the_key() {
let flight: Arc<SingleFlight<u32, u32>> = Arc::new(SingleFlight::new());
{
let pending = flight.run(1, || async {
std::future::pending::<()>().await;
Ok(0)
});
// Poll once so it becomes the leader, then drop it unfinished.
futures::pin_mut!(pending);
let mut context = std::task::Context::from_waker(futures::task::noop_waker_ref());
assert!(std::future::Future::poll(pending.as_mut(), &mut context).is_pending());
}
assert!(
flight.lock().is_empty(),
"entry outlived the cancelled call"
);
assert_eq!(flight.run(1, || async { Ok(5) }).await.expect("retry"), 5);
}
}
+334
View File
@@ -0,0 +1,334 @@
//! Opening streams, and keeping the open ones around.
//!
//! A cold open is expensive: link details, an ancestor-key unlock (an S2K
//! derivation, tens of milliseconds), then a revision listing — all before the
//! first byte. Two things make that cost once instead of repeatedly:
//!
//! * an **LRU of open streams**, so going back to the episode you paused, or
//! scrubbing after the player reopened its demuxer, is instant;
//! * **single-flight on the open itself**, because the UI's "play" click and the
//! player's first read arrive milliseconds apart and would otherwise both pay
//! for the same handshake, then race to insert into the LRU.
use std::sync::{Arc, Mutex, PoisonError};
use async_trait::async_trait;
use lru::LruCache;
use proton_sdk::ids::NodeUid;
use crate::block::SharedBlocks;
use crate::disk::{DiskCache, DiskCacheConfig};
use crate::error::Result;
use crate::ring::{BlockRing, DEFAULT_RING_BYTES, non_zero};
use crate::single_flight::SingleFlight;
use crate::stream::{DEFAULT_READAHEAD_BLOCKS, VideoStream};
/// How the source turns a node into blocks.
///
/// A trait so the cache, ring and read-ahead can be exercised without an
/// account: the only implementation that talks to Proton is
/// [`crate::reader::LibraryOpener`].
#[async_trait]
pub trait RevisionOpener: Send + Sync + 'static {
/// Open the active revision of `uid` inside `share_id`.
async fn open(&self, share_id: &str, uid: &NodeUid) -> Result<SharedBlocks>;
}
/// How much memory, how much disk, and how far ahead to read.
#[derive(Debug, Clone)]
pub struct StreamConfig {
/// Resident block budget, shared by every open stream.
pub ring_bytes: u64,
/// Blocks to pull ahead of the reader. Zero disables read-ahead.
pub readahead_blocks: usize,
/// How many opened streams to keep. Cheap to hold — the bytes live in the
/// ring, not here — so this is generous.
pub max_open_streams: usize,
/// The on-disk block cache, when enabled.
pub disk_cache: Option<DiskCacheConfig>,
}
impl Default for StreamConfig {
fn default() -> Self {
Self {
ring_bytes: DEFAULT_RING_BYTES,
readahead_blocks: DEFAULT_READAHEAD_BLOCKS,
max_open_streams: 64,
disk_cache: None,
}
}
}
impl StreamConfig {
pub fn with_disk_cache(mut self, cache: DiskCacheConfig) -> Self {
self.disk_cache = Some(cache);
self
}
pub fn with_readahead(mut self, blocks: usize) -> Self {
self.readahead_blocks = blocks;
self
}
pub fn with_ring_bytes(mut self, bytes: u64) -> Self {
self.ring_bytes = bytes;
self
}
}
/// Identifies an open stream. The share is part of the key: the same file can
/// legitimately be reachable through two different links.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct StreamKey {
share: String,
uid: NodeUid,
}
/// Opens and caches [`VideoStream`]s.
#[derive(Clone)]
pub struct StreamSource {
inner: Arc<Inner>,
}
struct Inner {
opener: Arc<dyn RevisionOpener>,
ring: Arc<BlockRing>,
disk: Option<Arc<DiskCache>>,
readahead: usize,
open: Mutex<LruCache<StreamKey, VideoStream>>,
flight: Arc<SingleFlight<StreamKey, VideoStream>>,
}
impl StreamSource {
/// Build a source, opening the disk cache if one is configured.
pub async fn new(opener: Arc<dyn RevisionOpener>, config: StreamConfig) -> Result<Self> {
let disk = match config.disk_cache {
Some(cache) => Some(Arc::new(DiskCache::open(cache).await?)),
None => None,
};
Ok(Self {
inner: Arc::new(Inner {
opener,
ring: Arc::new(BlockRing::new(config.ring_bytes)),
disk,
readahead: config.readahead_blocks,
open: Mutex::new(LruCache::new(non_zero(config.max_open_streams))),
flight: Arc::new(SingleFlight::new()),
}),
})
}
/// Open a file for playback, or hand back the stream already open on it.
pub async fn open(&self, share_id: &str, uid: &NodeUid) -> Result<VideoStream> {
let key = StreamKey {
share: share_id.to_string(),
uid: uid.clone(),
};
if let Some(stream) = self.inner.lock().get(&key) {
return Ok(stream.clone());
}
let inner = Arc::clone(&self.inner);
let flight = Arc::clone(&self.inner.flight);
let share = share_id.to_string();
let uid = uid.clone();
let cached_key = key.clone();
flight
.run(key, move || async move {
let blocks = inner.opener.open(&share, &uid).await?;
let stream = VideoStream::new(
uid,
blocks,
Arc::clone(&inner.ring),
inner.disk.clone(),
inner.readahead,
);
// Evicting a stream here only drops the handle; its blocks stay
// in the ring under the ring's own budget, so a viewer who
// reopens still gets them.
inner.lock().put(cached_key, stream.clone());
Ok(stream)
})
.await
}
/// Drop a stream and everything cached for it in memory.
///
/// The disk cache is deliberately untouched: it exists precisely so the next
/// viewing is cheap.
pub fn close(&self, share_id: &str, uid: &NodeUid) {
let key = StreamKey {
share: share_id.to_string(),
uid: uid.clone(),
};
if let Some(stream) = self.inner.lock().pop(&key) {
self.inner.ring.forget(uid, stream.revision_id());
}
}
/// Ring statistics, across every stream.
pub fn ring_stats(&self) -> crate::ring::RingStats {
self.inner.ring.stats()
}
/// Disk-cache statistics, when one is configured.
pub fn disk_stats(&self) -> Option<crate::disk::DiskStats> {
self.inner.disk.as_ref().map(|disk| disk.stats())
}
}
impl Inner {
fn lock(&self) -> std::sync::MutexGuard<'_, LruCache<StreamKey, VideoStream>> {
self.open.lock().unwrap_or_else(PoisonError::into_inner)
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use proton_sdk::ids::{LinkId, VolumeId};
use super::*;
use crate::block::MemoryBlocks;
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::new("vol"), LinkId::new(link))
}
struct CountingOpener {
opens: AtomicUsize,
delay: std::time::Duration,
}
impl CountingOpener {
fn new(delay: std::time::Duration) -> Arc<Self> {
Arc::new(Self {
opens: AtomicUsize::new(0),
delay,
})
}
fn opens(&self) -> usize {
self.opens.load(Ordering::SeqCst)
}
}
#[async_trait]
impl RevisionOpener for CountingOpener {
async fn open(&self, _share: &str, _uid: &NodeUid) -> Result<SharedBlocks> {
self.opens.fetch_add(1, Ordering::SeqCst);
if !self.delay.is_zero() {
tokio::time::sleep(self.delay).await;
}
Ok(Arc::new(MemoryBlocks::new(
"rev1",
vec![vec![1_u8; 64], vec![2_u8; 64]],
)))
}
}
async fn source(opener: Arc<CountingOpener>) -> StreamSource {
StreamSource::new(opener, StreamConfig::default().with_readahead(0))
.await
.expect("source")
}
#[tokio::test]
async fn an_opened_stream_reads_its_blocks() {
let source = source(CountingOpener::new(std::time::Duration::ZERO)).await;
let stream = source.open("share", &uid("a")).await.unwrap();
assert_eq!(stream.size(), 128);
assert_eq!(
stream.read_range(60, 8).await.unwrap(),
vec![1, 1, 1, 1, 2, 2, 2, 2]
);
}
/// Reopening the episode you paused must not re-run the handshake.
#[tokio::test]
async fn reopening_the_same_file_reuses_the_open_stream() {
let opener = CountingOpener::new(std::time::Duration::ZERO);
let source = source(Arc::clone(&opener)).await;
source.open("share", &uid("a")).await.unwrap();
source.open("share", &uid("a")).await.unwrap();
source.open("share", &uid("a")).await.unwrap();
assert_eq!(opener.opens(), 1);
}
/// The race the single-flight exists for: the UI click and the player's
/// first read, milliseconds apart, against a slow open.
#[tokio::test]
async fn concurrent_opens_of_one_file_handshake_once() {
let opener = CountingOpener::new(std::time::Duration::from_millis(40));
let source = source(Arc::clone(&opener)).await;
let mut handles = Vec::new();
for _ in 0..6 {
let source = source.clone();
handles.push(tokio::spawn(async move {
source.open("share", &uid("a")).await
}));
}
for handle in handles {
handle.await.unwrap().unwrap();
}
assert_eq!(opener.opens(), 1);
}
/// The same file behind two links is two streams — the shares may have
/// different lifetimes and different credentials.
#[tokio::test]
async fn the_same_file_in_two_shares_is_two_streams() {
let opener = CountingOpener::new(std::time::Duration::ZERO);
let source = source(Arc::clone(&opener)).await;
source.open("share-one", &uid("a")).await.unwrap();
source.open("share-two", &uid("a")).await.unwrap();
assert_eq!(opener.opens(), 2);
}
/// Closing must reclaim the memory, or a long session leaks a ring's worth
/// of every episode watched.
#[tokio::test]
async fn closing_a_stream_releases_its_blocks() {
let source = source(CountingOpener::new(std::time::Duration::ZERO)).await;
let stream = source.open("share", &uid("a")).await.unwrap();
stream.read_range(0, 128).await.unwrap();
assert!(source.ring_stats().resident_bytes > 0);
source.close("share", &uid("a"));
assert_eq!(source.ring_stats().resident_bytes, 0);
}
/// Beyond the LRU's capacity the oldest handle is dropped; the next open
/// re-runs the handshake rather than failing.
#[tokio::test]
async fn the_open_stream_lru_is_bounded() {
let opener = CountingOpener::new(std::time::Duration::ZERO);
let source = StreamSource::new(
Arc::clone(&opener) as Arc<dyn RevisionOpener>,
StreamConfig {
max_open_streams: 2,
readahead_blocks: 0,
..StreamConfig::default()
},
)
.await
.unwrap();
for link in ["a", "b", "c"] {
source.open("share", &uid(link)).await.unwrap();
}
// "a" was evicted, so this is a fourth open.
source.open("share", &uid("a")).await.unwrap();
assert_eq!(opener.opens(), 4);
}
}
+835
View File
@@ -0,0 +1,835 @@
//! A seekable byte stream over one revision, with caching and read-ahead.
//!
//! This is what the player reads from. mpv's demuxer asks for small, mostly
//! forward, occasionally wildly-seeking ranges; Proton serves 4 MiB encrypted
//! blocks. Everything between those two facts lives here:
//!
//! * a **byte range → blocks** mapping that never assumes a block size,
//! * a **memory ring** and an optional **disk cache**, checked in that order,
//! * **deduplication**, so read-ahead and the demand read never fetch the same
//! block twice,
//! * **forward read-ahead**, so the demuxer's next read is usually already in
//! memory rather than a 4 MiB round-trip away.
//!
//! ## Read-ahead has to yield to seeks
//!
//! Measured against a real 761 MiB episode over a public link, naive read-ahead
//! made everything *worse*: worst-case cold seek 2895 ms with six blocks of
//! prefetch versus 572 ms with none, and sustained throughput 5.0 MiB/s versus
//! 7.2. The cause is not subtle — prefetches queued from the position the viewer
//! just left keep running, and the block they are fetching is exactly the
//! bandwidth the seek needs.
//!
//! So a read that is not a continuation of the previous one cancels every
//! outstanding prefetch before it fetches anything. Read-ahead resumes from
//! wherever the seek settles. What is thrown away is speculative by definition;
//! what is protected is the only latency the viewer can feel.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use futures::stream::{self, StreamExt, TryStreamExt};
use proton_sdk::ids::NodeUid;
use tokio::sync::Semaphore;
use crate::block::{BlockMap, SharedBlocks, splice};
use crate::disk::{DiskCache, DiskStats};
use crate::error::Result;
use crate::ring::{BlockKey, BlockRing, CachedBlock, RingStats};
use crate::single_flight::SingleFlight;
/// Blocks fetched concurrently to satisfy one read.
///
/// A read spanning more than a couple of blocks only happens on a cold seek;
/// steady-state playback reads well inside one. Kept modest because the SDK
/// enforces its own client-wide in-flight ceiling underneath this.
const MAX_CONCURRENT_BLOCKS_PER_READ: usize = 4;
/// How much of the file to pull ahead of the reader, in blocks.
///
/// Measured on a 761 MiB episode over a real public link, reading in 64 KiB
/// chunks against the default 128 MiB ring:
///
/// | depth | sustained | note |
/// |------:|----------:|------|
/// | 0 | 7.2 MiB/s | strictly one block at a time — latency-bound |
/// | 6 | 5.8 MiB/s | *worse* than serial: too shallow to overlap, still competing |
/// | 12 | 8.7 MiB/s | no evictions |
/// | 16 | 8.1–9.6 MiB/s | occasional evictions |
/// | 32 | 7.8 MiB/s | regresses — see [`clamp_readahead`] |
///
/// Run-to-run spread is wide enough that 12 and 16 are not meaningfully
/// different; 12 is chosen because it never evicted and wasted a third less
/// bandwidth on cancelled prefetches. Well past a 1080p bitrate either way.
pub const DEFAULT_READAHEAD_BLOCKS: usize = 12;
/// How far a read may land from where the previous one ended and still count as
/// a continuation rather than a seek.
///
/// One 4 MiB block's worth. Demuxers routinely re-read a little behind
/// themselves for headers and index entries, and a small forward hop is a
/// container skipping a chunk, not a viewer moving the seek bar. Treating either
/// as a seek would cancel read-ahead constantly and give back everything it
/// buys.
const SEQUENTIAL_SLACK: u64 = 4 * 1024 * 1024;
/// What a stream did, for the benchmark and the settings screen.
#[derive(Debug, Clone, Copy)]
pub struct StreamStats {
pub ring: RingStats,
pub disk: Option<DiskStats>,
/// Blocks that actually came off the network.
pub blocks_fetched: u64,
pub bytes_fetched: u64,
/// Read-ahead fetches that were started.
pub readahead_blocks: u64,
/// Read-ahead fetches cancelled by a seek.
pub readahead_cancelled: u64,
/// Reads that were not a continuation of the previous one.
pub seeks: u64,
}
/// One open revision, readable at any offset.
///
/// Cheap to clone — clones share the same reader, cache state and read-ahead.
#[derive(Clone)]
pub struct VideoStream {
inner: Arc<Inner>,
}
struct Inner {
uid: NodeUid,
blocks: SharedBlocks,
map: BlockMap,
ring: Arc<BlockRing>,
disk: Option<Arc<DiskCache>>,
/// One fetch per block, however many readers want it.
flight: Arc<SingleFlight<BlockKey, CachedBlock>>,
readahead: usize,
/// Bounds detached read-ahead work, so a seek-happy viewer cannot queue an
/// unbounded pile of prefetches.
readahead_slots: Arc<Semaphore>,
/// Live prefetches, so a seek can cancel them. See the module note.
readahead_tasks: Mutex<Vec<tokio::task::JoinHandle<()>>>,
/// Plaintext offset just past the previous read, for seek detection.
last_end: AtomicU64,
fetched_blocks: AtomicU64,
fetched_bytes: AtomicU64,
readahead_started: AtomicU64,
readahead_cancelled: AtomicU64,
seeks: AtomicU64,
}
impl VideoStream {
pub(crate) fn new(
uid: NodeUid,
blocks: SharedBlocks,
ring: Arc<BlockRing>,
disk: Option<Arc<DiskCache>>,
readahead: usize,
) -> Self {
let map = BlockMap::new(blocks.block_sizes());
let readahead = clamp_readahead(readahead, ring.budget(), blocks.block_sizes());
Self {
inner: Arc::new(Inner {
uid,
blocks,
map,
ring,
disk,
flight: Arc::new(SingleFlight::new()),
readahead,
readahead_slots: Arc::new(Semaphore::new(readahead.max(1))),
readahead_tasks: Mutex::new(Vec::new()),
last_end: AtomicU64::new(0),
fetched_blocks: AtomicU64::new(0),
fetched_bytes: AtomicU64::new(0),
readahead_started: AtomicU64::new(0),
readahead_cancelled: AtomicU64::new(0),
seeks: AtomicU64::new(0),
}),
}
}
/// Total plaintext size of the revision.
pub fn size(&self) -> u64 {
self.inner.map.size()
}
/// The node this stream reads.
pub fn uid(&self) -> &NodeUid {
&self.inner.uid
}
/// The revision this stream is pinned to. It does not follow a reseal —
/// reopen for that.
pub fn revision_id(&self) -> &str {
self.inner.blocks.revision_id()
}
/// Plaintext size of each block, in block order.
pub fn block_sizes(&self) -> &[u64] {
self.inner.blocks.block_sizes()
}
/// Fill `buf` from `offset`, returning how many bytes were written.
///
/// A read at or past EOF returns 0 rather than failing — that is how a
/// demuxer discovers the end of a file.
pub async fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<usize> {
let inner = &self.inner;
if buf.is_empty() || offset >= inner.map.size() {
return Ok(0);
}
let end = offset
.saturating_add(buf.len() as u64)
.min(inner.map.size());
// Before anything is fetched: a seek must not queue behind the
// speculation it just invalidated. See the module note — this is worth
// roughly 5x on worst-case seek latency.
let previous_end = inner.last_end.swap(end, Ordering::Relaxed);
if !is_continuation(offset, previous_end) {
inner.seeks.fetch_add(1, Ordering::Relaxed);
self.cancel_read_ahead();
}
let wanted = inner.map.blocks_in(offset, end);
// Taken by value: a closure borrowing from the range would give each
// fetch future a higher-ranked lifetime, which `tokio::spawn` rejects in
// callers with "implementation of `FnOnce` is not general enough".
let indices: Vec<usize> = wanted.clone().collect();
let last = wanted.end;
let mut fetches = stream::iter(
indices
.into_iter()
.map(|index| block_at(Arc::clone(inner), index)),
)
.buffered(MAX_CONCURRENT_BLOCKS_PER_READ);
let mut written = 0_usize;
let mut index = wanted.start;
// `buffered` yields in input order, so blocks arrive in file order and
// each one splices into its own part of `buf`.
while let Some(block) = fetches.try_next().await? {
let start = inner.map.start_of(index).unwrap_or(0);
written += splice(buf, offset, &block, start, offset, end);
index += 1;
}
self.read_ahead_from(last);
Ok(written)
}
/// [`Self::read_at`] into a fresh buffer. Convenient for tests and for the
/// benchmark; the player uses the buffer form.
pub async fn read_range(&self, offset: u64, length: u64) -> Result<Vec<u8>> {
let end = offset.saturating_add(length).min(self.size());
if length == 0 || offset >= end {
return Ok(Vec::new());
}
let mut buf = vec![0_u8; (end - offset) as usize];
let read = self.read_at(offset, &mut buf).await?;
buf.truncate(read);
Ok(buf)
}
pub fn stats(&self) -> StreamStats {
StreamStats {
ring: self.inner.ring.stats(),
disk: self.inner.disk.as_ref().map(|disk| disk.stats()),
blocks_fetched: self.inner.fetched_blocks.load(Ordering::Relaxed),
bytes_fetched: self.inner.fetched_bytes.load(Ordering::Relaxed),
readahead_blocks: self.inner.readahead_started.load(Ordering::Relaxed),
readahead_cancelled: self.inner.readahead_cancelled.load(Ordering::Relaxed),
seeks: self.inner.seeks.load(Ordering::Relaxed),
}
}
/// Abandon every outstanding prefetch.
///
/// Aborting mid-fetch throws away whatever that block had downloaded. That
/// is the intended trade: those bytes were speculative, and the request slot
/// they occupy is what the seek is waiting for. A demand read that later
/// wants an aborted block simply refetches it — the single-flight entry is
/// released by its guard when the task is dropped, so nothing waits on a
/// call that will never finish.
fn cancel_read_ahead(&self) {
let tasks = std::mem::take(&mut *self.inner.tasks());
let mut cancelled = 0_u64;
for task in tasks {
if !task.is_finished() {
task.abort();
cancelled += 1;
}
}
if cancelled > 0 {
self.inner
.readahead_cancelled
.fetch_add(cancelled, Ordering::Relaxed);
}
}
/// Queue the next few blocks after `from`.
///
/// Detached and best-effort by design: a prefetch that fails, or that never
/// gets a slot, costs the next demand read a round-trip and nothing else.
/// Blocks already resident are skipped without touching their recency, so
/// speculation cannot reorder the LRU against real playback.
fn read_ahead_from(&self, from: usize) {
let inner = &self.inner;
if inner.readahead == 0 {
return;
}
let revision = inner.blocks.revision_id();
let upto = (from + inner.readahead).min(inner.map.len());
for index in from..upto {
let key = BlockKey::new(&inner.uid, revision, index);
if inner.ring.contains(&key) {
continue;
}
let Ok(slot) = Arc::clone(&inner.readahead_slots).try_acquire_owned() else {
// Already as far ahead as this stream is allowed to run.
return;
};
inner.readahead_started.fetch_add(1, Ordering::Relaxed);
let spawned = Arc::clone(inner);
let task = tokio::spawn(async move {
let _slot = slot;
if let Err(e) = block_at(spawned, index).await {
tracing::debug!(index, error = %e, "read-ahead block failed");
}
});
let mut tasks = inner.tasks();
// Finished handles are dead weight; drop them while we are here so
// the list stays the size of the read-ahead window.
tasks.retain(|task| !task.is_finished());
tasks.push(task);
}
}
}
/// Cap read-ahead at half the ring's capacity in blocks.
///
/// Reading further ahead than the ring can hold is self-defeating: the blocks at
/// the front of the window are evicted before the player reaches them, so they
/// are fetched twice and the bandwidth spent on them is lost. Measured at depth
/// 32 against a 128 MiB ring (32 blocks), sustained throughput fell from
/// 8.7 MiB/s to 7.8 with six evictions — worse than depth 12.
///
/// Half, not all, because the ring also has to hold what the player has just
/// *passed*: a scrub a few seconds backwards is common and should not refetch.
fn clamp_readahead(requested: usize, ring_budget: u64, block_sizes: &[u64]) -> usize {
let block = block_sizes.iter().copied().max().unwrap_or(0);
if block == 0 {
return requested;
}
let capacity = (ring_budget / block) as usize;
requested.min((capacity / 2).max(1))
}
/// Whether a read starting at `offset` continues from where the last one ended.
///
/// Deliberately generous in both directions — see [`SEQUENTIAL_SLACK`]. The
/// first read of a stream (`previous_end == 0`, `offset == 0`) is a
/// continuation, so opening a file does not count as a seek.
fn is_continuation(offset: u64, previous_end: u64) -> bool {
offset >= previous_end.saturating_sub(SEQUENTIAL_SLACK)
&& offset <= previous_end.saturating_add(SEQUENTIAL_SLACK)
}
impl Inner {
fn tasks(&self) -> std::sync::MutexGuard<'_, Vec<tokio::task::JoinHandle<()>>> {
self.readahead_tasks
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
}
/// Resolve one block: memory, then disk, then the network.
///
/// Takes an owned `Arc` so the future is `'static` and can be both spawned for
/// read-ahead and buffered inside a read.
async fn block_at(inner: Arc<Inner>, index: usize) -> Result<CachedBlock> {
let key = BlockKey::new(&inner.uid, inner.blocks.revision_id(), index);
if let Some(block) = inner.ring.get(&key) {
return Ok(block);
}
let flight = Arc::clone(&inner.flight);
let fetch_key = key.clone();
flight
.run(key, move || async move {
if let Some(disk) = inner.disk.as_ref()
&& let Some(bytes) = disk.get(&fetch_key).await
{
let block: CachedBlock = bytes.into();
inner.ring.insert(fetch_key, Arc::clone(&block));
return Ok(block);
}
let bytes = inner.blocks.read_block(index).await?;
inner.fetched_blocks.fetch_add(1, Ordering::Relaxed);
inner
.fetched_bytes
.fetch_add(bytes.len() as u64, Ordering::Relaxed);
if let Some(disk) = inner.disk.as_ref() {
disk.put(&fetch_key, &bytes).await;
}
let block: CachedBlock = bytes.into();
inner.ring.insert(fetch_key, Arc::clone(&block));
Ok(block)
})
.await
}
#[cfg(test)]
mod tests {
use std::sync::atomic::AtomicUsize;
use async_trait::async_trait;
use proton_sdk::ids::{LinkId, VolumeId};
use super::*;
use crate::block::BlockSource;
use crate::ring::DEFAULT_RING_BYTES;
fn uid() -> NodeUid {
NodeUid::new(VolumeId::new("vol"), LinkId::new("link"))
}
/// Deterministic content: byte `i` of the file is `(i % 251) as u8`, so any
/// range can be checked without holding the whole file.
fn expected(offset: u64, len: usize) -> Vec<u8> {
(0..len)
.map(|i| ((offset + i as u64) % 251) as u8)
.collect()
}
/// A block source that counts fetches per block and can be made slow or
/// failing.
///
/// Per-block counters rather than one total: read-ahead runs detached, so a
/// total is inherently racy, whereas "block 1 was fetched exactly once" is
/// the property the tests actually care about and is stable regardless of
/// when the prefetch tasks happen to be scheduled.
struct Fake {
sizes: Vec<u64>,
starts: Vec<u64>,
per_block: Vec<AtomicUsize>,
delay: std::time::Duration,
fail_block: Option<usize>,
}
impl Fake {
fn new(sizes: &[u64]) -> Arc<Self> {
let mut starts = Vec::new();
let mut offset = 0;
for &size in sizes {
starts.push(offset);
offset += size;
}
Arc::new(Self {
sizes: sizes.to_vec(),
starts,
per_block: sizes.iter().map(|_| AtomicUsize::new(0)).collect(),
delay: std::time::Duration::ZERO,
fail_block: None,
})
}
fn slow(sizes: &[u64], delay: std::time::Duration) -> Arc<Self> {
let mut fake = Fake::new(sizes);
Arc::get_mut(&mut fake).unwrap().delay = delay;
fake
}
fn failing(sizes: &[u64], block: usize) -> Arc<Self> {
let mut fake = Fake::new(sizes);
Arc::get_mut(&mut fake).unwrap().fail_block = Some(block);
fake
}
fn fetches_of(&self, index: usize) -> usize {
self.per_block[index].load(Ordering::SeqCst)
}
fn fetches(&self) -> usize {
self.per_block
.iter()
.map(|count| count.load(Ordering::SeqCst))
.sum()
}
}
#[async_trait]
impl BlockSource for Fake {
fn revision_id(&self) -> &str {
"rev1"
}
fn block_sizes(&self) -> &[u64] {
&self.sizes
}
async fn read_block(&self, index: usize) -> Result<Vec<u8>> {
if Some(index) == self.fail_block {
return Err(crate::Error::NotFound(format!("block {index} is broken")));
}
self.per_block[index].fetch_add(1, Ordering::SeqCst);
if !self.delay.is_zero() {
tokio::time::sleep(self.delay).await;
}
let start = self.starts[index];
Ok(expected(start, self.sizes[index] as usize))
}
}
fn stream_over(fake: Arc<Fake>, readahead: usize) -> VideoStream {
VideoStream::new(
uid(),
fake,
Arc::new(BlockRing::new(DEFAULT_RING_BYTES)),
None,
readahead,
)
}
#[tokio::test]
async fn a_stream_reports_the_revisions_size() {
let stream = stream_over(Fake::new(&[1000, 1000, 500]), 0);
assert_eq!(stream.size(), 2500);
assert_eq!(stream.block_sizes(), &[1000, 1000, 500]);
}
#[tokio::test]
async fn a_read_inside_one_block_returns_the_right_bytes() {
let stream = stream_over(Fake::new(&[1000, 1000, 500]), 0);
assert_eq!(stream.read_range(10, 20).await.unwrap(), expected(10, 20));
}
/// The case that matters for playback: a read straddling a block boundary
/// must stitch two blocks without a seam.
#[tokio::test]
async fn a_read_across_a_boundary_stitches_blocks_correctly() {
let stream = stream_over(Fake::new(&[1000, 1000, 500]), 0);
assert_eq!(stream.read_range(990, 30).await.unwrap(), expected(990, 30));
}
#[tokio::test]
async fn a_read_spanning_every_block_returns_the_whole_file() {
let stream = stream_over(Fake::new(&[1000, 1000, 500]), 0);
assert_eq!(stream.read_range(0, 2500).await.unwrap(), expected(0, 2500));
}
/// A cold seek to the middle must fetch only the block it lands in — that is
/// the entire reason this app can exist over a 4 MiB block store.
#[tokio::test]
async fn a_seek_fetches_only_the_blocks_it_touches() {
let fake = Fake::new(&[1000; 100]);
let stream = stream_over(Arc::clone(&fake), 0);
stream.read_range(75_000, 100).await.unwrap();
assert_eq!(fake.fetches(), 1, "seek pulled more than the landing block");
}
/// The short tail. Reading past it yields what exists, not an error.
#[tokio::test]
async fn a_read_past_the_end_is_clamped_rather_than_failing() {
let stream = stream_over(Fake::new(&[1000, 500]), 0);
let bytes = stream.read_range(1400, 1000).await.unwrap();
assert_eq!(bytes, expected(1400, 100));
}
#[tokio::test]
async fn a_read_at_or_past_the_end_returns_nothing() {
let stream = stream_over(Fake::new(&[100]), 0);
assert!(stream.read_range(100, 10).await.unwrap().is_empty());
assert!(stream.read_range(5_000, 10).await.unwrap().is_empty());
}
/// Rewatching a scene must come out of memory, not off the wire.
#[tokio::test]
async fn a_repeated_read_is_served_from_the_ring() {
let fake = Fake::new(&[1000, 1000]);
let stream = stream_over(Arc::clone(&fake), 0);
stream.read_range(0, 100).await.unwrap();
stream.read_range(200, 100).await.unwrap();
stream.read_range(500, 100).await.unwrap();
assert_eq!(fake.fetches(), 1);
assert!(stream.stats().ring.hits >= 2);
}
/// Read-ahead exists so the demuxer's *next* read is already resident.
#[tokio::test]
async fn read_ahead_pulls_the_following_blocks() {
let fake = Fake::new(&[1000; 10]);
let stream = stream_over(Arc::clone(&fake), 3);
stream.read_range(0, 100).await.unwrap();
// Detached tasks; give them a chance to run.
for _ in 0..50 {
if fake.fetches() >= 4 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
for index in 0..4 {
assert_eq!(fake.fetches_of(index), 1, "block {index} not read ahead");
}
assert_eq!(fake.fetches_of(4), 0, "read-ahead ran past its window");
// And the next read is served from what read-ahead already pulled.
assert_eq!(
stream.read_range(1000, 50).await.unwrap(),
expected(1000, 50)
);
assert_eq!(fake.fetches_of(1), 1, "read-ahead's block was refetched");
}
/// Read-ahead must stop at EOF rather than fetching blocks that do not
/// exist.
#[tokio::test]
async fn read_ahead_stops_at_the_end_of_the_file() {
let fake = Fake::new(&[1000, 1000]);
let stream = stream_over(Arc::clone(&fake), 8);
stream.read_range(0, 10).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(fake.fetches(), 2, "fetched past the last block");
}
/// The collision read-ahead makes inevitable: the demand read wants the
/// block a prefetch is already fetching. It must wait for it, not start a
/// second fetch.
#[tokio::test]
async fn a_demand_read_joins_an_in_flight_read_ahead_instead_of_refetching() {
let fake = Fake::slow(&[1000; 10], std::time::Duration::from_millis(80));
let stream = stream_over(Arc::clone(&fake), 4);
stream.read_range(0, 10).await.unwrap();
// Read-ahead for blocks 1..5 is now in flight against an 80 ms source.
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
assert_eq!(
stream.read_range(1000, 10).await.unwrap(),
expected(1000, 10)
);
assert_eq!(
fake.fetches_of(1),
1,
"the demand read started a second fetch of an in-flight block"
);
}
/// Read-ahead is speculative; its failures must never surface to the player.
#[tokio::test]
async fn a_failing_read_ahead_does_not_fail_the_read() {
let fake = Fake::failing(&[1000; 5], 2);
let stream = stream_over(Arc::clone(&fake), 4);
assert_eq!(stream.read_range(0, 10).await.unwrap(), expected(0, 10));
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
// And the demand read for that block still reports the real failure.
assert!(stream.read_range(2000, 10).await.is_err());
}
/// A source failure on the demand path is an error, not silence — silence
/// would look like EOF and the player would stop mid-episode.
#[tokio::test]
async fn a_failing_block_surfaces_as_an_error() {
let fake = Fake::failing(&[1000; 3], 0);
let stream = stream_over(fake, 0);
assert!(stream.read_range(0, 10).await.is_err());
}
/// Concurrent readers of one stream — mpv's demuxer and its seek probe do
/// exactly this — must not multiply the fetches.
#[tokio::test]
async fn concurrent_reads_of_the_same_block_fetch_it_once() {
let fake = Fake::slow(&[4096], std::time::Duration::from_millis(50));
let stream = stream_over(Arc::clone(&fake), 0);
let mut handles = Vec::new();
for i in 0..8_u64 {
let stream = stream.clone();
handles.push(tokio::spawn(
async move { stream.read_range(i * 10, 10).await },
));
}
for (i, handle) in handles.into_iter().enumerate() {
let bytes = handle.await.unwrap().unwrap();
assert_eq!(bytes, expected(i as u64 * 10, 10));
}
assert_eq!(fake.fetches(), 1);
}
/// Non-uniform block sizes must read correctly — the SDK warns that
/// assuming 4 MiB serves bytes from the wrong offset, silently.
#[tokio::test]
async fn non_uniform_block_sizes_read_correctly() {
let fake = Fake::new(&[13, 1021, 7, 4096, 1]);
let stream = stream_over(fake, 0);
assert_eq!(stream.size(), 5138);
assert_eq!(stream.read_range(0, 5138).await.unwrap(), expected(0, 5138));
assert_eq!(
stream.read_range(1030, 40).await.unwrap(),
expected(1030, 40)
);
}
/// The memory promise holds even for a file far larger than the ring.
#[tokio::test]
async fn streaming_a_file_larger_than_the_ring_stays_inside_the_budget() {
let fake = Fake::new(&[1024; 200]);
let stream = VideoStream::new(uid(), fake, Arc::new(BlockRing::new(8 * 1024)), None, 0);
let mut offset = 0;
while offset < stream.size() {
stream.read_range(offset, 512).await.unwrap();
assert!(stream.stats().ring.resident_bytes <= 8 * 1024);
offset += 512;
}
}
#[tokio::test]
async fn read_at_fills_the_callers_buffer() {
let stream = stream_over(Fake::new(&[1000, 1000]), 0);
let mut buf = vec![0_u8; 64];
assert_eq!(stream.read_at(990, &mut buf).await.unwrap(), 64);
assert_eq!(buf, expected(990, 64));
}
/// Reading further ahead than the ring can hold evicts the window's own
/// blocks before the player reaches them.
#[test]
fn read_ahead_is_capped_at_half_the_rings_capacity() {
// 128 MiB of 4 MiB blocks is 32 blocks, so at most 16 ahead.
let four_mib = 4 * 1024 * 1024;
assert_eq!(clamp_readahead(32, 128 * 1024 * 1024, &[four_mib; 200]), 16);
assert_eq!(clamp_readahead(12, 128 * 1024 * 1024, &[four_mib; 200]), 12);
}
/// A ring too small for even one block must still read ahead by one, or
/// playback degenerates to a stall per block with nothing overlapping.
#[test]
fn a_tiny_ring_still_reads_one_block_ahead() {
assert_eq!(clamp_readahead(12, 1024, &[4 * 1024 * 1024]), 1);
}
/// A stream configured with no read-ahead must keep none — the clamp raises
/// a floor for what to *fetch*, not for whether to fetch at all.
#[tokio::test]
async fn read_ahead_stays_off_when_it_is_configured_off() {
let fake = Fake::new(&[1024; 32]);
let stream = VideoStream::new(
uid(),
Arc::clone(&fake) as SharedBlocks,
Arc::new(BlockRing::new(1024)),
None,
0,
);
stream.read_range(0, 100).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert_eq!(fake.fetches(), 1);
}
#[test]
fn a_forward_read_within_one_block_continues_the_previous_one() {
assert!(is_continuation(0, 0), "the first read is not a seek");
assert!(is_continuation(1_000, 1_000));
assert!(is_continuation(1_000 + SEQUENTIAL_SLACK, 1_000));
}
/// Demuxers re-read a little behind themselves for headers and index
/// entries. Calling that a seek would cancel read-ahead constantly.
#[test]
fn a_small_backward_read_still_continues_the_previous_one() {
assert!(is_continuation(100_000_000 - SEQUENTIAL_SLACK, 100_000_000));
assert!(is_continuation(0, SEQUENTIAL_SLACK));
}
#[test]
fn a_jump_past_the_slack_is_a_seek() {
assert!(!is_continuation(100_000_000, 1_000));
assert!(!is_continuation(0, 100_000_000));
}
/// The measured bug: prefetches from the position the viewer left were
/// holding the bandwidth the seek needed, costing 5x on worst-case latency.
#[tokio::test]
async fn a_seek_cancels_the_read_ahead_it_invalidates() {
let fake = Fake::slow(&[1024 * 1024; 200], std::time::Duration::from_millis(200));
let stream = stream_over(Arc::clone(&fake), 4);
stream.read_range(0, 1_000).await.unwrap();
// Prefetches for blocks 1..5 are now in flight against a slow source.
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert!(stream.stats().readahead_blocks > 0, "nothing to cancel");
// Far past the slack: a seek-bar drag.
stream.read_range(150 * 1024 * 1024, 1_000).await.unwrap();
let stats = stream.stats();
assert_eq!(stats.seeks, 1);
assert!(
stats.readahead_cancelled > 0,
"seek did not cancel the stale prefetches"
);
}
/// And the mirror image: steady playback must not keep throwing its own
/// read-ahead away.
#[tokio::test]
async fn sequential_playback_never_cancels_its_own_read_ahead() {
let fake = Fake::new(&[1024; 64]);
let stream = stream_over(fake, 4);
let mut offset = 0;
while offset < stream.size() {
stream.read_range(offset, 256).await.unwrap();
offset += 256;
}
let stats = stream.stats();
assert_eq!(stats.seeks, 0, "sequential reads read as seeks");
assert_eq!(stats.readahead_cancelled, 0);
}
/// A cancelled prefetch must not leave the block unfetchable — the
/// single-flight entry has to be released when the task is dropped.
#[tokio::test]
async fn a_block_whose_prefetch_was_cancelled_can_still_be_read() {
let fake = Fake::slow(&[1024 * 1024; 200], std::time::Duration::from_millis(100));
let stream = stream_over(Arc::clone(&fake), 4);
stream.read_range(0, 1_000).await.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
stream.read_range(150 * 1024 * 1024, 1_000).await.unwrap();
// Block 1 was almost certainly aborted mid-fetch; reading it must work.
assert_eq!(
stream.read_range(1024 * 1024, 16).await.unwrap(),
expected(1024 * 1024, 16)
);
}
/// The player passes a fixed-size buffer at every offset, including the last
/// one; a short read there is normal and must be reported honestly.
#[tokio::test]
async fn a_short_read_at_the_tail_reports_what_it_wrote() {
let stream = stream_over(Fake::new(&[100]), 0);
let mut buf = vec![0_u8; 64];
assert_eq!(stream.read_at(80, &mut buf).await.unwrap(), 20);
assert_eq!(&buf[..20], &expected(80, 20)[..]);
}
}