From ad245575882b48c3ee218743ce8938ee2cd6eb3d Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 15 Aug 2026 14:49:04 +0200 Subject: [PATCH] feature(gui) themes + chore(deps) version bump --- Cargo.lock | 22 +- Cargo.toml | 6 +- README.md | 2 +- .../narl/protonstream/ui/ProtonStreamApp.kt | 1 + crates/pstr-android/src/lib.rs | 6 + crates/pstr-app/src/app.rs | 9 + crates/pstr-app/src/lib.rs | 1 + crates/pstr-app/src/main.rs | 6 +- crates/pstr-app/src/pacing.rs | 186 ++++++++++++++ crates/pstr-core/src/appearance.rs | 81 ++++++- crates/pstr-core/src/shares.rs | 70 ++++-- crates/pstr-meta/Cargo.toml | 4 +- crates/pstr-meta/src/anilist.rs | 70 ++++-- crates/pstr-meta/src/lib.rs | 1 + crates/pstr-meta/src/limiter.rs | 227 ++++++++++++++++++ crates/pstr-meta/src/tmdb.rs | 93 ++++--- docs/BUGS.md | 87 +++++++ packaging/PKGBUILD | 2 +- 18 files changed, 782 insertions(+), 92 deletions(-) create mode 100644 crates/pstr-app/src/pacing.rs create mode 100644 crates/pstr-meta/src/limiter.rs diff --git a/Cargo.lock b/Cargo.lock index f32497a..8986bd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4202,9 +4202,9 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proton-drive-rs" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b162681255e296b48ef7261cad899ffb384756e866a1c6d7b7c791cbbc8af3ac" +checksum = "3abae5e90e6889b120208159eb2c60b332612e5b708fdf3c14287307ce01715d" dependencies = [ "async-trait", "base64 0.22.1", @@ -4229,9 +4229,9 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.4.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d9b94ec901edb0e849238ea833121f2fb173308b4927b8aa6b157643fa0a73" +checksum = "3fa729310c1a66e12bb41b9f5f036c86b9857e76536556052203ffecdb0b44c3" dependencies = [ "aes-gcm 0.11.0", "async-trait", @@ -4262,7 +4262,7 @@ dependencies = [ [[package]] name = "pstr-android" -version = "0.4.0" +version = "0.4.1" dependencies = [ "android_logger", "jni 0.21.1", @@ -4281,7 +4281,7 @@ dependencies = [ [[package]] name = "pstr-app" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "eframe", @@ -4303,7 +4303,7 @@ dependencies = [ [[package]] name = "pstr-cli" -version = "0.4.0" +version = "0.4.1" dependencies = [ "anyhow", "clap", @@ -4321,7 +4321,7 @@ dependencies = [ [[package]] name = "pstr-core" -version = "0.4.0" +version = "0.4.1" dependencies = [ "directories", "futures", @@ -4339,7 +4339,7 @@ dependencies = [ [[package]] name = "pstr-meta" -version = "0.4.0" +version = "0.4.1" dependencies = [ "pstr-core", "reqwest", @@ -4352,7 +4352,7 @@ dependencies = [ [[package]] name = "pstr-player" -version = "0.4.0" +version = "0.4.1" dependencies = [ "async-trait", "libloading", @@ -4367,7 +4367,7 @@ dependencies = [ [[package]] name = "pstr-stream" -version = "0.4.0" +version = "0.4.1" dependencies = [ "async-trait", "futures", diff --git a/Cargo.toml b/Cargo.toml index 01cd99c..39064ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.4.0" +version = "0.4.1" edition = "2024" license = "MIT" repository = "https://github.com/narrrl/proton-stream" @@ -28,8 +28,8 @@ authors = ["Nils Pukropp "] opt-level = 2 [workspace.dependencies] -proton-sdk = { version = "0.4.0" } -proton-drive-rs = { version = "0.4.0" } +proton-sdk = { version = "0.5.1" } +proton-drive-rs = { version = "0.5.1" } # Internal crates. pstr-core = { path = "crates/pstr-core" } diff --git a/README.md b/README.md index a352f7f..fb9a84b 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ against the SDK's own range download. | Player | Embedded libmpv, drawing into the app's own GL surface: HEVC 10-bit, HDR and ASS subtitles with hardware decode. Volume, audio-track, subtitle and chapter pickers; skip-the-opening; next/previous episode and autoplay. The language picked is preferred for the next file too. | | Transcoding | None. mpv demuxes and decodes natively. | | UI | egui/eframe on glow; mpv renders into the same GL context. | -| Themes | The shipped near-black palette plus Catppuccin Latte, Frappé, Macchiato and Mocha, each with a choice of eight accents — pink and sky among them — drawn as a gradient unless you turn that off. Picked on the Shares page, applied without a restart. | +| Themes | The shipped near-black palette, Catppuccin Latte, Frappé, Macchiato and Mocha, and a Persona 5 one — black, white and one loud red, which is what the Red accent is there for — each with a choice of nine accents, drawn as a gradient unless you turn that off. Picked on the Settings page, applied without a restart. | | Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. It brings posters, synopses and per-episode titles; a title the scorer refuses to guess about can be pinned by hand, and a pinned match is never overwritten. | | Platforms | Linux and Windows; macOS packaging is written but has not been run on a Mac. A native Android 12+ client is available featuring a Material 3 UI and immersive fullscreen playback. | diff --git a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt index b90b3f3..f4060e6 100644 --- a/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt +++ b/android/app/src/main/java/io/narl/protonstream/ui/ProtonStreamApp.kt @@ -1624,6 +1624,7 @@ private fun FlavorChoice.label() = when (this) { FlavorChoice.FRAPPE -> "Catppuccin Frappé" FlavorChoice.MACCHIATO -> "Catppuccin Macchiato" FlavorChoice.MOCHA -> "Catppuccin Mocha" + FlavorChoice.PERSONA5 -> "Persona 5" } /** diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 145a761..135e63d 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -152,6 +152,7 @@ pub enum FlavorChoice { Frappe, Macchiato, Mocha, + Persona5, } /// The one strong colour. The shared `pstr_core::appearance::Accent`. @@ -165,6 +166,7 @@ pub enum AccentChoice { Blue, Teal, Peach, + Red, } /// What the viewer chose, stored where the desktop client reads it too. @@ -2348,6 +2350,7 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord { Flavor::Frappe => FlavorChoice::Frappe, Flavor::Macchiato => FlavorChoice::Macchiato, Flavor::Mocha => FlavorChoice::Mocha, + Flavor::Persona5 => FlavorChoice::Persona5, }, accent: match appearance.accent { Accent::Mauve => AccentChoice::Mauve, @@ -2358,6 +2361,7 @@ fn appearance_record(appearance: Appearance) -> AppearanceRecord { Accent::Blue => AccentChoice::Blue, Accent::Teal => AccentChoice::Teal, Accent::Peach => AccentChoice::Peach, + Accent::Red => AccentChoice::Red, }, gradients: appearance.gradients, } @@ -2371,6 +2375,7 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance { FlavorChoice::Frappe => Flavor::Frappe, FlavorChoice::Macchiato => Flavor::Macchiato, FlavorChoice::Mocha => Flavor::Mocha, + FlavorChoice::Persona5 => Flavor::Persona5, }, accent: match record.accent { AccentChoice::Mauve => Accent::Mauve, @@ -2381,6 +2386,7 @@ fn appearance_choice(record: AppearanceRecord) -> Appearance { AccentChoice::Blue => Accent::Blue, AccentChoice::Teal => Accent::Teal, AccentChoice::Peach => Accent::Peach, + AccentChoice::Red => Accent::Red, }, gradients: record.gradients, } diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 525bd8f..eab7e1b 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -17,6 +17,7 @@ use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord}; use crate::engine::{ DownloadItem, DownloadKey, Engine, Event, ImageCache, describe_failures, watch_state, }; +use crate::pacing::Pacer; use crate::playback::{Playback, PlaybackTarget}; use crate::ui::player::UpNextCard; use crate::{theme, ui}; @@ -297,6 +298,9 @@ pub struct App { pub offline_files: std::collections::HashSet, pub confirm_partial_delete: Option, pub status: Option, + /// The frame ceiling, where this session is pacing itself rather than + /// letting vsync do it — see [`crate::pacing`]. + pacer: Pacer, } impl App { @@ -347,6 +351,7 @@ impl App { offline_files: std::collections::HashSet::new(), confirm_partial_delete: None, status: None, + pacer: Pacer::new(), }) } @@ -973,6 +978,10 @@ impl App { impl eframe::App for App { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + // Before the timer: this is the frame budget being spent waiting, not + // the frame taking longer, and `FrameTimer` reports slow frames. + self.pacer.wait(); + let _timer = FrameTimer::new(&self.page); let ctx = ui.ctx().clone(); let ctx = &ctx; diff --git a/crates/pstr-app/src/lib.rs b/crates/pstr-app/src/lib.rs index 6ebc7e0..14534e1 100644 --- a/crates/pstr-app/src/lib.rs +++ b/crates/pstr-app/src/lib.rs @@ -21,6 +21,7 @@ pub mod app; pub mod engine; +pub mod pacing; pub mod playback; pub mod theme; pub mod ui; diff --git a/crates/pstr-app/src/main.rs b/crates/pstr-app/src/main.rs index 0de4802..bb533d3 100644 --- a/crates/pstr-app/src/main.rs +++ b/crates/pstr-app/src/main.rs @@ -31,7 +31,7 @@ fn main() -> anyhow::Result<()> { ); let dirs = AppDirs::ensure().context("resolve app directories")?; - let options = eframe::NativeOptions { + let mut options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("proton-stream") .with_inner_size([1180.0, 780.0]) @@ -39,6 +39,10 @@ fn main() -> anyhow::Result<()> { .with_app_id("io.narl.proton-stream"), ..Default::default() }; + // Off on Wayland, where a swap on a window nobody is looking at never + // returns and the compositor decides the app has hung. `pstr_app::pacing` + // has the whole of it, and caps the frame rate in vsync's place. + options.glow_options.vsync = pstr_app::pacing::vsync(); eframe::run_native( "proton-stream", diff --git a/crates/pstr-app/src/pacing.rs b/crates/pstr-app/src/pacing.rs new file mode 100644 index 0000000..fb19351 --- /dev/null +++ b/crates/pstr-app/src/pacing.rs @@ -0,0 +1,186 @@ +//! How often the window is allowed to draw — and why it does not simply let +//! vsync decide on Wayland. +//! +//! ## The bug this exists for +//! +//! A Wayland compositor throttles a client by *withholding* the `wl_surface` +//! frame callback, and it withholds it for as long as the surface is not being +//! shown — a window on another workspace, or behind a fullscreen one. Mesa's +//! EGL implements a swap interval of 1 by waiting on exactly that callback, so +//! `eglSwapBuffers` on a hidden surface does not return late, it does not +//! return at all: +//! +//! ```text +//! eframe run_ui_and_paint ─▶ glutin swap_buffers ─▶ eglSwapBuffers +//! │ +//! wl_display_dispatch_queue ◀─┘ ppoll, forever +//! ``` +//! +//! That is the whole of the UI thread, and the same thread is the one that +//! answers `xdg_wm_base.ping`. Hyprland pings a client it has not heard from +//! and puts up an "application is not responding" dialog when the pong does not +//! come, which is what a viewer sees a few seconds after switching away from a +//! playing window. Nothing is actually wrong — coming back releases the frame +//! callback and the app carries on — but a dialog offering to kill the player +//! is not an acceptable way to say "this window is not visible". +//! +//! winit cannot rescue this for us: `WindowEvent::Occluded` is X11, macOS and +//! web only — its Wayland implementation was deliberately removed in 0.29 — so +//! eframe never learns the window is hidden and keeps painting it. +//! +//! ## What is done instead +//! +//! On Wayland the swap interval is set to 0 ([`vsync`]), which makes +//! `eglSwapBuffers` return immediately whether the surface is being shown or +//! not, and the frame rate is capped here instead ([`Pacer`]). Nothing is lost +//! by dropping vsync there: a Wayland compositor composites from the last +//! buffer a client committed, at its own refresh, so a client that commits too +//! often wastes work but cannot tear. X11 and Windows keep vsync, where a +//! client really does drive the scanout and hidden windows do not block. +//! +//! The cap is a ceiling and not a rate: frames are drawn when something asks +//! for one — a new mpv frame, an animation, a keystroke — so an idle window +//! still draws nothing, and a 24 fps film still draws 24 times a second. + +use std::time::{Duration, Instant}; + +/// The default ceiling, in frames a second. +/// +/// Above the rate of any video this plays and of every animation in the app. +/// It is deliberately not the display's refresh rate, which neither eframe nor +/// egui gives us — a window on a 144 Hz panel therefore animates at 60 while +/// vsync is off. [`CAP_VAR`] is the way out for someone who minds. +const DEFAULT_CAP: f32 = 60.0; + +/// Overrides [`DEFAULT_CAP`]. `0` removes the ceiling altogether. +const CAP_VAR: &str = "PSTR_FRAME_CAP"; + +/// Whether this process is talking to a Wayland compositor, decided the way +/// winit decides it: either variable, non-empty, Wayland first. +pub fn on_wayland() -> bool { + ["WAYLAND_DISPLAY", "WAYLAND_SOCKET"] + .iter() + .any(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty())) +} + +/// Whether to ask for a swap interval of 1. False on Wayland — see the module +/// documentation. +pub fn vsync() -> bool { + !on_wayland() +} + +/// The ceiling to pace to, or `None` when vsync is doing the pacing. +fn cap() -> Option { + if vsync() { + return None; + } + let Ok(value) = std::env::var(CAP_VAR) else { + return Some(DEFAULT_CAP); + }; + // Not a number, or a negative one, is a typo rather than a request to + // free-run — free-running is what `0` says, explicitly. + let parsed = value + .trim() + .parse::() + .ok() + .filter(|fps| fps.is_finite() && *fps >= 0.0); + match parsed { + Some(fps) if fps > 0.0 => Some(fps), + Some(_) => None, + None => { + tracing::warn!("{CAP_VAR}={value:?} is not a frame rate; using {DEFAULT_CAP}"); + Some(DEFAULT_CAP) + } + } +} + +/// Holds the draw loop to a maximum rate. +/// +/// Sleeping is the only thing that can do this: a frame is under way by the +/// time the app sees it, and every other source of repaints — mpv, egui's own +/// animations — asks for the *next* one immediately. The sleep is at most one +/// frame's worth, which is far inside the several seconds a compositor waits +/// for a pong. +pub struct Pacer { + interval: Option, + last: Instant, +} + +impl Default for Pacer { + fn default() -> Self { + Self::new() + } +} + +impl Pacer { + /// The pacer this session wants: capped when vsync is off, a no-op when it + /// is on. + pub fn new() -> Self { + Self::with_cap(cap()) + } + + fn with_cap(fps: Option) -> Self { + Self { + interval: fps.map(|fps| Duration::from_secs_f32(1.0 / fps)), + last: Instant::now(), + } + } + + /// Whether this pacer does anything at all. + pub fn is_capped(&self) -> bool { + self.interval.is_some() + } + + /// Called once at the top of a frame: sleeps out whatever is left of the + /// last one's budget. + pub fn wait(&mut self) { + let Some(interval) = self.interval else { + return; + }; + let elapsed = self.last.elapsed(); + if let Some(left) = interval.checked_sub(elapsed) { + std::thread::sleep(left); + } + self.last = Instant::now(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_uncapped_pacer_never_sleeps() { + let mut pacer = Pacer::with_cap(None); + assert!(!pacer.is_capped()); + let before = Instant::now(); + pacer.wait(); + pacer.wait(); + assert!(before.elapsed() < Duration::from_millis(50)); + } + + #[test] + fn a_capped_pacer_holds_the_second_frame_back() { + let mut pacer = Pacer::with_cap(Some(50.0)); + assert!(pacer.is_capped()); + pacer.wait(); + let before = Instant::now(); + pacer.wait(); + // 20 ms apart, less whatever the first call spent. Sleeping is only + // ever long, so the lower bound is the one worth asserting on. + assert!( + before.elapsed() >= Duration::from_millis(10), + "second frame was not held back: {:?}", + before.elapsed() + ); + } + + #[test] + fn a_frame_that_took_longer_than_the_budget_is_not_delayed_further() { + let mut pacer = Pacer::with_cap(Some(1000.0)); + std::thread::sleep(Duration::from_millis(5)); + let before = Instant::now(); + pacer.wait(); + assert!(before.elapsed() < Duration::from_millis(2)); + } +} diff --git a/crates/pstr-core/src/appearance.rs b/crates/pstr-core/src/appearance.rs index ee4ac42..ebf2930 100644 --- a/crates/pstr-core/src/appearance.rs +++ b/crates/pstr-core/src/appearance.rs @@ -9,7 +9,8 @@ use crate::config::{AppDirs, read_json, write_json}; use crate::error::Result; -/// A palette family: Catppuccin's four, plus the one this app shipped with. +/// A palette family: Catppuccin's four, the one this app shipped with, and one +/// borrowed from a menu screen. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "kebab-case")] pub enum Flavor { @@ -22,15 +23,18 @@ pub enum Flavor { Frappe, Macchiato, Mocha, + /// Black, white and one loud red — the Persona 5 menus. + Persona5, } impl Flavor { - pub const ALL: [Self; 5] = [ + pub const ALL: [Self; 6] = [ Self::Proton, Self::Mocha, Self::Macchiato, Self::Frappe, Self::Latte, + Self::Persona5, ]; pub fn label(self) -> &'static str { @@ -40,6 +44,7 @@ impl Flavor { Self::Frappe => "Catppuccin Frappé", Self::Macchiato => "Catppuccin Macchiato", Self::Mocha => "Catppuccin Mocha", + Self::Persona5 => "Persona 5", } } @@ -50,6 +55,7 @@ impl Flavor { Self::Frappe => "Dark, warm, the lowest contrast of the three", Self::Macchiato => "Dark, between Frappé and Mocha", Self::Mocha => "Dark, the deepest of Catppuccin's three", + Self::Persona5 => "Black and white with one loud red — pick the Red accent for it", } } @@ -82,10 +88,15 @@ pub enum Accent { Blue, Teal, Peach, + /// The flavour's warning colour, used on purpose. Every flavour has one + /// because everything here needs a colour for a failure, and in + /// [`Flavor::Persona5`] it is the point of the palette rather than an + /// afterthought. + Red, } impl Accent { - pub const ALL: [Self; 8] = [ + pub const ALL: [Self; 9] = [ Self::Mauve, Self::Pink, Self::Sky, @@ -94,6 +105,7 @@ impl Accent { Self::Blue, Self::Teal, Self::Peach, + Self::Red, ]; pub fn label(self) -> &'static str { @@ -106,6 +118,7 @@ impl Accent { Self::Blue => "Blue", Self::Teal => "Teal", Self::Peach => "Peach", + Self::Red => "Red", } } } @@ -423,6 +436,36 @@ const MOCHA: Ramp = Ramp { light: false, }; +/// Persona 5's menus: black, white, and a red that is not asking permission. +/// +/// The three colours that matter are `crust`/`base` (black), `text` (white) and +/// `red` — everything between them is that same red bled into the black, which +/// is what keeps a card from reading as grey furniture in a palette that has no +/// grey in it. The other hues exist because an accent can be any of them; they +/// are pushed to full saturation to sit on a black page, which is the one thing +/// this palette has in common with [`PROTON`]. +const PERSONA5: Ramp = Ramp { + base: rgb(0x0a0708), + mantle: rgb(0x140d0f), + crust: rgb(0x000000), + surface0: rgb(0x1d1215), + surface1: rgb(0x33191d), + subtext0: rgb(0xb59a9d), + text: rgb(0xf5f2f2), + pink: rgb(0xff2e63), + mauve: rgb(0xb13bd6), + sky: rgb(0x2ec7ff), + sapphire: rgb(0x1f9bd0), + blue: rgb(0x2f5ee0), + lavender: rgb(0xa07bff), + teal: rgb(0x12c2a0), + green: rgb(0x2fbf4f), + peach: rgb(0xff7a1a), + yellow: rgb(0xffc400), + red: rgb(0xe60012), + light: false, +}; + impl Ramp { const fn of(flavor: Flavor) -> Self { match flavor { @@ -431,6 +474,7 @@ impl Ramp { Flavor::Frappe => FRAPPE, Flavor::Macchiato => MACCHIATO, Flavor::Mocha => MOCHA, + Flavor::Persona5 => PERSONA5, } } @@ -450,6 +494,7 @@ impl Ramp { Accent::Blue => (self.blue, self.sapphire), Accent::Teal => (self.teal, self.green), Accent::Peach => (self.peach, self.yellow), + Accent::Red => (self.red, self.pink), } } } @@ -630,6 +675,36 @@ mod tests { } } + #[test] + fn persona_5_is_black_with_that_red_in_it() { + let palette = Palette::resolve(Appearance { + flavor: Flavor::Persona5, + accent: Accent::Red, + gradients: false, + }); + assert_eq!(palette.accent, PERSONA5.red); + assert!(!palette.light); + // Black enough that the artwork is the brightest thing on the page: + // darker than the palette this app shipped with, which is the other + // near-black one. + assert!(luminance(palette.background) < luminance(PROTON.base)); + } + + #[test] + fn every_flavour_can_wear_its_own_warning_colour() { + for flavor in Flavor::ALL { + let palette = Palette::resolve(Appearance { + flavor, + accent: Accent::Red, + gradients: true, + }); + // The accent is the flavour's red — deepened on the light flavour, + // where it would otherwise be too bright to put a label on. + assert!(palette.accent.r > palette.accent.b, "{flavor:?}"); + assert_ne!(palette.accent, palette.background, "{flavor:?}"); + } + } + #[test] fn turning_gradients_off_leaves_one_colour_to_draw() { let flat = Palette::resolve(Appearance { diff --git a/crates/pstr-core/src/shares.rs b/crates/pstr-core/src/shares.rs index 52b633c..3ed8bf3 100644 --- a/crates/pstr-core/src/shares.rs +++ b/crates/pstr-core/src/shares.rs @@ -337,43 +337,77 @@ impl SharedLibrary { Ok(()) } - /// Walk one share's whole subtree, depth first, yielding every node. + /// Walk one share's whole subtree, level by level, 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. + /// + /// **Breadth first and fanned out, because a media share is wide.** The + /// shape of one is a root holding a folder per series, each holding a folder + /// per season: a couple of hundred folders that are each one listing request + /// plus a detail fetch, and almost all of them siblings. Walking that one + /// folder at a time spends the whole crawl waiting on a round trip it could + /// have overlapped — the crawl was minutes of latency and seconds of work. + /// Taking a whole level at once is what collapses it. + /// + /// The client is cheap to clone and its clones share one node-key cache and + /// one single-flight map, so siblings that need the same ancestor key wait + /// on one derivation rather than each starting their own. `buffered` rather + /// than `buffer_unordered`: the crawl's output order is otherwise a race, + /// and a catalog that reorders itself between two identical crawls is + /// needlessly hard to diff. pub async fn crawl(&self, share_id: &str) -> Result> { + use futures::stream::{StreamExt as _, TryStreamExt as _}; + 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); + let mut level = vec![root.uid.clone()]; + let mut found = vec![root]; - while let Some(folder) = folders.pop() { - let child_uids: Vec = - 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()); + while !level.is_empty() { + let listings = level.into_iter().map(|folder| { + let client = client.clone(); + async move { + let child_uids: Vec = + client.enumerate_folder_children_node_uids(&folder).await?; + if child_uids.is_empty() { + return Ok(Vec::new()); + } + // Chunked and fanned out inside the SDK too, so this is a + // multiplier on that concurrency rather than the only one. + client.enumerate_nodes(&child_uids).await + } + }); + + let mut listings = futures::stream::iter(listings).buffered(CRAWL_CONCURRENCY); + let mut next = Vec::new(); + while let Some(children) = listings.try_next().await? { + for child in children { + if child.is_folder() { + next.push(child.uid.clone()); + } + found.push(child); } - found.push(child); } + level = next; } Ok(found) } } +/// How many folders of one level to list at once. +/// +/// Each of these fans out again inside the SDK — up to four detail batches per +/// folder — so this is not the number of requests in flight but a multiplier on +/// it. Six keeps the ceiling in the low tens: enough to hide the round trip on +/// a wide level, well short of a burst a share server would read as abuse. +const CRAWL_CONCURRENCY: usize = 6; + /// The app-version string Proton identifies this client by. /// /// The `external-drive-` prefix is **not** decoration: the API parses the part diff --git a/crates/pstr-meta/Cargo.toml b/crates/pstr-meta/Cargo.toml index ba6ef14..99bd704 100644 --- a/crates/pstr-meta/Cargo.toml +++ b/crates/pstr-meta/Cargo.toml @@ -10,6 +10,8 @@ authors.workspace = true [dependencies] pstr-core.workspace = true +# `time` for the rate limiter's sleeps, `sync` for the mutex it paces behind. +tokio = { workspace = true, features = ["time", "sync"] } serde.workspace = true serde_json.workspace = true thiserror.workspace = true @@ -19,4 +21,4 @@ tracing.workspace = true reqwest.workspace = true [dev-dependencies] -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "test-util"] } diff --git a/crates/pstr-meta/src/anilist.rs b/crates/pstr-meta/src/anilist.rs index bb43b4b..785e342 100644 --- a/crates/pstr-meta/src/anilist.rs +++ b/crates/pstr-meta/src/anilist.rs @@ -14,11 +14,30 @@ use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata}; use serde::Deserialize; use crate::error::{Error, Result}; +use crate::limiter::RateLimiter; use crate::matching::{Candidate, Query}; use crate::provider::Provider; const ENDPOINT: &str = "https://graphql.anilist.co"; +/// What AniList documents as its ceiling: ninety requests a minute. +/// +/// A starting point, not a belief — the API has been running in a degraded mode +/// that allows thirty for a long time now, and says so in `X-RateLimit-Limit` on +/// every answer. The limiter retunes itself off that header after the first +/// request, so the only cost of starting optimistic is one `429` on a run that +/// begins during a degraded window. +const REQUESTS_PER_MINUTE: u32 = 90; + +/// How many times to sit out a `429` before giving up on one request. +/// +/// Two, because the pause it asks for is the rest of its rate-limit window and +/// the limiter holds every other lookup for the same stretch — a third round is +/// minutes of a scan spent waiting rather than matching. Giving up returns +/// [`Error::RateLimited`], which is a failure and therefore not cached: the +/// title stays askable and the next run picks it up. +const RETRIES: usize = 2; + /// 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. @@ -66,11 +85,16 @@ query ($id: Int) { pub struct AniList { http: reqwest::Client, + /// Shared by every lookup in flight — see [`crate::limiter`]. + limiter: RateLimiter, } impl AniList { pub fn new(http: reqwest::Client) -> Self { - Self { http } + Self { + http, + limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE), + } } } @@ -132,24 +156,40 @@ impl Provider for AniList { } 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. + /// One GraphQL request, paced, with AniList's two ways of failing — an HTTP + /// status and an `errors` array under a 200 — folded into one. + /// + /// A `429` is waited out rather than returned: a scan sends hundreds of + /// these, and the request that trips the limit is not the one that was + /// wrong. See [`crate::limiter`]. async fn query( &self, body: serde_json::Value, ) -> Result> { - 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 mut attempts = 0usize; + let response = loop { + self.limiter.acquire().await; + let response = self.http.post(ENDPOINT).json(&body).send().await?; + let status = response.status(); + + if status.as_u16() == 429 { + // The pause holds every other lookup too, so the rest of the + // run does not spend the window earning more 429s. + let waited = self.limiter.throttled(response.headers()).await; + attempts += 1; + if attempts > RETRIES { + return Err(Error::RateLimited); + } + tracing::debug!("AniList is throttling; waiting {waited:?}"); + continue; + } + + self.limiter.observe(response.headers()).await; + if !status.is_success() { + return Err(Error::Http(format!("AniList answered {status}"))); + } + break response; + }; let payload: Response = response.json().await?; if let Some(errors) = payload.errors diff --git a/crates/pstr-meta/src/lib.rs b/crates/pstr-meta/src/lib.rs index 4221fad..7671cce 100644 --- a/crates/pstr-meta/src/lib.rs +++ b/crates/pstr-meta/src/lib.rs @@ -29,6 +29,7 @@ pub mod anilist; pub mod error; +pub mod limiter; pub mod matching; pub mod provider; pub mod service; diff --git a/crates/pstr-meta/src/limiter.rs b/crates/pstr-meta/src/limiter.rs new file mode 100644 index 0000000..28a23e6 --- /dev/null +++ b/crates/pstr-meta/src/limiter.rs @@ -0,0 +1,227 @@ +//! Spacing requests out, so a provider does not have to. +//! +//! Scanning a new library is the one moment this app talks to a metadata +//! provider in bulk: every title is one search, a matched series is one more +//! request per season, and a hundred-folder share is several hundred requests +//! in a burst. AniList allows ninety a minute — thirty while its API is in the +//! degraded mode it has been in for a while — and answers everything past that +//! with a `429`. Without pacing, the first thirty titles match and the rest of +//! the run fails; because a failure is deliberately not cached (see +//! [`crate::service`]), the next scan starts over and fails in the same place. +//! +//! So requests take a slot here first. The rate is not a constant but the +//! provider's own: `X-RateLimit-Limit` on any answer retunes it, and a `429` +//! parks every caller for as long as `Retry-After` asks. One limiter is shared +//! by every lookup in flight — it lives in the provider, the provider lives in +//! an `Arc` inside [`crate::MetadataService`], and cloning the service to hand +//! it to a worker shares the limiter rather than copying it. + +use std::time::Duration; + +use tokio::sync::Mutex; +use tokio::time::Instant; + +/// Never faster than this, whatever a header claims. +const MIN_INTERVAL: Duration = Duration::from_millis(20); + +/// Never slower than this. A provider that asks for a longer pause gets it via +/// [`RateLimiter::throttled`]; this is only the floor the steady rate decays to, +/// so a misread header cannot wedge a scan forever. +const MAX_INTERVAL: Duration = Duration::from_secs(5); + +/// How long to honour a `429` that came with no `Retry-After`. +const DEFAULT_BACKOFF: Duration = Duration::from_secs(60); + +/// The longest a single throttle may park requests for. +/// +/// A scan is something a person is watching happen. Two minutes of silence is +/// worse than a partial answer they can ask to complete later, and the titles +/// that did not get through stay unmatched and askable. +const MAX_BACKOFF: Duration = Duration::from_secs(30); + +/// A shared, self-tuning pacer for one provider's API. +pub struct RateLimiter { + state: Mutex, +} + +struct State { + /// The earliest a request may be sent. + next: Instant, + /// The spacing between requests. + interval: Duration, +} + +impl RateLimiter { + /// A limiter starting at `per_minute` requests a minute. + /// + /// A starting point only: the first answer that carries a limit header + /// replaces it with what the provider actually allows right now. + pub fn per_minute(per_minute: u32) -> Self { + Self::new(interval_for(per_minute.max(1))) + } + + fn new(interval: Duration) -> Self { + Self { + state: Mutex::new(State { + next: Instant::now(), + interval, + }), + } + } + + /// Wait until a request may be sent, then claim that slot. + /// + /// Re-checks after every sleep rather than reserving a slot up front, so a + /// [`throttled`](Self::throttled) that lands while callers are already + /// waiting holds them too — which is the whole point of a shared limiter + /// during a burst. + pub async fn acquire(&self) { + loop { + let wait = { + let mut state = self.state.lock().await; + let now = Instant::now(); + if state.next <= now { + state.next = now + state.interval; + return; + } + state.next - now + }; + tokio::time::sleep(wait).await; + } + } + + /// Retune from a provider's own rate-limit headers. + pub async fn observe(&self, headers: &reqwest::header::HeaderMap) { + let Some(limit) = header_number(headers, "x-ratelimit-limit") else { + return; + }; + let interval = interval_for(limit.max(1) as u32); + let mut state = self.state.lock().await; + if state.interval != interval { + tracing::debug!("provider allows {limit}/min; pacing at {interval:?}"); + state.interval = interval; + } + } + + /// Park every caller after a `429`. + /// + /// `Retry-After` is seconds, and is the provider telling us exactly how + /// long its window has left to run — a shorter guess would just earn + /// another `429`. + pub async fn throttled(&self, headers: &reqwest::header::HeaderMap) -> Duration { + let wait = header_number(headers, "retry-after") + .map(|seconds| Duration::from_secs(seconds.max(1))) + .unwrap_or(DEFAULT_BACKOFF) + .min(MAX_BACKOFF); + + let mut state = self.state.lock().await; + let until = Instant::now() + wait; + if until > state.next { + state.next = until; + } + wait + } +} + +/// `90` a minute → one every 667ms. +fn interval_for(per_minute: u32) -> Duration { + Duration::from_secs_f64(60.0 / f64::from(per_minute)).clamp(MIN_INTERVAL, MAX_INTERVAL) +} + +fn header_number(headers: &reqwest::header::HeaderMap, name: &str) -> Option { + headers.get(name)?.to_str().ok()?.trim().parse::().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers(pairs: &[(&str, &str)]) -> reqwest::header::HeaderMap { + let mut map = reqwest::header::HeaderMap::new(); + for (name, value) in pairs { + map.insert( + reqwest::header::HeaderName::from_bytes(name.as_bytes()).expect("header name"), + value.parse().expect("header value"), + ); + } + map + } + + #[test] + fn a_rate_becomes_the_spacing_it_implies() { + assert_eq!(interval_for(60), Duration::from_secs(1)); + assert_eq!(interval_for(30), Duration::from_secs(2)); + // Clamped at both ends: a provider claiming one request a minute must + // not stall a scan for a minute per title, and one claiming a million + // still gets paced. + assert_eq!(interval_for(1), MAX_INTERVAL); + assert_eq!(interval_for(1_000_000), MIN_INTERVAL); + } + + #[tokio::test] + async fn the_first_request_of_a_run_does_not_wait() { + let limiter = RateLimiter::per_minute(30); + let started = Instant::now(); + limiter.acquire().await; + assert!(started.elapsed() < Duration::from_millis(50)); + } + + /// The one that matters: two requests in a row are spaced, not sent + /// together. + #[tokio::test(start_paused = true)] + async fn a_second_request_waits_out_the_interval() { + let limiter = RateLimiter::per_minute(60); + limiter.acquire().await; + let started = Instant::now(); + limiter.acquire().await; + assert!(started.elapsed() >= Duration::from_secs(1)); + } + + #[tokio::test(start_paused = true)] + async fn a_429_parks_the_next_request_for_as_long_as_it_asked() { + let limiter = RateLimiter::per_minute(600); + let waited = limiter.throttled(&headers(&[("retry-after", "12")])).await; + assert_eq!(waited, Duration::from_secs(12)); + + let started = Instant::now(); + limiter.acquire().await; + assert!(started.elapsed() >= Duration::from_secs(12)); + } + + /// A throttle with no header still has to park for something, and a + /// ridiculous one is capped — a scan is something a person is watching. + #[tokio::test] + async fn a_429_without_a_header_backs_off_by_a_capped_default() { + let limiter = RateLimiter::per_minute(90); + assert_eq!( + limiter.throttled(&headers(&[])).await, + DEFAULT_BACKOFF.min(MAX_BACKOFF) + ); + assert_eq!( + limiter + .throttled(&headers(&[("retry-after", "86400")])) + .await, + MAX_BACKOFF + ); + } + + #[tokio::test] + async fn the_provider_s_own_limit_retunes_the_pacing() { + let limiter = RateLimiter::per_minute(90); + limiter + .observe(&headers(&[("x-ratelimit-limit", "30")])) + .await; + assert_eq!( + limiter.state.lock().await.interval, + Duration::from_secs(2), + "AniList's degraded mode allows 30/min and says so on every answer" + ); + + // Nonsense, and an absent header, leave the pacing alone. + limiter + .observe(&headers(&[("x-ratelimit-limit", "soon")])) + .await; + limiter.observe(&headers(&[])).await; + assert_eq!(limiter.state.lock().await.interval, Duration::from_secs(2)); + } +} diff --git a/crates/pstr-meta/src/tmdb.rs b/crates/pstr-meta/src/tmdb.rs index d96005e..aacc32f 100644 --- a/crates/pstr-meta/src/tmdb.rs +++ b/crates/pstr-meta/src/tmdb.rs @@ -15,6 +15,7 @@ use pstr_core::metadata::{EpisodeMetadata, ProviderId, TitleMetadata}; use serde::Deserialize; use crate::error::{Error, Result}; +use crate::limiter::RateLimiter; use crate::matching::{Candidate, Query}; use crate::provider::Provider; @@ -37,13 +38,23 @@ const STILL_SIZE: &str = "w300"; /// 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"; +/// TMDB's published ceiling is around fifty requests a second and it no longer +/// enforces a daily one. This is well under it: the point of pacing here is a +/// burst that would look like abuse, not a rate anyone is close to. +const REQUESTS_PER_MINUTE: u32 = 600; + +/// As [`crate::anilist`]'s. +const RETRIES: usize = 2; + pub struct Tmdb { http: reqwest::Client, api_key: String, language: String, + limiter: RateLimiter, } impl Tmdb { @@ -52,6 +63,7 @@ impl Tmdb { http, api_key, language, + limiter: RateLimiter::per_minute(REQUESTS_PER_MINUTE), } } } @@ -72,28 +84,12 @@ impl Provider for Tmdb { 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() + let payload: SearchResponse = self + .get( + ENDPOINT, + &[("query", query.name.as_str()), ("include_adult", "false")], + ) .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() @@ -172,7 +168,11 @@ impl Provider for Tmdb { } impl Tmdb { - /// One authenticated GET, with TMDB's failures named. + /// One authenticated GET, paced, with TMDB's failures named. + /// + /// TMDB is far more generous than AniList and a library-sized run rarely + /// troubles it, but the pacing costs nothing here and the `429` handling is + /// the same code — see [`crate::limiter`]. async fn get( &self, url: &str, @@ -181,23 +181,40 @@ impl Tmdb { 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}")), - }); - } + let mut attempts = 0usize; + let response = loop { + 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]); + } + + self.limiter.acquire().await; + let response = request.send().await?; + let status = response.status(); + + if status.as_u16() == 429 { + let waited = self.limiter.throttled(response.headers()).await; + attempts += 1; + if attempts > RETRIES { + return Err(Error::RateLimited); + } + tracing::debug!("TMDB is throttling; waiting {waited:?}"); + continue; + } + + if !status.is_success() { + return Err(match status.as_u16() { + 401 => Error::MissingApiKey(ProviderId::Tmdb), + _ => Error::Http(format!("TMDB answered {status}")), + }); + } + break response; + }; + Ok(response.json().await?) } } diff --git a/docs/BUGS.md b/docs/BUGS.md index 11115bd..a0804cd 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -146,6 +146,93 @@ and [B44](#b44--r8-broke-the-app-under-instrumentation-in-five-places). ## Fixed +### B54 — a window on another workspace was declared "not responding" + +**Symptom.** On Hyprland, switching to another workspace while `proton-stream` +was playing brought up the compositor's "application is not responding" dialog +a few seconds later, offering to wait or to kill it. Choosing *wait* dismissed +it for good, and coming back to the workspace found the window working normally. +Wayland only; the app was never actually broken. + +**Cause.** A Wayland compositor throttles a client by withholding the +`wl_surface` frame callback, and withholds it entirely while the surface is not +being shown. Mesa's EGL implements a swap interval of 1 by waiting on that +callback, so `eglSwapBuffers` on a hidden surface never returns: +`run_ui_and_paint` → `glutin swap_buffers` → `eglSwapBuffers` → +`wl_display_dispatch_queue` → `ppoll`, with no timeout. That is the UI thread, +and the UI thread is what answers `xdg_wm_base.ping`, so the compositor stops +hearing from the app and puts up the ANR dialog. It takes *sustained* repaints +to hit — the throttle waits on the previous frame's callback, so the first swap +after the window is hidden still returns — which is why it showed up during +playback, where mpv asks for a frame per picture, and not on an idle window. + +eframe already skips painting a viewport it believes is hidden, but nothing +tells it: `WindowEvent::Occluded` is X11, macOS and web only — winit removed the +Wayland implementation in 0.29 — so `ViewportInfo::occluded` stays `None`. + +**Fix.** `pstr-app::pacing`. On Wayland the swap interval is set to 0 +(`NativeOptions::glow_options.vsync`), which makes `eglSwapBuffers` return +whether the surface is being shown or not, and `Pacer` caps the frame rate in +vsync's place — 60 fps by default, `PSTR_FRAME_CAP` to change it, `0` to remove +the ceiling. Nothing is lost by dropping vsync there: a Wayland compositor +composites from the last buffer a client committed, at its own refresh, so a +client that commits too often wastes work but cannot tear. X11 and Windows are +untouched and keep vsync. The cap is a ceiling and not a rate, so an idle window +still draws nothing at all. + +**Verified.** Reproduced on Hyprland 0.56.2 with a minimal eframe 0.35 client +that repaints continuously: moved to a hidden workspace with `hyprctl dispatch +movetoworkspacesilent`, its frame counter stopped dead and `gdb` put thread 1 in +`eglSwapBuffers` → `wl_display_dispatch_queue`. The same client with the swap +interval at 0 kept drawing while hidden and stayed in the event loop. The app +itself was then run hidden for 30 s: it stays in `calloop`'s poll, and steady +state costs 0% of a core, so the ceiling has not turned an idle window into a +busy one. `cargo test -p pstr-app pacing` covers the cap. + +### B53 — scanning a new library outran AniList, and the crawl waited on one folder at a time + +**Symptom.** Two slow starts, both on the first scan of a share. Matching a +library against AniList worked for the first couple of dozen titles and then +returned nothing but failures for the rest of the run; because a failure is +deliberately not cached, the next run started over and failed in the same place, +so a large library never finished matching. Separately, the crawl itself took +minutes on a share whose contents are a few hundred nodes. + +**Cause.** Two independent ones. + +- Nothing paced provider requests. A scan sends one search per title, a second + search for a name that needs its apostrophe repaired, one episode request per + match and up to three more per further season — several hundred requests as + fast as two workers could issue them, against an API that allows ninety a + minute and has been running in a degraded mode that allows thirty. Everything + past the window got a `429`, which `AniList::query` turned straight into + `Error::RateLimited` with no wait and no retry. +- `SharedLibrary::crawl` walked the tree one folder per round trip. A media + share is wide, not deep — a folder per series, a folder per season under it — + so a few hundred sibling folders were listed strictly in sequence. The SDK + already fans its detail batches out within one folder; nothing overlapped + across folders, and the crawl was almost entirely latency. + +**Fix.** `pstr-meta::limiter` is a shared, self-tuning pacer: requests take a +slot before they are sent, `X-RateLimit-Limit` on any answer retunes the rate to +what the provider currently allows, and a `429` parks *every* lookup in flight +for as long as `Retry-After` asks (capped at 30s) and then retries, twice, before +giving up as `Error::RateLimited`. One limiter lives inside each provider, which +lives in an `Arc` inside `MetadataService`, so the per-title clones share it. +TMDB routes through the same code — its search request now goes through the same +paced `get` as everything else rather than building its own. + +`SharedLibrary::crawl` is now breadth-first and lists `CRAWL_CONCURRENCY` (6) +folders of a level at once. `buffered`, not `buffer_unordered`: two identical +crawls should produce the same order. The client clones share one node-key cache +and one single-flight map, so siblings needing the same ancestor key still wait +on a single derivation. + +**Verified.** `cargo fmt --all -- --check`, `cargo clippy --workspace +--all-targets -- -D warnings`, `cargo test --workspace --locked`. The limiter's +pacing, its `429` parking and its retuning from headers are covered by unit tests +on a paused clock (`crates/pstr-meta/src/limiter.rs`). + ### B52 — "paint the accent as a gradient" did nothing on Android **Symptom.** The appearance page on Android offers a *Paint the accent as a diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index e765eda..7d8db58 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -14,7 +14,7 @@ # builds from the registry alone. pkgname=proton-stream -pkgver=0.4.0 +pkgver=0.4.1 pkgrel=1 pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share" arch=('x86_64')