mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
init
This commit is contained in:
commit
a1b345a029
80 files changed
+28033
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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."));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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, ®istry)?;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>()) });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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};
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)[..]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user