From a1b345a0295039cb33ed73dbf94b84a88fac7f13 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sun, 2 Aug 2026 19:37:03 +0200 Subject: [PATCH] init --- .github/workflows/ci.yml | 41 + .gitignore | 13 + Cargo.lock | 6800 ++++++++++++++++++++ Cargo.toml | 91 + README.md | 89 + crates/pstr-app/Cargo.toml | 29 + crates/pstr-app/examples/embedded_video.rs | 178 + crates/pstr-app/src/app.rs | 1064 +++ crates/pstr-app/src/engine.rs | 1083 ++++ crates/pstr-app/src/lib.rs | 27 + crates/pstr-app/src/main.rs | 46 + crates/pstr-app/src/playback.rs | 628 ++ crates/pstr-app/src/theme.rs | 81 + crates/pstr-app/src/ui/library.rs | 191 + crates/pstr-app/src/ui/mod.rs | 376 ++ crates/pstr-app/src/ui/player.rs | 668 ++ crates/pstr-app/src/ui/shares.rs | 283 + crates/pstr-app/src/ui/title.rs | 331 + crates/pstr-app/src/ui/transport.rs | 474 ++ crates/pstr-app/src/video.rs | 274 + crates/pstr-cli/Cargo.toml | 27 + crates/pstr-cli/src/main.rs | 928 +++ crates/pstr-core/Cargo.toml | 23 + crates/pstr-core/src/catalog.rs | 1385 ++++ crates/pstr-core/src/config.rs | 225 + crates/pstr-core/src/error.rs | 40 + crates/pstr-core/src/lib.rs | 21 + crates/pstr-core/src/library.rs | 645 ++ crates/pstr-core/src/metadata.rs | 450 ++ crates/pstr-core/src/naming.rs | 1213 ++++ crates/pstr-core/src/prefs.rs | 161 + crates/pstr-core/src/shares.rs | 333 + crates/pstr-meta/Cargo.toml | 23 + crates/pstr-meta/src/anilist.rs | 519 ++ crates/pstr-meta/src/error.rs | 39 + crates/pstr-meta/src/lib.rs | 41 + crates/pstr-meta/src/matching.rs | 372 ++ crates/pstr-meta/src/provider.rs | 52 + crates/pstr-meta/src/service.rs | 347 + crates/pstr-meta/src/settings.rs | 140 + crates/pstr-meta/src/tmdb.rs | 436 ++ crates/pstr-player/Cargo.toml | 33 + crates/pstr-player/src/chapters.rs | 503 ++ crates/pstr-player/src/cursor.rs | 283 + crates/pstr-player/src/error.rs | 27 + crates/pstr-player/src/gl.rs | 204 + crates/pstr-player/src/lib.rs | 55 + crates/pstr-player/src/player.rs | 560 ++ crates/pstr-player/src/protocol.rs | 154 + crates/pstr-player/src/registry.rs | 185 + crates/pstr-player/src/render.rs | 268 + crates/pstr-player/src/testing.rs | 62 + crates/pstr-player/src/tracks.rs | 254 + crates/pstr-stream/Cargo.toml | 24 + crates/pstr-stream/src/block.rs | 281 + crates/pstr-stream/src/disk.rs | 571 ++ crates/pstr-stream/src/error.rs | 32 + crates/pstr-stream/src/lib.rs | 45 + crates/pstr-stream/src/reader.rs | 107 + crates/pstr-stream/src/ring.rs | 338 + crates/pstr-stream/src/single_flight.rs | 263 + crates/pstr-stream/src/source.rs | 334 + crates/pstr-stream/src/stream.rs | 835 +++ docs/ARCHITECTURE.md | 123 + docs/BUGS.md | 282 + docs/DEVELOPMENT.md | 193 + docs/TESTING.md | 200 + packaging/PKGBUILD | 95 + packaging/README.md | 139 + packaging/io.narl.proton-stream.desktop | 12 + packaging/io.narl.proton-stream.svg | 18 + packaging/macos/Info.plist.in | 38 + packaging/proton-stream.spec | 73 + packaging/windows/license.rtf | 14 + packaging/windows/proton-stream.ico | Bin 0 -> 372526 bytes packaging/windows/proton-stream.wixl.wxs | 137 + packaging/windows/proton-stream.wxs | 82 + scripts/build-windows.sh | 284 + scripts/build.ps1 | 265 + scripts/build.sh | 473 ++ 80 files changed, 28033 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 crates/pstr-app/Cargo.toml create mode 100644 crates/pstr-app/examples/embedded_video.rs create mode 100644 crates/pstr-app/src/app.rs create mode 100644 crates/pstr-app/src/engine.rs create mode 100644 crates/pstr-app/src/lib.rs create mode 100644 crates/pstr-app/src/main.rs create mode 100644 crates/pstr-app/src/playback.rs create mode 100644 crates/pstr-app/src/theme.rs create mode 100644 crates/pstr-app/src/ui/library.rs create mode 100644 crates/pstr-app/src/ui/mod.rs create mode 100644 crates/pstr-app/src/ui/player.rs create mode 100644 crates/pstr-app/src/ui/shares.rs create mode 100644 crates/pstr-app/src/ui/title.rs create mode 100644 crates/pstr-app/src/ui/transport.rs create mode 100644 crates/pstr-app/src/video.rs create mode 100644 crates/pstr-cli/Cargo.toml create mode 100644 crates/pstr-cli/src/main.rs create mode 100644 crates/pstr-core/Cargo.toml create mode 100644 crates/pstr-core/src/catalog.rs create mode 100644 crates/pstr-core/src/config.rs create mode 100644 crates/pstr-core/src/error.rs create mode 100644 crates/pstr-core/src/lib.rs create mode 100644 crates/pstr-core/src/library.rs create mode 100644 crates/pstr-core/src/metadata.rs create mode 100644 crates/pstr-core/src/naming.rs create mode 100644 crates/pstr-core/src/prefs.rs create mode 100644 crates/pstr-core/src/shares.rs create mode 100644 crates/pstr-meta/Cargo.toml create mode 100644 crates/pstr-meta/src/anilist.rs create mode 100644 crates/pstr-meta/src/error.rs create mode 100644 crates/pstr-meta/src/lib.rs create mode 100644 crates/pstr-meta/src/matching.rs create mode 100644 crates/pstr-meta/src/provider.rs create mode 100644 crates/pstr-meta/src/service.rs create mode 100644 crates/pstr-meta/src/settings.rs create mode 100644 crates/pstr-meta/src/tmdb.rs create mode 100644 crates/pstr-player/Cargo.toml create mode 100644 crates/pstr-player/src/chapters.rs create mode 100644 crates/pstr-player/src/cursor.rs create mode 100644 crates/pstr-player/src/error.rs create mode 100644 crates/pstr-player/src/gl.rs create mode 100644 crates/pstr-player/src/lib.rs create mode 100644 crates/pstr-player/src/player.rs create mode 100644 crates/pstr-player/src/protocol.rs create mode 100644 crates/pstr-player/src/registry.rs create mode 100644 crates/pstr-player/src/render.rs create mode 100644 crates/pstr-player/src/testing.rs create mode 100644 crates/pstr-player/src/tracks.rs create mode 100644 crates/pstr-stream/Cargo.toml create mode 100644 crates/pstr-stream/src/block.rs create mode 100644 crates/pstr-stream/src/disk.rs create mode 100644 crates/pstr-stream/src/error.rs create mode 100644 crates/pstr-stream/src/lib.rs create mode 100644 crates/pstr-stream/src/reader.rs create mode 100644 crates/pstr-stream/src/ring.rs create mode 100644 crates/pstr-stream/src/single_flight.rs create mode 100644 crates/pstr-stream/src/source.rs create mode 100644 crates/pstr-stream/src/stream.rs create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/BUGS.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/TESTING.md create mode 100644 packaging/PKGBUILD create mode 100644 packaging/README.md create mode 100644 packaging/io.narl.proton-stream.desktop create mode 100644 packaging/io.narl.proton-stream.svg create mode 100644 packaging/macos/Info.plist.in create mode 100644 packaging/proton-stream.spec create mode 100644 packaging/windows/license.rtf create mode 100644 packaging/windows/proton-stream.ico create mode 100644 packaging/windows/proton-stream.wixl.wxs create mode 100644 packaging/windows/proton-stream.wxs create mode 100755 scripts/build-windows.sh create mode 100644 scripts/build.ps1 create mode 100755 scripts/build.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a591599 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +env: + CARGO_TERM_COLOR: always + +jobs: + gate: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + # Pinned: the MSRV the workspace declares. + toolchain: "1.96" + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + + # libmpv for pstr-player. On Windows the DLL and import library are + # bundled from the mpv-dev build rather than installed system-wide. + - name: Install libmpv (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libmpv-dev + + - run: cargo fmt --all -- --check + + - run: cargo clippy --workspace --all-targets -- -D warnings + + - run: cargo test --workspace --locked diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f08480d --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +/target +*.db +*.db-wal +*.db-shm +.env + +# Build artifacts. +/dist + +# makepkg leftovers. +/packaging/pkg/ +/packaging/src/ +/packaging/*.pkg.tar.zst diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b972b95 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6800 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "bytes", + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + +[[package]] +name = "aes-gcm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +dependencies = [ + "aead 0.6.1", + "aes 0.9.2", + "cipher 0.5.2", + "ctr 0.10.1", + "ghash 0.6.0", + "subtle", +] + +[[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes 0.8.4", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.19", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", + "zeroize", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a" +dependencies = [ + "base64 0.23.0", + "blowfish 0.10.0", + "getrandom 0.4.3", + "subtle", + "zeroize", +] + +[[package]] +name = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitfields" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef6e59298da389bc0649c7463856b34c6e17fe542f88939426ede4436c6b1195" +dependencies = [ + "bitfields-impl", +] + +[[package]] +name = "bitfields-impl" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c044f98f86f15414668d6c8187c7e4fadab1ad2b31680f648703e0fe07c555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.19", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher 0.4.4", +] + +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + +[[package]] +name = "buffer-redux" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54" +dependencies = [ + "memchr", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +dependencies = [ + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher 0.4.4", +] + +[[package]] +name = "cast5" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmac" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" +dependencies = [ + "cipher 0.4.4", + "dbl", + "digest 0.10.7", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc24" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cx448" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c0cf476284b03eb6c10e78787b21c7abb7d7d43cb2f02532ba6b831ed892fa" +dependencies = [ + "crypto-bigint", + "elliptic-curve", + "pkcs8", + "rand_core 0.6.4", + "serdect 0.3.0", + "sha3", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9" +dependencies = [ + "generic-array", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes 0.8.4", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf 0.12.4", + "num", + "once_cell", + "sha2 0.10.9", + "zeroize", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "des" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dsa" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" +dependencies = [ + "digest 0.10.7", + "num-bigint-dig", + "num-traits", + "pkcs8", + "rfc6979", + "sha2 0.10.9", + "signature", + "zeroize", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "eax" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "cmac", + "ctr 0.9.2", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ecolor" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6758be723a3f298bbfda4db75748bc2ba0abafe096b6383c7c32da264764fbc3" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "eframe" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc8234e2f681b2afd2b2e8c332fa614de2fddbdcb28d0acf5c420449682ea90" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796c98d50b79631281d516343a6f6e93c0666462ca36e2c93b39f25d7793325" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.1", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-wgpu" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e6cfac0725563555fa4f91e9f799b9d7c6c5dd831fca6abc8234afc64b7a34" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.19", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db" +dependencies = [ + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_glow" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52274b9bfb8d8e252cd0f00c9f6214f360d75a0962baa77a2d62ddb002fe99d4" +dependencies = [ + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "winit", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "base64ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serde_json", + "serdect 0.2.0", + "subtle", + "tap", + "zeroize", +] + +[[package]] +name = "emath" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4ec073c9898516584d8c6cfdcee95b530b3d941cd5031ef4050aa36812308b" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "epaint" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e60a8888b51da911df23918fd7301359b1d43a406a0ff3b8863af093dd7fc6c" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ee4e1f553a3584c301f3a56ff1a775f1384781396cea301c8d952e9b93f560" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "glow" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "once_cell", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", +] + +[[package]] +name = "glutin-winit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" +dependencies = [ + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", +] + +[[package]] +name = "glutin_egl_sys" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" +dependencies = [ + "gl_generator", + "windows-sys 0.52.0", +] + +[[package]] +name = "glutin_glx_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0431e8e389aa0f1e72bb9d1c2db8957a1a7a3580e8ed97db819c14837aac9b3e" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idea" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "sha2 0.10.9", + "signature", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "dbus-secret-service", + "log", + "secret-service", + "zeroize", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libmpv2" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3d44c1c29b866d44453249d1d8b2822d891fdabd08359a5b85bb6944445e70" +dependencies = [ + "libmpv2-sys", +] + +[[package]] +name = "libmpv2-sys" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edb32cabe7176b7d270b0dca6ff418b75187ae8d3854423e3d06fbff376841e4" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "thiserror 2.0.19", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.8", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.7", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.8", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "ocb3" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "ctr 0.9.2", + "subtle", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2 0.10.9", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pgp" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "aes-gcm 0.10.3", + "aes-kw", + "argon2", + "base64 0.22.1", + "bitfields", + "block-padding", + "blowfish 0.9.1", + "buffer-redux", + "byteorder", + "bytes", + "bzip2", + "camellia", + "cast5", + "cfb-mode", + "cipher 0.4.4", + "const-oid 0.9.6", + "crc24", + "curve25519-dalek", + "cx448", + "derive_builder", + "derive_more", + "des", + "digest 0.10.7", + "dsa", + "eax", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "flate2", + "generic-array", + "hex", + "hkdf 0.12.4", + "idea", + "k256", + "log", + "md-5", + "memchr", + "nom", + "num-bigint-dig", + "num-traits", + "num_enum", + "ocb3", + "p256", + "p384", + "p521", + "rand 0.8.7", + "replace_with", + "ripemd", + "rsa", + "sha1 0.10.7", + "sha1-checked", + "sha2 0.10.9", + "sha3", + "signature", + "smallvec", + "snafu", + "subtle", + "twofish", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "proton-drive-rs" +version = "0.3.3" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "futures", + "getrandom 0.4.3", + "hex", + "hmac 0.13.0", + "lru", + "pgp", + "proton-sdk", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "sha1 0.11.0", + "sha2 0.11.0", + "thiserror 2.0.19", + "tokio", + "tracing", +] + +[[package]] +name = "proton-sdk" +version = "0.3.3" +dependencies = [ + "aes-gcm 0.11.0", + "async-trait", + "base64 0.22.1", + "bcrypt", + "bytes", + "chrono", + "futures", + "getrandom 0.4.3", + "hex", + "hkdf 0.13.0", + "hmac 0.13.0", + "lru", + "num-bigint 0.5.1", + "num-traits", + "pgp", + "rand 0.10.2", + "rand 0.8.7", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "sha2 0.11.0", + "thiserror 2.0.19", + "tokio", + "tracing", +] + +[[package]] +name = "pstr-app" +version = "0.1.0" +dependencies = [ + "anyhow", + "eframe", + "egui", + "futures", + "image", + "parking_lot", + "pstr-core", + "pstr-meta", + "pstr-player", + "pstr-stream", + "sha2 0.10.9", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "pstr-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "proton-drive-rs", + "proton-sdk", + "pstr-core", + "pstr-meta", + "pstr-player", + "pstr-stream", + "rpassword", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "pstr-core" +version = "0.1.0" +dependencies = [ + "directories", + "futures", + "keyring", + "proton-drive-rs", + "proton-sdk", + "rusqlite", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.19", + "tokio", + "tracing", +] + +[[package]] +name = "pstr-meta" +version = "0.1.0" +dependencies = [ + "keyring", + "pstr-core", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.19", + "tokio", + "tracing", +] + +[[package]] +name = "pstr-player" +version = "0.1.0" +dependencies = [ + "async-trait", + "libloading", + "libmpv2", + "libmpv2-sys", + "pstr-stream", + "thiserror 2.0.19", + "tokio", + "tracing", +] + +[[package]] +name = "pstr-stream" +version = "0.1.0" +dependencies = [ + "async-trait", + "futures", + "lru", + "proton-drive-rs", + "proton-sdk", + "pstr-core", + "sha2 0.10.9", + "thiserror 2.0.19", + "tokio", + "tracing", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "replace_with" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51743d3e274e2b18df81c4dc6caf8a5b8e15dbe799e0dca05c7617380094e884" + +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "ripemd" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.21.1", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes 0.8.4", + "cbc", + "futures-util", + "generic-array", + "hkdf 0.12.4", + "num", + "once_cell", + "rand 0.8.7", + "serde", + "sha2 0.10.9", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "serdect" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f42f67da2385b51a5f9652db9c93d78aeaf7610bf5ec366080b6de810604af53" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha1-checked" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" +dependencies = [ + "digest 0.10.7", + "sha1 0.10.7", + "zeroize", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-client-toolkit" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" +dependencies = [ + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.19", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "twofish" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "smallvec", + "thiserror 2.0.19", +] + +[[package]] +name = "vello_cpu" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wayland-backend" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webbrowser" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f" +dependencies = [ + "jni 0.22.4", + "log", + "ndk-context", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.19", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libloading", + "log", + "naga", + "portable-atomic", + "portable-atomic-util", + "raw-window-handle", + "renderdoc-sys", + "thiserror 2.0.19", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand 0.8.7", + "serde", + "serde_repr", + "sha1 0.10.7", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4b631e0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,91 @@ +[workspace] +resolver = "2" +members = [ + "crates/pstr-core", + "crates/pstr-stream", + "crates/pstr-meta", + "crates/pstr-player", + "crates/pstr-app", + "crates/pstr-cli", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/narrrl/proton-stream" +rust-version = "1.96" +authors = ["Nils Pukropp "] + +# Dependencies build optimized even in dev/test, for the same reason +# `proton-sdk-rs` does it: every CPU-bound thing this app does — rPGP block +# decryption, bcrypt, the S2K derivations — lives in those crates, and +# unoptimized rPGP is roughly two orders of magnitude slower. A debug build +# without this cannot keep up with playback at all. Workspace crates stay at +# `opt-level = 0` so incremental rebuilds and backtraces are unaffected. +[profile.dev.package."*"] +opt-level = 2 + +[workspace.dependencies] +proton-sdk = { version = "0.3.3" } +proton-drive-rs = { version = "0.3.3" } + +# Internal crates. +pstr-core = { path = "crates/pstr-core" } +pstr-meta = { path = "crates/pstr-meta" } +pstr-stream = { path = "crates/pstr-stream" } +pstr-player = { path = "crates/pstr-player" } + +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", "io-util", "time"] } +futures = "0.3" +# The TLS features match `proton-sdk-rs`'s deliberately: cargo unifies features +# across the graph, and a second configuration here would link a second TLS +# stack into the same binary. `query` is the one addition — TMDB takes its +# search terms in the query string, and it is purely additive. +reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls", "webpki-roots", "gzip"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +directories = "5" +sha2 = "0.10" +# `bundled` so a Windows build needs no system SQLite. +rusqlite = { version = "0.32", features = ["bundled"] } +# The share URL fragment and the custom password are both secrets; they go in +# the OS credential store (Secret Service on Linux, Credential Manager on +# Windows), never in the config file next to them. +keyring = { version = "3", features = ["sync-secret-service", "crypto-rust"] } +clap = { version = "4", features = ["derive"] } +# The UI. `glow` rather than `wgpu` because step two of the player hands mpv an +# `MPV_RENDER_API_TYPE_OPENGL` context, and that has to be the same context +# eframe is drawing into. +eframe = { version = "0.35", default-features = false, features = [ + "default_fonts", + "glow", + "wayland", + "x11", +] } +egui = "0.35" +# Poster thumbnails only: Proton renders them as JPEG, and a decoder that also +# pulls in every exotic format is weight this app has no use for. +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } +# Non-poisoning, deliberately: a panicking UI frame must not wedge the catalog +# behind a poisoned lock for the rest of the session. Same reason +# `proton-drive-linux` uses it. +parking_lot = "0.12" +rpassword = "7" +libloading = "0.8" +lru = "0.12" +# `async fn` in traits is stable at MSRV 1.96 but is not dyn-compatible, and the +# stream layer needs a `dyn` block source so its cache, ring and read-ahead can +# be tested against a fake without a network or an account. +async-trait = "0.1" + +# During development the SDK work for this app lives in the sibling repo and is +# not published yet. Remove once 0.3.3 is on crates.io — releases must build +# against the registry. +[patch.crates-io] +proton-sdk = { path = "../proton-sdk-rs/crates/proton-sdk" } +proton-drive-rs = { path = "../proton-sdk-rs/crates/proton-drive-rs" } diff --git a/README.md b/README.md new file mode 100644 index 0000000..4967527 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# proton-stream + +A Netflix-style desktop client for **Proton Drive public links**. + +Paste a share URL and its password; get a browsable, streamable library. No +Proton account, no server to host, no download step — episodes start playing in +seconds and the seek bar works. + +Built on [`proton-sdk-rs`](https://github.com/narrrl/proton-sdk-rs), a pure-Rust +Proton Drive SDK. + +## Why it works + +A Proton Drive file's content blocks are each a self-contained PGP packet +decrypted under one shared session key. There is no sequential streaming session +to resume, so seeking costs exactly the blocks the seek lands on — a 2 GB episode +is playable without downloading 2 GB of it. + +## Status + +Early, but it plays, and there is a window now. The SDK streaming surface, the +catalog, the streaming layer and the player are done and verified against a real +share; the app has a library wall, a page per title with seasons and episodes, +resume-where-you-left-off, a transport bar and share management. The picture +still lands in mpv's own window — embedding it in the app's surface is next. See +`docs/DEVELOPMENT.md`. + +```bash +cargo run --release -p pstr-app # the app +``` + +Working today, via the `pstr` CLI: + +```bash +cargo run -p pstr-cli -- add --name anime 'https://drive.proton.me/urls/TOKEN#fragment' +cargo run -p pstr-cli -- crawl +cargo run -p pstr-cli -- list +cargo run --release -p pstr-cli -- play --file Frieren --disk-cache +cargo run --release -p pstr-cli -- bench --file Frieren --verify +``` + +On a 761 MiB, 26-minute 1080p HEVC episode over a real password-protected link: +**2.3 s to first frame, ~1.5 s to resume after dragging to an arbitrary point, +61 MiB fetched for the first 38 seconds of playback.** Watch it again and the +disk cache serves it with **no network at all** — 0.3 s to first frame. + +`bench` measures the same file without a player, reporting cold seek latency, +sustained throughput and peak memory, and `--verify` cross-checks a mid-file read +against the SDK's own range download. + +## Design + +| | | +|---|---| +| Topology | Fat client. No server; each viewer's app talks to Proton directly. | +| Auth | Public links only — but several, merged into one library. | +| Player | Embedded libmpv: 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. | +| Metadata | Filename parsing, with optional AniList/TMDB enrichment — **off by default**, because enabling it sends your library's titles to a third party. | +| Platforms | Linux and Windows. | + +## Building + +Rust 2024, MSRV 1.96. + +```bash +cargo build --release +``` + +`pstr-player` needs libmpv development headers (`mpv` ≥ 2.0). On Windows, +`libmpv-2.dll` is bundled next to the executable. + +## Privacy + +- The share URL fragment **is** the decryption password. It and any custom + password live in the OS credential store — Secret Service on Linux, Credential + Manager on Windows — never in a config file. +- Nothing leaves your machine except requests to Proton, unless you turn metadata + enrichment on. The player is fed through an in-process mpv stream callback, not + a local HTTP server: there is no port for another process on the machine to ask + for the plaintext, and mpv is handed an opaque token rather than a URL that + would put a share secret in its log and window title. +- The block cache holds decrypted content. It lives in your cache directory and + can be deleted at any time the app is not running. + +## License + +MIT. diff --git a/crates/pstr-app/Cargo.toml b/crates/pstr-app/Cargo.toml new file mode 100644 index 0000000..2abf6ff --- /dev/null +++ b/crates/pstr-app/Cargo.toml @@ -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 diff --git a/crates/pstr-app/examples/embedded_video.rs b/crates/pstr-app/examples/embedded_video.rs new file mode 100644 index 0000000..6cb4aae --- /dev/null +++ b/crates/pstr-app/examples/embedded_video.rs @@ -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)), + ) + .map_err(|error| anyhow::anyhow!("run the window: {error}")) +} + +struct Harness { + player: Arc, + video: Option, + 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, + url: String, + ) -> anyhow::Result { + 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; + } +} diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs new file mode 100644 index 0000000..7530405 --- /dev/null +++ b/crates/pstr-app/src/app.rs @@ -0,0 +1,1064 @@ +//! The window: state, the event pump, and what a click turns into. +//! +//! One rule holds this together — **drawing never mutates**. A page is handed +//! what it needs by reference and pushes [`Action`]s onto a list; the list is +//! applied after the frame. That is what lets a card click change the page it +//! is drawn on, and it is why none of the `ui` modules take an `&mut App`. + +use std::collections::HashMap; +use std::sync::mpsc::Receiver; + +use pstr_core::Share; +use pstr_core::config::AppDirs; +use pstr_core::library::Library; +use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord}; + +use crate::engine::{Engine, Event, ImageCache, describe_failures, watch_state}; +use crate::playback::{Playback, PlaybackTarget}; +use crate::ui::player::UpNextCard; +use crate::{theme, ui}; + +/// How long a status line stays up before it fades on its own. +const STATUS_SECONDS: f64 = 6.0; + +/// A frame slower than this is not a slow frame, it is a hang. +/// +/// The UI thread has only a handful of blocking calls in it — building an mpv +/// instance, tearing one down, the OpenGL work in between — and every one of +/// them is somewhere the compositor will decide the window has stopped +/// answering. There is no way to tell afterwards which one it was, so the ones +/// that can block say how long they took, and the frame as a whole says so too. +const STALL: std::time::Duration = std::time::Duration::from_millis(300); + +/// Time a frame, and log what it was doing if it ran long. +/// +/// A guard rather than a wrapper because [`eframe::App::ui`] returns from two +/// places, and the interesting one is the early return for the player page. +struct FrameTimer { + started: std::time::Instant, + page: &'static str, +} + +impl FrameTimer { + fn new(page: &Page) -> Self { + Self { + started: std::time::Instant::now(), + page: match page { + Page::Library => "library", + Page::Title(_) => "title", + Page::Shares => "shares", + Page::Player => "player", + }, + } + } +} + +impl Drop for FrameTimer { + fn drop(&mut self) { + let took = self.started.elapsed(); + if took >= STALL { + tracing::warn!( + "ui thread blocked for {} ms drawing the {} page", + took.as_millis(), + self.page + ); + } + } +} + +/// How long the "up next" card counts down before the next episode starts. +/// +/// Ten seconds is what every service settled on, and the reason is the same +/// here: long enough to read what is coming and to say no, short enough that +/// sitting through it is not a decision. +const UP_NEXT_SECONDS: f64 = 10.0; + +/// Which page is showing. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Page { + Library, + /// One title, by [`pstr_core::library::Title::key`]. + Title(String), + Shares, + /// The picture, filling the window. Leaving this page does not stop + /// playback — the transport bar at the bottom is how you get back to it. + Player, +} + +/// Something a click asked for, applied once the frame is drawn. +pub enum Action { + Goto(Page), + Play(PlaybackTarget), + /// Crawl one share, or every share. + Crawl(Option), + AddShare { + name: String, + url: String, + password: Option, + }, + RemoveShare(String), + /// Flip an episode between seen and unseen by hand. + SetWatched { + share_id: String, + link_id: String, + watched: bool, + duration: Option, + }, + Player(crate::playback::Command), + /// Change the volume, 0–100. `commit` writes it to the preferences; a + /// slider mid-drag does not. + SetVolume { + volume: f64, + commit: bool, + }, + ToggleMute, + /// Play this track of that kind, or none of it, and remember the language + /// for the next file. + SelectTrack { + kind: pstr_player::TrackKind, + id: Option, + }, + /// Turn enrichment on or off, or change provider. + SetMetadataConfig(MetadataConfig), + SetApiKey { + provider: pstr_core::metadata::ProviderId, + key: String, + }, + /// Look every title up. `force` re-asks about ones already matched. + MatchTitles { + force: bool, + }, + /// Play the file before or after the one playing, within its title. + PlayAdjacent(Adjacent), + /// Start or stop the next episode playing on its own at the end of one. + SetAutoplay(bool), + /// In or out of fullscreen, from the player page. + ToggleFullscreen, + /// Stop watching, keep playing: back to the page this film came from, with + /// the transport bar at the bottom still driving it. + LeavePlayer, + /// Call off the end-of-episode countdown and let this file play out. + WatchToEnd, +} + +/// The end-of-episode countdown. +/// +/// Playback reaching the credits is not the same thing as the file ending, and +/// this is the difference: the run of chapters that closes an episode is known +/// (see [`pstr_player::credits_start`]), so the next episode can be offered +/// while the last one is still playing rather than after a black screen. +/// +/// It is deliberately per-file — a new player resets it — and deliberately +/// dismissable *for the rest of the file*: a viewer who wants to hear the +/// ending song should be asked once, not once a second. +#[derive(Debug, Default)] +pub struct UpNext { + /// Which player this belongs to. A different one means a new file, and a + /// new file means the viewer's last answer no longer applies. + playback_id: u64, + /// When the countdown started, on egui's clock. `None` while there is + /// nothing to count down. + started: Option, + /// The viewer said to play this one out, or the countdown has already + /// fired. Either way, do not ask again about this file. + dismissed: bool, +} + +/// Which way to step through a title's files. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Adjacent { + Previous, + Next, +} + +/// A line at the bottom of the window, for a few seconds. +pub struct Status { + pub text: String, + pub error: bool, + pub at: f64, +} + +/// The share the viewer is typing in. +#[derive(Default)] +pub struct ShareForm { + pub name: String, + pub url: String, + pub has_password: bool, + pub password: String, +} + +pub struct App { + engine: Engine, + events: Receiver, + pub page: Page, + pub library: Library, + pub shares: Vec, + /// Proton's own per-file thumbnails. + pub thumbs: ImageCache, + /// Artwork from a metadata provider, keyed by title key. + pub posters: ImageCache, + /// What the providers have said, keyed by title key. + pub metadata: HashMap, + /// What they said about the episodes under those titles. + pub episodes: HashMap, + pub settings: MetadataConfig, + /// What the viewer is typing into the API key box. Never persisted here — + /// it goes straight to the credential store on save. + pub api_key: String, + pub matching: bool, + pub search: String, + pub form: ShareForm, + pub playback: Option, + /// Whether the player page's controls are showing, and why. + pub overlay: ui::player::Overlay, + /// The end-of-episode countdown for whatever is playing. + pub up_next: UpNext, + /// Set while the window is fullscreen, so `F` can toggle rather than only + /// ever entering. egui has no way to ask the platform. + pub fullscreen: bool, + /// The file being opened, if a click is still waiting on the network. + pub opening: Option, + pub connecting: bool, + pub crawling: bool, + pub status: Option, +} + +impl App { + pub fn new( + cc: &eframe::CreationContext<'_>, + runtime: std::sync::Arc, + dirs: AppDirs, + ) -> anyhow::Result { + theme::apply(&cc.egui_ctx); + + let (engine, events) = Engine::new(runtime, dirs, cc.egui_ctx.clone())?; + + // Paint from the catalog immediately; the network catches up. A library + // that was crawled yesterday is on screen before the shares open. + engine.load_shares(); + engine.load_library(); + engine.load_metadata(); + engine.connect(); + let settings = engine.metadata_config(); + + Ok(Self { + engine, + events, + page: Page::Library, + library: Library::default(), + shares: Vec::new(), + thumbs: ImageCache::default(), + posters: ImageCache::default(), + metadata: HashMap::new(), + episodes: HashMap::new(), + settings, + api_key: String::new(), + matching: false, + search: String::new(), + form: ShareForm::default(), + playback: None, + overlay: ui::player::Overlay::default(), + up_next: UpNext::default(), + fullscreen: false, + opening: None, + connecting: true, + crawling: false, + status: None, + }) + } + + fn note(&mut self, ctx: &egui::Context, text: impl Into, error: bool) { + self.status = Some(Status { + text: text.into(), + error, + at: ctx.input(|input| input.time), + }); + } + + /// Drain everything the background side has said since the last frame. + /// + /// Takes `frame` because starting a player is one of the things that can + /// come out of this channel, and building the mpv render context needs + /// eframe's OpenGL context — which is current here and nowhere else. See + /// [`crate::playback`]. + fn pump(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { + while let Ok(event) = self.events.try_recv() { + match event { + Event::Shares(shares) => self.shares = shares, + Event::Connected { failures } => { + self.connecting = false; + if let Some(text) = describe_failures(&failures) { + self.note(ctx, text, true); + } + } + Event::ConnectFailed(error) => { + self.connecting = false; + self.note(ctx, format!("could not open the shares: {error}"), true); + } + Event::LibraryLoaded(library) => self.library = library, + Event::Crawled { + share_id, + nodes, + files, + seconds, + } => { + let name = self.share_name(&share_id); + self.note( + ctx, + format!("{name}: {files} playable of {nodes} nodes in {seconds:.0}s"), + false, + ); + } + Event::CrawlFinished => self.crawling = false, + Event::Thumbnail { key, image } => self.thumbs.insert(ctx, key, image), + Event::ThumbnailMissing { key } => self.thumbs.mark_missing(key), + Event::Poster { key, image } => self.posters.insert(ctx, key, image), + Event::PosterMissing { key } => self.posters.mark_missing(key), + Event::Metadata(records) => { + // Artwork keyed by a title whose record changed may now + // point somewhere else, and a title that was a miss may now + // have a poster to ask for. + self.posters.clear(); + self.metadata = records; + } + Event::EpisodeMetadata(episodes) => self.episodes = episodes, + Event::MetadataConfig(config) => { + self.settings = config; + self.posters.clear(); + // Turning enrichment off, or switching provider, clears the + // stored answers — including these, which would otherwise + // go on naming episodes after the provider that named them + // was dropped. + self.episodes.clear(); + } + Event::Matched { + matched, + unmatched, + failed, + } => { + let mut text = format!("matched {matched}, no match for {unmatched}"); + if failed > 0 { + // Worth its own clause: a failure is not a miss, and + // those titles will be asked about again. + text.push_str(&format!(", {failed} could not be looked up")); + } + self.note(ctx, text, failed > 0); + } + Event::MatchFinished => self.matching = false, + Event::PlaybackReady { target, stream } => { + self.opening = None; + // Dropped before the new one is built: two mpv cores would + // fight over the ring and the bandwidth, and the old one's + // render context has to be freed on this thread anyway. + // + // Both halves of this are blocking calls into mpv on the UI + // thread — destroying a core waits for its demuxer, and + // creating one compiles shaders — so both are timed. If the + // window ever stops answering while episodes change, this is + // the line that says which half did it. + let swap = std::time::Instant::now(); + self.playback = None; + let torn_down = swap.elapsed(); + let gl = frame.gl().cloned(); + let started = Playback::start(&self.engine, *target, stream, gl.as_ref(), ctx); + if swap.elapsed() >= STALL { + tracing::warn!( + "swapping players held the ui thread for {} ms, {} ms of it \ + destroying the previous mpv core", + swap.elapsed().as_millis(), + torn_down.as_millis() + ); + } + match started { + Ok(playback) => { + self.overlay = ui::player::Overlay::default(); + self.page = Page::Player; + self.playback = Some(playback); + } + Err(error) => self.note(ctx, error, true), + } + } + // Only from the player currently on the bar: one being replaced + // goes on reporting for a moment after its successor started. + Event::Player { id, event } => { + let mine = self.playback.as_ref().is_some_and(|p| p.id == id); + if let Some(playback) = self.playback.as_mut().filter(|p| p.id == id) { + playback.apply(&event); + } + // Played to the end, rather than stopped or failed: the one + // ending that means "and now the next one". + if mine + && matches!( + event, + pstr_player::PlayerEvent::EndFile(pstr_player::EndReason::Eof) + ) + { + self.autoplay_next(ctx); + } + } + Event::PlayerStopped { id } => { + if self.playback.as_ref().is_some_and(|p| p.id == id) { + // Dropping this frees the mpv render context, which has + // to happen on this thread with the GL context current. + // `pump` is called from `ui`, which is exactly that. + let target = self.playback.take().map(|p| p.target.title_key.clone()); + // Nothing to show on the player page any more. Back to + // where the click came from, rather than a black window + // — unless the next episode is already opening, which is + // exactly where the viewer wants to stay. + if self.page == Page::Player && self.opening.is_none() { + self.page = match target { + Some(key) => Page::Title(key), + None => Page::Library, + }; + } + } + // The position that was just saved is what the library + // should now show. + self.engine.load_library(); + } + Event::Error(text) => { + self.opening = None; + self.note(ctx, text, true); + } + Event::Status(text) => self.note(ctx, text, false), + } + } + } + + fn share_name(&self, id: &str) -> String { + self.shares + .iter() + .find(|share| share.id == id) + .map(|share| share.name.clone()) + .unwrap_or_else(|| id.to_string()) + } + + /// The file before or after what is playing, within its own title. + /// + /// `None` at either end, and for a title that has been recrawled out from + /// under the player — both are "there is nothing to step to", which is all + /// the caller does with it. + pub fn adjacent(&self, direction: Adjacent) -> Option { + let target = &self.playback.as_ref()?.target; + let title = self.library.get(&target.title_key)?; + let episode = match direction { + Adjacent::Previous => title.preceding(&target.share_id, &target.link_id), + Adjacent::Next => title.following(&target.share_id, &target.link_id), + }?; + Some(PlaybackTarget::new(title, episode)) + } + + /// What the provider calls the episode this target is, if anything. + fn episode_name(&self, target: &PlaybackTarget) -> Option { + let number = target.number?; + self.episodes + .get(&target.title_key)? + .get(target.season, number)? + .name + .clone() + } + + /// Whether there is anything to step to in either direction, for the + /// controls that offer it. + fn neighbours(&self) -> ui::transport::Neighbours { + ui::transport::Neighbours { + previous: self.adjacent(Adjacent::Previous).is_some(), + next: self.adjacent(Adjacent::Next).is_some(), + } + } + + /// Advance the end-of-episode countdown, and say what the card should show. + /// + /// Called once a frame from the player page, *before* it is drawn, because + /// drawing does not mutate — the card is told how many seconds are left and + /// nothing more. Returns `None` whenever there is nothing to offer, which + /// is nearly always. + /// + /// Four things have to hold before a viewer is interrupted, and each of + /// them is a way this has gone wrong elsewhere: the file has to actually be + /// in its credits, there has to be a next episode to go to, autoplay has to + /// be on, and playback has to be *running* — a paused episode is one + /// somebody walked away from, and coming back to the next one already + /// playing is the opposite of helpful. + fn tick_up_next( + &mut self, + ctx: &egui::Context, + actions: &mut Vec, + ) -> Option { + let has_next = self.neighbours().next; + let autoplay = self.engine.playback_prefs().autoplay_next; + let next = self.adjacent(Adjacent::Next); + + let Some(playback) = &self.playback else { + self.up_next = UpNext::default(); + return None; + }; + if self.up_next.playback_id != playback.id { + self.up_next = UpNext { + playback_id: playback.id, + ..UpNext::default() + }; + } + + let counting = playback.loaded + && !playback.paused + && playback.in_credits() + && has_next + && autoplay + && !self.up_next.dismissed; + if !counting { + self.up_next.started = None; + return None; + } + + let now = ctx.input(|input| input.time); + let started = *self.up_next.started.get_or_insert(now); + let left = UP_NEXT_SECONDS - (now - started); + if left <= 0.0 { + // Latched, not cleared: the next file takes a moment to open, and + // without this the countdown would fire again on every frame of + // that moment. + self.up_next.dismissed = true; + actions.push(Action::PlayAdjacent(Adjacent::Next)); + return None; + } + + // Nothing else causes a frame while a film plays and the mouse is + // still, and a countdown that only ticks when the pointer moves is + // worse than none. + ctx.request_repaint_after(std::time::Duration::from_millis(200)); + Some(UpNextCard { + seconds: left, + caption: next.map(|target| target.caption()).unwrap_or_default(), + }) + } + + /// Start the next episode, if there is one and the viewer wants it. + /// + /// Called on a clean end of file only. A file that failed to load has an + /// end too, and auto-advancing through a broken share one episode at a + /// time is how a player earns being turned off. + fn autoplay_next(&mut self, ctx: &egui::Context) { + if !self.engine.playback_prefs().autoplay_next { + return; + } + let Some(target) = self.adjacent(Adjacent::Next) else { + return; + }; + self.note(ctx, format!("next: {}", target.caption()), false); + self.apply(ctx, Action::Play(target)); + } + + fn send_player(&self, command: crate::playback::Command) { + if let Some(playback) = &self.playback { + playback.send(command); + } + } + + /// Set the volume everywhere it is kept: mpv, and — when the change is + /// settled rather than mid-drag — the preferences the next player is built + /// from. + /// + /// Moving the slider off zero unmutes, because a slider that visibly + /// changes while nothing gets louder is a bug from the viewer's side. + fn set_volume(&mut self, volume: f64, commit: bool) { + let mut prefs = self.engine.playback_prefs(); + prefs.volume = volume; + self.send_player(crate::playback::Command::SetVolume(volume)); + + let muted = self.playback.as_ref().map_or(prefs.muted, |p| p.muted); + if muted && volume > 0.0 { + prefs.muted = false; + self.send_player(crate::playback::Command::SetMuted(false)); + } + self.engine.set_playback_prefs(prefs, commit); + } + + fn apply(&mut self, ctx: &egui::Context, action: Action) { + match action { + Action::Goto(page) => self.page = page, + Action::Play(mut target) => { + // One player at a time. The old one is only *asked* to stop + // here; it is dropped when its replacement is ready, so a click + // that turns out not to open leaves the current film playing. + if let Some(playback) = &self.playback { + playback.send(crate::playback::Command::Stop); + } + // The one place every route into playback passes through — a + // click on a row, a keypress, autoplay — so the episode's name + // is looked up here rather than in each of them. + target.episode_name = self.episode_name(&target); + self.opening = Some(target.name.clone()); + self.engine.play(target); + } + Action::SetMetadataConfig(config) => { + self.settings = config.clone(); + self.engine.set_metadata_config(config); + } + Action::SetApiKey { provider, key } => self.engine.set_api_key(provider, key), + Action::MatchTitles { force } => { + if self.library.is_empty() { + self.note(ctx, "crawl a share first — there is nothing to match", true); + } else { + self.matching = true; + self.engine.match_titles(self.library.titles.clone(), force); + } + } + Action::ToggleFullscreen => { + self.fullscreen = !self.fullscreen; + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen)); + } + Action::WatchToEnd => self.up_next.dismissed = true, + Action::LeavePlayer => { + if self.fullscreen { + self.fullscreen = false; + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); + } + self.page = match &self.playback { + Some(playback) => Page::Title(playback.target.title_key.clone()), + None => Page::Library, + }; + } + Action::Crawl(share) => { + self.crawling = true; + self.note(ctx, "crawling…", false); + self.engine.crawl(share); + } + Action::AddShare { + name, + url, + password, + } => { + self.crawling = true; + self.engine.add_share(name, url, password); + self.form = ShareForm::default(); + } + Action::RemoveShare(id) => { + self.thumbs.clear(); + self.engine.remove_share(id); + } + Action::SetWatched { + share_id, + link_id, + watched, + duration, + } => { + // Unwatching rewinds: a file marked unseen that still holds a + // position would come back as "resume at 19:04". + let position = if watched { + duration.unwrap_or(0.0) + } else { + 0.0 + }; + self.engine.save_watch_state( + share_id, + link_id, + watch_state(position, duration, watched), + ); + self.engine.load_library(); + } + Action::Player(command) => { + if let Some(playback) = &self.playback { + playback.send(command); + } + } + Action::PlayAdjacent(direction) => match self.adjacent(direction) { + Some(target) => self.apply(ctx, Action::Play(target)), + None => self.note( + ctx, + match direction { + Adjacent::Previous => "this is the first one", + Adjacent::Next => "this is the last one", + }, + false, + ), + }, + Action::SetAutoplay(autoplay) => { + let mut prefs = self.engine.playback_prefs(); + prefs.autoplay_next = autoplay; + self.engine.set_playback_prefs(prefs, true); + } + Action::SetVolume { volume, commit } => self.set_volume(volume, commit), + Action::ToggleMute => { + let mut prefs = self.engine.playback_prefs(); + // From what is playing, not from the preferences: mpv is the + // one that knows, and a keypress in its own window changes it + // without going through here. + prefs.muted = match &self.playback { + Some(playback) => !playback.muted, + None => !prefs.muted, + }; + self.send_player(crate::playback::Command::SetMuted(prefs.muted)); + self.engine.set_playback_prefs(prefs, true); + } + Action::SelectTrack { kind, id } => { + self.send_player(crate::playback::Command::SelectTrack(kind, id)); + + // Remember the *language*, not the track number: the next + // episode is a different file, where track 3 may be a + // commentary and Japanese may be track 2. + let language = id.and_then(|id| { + self.playback.as_ref().and_then(|playback| { + playback + .tracks_of(kind) + .find(|track| track.id == id) + .and_then(|track| track.language.clone()) + }) + }); + let mut prefs = self.engine.playback_prefs(); + match kind { + pstr_player::TrackKind::Audio => prefs.audio_language = language, + pstr_player::TrackKind::Subtitle => { + prefs.subtitles = id.is_some(); + // Subtitles turned off keep the language they were + // last shown in, so turning them back on picks up + // where the viewer left off rather than at whatever + // the file defaults to. + if id.is_some() { + prefs.subtitle_language = language; + } + } + pstr_player::TrackKind::Video => {} + } + self.engine.set_playback_prefs(prefs, true); + } + } + } + + /// The bar across the top: where you are, and what to search. + fn navigation(&self, ui: &mut egui::Ui, actions: &mut Vec, search: &mut String) { + ui.horizontal(|ui| { + ui.add_space(4.0); + ui.label( + egui::RichText::new("proton-stream") + .size(18.0) + .strong() + .color(theme::ACCENT), + ); + ui.add_space(12.0); + + let on_library = matches!(self.page, Page::Library | Page::Title(_)); + if ui.selectable_label(on_library, "Library").clicked() { + actions.push(Action::Goto(Page::Library)); + } + if ui + .selectable_label(self.page == Page::Shares, "Shares") + .clicked() + { + actions.push(Action::Goto(Page::Shares)); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add_space(4.0); + if self.crawling { + ui.add(egui::Spinner::new().size(16.0)); + ui.label(ui::muted("crawling")); + } else if self.matching { + ui.add(egui::Spinner::new().size(16.0)); + ui.label(ui::muted("matching")); + } else if self.connecting { + ui.add(egui::Spinner::new().size(16.0)); + ui.label(ui::muted("connecting")); + } else if ui + .button("Refresh") + .on_hover_text("Re-crawl every share") + .clicked() + { + actions.push(Action::Crawl(None)); + } + + if on_library && !self.library.is_empty() { + ui.add_space(8.0); + ui.add( + egui::TextEdit::singleline(search) + .hint_text("Search") + .desired_width(220.0), + ); + } + }); + }); + } +} + +impl eframe::App for App { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + let _timer = FrameTimer::new(&self.page); + let ctx = ui.ctx().clone(); + let ctx = &ctx; + self.pump(ctx, frame); + + let mut actions: Vec = Vec::new(); + + // A player page with nothing playing and nothing opening is a black + // window with no way out. It should be unreachable — `pump` routes away + // when playback stops — but the failure mode is bad enough to guard. + if self.page == Page::Player && self.playback.is_none() && self.opening.is_none() { + self.page = Page::Library; + } + + if self.page == Page::Player { + self.overlay.observe(ctx); + let volume = self + .playback + .as_ref() + .map_or(0.0, |playback| playback.volume); + shortcuts(ctx, self.fullscreen, volume, &mut actions); + + // Before the draw, because drawing does not mutate — and it can + // push an action of its own, which is why it takes the same list. + let up_next = self.tick_up_next(ctx, &mut actions); + let neighbours = self.neighbours(); + let App { + playback, + opening, + overlay, + .. + } = self; + // No panels: the controls are drawn over the picture, and a nav bar + // above a film is the one thing every player agrees not to do. + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) + .show(ui, |ui| { + ui::player::show( + ui, + frame, + playback.as_mut(), + opening.as_deref(), + ui::player::Chrome { + overlay, + up_next, + neighbours, + }, + &mut actions, + ); + }); + + for action in actions { + self.apply(ctx, action); + } + return; + } + + // Split the borrow up front: the pages get read-only state and a place + // to put actions, which is what keeps them from mutating mid-draw. + { + let mut search = std::mem::take(&mut self.search); + let neighbours = self.neighbours(); + let autoplay = self.engine.playback_prefs().autoplay_next; + + egui::Panel::top("nav") + .resizable(false) + .frame( + egui::Frame::new() + .fill(theme::SURFACE) + .inner_margin(egui::Margin::symmetric(14, 10)), + ) + .show(ui, |ui| { + self.navigation(ui, &mut actions, &mut search); + }); + self.search = search; + + let App { + engine, + page, + library, + shares, + thumbs, + posters, + metadata, + episodes, + settings, + api_key, + search, + form, + playback, + opening, + status, + .. + } = self; + + if playback.is_some() || opening.is_some() { + egui::Panel::bottom("transport") + .resizable(false) + .frame( + egui::Frame::new() + .fill(theme::SURFACE) + .inner_margin(egui::Margin::symmetric(16, 10)), + ) + .show(ui, |ui| { + transport_panel( + ui, + playback.as_ref(), + opening.as_deref(), + neighbours, + &mut actions, + ); + }); + } + + if let Some(line) = status { + let age = ctx.input(|input| input.time) - line.at; + if age < STATUS_SECONDS { + egui::Panel::bottom("status") + .resizable(false) + .frame( + egui::Frame::new() + .fill(theme::BACKGROUND) + .inner_margin(egui::Margin::symmetric(16, 6)), + ) + .show(ui, |ui| { + let colour = if line.error { + theme::DANGER + } else { + theme::MUTED + }; + ui.label(egui::RichText::new(&line.text).size(12.0).color(colour)); + }); + // Repaint once the line is due to disappear, or it lingers + // until something else happens to cause a frame. + ctx.request_repaint_after(std::time::Duration::from_secs_f64( + STATUS_SECONDS - age, + )); + } + } + + egui::CentralPanel::default() + .frame( + egui::Frame::new() + .fill(theme::BACKGROUND) + .inner_margin(egui::Margin::symmetric(18, 12)), + ) + .show(ui, |ui| { + let mut art = ui::Art { + engine, + thumbs, + posters, + metadata, + episodes, + }; + match page { + Page::Library => { + ui::library::show(ui, &mut art, library, search, &mut actions) + } + Page::Title(key) => { + ui::title::show(ui, &mut art, library, key, &mut actions) + } + Page::Shares => ui::shares::show( + ui, + shares, + form, + settings, + autoplay, + api_key, + &mut actions, + ), + // Drawn above, without any of these panels. + Page::Player => {} + } + }); + } + + for action in actions { + self.apply(ctx, action); + } + } + + /// Save where playback got to before the window goes away. mpv is still + /// running at this point, so this is the last chance to ask it. + /// + /// The player is then dropped *here* rather than left to the app's own + /// destructor: dropping it frees the mpv render context, which has to + /// happen while the OpenGL context is still current. eframe calls this + /// immediately before tearing the painter down, which is the last moment + /// that is true. + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + if let Some(playback) = &self.playback { + playback.stop_and_wait(std::time::Duration::from_secs(2)); + } + self.playback = None; + } +} + +/// Keys the player page answers to. +/// +/// Only there: elsewhere space is a button press and the arrows move between +/// widgets, and taking them would break the rest of the app to serve a page +/// that is not on screen. +fn shortcuts(ctx: &egui::Context, fullscreen: bool, volume: f64, actions: &mut Vec) { + use crate::playback::Command; + use egui::Key; + + /// What one press of the volume keys is worth. mpv's own step, and small + /// enough that holding the key is a ramp rather than a switch. + const VOLUME_STEP: f64 = 5.0; + + let pressed: Vec = ctx.input(|input| { + [ + Key::Space, + Key::K, + Key::ArrowLeft, + Key::ArrowRight, + Key::ArrowUp, + Key::ArrowDown, + Key::M, + Key::N, + Key::P, + Key::F, + Key::Escape, + ] + .into_iter() + .filter(|key| input.key_pressed(*key)) + .collect() + }); + + for key in pressed { + match key { + Key::Space | Key::K => actions.push(Action::Player(Command::TogglePause)), + Key::ArrowLeft => actions.push(Action::Player(Command::SeekBy(-10.0))), + Key::ArrowRight => actions.push(Action::Player(Command::SeekBy(30.0))), + // Written straight to the preferences rather than debounced: a + // keypress is one change, not a drag. + Key::ArrowUp => actions.push(Action::SetVolume { + volume: (volume + VOLUME_STEP).min(pstr_player::MAX_VOLUME), + commit: true, + }), + Key::ArrowDown => actions.push(Action::SetVolume { + volume: (volume - VOLUME_STEP).max(0.0), + commit: true, + }), + Key::M => actions.push(Action::ToggleMute), + Key::N => actions.push(Action::PlayAdjacent(Adjacent::Next)), + Key::P => actions.push(Action::PlayAdjacent(Adjacent::Previous)), + Key::F => actions.push(Action::ToggleFullscreen), + // Escape leaves fullscreen first and the page second, which is what + // it does everywhere else and what stops one press from both + // un-maximising the window and hiding the film. + Key::Escape if fullscreen => actions.push(Action::ToggleFullscreen), + Key::Escape => actions.push(Action::LeavePlayer), + _ => {} + } + } +} + +/// The transport bar, or the line that says a file is still opening. +fn transport_panel( + ui: &mut egui::Ui, + playback: Option<&Playback>, + opening: Option<&str>, + neighbours: ui::transport::Neighbours, + actions: &mut Vec, +) { + match playback { + Some(playback) => ui::transport::mini(ui, playback, neighbours, actions), + None => { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(14.0)); + ui.label(ui::muted(format!( + "opening {}…", + opening.unwrap_or("the file") + ))); + }); + } + } +} diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs new file mode 100644 index 0000000..c1fbace --- /dev/null +++ b/crates/pstr-app/src/engine.rs @@ -0,0 +1,1083 @@ +//! Everything the UI is not allowed to do on its own thread. +//! +//! egui redraws at the display's rate; opening a share takes seconds, a crawl +//! takes minutes, and a thumbnail is a network round-trip plus a PGP decrypt. +//! So the UI holds an [`Engine`], calls a method that returns immediately, and +//! learns what happened from an [`Event`] on a channel. Nothing here blocks, and +//! nothing here draws. +//! +//! ```text +//! ui thread tokio runtime worker threads +//! ───────── ───────────── ────────────── +//! engine.crawl() ──spawn──▶ SharedLibrary::crawl ──▶ block decrypt +//! ▲ │ +//! └──── Event::LibraryLoaded ◀────┘ + ctx.request_repaint() +//! ``` +//! +//! The repaint call is the part that is easy to forget: without it an event +//! lands in the channel and sits there until the viewer happens to move the +//! mouse. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, Sender, channel}; + +use parking_lot::Mutex; +use pstr_core::catalog::{Catalog, CatalogNode, WatchState, build_rows}; +use pstr_core::config::AppDirs; +use pstr_core::library::{Library, Title}; +use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId}; +use pstr_core::prefs::PlaybackPrefs; +use pstr_core::proton_drive_rs::ThumbnailType; +use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId}; +use pstr_core::{Share, ShareStore, SharedLibrary}; +use pstr_meta::MetadataService; +use pstr_stream::{DiskCacheConfig, LibraryOpener, StreamConfig, StreamSource, VideoStream}; +use tokio::runtime::Runtime; + +use crate::playback::PlaybackTarget; + +/// Poster thumbnails are decoded to at most this edge before they become a +/// texture. Proton's preview is larger than any tile this UI draws, and the +/// difference is VRAM held for every title in the library. +const THUMBNAIL_MAX_EDGE: u32 = 640; + +/// What the background side tells the UI. +pub enum Event { + /// The share list changed on disk. + Shares(Vec), + /// Shares opened; playback is possible from here. + Connected { + /// Shares that did not open, with why. + failures: Vec<(String, String)>, + }, + /// Shares could not be opened at all. + ConnectFailed(String), + /// The catalog was re-read. + LibraryLoaded(Library), + /// A crawl finished for one share. + Crawled { + share_id: String, + nodes: usize, + files: usize, + seconds: f64, + }, + /// Every requested crawl is done. + CrawlFinished, + /// A decoded Proton thumbnail, keyed as [`thumbnail_key`]. + Thumbnail { + key: String, + image: egui::ColorImage, + }, + /// No thumbnail for this file — remembered, so the grid stops asking. + ThumbnailMissing { key: String }, + /// Everything the catalog knows about titles, keyed by title key. + Metadata(HashMap), + /// What the provider said about individual episodes, keyed by title key. + EpisodeMetadata(HashMap), + /// The enrichment settings changed, on disk. + MetadataConfig(MetadataConfig), + /// A matching run finished, with how it went. + Matched { + matched: usize, + unmatched: usize, + failed: usize, + }, + /// Every requested match is done. + MatchFinished, + /// A decoded piece of provider artwork, keyed by title key. + Poster { + key: String, + image: egui::ColorImage, + }, + /// The artwork for this title could not be had. + PosterMissing { key: String }, + /// A stream opened and is ready to hand to mpv. + PlaybackReady { + target: Box, + stream: VideoStream, + }, + /// mpv said something about what is playing. `id` is the player it came + /// from — a player being replaced goes on talking for a moment after the + /// next one has started. + Player { + id: u64, + event: pstr_player::PlayerEvent, + }, + /// That player is gone: its window was closed, or playback ended. + PlayerStopped { id: u64 }, + /// Something the viewer should see, phrased for them. + Error(String), + /// Something the viewer might like to see, briefly. + Status(String), +} + +/// The share clients and the block layer, once they exist. +struct Connection { + library: Arc, + source: StreamSource, +} + +/// The background half of the app. +/// +/// Cheap to clone: everything inside is shared. The UI keeps one, the tasks it +/// spawns keep clones. +#[derive(Clone)] +pub struct Engine { + runtime: Arc, + dirs: AppDirs, + store: Arc, + /// One connection, guarded rather than owned by the UI, because a task that + /// finishes long after the click that started it still needs it. + connection: Arc>>, + /// Held across the handshake, which a `parking_lot` guard cannot be. + /// + /// Without it the first frame is a stampede: `connect`, the click that + /// wanted a stream and every visible poster all find no connection and open + /// every share at once. One winner, everyone else waits and takes its + /// result. + connecting: Arc>, + /// `rusqlite::Connection` is `Send` but not `Sync`, and both the UI (watch + /// state) and crawls (rows) write. One lock, held for the length of a + /// statement. + catalog: Arc>, + /// Thumbnail fetches in flight. A fast scroll can ask for a hundred, and + /// each is a round-trip plus a decrypt — unbounded, they crowd out the reads + /// the player is blocked on. + thumbnails: Arc, + /// Provider lookups in flight. Far tighter than the thumbnail limit, and + /// not for our benefit: both providers rate-limit by the minute, and a + /// library of three hundred titles fired off at once earns a 429 for the + /// whole run rather than for the tail of it. + lookups: Arc, + /// The enrichment settings and the provider they resolve to. Rebuilt + /// whenever the settings change, so nothing holds a client for a provider + /// the viewer has switched away from. + metadata: Arc>, + /// Volume, mute and language preferences. Held here rather than in the UI + /// because a new player is built from them, and the player is started from + /// a background event. + prefs: Arc>, + events: Sender, + ctx: egui::Context, +} + +/// How many posters may be in flight at once. +const THUMBNAIL_CONCURRENCY: usize = 6; + +/// How many provider lookups may be in flight at once. See [`Engine::lookups`]. +const LOOKUP_CONCURRENCY: usize = 2; + +/// Enrichment as currently configured. +struct Enrichment { + config: MetadataConfig, + /// `None` when enrichment is off, or when the provider needs an API key + /// that is not stored. Both are ordinary states, not errors: the difference + /// only matters when the viewer asks for a match, which is where it is + /// reported. + service: Option, +} + +impl Enrichment { + /// Resolve a service for `config`, if one can be had. + fn build(config: MetadataConfig) -> Self { + let service = config.enabled.then(|| { + let key = pstr_meta::settings::api_key(config.provider); + MetadataService::new(&config, key) + .inspect_err(|error| tracing::debug!("metadata provider unavailable: {error}")) + .ok() + }); + + Self { + config, + service: service.flatten(), + } + } +} + +impl Engine { + /// Build the engine and the channel the UI drains. + pub fn new( + runtime: Arc, + dirs: AppDirs, + ctx: egui::Context, + ) -> anyhow::Result<(Self, Receiver)> { + let catalog = Catalog::open(&dirs.catalog_db())?; + let (events, receiver) = channel(); + + // An unreadable settings file must not stop the app starting: the + // library is the point and enrichment is decoration. It is reported and + // treated as "off", which is also the safe reading — never as "on". + let config = pstr_meta::settings::load(&dirs).unwrap_or_else(|error| { + tracing::warn!("read the metadata settings: {error}"); + MetadataConfig::default() + }); + + // Same reading as the metadata settings above: a preferences file that + // will not parse is worth a line in the log and nothing more. It is + // volume and a language, and the defaults play the film. + let prefs = pstr_core::prefs::load(&dirs).unwrap_or_else(|error| { + tracing::warn!("read the playback preferences: {error}"); + PlaybackPrefs::default() + }); + + let engine = Self { + runtime, + store: Arc::new(ShareStore::new(dirs.clone())), + dirs, + connection: Arc::new(Mutex::new(None)), + connecting: Arc::new(tokio::sync::Mutex::new(())), + catalog: Arc::new(Mutex::new(catalog)), + thumbnails: Arc::new(tokio::sync::Semaphore::new(THUMBNAIL_CONCURRENCY)), + lookups: Arc::new(tokio::sync::Semaphore::new(LOOKUP_CONCURRENCY)), + metadata: Arc::new(Mutex::new(Enrichment::build(config))), + prefs: Arc::new(Mutex::new(prefs)), + events, + ctx, + }; + Ok((engine, receiver)) + } + + pub fn runtime(&self) -> &Arc { + &self.runtime + } + + /// The playback preferences as they stand. + pub fn playback_prefs(&self) -> PlaybackPrefs { + self.prefs.lock().clone() + } + + /// Change the preferences, and write them if asked. + /// + /// `commit` is what keeps a dragged volume slider from being a file write + /// per frame: every change is kept in memory, so a player started mid-drag + /// is built from the current value, and the disk is touched when the drag + /// ends. Losing the last few points of a drag to a crash costs nothing. + pub fn set_playback_prefs(&self, prefs: PlaybackPrefs, commit: bool) { + let prefs = prefs.sanitized(); + *self.prefs.lock() = prefs.clone(); + if !commit { + return; + } + let engine = self.clone(); + self.runtime.spawn_blocking(move || { + if let Err(error) = pstr_core::prefs::save(&engine.dirs, &prefs) { + // Not `fail`: the viewer asked to change the volume, not to + // save a file, and the change did happen. Telling them their + // config directory is unwritable in a red line under the film + // helps nobody mid-episode. + tracing::warn!("save the playback preferences: {error}"); + } + }); + } + + /// Send an event and wake the UI. A closed channel means the window is + /// gone, which is not an error worth propagating anywhere. + pub(crate) fn emit(&self, event: Event) { + if self.events.send(event).is_ok() { + self.ctx.request_repaint(); + } + } + + fn fail(&self, context: &str, error: impl std::fmt::Display) { + tracing::warn!("{context}: {error}"); + self.emit(Event::Error(format!("{context}: {error}"))); + } + + // ---------------------------------------------------------------- shares + + /// Re-read the share list. + pub fn load_shares(&self) { + match self.store.list() { + Ok(shares) => self.emit(Event::Shares(shares)), + Err(error) => self.fail("read the share list", error), + } + } + + /// Add a share, then open and crawl it — which is what "add" means to + /// someone who just pasted a link. + pub fn add_share(&self, name: String, url: String, password: Option) { + let engine = self.clone(); + self.runtime.spawn(async move { + let share = match engine.store.add(&name, &url, password.as_deref()) { + Ok(share) => share, + Err(error) => return engine.fail("add that share", error), + }; + engine.emit(Event::Status(format!("added {}", share.name))); + engine.load_shares(); + engine.connect_and_crawl(Some(share.id)).await; + }); + } + + /// Forget a share and its stored secrets. + pub fn remove_share(&self, id: String) { + let engine = self.clone(); + self.runtime.spawn(async move { + if let Err(error) = engine.store.remove(&id) { + return engine.fail("remove that share", error); + } + { + let catalog = engine.catalog.lock(); + if let Err(error) = catalog.remove_share(&id) { + tracing::warn!("drop catalog rows for {id}: {error}"); + } + } + engine.load_shares(); + // Its clients are stale now; the next action reopens what is left. + engine.connection.lock().take(); + engine.load_library(); + engine.emit(Event::Status("removed".into())); + }); + } + + // ------------------------------------------------------------ connection + + /// The open connection, if there is one. Never held across an await — the + /// guard is `parking_lot`'s. + fn opened(&self) -> Option<(Arc, StreamSource)> { + let connection = self.connection.lock(); + let connection = connection.as_ref()?; + Some((Arc::clone(&connection.library), connection.source.clone())) + } + + /// Open every configured share and build the block layer. + /// + /// Idempotent: a second call while one connection exists is a no-op, so the + /// UI can call it whenever it needs the network without tracking whether it + /// already has it. + pub fn connect(&self) { + if self.opened().is_some() { + return; + } + let engine = self.clone(); + self.runtime.spawn(async move { + engine.ensure_connected().await; + }); + } + + /// Open the shares if they are not open. Returns the pieces playback needs. + async fn ensure_connected(&self) -> Option<(Arc, StreamSource)> { + if let Some(opened) = self.opened() { + return Some(opened); + } + + // One handshake at a time, and re-check after waiting: whoever was + // ahead in the queue has almost certainly just done it. + let _connecting = self.connecting.lock().await; + if let Some(opened) = self.opened() { + return Some(opened); + } + + let (library, failures) = match SharedLibrary::open_all(&self.store).await { + Ok(opened) => opened, + Err(error) => { + self.emit(Event::ConnectFailed(error.to_string())); + return None; + } + }; + let library = Arc::new(library); + + let opener = Arc::new(LibraryOpener::new(Arc::clone(&library))); + let config = StreamConfig::default() + // mpv brings its own read-ahead; a second one under it only competes + // for the bandwidth mpv is blocked on. See `pstr_player::READAHEAD_BLOCKS`. + .with_readahead(pstr_player::READAHEAD_BLOCKS) + .with_disk_cache(DiskCacheConfig::new(self.dirs.block_cache())); + + let source = match StreamSource::new(opener, config).await { + Ok(source) => source, + Err(error) => { + self.emit(Event::ConnectFailed(error.to_string())); + return None; + } + }; + + *self.connection.lock() = Some(Connection { + library: Arc::clone(&library), + source: source.clone(), + }); + + self.emit(Event::Connected { + failures: failures + .into_iter() + .map(|(share, error)| (share.name, error.to_string())) + .collect(), + }); + Some((library, source)) + } + + // ------------------------------------------------------------- catalogue + + /// Re-read the catalog into a [`Library`]. + pub fn load_library(&self) { + let engine = self.clone(); + self.runtime.spawn_blocking(move || { + let result = { + let catalog = engine.catalog.lock(); + catalog + .all_files() + .and_then(|files| Ok((files, catalog.all_watch_states()?))) + }; + match result { + Ok((files, watch)) => { + engine.emit(Event::LibraryLoaded(Library::build(files, &watch))) + } + Err(error) => engine.fail("read the catalog", error), + } + }); + } + + /// Crawl one share, or all of them, and reload the library. + pub fn crawl(&self, share: Option) { + let engine = self.clone(); + self.runtime.spawn(async move { + engine.connect_and_crawl(share).await; + }); + } + + async fn connect_and_crawl(&self, share: Option) { + let Some((library, _)) = self.ensure_connected().await else { + self.emit(Event::CrawlFinished); + return; + }; + + let targets: Vec = match share { + Some(id) => vec![id], + None => library.share_ids().map(str::to_string).collect(), + }; + + for share_id in targets { + let started = std::time::Instant::now(); + let nodes = match library.crawl(&share_id).await { + Ok(nodes) => nodes, + Err(error) => { + self.fail(&format!("crawl {share_id}"), error); + continue; + } + }; + let rows = build_rows(&share_id, &nodes); + let files = rows.len(); + + let stored = { + let mut catalog = self.catalog.lock(); + catalog.replace_share(&share_id, &rows) + }; + if let Err(error) = stored { + self.fail(&format!("store {share_id}"), error); + continue; + } + + self.emit(Event::Crawled { + share_id, + nodes: nodes.len(), + files, + seconds: started.elapsed().as_secs_f64(), + }); + self.load_library(); + } + self.emit(Event::CrawlFinished); + } + + /// Record where playback got to. + /// + /// Fire and forget: a watch position that fails to save is worth a log line, + /// not an interruption to what the viewer is watching. + pub fn save_watch_state(&self, share_id: String, link_id: String, state: WatchState) { + let engine = self.clone(); + self.runtime.spawn_blocking(move || { + engine.save_watch_state_now(&share_id, &link_id, &state); + }); + } + + /// The same write, on the calling thread. + /// + /// What the player thread uses for its last save: a task spawned as the + /// window is closing may never be polled, and the position at the moment + /// the viewer quit is exactly the one worth keeping. + pub fn save_watch_state_now(&self, share_id: &str, link_id: &str, state: &WatchState) { + if let Err(error) = self + .catalog + .lock() + .set_watch_state(share_id, link_id, state) + { + tracing::warn!("save watch state for {link_id}: {error}"); + } + } + + // ------------------------------------------------------------- playback + + /// Open a stream on `node` and hand it back through + /// [`Event::PlaybackReady`]. + /// + /// Opening is done here rather than in mpv's open callback for the reason + /// `pstr-player` documents: opening can take seconds and fail for reasons a + /// person needs to read, and mpv's callback can only say "loading failed". + pub fn play(&self, target: PlaybackTarget) { + let engine = self.clone(); + self.runtime.spawn(async move { + let Some((_, source)) = engine.ensure_connected().await else { + return; + }; + let uid = node_uid(&target.volume_id, &target.link_id); + match source.open(&target.share_id, &uid).await { + Ok(stream) if stream.size() == 0 => { + engine.emit(Event::Error(format!("{} has no content", target.name))); + } + Ok(stream) => engine.emit(Event::PlaybackReady { + target: Box::new(target), + stream, + }), + Err(error) => engine.fail(&format!("open {}", target.name), error), + } + }); + } + + /// Drop a stream the player is done with, so its reader and blocks are not + /// held for the rest of the session. + pub fn release(&self, share_id: String, volume_id: String, link_id: String) { + if let Some(connection) = self.connection.lock().as_ref() { + connection + .source + .close(&share_id, &node_uid(&volume_id, &link_id)); + } + } + + // ------------------------------------------------------------ thumbnails + + /// Fetch, decode and post a poster for `node`. + /// + /// The disk cache is checked first and written on success, so a restart + /// paints the grid without touching the network. A file with no thumbnail + /// answers [`Event::ThumbnailMissing`] — the grid must remember that, or + /// every render pays a round-trip per unmatched title. + pub fn request_thumbnail(&self, node: &CatalogNode) { + let engine = self.clone(); + let key = thumbnail_key(node); + let share_id = node.share_id.clone(); + let uid = node_uid(&node.volume_id, &node.link_id); + let path = self.dirs.thumbnail_cache().join(format!("{key}.bin")); + + self.runtime.spawn(async move { + if let Ok(bytes) = tokio::fs::read(&path).await { + match decode_thumbnail(&bytes) { + Ok(image) => return engine.emit(Event::Thumbnail { key, image }), + // A truncated or corrupt cache entry is not worth reporting; + // fall through and fetch it again. + Err(error) => tracing::debug!("cached thumbnail {key}: {error}"), + } + } + + // Past the cache, so this one costs the network. Wait for a permit + // before taking any of the bandwidth playback might want. + let permits = Arc::clone(&engine.thumbnails); + let Ok(_permit) = permits.acquire().await else { + return; + }; + + let Some((library, _)) = engine.ensure_connected().await else { + return; + }; + let Some(client) = library.client(&share_id) else { + return engine.emit(Event::ThumbnailMissing { key }); + }; + + // Preview first: it is the one sized for a card. Not every file has + // one, and the small thumbnail is better than a grey rectangle. + let mut bytes = None; + for kind in [ThumbnailType::Preview, ThumbnailType::Thumbnail] { + match client.download_thumbnail(&uid, kind).await { + Ok(Some(found)) => { + bytes = Some(found); + break; + } + Ok(None) => {} + Err(error) => tracing::debug!("thumbnail {key} ({kind:?}): {error}"), + } + } + let Some(bytes) = bytes else { + // Common, not exceptional: Proton renders thumbnails on upload, + // and a share filled by a client that does not attach any — the + // FUSE client among them — has none at all. The tile falls back + // to initials until a metadata provider gives it a poster. + tracing::debug!("no thumbnail for {key}"); + return engine.emit(Event::ThumbnailMissing { key }); + }; + + if let Some(parent) = path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let _ = tokio::fs::write(&path, &bytes).await; + + match decode_thumbnail(&bytes) { + Ok(image) => engine.emit(Event::Thumbnail { key, image }), + Err(error) => { + tracing::debug!("decode thumbnail {key}: {error}"); + engine.emit(Event::ThumbnailMissing { key }); + } + } + }); + } +} + +// ---------------------------------------------------------------- enrichment + +impl Engine { + /// The enrichment settings as they stand. + pub fn metadata_config(&self) -> MetadataConfig { + self.metadata.lock().config.clone() + } + + /// Whether a provider is configured *and* usable right now. + /// + /// False for "switched off" and for "TMDB with no API key stored" alike — + /// the UI distinguishes them from the config, not from here. + pub fn enrichment_ready(&self) -> bool { + self.metadata.lock().service.is_some() + } + + /// Change the settings, write them, and rebuild the provider. + /// + /// Turning enrichment *off* clears the stored answers as well. That is the + /// point of the switch: someone who turns this off has decided they would + /// rather the third party's answers were not on their disk either, and + /// leaving them there would make "off" mean only "stop asking". + pub fn set_metadata_config(&self, config: MetadataConfig) { + let engine = self.clone(); + self.runtime.spawn_blocking(move || { + let previous = engine.metadata_config(); + if let Err(error) = pstr_meta::settings::save(&engine.dirs, &config) { + return engine.fail("save the metadata settings", error); + } + + let forget = !config.enabled || config.provider != previous.provider; + if forget && let Err(error) = engine.catalog.lock().clear_metadata() { + tracing::warn!("clear stored metadata: {error}"); + } + + *engine.metadata.lock() = Enrichment::build(config.clone()); + engine.emit(Event::MetadataConfig(config)); + engine.load_metadata(); + }); + } + + /// Store an API key for a provider and rebuild against it. + pub fn set_api_key(&self, provider: ProviderId, key: String) { + let engine = self.clone(); + self.runtime.spawn_blocking(move || { + if let Err(error) = pstr_meta::settings::set_api_key(provider, &key) { + return engine.fail("store the API key", error); + } + let config = engine.metadata_config(); + *engine.metadata.lock() = Enrichment::build(config); + engine.emit(Event::Status(if key.trim().is_empty() { + format!("cleared the {} key", provider.label()) + } else { + format!("saved the {} key", provider.label()) + })); + }); + } + + /// Re-read every stored provider answer. + /// + /// Read whole rather than per tile, for the same reason watch state is — + /// see [`Catalog::all_metadata`]. + pub fn load_metadata(&self) { + let engine = self.clone(); + self.runtime.spawn_blocking(move || { + // One lock, both reads: they are drawn together, and a UI that has + // the titles but not their episodes for a frame flickers the rows. + let answers = { + let catalog = engine.catalog.lock(); + catalog + .all_metadata() + .and_then(|records| Ok((records, catalog.all_episode_metadata()?))) + }; + match answers { + Ok((records, episodes)) => { + engine.emit(Event::Metadata(records)); + engine.emit(Event::EpisodeMetadata(episodes)); + } + Err(error) => engine.fail("read stored metadata", error), + } + }); + } + + /// Look up every title that has no fresh answer. + /// + /// `force` re-asks about titles that already have one, which is what + /// "match again" means. Otherwise a run over an already-matched library + /// makes no requests at all, which is what makes it safe to call whenever + /// the library changes. + pub fn match_titles(&self, titles: Vec, force: bool) { + let engine = self.clone(); + self.runtime.spawn(async move { + engine.run_match(titles, force).await; + engine.emit(Event::MatchFinished); + }); + } + + async fn run_match(&self, titles: Vec<Title>, force: bool) { + let Some(service) = self.metadata.lock().service.clone() else { + let config = self.metadata_config(); + return self.emit(Event::Error(if !config.enabled { + "turn on metadata enrichment first".into() + } else { + format!("{} needs an API key", config.provider.label()) + })); + }; + let provider = service.provider(); + + let (stored, listed) = { + let catalog = self.catalog.lock(); + match catalog + .all_metadata() + .and_then(|stored| Ok((stored, catalog.episode_metadata_ages()?))) + { + Ok(pair) => pair, + Err(error) => return self.fail("read stored metadata", error), + } + }; + + // Two kinds of work, and the difference is a request saved: a title + // that already has a good match but no episode list needs the episode + // request only — searching for it again would ask the provider + // something it has already answered. + let mut pending: Vec<Work> = Vec::new(); + for title in titles { + let record = stored.get(&title.key); + if force || !pstr_meta::service::is_usable(record, provider) { + pending.push(Work::Match(title)); + continue; + } + let has_episodes = listed + .get(&title.key) + .is_some_and(|(asked, _)| *asked == provider); + if let Some(found) = record.and_then(|record| record.metadata.clone()) + && !has_episodes + { + pending.push(Work::Episodes(title, Box::new(found))); + } + } + + if pending.is_empty() { + return self.emit(Event::Status("everything is already matched".into())); + } + self.emit(Event::Status(format!( + "matching {} titles against {}…", + pending.len(), + provider.label() + ))); + + let (mut matched, mut unmatched, mut failed) = (0usize, 0usize, 0usize); + let mut lookups = futures::stream::FuturesUnordered::new(); + for work in &pending { + let service = service.clone(); + let permits = Arc::clone(&self.lookups); + lookups.push(async move { + let Ok(_permit) = permits.acquire().await else { + return None; + }; + Some(match work { + Work::Match(title) => match service.record(title).await { + Ok(record) => { + // Only for a title that matched: there is no id to + // ask about otherwise. + let episodes = match &record.metadata { + Some(found) => episodes_of(&service, title, found).await, + None => Vec::new(), + }; + Ok((record.title_key.clone(), Some(record), episodes)) + } + Err(error) => Err(error), + }, + Work::Episodes(title, found) => Ok(( + title.key.clone(), + None, + episodes_of(&service, title, found).await, + )), + }) + }); + } + + use futures::StreamExt as _; + let mut episodes_found = 0usize; + while let Some(result) = lookups.next().await { + match result { + Some(Ok((title_key, record, episodes))) => { + if let Some(record) = &record { + if record.metadata.is_some() { + matched += 1; + } else { + unmatched += 1; + } + // Misses are stored on purpose; failures below are not. + // See `pstr_meta::service`. + if let Err(error) = self.catalog.lock().set_metadata(record) { + tracing::warn!("store metadata for {}: {error}", record.title_key); + } + } + if !episodes.is_empty() { + episodes_found += episodes.len(); + let stored = self.catalog.lock().set_episode_metadata( + &title_key, + provider, + now(), + &episodes, + ); + if let Err(error) = stored { + tracing::warn!("store episodes for {title_key}: {error}"); + } + } + } + Some(Err(error)) => { + failed += 1; + tracing::warn!("metadata lookup: {error}"); + } + None => {} + } + } + + if episodes_found > 0 { + self.emit(Event::Status(format!("{episodes_found} episodes named"))); + } + self.emit(Event::Matched { + matched, + unmatched, + failed, + }); + self.load_metadata(); + } + + /// Fetch, decode and post one title's artwork. + /// + /// Cached on disk under a hash of the URL, so a provider that moves its CDN + /// paths invalidates by itself and the same picture shared by two titles is + /// fetched once. + pub fn request_poster(&self, title_key: String, url: String) { + let engine = self.clone(); + let path = self + .dirs + .poster_cache() + .join(format!("{}.img", digest(&url))); + + self.runtime.spawn(async move { + if let Ok(bytes) = tokio::fs::read(&path).await { + match decode_thumbnail(&bytes) { + Ok(image) => { + return engine.emit(Event::Poster { + key: title_key, + image, + }); + } + Err(error) => tracing::debug!("cached poster {title_key}: {error}"), + } + } + + let Some(service) = engine.metadata.lock().service.clone() else { + // Enrichment was turned off between the request and now. + return engine.emit(Event::PosterMissing { key: title_key }); + }; + + let bytes = match service.artwork(&url).await { + Ok(bytes) => bytes, + Err(error) => { + tracing::debug!("fetch poster for {title_key}: {error}"); + return engine.emit(Event::PosterMissing { key: title_key }); + } + }; + + if let Some(parent) = path.parent() { + let _ = tokio::fs::create_dir_all(parent).await; + } + let _ = tokio::fs::write(&path, &bytes).await; + + match decode_thumbnail(&bytes) { + Ok(image) => engine.emit(Event::Poster { + key: title_key, + image, + }), + Err(error) => { + tracing::debug!("decode poster for {title_key}: {error}"); + engine.emit(Event::PosterMissing { key: title_key }) + } + } + }); + } +} + +/// A short, filesystem-safe name for a URL. +fn digest(url: &str) -> String { + use sha2::{Digest as _, Sha256}; + let hash = Sha256::digest(url.as_bytes()); + hash.iter() + .take(16) + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// The cache and texture key for a file's poster. +/// One title's worth of work in a match run. +enum Work { + /// Search for it, and take its episodes if it matches. + Match(Title), + /// It is already matched; only the episode list is missing. + Episodes(Title, Box<pstr_core::metadata::TitleMetadata>), +} + +/// The episode list for a whole title, with a failure treated as an empty one. +/// +/// Deliberately not fatal to the title it belongs to: a poster and a synopsis +/// that arrived are worth keeping even when the episode request was the one +/// that hit the rate limit. The empty result is not cached as an answer — the +/// next match run asks again — because the only thing that triggers one is a +/// viewer pressing the button. +/// +/// The match itself only ever answers for *one* entry, and on a provider that +/// files each sequel separately — AniList — that entry is season one. So each +/// further season of the title is searched for by name and its episodes are +/// tagged with the season they came from; without that, seasons two and three +/// have no episode names at all. See +/// [`MetadataService::season_episodes`](pstr_meta::MetadataService::season_episodes). +async fn episodes_of( + service: &MetadataService, + title: &Title, + found: &pstr_core::metadata::TitleMetadata, +) -> Vec<pstr_core::metadata::EpisodeMetadata> { + let mut episodes = service + .episodes(found) + .await + .inspect_err(|error| tracing::warn!("episodes for {}: {error}", found.name)) + .unwrap_or_default(); + + if !service.splits_seasons() { + return episodes; + } + + // Season one is what the title's own match already answered for. + let later: Vec<u32> = title + .seasons + .iter() + .filter_map(|season| season.number) + .filter(|number| *number > 1) + .collect(); + for season in later { + match service.season_episodes(title, season).await { + Ok(found) => episodes.extend(found), + Err(error) => { + tracing::warn!("episodes for {} season {season}: {error}", title.name); + } + } + } + episodes +} + +/// Unix seconds, for the "when was this asked" columns. +fn now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_secs() as i64) + .unwrap_or_default() +} + +pub fn thumbnail_key(node: &CatalogNode) -> String { + format!("{}-{}", node.share_id, node.link_id) +} + +fn node_uid(volume_id: &str, link_id: &str) -> NodeUid { + NodeUid::new( + VolumeId::new(volume_id.to_string()), + LinkId::new(link_id.to_string()), + ) +} + +/// Decode image bytes into something egui can upload, scaled down to +/// [`THUMBNAIL_MAX_EDGE`]. +/// +/// Runs on the runtime rather than the UI thread: a JPEG decode per tile during +/// the first paint of a large library is visible as dropped frames. +fn decode_thumbnail(bytes: &[u8]) -> anyhow::Result<egui::ColorImage> { + let decoded = image::load_from_memory(bytes)?; + let decoded = if decoded.width().max(decoded.height()) > THUMBNAIL_MAX_EDGE { + decoded.thumbnail(THUMBNAIL_MAX_EDGE, THUMBNAIL_MAX_EDGE) + } else { + decoded + }; + let rgba = decoded.to_rgba8(); + let size = [rgba.width() as usize, rgba.height() as usize]; + Ok(egui::ColorImage::from_rgba_unmultiplied( + size, + rgba.as_flat_samples().as_slice(), + )) +} + +/// Watch state as of now, for a position in a file. +pub fn watch_state(position: f64, duration: Option<f64>, watched: bool) -> WatchState { + WatchState { + position_secs: position, + duration_secs: duration, + watched, + updated_at: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_secs() as i64) + .unwrap_or(0), + } +} + +/// Shares that failed to open, formatted for one status line. +pub fn describe_failures(failures: &[(String, String)]) -> Option<String> { + if failures.is_empty() { + return None; + } + let names: Vec<&str> = failures.iter().map(|(name, _)| name.as_str()).collect(); + Some(format!("could not open: {}", names.join(", "))) +} + +/// Pictures the UI has, plus the ones it has already asked for. +/// +/// Two instances exist and they hold different things: one keyed by file for +/// Proton's own thumbnails, one keyed by title for a provider's artwork. What +/// they share is the part that matters — a fast scroll must not re-ask for the +/// same picture sixty times a second, and a picture that turned out not to exist +/// must be remembered as not existing. Without that second half, every render +/// pays a round-trip per unmatched tile, which is exactly what +/// `proton-drive-linux`'s photo grid had to learn. +#[derive(Default)] +pub struct ImageCache { + textures: HashMap<String, egui::TextureHandle>, + requested: std::collections::HashSet<String>, + missing: std::collections::HashSet<String>, +} + +impl ImageCache { + /// The texture for `key`, calling `fetch` the first time it is asked for. + /// + /// `None` while a fetch is in flight, and forever for a picture that turned + /// out not to exist — the negative answer is remembered deliberately. + pub fn texture(&mut self, key: String, fetch: impl FnOnce()) -> Option<egui::TextureHandle> { + if let Some(texture) = self.textures.get(&key) { + return Some(texture.clone()); + } + if !self.missing.contains(&key) && self.requested.insert(key) { + fetch(); + } + None + } + + pub fn insert(&mut self, ctx: &egui::Context, key: String, image: egui::ColorImage) { + let texture = ctx.load_texture(&key, image, egui::TextureOptions::LINEAR); + self.textures.insert(key, texture); + } + + pub fn mark_missing(&mut self, key: String) { + self.missing.insert(key); + } + + /// Forget everything. Used when the catalog is replaced, so a recrawl that + /// changed link ids does not leave stale pictures behind, and when the + /// metadata provider changes, so the old provider's artwork goes with it. + pub fn clear(&mut self) { + self.textures.clear(); + self.requested.clear(); + self.missing.clear(); + } +} diff --git a/crates/pstr-app/src/lib.rs b/crates/pstr-app/src/lib.rs new file mode 100644 index 0000000..6ebc7e0 --- /dev/null +++ b/crates/pstr-app/src/lib.rs @@ -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; diff --git a/crates/pstr-app/src/main.rs b/crates/pstr-app/src/main.rs new file mode 100644 index 0000000..5f24057 --- /dev/null +++ b/crates/pstr-app/src/main.rs @@ -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}")) +} diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs new file mode 100644 index 0000000..85ffd33 --- /dev/null +++ b/crates/pstr-app/src/playback.rs @@ -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), + ); +} diff --git a/crates/pstr-app/src/theme.rs b/crates/pstr-app/src/theme.rs new file mode 100644 index 0000000..37389f2 --- /dev/null +++ b/crates/pstr-app/src/theme.rs @@ -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(); + }); +} diff --git a/crates/pstr-app/src/ui/library.rs b/crates/pstr-app/src/ui/library.rs new file mode 100644 index 0000000..6cd07a7 --- /dev/null +++ b/crates/pstr-app/src/ui/library.rs @@ -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"); + } +} diff --git a/crates/pstr-app/src/ui/mod.rs b/crates/pstr-app/src/ui/mod.rs new file mode 100644 index 0000000..50e666a --- /dev/null +++ b/crates/pstr-app/src/ui/mod.rs @@ -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); + } +} diff --git a/crates/pstr-app/src/ui/player.rs b/crates/pstr-app/src/ui/player.rs new file mode 100644 index 0000000..ae31fdd --- /dev/null +++ b/crates/pstr-app/src/ui/player.rs @@ -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), + ); + } +} diff --git a/crates/pstr-app/src/ui/shares.rs b/crates/pstr-app/src/ui/shares.rs new file mode 100644 index 0000000..40e36e2 --- /dev/null +++ b/crates/pstr-app/src/ui/shares.rs @@ -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.")); + } + }); + }); +} diff --git a/crates/pstr-app/src/ui/title.rs b/crates/pstr-app/src/ui/title.rs new file mode 100644 index 0000000..d64bb22 --- /dev/null +++ b/crates/pstr-app/src/ui/title.rs @@ -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); +} diff --git a/crates/pstr-app/src/ui/transport.rs b/crates/pstr-app/src/ui/transport.rs new file mode 100644 index 0000000..4b64798 --- /dev/null +++ b/crates/pstr-app/src/ui/transport.rs @@ -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); + } +} diff --git a/crates/pstr-app/src/video.rs b/crates/pstr-app/src/video.rs new file mode 100644 index 0000000..f0879ff --- /dev/null +++ b/crates/pstr-app/src/video.rs @@ -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()); + } +} diff --git a/crates/pstr-cli/Cargo.toml b/crates/pstr-cli/Cargo.toml new file mode 100644 index 0000000..752dc4d --- /dev/null +++ b/crates/pstr-cli/Cargo.toml @@ -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 diff --git a/crates/pstr-cli/src/main.rs b/crates/pstr-cli/src/main.rs new file mode 100644 index 0000000..f30be02 --- /dev/null +++ b/crates/pstr-cli/src/main.rs @@ -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(()) +} diff --git a/crates/pstr-core/Cargo.toml b/crates/pstr-core/Cargo.toml new file mode 100644 index 0000000..0d0d75a --- /dev/null +++ b/crates/pstr-core/Cargo.toml @@ -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 diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs new file mode 100644 index 0000000..a7f875b --- /dev/null +++ b/crates/pstr-core/src/catalog.rs @@ -0,0 +1,1385 @@ +//! The catalog: what a crawl found, and where playback left off. +//! +//! SQLite, and — following `proton-drive-linux` — **forward-only**: never edit a +//! shipped `MIGRATION_V*`, add a new one and bump [`SCHEMA_VERSION`]. A database +//! newer than this build understands is a hard refuse-to-open rather than a +//! downgrade, because silently reinterpreting a newer schema is how watch state +//! gets corrupted. +//! +//! Unlike the daemon's cache, nothing here is irreplaceable: the catalog is +//! rebuilt by crawling again. Watch positions are the exception, which is why +//! they live in their own table keyed by `(share_id, link_id)` — stable across +//! recrawls, and untouched when the node rows are replaced. + +use std::collections::HashMap; + +use rusqlite::{Connection, OptionalExtension, params}; + +use crate::error::Result; +use crate::library::TitleKind; +use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId, TitleMetadata}; +use crate::naming::{self, ParsedName}; + +/// Bump when adding a migration. +pub const SCHEMA_VERSION: i64 = 4; + +const MIGRATION_V1: &str = r#" +CREATE TABLE nodes ( + share_id TEXT NOT NULL, + link_id TEXT NOT NULL, + volume_id TEXT NOT NULL, + parent_link_id TEXT, + name TEXT NOT NULL, + is_folder INTEGER NOT NULL, + media_type TEXT, + size INTEGER, + active_revision_id TEXT, + title TEXT, + season INTEGER, + episode INTEGER, + year INTEGER, + PRIMARY KEY (share_id, link_id) +); +CREATE INDEX nodes_by_parent ON nodes (share_id, parent_link_id); +CREATE INDEX nodes_by_title ON nodes (title, season, episode); + +CREATE TABLE watch_state ( + share_id TEXT NOT NULL, + link_id TEXT NOT NULL, + position_secs REAL NOT NULL, + duration_secs REAL, + watched INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + PRIMARY KEY (share_id, link_id) +); +"#; + +/// Episode names, which the first schema had nowhere to put: a library that +/// numbers its files (`S01E01-Mother and Children.mkv`) has the episode's own +/// name in the filename, and a poster-wall UI wants it. +const MIGRATION_V2: &str = r#" +ALTER TABLE nodes ADD COLUMN episode_title TEXT; +"#; + +/// What a metadata provider said about a title, including that it said nothing. +/// +/// Keyed by [`crate::library::title_key`] rather than by anything from a share: +/// a title merges across shares, and the answer is about the *title*. That also +/// means these rows survive a recrawl, like watch state and unlike node rows — +/// re-matching a whole library because one share was refreshed would be a few +/// hundred needless requests to a third party. +/// +/// `matched = 0` is a real, cached answer: the provider was asked and had +/// nothing. `genres` is a newline-separated list, which is enough for something +/// never queried by genre and avoids a table for it. +const MIGRATION_V3: &str = r#" +CREATE TABLE title_metadata ( + title_key TEXT PRIMARY KEY, + provider TEXT NOT NULL, + matched INTEGER NOT NULL, + fetched_at INTEGER NOT NULL, + remote_id TEXT, + name TEXT, + original_name TEXT, + overview TEXT, + year INTEGER, + kind TEXT, + poster_url TEXT, + backdrop_url TEXT, + rating REAL, + genres TEXT, + episodes INTEGER, + url TEXT +); +"#; + +/// What a provider said about the individual episodes of a title. +/// +/// Separate from `title_metadata` rather than a JSON blob in it, because this +/// is looked up per row while a season is drawn — once per episode, hundreds of +/// times a scroll — and because a title's answer and its episode list are +/// fetched on different requests and can each exist without the other. +/// +/// `season = -1` stands for the provider numbering episodes straight through +/// rather than by season; SQLite has no nullable primary-key column that would +/// compare equal, and a sentinel here is cheaper than a second index. +const MIGRATION_V4: &str = r#" +CREATE TABLE episode_metadata ( + title_key TEXT NOT NULL, + season INTEGER NOT NULL, + number INTEGER NOT NULL, + provider TEXT NOT NULL, + fetched_at INTEGER NOT NULL, + name TEXT, + overview TEXT, + still_url TEXT, + air_date TEXT, + PRIMARY KEY (title_key, season, number) +); +"#; + +/// What `season = -1` means in `episode_metadata`: no season at all. +const ABSOLUTE_SEASON: i64 = -1; + +/// One catalog row: a file or folder inside a share. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CatalogNode { + pub share_id: String, + pub link_id: String, + pub volume_id: String, + pub parent_link_id: Option<String>, + pub name: String, + pub is_folder: bool, + pub media_type: Option<String>, + /// Plaintext size as claimed by the uploader, when the share reports one. + pub size: Option<i64>, + /// The revision the row describes. This is the cache-validity key: it + /// advances if and only if a new revision was sealed, which `(mtime, size)` + /// only approximates. + pub active_revision_id: Option<String>, + /// What [`naming::parse`] made of the file name. + pub parsed: ParsedName, +} + +/// Where playback left off. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct WatchState { + pub position_secs: f64, + pub duration_secs: Option<f64>, + pub watched: bool, + pub updated_at: i64, +} + +/// The catalog database. +pub struct Catalog { + conn: Connection, +} + +impl Catalog { + /// Open (or create) the catalog at `path`. + pub fn open(path: &std::path::Path) -> Result<Self> { + let conn = Connection::open(path)?; + Self::from_connection(conn) + } + + /// An in-memory catalog, for tests. + pub fn in_memory() -> Result<Self> { + Self::from_connection(Connection::open_in_memory()?) + } + + fn from_connection(conn: Connection) -> Result<Self> { + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + + let mut catalog = Self { conn }; + catalog.migrate()?; + Ok(catalog) + } + + fn migrate(&mut self) -> Result<()> { + let version: i64 = self + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0))?; + + if version > SCHEMA_VERSION { + // Refuse rather than guess. A newer build wrote this; reinterpreting + // it under older rules would silently mangle watch state. + return Err(crate::error::Error::Config(format!( + "catalog schema is version {version}, newer than this build supports \ + ({SCHEMA_VERSION}); upgrade proton-stream or delete the catalog" + ))); + } + + if version < 1 { + let tx = self.conn.transaction()?; + tx.execute_batch(MIGRATION_V1)?; + tx.pragma_update(None, "user_version", 1)?; + tx.commit()?; + } + + if version < 2 { + let tx = self.conn.transaction()?; + tx.execute_batch(MIGRATION_V2)?; + tx.pragma_update(None, "user_version", 2)?; + tx.commit()?; + } + + if version < 3 { + let tx = self.conn.transaction()?; + tx.execute_batch(MIGRATION_V3)?; + tx.pragma_update(None, "user_version", 3)?; + tx.commit()?; + } + + if version < 4 { + let tx = self.conn.transaction()?; + tx.execute_batch(MIGRATION_V4)?; + tx.pragma_update(None, "user_version", 4)?; + tx.commit()?; + } + + Ok(()) + } + + /// Replace everything known about one share with `nodes`. + /// + /// Done as one transaction so a failed crawl leaves the previous catalog + /// intact rather than a half-replaced one. Watch state is keyed separately + /// and deliberately untouched — a recrawl must not lose where you were. + pub fn replace_share(&mut self, share_id: &str, nodes: &[CatalogNode]) -> Result<()> { + let tx = self.conn.transaction()?; + tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; + + { + let mut insert = tx.prepare( + "INSERT INTO nodes ( + share_id, link_id, volume_id, parent_link_id, name, is_folder, + media_type, size, active_revision_id, title, season, episode, year, + episode_title + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + )?; + for node in nodes { + insert.execute(params![ + node.share_id, + node.link_id, + node.volume_id, + node.parent_link_id, + node.name, + node.is_folder as i64, + node.media_type, + node.size, + node.active_revision_id, + node.parsed.title, + node.parsed.season, + node.parsed.episode, + node.parsed.year, + node.parsed.episode_title, + ])?; + } + } + + tx.commit()?; + Ok(()) + } + + /// Every file in a share, folders excluded, ordered for display. + pub fn files(&self, share_id: &str) -> Result<Vec<CatalogNode>> { + let mut statement = self.conn.prepare( + "SELECT share_id, link_id, volume_id, parent_link_id, name, is_folder, + media_type, size, active_revision_id, title, season, episode, year, + episode_title + FROM nodes + WHERE share_id = ?1 AND is_folder = 0 + ORDER BY title, season, episode, name", + )?; + let rows = statement.query_map(params![share_id], row_to_node)?; + Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?) + } + + /// Every file across every share, grouped by parsed title. + pub fn all_files(&self) -> Result<Vec<CatalogNode>> { + let mut statement = self.conn.prepare( + "SELECT share_id, link_id, volume_id, parent_link_id, name, is_folder, + media_type, size, active_revision_id, title, season, episode, year, + episode_title + FROM nodes + WHERE is_folder = 0 + ORDER BY title, season, episode, name", + )?; + let rows = statement.query_map([], row_to_node)?; + Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?) + } + + /// Drop everything a share contributed, watch positions included. + /// + /// Unlike a recrawl — which replaces rows and deliberately leaves watch + /// state alone — this is the viewer saying they are done with the share. + /// Keeping positions for files they can no longer reach would only leave + /// the "continue watching" row pointing at nothing. + pub fn remove_share(&self, share_id: &str) -> Result<()> { + self.conn + .execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; + self.conn.execute( + "DELETE FROM watch_state WHERE share_id = ?1", + params![share_id], + )?; + Ok(()) + } + + /// Every watch position there is, keyed by `(share_id, link_id)`. + /// + /// What [`crate::library::Library::build`] joins against. A grid that asked + /// per tile would pay a round-trip per poster on every render. + pub fn all_watch_states(&self) -> Result<HashMap<(String, String), WatchState>> { + let mut statement = self.conn.prepare( + "SELECT share_id, link_id, position_secs, duration_secs, watched, updated_at + FROM watch_state", + )?; + let rows = statement.query_map([], |row| { + Ok(( + (row.get(0)?, row.get(1)?), + WatchState { + position_secs: row.get(2)?, + duration_secs: row.get(3)?, + watched: row.get::<_, i64>(4)? != 0, + updated_at: row.get(5)?, + }, + )) + })?; + Ok(rows.collect::<std::result::Result<HashMap<_, _>, _>>()?) + } + + /// Record where playback is. + pub fn set_watch_state(&self, share_id: &str, link_id: &str, state: &WatchState) -> Result<()> { + self.conn.execute( + "INSERT INTO watch_state (share_id, link_id, position_secs, duration_secs, watched, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT (share_id, link_id) DO UPDATE SET + position_secs = excluded.position_secs, + duration_secs = excluded.duration_secs, + watched = excluded.watched, + updated_at = excluded.updated_at", + params![ + share_id, + link_id, + state.position_secs, + state.duration_secs, + state.watched as i64, + state.updated_at, + ], + )?; + Ok(()) + } + + /// Where playback left off, if it ever started. + pub fn watch_state(&self, share_id: &str, link_id: &str) -> Result<Option<WatchState>> { + let state = self + .conn + .query_row( + "SELECT position_secs, duration_secs, watched, updated_at + FROM watch_state WHERE share_id = ?1 AND link_id = ?2", + params![share_id, link_id], + |row| { + Ok(WatchState { + position_secs: row.get(0)?, + duration_secs: row.get(1)?, + watched: row.get::<_, i64>(2)? != 0, + updated_at: row.get(3)?, + }) + }, + ) + .optional()?; + Ok(state) + } + + // -------------------------------------------------------------- metadata + + /// Every stored provider answer, keyed by title key. + /// + /// Read whole, like watch state and for the same reason: the poster wall + /// wants all of it at once, and asking per tile is a round-trip per tile. + /// Rows naming a provider this build does not know are dropped rather than + /// failing the read — the title simply looks unmatched and gets re-asked. + pub fn all_metadata(&self) -> Result<HashMap<String, MetadataRecord>> { + let mut statement = self.conn.prepare( + "SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name, + overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url + FROM title_metadata", + )?; + let rows = statement.query_map([], row_to_metadata)?; + + let mut records = HashMap::new(); + for row in rows { + if let Some(record) = row? { + records.insert(record.title_key.clone(), record); + } + } + Ok(records) + } + + /// One title's stored answer. + pub fn metadata(&self, title_key: &str) -> Result<Option<MetadataRecord>> { + let record = self + .conn + .query_row( + "SELECT title_key, provider, matched, fetched_at, remote_id, name, original_name, + overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url + FROM title_metadata WHERE title_key = ?1", + params![title_key], + row_to_metadata, + ) + .optional()?; + Ok(record.flatten()) + } + + /// Store what a provider said, replacing whatever was there. + /// + /// A miss is stored too — see [`MetadataRecord::metadata`]. Storing only + /// matches would leave every unmatched title asking again on every render. + pub fn set_metadata(&self, record: &MetadataRecord) -> Result<()> { + let data = record.metadata.as_ref(); + self.conn.execute( + "INSERT INTO title_metadata ( + title_key, provider, matched, fetched_at, remote_id, name, original_name, + overview, year, kind, poster_url, backdrop_url, rating, genres, episodes, url + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16) + ON CONFLICT (title_key) DO UPDATE SET + provider = excluded.provider, + matched = excluded.matched, + fetched_at = excluded.fetched_at, + remote_id = excluded.remote_id, + name = excluded.name, + original_name = excluded.original_name, + overview = excluded.overview, + year = excluded.year, + kind = excluded.kind, + poster_url = excluded.poster_url, + backdrop_url = excluded.backdrop_url, + rating = excluded.rating, + genres = excluded.genres, + episodes = excluded.episodes, + url = excluded.url", + params![ + record.title_key, + record.provider.as_str(), + data.is_some() as i64, + record.fetched_at, + data.map(|data| data.remote_id.as_str()), + data.map(|data| data.name.as_str()), + data.and_then(|data| data.original_name.as_deref()), + data.and_then(|data| data.overview.as_deref()), + data.and_then(|data| data.year), + data.map(|data| kind_name(data.kind)), + data.and_then(|data| data.poster_url.as_deref()), + data.and_then(|data| data.backdrop_url.as_deref()), + data.and_then(|data| data.rating), + data.map(|data| data.genres.join("\n")), + data.and_then(|data| data.episodes), + data.and_then(|data| data.url.as_deref()), + ], + )?; + Ok(()) + } + + /// Forget every stored answer. + /// + /// What "match again" and "turn enrichment off" both do. Deliberately not + /// called on a recrawl: the answers are about titles, not about shares. + pub fn clear_metadata(&self) -> Result<()> { + self.conn.execute("DELETE FROM title_metadata", [])?; + self.conn.execute("DELETE FROM episode_metadata", [])?; + Ok(()) + } + + /// Replace everything stored about one title's episodes. + /// + /// A replace rather than an upsert per row: a provider that has *dropped* + /// an episode — a special that was recounted, a season renumbered — should + /// leave nothing behind, and an episode list is small enough that the + /// difference is a few hundred microseconds. + pub fn set_episode_metadata( + &mut self, + title_key: &str, + provider: ProviderId, + fetched_at: i64, + episodes: &[EpisodeMetadata], + ) -> Result<()> { + let tx = self.conn.transaction()?; + tx.execute( + "DELETE FROM episode_metadata WHERE title_key = ?1", + params![title_key], + )?; + { + let mut insert = tx.prepare( + "INSERT INTO episode_metadata ( + title_key, season, number, provider, fetched_at, + name, overview, still_url, air_date + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + )?; + for episode in episodes { + insert.execute(params![ + title_key, + episode.season.map_or(ABSOLUTE_SEASON, i64::from), + episode.number, + provider.as_str(), + fetched_at, + episode.name.as_deref(), + episode.overview.as_deref(), + episode.still_url.as_deref(), + episode.air_date.as_deref(), + ])?; + } + } + tx.commit()?; + Ok(()) + } + + /// Every episode answer there is, keyed by title. + /// + /// Read whole, for the same reason watch state and title metadata are: a + /// season drawn a row at a time must not be a SQLite round-trip per row. + pub fn all_episode_metadata(&self) -> Result<HashMap<String, EpisodeGuide>> { + let mut statement = self.conn.prepare( + "SELECT title_key, season, number, name, overview, still_url, air_date + FROM episode_metadata", + )?; + let rows = statement.query_map([], |row| { + let title_key: String = row.get(0)?; + let season: i64 = row.get(1)?; + Ok(( + title_key, + EpisodeMetadata { + season: (season != ABSOLUTE_SEASON).then_some(season as u32), + number: row.get::<_, i64>(2)? as u32, + name: row.get(3)?, + overview: row.get(4)?, + still_url: row.get(5)?, + air_date: row.get(6)?, + }, + )) + })?; + + let mut by_title: HashMap<String, Vec<EpisodeMetadata>> = HashMap::new(); + for row in rows { + let (title_key, episode) = row?; + by_title.entry(title_key).or_default().push(episode); + } + Ok(by_title + .into_iter() + .map(|(title_key, episodes)| (title_key, EpisodeGuide::new(episodes))) + .collect()) + } + + /// Which titles already have an episode list stored, and how fresh. + /// + /// Enough to decide whether to ask again without reading every row: the + /// answer is only ever compared against a TTL. + pub fn episode_metadata_ages(&self) -> Result<HashMap<String, (ProviderId, i64)>> { + let mut statement = self.conn.prepare( + "SELECT title_key, provider, MIN(fetched_at) FROM episode_metadata GROUP BY title_key", + )?; + let rows = statement.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + })?; + + let mut ages = HashMap::new(); + for row in rows { + let (title_key, provider, fetched_at) = row?; + if let Some(provider) = ProviderId::parse(&provider) { + ages.insert(title_key, (provider, fetched_at)); + } + } + Ok(ages) + } +} + +/// `None` for a row whose provider column this build does not recognise. +fn row_to_metadata(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<MetadataRecord>> { + let title_key: String = row.get(0)?; + let Some(provider) = ProviderId::parse(&row.get::<_, String>(1)?) else { + return Ok(None); + }; + let matched: i64 = row.get(2)?; + let fetched_at: i64 = row.get(3)?; + + let metadata = if matched == 0 { + None + } else { + let genres: Option<String> = row.get(13)?; + Some(TitleMetadata { + provider, + remote_id: row.get::<_, Option<String>>(4)?.unwrap_or_default(), + name: row.get::<_, Option<String>>(5)?.unwrap_or_default(), + original_name: row.get(6)?, + overview: row.get(7)?, + year: row.get(8)?, + kind: parse_kind(row.get::<_, Option<String>>(9)?.as_deref()), + poster_url: row.get(10)?, + backdrop_url: row.get(11)?, + rating: row.get(12)?, + genres: genres + .unwrap_or_default() + .lines() + .filter(|genre| !genre.is_empty()) + .map(str::to_string) + .collect(), + episodes: row.get(14)?, + url: row.get(15)?, + }) + }; + + Ok(Some(MetadataRecord { + title_key, + provider, + metadata, + fetched_at, + })) +} + +fn kind_name(kind: TitleKind) -> &'static str { + match kind { + TitleKind::Series => "series", + TitleKind::Film => "film", + } +} + +/// Anything but `series` reads as a film, including a value from a newer build. +/// The kind only chooses a badge; guessing wrong is a cosmetic error, and a +/// hard failure here would take the whole grid down with it. +fn parse_kind(text: Option<&str>) -> TitleKind { + match text { + Some("series") => TitleKind::Series, + _ => TitleKind::Film, + } +} + +fn row_to_node(row: &rusqlite::Row<'_>) -> rusqlite::Result<CatalogNode> { + Ok(CatalogNode { + share_id: row.get(0)?, + link_id: row.get(1)?, + volume_id: row.get(2)?, + parent_link_id: row.get(3)?, + name: row.get(4)?, + is_folder: row.get::<_, i64>(5)? != 0, + media_type: row.get(6)?, + size: row.get(7)?, + active_revision_id: row.get(8)?, + parsed: ParsedName { + title: row.get(9)?, + season: row.get(10)?, + episode: row.get(11)?, + year: row.get(12)?, + episode_title: row.get(13)?, + }, + }) +} + +/// Build a catalog row from a crawled [`Node`](proton_drive_rs::Node), from the +/// filename alone. +/// +/// [`build_rows`] is what callers want: a filename in isolation usually does not +/// say what series it belongs to. +pub fn from_node(share_id: &str, node: &proton_drive_rs::Node) -> CatalogNode { + use proton_drive_rs::NodeKind; + + let (is_folder, media_type, size, active_revision_id) = match &node.kind { + NodeKind::Folder => (true, None, None, None), + NodeKind::File { + media_type, + claimed_size, + active_revision_id, + .. + } => ( + false, + Some(media_type.clone()), + *claimed_size, + active_revision_id.clone(), + ), + }; + + CatalogNode { + share_id: share_id.to_string(), + link_id: node.uid.link_id.to_string(), + volume_id: node.uid.volume_id.to_string(), + parent_link_id: node.parent_uid.as_ref().map(|uid| uid.link_id.to_string()), + name: node.name.clone(), + is_folder, + media_type, + size, + active_revision_id, + parsed: naming::parse(&node.name), + } +} + +/// Turn a whole crawl into catalog rows, reading each file's title from its +/// **folder path** rather than from its name alone. +/// +/// This is the difference between a browsable catalog and an unusable one. A +/// real library is laid out as +/// +/// ```text +/// Sousou no Frieren/Season 01/S01E01.mkv +/// Neon Genesis Evangelion/Movies/[Anime Time] … - Death & Rebirth-004.mkv +/// Fullmetal Alchemist Brotherhood/OVA/[Reaktor] … OVA - E1 v2 [1080p].mkv +/// ``` +/// +/// — where `S01E01.mkv` states no title at all, and the two below it state one +/// that is really the *episode's* name. So: +/// +/// - **the folder names the series**, because that is how the user organised it; +/// - **the filename names the numbering**, falling back to a `Season NN` folder; +/// - grouping folders (`Movies`, `OVA`, `Specials`) are stepped over, so their +/// contents group under the series above them; +/// - whatever the filename claimed as a title, when it differs, is kept as the +/// episode's own name — with the redundant series prefix stripped. +/// +/// Folders and non-video files are dropped: a share holds `.nfo` and `.jpg` +/// alongside the video, and a catalog that offers those as episodes is worse +/// than one that omits an exotic container. +pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec<CatalogNode> { + use std::collections::HashMap; + + let by_link: HashMap<&str, &proton_drive_rs::Node> = nodes + .iter() + .map(|node| (node.uid.link_id.as_str(), node)) + .collect(); + + nodes + .iter() + .filter(|node| !node.is_folder() && naming::is_video_file(&node.name)) + .map(|node| { + let mut row = from_node(share_id, node); + let context = ancestry(node, &by_link); + + if let Some(title) = context.title { + // The filename's own title, if it had one and it differs, is the + // episode's name rather than the series'. + if row.parsed.episode_title.is_none() + && !row.parsed.title.is_empty() + && row.parsed.title != title + { + let rest = strip_prefix_title(&row.parsed.title, &title); + // …unless what is left is a season marker. `Oshi no Ko 3rd + // Season - 07.mkv` under a folder called `Oshi no Ko` leaves + // `3rd Season`, and calling every episode of season three + // that is worse than calling none of them anything. + if naming::is_season_marker(&rest) { + row.parsed.season = row.parsed.season.or(naming::parse(&rest).season); + } else { + row.parsed.episode_title = Some(rest); + } + } + row.parsed.title = title; + } + if row.parsed.season.is_none() { + row.parsed.season = context.season; + } + if row.parsed.year.is_none() { + row.parsed.year = context.year; + } + row + }) + .collect() +} + +/// What a file's ancestors say about it. +#[derive(Debug, Default)] +struct Ancestry { + title: Option<String>, + season: Option<u32>, + year: Option<u32>, +} + +/// Walk up from `node`, taking the nearest season and the nearest naming folder. +/// +/// The share root is skipped: it is the folder that was shared (`anime`), and +/// using it as a title would label every top-level file identically. +fn ancestry( + node: &proton_drive_rs::Node, + by_link: &std::collections::HashMap<&str, &proton_drive_rs::Node>, +) -> Ancestry { + use crate::naming::FolderRole; + + let mut found = Ancestry::default(); + let mut cursor = node.parent_uid.as_ref().map(|uid| uid.link_id.to_string()); + + while let Some(link_id) = cursor { + let Some(ancestor) = by_link.get(link_id.as_str()) else { + // Off the top of the share: the parent is outside what we can see, + // so this was the root. + break; + }; + // The root itself names the share, not a series. + if ancestor.parent_uid.is_none() + || !by_link.contains_key( + ancestor + .parent_uid + .as_ref() + .map(|uid| uid.link_id.as_str()) + .unwrap_or_default(), + ) + { + break; + } + + match crate::naming::classify_folder(&ancestor.name) { + FolderRole::Season(season) => found.season = found.season.or(Some(season)), + FolderRole::Container => {} + FolderRole::Title(parsed) => { + if found.title.is_none() && !parsed.title.is_empty() { + found.title = Some(parsed.title.clone()); + found.year = found.year.or(parsed.year); + // `Game of Thrones - Season 1` names both; the season it + // states belongs to the files under it. + found.season = found.season.or(parsed.season); + } + } + } + + cursor = ancestor + .parent_uid + .as_ref() + .map(|uid| uid.link_id.to_string()); + } + + found +} + +/// Drop a redundant series prefix from an episode name. +/// +/// `Neon Genesis Evangelion - Death & Rebirth` under a folder called +/// `Neon Genesis Evangelion` should read as `Death & Rebirth`. +fn strip_prefix_title(episode: &str, series: &str) -> String { + let stripped = episode + .strip_prefix(series) + .map(|rest| rest.trim_start_matches([' ', '-', '_', '.', ':'])) + .unwrap_or(episode) + .trim(); + + if stripped.is_empty() { + episode.to_string() + } else { + stripped.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file(share: &str, link: &str, name: &str) -> CatalogNode { + CatalogNode { + share_id: share.into(), + link_id: link.into(), + volume_id: "vol".into(), + parent_link_id: Some("root".into()), + name: name.into(), + is_folder: false, + media_type: Some("video/x-matroska".into()), + size: Some(1 << 30), + active_revision_id: Some("rev-1".into()), + parsed: naming::parse(name), + } + } + + fn found() -> TitleMetadata { + TitleMetadata { + provider: ProviderId::AniList, + remote_id: "16498".into(), + name: "Attack on Titan".into(), + original_name: Some("進撃の巨人".into()), + overview: Some("Several hundred years ago…".into()), + year: Some(2013), + kind: TitleKind::Series, + poster_url: Some("https://example.invalid/p.jpg".into()), + backdrop_url: None, + rating: Some(8.4), + genres: vec!["Action".into(), "Drama".into()], + episodes: Some(25), + url: Some("https://anilist.co/anime/16498".into()), + } + } + + #[test] + fn metadata_round_trips_through_the_catalog() { + let catalog = Catalog::in_memory().expect("open"); + let record = MetadataRecord { + title_key: "attack on titan".into(), + provider: ProviderId::AniList, + metadata: Some(found()), + fetched_at: 1_700_000_000, + }; + + catalog.set_metadata(&record).expect("store"); + assert_eq!( + catalog.metadata("attack on titan").expect("read"), + Some(record.clone()) + ); + assert_eq!(catalog.all_metadata().expect("read all").len(), 1); + + // Second write is an update, not a duplicate row. + catalog.set_metadata(&record).expect("store again"); + assert_eq!(catalog.all_metadata().expect("read all").len(), 1); + } + + /// The negative answer is the one that has to survive a round trip: without + /// it every render re-asks about every unmatched title. + #[test] + fn a_stored_miss_reads_back_as_a_miss_and_not_as_absence() { + let catalog = Catalog::in_memory().expect("open"); + let record = MetadataRecord { + title_key: "something obscure".into(), + provider: ProviderId::AniList, + metadata: None, + fetched_at: 1_700_000_000, + }; + catalog.set_metadata(&record).expect("store"); + + let read = catalog.metadata("something obscure").expect("read"); + assert_eq!(read, Some(record)); + assert!( + catalog + .metadata("never asked about") + .expect("read") + .is_none() + ); + } + + /// A row written by a build that knows a provider this one does not must + /// read as "unmatched", never as a failure that takes the whole grid down. + #[test] + fn a_row_from_an_unknown_provider_is_skipped_rather_than_fatal() { + let catalog = Catalog::in_memory().expect("open"); + catalog + .conn + .execute( + "INSERT INTO title_metadata (title_key, provider, matched, fetched_at, name) + VALUES ('x', 'letterboxd', 1, 1, 'Something')", + [], + ) + .expect("insert"); + + assert!(catalog.all_metadata().expect("read all").is_empty()); + assert!(catalog.metadata("x").expect("read").is_none()); + } + + /// Metadata is about titles, not shares — a recrawl replaces node rows and + /// must leave the answers alone, exactly as it does watch state. + #[test] + fn replacing_a_share_does_not_drop_stored_metadata() { + let mut catalog = Catalog::in_memory().expect("open"); + catalog + .set_metadata(&MetadataRecord { + title_key: "attack on titan".into(), + provider: ProviderId::AniList, + metadata: Some(found()), + fetched_at: 1, + }) + .expect("store"); + + catalog + .replace_share( + "share-a", + &[file("share-a", "l1", "Attack on Titan S01E01.mkv")], + ) + .expect("replace"); + + assert_eq!(catalog.all_metadata().expect("read all").len(), 1); + catalog.clear_metadata().expect("clear"); + assert!(catalog.all_metadata().expect("read all").is_empty()); + } + + #[test] + fn episode_answers_round_trip_and_replace_rather_than_accumulate() { + let mut catalog = Catalog::in_memory().expect("open"); + let episodes = vec![ + EpisodeMetadata { + season: None, + number: 57, + name: Some("The Immortal Legion".into()), + overview: Some("Ed and Al…".into()), + still_url: None, + air_date: Some("2010-11-14".into()), + }, + EpisodeMetadata { + season: Some(1), + number: 1, + name: Some("Fullmetal Alchemist".into()), + overview: None, + still_url: Some("still.jpg".into()), + air_date: None, + }, + ]; + catalog + .set_episode_metadata("fma", ProviderId::AniList, 100, &episodes) + .expect("store"); + + let stored = catalog.all_episode_metadata().expect("read"); + let guide = stored.get("fma").expect("a guide for the title"); + assert_eq!(guide.len(), 2); + assert_eq!( + guide.get(None, 57).and_then(|e| e.name.clone()), + Some("The Immortal Legion".to_string()) + ); + // The absolute/season distinction has to survive the round trip, or an + // absolutely-numbered file stops matching after a restart. + assert_eq!(guide.get(None, 57).map(|e| e.season), Some(None)); + assert_eq!(guide.get(Some(1), 1).map(|e| e.season), Some(Some(1))); + + // A shorter list replaces rather than merges: an episode the provider + // has dropped must not linger. + catalog + .set_episode_metadata("fma", ProviderId::AniList, 200, &episodes[..1]) + .expect("store again"); + let stored = catalog.all_episode_metadata().expect("read"); + assert_eq!(stored["fma"].len(), 1); + + let ages = catalog.episode_metadata_ages().expect("ages"); + assert_eq!(ages["fma"], (ProviderId::AniList, 200)); + } + + #[test] + fn clearing_metadata_takes_the_episode_answers_with_it() { + let mut catalog = Catalog::in_memory().expect("open"); + catalog + .set_episode_metadata( + "fma", + ProviderId::AniList, + 100, + &[EpisodeMetadata { + season: None, + number: 1, + name: Some("Fullmetal Alchemist".into()), + overview: None, + still_url: None, + air_date: None, + }], + ) + .expect("store"); + catalog.clear_metadata().expect("clear"); + assert!(catalog.all_episode_metadata().expect("read").is_empty()); + } + + #[test] + fn a_fresh_catalog_is_at_the_current_schema_version() { + let catalog = Catalog::in_memory().expect("open"); + let version: i64 = catalog + .conn + .query_row("PRAGMA user_version", [], |row| row.get(0)) + .expect("read version"); + assert_eq!(version, SCHEMA_VERSION); + } + + /// A newer schema is refused, not downgraded — reinterpreting it would + /// silently mangle whatever the newer build recorded. + #[test] + fn a_newer_schema_refuses_to_open() { + let conn = Connection::open_in_memory().expect("open"); + conn.pragma_update(None, "user_version", SCHEMA_VERSION + 1) + .expect("set version"); + + let error = match Catalog::from_connection(conn) { + Err(error) => error, + Ok(_) => panic!("a newer schema must refuse to open"), + }; + assert!( + error.to_string().contains("newer than this build supports"), + "says why: {error}" + ); + } + + #[test] + fn nodes_round_trip_through_a_share_replace() { + let mut catalog = Catalog::in_memory().expect("open"); + let nodes = vec![ + file("s1", "a", "Show.Name.S01E01.1080p.mkv"), + file("s1", "b", "Show.Name.S01E02.1080p.mkv"), + ]; + catalog.replace_share("s1", &nodes).expect("replace"); + + let read = catalog.files("s1").expect("read"); + assert_eq!(read.len(), 2); + assert_eq!(read[0].parsed.title, "Show Name"); + assert_eq!(read[0].parsed.episode, Some(1)); + assert_eq!(read[1].parsed.episode, Some(2)); + } + + /// A recrawl replaces the share's rows wholesale, so files deleted upstream + /// disappear rather than lingering as unplayable ghosts. + #[test] + fn replacing_a_share_drops_rows_that_are_no_longer_there() { + let mut catalog = Catalog::in_memory().expect("open"); + catalog + .replace_share( + "s1", + &[ + file("s1", "a", "One.S01E01.mkv"), + file("s1", "b", "One.S01E02.mkv"), + ], + ) + .expect("first crawl"); + catalog + .replace_share("s1", &[file("s1", "a", "One.S01E01.mkv")]) + .expect("second crawl"); + + let read = catalog.files("s1").expect("read"); + assert_eq!(read.len(), 1); + assert_eq!(read[0].link_id, "a"); + } + + /// One share's crawl must not touch another's rows. + #[test] + fn replacing_one_share_leaves_the_others_alone() { + let mut catalog = Catalog::in_memory().expect("open"); + catalog + .replace_share("s1", &[file("s1", "a", "One.S01E01.mkv")]) + .expect("s1"); + catalog + .replace_share("s2", &[file("s2", "c", "Two.S01E01.mkv")]) + .expect("s2"); + catalog.replace_share("s1", &[]).expect("s1 emptied"); + + assert!(catalog.files("s1").expect("read s1").is_empty()); + assert_eq!(catalog.files("s2").expect("read s2").len(), 1); + } + + /// **The one thing here that is not rebuildable.** A recrawl must not lose + /// where you were in an episode. + #[test] + fn a_recrawl_preserves_watch_state() { + let mut catalog = Catalog::in_memory().expect("open"); + catalog + .replace_share("s1", &[file("s1", "a", "One.S01E01.mkv")]) + .expect("crawl"); + + let state = WatchState { + position_secs: 942.5, + duration_secs: Some(1440.0), + watched: false, + updated_at: 1_700_000_000, + }; + catalog.set_watch_state("s1", "a", &state).expect("set"); + + catalog + .replace_share("s1", &[file("s1", "a", "One.S01E01.mkv")]) + .expect("recrawl"); + + assert_eq!( + catalog.watch_state("s1", "a").expect("read"), + Some(state), + "the recrawl must not have cleared the resume position" + ); + } + + /// Resuming overwrites rather than accumulating rows. + #[test] + fn watch_state_updates_in_place() { + let catalog = Catalog::in_memory().expect("open"); + for position in [10.0, 200.0, 3000.0] { + catalog + .set_watch_state( + "s1", + "a", + &WatchState { + position_secs: position, + duration_secs: Some(3600.0), + watched: false, + updated_at: 1, + }, + ) + .expect("set"); + } + let state = catalog.watch_state("s1", "a").expect("read").expect("some"); + assert_eq!(state.position_secs, 3000.0); + } + + // ---- build_rows: titles from the folder path ------------------------- + + use proton_drive_rs::{Node, NodeKind}; + use proton_sdk::ids::NodeUid; + + fn uid(link: &str) -> NodeUid { + NodeUid::new("vol".into(), link.into()) + } + + fn folder(link: &str, parent: Option<&str>, name: &str) -> Node { + Node { + uid: uid(link), + parent_uid: parent.map(uid), + kind: NodeKind::Folder, + name: name.into(), + creation_time: 0, + modification_time: 0, + trashed: false, + is_shared: true, + is_shared_publicly: true, + signature_email: None, + membership: None, + photo: None, + album: None, + verification: Default::default(), + } + } + + fn video(link: &str, parent: &str, name: &str) -> Node { + Node { + kind: NodeKind::File { + media_type: "video/x-matroska".into(), + total_size_on_storage: 0, + active_revision_state: None, + active_revision_id: Some("rev".into()), + claimed_size: Some(1 << 30), + claimed_modification_time: None, + content_sha1: None, + }, + ..folder(link, Some(parent), name) + } + } + + /// The share root, whose parent is outside what a visitor can see. + fn root() -> Node { + folder("root", Some("outside-the-share"), "anime") + } + + fn row<'a>(rows: &'a [CatalogNode], link: &str) -> &'a CatalogNode { + rows.iter() + .find(|row| row.link_id == link) + .unwrap_or_else(|| panic!("no row for {link}")) + } + + /// **The layout nearly every real library uses.** `S01E01.mkv` names no + /// series at all; without walking the folder path the whole catalog would be + /// untitled. + #[test] + fn a_files_title_comes_from_its_series_folder() { + let nodes = vec![ + root(), + folder("frieren", Some("root"), "Sousou no Frieren"), + folder("s1", Some("frieren"), "Season 01"), + video("ep1", "s1", "S01E01.mkv"), + ]; + let rows = build_rows("share", &nodes); + + assert_eq!(rows.len(), 1, "folders are not catalog entries"); + let episode = row(&rows, "ep1"); + assert_eq!(episode.parsed.title, "Sousou no Frieren"); + assert_eq!(episode.parsed.season, Some(1)); + assert_eq!(episode.parsed.episode, Some(1)); + } + + /// A season folder supplies the season when the filename states only an + /// episode marker. + #[test] + fn a_season_folder_supplies_the_season() { + let nodes = vec![ + root(), + folder("kaijuu", Some("root"), "Kaijuu 8-gou"), + folder("s2", Some("kaijuu"), "Season 02"), + video("e4", "s2", "E04.mkv"), + ]; + let episode = build_rows("share", &nodes); + let episode = row(&episode, "e4"); + assert_eq!(episode.parsed.title, "Kaijuu 8-gou"); + assert_eq!(episode.parsed.season, Some(2), "from the folder"); + assert_eq!(episode.parsed.episode, Some(4), "from the filename"); + } + + /// A grouping folder is stepped over, so its contents group under the series + /// above it rather than under a folder called `Movies`. + #[test] + fn a_grouping_folder_is_stepped_over_to_reach_the_series() { + let nodes = vec![ + root(), + folder("eva", Some("root"), "Neon Genesis Evangelion"), + folder("movies", Some("eva"), "Movies"), + video( + "film", + "movies", + "[Anime Time] Neon Genesis Evangelion - The End of Evangelion-003.mkv", + ), + ]; + let film = build_rows("share", &nodes); + let film = row(&film, "film"); + assert_eq!(film.parsed.title, "Neon Genesis Evangelion"); + assert_eq!( + film.parsed.episode_title.as_deref(), + Some("The End of Evangelion"), + "the filename's own title is the film's name, minus the series prefix" + ); + } + + /// **What every episode of Oshi no Ko season three was called.** The + /// filename states the season in words, the folder states the series, and + /// the difference between the two is a season marker — not the name of the + /// episode. + #[test] + fn a_season_stated_in_the_filename_does_not_become_the_episodes_name() { + let nodes = vec![ + root(), + folder("onk", Some("root"), "Oshi no Ko"), + video( + "e7", + "onk", + "[DB]Oshi no Ko 3rd Season_-_07_(Dual Audio_10bit_1080p_x265).mkv", + ), + ]; + let rows = build_rows("share", &nodes); + let episode = row(&rows, "e7"); + + assert_eq!(episode.parsed.title, "Oshi no Ko"); + assert_eq!(episode.parsed.season, Some(3)); + assert_eq!(episode.parsed.episode, Some(7)); + assert_eq!(episode.parsed.episode_title, None); + } + + /// The deepest naming folder wins, so films inside a collection folder are + /// titled individually rather than by the collection. + #[test] + fn the_deepest_naming_folder_wins() { + let nodes = vec![ + root(), + folder("kon", Some("root"), "Satoshi Kon Movies"), + folder("millennium", Some("kon"), "Millennium Actress"), + video("film", "millennium", "Millennium.Actress.1080p.mkv"), + ]; + let film = build_rows("share", &nodes); + assert_eq!(row(&film, "film").parsed.title, "Millennium Actress"); + } + + /// The share root names the *share*, not a series — using it would label + /// every top-level file identically. + #[test] + fn the_share_root_is_not_used_as_a_title() { + let nodes = vec![root(), video("loose", "root", "Some.Film.2019.mkv")]; + let film = build_rows("share", &nodes); + assert_eq!( + row(&film, "loose").parsed.title, + "Some Film", + "the filename's title stands, rather than the root folder's name" + ); + } + + /// A folder year reaches the files under it. + #[test] + fn a_year_in_the_folder_name_reaches_its_files() { + let nodes = vec![ + root(), + folder("gits", Some("root"), "Ghost in the Shell (1995)"), + video("film", "gits", "gits.bd.1080p.mkv"), + ]; + let film = build_rows("share", &nodes); + let film = row(&film, "film"); + assert_eq!(film.parsed.title, "Ghost in the Shell"); + assert_eq!(film.parsed.year, Some(1995)); + } + + /// Non-video files are dropped: a share holds `.nfo` and `.jpg` next to the + /// video, and offering those as episodes is worse than omitting them. + #[test] + fn non_video_files_do_not_enter_the_catalog() { + let nodes = vec![ + root(), + folder("gits", Some("root"), "Ghost in the Shell (1995)"), + video("film", "gits", "gits.mkv"), + video("info", "gits", "torrent.info.nfo"), + video("art", "gits", "poster.jpg"), + ]; + let rows = build_rows("share", &nodes); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].link_id, "film"); + } + + #[test] + fn an_unwatched_file_has_no_watch_state() { + let catalog = Catalog::in_memory().expect("open"); + assert_eq!( + catalog.watch_state("s1", "never-played").expect("read"), + None + ); + } +} diff --git a/crates/pstr-core/src/config.rs b/crates/pstr-core/src/config.rs new file mode 100644 index 0000000..ce8fedb --- /dev/null +++ b/crates/pstr-core/src/config.rs @@ -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(); + } +} diff --git a/crates/pstr-core/src/error.rs b/crates/pstr-core/src/error.rs new file mode 100644 index 0000000..d166652 --- /dev/null +++ b/crates/pstr-core/src/error.rs @@ -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>; diff --git a/crates/pstr-core/src/lib.rs b/crates/pstr-core/src/lib.rs new file mode 100644 index 0000000..ecb5edc --- /dev/null +++ b/crates/pstr-core/src/lib.rs @@ -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; diff --git a/crates/pstr-core/src/library.rs b/crates/pstr-core/src/library.rs new file mode 100644 index 0000000..b4e856c --- /dev/null +++ b/crates/pstr-core/src/library.rs @@ -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()); + } +} diff --git a/crates/pstr-core/src/metadata.rs b/crates/pstr-core/src/metadata.rs new file mode 100644 index 0000000..e784d2b --- /dev/null +++ b/crates/pstr-core/src/metadata.rs @@ -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)); + } +} diff --git a/crates/pstr-core/src/naming.rs b/crates/pstr-core/src/naming.rs new file mode 100644 index 0000000..76b60a6 --- /dev/null +++ b/crates/pstr-core/src/naming.rs @@ -0,0 +1,1213 @@ +//! Turning release filenames into titles, seasons and episodes. +//! +//! A pure function over a string, deliberately: no I/O, no state, no network. +//! That is what lets the whole thing be pinned by table-driven tests instead of +//! by squinting at a real library, which is the only way a parser this +//! heuristic stays honest as cases are added. +//! +//! Written rather than bound to `anitomy` because that is C++ and would poison +//! the cross-compilation story the app exists to have. + +/// What a filename claims to be. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedName { + /// The series or film title, cleaned of groups, tags and separators. + pub title: String, + /// Season number, when the name states one. Absent is *not* season 1 — a + /// film has no season, and an absolute-numbered anime episode states none + /// either, so the caller decides what to do about it. + pub season: Option<u32>, + /// Episode number, when the name states one. + pub episode: Option<u32>, + /// Release year, from a parenthesised or bracketed four-digit group. + pub year: Option<u32>, + /// The episode's own name, when the filename states one after the + /// numbering (`S01E01-Mother and Children`). Never the quality and codec + /// tags that follow numbering in scene releases — see [`episode_title`]. + pub episode_title: Option<String>, +} + +impl ParsedName { + /// Whether this looks like one episode of a series rather than a film. + pub fn is_episode(&self) -> bool { + self.episode.is_some() + } +} + +/// Parse a filename (with or without its extension) into a title and numbering. +/// +/// Never fails: an unrecognisable name yields its cleaned-up self as the title +/// with nothing else filled in. A catalog with a wrong title is recoverable; one +/// that drops files it could not parse is not. +pub fn parse(file_name: &str) -> ParsedName { + let stem = strip_extension(file_name); + + // Release-group prefixes (`[SubsPlease] …`) and trailing checksums + // (`… [A1B2C3D4]`) are noise in every convention that uses them, but a + // bracketed *year* is signal, so the year is read before they are dropped. + let bracketed = bracketed_year(stem); + let without_groups = strip_bracketed(stem); + + // A bare year is only looked for when there is no bracketed one, and only + // in the stripped text, so its offset lines up with the title span below. + let bare = bracketed + .is_none() + .then(|| bare_year(&without_groups)) + .flatten(); + let year = bracketed.or(bare.map(|(year, _)| year)); + + let found = find_numbering(&without_groups); + + // The title ends at the numbering, or — for a film, which has none — at the + // bare year, because everything past it is source and codec tags. + let title_end = found + .as_ref() + .map(|m| m.start) + .or(bare.map(|(_, at)| at)) + .unwrap_or(without_groups.len()); + let title = clean_title(&without_groups[..title_end], year); + + let episode_title = found + .as_ref() + .and_then(|m| episode_title(&without_groups[m.end..])); + + ParsedName { + title, + season: found.as_ref().and_then(|m| m.numbering.season), + episode: found.as_ref().and_then(|m| m.numbering.episode), + year, + episode_title, + } +} + +#[derive(Debug, Clone, Copy)] +struct Numbering { + season: Option<u32>, + episode: Option<u32>, +} + +/// Where the numbering was found, and how far it ran. +#[derive(Debug, Clone, Copy)] +struct NumberingMatch { + numbering: Numbering, + /// The title ends here. + start: usize, + /// Just past the numbering token; the episode name, if any, starts here. + end: usize, +} + +/// The episode's own name out of what followed the numbering. +/// +/// Only taken when a dash introduces it. Scene releases separate their quality, +/// source and codec tags with the same character as their title words (`.`), so +/// without that requirement `Show.Name.S02E05.1080p.WEB-DL.x265` would yield an +/// "episode title" of `1080p WEB-DL x265`. +fn episode_title(tail: &str) -> Option<String> { + let rest = tail.trim_start(); + let rest = rest + .strip_prefix('-') + .or_else(|| rest.strip_prefix('\u{2013}'))?; + + let cleaned = clean_title(rest, None); + let cleaned = cleaned.trim().to_string(); + if cleaned.is_empty() { + None + } else { + Some(cleaned) + } +} + +/// Drop a trailing extension, but only one that looks like an extension. +/// +/// A bare `rsplit('.')` would eat the tail of `Serial.Experiments.Lain`, so the +/// candidate has to be short and alphanumeric — which every container extension +/// is and no dot-separated title word reliably is. +fn strip_extension(name: &str) -> &str { + match name.rsplit_once('.') { + Some((stem, ext)) + if !ext.is_empty() + && ext.len() <= 4 + && ext.chars().all(|c| c.is_ascii_alphanumeric()) + && ext.chars().any(|c| c.is_ascii_alphabetic()) => + { + stem + } + _ => name, + } +} + +/// A four-digit year in brackets or parentheses, e.g. `Movie Title (2019)`. +/// +/// Bounded to plausible release years so a resolution (`1080`) or a checksum +/// that happens to be four digits cannot be mistaken for one. +/// `Movie Title (2019)` / `Movie Title [2019]`. +fn bracketed_year(text: &str) -> Option<u32> { + let bytes = text.as_bytes(); + for (index, window) in bytes.windows(6).enumerate() { + let opens = window[0] == b'(' || window[0] == b'['; + let closes = window[5] == b')' || window[5] == b']'; + // Checked on the *bytes*, before any slicing: a four-byte run of ASCII + // digits is four characters, so `text[index + 1..index + 5]` is only a + // valid slice once this holds. `Death (True)²-007.mkv` is the case that + // proves it — indexing blind there cuts inside the `²`. + if !opens || !closes || !window[1..5].iter().all(u8::is_ascii_digit) { + continue; + } + if let Some(year) = plausible_year(&text[index + 1..index + 5]) { + return Some(year); + } + } + None +} + +/// `Movie.Name.2019.1080p.BluRay.mkv` — the scene form for films, where the +/// year is a bare token rather than a bracketed one. +/// +/// Only a token of *exactly* four digits counts, which is what keeps a +/// resolution out: `1080` and `720` are outside the plausible range, and `2160` +/// is inside it but never appears bare — it is always `2160p`, whose trailing +/// letter makes the token five characters and disqualifies it. +fn bare_year(text: &str) -> Option<(u32, usize)> { + // Walked by `char_indices`, not by byte: a token boundary is any character + // that is not ASCII-alphanumeric, and several of those are multi-byte. + let mut token_start = 0; + let mut token_len = 0_usize; + + for (index, ch) in text + .char_indices() + .chain(std::iter::once((text.len(), ' '))) + { + if ch.is_ascii_alphanumeric() { + if token_len == 0 { + token_start = index; + } + token_len += 1; + continue; + } + + if token_len == 4 + && let Some(year) = plausible_year(&text[token_start..index]) + { + return Some((year, token_start)); + } + token_len = 0; + } + None +} + +/// Four digits that could be a release year. +fn plausible_year(digits: &str) -> Option<u32> { + if !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits + .parse() + .ok() + .filter(|year| (1900..=2999).contains(year)) +} + +/// Remove `[...]` and `(...)` spans. +/// +/// Release groups, resolutions, codecs, checksums and source tags all live in +/// them, and none of it belongs in a title. Unbalanced brackets are left alone +/// rather than swallowing the rest of the name. +fn strip_bracketed(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut depth = 0_usize; + + for ch in text.chars() { + match ch { + '[' | '(' => depth += 1, + ']' | ')' => depth = depth.saturating_sub(1), + _ if depth == 0 => out.push(ch), + _ => {} + } + } + + if depth != 0 { + // Unbalanced: the input was not the convention we assumed, so trust the + // original over a half-stripped guess. + return text.to_string(); + } + out +} + +/// Find the episode numbering and where the title ends. +/// +/// Returns the byte offsets in `text` at which the numbering begins and ends, so +/// the caller keeps everything before it as the title and discards everything +/// after it — which is where quality, codec and source tags live in every +/// convention. +fn find_numbering(text: &str) -> Option<NumberingMatch> { + // `S02E05` / `s02e05`, the western convention. Checked first because it is + // unambiguous, where a bare number is not. + if let Some(matched) = find_season_episode(text) { + if matched.numbering.episode.is_some() { + return Some(matched); + } + // A season stated *alone* does not end the search: `Oshi no Ko S2 - 04` + // names its season one way and its episode another, and returning here + // would drop the episode entirely. + return Some(with_episode(matched, text)); + } + + // `Oshi no Ko 3rd Season - 07`: the season is spelled out. Same shape as the + // `S2` case above — a season with the episode stated some other way — and it + // has to be read, or the words end up in the title and, worse, in the + // episode's *name*. + if let Some(matched) = find_worded_season(text) { + return Some(with_episode(matched, text)); + } + + find_episode_only(text) +} + +/// Fold whatever states the episode number into a season-only match. +/// +/// The title ends at whichever of the two came first; the quality tags start +/// after whichever came last. +fn with_episode(season: NumberingMatch, text: &str) -> NumberingMatch { + let Some(episode) = find_episode_only(text) else { + return season; + }; + NumberingMatch { + numbering: Numbering { + season: season.numbering.season, + episode: episode.numbering.episode, + }, + start: season.start.min(episode.start), + end: season.end.max(episode.end), + } +} + +/// `3rd Season`, `Season 3` — a season stated in words rather than as `S03`. +/// +/// Both orders are in use and both are common in anime releases, where a sequel +/// is named rather than numbered. The keyword is required in either position: a +/// bare ordinal (`3rd`) says nothing on its own, and a bare number is the +/// episode. +fn find_worded_season(text: &str) -> Option<NumberingMatch> { + let lower = text.to_ascii_lowercase(); + + for (index, _) in lower.match_indices("season") { + if !is_token_start(&lower, index) { + continue; + } + let after_keyword = index + "season".len(); + // `Season 3`: the number follows the word. A dash is deliberately *not* + // a separator here — `Show 2nd Season - 04` puts the episode there, and + // reading across it makes episode four season four. + let mut cursor = after_keyword; + while matches!(lower.as_bytes().get(cursor), Some(b' ' | b'_' | b'.')) { + cursor += 1; + } + if let Some((season, end)) = read_number(&lower, cursor) + && end > cursor + && season > 0 + && !is_token_char(lower.as_bytes().get(end).copied()) + { + return Some(NumberingMatch { + numbering: Numbering { + season: Some(season), + episode: None, + }, + start: index, + end, + }); + } + + // `3rd Season`: the ordinal precedes it. The keyword has to *end* a + // token here — `2nd Seasoning` is a word, not a season — where in the + // branch above the digits are allowed to run straight on (`Season3`). + if !is_token_char(lower.as_bytes().get(after_keyword).copied()) + && let Some((season, start)) = ordinal_before(&lower, index) + { + return Some(NumberingMatch { + numbering: Numbering { + season: Some(season), + episode: None, + }, + start, + end: after_keyword, + }); + } + } + None +} + +/// The `3rd` of `3rd Season`, if that is what sits before `at`. Returns the +/// number and where it starts, so the title can be cut there. +fn ordinal_before(lower: &str, at: usize) -> Option<(u32, usize)> { + let head = lower[..at].trim_end_matches([' ', '_', '.', '-']); + // The suffix is required: `Oshi no Ko 3 Season` is not a thing anyone + // writes, and without it a title ending in a digit would lose it. + let head = ["st", "nd", "rd", "th"] + .into_iter() + .find_map(|suffix| head.strip_suffix(suffix))?; + + let digits_start = head + .rfind(|c: char| !c.is_ascii_digit()) + .map_or(0, |index| index + 1); + let digits = &head[digits_start..]; + if digits.is_empty() || digits.len() > 2 { + return None; + } + let season: u32 = digits.parse().ok()?; + (season > 0).then_some((season, digits_start)) +} + +/// The conventions that state an episode without stating a season. +fn find_episode_only(text: &str) -> Option<NumberingMatch> { + // `1x05`, the older western convention. + find_cross_numbering(text) + // ` - 12` / ` - E1`, the fansub convention: absolute episode number. + .or_else(|| find_dash_numbering(text)) + // `E11` standing alone, for libraries that name files with nothing else. + .or_else(|| find_episode_marker(text)) + // `11.mkv` — the filename *is* the episode number. + .or_else(|| find_bare_number(text)) +} + +/// A name that is nothing but a number. +/// +/// Capped at three digits so a film named for a year (`1917.mkv`) cannot become +/// episode 1917 — that case is caught by the bare-year rule instead. The title +/// comes from the folder, which for this convention is the only place it exists. +fn find_bare_number(text: &str) -> Option<NumberingMatch> { + let digits = text.trim(); + if digits.is_empty() || digits.len() > 3 || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let start = text.len() - text.trim_start().len(); + + Some(NumberingMatch { + numbering: Numbering { + season: None, + episode: digits.parse().ok(), + }, + start, + end: start + digits.len(), + }) +} + +/// A standalone `E01` / `EP01`, with no dash to introduce it. +/// +/// Last in the chain because it is the weakest signal: it is only reached when +/// nothing else matched, which for a real library means the filename *is* the +/// episode number and the title comes from the folder. +fn find_episode_marker(text: &str) -> Option<NumberingMatch> { + let bytes = text.as_bytes(); + + for start in 0..bytes.len() { + if !matches!(bytes[start], b'e' | b'E') || !is_token_start(text, start) { + continue; + } + let digits = skip_episode_marker(text, start); + if digits == start { + continue; + } + let Some((episode, after)) = read_number(text, digits) else { + continue; + }; + if after == digits { + continue; + } + // The number must end the token, so a checksum fragment cannot match. + if is_token_char(bytes.get(after).copied()) { + continue; + } + return Some(NumberingMatch { + numbering: Numbering { + season: None, + episode: Some(episode), + }, + start, + end: after, + }); + } + None +} + +/// `S02E05`, or `S02` alone. +fn find_season_episode(text: &str) -> Option<NumberingMatch> { + let lower = text.to_ascii_lowercase(); + let bytes = lower.as_bytes(); + + for start in 0..bytes.len() { + if bytes[start] != b's' || !is_token_start(&lower, start) { + continue; + } + let (season, after_season) = read_number(&lower, start + 1)?; + if after_season == start + 1 { + continue; + } + + // `S02E05` + if bytes.get(after_season) == Some(&b'e') + && let Some((episode, after_episode)) = read_number(&lower, after_season + 1) + && after_episode > after_season + 1 + { + return Some(NumberingMatch { + numbering: Numbering { + season: Some(season), + episode: Some(episode), + }, + start, + end: after_episode, + }); + } + + // `S02` on its own — a season folder or a season pack. + if !is_token_char(bytes.get(after_season).copied()) { + return Some(NumberingMatch { + numbering: Numbering { + season: Some(season), + episode: None, + }, + start, + end: after_season, + }); + } + } + None +} + +/// `1x05`. +fn find_cross_numbering(text: &str) -> Option<NumberingMatch> { + let lower = text.to_ascii_lowercase(); + let bytes = lower.as_bytes(); + + for (index, &byte) in bytes.iter().enumerate() { + if byte != b'x' || index == 0 { + continue; + } + // Walk back over the season digits to the start of the token. + let mut start = index; + while start > 0 && bytes[start - 1].is_ascii_digit() { + start -= 1; + } + if start == index || !is_token_start(&lower, start) { + continue; + } + let season: u32 = lower[start..index].parse().ok()?; + let (episode, after) = read_number(&lower, index + 1)?; + if after == index + 1 { + continue; + } + return Some(NumberingMatch { + numbering: Numbering { + season: Some(season), + episode: Some(episode), + }, + start, + end: after, + }); + } + None +} + +/// ` - 12`, the fansub convention: a dash-delimited bare number. +/// +/// The *last* such group wins, because a title may contain a dash of its own +/// (`Fate/stay night - Unlimited Blade Works - 06`). +fn find_dash_numbering(text: &str) -> Option<NumberingMatch> { + let mut found: Option<NumberingMatch> = None; + let bytes = text.as_bytes(); + + for (index, &byte) in bytes.iter().enumerate() { + if byte != b'-' { + continue; + } + // `_-_01_` is as common as ` - 01 `: some groups separate every field + // with underscores, dashes included. + let mut cursor = index + 1; + while matches!(bytes.get(cursor), Some(b' ' | b'_' | b'.')) { + cursor += 1; + } + + // An `E`/`EP` marker may introduce the number (`- E1`, `- Ep 03`). + // Handled here rather than as its own convention because it only ever + // appears in this position. + cursor = skip_episode_marker(text, cursor); + + let Some((episode, after)) = read_number(text, cursor) else { + continue; + }; + if after == cursor { + continue; + } + // A version suffix (`12v2`) still names episode 12. + let tail = bytes.get(after).copied(); + if is_token_char(tail) && tail != Some(b'v') { + continue; + } + found = Some(NumberingMatch { + numbering: Numbering { + season: None, + episode: Some(episode), + }, + start: index, + end: after, + }); + } + found +} + +/// Step over an `E` / `EP` episode marker and any separator after it. +/// +/// Returns `at` unchanged when there is none, so a bare number and a marked one +/// are handled identically by the caller. +fn skip_episode_marker(text: &str, at: usize) -> usize { + let bytes = text.as_bytes(); + if !matches!(bytes.get(at), Some(b'e' | b'E')) { + return at; + } + let mut cursor = at + 1; + if matches!(bytes.get(cursor), Some(b'p' | b'P')) { + cursor += 1; + } + while matches!(bytes.get(cursor), Some(b'.' | b' ' | b'_')) { + cursor += 1; + } + // Only a marker if a number actually follows; otherwise the `e` was the + // first letter of a word. + if bytes.get(cursor).is_some_and(u8::is_ascii_digit) { + cursor + } else { + at + } +} + +/// Read an unsigned number at `from`, returning it and the offset after it. +fn read_number(text: &str, from: usize) -> Option<(u32, usize)> { + let bytes = text.as_bytes(); + let mut end = from; + while bytes.get(end).is_some_and(u8::is_ascii_digit) { + end += 1; + } + if end == from { + return Some((0, from)); + } + // A run of digits far longer than any episode number is a checksum or a + // date, not numbering. + if end - from > 4 { + return None; + } + text[from..end].parse().ok().map(|n| (n, end)) +} + +fn is_token_char(byte: Option<u8>) -> bool { + byte.is_some_and(|b| b.is_ascii_alphanumeric()) +} + +/// Whether `at` begins a token rather than sitting inside a word — so the `s` +/// of `Seasons` cannot start a season match. +fn is_token_start(text: &str, at: usize) -> bool { + if at == 0 { + return true; + } + !text.as_bytes()[at - 1].is_ascii_alphanumeric() +} + +/// Normalize the title span: separators to spaces, then trim the debris that +/// sits between the title and whatever followed it. +fn clean_title(raw: &str, year: Option<u32>) -> String { + let mut text: String = raw + .chars() + .map(|c| match c { + '.' | '_' => ' ', + other => other, + }) + .collect(); + + // The year was extracted separately; leave it out of the title. + if let Some(year) = year { + text = text.replace(&year.to_string(), " "); + } + + let trimmed = text.trim().trim_end_matches(['-', '–', ' ']).trim(); + + // Collapse the runs of spaces the substitutions above create. + let mut out = String::with_capacity(trimmed.len()); + let mut last_was_space = false; + for ch in trimmed.chars() { + let is_space = ch == ' '; + if is_space && last_was_space { + continue; + } + last_was_space = is_space; + out.push(ch); + } + out +} + +/// What a folder contributes to the files beneath it. +/// +/// A library is organised as `<Series>/Season 01/<file>.mkv` far more often than +/// it is named `<Series> - S01E01.mkv`, so for most real collections the folder +/// path — not the filename — is where the title lives. This is what +/// [`crate::catalog::build_rows`] walks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FolderRole { + /// `Season 01`, `S02` — states a season and nothing else. + Season(u32), + /// `Movies`, `OVA`, `Specials` — groups files without naming them. Skipped + /// when looking for a title; the folder above it is the series. + Container, + /// Anything else: the folder names the series or film. + Title(Box<ParsedName>), +} + +/// Folder names that group without naming. +const CONTAINER_FOLDERS: &[&str] = &[ + "movies", + "movie", + "films", + "film", + "ova", + "ovas", + "oad", + "oads", + "specials", + "special", + "sp", + "extras", + "extra", + "bonus", + "ncop", + "nced", + "creditless", + "subs", + "subtitles", +]; + +/// What a folder name says about the files inside it. +pub fn classify_folder(name: &str) -> FolderRole { + let trimmed = name.trim(); + let lower = trimmed.to_ascii_lowercase(); + + if let Some(season) = season_folder(&lower) { + return FolderRole::Season(season); + } + if CONTAINER_FOLDERS.contains(&lower.as_str()) { + return FolderRole::Container; + } + + // `Game of Thrones - Season 1` names the series *and* its season. Read + // both, or the shelf grows one poster per season of the same show. + let mut parsed = parse(trimmed); + if parsed.season.is_none() + && let Some((series, season)) = trailing_season(trimmed) + { + let mut from_series = parse(series); + if !from_series.title.is_empty() { + from_series.season = Some(season); + parsed = from_series; + } + } + FolderRole::Title(Box::new(parsed)) +} + +/// Split `<series> … Season 3` / `<series> … S03` into the series and the +/// season number. +/// +/// The keyword is required. Without it, every title ending in a digit — +/// `Evangelion 3`, `Kaijuu 8-gou` — would lose it to a season it never claimed. +fn trailing_season(name: &str) -> Option<(&str, u32)> { + let trimmed = name.trim_end(); + let digits_start = trimmed.rfind(|c: char| !c.is_ascii_digit())? + 1; + let (head, digits) = trimmed.split_at(digits_start); + let season: u32 = digits.parse().ok()?; + + // `… S03`: the marker is glued to the number and stands alone as a token, + // so a series whose own name ends in `s` keeps its last letter. + if let Some(before) = head.strip_suffix(['S', 's']) + && before.ends_with([' ', '_', '.', '-']) + { + let series = before.trim_end_matches([' ', '_', '.', '-']); + return (!series.is_empty()).then_some((series, season)); + } + + // `… Season 3`. + let head = head.trim_end_matches([' ', '_', '.', '-']); + if !head.to_ascii_lowercase().ends_with("season") { + return None; + } + let series = head[..head.len() - "season".len()].trim_end_matches([' ', '_', '.', '-']); + // `Season 01` alone is a season folder, not a series called nothing. + (!series.is_empty()).then_some((series, season)) +} + +/// `season 01`, `season 1`, `s01`, `3rd season` — and nothing else in the name. +fn season_folder(lower: &str) -> Option<u32> { + // The ordinal form first: `3rd season` names a season and nothing else, and + // a folder called that must not become a title called that. + if let Some(matched) = find_worded_season(lower) + && matched.start == 0 + && lower[matched.end..].trim().is_empty() + { + return matched.numbering.season; + } + + let digits = lower + .strip_prefix("season") + .or_else(|| lower.strip_prefix('s'))? + .trim_start_matches([' ', '_', '.', '-']); + + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + digits.parse().ok() +} + +/// Whether a name states a season and nothing else — `3rd Season`, `Season 2`, +/// `S03`. +/// +/// What [`crate::catalog::build_rows`] asks before keeping the remains of a +/// filename as the episode's own name: `[DB]Oshi no Ko 3rd Season - 07.mkv` +/// under a folder called `Oshi no Ko` leaves `3rd Season` behind, and that is a +/// season marker, not what episode seven is called. +pub fn is_season_marker(text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.is_empty() { + return false; + } + if season_folder(&trimmed.to_ascii_lowercase()).is_some() { + return true; + } + let parsed = parse(trimmed); + parsed.title.is_empty() && parsed.season.is_some() && parsed.episode.is_none() +} + +/// Containers this app can hand to mpv. +/// +/// Deliberately a list rather than "anything that is not obviously metadata": a +/// share holds `.nfo`, `.txt` and `.jpg` alongside the video, and a catalog that +/// offers those as episodes is worse than one that omits an exotic container. +const VIDEO_EXTENSIONS: &[&str] = &[ + "mkv", "mp4", "m4v", "avi", "mov", "webm", "ts", "m2ts", "mts", "mpg", "mpeg", "wmv", "flv", + "ogv", "ogm", "asf", "rmvb", "divx", "vob", +]; + +/// Whether a filename names something playable. +pub fn is_video_file(name: &str) -> bool { + name.rsplit_once('.') + .is_some_and(|(_, ext)| VIDEO_EXTENSIONS.contains(&ext.to_ascii_lowercase().as_str())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parsed(name: &str) -> (String, Option<u32>, Option<u32>, Option<u32>) { + let p = parse(name); + (p.title, p.season, p.episode, p.year) + } + + /// The fansub convention: bracketed group, dash-delimited absolute episode + /// number, bracketed checksum. No season is stated and none is invented. + #[test] + fn a_fansub_release_yields_title_and_absolute_episode() { + assert_eq!( + parsed("[SubsPlease] Frieren - 12 (1080p) [A1B2C3D4].mkv"), + ("Frieren".into(), None, Some(12), None) + ); + } + + /// A title containing its own dash must not be cut at the first one — the + /// numbering is the *last* dash-delimited number. + #[test] + fn a_dash_in_the_title_survives_absolute_numbering() { + assert_eq!( + parsed("[Group] Fate stay night - Unlimited Blade Works - 06 [720p].mkv"), + ( + "Fate stay night - Unlimited Blade Works".into(), + None, + Some(6), + None + ) + ); + } + + /// A version suffix still names the same episode. + #[test] + fn a_versioned_fansub_episode_keeps_its_number() { + assert_eq!( + parsed("[Group] Show Name - 07v2 [1080p].mkv"), + ("Show Name".into(), None, Some(7), None) + ); + } + + /// The western convention: dot separators, `SxxEyy`, and a tail of quality + /// and codec tags that must not reach the title. + #[test] + fn a_scene_release_yields_title_season_and_episode() { + assert_eq!( + parsed("Show.Name.S02E05.1080p.WEB-DL.x265.mkv"), + ("Show Name".into(), Some(2), Some(5), None) + ); + } + + /// Lowercase numbering is the same numbering. + #[test] + fn season_episode_matching_is_case_insensitive() { + assert_eq!( + parsed("show.name.s01e01.720p.mkv"), + ("show name".into(), Some(1), Some(1), None) + ); + } + + /// The older cross convention. + #[test] + fn cross_numbering_is_read_as_season_and_episode() { + assert_eq!( + parsed("Some Show 1x05 [HDTV].avi"), + ("Some Show".into(), Some(1), Some(5), None) + ); + } + + /// A film: a year, no numbering, and tags that are dropped. + #[test] + fn a_film_yields_a_title_and_year_and_no_numbering() { + assert_eq!( + parsed("Movie Title (2019) [1080p] [HEVC].mkv"), + ("Movie Title".into(), None, None, Some(2019)) + ); + } + + /// The scene form for films: a bare, dot-delimited year. + #[test] + fn a_bare_year_token_is_read_as_a_year() { + let p = parse("Movie.Name.2019.1080p.BluRay.x264.mkv"); + assert_eq!(p.year, Some(2019)); + assert_eq!(p.title, "Movie Name"); + } + + /// And the resolutions that sit right next to it are not years — `1080` and + /// `720` are out of range, and `2160` never appears without its `p`. + #[test] + fn a_bare_resolution_is_not_mistaken_for_a_year() { + assert_eq!(parse("Show.Name.S01E01.1080p.mkv").year, None); + assert_eq!(parse("Show.Name.S01E01.720p.mkv").year, None); + assert_eq!(parse("Show.Name.S01E01.2160p.HDR.mkv").year, None); + } + + /// **A panic, not a mis-parse.** Superscripts and other multi-byte + /// characters appear in real release names (`Death (True)²-007.mkv`), and + /// byte-indexed slicing around them cuts inside a character. + #[test] + fn a_multi_byte_character_does_not_break_parsing() { + for name in [ + "[Anime Time] Neon Genesis Evangelion - Death (True)\u{b2}-007.mkv", + "Show \u{2013} S01E01 \u{2013} caf\u{e9}.mkv", + "\u{5c0f}\u{8aac} Season 01 E03.mkv", + ] { + let parsed = parse(name); + assert!( + !parsed.title.is_empty() || parsed.episode.is_some(), + "{name}" + ); + } + } + + /// A resolution is four digits in brackets too, and must not read as a year. + #[test] + fn a_bracketed_resolution_is_not_mistaken_for_a_year() { + let p = parse("Show Name - 03 [1080p].mkv"); + assert_eq!(p.year, None, "1080p is not a release year"); + assert_eq!(p.episode, Some(3)); + } + + /// A season pack or season folder states a season and no episode. + #[test] + fn a_bare_season_is_read_without_an_episode() { + assert_eq!( + parsed("Show Name S03 COMPLETE"), + ("Show Name".into(), Some(3), None, None) + ); + } + + /// `Seasons` starts with an `s` and a digit follows nearby; it is still not + /// numbering. + #[test] + fn a_word_beginning_with_s_does_not_start_a_season() { + let p = parse("Four Seasons 2019.mkv"); + assert_eq!(p.season, None, "'Seasons' is not 'S...'"); + } + + /// Titles with dots in them are exactly why the extension check is + /// conservative. + #[test] + fn a_dotted_title_keeps_its_words() { + assert_eq!( + parsed("Serial.Experiments.Lain.S01E03.mkv"), + ("Serial Experiments Lain".into(), Some(1), Some(3), None) + ); + } + + /// Nothing recognisable still yields a usable title — a catalog that drops + /// files it cannot parse is worse than one with an odd name in it. + #[test] + fn an_unrecognisable_name_still_yields_a_title() { + assert_eq!( + parsed("some_random_file.mkv"), + ("some random file".into(), None, None, None) + ); + } + + /// Unbalanced brackets mean the name is not the convention assumed, so the + /// stripper leaves it alone rather than swallowing the rest. + #[test] + fn unbalanced_brackets_do_not_swallow_the_name() { + let p = parse("[Group Show Name - 05.mkv"); + assert!(p.title.contains("Show Name"), "title survived: {}", p.title); + } + + /// A name with no extension at all parses the same way. + #[test] + fn a_name_without_an_extension_parses_the_same() { + assert_eq!( + parsed("Show.Name.S02E05"), + ("Show Name".into(), Some(2), Some(5), None) + ); + } + + /// The episode's own name, where the filename states one. + #[test] + fn an_episode_name_after_the_numbering_is_captured() { + let p = parse("S01E01-Mother and Children [B496EBF1].mkv"); + assert_eq!(p.season, Some(1)); + assert_eq!(p.episode, Some(1)); + assert_eq!(p.episode_title.as_deref(), Some("Mother and Children")); + assert_eq!(p.title, "", "the filename states no series"); + } + + /// **The trap.** Scene releases separate their tags with the same character + /// they separate title words with, so without requiring a dash the quality + /// and codec tags would be read as the episode's name. + #[test] + fn quality_tags_after_the_numbering_are_not_an_episode_name() { + let p = parse("Show.Name.S02E05.1080p.WEB-DL.x265.mkv"); + assert_eq!(p.episode_title, None, "1080p.WEB-DL.x265 is not a name"); + assert_eq!(p.title, "Show Name"); + } + + /// A library that names its files with nothing but the episode marker. + #[test] + fn a_bare_episode_marker_is_read_as_an_episode() { + assert_eq!(parse("E11.mkv").episode, Some(11)); + assert_eq!(parse("EP07.mkv").episode, Some(7)); + assert_eq!(parse("Ep 3.mkv").episode, Some(3)); + } + + /// A dash-introduced marker, the form fansubs use for OVAs. + #[test] + fn a_dash_introduced_episode_marker_is_read_as_an_episode() { + let p = parse("[Reaktor] Fullmetal Alchemist Brotherhood OVA - E2 v2 [1080p].mkv"); + assert_eq!(p.episode, Some(2)); + assert_eq!(p.title, "Fullmetal Alchemist Brotherhood OVA"); + } + + /// A filename that is nothing but its episode number — the folder carries + /// the title in this convention. + #[test] + fn a_bare_number_filename_is_read_as_an_episode() { + for (name, episode) in [("11.mkv", 11), ("01.mkv", 1), ("7.mkv", 7)] { + let parsed = parse(name); + assert_eq!(parsed.episode, Some(episode), "{name}"); + assert_eq!(parsed.title, "", "{name} states no title"); + } + } + + /// A film named for a year must not become episode 1917. + #[test] + fn a_year_shaped_filename_is_not_an_episode() { + let parsed = parse("1917.mkv"); + assert_eq!(parsed.episode, None); + assert_eq!(parsed.year, Some(1917)); + } + + /// **A season and an episode stated in different conventions.** Matching the + /// season must not end the search, or the episode is dropped. + #[test] + fn a_season_marker_and_a_dash_episode_are_both_read() { + let p = parse("[EMBER] Oshi no Ko S2 - 04.mkv"); + assert_eq!(p.title, "Oshi no Ko"); + assert_eq!(p.season, Some(2)); + assert_eq!(p.episode, Some(4)); + } + + /// Some groups separate every field with underscores, the dash included — + /// and spell the season out rather than numbering it `S03`. + /// + /// **Both halves matter.** Reading `3rd Season` as part of the title files + /// the season under its own poster; leaving it in the span after the series + /// name makes it the *episode's* name, so every episode of season three is + /// called "3rd Season" in the list. + #[test] + fn underscore_separated_numbering_is_read() { + let p = parse("[DB]Oshi no Ko 3rd Season_-_07_(Dual Audio_10bit_1080p_x265).mkv"); + assert_eq!(p.episode, Some(7)); + assert_eq!(p.season, Some(3)); + assert_eq!(p.title, "Oshi no Ko"); + assert_eq!(p.episode_title, None); + } + + /// Every way a release spells a season out, in both orders. + #[test] + fn a_worded_season_is_read_as_a_season() { + for (name, season, episode) in [ + ("[Group] Show 2nd Season - 04 [1080p].mkv", 2, 4), + ("[Group] Show 3rd Season - 11.mkv", 3, 11), + ("Show 1st Season - 01.mkv", 1, 1), + ("Show 4th Season - 02.mkv", 4, 2), + ("Show Season 2 - 06.mkv", 2, 6), + ("Show.Season.3.E05.mkv", 3, 5), + ] { + let parsed = parse(name); + assert_eq!(parsed.season, Some(season), "{name}"); + assert_eq!(parsed.episode, Some(episode), "{name}"); + assert_eq!(parsed.title, "Show", "{name}"); + } + } + + /// The ordinal has to belong to the word `Season`. A title that merely ends + /// in one keeps it, and a season with no keyword is not a season. + #[test] + fn an_ordinal_that_is_part_of_the_title_is_not_a_season() { + for name in [ + "[Group] 3rd Rate Duelist - 04.mkv", + "[Group] My 1st Kiss - 02.mkv", + "[Group] Show 3 - 04.mkv", + ] { + let parsed = parse(name); + assert_eq!(parsed.season, None, "{name}"); + } + // `Seasons` is not `Season`: the keyword has to end the token. + assert_eq!(parse("Show 2nd Seasoning - 03.mkv").season, None); + } + + /// A folder that spells its season out is a season folder, not a series + /// called `3rd Season`. + #[test] + fn a_worded_season_folder_is_a_season_folder() { + assert_eq!(classify_folder("3rd Season"), FolderRole::Season(3)); + assert_eq!(classify_folder("2nd season"), FolderRole::Season(2)); + assert_eq!(classify_folder("Season 3"), FolderRole::Season(3)); + } + + /// What `build_rows` asks before keeping a filename's leftovers as the + /// episode's name. + #[test] + fn a_season_marker_is_recognised_as_one() { + for name in ["3rd Season", "2nd season", "Season 2", "S03", "season 01"] { + assert!(is_season_marker(name), "{name} states only a season"); + } + for name in ["Mother and Children", "The End of Evangelion", ""] { + assert!(!is_season_marker(name), "{name} is a name"); + } + } + + /// A title beginning with `E` is not an episode marker. + #[test] + fn a_word_beginning_with_e_does_not_start_an_episode() { + assert_eq!(parse("Evangelion.mkv").episode, None); + assert_eq!(parse("The End of Evangelion.mkv").episode, None); + } + + /// Season folders state a season and nothing else. + #[test] + fn season_folders_are_classified_as_seasons() { + assert_eq!(classify_folder("Season 01"), FolderRole::Season(1)); + assert_eq!(classify_folder("season 2"), FolderRole::Season(2)); + assert_eq!(classify_folder("S03"), FolderRole::Season(3)); + } + + /// A folder that names the series *and* its season must give up both, or + /// the shelf shows one poster per season of the same show. + #[test] + fn a_folder_naming_series_and_season_yields_both() { + for (name, series, season) in [ + ("Game of Thrones - Season 1", "Game of Thrones", 1), + ("Game of Thrones Season 10", "Game of Thrones", 10), + ("The Expanse - S03", "The Expanse", 3), + ("Breaking Bad.season.2", "Breaking Bad", 2), + ] { + let FolderRole::Title(parsed) = classify_folder(name) else { + panic!("{name} was not classified as a title"); + }; + assert_eq!(parsed.title, series, "{name}"); + assert_eq!(parsed.season, Some(season), "{name}"); + } + } + + /// The keyword is what makes it a season. A title that merely ends in a + /// number keeps it. + #[test] + fn a_title_ending_in_a_number_keeps_it() { + for name in ["Evangelion 3", "Kaijuu 8", "Alien 3"] { + let FolderRole::Title(parsed) = classify_folder(name) else { + panic!("{name} was not classified as a title"); + }; + assert_eq!(parsed.season, None, "{name}"); + assert_eq!(parsed.title, name, "{name}"); + } + + // A four-digit tail is read as a year, as it is everywhere else — but + // still never as a season. + let FolderRole::Title(parsed) = classify_folder("Blade Runner 2049") else { + panic!("not a title"); + }; + assert_eq!(parsed.season, None); + assert_eq!(parsed.year, Some(2049)); + } + + /// Grouping folders name nothing, so a title has to come from above them. + #[test] + fn grouping_folders_are_classified_as_containers() { + for name in ["Movies", "OVA", "Specials", "extras", "NCED"] { + assert_eq!( + classify_folder(name), + FolderRole::Container, + "{name} groups without naming" + ); + } + } + + /// Everything else names the series or film — and is parsed, so a year in + /// the folder name is picked up too. + #[test] + fn other_folders_name_the_series() { + match classify_folder("Ghost in the Shell (1995)") { + FolderRole::Title(parsed) => { + assert_eq!(parsed.title, "Ghost in the Shell"); + assert_eq!(parsed.year, Some(1995)); + } + other => panic!("expected a title, got {other:?}"), + } + // A folder that merely contains the word is not a container. + assert!(matches!( + classify_folder("Satoshi Kon Movies"), + FolderRole::Title(_) + )); + } + + /// A share holds `.nfo` and `.jpg` next to the video; a catalog that offers + /// those as episodes is worse than one missing an exotic container. + #[test] + fn only_playable_containers_count_as_video() { + for name in ["a.mkv", "a.MP4", "a.webm", "a.m2ts", "a.avi"] { + assert!(is_video_file(name), "{name} is playable"); + } + for name in ["torrent.info.nfo", "poster.jpg", "readme.txt", "noext"] { + assert!(!is_video_file(name), "{name} is not"); + } + } + + /// `is_episode` is what the catalog groups on. + #[test] + fn episodes_and_films_are_distinguishable() { + assert!(parse("Show.Name.S01E01.mkv").is_episode()); + assert!(!parse("Movie Title (2019).mkv").is_episode()); + } +} diff --git a/crates/pstr-core/src/prefs.rs b/crates/pstr-core/src/prefs.rs new file mode 100644 index 0000000..b94aa33 --- /dev/null +++ b/crates/pstr-core/src/prefs.rs @@ -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); + } +} diff --git a/crates/pstr-core/src/shares.rs b/crates/pstr-core/src/shares.rs new file mode 100644 index 0000000..19250d4 --- /dev/null +++ b/crates/pstr-core/src/shares.rs @@ -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"); + } +} diff --git a/crates/pstr-meta/Cargo.toml b/crates/pstr-meta/Cargo.toml new file mode 100644 index 0000000..decfd44 --- /dev/null +++ b/crates/pstr-meta/Cargo.toml @@ -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"] } diff --git a/crates/pstr-meta/src/anilist.rs b/crates/pstr-meta/src/anilist.rs new file mode 100644 index 0000000..0895635 --- /dev/null +++ b/crates/pstr-meta/src/anilist.rs @@ -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()); + } +} diff --git a/crates/pstr-meta/src/error.rs b/crates/pstr-meta/src/error.rs new file mode 100644 index 0000000..c139863 --- /dev/null +++ b/crates/pstr-meta/src/error.rs @@ -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>; diff --git a/crates/pstr-meta/src/lib.rs b/crates/pstr-meta/src/lib.rs new file mode 100644 index 0000000..4221fad --- /dev/null +++ b/crates/pstr-meta/src/lib.rs @@ -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; diff --git a/crates/pstr-meta/src/matching.rs b/crates/pstr-meta/src/matching.rs new file mode 100644 index 0000000..e763b44 --- /dev/null +++ b/crates/pstr-meta/src/matching.rs @@ -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())); + } +} diff --git a/crates/pstr-meta/src/provider.rs b/crates/pstr-meta/src/provider.rs new file mode 100644 index 0000000..d616b65 --- /dev/null +++ b/crates/pstr-meta/src/provider.rs @@ -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; +} diff --git a/crates/pstr-meta/src/service.rs b/crates/pstr-meta/src/service.rs new file mode 100644 index 0000000..3742709 --- /dev/null +++ b/crates/pstr-meta/src/service.rs @@ -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)); + } +} diff --git a/crates/pstr-meta/src/settings.rs b/crates/pstr-meta/src/settings.rs new file mode 100644 index 0000000..e9fae99 --- /dev/null +++ b/crates/pstr-meta/src/settings.rs @@ -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); + } +} diff --git a/crates/pstr-meta/src/tmdb.rs b/crates/pstr-meta/src/tmdb.rs new file mode 100644 index 0000000..d96005e --- /dev/null +++ b/crates/pstr-meta/src/tmdb.rs @@ -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); + } +} diff --git a/crates/pstr-player/Cargo.toml b/crates/pstr-player/Cargo.toml new file mode 100644 index 0000000..cbbf077 --- /dev/null +++ b/crates/pstr-player/Cargo.toml @@ -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 diff --git a/crates/pstr-player/src/chapters.rs b/crates/pstr-player/src/chapters.rs new file mode 100644 index 0000000..35335db --- /dev/null +++ b/crates/pstr-player/src/chapters.rs @@ -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); + } +} diff --git a/crates/pstr-player/src/cursor.rs b/crates/pstr-player/src/cursor.rs new file mode 100644 index 0000000..310b9d7 --- /dev/null +++ b/crates/pstr-player/src/cursor.rs @@ -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); + } +} diff --git a/crates/pstr-player/src/error.rs b/crates/pstr-player/src/error.rs new file mode 100644 index 0000000..d5fe3d3 --- /dev/null +++ b/crates/pstr-player/src/error.rs @@ -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), +} diff --git a/crates/pstr-player/src/gl.rs b/crates/pstr-player/src/gl.rs new file mode 100644 index 0000000..0448b20 --- /dev/null +++ b/crates/pstr-player/src/gl.rs @@ -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()); + } +} diff --git a/crates/pstr-player/src/lib.rs b/crates/pstr-player/src/lib.rs new file mode 100644 index 0000000..6872778 --- /dev/null +++ b/crates/pstr-player/src/lib.rs @@ -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}; diff --git a/crates/pstr-player/src/player.rs b/crates/pstr-player/src/player.rs new file mode 100644 index 0000000..fc54809 --- /dev/null +++ b/crates/pstr-player/src/player.rs @@ -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 + ); + } +} diff --git a/crates/pstr-player/src/protocol.rs b/crates/pstr-player/src/protocol.rs new file mode 100644 index 0000000..77a18f9 --- /dev/null +++ b/crates/pstr-player/src/protocol.rs @@ -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>()) }); +} diff --git a/crates/pstr-player/src/registry.rs b/crates/pstr-player/src/registry.rs new file mode 100644 index 0000000..b450438 --- /dev/null +++ b/crates/pstr-player/src/registry.rs @@ -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); + } +} diff --git a/crates/pstr-player/src/render.rs b/crates/pstr-player/src/render.rs new file mode 100644 index 0000000..d5a2a4f --- /dev/null +++ b/crates/pstr-player/src/render.rs @@ -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(); +} diff --git a/crates/pstr-player/src/testing.rs b/crates/pstr-player/src/testing.rs new file mode 100644 index 0000000..c9d7dd1 --- /dev/null +++ b/crates/pstr-player/src/testing.rs @@ -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") + }) +} diff --git a/crates/pstr-player/src/tracks.rs b/crates/pstr-player/src/tracks.rs new file mode 100644 index 0000000..7ba80e0 --- /dev/null +++ b/crates/pstr-player/src/tracks.rs @@ -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); + } +} diff --git a/crates/pstr-stream/Cargo.toml b/crates/pstr-stream/Cargo.toml new file mode 100644 index 0000000..9b8cd5e --- /dev/null +++ b/crates/pstr-stream/Cargo.toml @@ -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"] } diff --git a/crates/pstr-stream/src/block.rs b/crates/pstr-stream/src/block.rs new file mode 100644 index 0000000..4a249da --- /dev/null +++ b/crates/pstr-stream/src/block.rs @@ -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]); + } +} diff --git a/crates/pstr-stream/src/disk.rs b/crates/pstr-stream/src/disk.rs new file mode 100644 index 0000000..10f0caf --- /dev/null +++ b/crates/pstr-stream/src/disk.rs @@ -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); + } +} diff --git a/crates/pstr-stream/src/error.rs b/crates/pstr-stream/src/error.rs new file mode 100644 index 0000000..af78025 --- /dev/null +++ b/crates/pstr-stream/src/error.rs @@ -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>; diff --git a/crates/pstr-stream/src/lib.rs b/crates/pstr-stream/src/lib.rs new file mode 100644 index 0000000..8e57e28 --- /dev/null +++ b/crates/pstr-stream/src/lib.rs @@ -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}; diff --git a/crates/pstr-stream/src/reader.rs b/crates/pstr-stream/src/reader.rs new file mode 100644 index 0000000..ad93180 --- /dev/null +++ b/crates/pstr-stream/src/reader.rs @@ -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>(); + } +} diff --git a/crates/pstr-stream/src/ring.rs b/crates/pstr-stream/src/ring.rs new file mode 100644 index 0000000..db2e74d --- /dev/null +++ b/crates/pstr-stream/src/ring.rs @@ -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); + } +} diff --git a/crates/pstr-stream/src/single_flight.rs b/crates/pstr-stream/src/single_flight.rs new file mode 100644 index 0000000..90b31dd --- /dev/null +++ b/crates/pstr-stream/src/single_flight.rs @@ -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); + } +} diff --git a/crates/pstr-stream/src/source.rs b/crates/pstr-stream/src/source.rs new file mode 100644 index 0000000..af5e782 --- /dev/null +++ b/crates/pstr-stream/src/source.rs @@ -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); + } +} diff --git a/crates/pstr-stream/src/stream.rs b/crates/pstr-stream/src/stream.rs new file mode 100644 index 0000000..28f378a --- /dev/null +++ b/crates/pstr-stream/src/stream.rs @@ -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)[..]); + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..c10ea45 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,123 @@ +# Architecture + +## The problem + +A Proton Drive public link is normally a download link. This app turns one into a +streamable library. + +Nothing about the crypto prevents that: a file's content blocks are each a +self-contained PGP SEIPD packet decrypted under one shared session key, so there +is no sequential streaming session to resume and seeking costs exactly the blocks +the seek lands on. What was missing was plumbing — `RevisionReader`, the SDK's +random-access reader, was welded to the *authenticated* client. Undoing that weld +(`proton-sdk-rs` 0.3.3, `transport.rs`) is what made this app possible. + +## Crates + +``` +pstr-app (bin: proton-stream) pstr-cli (bin: pstr) + │ │ + ├── pstr-player ── libmpv │ + │ │ stream_cb + render API │ + │ ▼ │ + └── pstr-stream ───────────────────┤ + │ │ + └── pstr-core ─────────────┘ + │ + proton-drive-rs ──▶ proton-sdk +``` + +- **`pstr-core`** — the share list and its secrets, the catalog database, the + filename parser, and the crawl. Knows nothing about playback or pixels. +- **`pstr-stream`** — `RevisionReader` to a seekable byte stream: reader LRU, + in-memory block ring, forward read-ahead, optional on-disk block cache. Only + its `reader` module touches the SDK; everything else runs against a + `BlockSource` trait, which is what makes the caching and read-ahead testable + with no account and no network. +- **`pstr-player`** — libmpv. Fed through a custom `pstr://` protocol registered + with `mpv_stream_cb_add_ro`; rendered through `mpv_render_context_create` with + `MPV_RENDER_API_TYPE_OPENGL` into eframe's glow context. The URL is an opaque + token into a registry, never an identity: the caller opens the stream in async + code where a failure can be reported, then publishes it. mpv's open callback is + a hash lookup that cannot block and cannot leak a share secret into a log line. + Everything the callbacks decide lives in a safe, tested `StreamCursor`; the + `unsafe` is confined to one module of pointer plumbing. What is inside the file + — audio and subtitle tracks — is read from `track-list` one sub-property at a + time (`tracks.rs`) and selected through `aid`/`sid`; the *language* of a choice + is carried to the next file with `alang`/`slang`, which is what makes "Japanese + audio" hold for the rest of a season. +- **`pstr-app`** — the egui UI. Three threads' worth of work kept apart: the UI + thread only draws, an `Engine` runs everything with a network or a disk in it + on the tokio runtime and answers with events, and a player thread owns mpv for + the length of a file. Pages are pure functions of state that push `Action`s + rather than mutating, which is what allows a click to change the page it was + drawn on. +- **`pstr-cli`** — the headless front end, and the thing that tells you which + layer is broken. + +## Data flow, playing an episode + +``` +click egui + │ + ▼ +pstr://<share>/<link_id> mpv opens the URL, calls back on its demuxer thread + │ + ▼ +VideoStream::read_at pstr-stream: ring? disk cache? else fetch + │ + ▼ +RevisionReader::read_at SDK: the blocks overlapping the range, 10 at a time + │ + ▼ +get_storage_blob block storage, pm-storage-token, no session credential + │ + ▼ +ContentKey::decrypt_block one PGP packet per block, on the blocking pool +``` + +The demuxer thread is allowed to block, so the `stream_cb` callbacks bridge into +a dedicated tokio runtime with `Handle::block_on`. + +Two properties of that middle layer are load-bearing enough to state here, both +established by measurement rather than reasoning (`docs/TESTING.md`): + +- **A seek cancels outstanding read-ahead before it fetches anything.** Otherwise + the prefetches from the abandoned position hold the bandwidth the seek needs — + worth 5x on worst-case seek latency. +- **Read-ahead never runs deeper than half the ring.** A window larger than the + cache it lands in evicts its own blocks before the player reaches them. +- **How far to read ahead belongs to the consumer, not to the block layer.** mpv + buffers ahead of the picture itself, so under mpv the block layer reads ahead + *not at all*; a second speculative layer only takes bandwidth from the reads + mpv is blocked on. A reader with no buffer of its own still wants twelve. + +Every block is fetched exactly once no matter how many readers want it: the +open, and each block fetch, go through a single-flight keyed on the block. The +demand read and the prefetch racing for the same block is the normal case, not +an edge case. + +## Trust and secrets + +- The share URL fragment **is** the decryption password, and a custom-password + link has a second secret. Both live in the OS credential store; `shares.json` + holds only the id, display name and token. +- Nothing this app holds can write to the share: a viewer link grants + `MemberRole::Viewer`, and the SDK's public-link write side is unported anyway. +- Signature verification is absent on the visitor path by design — an anonymous + visitor cannot read `core/v4/keys/all` to resolve a signer. Decryption still + proves the bytes were encrypted to the node key. Nodes come back with nothing + *claimed* rather than a verification that silently passed. +- Metadata enrichment is off by default because enabling it sends the user's + library titles to a third party. + +## Persistence + +| Store | Path | Rebuildable? | +|---|---|---| +| Share list | config dir, `shares.json` | **No** — atomic writes, never overwritten when unparseable | +| Share secrets | OS credential store | **No** | +| Catalog | data dir, `catalog.db` | Yes — recrawl | +| Watch state | same DB, own table | **No** — deliberately survives a recrawl | +| Block cache | cache dir, `blocks/` | Yes — must work correctly after deletion | +| Poster thumbnails | cache dir, `thumbs/` | Yes — refetched per file, one small decrypt each | diff --git a/docs/BUGS.md b/docs/BUGS.md new file mode 100644 index 0000000..bcffc7a --- /dev/null +++ b/docs/BUGS.md @@ -0,0 +1,282 @@ +# Bugs + +The issue ledger. Open items first, then a log of what was fixed and where. + +Format: `Bn — one-line summary`, then symptom, cause, fix, and how it was +verified. Reference entries from PRs. + +## Open + +_None yet — the app is young enough that everything known is in +`docs/DEVELOPMENT.md` as unbuilt rather than broken._ + +## Fixed + +### B13 — the player's controls ran off the bottom of the window + +**Symptom.** On the player page the transport row — previous, ±seconds, play, +volume, the pickers — sat flush against the bottom edge of the window and was +clipped by it. The floating **Skip opening** button sat at the same height as +the seek bar rather than above the controls, four pixels out of line with them. + +**Cause.** `CHROME_HEIGHT` was a constant 132 px. The controls are two rows of +themed buttons whose height follows the theme and the platform's text scaling, +so the constant was a guess — and a guess that is low does not clip in egui, it +draws past the rectangle and off the window. The skip button then used its own +22 px inset where the controls used 26. + +**Fix.** `ui::player::chrome` returns the height it actually measured +(`ui.min_rect().height()` plus padding), the app carries it in `Overlay`, and the +next frame uses it for the scrim and for floating anything above the controls. +The content is anchored to the bottom of the window rather than laid out from +the top of the strip, so the last row ends a fixed distance above the edge +whatever it turns out to contain. One frame of lag, and only while resizing. +`floating` puts both the skip button and the up-next card at the controls' own +`CHROME_PAD_X`. + +### B12 — the skip button believed a chapter's name + +**Symptom.** Episode one of Oshi no Ko opens with a chapter called `Intro` that +is eleven minutes of the story. The player offered **Skip opening** over it. + +**Cause.** `ChapterRole::of` read the first word of the name and nothing else, +mapping `intro` to `Opening` unconditionally. Nothing looked at how long the +chapter was or at what else the file contained. + +**Fix.** Two passes — `Claim` from the name, `roles` from the name *and* the +file. A name that could go either way (`Intro`, `Credits`, `Cast`) is resolved +by length and position: an opening has to be theme-length, near the front, and +the only candidate in the file. Both ambiguities fail towards content. The same +pass is what `credits_start` reads, so the end-of-episode countdown does not +assume the last chapter is the ending either — see the Part C case in +`docs/DEVELOPMENT.md`. + +**Verified.** `a_long_intro_is_the_episode_and_not_an_opening`, +`a_theme_length_intro_near_the_front_is_an_opening`, +`content_after_the_ending_keeps_the_ending_out_of_the_tail`. + +### B11 — seasons past the first had no episode names, and were one query from having the wrong ones + +**Symptom.** Oshi no Ko seasons two and three showed filenames where season one +showed episode names. Season one only "worked" because *its* filenames carry +them (`S01E01-Mother and Children.mkv`); `episode_metadata` was empty for the +whole library. + +**Cause.** Two, stacked. A title is looked up once, and on AniList that match is +the *first season's* entry — a sequel is a separate id numbering from one — so +nothing was ever asked about seasons two and three. And `EpisodeGuide::get` fell +back from `(Some(2), 1)` to the provider's absolute `(None, 1)`, so the moment +season one's episodes were fetched, every later season would have been captioned +with season one's names. A wrong caption does not read as a bug; it reads as the +library being wrong. + +**Fix.** `Provider::seasons_are_separate_entries` distinguishes the two +providers. For AniList, `MetadataService::season_episodes` searches each season +past the first by the name the provider uses (`<title> 2nd Season`, then +`<title> Season 2`) and tags the result with that season; TMDB already returns +seasons numbered and is left alone. The absolute fallback now stops at season +one. + +**Verified.** `an_absolutely_numbered_answer_never_reaches_a_later_season`, +`a_season_is_searched_for_the_way_the_provider_names_it`. Against the live API, +`[Oshi no Ko] 2nd Season` is id 166531 with 13 episodes and `3rd Season` is +182587 with 11 — which is exactly what the share holds. + +**Still missing for that show, and not a bug here.** AniList's episode titles +come from `streamingEpisodes`, and all three Oshi no Ko entries have none at +all. Those rows keep their filenames until the viewer switches to TMDB. + +### B10 — every episode of season three was called "3rd Season" + +**Symptom.** Under `Oshi no Ko`, all eleven rows of season three read +`3rd Season` instead of an episode name. + +**Cause.** `naming::parse` knew `S03` and `Season 03` but not `3rd Season`, so +`[DB]Oshi no Ko 3rd Season_-_07_….mkv` parsed as a title of `Oshi no Ko 3rd +Season`. `catalog::build_rows` then took the difference between that and the +folder's `Oshi no Ko` to be the *episode's* own name — which is what it is for, +and here it was a season marker. + +**Fix.** `find_worded_season` reads both orders (`3rd Season`, `Season 3`) and +ends the title there, so the season comes out of the filename as a season. +`build_rows` additionally asks `naming::is_season_marker` before keeping any +leftover as an episode name, and folds it into the season instead. The dash is +deliberately not a separator between `Season` and its number: `Show 2nd Season - +04` puts the episode there, and reading across it made episode four season four. + +**Verified.** `underscore_separated_numbering_is_read`, +`a_worded_season_is_read_as_a_season`, +`an_ordinal_that_is_part_of_the_title_is_not_a_season`, +`a_season_stated_in_the_filename_does_not_become_the_episodes_name`. + +### B9 — a series split into one poster per season on the shelf + +**Symptom.** The library wall showed `Game of Thrones - Season 1` … `Season 8` +as eight separate titles, each with its own poster and its own resume point. + +**Cause.** `naming::classify_folder` recognised a season folder only when the +whole name was one (`Season 01`, `S03`). A folder named `<Series> - Season 1` +therefore classified as a title, and the season number stayed in the title +string, so the grouping key differed per season. + +**Fix.** `naming::trailing_season` splits a `… Season N` / `… SNN` tail off a +folder name, and `catalog::ancestry` takes the season from a folder that names +both. The keyword is required — `Evangelion 3` and `Alien 3` keep their number, +and `Blade Runner 2049` still reads as a year. + +**Verified.** `a_folder_naming_series_and_season_yields_both`, +`a_title_ending_in_a_number_keeps_it`; on the real share, 30 titles became 23, +with Game of Thrones one poster of 73 episodes. + +### B5 — a multi-byte character in a filename panicked the parser + +**Symptom.** The crawl aborted on +`[Anime Time] Neon Genesis Evangelion - Death (True)²-007.mkv` with +`start byte index 34 is not a char boundary; it is inside '²'`. + +**Cause.** Year detection scanned bytes and sliced on byte offsets, so a token +boundary could land inside a multi-byte character. + +**Fix.** `bare_year` walks `char_indices`; `bracketed_year` verifies the four +bytes are ASCII digits *before* slicing. + +**Verified.** `a_multi_byte_character_does_not_break_parsing`. + +### B4 — files were catalogued with no title + +**Symptom.** Every episode in the catalog had an empty title: a real library is +laid out `Sousou no Frieren/Season 01/S01E01.mkv`, and `S01E01.mkv` states no +series at all. + +**Cause.** Rows were built from the filename alone. + +**Fix.** `catalog::build_rows` walks the folder path: the folder names the +series, the filename names the numbering, grouping folders (`Movies`, `OVA`, +`Specials`) are stepped over, and a `Season NN` folder supplies a season the +filename omitted. Non-video files (`.nfo`, `.jpg`) are dropped. + +**Verified.** Seven `build_rows` tests, and a real 354-node share that went from +0 usable titles to 22 correct ones. + +### B3 — several naming conventions lost their episode numbers + +**Symptom.** 35 of 310 files in a real share had no episode number. + +**Cause.** Four gaps: a bare `E11.mkv`; a bare `11.mkv`; `_-_01_` underscore +separators; and — the subtle one — `Oshi no Ko S2 - 04`, where matching the +season *returned*, so the differently-stated episode was never looked for. + +**Fix.** `find_episode_marker`, `find_bare_number`, underscore separators in +`find_dash_numbering`, and `find_numbering` continuing past a season-only match. + +**Verified.** Down to 11 unnumbered files, all of which are films or OP/ED +extras that genuinely have no episode number. + +### B1 — a public-link download silently truncated past 200 MiB + +**Symptom.** `download_file` over a public link returned a file cut off at +exactly 50 blocks, with no error. + +**Cause.** The visitor path issued a single un-paged +`GET …/revisions/{rid}` and used whatever `Blocks` came back. The endpoint pages +at 50. + +**Fix.** `proton-sdk-rs` 0.3.3: the visitor path now uses the same paged +`list_blocks` as the authenticated one, and fetches blocks concurrently. + +**Verified.** `a_visitor_can_seek_within_a_shared_multi_block_file` reads back a +9 MiB file whole and in ranges. + +### B2 — a catalog crawl re-derived the same keys per child + +**Symptom.** Listing a folder of several hundred episodes over a public link took +minutes. + +**Cause.** `resolve_parent_key` walked and re-*unlocked* the whole ancestor chain +for every child. Each unlock is an S2K derivation — tens of milliseconds — so 500 +siblings cost 500 redundant unlocks of the same parent key. `enumerate_nodes` +also never chunked its link ids, exceeding the server's 150-id batch limit. + +**Fix.** `proton-sdk-rs` 0.3.3: an LRU node-key cache with an iterative +ancestor walk (up to the nearest cached key, then unlock back down caching each) +behind `SingleFlight`; `enumerate_nodes` chunked at 150 and fanned out. + +### B6 — read-ahead starved the seeks it was supposed to smooth + +**Symptom.** Dragging to a new position took ~2.9 s with the block layer's +read-ahead enabled, versus ~0.57 s with it turned off. Sustained throughput was +also *lower* with read-ahead than without: 5.0 MiB/s against 7.2. + +**Cause.** Prefetches queued from the position the viewer had just left kept +running. The block they were fetching was exactly the bandwidth the seek needed, +and a 4 MiB block over this link takes most of a second, so the seek waited +behind up to six of them. + +**Fix.** `pstr-stream::stream` tracks where the previous read ended and treats a +read landing more than one block away as a seek. A seek cancels every outstanding +prefetch *before* it fetches anything. Aborting mid-fetch throws those bytes +away, which is the intended trade — they were speculative, and the request slot +they held is what the seek is waiting for. Read-ahead resumes wherever the seek +settles. + +**Verified.** Worst-case cold seek back to 715 ms with read-ahead on. +`a_seek_cancels_the_read_ahead_it_invalidates` and +`sequential_playback_never_cancels_its_own_read_ahead` pin both directions, and +`a_block_whose_prefetch_was_cancelled_can_still_be_read` pins that an aborted +prefetch releases its single-flight entry rather than leaving the block +permanently unfetchable. + +### B7 — a read-ahead window deeper than the ring evicted its own blocks + +**Symptom.** Raising read-ahead from 12 to 32 blocks *reduced* sustained +throughput, 8.7 MiB/s to 7.8, and the ring started reporting evictions. + +**Cause.** The default 128 MiB ring holds 32 blocks of 4 MiB. A 32-block +read-ahead window is the entire ring, so blocks at the front of the window were +evicted before the player reached them and had to be fetched a second time. + +**Fix.** `clamp_readahead` caps the window at half the ring's capacity in blocks +— half rather than all, because the ring also has to hold what the player has +just passed, so a scrub a few seconds backwards does not refetch. The default +depth is 12, chosen because it never evicted and wasted a third less bandwidth on +cancelled prefetches than 16 did, for indistinguishable throughput. + +**Verified.** `read_ahead_is_capped_at_half_the_rings_capacity` and +`a_tiny_ring_still_reads_one_block_ahead`. + +### B8 — the block layer's read-ahead made playback worse once mpv was in front of it + +**Symptom.** With `pstr-stream`'s tuned default of 12 blocks, mpv took 5.5 s to +show a first frame and 3.6 s to resume after a mid-file seek. Turning the block +layer's read-ahead **off entirely** cut both to 2.5 s and 1.5 s, and fetched a +third as much data for the same playback. + +**Cause.** [B6](#b6--read-ahead-starved-the-seeks-it-was-supposed-to-smooth) and +[B7](#b7--a-read-ahead-window-deeper-than-the-ring-evicted-its-own-blocks) were +measured against `pstr bench`, a reader with no read-ahead of its own — for +*that* reader, 12 blocks is right. mpv is not that reader. Its demuxer cache +already runs `demuxer-readahead-secs` ahead of the picture, sequentially and +eagerly, so a second speculative layer underneath adds no buffer at all. It only +competes for bandwidth with the reads mpv is blocked on right now, and it does so +worst exactly when the viewer is waiting: at startup and just after a seek. The +degradation is monotonic in depth. + +| blocks ahead | 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 | + +**Fix.** `pstr_player::READAHEAD_BLOCKS` is 0, and it is what `pstr play` and the +app pass to `StreamConfig`. `pstr_stream::DEFAULT_READAHEAD_BLOCKS` stays 12 for +consumers that genuinely have no read-ahead — the benchmark, and anything that +reads a revision without a demuxer. mpv's buffer is also expressed in *seconds* +rather than bytes (`PlayerConfig::readahead_seconds`, 30 s), because a byte +budget buys two minutes of buffer on a 4 Mbit/s episode and eight seconds of it +on a 4K remux, which is backwards from what either wants; the byte figure is kept +only as a ceiling, sized under the ring so B7 cannot recur one layer up. + +**Verified.** Sustained playback holds realtime at depth 0 (178 s of media in +180 s of wall clock, the deficit being the 2.4 s to first frame), with no ring +evictions, against the same 761 MiB episode over a real public link. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..c156514 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,193 @@ +# Development + +## Where things stand + +| Layer | State | +|---|---| +| SDK streaming surface (`../proton-sdk-rs` 0.3.3) | **done, live-verified** — `open_revision` / `download_range` / thumbnails / `refresh_session` on the visitor path; node-key cache; chunked `enumerate_nodes`; paged `download_file_to` | +| `pstr-core` — shares, config, catalog, naming, library grouping | **done** — 67 offline tests | +| `pstr-cli` — add / crawl / list | **done** — verified against a real 354-node share: 310 playable files, 22 titles, correct seasons and episodes | +| `pstr-stream` — block cache, read-ahead | **done, live-verified** — 68 offline tests; benchmarked against a real 761 MiB episode (see `docs/TESTING.md`) | +| `pstr-player` — libmpv `stream_cb` | **done, live-verified** — 16 offline tests; plays a real 1080p HEVC episode over a public link, seeks, resumes from the disk cache. The render API (embedding) is not started | +| `pstr-app` — egui UI, poster grid | **done, offline-tested** — library wall, title pages, transport, share management; mpv still opens its own window | +| Metadata providers (AniList / TMDB) | not started — and this is what the wall is waiting on: see the note on thumbnails below | +| Packaging | **done, locally verified** — `scripts/build.sh` (tarball, `.deb`, `.rpm`, `.app`, `.dmg`) and `scripts/build.ps1` (portable `.zip`, WiX `.msi`), plus `packaging/PKGBUILD`. See `packaging/README.md`. The Windows and macOS halves are written but only the Linux ones have been run on this machine | + +## Build order from here + +1. **`pstr-player`, step two.** `mpv_render_context_create` with + `MPV_RENDER_API_TYPE_OPENGL` against eframe's glow context, rendered inside an + `egui::PaintCallback`; overlay controls. If this proves troublesome, the + standalone-window mode already built remains shippable. Nothing in + `pstr-player` except `PlayerConfig`'s window options is about where pixels + land, so this does not reach the streaming path. +2. Metadata providers, docs. Packaging is in place (`packaging/README.md`); what + is left there is running `scripts/build.ps1` and the macOS half on real + hosts, and a release workflow that drives both. + +Done: `pstr-player` step one — `mpv_stream_cb_add_ro`, mpv in its own window. +`pstr play --file <text>` plays a catalogued episode over a public link. + +Done: `pstr-app` step one — `cargo run -p pstr-app` opens a window with the +library wall, a title page per series, a transport bar and share management. It +plays through the same `Player` the CLI uses, so mpv still owns its own window; +the app's transport drives it from a command channel. Two pieces of that are +worth knowing before touching it: + +- **Drawing never mutates.** Pages are handed `&`-state and push `Action`s onto + a list the app applies after the frame. That is what lets a card click change + the page it was drawn on. +- **The UI thread never opens a share, reads the catalog or decodes a JPEG.** + All of it goes through `pstr_app::engine::Engine`, which spawns onto the tokio + runtime and answers with an `Event` — plus a `ctx.request_repaint()`, without + which the event sits in the channel until the mouse moves. + +Done: player controls beyond play/pause/seek — volume with mute, an audio-track +picker and a subtitle picker, in the transport bar (so on the player page and on +the bar under the library alike), plus `M` and `↑`/`↓` on the player page. All +four preferences persist to `playback.json` (`pstr_core::prefs`), and a track +chosen by hand sets `alang`/`slang` for every file loaded afterwards — the point +being that picking Japanese audio once picks it for the rest of the season. +A dragged volume slider writes to disk on release, not per frame. + +Done: the player page's overlay — a full-width seek bar with the file's chapters +marked on it and the current chapter named beside the clock, a transport cluster +(previous / −10s / play / +30s / next), and volume plus the audio, subtitle and +chapter pickers to the right. Chapters come from mpv's `chapter-list` +(`pstr-player/src/chapters.rs`); a chapter whose name reads as an opening, +ending or preview also puts a **skip** button in the bottom-right corner, which +is drawn whether or not the rest of the controls are up — an opening lasts +ninety seconds and a button you have to wake with the mouse is one nobody uses. +The strip under the library is the same controls at a lower density plus a +**Back to video** button, which is what makes leaving the player page with Esc +reversible. + +Done: **next / previous episode and autoplay.** The neighbours come from +`Title::following`/`preceding` in `pstr-core` — display order, so it walks off +the end of a season into the next one — and autoplay fires only on a clean end +of file, never on a failure. `P`/`N` on the player page, and the page stays put +while the next file opens rather than dropping back to the library. + +Done: **what a chapter is, and the "up next" countdown.** Chapter names are read +in two passes (`pstr-player/src/chapters.rs`). The first reads the name alone — +`OP`, `ED`, `Next Episode`, and the Japanese forms — and yields a *claim*; the +second (`roles`) settles the claims that a name cannot settle, using the rest of +the file. `Intro` is the opening only when the file has no chapter that says so +outright, the chapter is the length of a theme song and it is near the front: +episode one of Oshi no Ko opens with eleven minutes called `Intro` that are the +story. `Credits` / `Cast` are the ending at about that length or in the last +quarter. Both ambiguities fail *towards content*, because not offering a skip +costs a button and skipping the story costs the story. + +`credits_start` then takes the run of chapters that **ends** the file — walking +back from the last one and stopping at the first that is content. That is the +part worth stating: the ending is not the tail if something follows it. +Fullmetal Alchemist Brotherhood episode 46 has a Part C after the ED and the +preview after that, so only the preview is skippable there, while the ordinary +`… ED · Preview` gives up both. Reaching that run starts a ten-second **Up next** +card (`App::tick_up_next`, `ui::player::up_next_card`) with *Play now* and +*Watch till the end*; the latter holds for the rest of the file. It needs +chapters, autoplay on, a next episode, and playback actually running — a paused +episode is one somebody walked away from. + +Done: **per-episode metadata.** `Provider::episodes` fetches an episode list for +a title that already matched (AniList `streamingEpisodes`, which is titles and +thumbnails only and no synopsis; TMDB `/tv/{id}` plus every season appended onto +one more request), stored in `episode_metadata` — schema **v4**. The reconciling +between a release's numbering and a provider's lives in one tested place, +`EpisodeGuide::get`: the season and number the filename states, then the +provider's absolute numbering, then season one for a file that states no season +at all. That last fallback is what makes `#057` line up with TMDB filing a +64-episode anime under one season. Episode names show on the title page's rows, +in the player's title strip and in the "next:" line autoplay prints. + +**A season is a separate lookup on AniList.** `Provider::seasons_are_separate_entries` +is what splits the two providers: TMDB files every season under one show and +returns them numbered, while AniList makes each sequel its own entry numbering +from one (`[Oshi no Ko] 2nd Season`, id 166531, is not `[Oshi no Ko]`, id +150672). So for AniList each season past the first is searched for by name — +`<title> 2nd Season`, then `<title> Season 2` — and its episodes are stored +tagged with that season. The absolute fallback in `EpisodeGuide::get` stops at +season one for the same reason: without that it answered `S02E01` with season +one's episode one, which is not a missing caption but a wrong one. + +Worth knowing before chasing a missing name: AniList's episode titles come from +`streamingEpisodes`, which is what streaming sites published, and for some shows +it is simply **empty** — all three Oshi no Ko entries have none, while Fullmetal +Alchemist Brotherhood has 64. There is nothing to fix on our side for those; +TMDB is the provider that has them. + +Note the glyph trap: egui's bundled fonts cover the transport icons +(`⏮ ⏭ ⏸ ▶ 🔊 🔇 ⛶`) and *not* `← 💬 ▣`, which draw as an empty box. Anything new +on a button wants checking on screen before it ships. + +Not done: **sidecar subtitle files**. A `.srt`/`.ass` sitting beside the video in +the share is invisible to all of this — the crawl stores video files only, so +there is nothing to offer in the menu. The player side is nearly free once the +catalog has them (`sub-add pstr://<token>` through the same registry, since mpv +reads an external subtitle through the same protocol); the work is in +`pstr-core`: crawl them, and pair them to a video by name. + +## Design decisions already taken + +- **Fat client, no server.** Each viewer's app talks to Proton directly. +- **Public links only**, but several, merged into one catalog. No account login. +- **Embedded libmpv**, not a bundled decoder stack. HEVC 10-bit, HDR and ASS + subtitles with hardware decode, for free. +- **No transcoding.** mpv demuxes and decodes natively; there is no remux or + re-encode step and no ffmpeg dependency of our own. +- **`stream_cb`, not a loopback HTTP server.** No port to open, no local attack + surface, no plaintext leaving the process. +- **Metadata enrichment is opt-in and off by default**, with the privacy cost + stated in the UI: enabling it sends your library's titles to a third party. + With it off, posters fall back to Proton thumbnails. +- **WASM is out.** Not a small port and not worth reopening: the SDK pulls tokio + with `rt-multi-thread` + `fs`, has four `spawn_blocking` sites, resolves two + `getrandom` versions with no wasm backend, and drags `aws-lc-rs`/`ring` through + rustls. Separately a browser cannot reach the Proton API at all (CORS) without + a proxy server, which the fat-client decision rules out. + +## Traps + +- **Block sizes are not uniform.** Always `RevisionReader::block_sizes()`; never + a hardcoded 4 MiB. A padded vector shifts every later block's start and serves + full-length reads of the wrong bytes, silently. +- **Cache validity is the revision id**, not `(mtime, size)`. It advances if and + only if a new revision was sealed. +- **Read-ahead must yield to seeks, and must not outrun the ring.** Both were + measured, both cost more than they bought when got wrong: prefetches left + running from the position the viewer left took worst-case seek latency from + 572 ms to 2895 ms, and a window deeper than half the ring evicts its own blocks + before the player reaches them. `pstr-stream::stream` cancels on seek and + clamps the depth; the numbers are in its module docs. +- **Read-ahead depth is a property of the *consumer*, not of the block layer.** + mpv brings its own, so under mpv the block layer's read-ahead should be **0**: + at depth 12 the first frame took 5.5 s instead of 2.5 s and a seek 3.6 s + instead of 1.5 s. `pstr_player::READAHEAD_BLOCKS` is what a player-driven + caller passes; `pstr_stream::DEFAULT_READAHEAD_BLOCKS` is for readers that have + none of their own. Do not "unify" them. See `docs/BUGS.md` B8. +- **mpv's buffer is in seconds, not bytes.** A byte budget means two minutes of + buffer on a 4 Mbit/s episode and eight seconds on a 4K remux. The byte figure + survives only as a ceiling, and it must stay under the ring or B7 recurs a + layer up. +- **The anonymous session expires mid-film.** The SDK renews it in place and + readers survive; the app should still be able to reopen and resume at the same + byte offset as a belt-and-braces fallback. +- **A share filled by the FUSE client has no thumbnails at all.** Proton renders + a thumbnail at upload time and the SDK does not generate one, so every + `download_thumbnail` on this library answers `Ok(None)` and every tile falls + back to initials. Nothing is broken — it is why metadata providers matter more + than they looked like they did, and why an empty `thumbs/` cache directory is + not evidence the thumbnail path is wrong. +- **Negative caching matters.** Without it, every grid render pays a round-trip + per unmatched title. `proton-drive-linux`'s photo grid learned this the hard + way. `ThumbnailCache` remembers both the textures it has *and* the files that + turned out to have no thumbnail. +- **A dragged seek bar must seek on release, not per frame.** Every seek cancels + outstanding read-ahead and refetches; a bar that seeks while dragging spends + the whole drag throwing away blocks it just paid for. `ui::transport` acts on + `clicked() || drag_stopped()`. +- **egui 0.35 moved the panels.** `eframe::App` is `fn ui(&mut self, ui, frame)`, + not `update(ctx, frame)`; panels are `egui::Panel::top(id).show(ui, …)` rather + than `TopBottomPanel::top(id).show(ctx, …)`, and `Context::style_mut` is now + `all_styles_mut`. Older egui snippets will not compile as written. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..680d7ad --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,200 @@ +# Testing + +## Offline + +Everything in `pstr-core` is offline and pure. That is deliberate: the filename +parser, the config writer and the catalog migrations are exactly the things whose +edge cases are painful to reproduce against a live share, so they are pinned by +table-driven tests instead. + +```bash +cargo test --workspace --locked +cargo test -p pstr-core naming:: # just the parser +``` + +What is pinned today: + +- **`naming`** — the fansub, scene and cross conventions; dashes inside titles; + version suffixes; a bracketed resolution that must not read as a year; a word + starting with `s` that must not start a season; dotted titles vs. extensions; + and that an unrecognisable name still yields a usable title rather than being + dropped. +- **`config`** — round trip, missing-is-`None`, **unparseable-is-an-error** (the + one that protects the share list from a read-modify-write wiping it), and no + temp file left behind. +- **`catalog`** — schema version, refuse-to-open on a newer schema, share replace + semantics (drops removed rows, leaves other shares alone), and that **a recrawl + preserves watch state**. + +`pstr-stream` is offline too, and for the same reason: it runs against a +`BlockSource` fake, so the caching, deduplication and read-ahead behaviour that +is nearly impossible to provoke deliberately over a network is pinned exactly. + +- **`block`** — byte range → block mapping, including non-uniform sizes, a range + ending exactly on a boundary (which must not pull the next block), and splicing + into the right part of the output buffer. +- **`ring`** — never exceeds its byte budget, evicts least-recently-*used*, a + containment check records no traffic and no recency, a block bigger than the + budget is not stored, reinserting does not double-count. +- **`disk`** — round trip, a block disagreeing with its sidecar is rejected *and + removed*, a block with no sidecar is a miss, eviction deletes the files rather + than merely forgetting them, a reopened cache adopts the previous run's bytes, + and cache paths carry no names. +- **`single_flight`** — N callers run the work once, a waiter sees the leader's + failure, a failed call leaves the key retryable, and a **cancelled** leader does + not wedge the key. +- **`stream`** — reads across block boundaries, a seek fetches only the block it + lands in, a demand read joins an in-flight prefetch instead of refetching, a + failing prefetch never fails the read, **a seek cancels the read-ahead it + invalidates** while sequential playback cancels none, and streaming a file far + larger than the ring stays inside the budget. + +`pstr-player` is offline too, everywhere it can be. mpv itself needs a window and +a codec, but nothing mpv's stream callbacks *decide* lives in the callbacks — +they hand straight to `StreamCursor`, a plain safe type over a `BlockSource` +fake, which is what the tests drive. + +- **`cursor`** — sequential reads walk the file and stop at EOF, a seek moves + where the next read starts, a read crossing a block boundary is allowed to come + up short but returns the right bytes, seeking exactly to EOF is allowed while + past it is an error, **mpv's post-open seekability probe to 0 succeeds** (fail + it and every seek bar is gone), and a cancelled stream fails its reads and + stays failed. +- **`registry`** — a published stream is reachable through its URL, dropping the + handle revokes it, and **a token is never reused** after the stream behind it + is gone, so a stale URL cannot start playing whatever took the number over. + Only well-formed `pstr://` URLs parse. +- **`player`** — every mpv end-file reason maps to something the UI can act on + (`Eof` means "next episode"; nothing else does), and an observed property + arriving in an unexpected format is *not* read as a position of zero. + +## Against a real share + +Needs a Proton Drive public link. Use a share you can afford to have indexed; +nothing here writes, but the crawl reads every node's metadata. + +```bash +cargo run -p pstr-cli -- add --name anime 'https://drive.proton.me/urls/TOKEN#fragment' +cargo run -p pstr-cli -- add --name anime 'https://…' --custom-password # prompts +cargo run -p pstr-cli -- crawl +cargo run -p pstr-cli -- list +``` + +Check: node count matches the folder, titles and episode numbers look right, and +the crawl time is seconds rather than minutes (if it is minutes, the SDK's +node-key cache is not doing its job). + +The reference run, against a real library: **354 nodes crawled, 310 playable, 22 +titles, ~25s**. Eleven files end up with no episode number and that is correct — +they are films and OP/ED extras. Anything else unnumbered is a parser gap; add +the filename to `naming`'s tests before fixing it. + +Useful for spotting gaps: + +```bash +sqlite3 ~/.local/share/proton-stream/catalog.db \ + "SELECT title, name FROM nodes WHERE is_folder=0 AND episode IS NULL;" +``` + +## Benchmarking playback, without a player + +`pstr bench` reads a catalogued file the way a demuxer would — 64 KiB at a time — +and reports the three numbers that decide whether the app is usable: cold seek +latency, sustained throughput, and peak resident memory. + +```bash +cargo run -p pstr-cli -- bench --file Frieren --verify +cargo run -p pstr-cli -- bench --file Frieren --readahead 0 # isolate read-ahead +cargo run -p pstr-cli -- bench --file Frieren --disk-cache # run twice +``` + +`--verify` cross-checks a mid-file read against the SDK's own `download_range`. +Run it whenever anything in the block-mapping or caching path changes; it is the +only check that catches bytes served from the wrong offset, which otherwise +surfaces as video that decodes into garbage rather than as an error. + +Reference run, 761 MiB episode, 191 blocks, over a real public link: + +| | cold seek (worst) | sustained | peak resident | +|---|---:|---:|---:| +| read-ahead off | 572 ms | 7.2 MiB/s | 60 MiB | +| read-ahead 6, no seek cancellation | **2895 ms** | 5.0 MiB/s | 128 MiB | +| read-ahead 12, seek cancellation | 715 ms | 8.7 MiB/s | 128 MiB | +| read-ahead 32 | 716 ms | 7.8 MiB/s | 128 MiB (evicting) | +| warm disk cache | **14 ms** | 1039 MiB/s | 76 MiB | + +Two findings worth not rediscovering. Read-ahead left running from the position +the viewer just left holds exactly the bandwidth the seek needs — 5x on +worst-case latency. And a read-ahead window deeper than half the ring evicts its +own blocks before the player reaches them, so throughput *falls*. Both are fixed +in `pstr-stream::stream`; both are pinned by offline tests. + +Expect run-to-run spread of 15–20% on the throughput number. Treat a change under +that as noise. + +Note that `bench`'s tuned read-ahead default (12) is **not** the right one for +mpv, which brings its own — see `docs/BUGS.md` B8. A change that improves `bench` +has not thereby improved playback; measure both. + +## Playing, with a player + +```bash +cargo run --release -p pstr-cli -- play --file Frieren +cargo run --release -p pstr-cli -- play --file Frieren --start 900 # cold mid-file seek +cargo run --release -p pstr-cli -- play --file Frieren --disk-cache # run twice +cargo run --release -p pstr-cli -- play --file Frieren --headless --for-seconds 60 +``` + +Use `--release`. A debug build is watchable but the numbers below are not +reproducible from one. + +`--headless` decodes with no window and no sound, so the whole chain still runs +somewhere there is no display; `--for-seconds` ends an unattended run without a +window to close. Together they are the smoke test. Every run prints time to first +frame, seek-resume latency, and what the block layer actually fetched. + +Reference run, same 761 MiB / 1560 s episode over a real public link, defaults: + +| | cold | warm disk cache | +|---|---:|---:| +| time to first frame | 2.3 s | **0.3 s** | +| mid-file seek resumed in | 1.0–1.6 s | — | +| blocks fetched for 38 s of playback | 16 (61 MiB) | **0** | +| ring evictions | 0 | 0 | + +Sustained playback holds exactly realtime: 178 s of media in 180 s of wall clock, +the 2 s deficit being the time to first frame, with no drift over the run. Check +that: a deficit that *grows* is the block layer failing to keep up, and it is the +one failure the smoke test can see but a short run cannot. + +`Cannot load libcuda.so.1` on stderr is mpv's `hwdec=auto-safe` probe declining a +backend. Not an error. + +## SDK-side + +The streaming surface this app depends on is tested in `../proton-sdk-rs`: + +```bash +cd ../proton-sdk-rs +PROTON_TOTP_SECRET=... cargo test -p proton-drive-rs --test live_sharing \ + -- --ignored --nocapture --test-threads=1 +``` + +`a_visitor_can_seek_within_a_shared_multi_block_file` is the load-bearing one — +it verifies that the `drive/unauth/` revision endpoint returns a usable `XAttr`, +without which block boundaries cannot be placed and seeking is impossible. + +Live runs there must stay `--test-threads=1`: Proton throttles repeated logins +and each fresh one burns a TOTP window. + +## Acceptance, once playback lands + +1. Add the share; the catalog populates with correct titles and seasons. +2. Play a 1080p HEVC episode — first frame in a couple of seconds, not a full + download. +3. Drag the seek bar to ~75%; playback resumes there within a block fetch. +4. Close and reopen; the resume position is honoured. +5. Leave it paused past the anonymous session's lifetime, then seek — the + session renews rather than erroring. +6. Watch the same episode again; it comes from the disk block cache. +7. Repeat on Windows with the bundled `libmpv-2.dll`. diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD new file mode 100644 index 0000000..83a7ac1 --- /dev/null +++ b/packaging/PKGBUILD @@ -0,0 +1,95 @@ +# Maintainer: Nils Pukropp <contact@narl.io> +# +# Local-build PKGBUILD, mirroring ../proton-drive-linux/packaging/PKGBUILD: this +# is NOT a source-fetch package, it compiles the working tree this file lives in. +# +# cd packaging && makepkg -fi +# +# Two things it needs that an ordinary AUR package would not: +# +# * Network, so cargo can fetch crates.io (`--locked`, not `--frozen`). +# * A sibling `../../proton-sdk-rs` checkout, for as long as the workspace +# carries `[patch.crates-io]`. Once proton-sdk 0.3.3 is published and that +# block is removed, the check in build() stops firing and the package +# builds from the registry alone. + +pkgname=proton-stream +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Netflix-style desktop client for Proton Drive public links: browse and stream a share" +arch=('x86_64') +url="https://github.com/narrrl/proton-stream" +license=('MIT') +# mpv carries libmpv.so plus its headers on Arch, so it is both the runtime and +# the build dependency. libsecret is the Secret Service side of the keyring — +# the share fragment and custom password never touch shares.json. +depends=('mpv' 'libsecret' 'hicolor-icon-theme') +makedepends=('cargo') +optdepends=('gnome-keyring: credential storage via Secret Service' + 'kwallet: credential storage via Secret Service on KDE') +provides=('pstr') +# !lto for the same reason the sibling repo sets it; !debug keeps a 0.1.0 local +# package from also emitting a multi-hundred-MB -debug split. +options=('!lto' '!debug') + +# Built in place from the checkout; nothing to fetch or checksum. +source=() +sha256sums=() + +# Absolute path to the workspace root (packaging/ lives directly under it). +_workspace="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +pkgver() { + cd "$_workspace" + local v + v=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2) + printf '%s' "${v:-0.1.0}" +} + +build() { + cd "$_workspace" + + # The dev-only path patch. Fail early and legibly rather than a hundred lines + # into a cargo resolve error. + if grep -q '^\[patch\.crates-io\]' Cargo.toml && [[ ! -d "$_workspace/../proton-sdk-rs" ]]; then + echo "error: Cargo.toml still carries [patch.crates-io] pointing at ../proton-sdk-rs," >&2 + echo " but that checkout is missing. Clone it beside this repo, or drop the" >&2 + echo " patch block once proton-sdk 0.3.3 is on crates.io." >&2 + return 1 + fi + + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR="$_workspace/target" + cargo build --release --locked \ + --bin proton-stream \ + --bin pstr +} + +check() { + cd "$_workspace" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR="$_workspace/target" + # Offline suites only; nothing here needs a share or an account. + cargo test --workspace --locked +} + +package() { + cd "$_workspace" + local rel="target/release" + + install -Dm755 "$rel/proton-stream" "$pkgdir/usr/bin/proton-stream" + install -Dm755 "$rel/pstr" "$pkgdir/usr/bin/pstr" + + install -Dm644 "packaging/io.narl.proton-stream.desktop" \ + "$pkgdir/usr/share/applications/io.narl.proton-stream.desktop" + install -Dm644 "packaging/io.narl.proton-stream.svg" \ + "$pkgdir/usr/share/icons/hicolor/scalable/apps/io.narl.proton-stream.svg" + + install -Dm644 "README.md" "$pkgdir/usr/share/doc/$pkgname/README.md" + + if [[ -f LICENSE ]]; then + install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + else + warning "no LICENSE file in the repo root; the package ships without one" + fi +} diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..71a513f --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,139 @@ +# Packaging + +Everything here is driven by two scripts. `scripts/build.sh` covers Linux and +macOS; `scripts/build.ps1` covers Windows, because the `.exe` and the `.msi` +need MSVC and WiX and neither is reachable from a Unix host. + +| Artifact | Command | Host it must run on | +|---|---|---| +| `dist/proton-stream-<ver>-<triple>.tar.gz` | `scripts/build.sh tarball` | Linux or macOS | +| `dist/proton-stream_<ver>-1_amd64.deb` | `scripts/build.sh deb` | any Linux | +| `dist/proton-stream-<ver>-1.x86_64.rpm` | `scripts/build.sh rpm` | any Linux with `rpmbuild` | +| `packaging/*.pkg.tar.zst` | `cd packaging && makepkg -fi` | Arch | +| `dist/Proton Stream.app` + `.dmg` | `scripts/build.sh dmg` | macOS | +| `dist/windows/proton-stream.exe` + `.zip` | `scripts\build.ps1 -Artifacts zip` | Windows | +| `dist/proton-stream-<ver>-x64.msi` | `scripts\build.ps1 -Artifacts msi` | Windows | + +`scripts/build.sh` with no arguments produces everything the host can. + +## How it fits together + +One cargo build feeds every Linux and macOS artifact. The binaries are compiled +once, staged into a filesystem image at `dist/stage` — rooted so that +`dist/stage/usr/bin/pstr` is exactly where the file lands on the target system — +and each packager copies out of that image. A `.deb` and an `.rpm` from the same +run are therefore the same bytes, and neither `dpkg-deb` nor `rpmbuild` needs a +Rust toolchain or libmpv headers. + +`--skip-build` reuses `dist/stage`, which is what you want when iterating on a +control file or a spec. + +The Arch package is the exception: `makepkg` compiles in place from the +checkout, by Arch convention and to match `../proton-drive-linux`. It ignores +`dist/stage` entirely. `scripts/build.sh arch` runs it for you and copies the +result into `dist/`, but it is not part of `all` — it recompiles, and most hosts +are not Arch. + +## Runtime dependencies, and why they are what they are + +| | Why | +|---|---| +| **libmpv ≥ 0.34** | The player. `pstr-player` links it directly and drives it through `stream_cb`; nothing here shells out to `mpv(1)`, so packages depend on the library, not the player binary. | +| **libsecret / Secret Service** | The share URL fragment and the custom password are secrets and live in the OS credential store, never in `shares.json`. | +| **libGL, libxkbcommon, libX11, libwayland-client** | eframe on glow, with both display backends compiled in. | + +Not dependencies, deliberately: **SQLite** (rusqlite is `bundled`) and +**OpenSSL** (TLS is rustls with webpki-roots). Both are inside the binary. + +Per packager that is: + +- Debian — `libmpv2 | libmpv1` (trixie | bookworm), `libsecret-1-0`, `libgl1`, + `libxkbcommon0`, `libx11-6`, `libwayland-client0`. +- RPM — `mpv-libs`, `libsecret`, `mesa-libGL`, `libxkbcommon`, `libX11`, + `libwayland-client`. +- Arch — `mpv` (which carries both `libmpv.so` and its headers), `libsecret`. + +The `.deb` and `.rpm` are built from binaries compiled on the packaging host, so +they inherit its glibc. A package built on Arch installs on Fedora rawhide but +not on an older distribution — build on the oldest one you intend to support, or +in that distribution's container. + +`AutoReqProv: no` in the spec is for the same reason: the ELF scan would generate +soname requirements against whatever the packaging host happens to have, so the +explicit `Requires` list stands instead. + +## Windows + +libmpv is the whole difficulty. `libmpv2-sys` emits `cargo:rustc-link-lib=mpv`, +so the linker wants an import library named `mpv.lib` and the process wants +`libmpv-2.dll` at run time. Neither ships with mpv's Windows *player* build — +both come from the separate **mpv-dev** archive: + +<https://sourceforge.net/projects/mpv-player-windows/files/libmpv/> + +Unpack it and point the script at it: + +```powershell +scripts\build.ps1 -MpvDev C:\mpv-dev +``` + +If that directory has `mpv.def` but no `mpv.lib`, the script generates the import +library with `lib.exe` (`/name:libmpv-2.dll`, so it references the DLL that +actually ships). It finds `lib.exe` on `PATH` or imports the MSVC environment via +`vswhere`, so a plain PowerShell session works. + +There is no static libmpv worth shipping, so **"standalone exe" means the exe +plus `libmpv-2.dll` beside it** — that pair, plus `pstr.exe` and the README, is +what the portable `.zip` contains and what the `.msi` installs. + +The MSI needs the WiX toolset; the script installs it (`dotnet tool install +--global wix`) and adds the UI extension if they are missing. It installs to +`Program Files\Proton Stream`, puts a Start Menu shortcut on the GUI, and adds +the install directory to the system `PATH` so `pstr` works from a shell — that +`PATH` entry is tied to `pstr.exe`'s component, so uninstalling removes it. + +`UpgradeCode` in `proton-stream.wxs` is fixed forever: it is what makes 0.2.0 +replace 0.1.0 rather than install beside it. Never regenerate it. + +Signing is opt-in: `-SignCertThumbprint <thumbprint>` signs both executables and +the MSI. + +## macOS + +`scripts/build.sh dmg` produces `Proton Stream.app` inside a drag-to-Applications +disk image. + +By default it copies libmpv and its non-system dependency closure into +`Contents/Frameworks` and rewrites every install name, because a `.dmg` that +requires the recipient to have run `brew install mpv` is not a `.dmg` anyone can +use. The bundle is then ad-hoc signed — every `install_name_tool` edit +invalidates the signature, and an unsigned-but-modified binary is killed outright +on arm64. Pass `--no-bundle-libs` to link against the host's libmpv instead. + +The `.icns` is rasterized from `io.narl.proton-stream.svg` when `rsvg-convert` or +`magick` is available; without either, the bundle ships without an icon. + +## Files + +| | | +|---|---| +| `PKGBUILD` | Arch, in-tree `makepkg` build | +| `proton-stream.spec` | RPM, installs from `dist/stage` | +| `io.narl.proton-stream.desktop` / `.svg` | Freedesktop entry and icon | +| `macos/Info.plist.in` | `.app` bundle plist; `@VERSION@`/`@BIN@`/`@APP_ID@` substituted at build time | +| `windows/proton-stream.wxs` | WiX v4+ installer definition | +| `windows/proton-stream.ico` | MSI and shortcut icon, rasterized from the SVG | +| `windows/license.rtf` | Fallback for the installer's license dialog; the repo's own `LICENSE` is preferred when it exists | + +The Debian `control` file has no template — `scripts/build.sh` writes it, because +almost every field in it is computed. + +## Two things to know before releasing + +- **The `[patch.crates-io]` block in `Cargo.toml` is development only.** It points + at `../proton-sdk-rs`, so both build scripts refuse to start when it is present + and that checkout is missing. A release must build against the published + crates — drop the block once proton-sdk 0.3.3 is on crates.io. +- **There is no `LICENSE` file in the repository root** even though `Cargo.toml` + declares MIT. Every packager warns and ships without the license text; the MSI + falls back to `windows/license.rtf`. Adding the file fixes all of them at once. diff --git a/packaging/io.narl.proton-stream.desktop b/packaging/io.narl.proton-stream.desktop new file mode 100644 index 0000000..69d0b5b --- /dev/null +++ b/packaging/io.narl.proton-stream.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Proton Stream +GenericName=Media Library +Comment=Browse and stream a Proton Drive public link +Exec=proton-stream +Icon=io.narl.proton-stream +Terminal=false +Categories=AudioVideo;Video;Player; +Keywords=proton;drive;stream;video;media;library; +StartupNotify=true +SingleMainWindow=true diff --git a/packaging/io.narl.proton-stream.svg b/packaging/io.narl.proton-stream.svg new file mode 100644 index 0000000..5288310 --- /dev/null +++ b/packaging/io.narl.proton-stream.svg @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> + <defs> + <linearGradient id="g" x1="0" y1="0" x2="0" y2="1"> + <stop offset="0" stop-color="#8b6dff"/> + <stop offset="1" stop-color="#6d4aff"/> + </linearGradient> + </defs> + <rect x="8" y="8" width="112" height="112" rx="26" fill="url(#g)"/> + <!-- The blocks a revision is fetched in: each one decrypts on its own, which + is the whole reason this app can seek. --> + <rect x="26" y="30" width="14" height="14" rx="3" fill="#ffffff" opacity="0.45"/> + <rect x="26" y="50" width="14" height="14" rx="3" fill="#ffffff" opacity="0.7"/> + <rect x="26" y="70" width="14" height="14" rx="3" fill="#ffffff" opacity="0.45"/> + <rect x="26" y="90" width="14" height="14" rx="3" fill="#ffffff" opacity="0.25"/> + <!-- play --> + <path fill="#ffffff" d="M56 34l46 30-46 30z"/> +</svg> diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in new file mode 100644 index 0000000..481a513 --- /dev/null +++ b/packaging/macos/Info.plist.in @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Templated by scripts/build.sh: @VERSION@, @BIN@, @APP_ID@. --> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>CFBundleName</key> + <string>Proton Stream</string> + <key>CFBundleDisplayName</key> + <string>Proton Stream</string> + <key>CFBundleIdentifier</key> + <string>@APP_ID@</string> + <key>CFBundleExecutable</key> + <string>@BIN@</string> + <key>CFBundleIconFile</key> + <string>proton-stream</string> + <key>CFBundleShortVersionString</key> + <string>@VERSION@</string> + <key>CFBundleVersion</key> + <string>@VERSION@</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>LSMinimumSystemVersion</key> + <string>11.0</string> + <!-- The player is a window, not an agent; it belongs in the Dock. --> + <key>LSUIElement</key> + <false/> + <!-- Windowed video, so let the display stay awake is mpv's business, but the + bundle must at least declare it is not a background-only app. --> + <key>NSHighResolutionCapable</key> + <true/> + <key>NSSupportsAutomaticGraphicsSwitching</key> + <true/> + <key>NSHumanReadableCopyright</key> + <string>MIT licensed. Nils Pukropp.</string> +</dict> +</plist> diff --git a/packaging/proton-stream.spec b/packaging/proton-stream.spec new file mode 100644 index 0000000..1abfbf7 --- /dev/null +++ b/packaging/proton-stream.spec @@ -0,0 +1,73 @@ +# RPM built from an already-staged tree, not from source. +# +# `scripts/build.sh rpm` runs cargo once, stages a filesystem image under +# dist/stage, and then calls: +# +# rpmbuild -bb packaging/proton-stream.spec \ +# --define "version 0.1.0" --define "stagedir /abs/path/to/dist/stage" +# +# Staging rather than compiling inside rpmbuild is deliberate: one cargo build +# feeds the tarball, the .deb and the .rpm, and rpmbuild does not then need +# libmpv headers or a Rust toolchain on the packaging host. + +%global debug_package %{nil} +# The binaries are prebuilt, quite possibly on a different distribution, so let +# the explicit Requires below stand rather than having rpm scan the ELF and +# generate soname deps against whatever this host happens to have. +AutoReqProv: no + +%{!?version: %global version 0.1.0} + +Name: proton-stream +Version: %{version} +Release: 1%{?dist} +Summary: Netflix-style desktop client for Proton Drive public links +License: MIT +URL: https://github.com/narrrl/proton-stream +ExclusiveArch: x86_64 + +# libmpv does the demuxing and decoding; libsecret is where the share fragment +# and custom password live. SQLite is bundled into the binary (rusqlite +# `bundled`) and TLS is rustls, so neither shows up here. +Requires: mpv-libs >= 0.34 +Requires: libsecret +Requires: mesa-libGL +Requires: libxkbcommon +Requires: libX11 +Requires: libwayland-client +Requires: hicolor-icon-theme + +Recommends: gnome-keyring + +Provides: pstr = %{version}-%{release} + +%description +Paste a Proton Drive share URL and its password, and get a browsable, +streamable library: a poster wall, a page per title with seasons and episodes, +resume-where-you-left-off, and an embedded libmpv player. No Proton account, +no server, no download step — a file's content blocks each decrypt on their +own, so seeking costs the blocks the seek lands on. + +%prep +test -n "%{?stagedir}" || (echo 'Pass --define "stagedir /abs/path" (see scripts/build.sh)' >&2; exit 1) +test -d "%{stagedir}/usr/bin" + +%build +# Nothing to do: %{stagedir} already holds release binaries. + +%install +cp -a "%{stagedir}"/. %{buildroot}/ + +%files +%{_bindir}/proton-stream +%{_bindir}/pstr +%{_datadir}/applications/io.narl.proton-stream.desktop +%{_datadir}/icons/hicolor/scalable/apps/io.narl.proton-stream.svg +# Directory entries, so these stay correct whether or not the checkout has a +# LICENSE yet (a listed directory takes its contents with it). +%{_datadir}/doc/proton-stream/ +%{_datadir}/licenses/proton-stream/ + +%changelog +* Sun Aug 02 2026 Nils Pukropp <contact@narl.io> - 0.1.0-1 +- Initial package. diff --git a/packaging/windows/license.rtf b/packaging/windows/license.rtf new file mode 100644 index 0000000..7b7e252 --- /dev/null +++ b/packaging/windows/license.rtf @@ -0,0 +1,14 @@ +{\rtf1\ansi\ansicpg1252\deff0\nouicompat{\fonttbl{\f0\fnil\fcharset0 Segoe UI;}} +\viewkind4\uc1\pard\f0\fs20 +MIT License\par +\par +Copyright (c) 2026 Nils Pukropp\par +\par +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\par +\par +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\par +\par +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\par +\par +This text mirrors the license Cargo.toml declares. It is regenerated from the repository's LICENSE file by scripts/build.ps1 when one exists.\par +} diff --git a/packaging/windows/proton-stream.ico b/packaging/windows/proton-stream.ico new file mode 100644 index 0000000000000000000000000000000000000000..b9499d9cf3dc8e03c6cb3a35285d3988aab1b7ea GIT binary patch literal 372526 zcmeHw3Ah&3ndVP4ou!v7Go8uIJRO`#XJXH!Gf5|t?q@pP`t;M2MBuWh#6*o6g}d)8 z3M%{xcV`h04DO<!xP&O~h>}E9FfOPO#O*5X0?~*@6!_-*s_yx3-TLeNwVbLtr~dlx z^L)4dI{UZ0-#K;aoKuCusKTcTpZ|OTuSXQ>4=)sE77B$gf7!n8{Y;_o0)`!NgnfS< zrmOv{Lg8EA8oK{lq3~Cu3x%VPw(pPpw?g6ee=ih@#i9F8k1D*-jRoQ#m4D<BhZp{A z@u<R)c#lP4h#iiXLILYZ;t#M27}MGW<63vX*w#ZxAfZEcnzlyDBiaHJ+M8f}JA|Z$ zQvQjZ&%ne^fVKgE*%yNKo9-*ogwE}y{Kw5W2*=F;Xy^gxSP0f{y1#TC!0|JO?<s7U z4wmvyJ`;ebeSuxSxCi{(F=&U2Fvqo&|0lBmh%Q(IaP@8WZ@L$MAAJmP<2?W;oiiMV z@umDHo((`$J0GB_*ZxiS?!T8_2RM6qDZZ5dXXgSC{qkObtxws%>E8WE`Om$m6kp1J z>RbS#mA?eI3Cj@oB0sf%-F*P3pFbRjX-fG|KM&)OsBQBvwSUt+{XP4#9WK&1uBH4_ z<{gA7^8kKv6TtNw!1_)1YwiS?iZY?!<ut6nx}Kd-*A39Q7+~g7uzu5hMe5m6s=uMP z1sYK9;i##UzW}44rLP&9`<{X3{zFJ0p+nHrzXPcWFptQuas|{v5lf0fl$ZPry0QYg zD+Kfr_#gk!r|JHKA2j|A9Mdut{m}V%?F~efc1-IO$}4L>6pNogAJz>utsmhPA`#^s z+d7}ha@Pan+Ocf(h4IjQXZcXm_7#}W@ez{k8>b*1$!Gi+eHVp=CoHS8BrBLWtrjLt z1B<B4b640@*JX!^xG>jar%e$&z;V;&z_HW8BCCA#MdXi&2N6#G{}l9H;@<CyWtPf6 zerD0~_vG`5$On@jC7&(A$iKYv9>9XDhr>ji_qA9m|Ho$)Em>3Be>xjrU=_fkYiyrQ zVMI4z`Qq=9rvd6wb|TL6de(6HKf&@(I14N~ZLZyyP=7))65%&sKjQq`_a}gh(Qk`* z_Z7=5wg1G~MXTM^_EY8nTy&$IcN+Rl3M0C4qqFqk-;X~7(1LzVq!U*xvsC`c=M>>2 zly@b%Nm4%5wErW3>+S+L8T(0529`No{+uG5jJ82E1#M>Q?*Sg&Ve3JJQy(b%v&z34 z`#<Deq(6DiaQUZT`6L&j=01SOkY^&2lL)7N(ChDsXYH|vNH=^PF8{P*5l$<DMQ0#( zuUv2I_^f4i7{&c+)5y}_NBvSdk+1wZT>k0j72)UD?+R+ivC#Aj>^@Y4Im;)Vb}kxD zFVZ-#SY~1952j%G)NZ0i>@U}%EQz`<v%@I-=3fK6yANQ+4Z~p~kJlB;ER{cX{v4~n zr#`<0$AW}aqMj>k9w@x;N*pKRIE{V>qhbG0*HsI(h(|$legSa)C3amREbMh^_taAR zo92&(`ks%G5cjdrN6<LG<e#Y?XzJ^=WKZ%}rKX;tClA2^mOuIW!=P!P7sn_cBcX4L zL?2-p-Cq5ps0o@DjE2?)wW#MaLlNa^>-~x-C#@@jvhYsfbA?TX!z?P|Xi0@cIaDT< zP2;d5kd8(wjl<{xkpFxo0LL`{GmLFpgJZH?NC%K)&#!*rly?`730LFw|N2!7M;8yr zdY8kvHXKJLLd&QQSvzn%xICtDrhL3Qu49>3{ZP~SPcXjY5E4H3Cr$1D>}~^0n0D=m z`GHfh&!*?kbC9Ovc&5_aLD;wno!6ArKWW;lrLi)NhiQx~#>;dKGe-7LyQ{4JW2YY& zF-ErP$8jQ!6{&vrxS7Uy?y%tZ2g>Rno=36gRA{VCZ6Km&e0ThTgZ3N(rE|vxxG$@J zGLG-VkQi0Hz8j#3{eGCdn6|8b_gKCI$0Vz6wQ0rm<zs$PKV1(U0@$#{e$H{1Be^fD z-#yMJz0<RTAa}g8e)0RQy*OWkV>fqNkNdLvNsmq<n-g`@)fq>>EB!tA1diu%?C8$p zxG$^!#M$_4=OD7VbFdB6j%hfib;i+ezrR2H5n%D^@_a+(l+`buPl8_k>KE}3J&E&B zICteLx2*os^9w!;(Qne@Birrgp?lD_-0`A(zw4H#?0GO({<8Y%8Olj3@EJ)wFVWQ* zN5B34=$U-UwdMJS$|<Y=)VU+(BCY2QQ9nHsy5lR=PjjX;mUicH+?Unweh#BKzh!Fy zE=25iU0(jY=2idOf3Tlj-DP>)m(}llMho)WSwFfT*z}0~j1nX-rYWnR=7ODcHsbC& zd={kV!OLzQ8As{F-<x~z8Sbj`cxPE2zsu@(&j-^pqIh1Ut2_SSL4Z3p+wTb6aRKhj z>YqCQz=-Ehcm3{X(|I^A?hXrnU*7-LcD-79j-_YTR(xLWM!V}qIh>3ATiEB=%IRMz z<G-$ZZRPoOzxnr2@oUQJZ|eP5>wOQs^GTZOyUWI()E;Q;yGZ#N-1iy2XSthqagWc} zhg)rE!nvv>sJU-B)gkJWU2$IUU+{g$bx5xw9gw{Hki8RehEv{GaPI9|l<W6p_2oHS zxX}*_h0%kB!l#Hf6$*zXK~y#o)g|hsc?x=0bQ)4=p5kxuhW}UsDM|n)wtpVyXlh|h z%dJQ+B7KMi2@#e30=`>ZZ@rWJ_u9`zWfccfqp;mANV}2z=A)8|f7@E`K@bk{2Q{^y z#ksK?5}NA@KZiE9eJxDx2=unvl6y`2b&1cHg@5mR6Sd7x4LHZv7IJ+}*!4IH>D!ts zwco<(gpS=2-cLt)ryV8F+xU)HifMdDUWbI`y6D&$xZY%mJ?}_!k3=*Mcc)9`e)v7R zTNgMNdYk8bD9tg@e5sql&X2~Ee!aWh;vVNxU-X<$rT4@%-{U6#`P8`QXQ{mBMSI;> zoOk`ubMBSaXj${MV%~P>Dw~(3wG6U2^>7^KdH+f0YiSJtwV&Rj3YxV{KEF%zjr6`X zz<h6%G~TVp=d|Myr!u1XJ$iQ_h~_?JX~iJ;fo=HCe6Bp5EWb~<y!|tSu2rd=PPCt{ z;nu)ZE_Z0%jI{lZHIvp_9Opf)KM=M<wp5Sv0?v5nZ)f|(@83Ob&viJ{Mf@#ozhq7x zrsBFpG3PI?m*d=*EWK#6`?~J~T!(X$JfEyQSGctO9`pIuoPQ(EojB+0#cx@9ce~y9 zkKw#4tzVVp_X(G_-|-$`#5)42OSIW}m8EyL*X#a+4*{-PZ?AKbl_Lq4wqLB3a$l+a z*W5mmh!1x<aqrb$Ss1NbCTxkk0j{CjPyKH%uE(Ku&w}V3jI1tMTV>(2UW?WM)4FL{ znT~L2`>EfIgJkXW3xDx7dtJFt8>H<g{^KCOc2<f%Z~)+z-`MX+ByEwlpX|ho$bZs% z2zp;5h}Ow_rKK>HKWhJaZ2xI<rR7W8FV@$)uQb*npXVlbI&mMh{ex&fgey^w<SK3d z@LI&-b&M1?8^`|Qotg70OCPrVgkO3eMDMd?Wjex*YX37Y0i3xsP`d?h()OQ<Hs?jO zj*;HkI%WTxN4?Wh{vdzUf33d5mbq7*6~d(L_jn&!T8~O=Or7I5dKV*0@7Mlq&*Hm# zT&E(-?-MR<f53N~rFFBi^s@GgHM+8V0mG&3ckG$5*48-h$uIO?VH3TBB>6exoxi=> zFV+A%(?$F(ZNJyLUkanWT4D{cxUNClm8EyKKg=3qS$VE-dHb=SI>Uoz;M~vJ4dvvO zmcqo}!%y35m}TiAhD+Nod%sO{SI&3l;<qgQ?sx1pGO~EZaB2Ie&i~N!-8so$v{_t9 zAF}uAv_?Y~ryee!KdJ3{$@6_Ytr4d+FSPGX5WT;z{4PGJ_!p$|ukX1%#drNa`}S~t zz;j<;&#m(IcQ;t`3N*K1DmC=fO55Mi^95}8o|yI&#I-lUS<Y@~FIHTw4vjq>A@^6> z>(IjO$&8w(MJ9U@PWv@+O~Tsb*L&ccfbW`+5oz%KQLw$FP+R;guC2H!k+mX`)}Qp= zNNo}_4sx~h(A?jF>qPb_tTS=0If?RnH`d)2RPJ7U;hLo{K=XonXdbu?_rDAxeTZc5 zMT-AQWe*P3RR`^hD`M5Fy&T382qOV|sEhAjtUt$<3c(lN)`G^P3I%J>G`fI$tMCus zz>4c2-G8c3$m><eL0pBLgxpYsmK@9EJ0x#fr*$+^5vjCJivswMCBPD32}G0tuK)TP zu7#U{>&NcK>vkC1@?Jt~?)=yCS#>u*jrHGUwF%o6kuBeBxK{5Q$oqPYYxaV!1>AsZ z-2TCrp@0eCJGGyLaV^V`cRKqi+IcDI=R1h!yDY7=cQy$7Ft*_fxX1QhW7a!{-($Ry zcnHF)I}OyL-fP-6BH`ZP4Ac15-@xSJpSk(-yT`Sb7aF)$RK8EU^19Qde*8M)alPiZ zkpF|`t~U*R*4!bO*!mqOkClGI#Ey*yuT_n*zLoN9sKm2~gK;yy=DU8C&TFD`n#7t{ zy3##`r?S@7wGKCEos&BMhu~9o#rB`+xmMP>#`SFE)xBnx?kU`v&a~eXI;YF{7U6gX z*apwFw$3%R?)A3LduKZ1es|s__>^6d_h+h?|7oQ&Go}0=JN=KoYmB|tEQ>Jpb;!xB zah^2&T^ZjZ+?RLa&pKxS*QyGh#g)SS_@}cD=>581x`g5dpR#M9b=S0Rlz0~FKIlsK zwD!YqZ8yzN(R$Zk;CTVGZrCq<%<(e5MYweBx6j(`!1?S6JNrMa`=Rpz<0?BqIf75w zwRBFR_gRR6)`3?n!^uDW{t(xQ(HdA<vz=6(j&fyui}0}L!h}xm=3m_JcoEMT#`ixV z&!q{QUhpZqO4qJC*P72ktkao<V$C~U>7K%!>AZdme!Z@=UT`(q0<Ew1${RzNjBgPh zs9rbkLJkz}*G4bC-C-}jYOg)?D=R|0;8S)Dw07UQ*53WwXN|v`Rp~vg*S&d@y;j$+ z%n<Q1zC}2kfVSsHZl0a@etD$~OCnx=!+y^4D>q=g;8S)TvA;sH&%txgg^+<Pj|i8r zSuvQ-%c3)lXzi*fC*oDcw+Iii&%p9~?tL3{Pkz9$AHnl?MMixh>FgXjU)`^c3h{zZ z*)>qv#g#U%P8ksI6kefjE6#^S-)ORhja%(8v|n8w@iM+ecr5!wD(&;|WiViZy?+E) zcunYiErL(k)qU<*?DqlL3!IG3OQSKg>%27Cy^L=W9?L!($^ICt?7)nLNJx7*mtGfW zkC5O~c9rhEaqh{X^RQ{(rP#AWSGuQgXF9LnA=nC>RPgUrpDRqpw+JsgH_vqro)z!r zUC4pfb$g|Eh6QFSU_$G>K78(W)=}ZN;8S+>+hb(KyZ1Xe?<ISaO63M*%QqSMKRU0` zuRd42jBgPx_VQG`I``|ie>?Uwd6wbJm`?)g|L%L(K4;ahE{}M@r|jz4W_LMmo}KsZ zwC;N!_9{lx`2n=o#a(8Idl}y%+;g86jXRy>-m@k6C%@oK=k>cHgOW%~=fAVp{d<)e zB24fpyIT7rPeXny(IvmM_pQ<1AweV)`VK{<d=wY<@0B<4%!rHq%k`^wC|<_52#>n= zEO5S{{L^{Kx7}}_W9hT+EpXjfj^I;v_1yy(a4(y*oFM$ucdCl0bJe;$^2_)Z;Yrjv zqRdMC@BbLTA9Ihr|IMTABg%;wFZh&QN1W;EaSp2$-*W}PwRer&yGQpFF3TgrW&B&; z30vWxUz8K^D&t#(OZOf+_X^TCYQ%m;=as^p>AZfs`KLKg+Vf6(=e*L#947dbT|M_X zy7wb`?RgYo?zHZEC;v}92QXu)_ufi(zL@T1e2eh1bA!G1G>W~3URSU5&M@Mi_6x7V zb3W+&17}<czXhMNYoPs+UVMu%zkQV3cB0O4-=<%>gyLm<i*Rh$d%kTW%nN><R|-$4 z-7%H>p5Rk<#l5FHefOXCud??%Q(q;B@K51-_O1r4cZZB`5svM@$8a8k@>-s5YS-O@ zPuUe}yU+68Un|>po1yU9t{F1EML3?P{ddOh5mw#1+%WI!g3n4<sO!Dg;JwDqeaB&b z2U`C~a>2dI{|EOfA2N8qa@c*&$V*S)^{<u;8hTfR+=m=?-*SrSdzMwQcdt08UHM7$ z1@|IhJ2Op@*1uae(6Q)?`2D~8%-JuFdI-1A{9f8;?WNzKVU`^<^{+rV(6^hh{!9?H z^+IZUP(J<A;kg0d#XavEHSW0&wokulU^BMS^LrhBJO+t}w!W|9UV8jyW&b^R-Hx;m zDXD$?e&tf#*aoXDXHwgO@W5$U0xSWR07<|(9q0MmK7atv>7>+$;g@QyKR9Gx=MH_C z>L_s?Is|L9xLVoqWCy-l0t+n%AxCi)au!#i2cegeo`l{!l{y?q=R_2#Y4`<-(m4_S zpQE50{Kpbt39tlM0xSWR084-+z!G2yumo5FECH4POCYiYtTX10X_<;=%q_?B<?cax z7SDIvgR~#X|6I80N<%yo?>mt;STZ5m{IIdAzPb!QYWpVYc>(hH5>noD9F^ZeIHq|3 z#x(zZWUy(@h*%$kXDV*az`2S+&tKG(uI+yG4L{PX&cM~v8I`E}2U+iUre?g~0pE<@ zDF1d~MWmb%jy~<vXr~t-&j(`WJ8n5<xjd5XBD4cMLEW4`Yg{+3^-;WL<s8|pxAS*s z179`40$^On_wXFuw=;Z}F4_W~gPT3ti|4o|wjL^oZPB;{<@SCCWtT;Ld$sv}7~A&4 zAi9b>4NPeJM?61zU#8EH&hq)!!)^Z{Z9ic3_ciT%s^}c;sON8+l5aOmOlLemV^7rk zBdmKotGx2r>}khugUKDAiQCV+%Y&MZB~^5OJX94gHEoOB`ik?O&e}(L9jGdqjSv0J z0h4V8b<>&191*TV9fjE60^>W7e<p)vusKf5i?U!+=eR2Tc1F~1ZkU~Kd`Il>3sCnD zu>R5SO~?e@FK`>j)%tFbD&O?Y6#9+~eVg9-eHpLcl!nq$9@TpDnty!TH{;Mfm4SL# zsQmpOIzPlq*OkI3Ptb1waXOc*ruE0C|BhYC-#<DN&x7*%wo;`q%0oVYDT)jR<Iq2h zZ~J;I@~@=lN^z|FD7v@vjPFohONRcpj`g2WA2g|Rs;UpbZ>C-3``xr4--C00OD@ds zAZ2qJkm%^b81#Ryq-<H8-r-?%?o7+N^_FK4x!^Y+mGyt@v=><a)1vDjh@O&5^SrwL zr|*g7n}*_Bieaw_eJ?Q<p3HCC+cD^We=M>Jt9Oy!d>L!|=4SxC3>_~L-3kKK5sZJK z&#+fj{cHX%Yi#n1D2M2pw{yaA_+8vMh`u9E-;oSMlt;C0IufV!UD_Gy<*BwO<^1aU zCz-`S^lf41cZGxeraY>34<CsHK;Ltz!#QQuGBcFl82!_?dP$#_AHeTQSJMA1#66{@ zJgTyG>YskocZunnmT{D=+O`z(E9*bNx2Mzm&UNUz-1-;y+g}7|!}C3}&Yw^FS^q-e z9#{HC%Qff==zGgPa?EPHy8h7zJHE4?x!)dYFAJjYa?_fFEb}R8JL|tp+ui~6UGjDJ z+21*q$TExep{)POi2p2;x3=Y>FTV~jYgx8=cIZo8|JXN9&M?U}Esvq^a&FjSe|tB} zykpyq(f<jv>}NjumMDEwz7kOydOoB)s`8g>*+1ruJpkujtlkDy9u)E`>z~HPaS-XV zlFq&2D35C0{#ve_LimDH=cu<um6r(ljnRJ)+`Dan@<iZyM4rmp4IaDqUHlH-RqE|g z<tJo*b^V`(JjX!vUCbz?T6b^_2TMusljxhJs_jZCzq0;MK;6edH0K(HRO=3=)o`ia zc@JRm>UiX$%AZeu)_*wFhD!a&cKlu+ekapM2Rh?f|DiM*KJmUk0nj+*C-|&lpdBdd zpZJP{qWFHwqgr=(jYiAy_)h$e9e&49wY@RtXZ=T0XQW*8Zh6g}_8fA|dW>pUtp7-w zRLitu5TJ9B5xziK|35|l9|zHU)+nS}x0)uS=caed<O@zZJ0AH(l!LndXWRblr;69h zeuetKJseRU)q10AFrJ))ui0y!RQZUQ-x&SVJbGCDQ=W)?m-5A<MYU4s-SREJ1~>)h zkX88!nco=wpLCA>E+vQ`xD;_ux~DuL`7KMYrnz`?zx)R7fnTA{QwW(T>;FXTzp_m6 zYEZp2dY`m$E5OgsOGU1%e|425Qo@_~Zn^8S#BG4}pNRJ2DOC7^GtN)U4wUsz&o5b~ zcr=x4DSQ72u;`jZ<gBiL9D~s~EUT2P=He~=p(g?A@qJQ^vS$6qtD?k9qj$?ouZztF zl=XiyVm}U|^;0Wv0{HD?0FUmdOq7PwQXbX16IWQw6$t&S@;#K_82ytz{q*jRr#z~1 zh*>>}7PkfcKdmQF<=K&6UH|7|dt#s<xOeOSH|Pgc>rPZ*F%@o&|8PB#Dz6^-mG%EK z)O{TE82bDuq*`}ODoUgndj7ljVf(CGRX!#8S^tS>PQB1K_X6}>5f8Q`x+mPS{?%0# zYY77LQ*aG&1--=;r>uW^XB7w0*&9(vweDDzr&!poHvx((<B?}Txv>5fbrwf<djEUt zZvjrndES6}iz^-LKMn;dl<^YoC#Ey6Qr5euPhJ0WvF$NX6yHyIRO?nyol;g>|Fw1l zz-joNSCt2a{L1=26?Gp6Mb*D*-AYP~n0e<*0B0?WM{bJz8>9dG9}hqN+l+Y>`H!f< zXxRw!*Ka_~7uDOX%J)!yWAuN?y72m^JgRbtrpfTRo_N+?^R3$6nDZ;^pKLh}qOmWn z>mxt#IQHw6h|-YmDUWL1;WZg9$NRYd>&o@Go+~Cfsq*W}Z;bw3?NZij(DK4*GhAx2 z;f{ssHX4+d2<ce=;ZzzZ^}G83mS1nQ-UYst^-q2+%M?hVArfu<JwQu;W@MtSe|&zW zwaQtg5UQ=1^bfd3kJja6Ob)F7idyxL#8<Si=@C4Sx;JyWCtFq4|LKVRER(;6<tg66 zceDemGR?m+`k#W&a=ljoT!m-<t-ZT4Q5s51c~osru3dQyy`v?}(^@Rm`ZecQ*FWhX z2I{@Cvd+D1g7T==E!VPl%q#egcHX7x^{Tczru@qKr@bk05S{JkrQ=Frlt;C0Z!MRH z9ykba`)2$5AgXm5l;0TrSJHE(I8|AeYq~sy&U`-q5_MZsZEFJg)%B0-l<2%eRibyH zQAoAka%~TX?Ee_x#*F||=d1Il+RlXXv;K$GJ>u81F9V#7d+`&JyD8-=>z~#s#zFL6 zGzzKKJwoYtL+@w>=2gqiKz`Q0!vLOo4!`rVG#-1-fbI#m>iWmNaY_sn1^bjowQf>B zy`!bGr>UP;EiYsFmGw_Di-V%-U$yRSI{{|kx1ChW$!Pu<^uIqA+3C`MM)ekp%%{#* z|NisT`Fmn%3!VKI)&?j~zV)ZdzxwyzYr9^A+AiFqs7Ul4<%WC05#>>>H{bc6SN8w4 zUH8VK13K4a;A%Vr@(zC+Al#GgDNnxjr^tWz24($E?Yf9{py>Pb+3<zR`mgJ$hdRU| zQwC+wQ>(21+U_G+_ZhYS#-6`b)j#D&{lCICkU1N8DGuFR`mgU<WR1mWJjRp_88mbc z#HjyoLwyhO&6F7#G<1D4F8vSrfCn?fJKO2zICXF7zoC0P>pvs@uc7yt*!5q4QP9}^ zFf^bHm`s+zgMhk<TmP01sO$SKH1r-oLjIWyl)*u$@B3a7x))_4&leiVH`^oOvA*{L zp{Jy-xUclnxEJbCYd;k2iDEKD20h!LW6@`l(z~-<Xz2So>irL_dqeEMsdo?5^?%K& zqojYM4g3={_U%JL`)AT9gAWjg-%ncKUgbk`?=jHS_W=?xX_3ME(AfVYFWsaS22BJ1 zh`Qg+`cGZ|dv)R7ZCloOps9ZwG~u}zOlipAF=$P<zjy20eNSUgXdYMw&HV?FfGH6f z97I36gr33N@;BukTKc|+x__AUAG`jyKy%-BP0@3ucF_DIv<%ds4Lrzpplk;ZV4pfJ zsX5(BdW<6u+WWtSebplD8(u-1z_YWM0?1$&rdbFr3;%Z<IxwXSXkKt6Qa#$kMabVq z^bODBbuZFKNO`jj;+c4V4rwFO3Zyz{>-)MX`r>x51Xuzr0hRzufF-~ZU<t4USOP2o zmH<nDB~Tp_aGZ{$>*t`5<frTwh+EuvMt=u}?~kJUcZTmjNB5hC?+>H<;_&?_x*xu^ z3OwYzpF5)XLg6TY5hrGh9#LJPfE7Bbx9$g><sk=~D&1oPN5tcO(U~3_?04@>P7LwA zQvxgxr!?3;*dEfhhoZB>0RC)~Grfm@bjJ@m)1$w2`(yXL+n+nS6U*Pb`-2e<6}u0o zL$>-8M}w^X#!;cwA2}+-?gRgZ8*JS|iG!j3&JNY||43>b?H2h2U5=nT{5#yf@PC#7 zOMoT75?~3i1Xuzr0hRzufF-~ZU<t4USOP2omH<nDCBPD339tlM0xSWR084-+z!G2y zumo5FECH4POMoT75?~3i1Xuzr0hRzufF-~ZU<t4USOP2omH<nDCBPD339tlM0xSWR z084-+z!G2yumo5F8YBQesQ(g-Y5P|=re!J|(>#FJn_*1L{Ya0)nC9p4x(8`L5-?S@ z3`obQYfI;-d$a+v1+s}Tty6|<<gYcDC|4Py1Ym6Y-=RL6kZwdBzs;DgYD_2Lv){68 zXKZsL*;0(A$weg~0hm1bFc{nXL-gZUBGzAHyffZowgIxKD{!pv1G2RQOqh#|AOY0J z|AcX^MHt)iA`&pM3{tRxS7BVs5~ykVhX^LiSrjFJCohhIv2EjFZ0kcvh<PTKfhHRm z*ZLs(f*P_(MYHDYJ`#Xp@e`J;$F@Dp_O8j^+1E<?o3<xlT-z!9Y~Taph|xI4AA_`m zanHCn+y=Jeyy1@`nmK24NdU$-{};slD#LXy=@S^Mxz*da*7Y!^X|&6n`8J>g;5)US zMBL9r``MRU8{)Bc*8S>x_|9Mk*>XU$=VT}Wn9%t*IKOv4<DSP4Rbc~TTDL+?^M7M- z`%A!@-y7Gu2m5|tVi{D84b-%~gSLIVKLYZZ!W!?7ZCiwMsE3dc`%El@s<VMZP}8=6 zp9e<zIQYTzKSx`?o$bBq?4A9%XFt}u0mje#%aJI^=N<g8<BN#<&5V7<e$H%QE3RG5 z^*$qdKY}{no-@63pXk{q=FtDuv^|GD;H!D;`G>H-i?N?WyJ!46`+!|}TPFaMn*IaM zgFKA{Oe_PofwrA6Y5JG)Fb;s4+RtL2znOK;xMv%1+Cb|TnB4dm)$9YH7T5HRZ@ZKA z?$kT~W_u{v!0m*QYV`q7)4mu9$NWqz1GWK)4GdHx{?W&eLw<4n1M8h-z&2391`s11 z6RODvSnK&~+ILsrna2d|7ug0%HqfybCbl0@4L-nn=C5ggh_TOo9@{_&8yMHIxf<pI zV0`;5Bw%70unmN@fwmc09s^k8{_*YmSnpx=>1^EE{+q#!SoX86q&DeI4tOvCKB0 zWCJ&3aSQ+x+I|S*J8<03#4=zTP_lsuonx}(1FUaxjPH0xiD&j3>^s>8gbi$`v#+z_ z18|=Ar;L5>^VkOBw1LjaS-`*bZH@^YPrwA^orz_@HV}^ujPH0n3*Q*Qbqf<2_w4)F z1`@D=NuA?N9|NG?x3KOL&^`B~@${qFmg9pa;~&O%e%l!APwWIZy$H~`5Mb6afU}pY z5tTt@QJGYBw(W@9pH=;hZ~LaHJ^(SX$Ov2<KLcReA~n2M@)uM#)sa;j;&x|Qcl{>f zAC4{_j_dtiM?%?Zq>0lI`-_vqzVHiF2i2vMSFSsc@_*IDZ;ZmR)5lulVj3Ikq%+O~ zI0wi2X%N+=lUJ@gpYoqH?f;oJ20$FFHUbNsI5tlM`&0+j#TaB6q;3P#t~3e%);s@+ zoo^U{h1nOThku$AOkMYy$5+g+2|9nxDEv>F{taVrkXGL>>R~*XfQS6G;kao>n&Jas zQfDjUpZjHFY#<Llc|u2{G5DX@xy~5fi+(w+YaVQkc{Fc)F{#6teZlx1Y`66;jNXUo zrgK){^M8t@n_sS9zs+gU!SuIG=>K8zj4#6^#JwqWE@D4TGKFt$hdDNwmM$ji|9&V; z>Nv_6?33Kn#6H!Pmae%TmUX!ed|&q%0LS~aMqz;Q&p0W^Np;F(;`B4L<A2ihg^YiL zo`3RU`^+c#H0^r<unp@O|BQd5Y#=|r;b#5)|Fj2;(mjv=t8=WIA54ePU+4YW@qg^J zC*fG^|4gB@@ox&>+zxYWFg;yt(~tk@gSht(Wp4<bdl9|^NRdq8o7-WI4W_4y7YxAv z9%Haia!(WcR9AYs=DJwcZX0;p0Q~Pa3ImLPw~g?9_4<tu4Zy!y_LVpMKllWmNr>l= zRIiOQZxwXQ_;<}a<PHCTfBQZFxadZJ<M2$v3bw*=E?cPrS!u`raVS%hi1z)>!RPWc zi0abKJK<maJ+=d&Zod9D>6S^3>Sz4B{Lc(Lmop9QQyo;7Zd()B7uOFz0=W7%`+Q>E zZR7eAw-5dJ$1x|3J58Zl?EmKcA|0AaeXVBR1@7I~?Jokf4(M;6ZuwMs{fvJX{*Ips zFmp-r_@_FkF5UL!#=Y}?|Ik=q@|pVEsasA}T0i68g@4lXPi7_S0|@(6hY2?2#JlYG zi?8Cd$^~Y$*94hWQ5zWl9{4ByQr}F^0%Z4e9+2uY0hK{z(U{+aeqY$I4D;^r0|x-s z-Di&967A=!l3!;0OYol;yK>{)@BXFN0nS{iWOGK0d-9@RA3n9?pT2{~q)!HZc=w7w zbO>O>LjWh8t*_l|GqK2?@t<@5<Av{_VQ=gKD6Wjf4!IA|V~31?J#sh3-h*OYmQIWX zes(VVfeemG^y44r*=RnDNskO<xDFe>_g#R#EA{Zewi8+IjQ^bbp0GIgOaH(&fS==j zBF1DUWWe}m{QE&Vbo@K-0W7>G6ZW5XKcyZ2lM#PR`eYCq+#xdj?rGdl)TNI%wku!R zGyZe#e?q`HQc~Jmyz(Z1ALANg#-njEVEi-wBjGx1rpKQFXy`W1{`2mW82^m_uuzVi zp7s`Bb32a(3>pXN$3Kk=nY76uGME)I?id7UU%+}d!0s9U65n&e&>p}g*8*IA3&5&d z)riWtV2!=6kjged_f{JeV62pt_7>Avz$96iqLUQc!T9&UKdoh3wpI=AmHY*jO?9NG zXK7tZAdVq3?Jb^(`-x1DMGo7b9se{hY!cDF{0kGrzVHiF2i2vU_ZT3oTm<bc{?#UX zKap;E<g9+izsvv7*&G6+Nnfch-S(yo-WUsb`3->C7wVUZZk?o9KjYtp|E1TZhySHG zw~(T)J?n}QzOfW#?I+q|KOgCqO%Cd3{JZdfMGSL&LB|c3-^}=zIStxdeE!Ay<x}N8 zKtKM^LLD1Kt8T;oKSk2by9|FOgp0AjsdLS;5#2hAv3|zC%lD*-eX1))T}CR)1f2V| zf%X>nuhK6o-MUJke(m@_0r6)N(YG(sB;CCGVb7#^`c~nTd1l#*Zv83N&-i!wpER*g zbt&pkIloEx4$>CdTfBI+e!1z^TTJyc{u%#4U}s#KN4DGhiDJTmvLDT8I~f0rf8($n zv_bp+1aR>$?R8Wc#h-4!!}w?X2Zful>1c0pQ!hUgMDT%(f5yMDxDL}W+FQK(4jv1H z^a0xOPix>!qO|A#bn_ktV_8T`dy6|4nPpSDbzGr-#=pz|F#fZE^2%+by~S(p1UM1D zPgKG7Ic^p({u%$3!Ia_H!Pfw0En^>0!3Q$_8UGnZxPQ}WZ}H8zx0udbsDOjK#xedG z|NbDVf)up3`0VBE16)3k@z40L0(1vsf%X&KgL{i<KamRudA(=+Gya2tr;1X&iF=FZ zUCKTHeV}&y|8zEhNknH-rb)VauOe87Vui*68@B?Sj^8TOtpknqGyYxv=c@JT;h*Z# zsC%pKP@t*8#I(0~!PWZppvevx|1SK~xtwYB|5TSIy_f2(LP$qq>7ge9YUk_MN6PlV z_;=x-&Yw&R|Ff{Jl=WR!UnH<pX(rlROy6kGt&<e%XZ*YHf6_SsD{e^M2T&bUSBkp! ztgA|)ti;x%xVM=06Y17X3iUJoUHB)N|Gb#24<PJQ9VzJ7w+<zsRB`6_ac}V@>+Che zCg>@lHfYEHiHJ87>16Di=~;lD(c_pCq%x>1DwE1Kh36`Ua4f8C+X>LpZ;rlVwj0L3 zq5dakJxl#fEFh`!LTEqHwYZ<?B-~GwmR?e-M?d~arcByoQ035#mAz+hZ}IdCs>)s& z|2g+Pu>vFqg;`^PyYLwf_Y+k?zs2}x{O16*@v}&Ki)Z70qKx|h#y{gfesJWZMA}<? z%dhSIL>cu1jDN;|PSBbho4ast@wpdeax9=7|1_t{q)!IPp(0-;JNFZ1$etPhIrl&L zLTU2c(%#~(%k=s4kiJJh{&6h~t%GIKBZK4-k-r+OvB1Ojw+aonYsP=heNX<-nI6Y` z{|K-Mzg1|k4`BQ={?h|QPHUpQ#r54r`T@p2<3FdUOqXriTfFo-<9vX2{L@|-6Y11B z01NS4nA`3L*zy#>qdU}y%DC;fc9~SRDLki(g8bGd@NbIVhuXpT_wYY7R=eu~HN02y z7gRRYVS+8>7m?|4z6Hns4Tk93Wpj*w7yeJ3n=JN)Umzb~!Z;v36y&caG5_bnoDqJX z`+x8Lci}b3_4@+*;!4jKx@{wWs7#(?djEGXzW<v#PrrVB>`Q<DkNc=-FO?~D`{wlU zPjd^p`A!}U`KsZ~y#U=;=+|pNyJGyid`}#6eWHK&yV851fO<+SUB1wn9Jiv+KZE*w zmrWUbuO0vNT}G4WF`Va5lXUZ*93t{l@~$@l=B_l$jsw~i<KN|f(!@U16;Mx!rOOXW z<L8w2|E~WHp5=<)5Yny3i1jo68UOJEBL^kE^g8bOU6EPb3xCD<XZ+^?t?{u)=YOre z+dlInVxKFpXTp{7&-jlI993S@&X)jYF3EhK@ANzR@sDd^e`XLp_6*MdrAWGYuX4!7 z!k*~!Pnm0$t;S$GjDMH!NfY~2R}4B-DkBzvRB<6sJPXjiP`{oMvJ>t2KLzn;5~YoQ z-Mm*Zbd^}6?|)r>i~XFhTSp1j&-i!wAI85DY^owN*+AO@v-+ikd;sI0@n02~je`l= zqf6gtObBZxmdp5O{Ko;E%$GsyzUiCaIl?~i%J^seXCAvM4EzDl=(}J|?r<;s9OIwy zuL7UU=B52Q_u(Ghx~|+}--!O7&f74F(w_g*&3k46tkCecaF1^9m1fzUCOgpH|7+q~ z>T?+X3cxZvtJUZK4xkqMeADbsQ+pWyb4S_&<KJ))M{FML(LL|tk-FA|Ig@-3<KKn< zt-nui{V&y}+Xf@TDKpt<kM6B_?lPUrp<C7l*3bBN;r|Ximou#~z#Vwjm~NZM43Hx< zl=kS(`GtNN>(+6F`WgQ&{4d6LL22QC@oN3H839(tXQBN%*Ke@T<)~o$+I_D9ZDahq z@PGRG02>}k-Ur-`bx>WpZNPXihioA2(LHOKe!1$_Yry)o<NvfGfN9j+hdy8nz~i_M zJP9J~QynJo9THx~CU^GvCdf4-ZD9OM{LdNKH`BT>nh$sw=jl}a0F^;yQJGY>DfVG3 zphGn5$>#v3EjCA{rsy_6I~e~G{0ESoF&I{9YY3Pbmz4JCUb!Bh^K-641^qMrb8b7v zfjVG=Xgt<|=W;06KIbj!-~Yq<(`&;4;bu&-clQA-zh1A*s>o)!|L6YS7;O7Bgw{(n z_cQim^gr71PtR^l`efjTITPY(kM7cI^~tD;?UV7JbN^!kzGZEo{W_a)zfNNhV?QAu zq#ytI?Iiks5|bVo$grn7oc8GUuhJuv9N8k{Kj*$jH_qMlTYdf`0QKFB{S^EV<Dc>G zhM>0lS8$K+`Ij>8Q^36#Z!`WG|Jv{_Dn4)!;I{h#rp{;Vr-*&>0ow6@I^vH>p9};B zbzNzXE}hH4HlRfYjQ^bbA6>ZL|1rP~_W(@6y%3CjZ8Bi|OYl#2SzHOQ@+N>aci7*2 zrtdySB`Sl;qB2dgc^&wF_GN&xmus_m_BX>euOI*9{|%!C{0_t=zlx4^KOaG5QynJo zohJTik1qB3bQYHhymK22c>XsG`%}<1lf=I84O9o!rP~J6!awcNoxVgrzq;jZSpD4p zyZlcP=b{8glfF`2y6qup{L>!Yt8TT=JkZT6*I#CDjDHvYFJ6}({#X98OdqNHgCz0) z_)hy=4#u5+Jxg?;9shI|j!CrU&h+q4bAq~gPXhn6NB5!|_4B4%wi&3O@$d3KNssy6 z<?FVQxbaWtoV6`r?CaKR!1@{gjQ?0%0PWFTww7@p5O><geM9ZkkAIv4r}sgo(Ao_+ z{!fu~^Q{Vg+@cG=#{D{UE{ASjxc<;OVEns$Pny`Lx^&yD3jS%2?!eXhdC)Dx4A;;2 zXZ(kD0klV#`us**!<1p$=eEz&zy62Tz?ekz-KaE4H}7G=|H@kcU6+}qGu<-GaQ)i* zf74`_CibZ=-TVi|Kkd<_@0HTI9J<@g^~a_I#=mR)!T2xt2rs{Z=W<*Uo6WKRaM_$j zf5!NC>4Nb;jQ@{52Dot}KrNojq0vUUZkLV5s0+rwOSX)E8~?ON_nZ}s`xvoiq94<b zf9hKdqSW!PnfKw|-u^{<bg9pu+GUnrOtO(GY767vg}*%G|EcEzW-eviR}sGb`d01u zr!yZ-qO|d^n|JE<X^-yJx7pA6y4%C`r-}#0zsvvR4gcG4kM0aSmxFPaOBon||NTbU zUf%G}xX&f-hcNe{0r=m;_;-y#7?XL%WSYFcZ2<lUjl%!6ccr)fm+E4yvkdgvzzf>( zU)%Mh^{$EDH<>_}-i-LiGiuWyIs?=Mp1BQq)x$Oe@c#hRBKA$8`Ip&eRMR=tNz=Sb z&EeY=`Extcvx%u)_iM+0-Td{&V4rM{_P1PqOY+#i9LE_{7u!3_K%WiVtR4TgU5kvv zK+8a~K7g=Kb?DPO*UhpAsGC2aAOGETM&f|_W_lL56ra(ryIXxcKxI%_H0I}ipZ$*+ z{%4AI{MXMr+DQDHp&!Nx>mfNCn9}_(+VNl8^`9C42F-sm-jc(cX|k;^en~g}seY*I z+Jl78BTOs<wt<vvK>OMs;d`L2dmR$upNVC_Hjt7H+#v9$`wF$)tts)$K8AfP+kj;Q zJ&n3?FY1Td-oI!3bHB$nkfIILb{{G5r~3*87=^ljgY}=H{&^gb=W)Pmx-svrAK!_u zX8be$*#=Uuf%@(%-T2dekM97-vhGvRJ@@;0?)PopYkPj68}IJ=p;$Z|#{jQeYvX8r z923icZ6FC7coimJf0!G8Cfq}P&mtt`nTch<Hjs!7H1uj;^Dpx?$nUok;hFsj`&708 z+XfoCzA3|>3E_zUt?UE1?_(QCzy=z69x?&rmD&Ie-4j{&3Fw~tKOO_s_109vo(XZ* zT7deVC)fwD4`3UJ)dsc^woJgdUmKvI_e5ww-kDeiYy<JwKx5Adez-Fwp59L&_IEP= z*$1)>#9{+GOnv7k^EuGi^CQ-MEV_@U|KNT&!*c8$E5n(|;fVj6@mT=n&crfc8;EEF zH<^s_N^Qe^fnPzM_eJEJ{RaEZyxYM0IEQ*fC5)LKhuELZ_~*WlZ6JaTH1^Ik9p8TK zgUQ7|gQngs(1`pqu?*M-g4)1?P`mPze)!5ly!9N=)VqiA&pwcCAdn60&HA|C?U$gb ze;n%m5bHmX{)6@roUZ!D6NjLoZ=xGN)o_pR1eY-W8UJhp6>OlXcVRVP-HQinE}*gR zZfNYoev64^z&0?#1{(VAtd?hfFZ@#&)D}OBJU=jkZ}tW34|8t={ac~2_!lzVR8u(A zF8Gho*uR~90QY}v1C|Xu3vFHhshTkE#|tzq7!6H*yO4m1WsqYVKnyGx?T43Yj34p= zJ94ah_I>&G|Ia~7|5vLK+d+APjzwQY{6D}xfX4v&vH^VFZ|VN)pg5`KbTED3&r$bx z<V*jI_Z;i{9yq=4FRK~TVR^&z!*QK(-x8!lIkpRa9;oi;0kZAI)&7nzEcTu0p}B87 zt`*#agjir=8B~o8ybI0!lbyECzr7^@ZMbi^X<#$T3z%32RbT@T<5?L0-P_tZ)Jp<1 zM#8z#*{J*Xt3dzk$MWgR_o3~c#p8W1ga-?Q`bGa0S_W3J58!b?#%y3cv}gP5j$jB) zCl&gD8r1*JjM)J9`?>4)`<_8t&E$D~=^#0JE%=V$6GMLB5%vQ-9x%)Xp2X+XQ|LRm z(Jh>F29bdEjgjWQi74-_hS>o3_qpru`?ug+^@OZ^n<EImvXlnT7W-c~Cg_Fcf!C1` ze@rZc#BE>?_B$(~al!Yqgq3P;D~-G8xeHncR^#<eBw%70#ApL=TDH~NKZeKnW_uFr z`Ji>+8>stMd`4J@*F7=X0sEDF_}1M+w$qBZ{|1lm&BnZ6>+rmRFXOoQXlPwfi@snX zUf1LG0o3smIF@@JfA2-wkHq+{ZW)k{_u4u~-IEPiw$Q)8vJJ8kv=u*l;&_$-OMoT7 z5?~3i1Xuzr0hRzufF-~ZU<t4USOP2omH<nDCBPD339tlM0xSWR084-+z!G2yumo5F zECH4POMoT75?~3i1Xuzr0hRzufF-~ZU<t4USOP2omH<nDCBPD339tlM0xSWR084-+ zz!G2yumo5FaY+F9k0k)G$!$-EjgZO#cXXj}lruqD_CjGq0p-~X1xNl5z2Zj?Cm8gK z|I~2&Ca-u$1H7Ug4e;QOavUWp@N8b8Fj~aF<6ZveMEoZ2_)-Il-tnVEe(&fK1BYbs zbL|2o?3EaluvcPG!d{^;#NeQ;{vi$d#cz`39}-Ye7C$5)S+o$4PrOuog@PqOzj zhXgDY?~s7~;-%srQh;jwpj<wd0{!AQ$@5zZRE;nClwTp<GLWR><;}AVkmKbNAO|In zW%;pu*ghB^atSa#7$0)$;QoX0!T4Z&$R)t|V0_4}gZmH02jjzZd=%vkOlAJ3LjAGK ze{GWUXO%xFj}J6|r#gS=m)~!G(=Xm{e${V&)-S%o1QfZyN)Et|FUk}!G(Wvb7C$t< zJt&J0IDalxz|j1>RKTO{_DcnPlpQY>;9NUiD!`#=D0(wEBp#2KPj>nt@c>8|EWKZm zFgRM&FJbT~5ien&6kYyyj2c*K9>Ad8;F14_v`3|QHgNQC`JN4MM0+*BQN3pc&f+~p OJR-ZZD*l}-3H*N<nP!Xt literal 0 HcmV?d00001 diff --git a/packaging/windows/proton-stream.wixl.wxs b/packaging/windows/proton-stream.wixl.wxs new file mode 100644 index 0000000..42aa34f --- /dev/null +++ b/packaging/windows/proton-stream.wixl.wxs @@ -0,0 +1,137 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + The Linux-hosted .msi definition, built by scripts/build-windows.sh with + msitools' wixl: + + wixl -a x64 -o dist/proton-stream-0.1.0-x64.msi packaging/windows/proton-stream.wixl.wxs \ + -D Version=0.1.0 -D BinDir=... -D MpvDll=... -D IconFile=... + + Why a second file at all, next to proton-stream.wxs + --------------------------------------------------- + The WiX toolset cannot build an .msi on Linux. Not "is unsupported" — it + fails outright, in WixToolset.Core's BundleValidator.GetCanonicalRelativePath, + which validates every Directory/@Name by prepending a drive letter and asking + .NET to canonicalise it: + + Path.GetFullPath("C:\\" + name) // on Linux: "/cwd/C:\name" + if (fullPath.StartsWith("C:\\")) // never true off Windows + + so every <Directory Name="..."> in any .wxs is rejected with WIX0389. That is + true of WiX v5 and v6; v7 additionally requires accepting the Open Source + Maintenance Fee EULA. There is no workaround in the XML, because the check + runs before anything the document can influence. + + wixl (from GNOME's msitools) writes the MSI tables directly and needs no + Windows anything, but it speaks the WiX **v3** schema and implements a subset + of it — hence this file rather than a preprocessor branch inside the v4 one. + + What this loses against the WiX build + ------------------------------------- + The dialog UI. WixUI_InstallDir, and with it the license page and the + "choose a folder" page, is a WiX extension with no wixl equivalent, so this + .msi installs with the plain Windows Installer progress UI and always lands in + Program Files. Everything that matters after that — per-machine install, + major-upgrade replacement, Add/Remove Programs entry with the icon, the PATH + entry, clean uninstall — is authored here and identical. + + Keep in sync with proton-stream.wxs + ----------------------------------- + UpgradeCode and every Component Guid are shared with that file, deliberately. + They are what lets an .msi built here upgrade one built on Windows (and the + reverse) instead of the two stacking up or corrupting each other's component + reference counts. Never regenerate them; a new *payload file* gets a new + Component with a new Guid, in both files. +--> +<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi"> + + <Product Id="*" + Name="Proton Stream" + Language="1033" + Version="$(var.Version)" + Manufacturer="Nils Pukropp" + UpgradeCode="0a5c3818-3401-469c-a2ac-a3172cef1938"> + + <Package InstallerVersion="500" + Compressed="yes" + InstallScope="perMachine" + Manufacturer="Nils Pukropp" + Description="Netflix-style desktop client for Proton Drive public links" /> + + <!-- Replace an older install rather than sitting beside it. Scheduled after + InstallInitialize so the old product is torn down before the new files + land: the reverse order leaves the uninstall of 0.1.0 deleting files + 0.2.0 just wrote. --> + <Upgrade Id="0a5c3818-3401-469c-a2ac-a3172cef1938"> + <UpgradeVersion Minimum="0.0.0" IncludeMinimum="yes" + Maximum="$(var.Version)" IncludeMaximum="yes" + Property="OLDERVERSIONBEINGUPGRADED" /> + <UpgradeVersion Minimum="$(var.Version)" IncludeMinimum="no" + OnlyDetect="yes" + Property="NEWERVERSIONDETECTED" /> + </Upgrade> + <Condition Message="A newer version of Proton Stream is already installed."> + NOT NEWERVERSIONDETECTED + </Condition> + <InstallExecuteSequence> + <RemoveExistingProducts After="InstallInitialize" /> + </InstallExecuteSequence> + + <Media Id="1" Cabinet="proton_stream.cab" EmbedCab="yes" /> + + <Icon Id="proton-stream.ico" SourceFile="$(var.IconFile)" /> + <Property Id="ARPPRODUCTICON" Value="proton-stream.ico" /> + <Property Id="ARPURLINFOABOUT" Value="https://github.com/narrrl/proton-stream" /> + <Property Id="ARPNOREPAIR" Value="1" /> + + <Directory Id="TARGETDIR" Name="SourceDir"> + <Directory Id="ProgramFiles64Folder"> + <Directory Id="INSTALLDIR" Name="Proton Stream"> + + <Component Id="AppExe" Guid="623c8cb8-b826-4d57-a054-e5be56f24c19" Win64="yes"> + <File Id="AppExe" Source="$(var.BinDir)/proton-stream.exe" + Name="proton-stream.exe" KeyPath="yes"> + <!-- Not advertised: an advertised shortcut needs the feature + state machine that the dialog-less install does not drive. --> + <Shortcut Id="StartMenuShortcut" + Directory="ProgramMenuFolder" + Name="Proton Stream" + Description="Browse and stream a Proton Drive public link" + WorkingDirectory="INSTALLDIR" + Icon="proton-stream.ico" + Advertise="no" /> + </File> + </Component> + + <!-- The CLI, and the PATH entry that makes it worth installing. Tied + to the same component so uninstalling takes the PATH edit with + it. --> + <Component Id="CliExe" Guid="6440650e-281f-4842-a75a-4cbea8be0d71" Win64="yes"> + <File Id="CliExe" Source="$(var.BinDir)/pstr.exe" Name="pstr.exe" KeyPath="yes" /> + <Environment Id="PstrPath" Name="PATH" Value="[INSTALLDIR]" + Part="last" Action="set" System="yes" Permanent="no" /> + </Component> + + <!-- libmpv does the demuxing and decoding. There is no static build + of it worth shipping, so the DLL sits beside the exe. --> + <Component Id="MpvDll" Guid="ff74fc79-78e3-4ed8-881d-f322ed6ff6b4" Win64="yes"> + <File Id="MpvDll" Source="$(var.MpvDll)" Name="libmpv-2.dll" KeyPath="yes" /> + </Component> + + <Component Id="ReadMe" Guid="5bb65b44-f5df-423b-8dcc-a753d7aef565" Win64="yes"> + <File Id="ReadMe" Source="$(var.ReadMe)" Name="README.md" KeyPath="yes" /> + </Component> + + </Directory> + </Directory> + <Directory Id="ProgramMenuFolder" /> + </Directory> + + <Feature Id="Main" Title="Proton Stream" Level="1"> + <ComponentRef Id="AppExe" /> + <ComponentRef Id="CliExe" /> + <ComponentRef Id="MpvDll" /> + <ComponentRef Id="ReadMe" /> + </Feature> + + </Product> +</Wix> diff --git a/packaging/windows/proton-stream.wxs b/packaging/windows/proton-stream.wxs new file mode 100644 index 0000000..7525aac --- /dev/null +++ b/packaging/windows/proton-stream.wxs @@ -0,0 +1,82 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + WiX v4/v5/v6 installer definition. Built by scripts/build.ps1: + + wix build -arch x64 -ext WixToolset.UI.wixext packaging/windows/proton-stream.wxs ` + -d Version=0.1.0 -d BinDir=... -d MpvDll=... -d IconFile=... -d LicenseRtf=... -d ReadMe=... ` + -o dist/proton-stream-0.1.0-x64.msi + + UpgradeCode is fixed forever: it is what makes 0.2.0 replace 0.1.0 instead of + installing beside it. Never regenerate it. +--> +<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs" + xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui"> + + <Package Name="Proton Stream" + Manufacturer="Nils Pukropp" + Version="$(var.Version)" + UpgradeCode="0a5c3818-3401-469c-a2ac-a3172cef1938" + Scope="perMachine" + Compressed="yes" + Language="1033"> + + <SummaryInformation Description="Netflix-style desktop client for Proton Drive public links" /> + + <MajorUpgrade AllowSameVersionUpgrades="yes" + DowngradeErrorMessage="A newer version of Proton Stream is already installed." /> + <MediaTemplate EmbedCab="yes" /> + + <Icon Id="AppIcon" SourceFile="$(var.IconFile)" /> + <Property Id="ARPPRODUCTICON" Value="AppIcon" /> + <Property Id="ARPURLINFOABOUT" Value="https://github.com/narrrl/proton-stream" /> + <Property Id="ARPNOREPAIR" Value="1" /> + + <!-- 6432 resolves to Program Files (x64) because the build passes -arch x64. --> + <StandardDirectory Id="ProgramFiles6432Folder"> + <Directory Id="INSTALLFOLDER" Name="Proton Stream" /> + </StandardDirectory> + <StandardDirectory Id="ProgramMenuFolder" /> + + <!-- The Guids are written out rather than left to WiX's deterministic + generation, and they match packaging/windows/proton-stream.wixl.wxs + one for one. That is what lets the Linux-built .msi and this one + upgrade each other instead of double-counting components. A new + payload file gets a new Component and a new Guid, in both files. --> + <ComponentGroup Id="AppComponents" Directory="INSTALLFOLDER"> + <Component Guid="623c8cb8-b826-4d57-a054-e5be56f24c19"> + <File Id="AppExe" Source="$(var.BinDir)\proton-stream.exe" KeyPath="yes" /> + <Shortcut Id="StartMenuShortcut" + Directory="ProgramMenuFolder" + Name="Proton Stream" + Description="Browse and stream a Proton Drive public link" + Icon="AppIcon" + Advertise="yes" /> + </Component> + + <!-- The CLI, and the PATH entry that makes it worth installing. Tied to + the same component so uninstalling takes the PATH edit with it. --> + <Component Guid="6440650e-281f-4842-a75a-4cbea8be0d71"> + <File Id="CliExe" Source="$(var.BinDir)\pstr.exe" KeyPath="yes" /> + <Environment Id="PstrPath" Name="PATH" Value="[INSTALLFOLDER]" + Part="last" Action="set" System="yes" Permanent="no" /> + </Component> + + <!-- libmpv does the demuxing and decoding. There is no static build of it + worth shipping, so the DLL sits beside the exe. --> + <Component Guid="ff74fc79-78e3-4ed8-881d-f322ed6ff6b4"> + <File Id="MpvDll" Source="$(var.MpvDll)" KeyPath="yes" /> + </Component> + + <Component Guid="5bb65b44-f5df-423b-8dcc-a753d7aef565"> + <File Id="ReadMe" Source="$(var.ReadMe)" Name="README.md" KeyPath="yes" /> + </Component> + </ComponentGroup> + + <Feature Id="Main" Title="Proton Stream" Level="1"> + <ComponentGroupRef Id="AppComponents" /> + </Feature> + + <WixVariable Id="WixUILicenseRtf" Value="$(var.LicenseRtf)" /> + <ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" /> + </Package> +</Wix> diff --git a/scripts/build-windows.sh b/scripts/build-windows.sh new file mode 100755 index 0000000..877874f --- /dev/null +++ b/scripts/build-windows.sh @@ -0,0 +1,284 @@ +#!/usr/bin/env bash +# +# Windows release build + packaging, hosted on Linux. No Windows, no Wine. +# +# scripts/build-windows.sh --fetch-mpv # everything, fetching libmpv +# scripts/build-windows.sh --mpv-dev ~/mpv-dev # everything, using a local copy +# scripts/build-windows.sh --skip-build msi # repackage what is already staged +# +# Artifacts land in dist/: +# dist/windows/ the portable payload +# dist/proton-stream-<version>-x86_64-windows.zip +# dist/proton-stream-<version>-x64.msi +# +# This is the cross-compiled counterpart to scripts/build.ps1, which needs MSVC +# and WiX and therefore a real Windows box. Two things differ, and both are +# deliberate: +# +# * The target is x86_64-pc-windows-gnu, not -msvc. mingw-w64 is a real cross +# compiler; MSVC is not one you can install here. The ABI difference is +# invisible to this application — everything it links is either Rust or +# compiled from source by a build script (aws-lc-sys, libsqlite3-sys), and +# the one prebuilt dependency, libmpv, is loaded through a DLL import +# library that mingw ships an equivalent of. +# +# * The .msi is built by wixl (msitools), not WiX, from +# packaging/windows/proton-stream.wixl.wxs. WiX cannot run off Windows at +# all; that file's header explains exactly where it breaks. The cost is the +# installer's dialog UI. See the same header. +# +# Prerequisites (Arch names; every distro has them): +# mingw-w64-gcc msitools zip pacman -S mingw-w64-gcc msitools zip +# rustup target add x86_64-pc-windows-gnu +# osslsigncode only for --sign + +set -euo pipefail + +readonly ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly DIST="$ROOT/dist" +readonly OUTDIR="$DIST/windows" +readonly PKG="$ROOT/packaging/windows" + +readonly PKGNAME="proton-stream" +readonly GUI_BIN="proton-stream.exe" +readonly CLI_BIN="pstr.exe" + +# The mpv-dev archive: libmpv-2.dll plus the import library to link against. +# mpv's *player* build ships neither. +readonly MPV_INDEX="https://sourceforge.net/projects/mpv-player-windows/rss?path=/libmpv" + +VERSION="$(grep -m1 '^version' "$ROOT/Cargo.toml" | cut -d'"' -f2)" +readonly VERSION + +# ---------------------------------------------------------------- options --- + +TARGET="x86_64-pc-windows-gnu" +MPV_DEV="${MPV_DEV_DIR:-}" +FETCH_MPV=0 +SKIP_BUILD=0 +SIGN_CERT="" +SIGN_KEY="" +TIMESTAMP_URL="http://timestamp.digicert.com" +TARGETS=() + +usage() { + cat <<EOF +usage: scripts/build-windows.sh [options] [artifact ...] + +artifacts + binaries cargo build --release --target ${TARGET}, then stage dist/windows + zip dist/${PKGNAME}-${VERSION}-x86_64-windows.zip + msi dist/${PKGNAME}-${VERSION}-x64.msi (needs wixl) + all all of the above (the default) + +options + --mpv-dev <dir> directory holding libmpv-2.dll and libmpv.dll.a (or mpv.def) + --fetch-mpv download the latest mpv-dev archive into dist/mpv-dev + --target <triple> default ${TARGET} + --skip-build reuse target/<triple>/release from a previous run + --sign <cert.pem> Authenticode-sign the binaries and the .msi (needs --sign-key) + --sign-key <key> the private key for --sign + --timestamp <url> RFC 3161 timestamp server, default ${TIMESTAMP_URL} +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --mpv-dev) MPV_DEV="$2"; shift 2 ;; + --fetch-mpv) FETCH_MPV=1; shift ;; + --target) TARGET="$2"; shift 2 ;; + --skip-build) SKIP_BUILD=1; shift ;; + --sign) SIGN_CERT="$2"; shift 2 ;; + --sign-key) SIGN_KEY="$2"; shift 2 ;; + --timestamp) TIMESTAMP_URL="$2"; shift 2 ;; + -h|--help) usage; exit 0 ;; + -*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; + *) TARGETS+=("$1"); shift ;; + esac +done + +[[ ${#TARGETS[@]} -eq 0 ]] && TARGETS=(all) +for t in "${TARGETS[@]}"; do + case "$t" in + binaries|zip|msi|all) ;; + *) echo "unknown artifact: $t" >&2; usage >&2; exit 2 ;; + esac +done +if [[ " ${TARGETS[*]} " == *" all "* ]]; then + TARGETS=(binaries zip msi) +fi + +log() { printf '\033[35m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[33mwarning:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +# ------------------------------------------------------------------ libmpv --- + +# Newest x86_64 mpv-dev build. The "v3" variants are x86-64-v3 microarchitecture +# builds and would refuse to load on older CPUs, so they are filtered out. +fetch_mpv() { + local dest="$DIST/mpv-dev" + if [[ -f "$dest/libmpv-2.dll" ]]; then + log "reusing $dest" + MPV_DEV="$dest" + return + fi + + have curl || die "--fetch-mpv needs curl" + have 7z || die "--fetch-mpv needs 7z (p7zip)" + + local url + url="$(curl -sL --max-time 60 "$MPV_INDEX" \ + | grep -o '<link>[^<]*mpv-dev-x86_64-2[^<]*\.7z/download</link>' \ + | sed 's|<link>||; s|</link>||' | head -1)" + [[ -n "$url" ]] || die "could not find an mpv-dev build in the SourceForge feed" + + log "downloading $(basename "$(dirname "$url")")" + mkdir -p "$dest" + curl -L --max-time 600 -o "$dest.7z" "$url" + 7z x -y -o"$dest" "$dest.7z" >/dev/null + rm -f "$dest.7z" + MPV_DEV="$dest" +} + +# Resolves MPV_DEV to a directory containing both libmpv-2.dll and an import +# library the linker will find as -lmpv. +resolve_mpv() { + [[ $FETCH_MPV -eq 1 ]] && fetch_mpv + + [[ -n "$MPV_DEV" ]] || die "$(cat <<EOF +no libmpv development files. Either pass --fetch-mpv, or download the mpv-dev +archive from + https://sourceforge.net/projects/mpv-player-windows/files/libmpv/ +unpack it, and pass --mpv-dev <dir> or set MPV_DEV_DIR. +EOF +)" + [[ -d "$MPV_DEV" ]] || die "--mpv-dev path does not exist: $MPV_DEV" + + local dll + dll="$(find "$MPV_DEV" -name libmpv-2.dll -print -quit)" + [[ -n "$dll" ]] || die "libmpv-2.dll not found under $MPV_DEV" + MPV_DIR="$(dirname "$dll")" + MPV_DLL="$dll" + + # The archive normally ships libmpv.dll.a, which is exactly what + # `-lmpv` resolves to under mingw. Older ones ship only the .def. + if [[ ! -f "$MPV_DIR/libmpv.dll.a" ]]; then + [[ -f "$MPV_DIR/mpv.def" ]] \ + || die "neither libmpv.dll.a nor mpv.def in $MPV_DIR — not an mpv-dev build" + have x86_64-w64-mingw32-dlltool || die "need x86_64-w64-mingw32-dlltool to build the import library" + log 'generating libmpv.dll.a from mpv.def' + # -D matters: without it the import library would reference mpv.dll, and the + # file that actually ships is libmpv-2.dll. + x86_64-w64-mingw32-dlltool -d "$MPV_DIR/mpv.def" -D libmpv-2.dll \ + -l "$MPV_DIR/libmpv.dll.a" + fi +} + +# ------------------------------------------------------------------- build --- + +build() { + if [[ $SKIP_BUILD -eq 1 ]]; then log 'skipping cargo build'; return; fi + + have x86_64-w64-mingw32-gcc || die 'no mingw-w64 cross compiler (pacman -S mingw-w64-gcc)' + rustup target list --installed | grep -qx "$TARGET" \ + || die "rust target $TARGET not installed — rustup target add $TARGET" + + # The dev-only path patch; say so plainly rather than deep in a resolve error. + if grep -q '^\[patch\.crates-io\]' "$ROOT/Cargo.toml" && [[ ! -d "$ROOT/../proton-sdk-rs" ]]; then + die 'Cargo.toml still carries [patch.crates-io] -> ../proton-sdk-rs, but that checkout is missing.' + fi + + log "cargo build --release --target $TARGET" + # libmpv2-sys emits `cargo:rustc-link-lib=mpv` and nothing else, so the search + # path has to come from outside. Appended rather than assigned: a RUSTFLAGS + # already in the environment is the caller's, and dropping it silently changes + # what gets built. + RUSTFLAGS="${RUSTFLAGS:-} -L native=$MPV_DIR" \ + cargo build --release --locked --target "$TARGET" --bin proton-stream --bin pstr \ + --manifest-path "$ROOT/Cargo.toml" +} + +stage() { + log 'staging into dist/windows' + local rel="$ROOT/target/$TARGET/release" + for f in "$GUI_BIN" "$CLI_BIN"; do + [[ -f "$rel/$f" ]] || die "$f missing from $rel — drop --skip-build" + done + + rm -rf "$OUTDIR" + mkdir -p "$OUTDIR" + install -m755 "$rel/$GUI_BIN" "$rel/$CLI_BIN" "$MPV_DLL" "$OUTDIR/" + install -m644 "$ROOT/README.md" "$OUTDIR/" + + if [[ -n "$SIGN_CERT" ]]; then + sign "$OUTDIR/$GUI_BIN" + sign "$OUTDIR/$CLI_BIN" + fi +} + +# signtool.exe has no Linux build; osslsigncode produces the same Authenticode +# signature from a PEM certificate and key. +sign() { + local path="$1" + have osslsigncode || die 'signing needs osslsigncode' + [[ -n "$SIGN_KEY" ]] || die '--sign needs --sign-key' + log "signing $(basename "$path")" + osslsigncode sign -certs "$SIGN_CERT" -key "$SIGN_KEY" \ + -h sha256 -ts "$TIMESTAMP_URL" \ + -n 'Proton Stream' -i 'https://github.com/narrrl/proton-stream' \ + -in "$path" -out "$path.signed" >/dev/null + mv "$path.signed" "$path" +} + +# ---------------------------------------------------------------- portable --- + +make_zip() { + local out="$DIST/${PKGNAME}-${VERSION}-x86_64-windows.zip" + log "zip -> $(basename "$out")" + have zip || die 'no zip on PATH' + rm -f "$out" + (cd "$OUTDIR" && zip -qr9 "$out" .) +} + +# --------------------------------------------------------------------- msi --- + +make_msi() { + have wixl || die 'the .msi needs wixl (pacman -S msitools / apt install msitools)' + + local out="$DIST/${PKGNAME}-${VERSION}-x64.msi" + log "msi -> $(basename "$out")" + rm -f "$out" + + # wixl reads the payload out of dist/windows, so a --sign run signs the + # binaries before they are packed rather than only the .msi around them. + wixl --arch x64 \ + -D "Version=$VERSION" \ + -D "BinDir=$OUTDIR" \ + -D "MpvDll=$OUTDIR/libmpv-2.dll" \ + -D "IconFile=$PKG/proton-stream.ico" \ + -D "ReadMe=$OUTDIR/README.md" \ + -o "$out" "$PKG/proton-stream.wixl.wxs" + + [[ -n "$SIGN_CERT" ]] && sign "$out" + return 0 +} + +# -------------------------------------------------------------------- main --- + +mkdir -p "$DIST" +# Only the compile and the staging copy need libmpv; repackaging out of +# dist/windows does not, so `--skip-build msi` works with no mpv-dev at hand. +[[ " ${TARGETS[*]} " == *" binaries "* ]] && resolve_mpv + +for t in "${TARGETS[@]}"; do + case "$t" in + binaries) build; stage ;; + zip) [[ -d "$OUTDIR" ]] || die 'nothing staged — run the binaries artifact first'; make_zip ;; + msi) [[ -d "$OUTDIR" ]] || die 'nothing staged — run the binaries artifact first'; make_msi ;; + esac +done + +log 'done — dist/' +find "$DIST" -maxdepth 1 -type f -printf ' %10s bytes %f\n' | sort -k3 diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..8d504f6 --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,265 @@ +<# +.SYNOPSIS + Release build + packaging for Windows: a portable .exe and an .msi installer. + +.DESCRIPTION + The Unix side is scripts/build.sh; this is the half that needs MSVC and WiX. + + libmpv is the one thing that makes a Windows build of this repo different from + `cargo build`. libmpv2-sys emits `cargo:rustc-link-lib=mpv`, so the linker + wants an import library called mpv.lib, and the process wants libmpv-2.dll at + run time. Neither ships with mpv's Windows *player* build — they come from the + separate "mpv-dev" archive: + + https://sourceforge.net/projects/mpv-player-windows/files/libmpv/ + + Unpack it somewhere and point this script at it with -MpvDev or $env:MPV_DEV_DIR. + If it contains mpv.def but no mpv.lib, the import library is generated here + with lib.exe. + + There is no static libmpv worth shipping, so "standalone .exe" means the exe + plus libmpv-2.dll next to it — which is what the portable .zip contains. + +.EXAMPLE + scripts\build.ps1 -MpvDev C:\mpv-dev + scripts\build.ps1 -Artifacts msi -SkipBuild +#> + +[CmdletBinding()] +param( + # Directory holding libmpv-2.dll plus mpv.lib or mpv.def. + [string] $MpvDev = $env:MPV_DEV_DIR, + + # exe | zip | msi | all + [ValidateSet('exe', 'zip', 'msi', 'all')] + [string[]] $Artifacts = @('all'), + + # Reuse target\release from a previous run. + [switch] $SkipBuild, + + # Rust target triple; the default is the host. + [string] $Target = 'x86_64-pc-windows-msvc', + + # Authenticode signing, if you have a certificate. Both or neither. + [string] $SignCertThumbprint, + [string] $TimestampUrl = 'http://timestamp.digicert.com' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$Root = Split-Path -Parent $PSScriptRoot +$Dist = Join-Path $Root 'dist' +$OutDir = Join-Path $Dist 'windows' +$Pkg = Join-Path $Root 'packaging\windows' + +$Version = (Select-String -Path (Join-Path $Root 'Cargo.toml') -Pattern '^version\s*=\s*"([^"]+)"' ` + | Select-Object -First 1).Matches[0].Groups[1].Value + +function Log ($m) { Write-Host "==> $m" -ForegroundColor Magenta } +function Warn ($m) { Write-Host "warning: $m" -ForegroundColor Yellow } +function Die ($m) { Write-Host "error: $m" -ForegroundColor Red; exit 1 } + +if ($Artifacts -contains 'all') { $Artifacts = @('exe', 'zip', 'msi') } + +# ------------------------------------------------------------------ libmpv --- + +function Resolve-MpvDev { + if (-not $MpvDev) { + Die @" +no libmpv development files. Download the mpv-dev archive from + https://sourceforge.net/projects/mpv-player-windows/files/libmpv/ +unpack it, and pass -MpvDev <dir> or set MPV_DEV_DIR. +"@ + } + if (-not (Test-Path $MpvDev)) { Die "-MpvDev path does not exist: $MpvDev" } + + $dll = Get-ChildItem -Path $MpvDev -Filter 'libmpv-2.dll' -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $dll) { Die "libmpv-2.dll not found under $MpvDev" } + $dir = $dll.DirectoryName + + $lib = Join-Path $dir 'mpv.lib' + if (-not (Test-Path $lib)) { + $def = Join-Path $dir 'mpv.def' + if (-not (Test-Path $def)) { + Die "neither mpv.lib nor mpv.def in $dir — the archive is not an mpv-dev build" + } + Log 'generating mpv.lib from mpv.def' + Ensure-MsvcTools + # /NAME matters: without it the import library would reference mpv.dll, + # and the file that actually ships is libmpv-2.dll. + & lib.exe /def:"$def" /name:libmpv-2.dll /out:"$lib" /machine:x64 | Out-Null + if ($LASTEXITCODE -ne 0) { Die 'lib.exe failed to build mpv.lib' } + } + + [pscustomobject]@{ Dir = $dir; Dll = $dll.FullName; Lib = $lib } +} + +# lib.exe and link.exe come from a Developer prompt. If this is a plain +# PowerShell session, find the toolchain with vswhere and import its environment. +function Ensure-MsvcTools { + if (Get-Command lib.exe -ErrorAction SilentlyContinue) { return } + + $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' + if (-not (Test-Path $vswhere)) { Die 'lib.exe not on PATH and vswhere.exe not found — run from a Developer PowerShell' } + + $vsPath = & $vswhere -latest -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vsPath) { Die 'no Visual Studio C++ toolchain found' } + + $devCmd = Join-Path $vsPath 'Common7\Tools\VsDevCmd.bat' + Log 'importing the MSVC environment' + cmd /c "`"$devCmd`" -arch=amd64 -host_arch=amd64 >nul && set" | ForEach-Object { + if ($_ -match '^([^=]+)=(.*)$') { Set-Item -Path "env:$($Matches[1])" -Value $Matches[2] } + } + if (-not (Get-Command lib.exe -ErrorAction SilentlyContinue)) { Die 'still no lib.exe after importing VsDevCmd' } +} + +# --------------------------------------------------------------------- build --- + +function Invoke-Build ($mpv) { + if ($SkipBuild) { Log 'skipping cargo build'; return } + + # The dev-only path patch; say so plainly rather than deep in a resolve error. + $manifest = Get-Content (Join-Path $Root 'Cargo.toml') -Raw + if ($manifest -match '(?m)^\[patch\.crates-io\]' -and -not (Test-Path (Join-Path $Root '..\proton-sdk-rs'))) { + Die 'Cargo.toml still carries [patch.crates-io] -> ../proton-sdk-rs, but that checkout is missing.' + } + + # MSVC reads the library search path out of LIB. + $env:LIB = "$($mpv.Dir);$env:LIB" + + Log "cargo build --release --target $Target" + Push-Location $Root + try { + & cargo build --release --locked --target $Target --bin proton-stream --bin pstr + if ($LASTEXITCODE -ne 0) { Die 'cargo build failed' } + } finally { Pop-Location } +} + +function Stage ($mpv) { + Log 'staging into dist\windows' + $rel = Join-Path $Root "target\$Target\release" + foreach ($f in 'proton-stream.exe', 'pstr.exe') { + if (-not (Test-Path (Join-Path $rel $f))) { Die "$f missing from $rel — drop -SkipBuild" } + } + + New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + Copy-Item (Join-Path $rel 'proton-stream.exe') $OutDir -Force + Copy-Item (Join-Path $rel 'pstr.exe') $OutDir -Force + Copy-Item $mpv.Dll $OutDir -Force + Copy-Item (Join-Path $Root 'README.md') $OutDir -Force + + if ($SignCertThumbprint) { + foreach ($f in 'proton-stream.exe', 'pstr.exe') { + Invoke-Sign (Join-Path $OutDir $f) + } + } +} + +function Invoke-Sign ($path) { + Log "signing $(Split-Path -Leaf $path)" + $cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My | + Where-Object Thumbprint -eq $SignCertThumbprint | Select-Object -First 1 + if (-not $cert) { Die "no certificate with thumbprint $SignCertThumbprint" } + Set-AuthenticodeSignature -FilePath $path -Certificate $cert ` + -TimestampServer $TimestampUrl -HashAlgorithm SHA256 | Out-Null +} + +# ----------------------------------------------------------------- portable --- + +function New-Zip { + $out = Join-Path $Dist "proton-stream-$Version-x86_64-windows.zip" + Log "zip -> $(Split-Path -Leaf $out)" + if (Test-Path $out) { Remove-Item $out } + Compress-Archive -Path (Join-Path $OutDir '*') -DestinationPath $out +} + +# ---------------------------------------------------------------------- msi --- + +function Ensure-Wix { + if (Get-Command wix -ErrorAction SilentlyContinue) { return } + if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { + Die 'the .msi needs the WiX toolset: install the .NET SDK, then `dotnet tool install --global wix`' + } + Log 'installing the WiX toolset' + & dotnet tool install --global wix | Out-Null + $env:PATH = "$env:USERPROFILE\.dotnet\tools;$env:PATH" + if (-not (Get-Command wix -ErrorAction SilentlyContinue)) { Die 'wix still not on PATH after install' } +} + +function New-Msi ($mpv) { + Ensure-Wix + # WixUI_InstallDir lives in the UI extension, which is per-installation state + # rather than per-project; adding it twice is a no-op. + & wix extension add -g WixToolset.UI.wixext 2>&1 | Out-Null + + # The RTF the license dialog shows. Prefer the repository's own LICENSE, so + # the installer cannot drift from it; fall back to the checked-in copy. + $rtf = Join-Path $Pkg 'license.rtf' + $repoLicense = Join-Path $Root 'LICENSE' + if (Test-Path $repoLicense) { + $rtf = Join-Path $Dist 'license.rtf' + # RTF escapes are backslash, brace-open and brace-close; paragraphs are + # \par. Done per line, because a lookbehind over already-rewritten line + # endings is a good way to emit \par twice. + $body = ((Get-Content $repoLicense) | ForEach-Object { + ($_ -replace '\\', '\\') -replace '([{}])', '\$1' + }) -join "\par`r`n" + @" +{\rtf1\ansi\ansicpg1252\deff0\nouicompat{\fonttbl{\f0\fnil\fcharset0 Segoe UI;}} +\viewkind4\uc1\pard\f0\fs20 +$body +} +"@ | Set-Content -Path $rtf -Encoding ASCII + } else { + Warn 'no LICENSE in the repo root; the installer shows packaging\windows\license.rtf' + } + + $out = Join-Path $Dist "proton-stream-$Version-x64.msi" + Log "msi -> $(Split-Path -Leaf $out)" + + # Every -d value is built into a string first: PowerShell does not evaluate a + # parenthesised expression glued to the middle of a bare argument token, so + # `-d MpvDll=(Join-Path ...)` would be passed through literally. + $wxs = Join-Path $Pkg 'proton-stream.wxs' + $mpvDll = Join-Path $OutDir (Split-Path -Leaf $mpv.Dll) + $icon = Join-Path $Pkg 'proton-stream.ico' + $readme = Join-Path $Root 'README.md' + + $wixArgs = @( + 'build', '-arch', 'x64', '-ext', 'WixToolset.UI.wixext', $wxs, + '-d', "Version=$Version", + '-d', "BinDir=$OutDir", + '-d', "MpvDll=$mpvDll", + '-d', "IconFile=$icon", + '-d', "LicenseRtf=$rtf", + '-d', "ReadMe=$readme", + '-o', $out + ) + & wix @wixArgs + if ($LASTEXITCODE -ne 0) { Die 'wix build failed' } + + if ($SignCertThumbprint) { Invoke-Sign $out } +} + +# --------------------------------------------------------------------- main --- + +New-Item -ItemType Directory -Force -Path $Dist | Out-Null +$mpv = Resolve-MpvDev +Invoke-Build $mpv +Stage $mpv + +foreach ($a in $Artifacts) { + switch ($a) { + 'exe' { } # Stage already produced dist\windows\proton-stream.exe + 'zip' { New-Zip } + 'msi' { New-Msi $mpv } + } +} + +Log 'done — dist\' +Get-ChildItem $Dist -File | ForEach-Object { + ' {0,10:N0} KB {1}' -f ($_.Length / 1KB), $_.Name +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..03fa31b --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,473 @@ +#!/usr/bin/env bash +# +# Release build + packaging driver for Linux and macOS. +# +# One cargo build feeds every artifact: the binaries are compiled once, staged +# into a filesystem image under dist/stage, and each packager copies out of that +# image. So a .deb and a .rpm from the same run are the same bytes, and neither +# needs a Rust toolchain or libmpv headers on the packaging host. +# +# scripts/build.sh # everything this host can produce +# scripts/build.sh deb rpm # just those +# scripts/build.sh --skip-build deb # repackage without recompiling +# scripts/build.sh --target aarch64-unknown-linux-gnu tarball +# +# Windows is scripts/build.ps1 — the .exe and the .msi need MSVC and WiX, and +# neither is reachable from here. Arch is packaging/PKGBUILD via `makepkg -fi`, +# which compiles in place by Arch convention rather than consuming dist/stage; +# `scripts/build.sh arch` runs it for you. +# +# Artifacts land in dist/. + +set -euo pipefail + +readonly ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly DIST="$ROOT/dist" +readonly STAGE="$DIST/stage" + +readonly APP_ID="io.narl.proton-stream" +readonly PKGNAME="proton-stream" +readonly GUI_BIN="proton-stream" +readonly CLI_BIN="pstr" + +VERSION="$(grep -m1 '^version' "$ROOT/Cargo.toml" | cut -d'"' -f2)" +readonly VERSION + +# ---------------------------------------------------------------- options --- + +TARGET="" # cargo --target; empty means host +SKIP_BUILD=0 +BUNDLE_LIBS=1 # macOS: copy libmpv & friends into the .app +TARGETS=() + +usage() { + cat <<EOF +usage: scripts/build.sh [options] [artifact ...] + +artifacts + binaries cargo build --release, then stage a filesystem image + tarball dist/${PKGNAME}-${VERSION}-<triple>.tar.gz (linux, macos) + deb dist/${PKGNAME}_${VERSION}-1_<arch>.deb (linux) + rpm dist/${PKGNAME}-${VERSION}-1.<arch>.rpm (linux, needs rpmbuild) + arch packaging/PKGBUILD via makepkg (linux, needs makepkg) + app dist/Proton Stream.app (macos) + dmg dist/${PKGNAME}-${VERSION}-<arch>.dmg (macos) + all every artifact this host can produce (the default) + +options + --target <triple> cross-compile; also names the tarball + --skip-build reuse whatever is already in dist/stage + --no-bundle-libs macOS: link against the host's libmpv instead of copying it + -h, --help this +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) TARGET="$2"; shift 2 ;; + --target=*) TARGET="${1#*=}"; shift ;; + --skip-build) SKIP_BUILD=1; shift ;; + --no-bundle-libs) BUNDLE_LIBS=0; shift ;; + -h|--help) usage; exit 0 ;; + -*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;; + *) TARGETS+=("$1"); shift ;; + esac +done + +case "$(uname -s)" in + Linux) HOST_OS=linux ;; + Darwin) HOST_OS=macos ;; + *) echo "error: $(uname -s) is not handled here — Windows is scripts/build.ps1" >&2; exit 1 ;; +esac +readonly HOST_OS + +[[ ${#TARGETS[@]} -gt 0 ]] || TARGETS=(all) + +# Where cargo puts the binaries, which --target moves. +if [[ -n "$TARGET" ]]; then + RELDIR="$ROOT/target/$TARGET/release" +else + RELDIR="$ROOT/target/release" +fi +readonly RELDIR + +log() { printf '\033[1;35m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarning:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } +have() { command -v "$1" >/dev/null 2>&1; } + +# ------------------------------------------------------------------ build --- + +build() { + [[ $SKIP_BUILD -eq 0 ]] || { log "skipping cargo build"; return; } + + # The dev-only path patch. Without the sibling checkout the resolve fails a + # hundred lines deep in cargo output; say so up front instead. + if grep -q '^\[patch\.crates-io\]' "$ROOT/Cargo.toml" && [[ ! -d "$ROOT/../proton-sdk-rs" ]]; then + die "Cargo.toml still carries [patch.crates-io] -> ../proton-sdk-rs, but that checkout is missing. + Clone it beside this repo, or drop the patch block once proton-sdk is published." + fi + + log "cargo build --release${TARGET:+ --target $TARGET}" + ( cd "$ROOT" && cargo build --release --locked \ + ${TARGET:+--target "$TARGET"} \ + --bin "$GUI_BIN" --bin "$CLI_BIN" ) +} + +# `install -D` is a GNU extension and macOS does not have it, so the staging +# below goes through mkdir + cp + chmod instead. +put() { + local mode="$1" src="$2" dst="$3" + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" + chmod "$mode" "$dst" +} + +# Filesystem image every Linux packager copies out of. Rooted so that +# stage/usr/bin/... is exactly where the file lands on the target system. +stage() { + log "staging into dist/stage" + rm -rf "$STAGE" + put 755 "$RELDIR/$GUI_BIN" "$STAGE/usr/bin/$GUI_BIN" + put 755 "$RELDIR/$CLI_BIN" "$STAGE/usr/bin/$CLI_BIN" + put 644 "$ROOT/packaging/$APP_ID.desktop" \ + "$STAGE/usr/share/applications/$APP_ID.desktop" + put 644 "$ROOT/packaging/$APP_ID.svg" \ + "$STAGE/usr/share/icons/hicolor/scalable/apps/$APP_ID.svg" + put 644 "$ROOT/README.md" "$STAGE/usr/share/doc/$PKGNAME/README.md" + + # Both packagers list these as directories, so they have to exist even when + # the checkout has no LICENSE yet. + mkdir -p "$STAGE/usr/share/licenses/$PKGNAME" + if [[ -f "$ROOT/LICENSE" ]]; then + put 644 "$ROOT/LICENSE" "$STAGE/usr/share/licenses/$PKGNAME/LICENSE" + else + warn "no LICENSE in the repo root; Cargo.toml declares MIT — packages ship without the text" + fi +} + +need_stage() { + [[ -x "$STAGE/usr/bin/$GUI_BIN" ]] || die "dist/stage is empty — drop --skip-build" +} + +# ---------------------------------------------------------------- helpers --- + +# Debian and RPM each spell the machine differently from the Rust triple. +deb_arch() { + case "${TARGET:-$(uname -m)}" in + x86_64*|amd64) echo amd64 ;; + aarch64*|arm64) echo arm64 ;; + *) die "no Debian architecture mapped for ${TARGET:-$(uname -m)}" ;; + esac +} + +rpm_arch() { + case "${TARGET:-$(uname -m)}" in + x86_64*|amd64) echo x86_64 ;; + aarch64*|arm64) echo aarch64 ;; + *) die "no RPM architecture mapped for ${TARGET:-$(uname -m)}" ;; + esac +} + +triple() { + if [[ -n "$TARGET" ]]; then echo "$TARGET" + else rustc -vV | awk '/^host:/ {print $2}' + fi +} + +# ---------------------------------------------------------------- tarball --- + +do_tarball() { + need_stage + local name="$PKGNAME-$VERSION-$(triple)" + local out="$DIST/$name.tar.gz" + log "tarball -> ${out#$ROOT/}" + + local tmp; tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + mkdir -p "$tmp/$name" + cp "$STAGE/usr/bin/$GUI_BIN" "$STAGE/usr/bin/$CLI_BIN" "$tmp/$name/" + cp "$ROOT/README.md" "$tmp/$name/" + cp "$ROOT/packaging/$APP_ID.svg" "$tmp/$name/" + if [[ -f "$ROOT/LICENSE" ]]; then cp "$ROOT/LICENSE" "$tmp/$name/"; fi + tar -C "$tmp" -czf "$out" "$name" +} + +# -------------------------------------------------------------------- deb --- + +do_deb() { + # GNU tar's --owner/--group and md5sum below; not worth emulating for a + # platform that cannot install the result anyway. + [[ "$HOST_OS" == linux ]] || die "the .deb can only be built on Linux" + need_stage + local arch; arch="$(deb_arch)" + local dir="$DIST/deb/${PKGNAME}_${VERSION}-1_${arch}" + local out="$DIST/${PKGNAME}_${VERSION}-1_${arch}.deb" + log "deb -> ${out#$ROOT/}" + + rm -rf "$dir" + mkdir -p "$dir" + cp -a "$STAGE/." "$dir/" + + # Debian keeps the copyright under doc/, not licenses/. + rm -rf "$dir/usr/share/licenses" + if [[ -f "$ROOT/LICENSE" ]]; then + put 644 "$ROOT/LICENSE" "$dir/usr/share/doc/$PKGNAME/copyright" + fi + + local size; size="$(du -sk "$dir" | cut -f1)" + + # libmpv2 is trixie and later; libmpv1 is bookworm. Neither pulls the mpv + # binary in, which is right — nothing here shells out to mpv(1). + # rusqlite is `bundled` and TLS is rustls, so no libsqlite3 and no libssl. + mkdir -p "$dir/DEBIAN" + cat > "$dir/DEBIAN/control" <<EOF +Package: $PKGNAME +Version: $VERSION-1 +Section: video +Priority: optional +Architecture: $arch +Maintainer: Nils Pukropp <contact@narl.io> +Installed-Size: $size +Depends: libc6, libmpv2 | libmpv1, libsecret-1-0, libgl1, libxkbcommon0, libx11-6, libwayland-client0 +Recommends: gnome-keyring +Homepage: https://github.com/narrrl/proton-stream +Description: Netflix-style desktop client for Proton Drive public links + Paste a Proton Drive share URL and its password, and get a browsable, + streamable library: a poster wall, a page per title with seasons and + episodes, resume-where-you-left-off, and an embedded libmpv player. + . + No Proton account, no server and no download step - a file's content blocks + each decrypt on their own, so seeking costs the blocks the seek lands on + rather than a re-stream. +EOF + + # md5sums over everything but the control archive itself. + ( cd "$dir" && find . -path ./DEBIAN -prune -o -type f -print0 \ + | xargs -0 md5sum | sed 's|\./||' > DEBIAN/md5sums ) + + if have dpkg-deb; then + dpkg-deb --root-owner-group --build "$dir" "$out" >/dev/null + else + # No dpkg on Arch without the AUR, so assemble the ar archive by hand. A + # .deb is three members in this order: debian-binary, control, data. + have ar || die "need either dpkg-deb or binutils' ar to build a .deb" + local tmp; tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + echo "2.0" > "$tmp/debian-binary" + tar -C "$dir/DEBIAN" --owner=0 --group=0 -czf "$tmp/control.tar.gz" . + tar -C "$dir" --owner=0 --group=0 --exclude=./DEBIAN -cJf "$tmp/data.tar.xz" . + rm -f "$out" + ( cd "$tmp" && ar rcD "$out" debian-binary control.tar.gz data.tar.xz ) + fi +} + +# -------------------------------------------------------------------- rpm --- + +do_rpm() { + [[ "$HOST_OS" == linux ]] || die "the .rpm can only be built on Linux" + need_stage + have rpmbuild || die "rpmbuild not found (Arch: pacman -S rpm-tools; Fedora: dnf install rpm-build)" + local arch; arch="$(rpm_arch)" + log "rpm -> dist/${PKGNAME}-${VERSION}-1.${arch}.rpm" + + local top="$DIST/rpmbuild" + rm -rf "$top" + mkdir -p "$top"/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} + + # rpmbuild narrates every shell line it runs; keep the log and only show it + # when something actually went wrong. + local logf="$DIST/rpmbuild.log" + if ! rpmbuild -bb "$ROOT/packaging/$PKGNAME.spec" \ + --define "_topdir $top" \ + --define "version $VERSION" \ + --define "stagedir $STAGE" \ + --define "dist %{nil}" \ + --target "$arch" >"$logf" 2>&1; then + cat "$logf" >&2 + die "rpmbuild failed (log: ${logf#$ROOT/})" + fi + + find "$top/RPMS" -name '*.rpm' -exec cp {} "$DIST/" \; +} + +# ------------------------------------------------------------------- arch --- + +do_arch() { + have makepkg || die "makepkg not found — this artifact is Arch-only" + # makepkg compiles in place from the checkout (Arch convention, and what the + # sibling repo does), so it ignores dist/stage entirely. + log "makepkg (packaging/PKGBUILD)" + ( cd "$ROOT/packaging" && makepkg -f --noconfirm ) + find "$ROOT/packaging" -maxdepth 1 -name "$PKGNAME-*.pkg.tar.zst" -newermt '-5 minutes' \ + -exec cp {} "$DIST/" \; +} + +# ------------------------------------------------------------------ macOS --- + +# Copy a dylib's non-system dependency closure into the bundle and rewrite every +# reference to it. Homebrew's libmpv pulls in ~40 of these; without this the +# .app only runs on a machine that already did `brew install mpv`. +bundle_dylibs() { + local app="$1" bin="$2" + local fw="$app/Contents/Frameworks" + mkdir -p "$fw" + + # An index cursor and a newline-delimited seen-set rather than array slicing + # and an associative array: /bin/bash on macOS is still 3.2, which has + # neither. + local queue=("$bin") + local seen=$'\n' + local i=0 + + while [[ $i -lt ${#queue[@]} ]]; do + local item="${queue[$i]}" + i=$((i + 1)) + local dep + while read -r dep; do + # System libraries stay put; they exist on every macOS. + case "$dep" in + /usr/lib/*|/System/*|@*) continue ;; + esac + local base; base="$(basename "$dep")" + if [[ "$seen" != *$'\n'"$base"$'\n'* ]]; then + seen="$seen$base"$'\n' + if [[ ! -f "$dep" ]]; then + warn "dependency not found on disk: $dep" + continue + fi + cp -f "$dep" "$fw/$base" + chmod u+w "$fw/$base" + install_name_tool -id "@rpath/$base" "$fw/$base" 2>/dev/null || true + queue+=("$fw/$base") + fi + install_name_tool -change "$dep" "@rpath/$base" "$item" 2>/dev/null || true + done < <(otool -L "$item" | tail -n +2 | awk '{print $1}') + done + + install_name_tool -add_rpath "@executable_path/../Frameworks" "$bin" 2>/dev/null || true + # Ad-hoc re-sign: every install_name_tool edit invalidates the signature, and + # an unsigned-but-modified binary is killed on arm64. + codesign --force --deep --sign - "$app" 2>/dev/null \ + || warn "codesign failed; the .app may not launch on Apple silicon" +} + +make_icns() { + local out="$1" + local svg="$ROOT/packaging/$APP_ID.svg" + have iconutil || { warn "iconutil missing; .app ships without an icon"; return 1; } + local rasterize="" + have rsvg-convert && rasterize=rsvg-convert + [[ -z "$rasterize" ]] && have magick && rasterize=magick + [[ -z "$rasterize" ]] && { warn "no rsvg-convert or magick; .app ships without an icon"; return 1; } + + local iconset; iconset="$(mktemp -d)/icon.iconset" + mkdir -p "$iconset" + local s + for s in 16 32 64 128 256 512 1024; do + if [[ "$rasterize" == rsvg-convert ]]; then + rsvg-convert -w "$s" -h "$s" "$svg" -o "$iconset/icon_${s}x${s}.png" + else + magick -background none "$svg" -resize "${s}x${s}" "$iconset/icon_${s}x${s}.png" + fi + done + # Retina names iconutil expects. + for s in 16 32 128 256 512; do + cp "$iconset/icon_$((s * 2))x$((s * 2)).png" "$iconset/icon_${s}x${s}@2x.png" + done + iconutil -c icns "$iconset" -o "$out" +} + +do_app() { + [[ "$HOST_OS" == macos ]] || die "the .app bundle can only be built on macOS" + need_stage + local app="$DIST/Proton Stream.app" + log "app bundle -> dist/Proton Stream.app" + + rm -rf "$app" + mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" + + cp "$STAGE/usr/bin/$GUI_BIN" "$app/Contents/MacOS/$GUI_BIN" + # The CLI rides along inside the bundle rather than in /usr/local/bin: a .dmg + # drag-install has no business writing outside /Applications. + cp "$STAGE/usr/bin/$CLI_BIN" "$app/Contents/MacOS/$CLI_BIN" + + sed -e "s/@VERSION@/$VERSION/g" -e "s/@BIN@/$GUI_BIN/g" -e "s/@APP_ID@/$APP_ID/g" \ + "$ROOT/packaging/macos/Info.plist.in" > "$app/Contents/Info.plist" + + make_icns "$app/Contents/Resources/$PKGNAME.icns" || true + + if [[ $BUNDLE_LIBS -eq 1 ]]; then + log "bundling dylibs into the .app" + bundle_dylibs "$app" "$app/Contents/MacOS/$GUI_BIN" + bundle_dylibs "$app" "$app/Contents/MacOS/$CLI_BIN" + else + warn "--no-bundle-libs: the .app needs \`brew install mpv\` on the target machine" + fi +} + +do_dmg() { + [[ "$HOST_OS" == macos ]] || die "the .dmg can only be built on macOS" + local app="$DIST/Proton Stream.app" + [[ -d "$app" ]] || do_app + local out="$DIST/$PKGNAME-$VERSION-$(uname -m).dmg" + log "dmg -> ${out#$ROOT/}" + + local tmp; tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + cp -R "$app" "$tmp/" + ln -s /Applications "$tmp/Applications" + rm -f "$out" + hdiutil create -volname "Proton Stream" -srcfolder "$tmp" \ + -ov -format UDZO "$out" >/dev/null +} + +# ------------------------------------------------------------------- main --- + +mkdir -p "$DIST" + +expand_all() { + case "$HOST_OS" in + linux) echo "binaries tarball deb rpm" ;; + macos) echo "binaries tarball app dmg" ;; + esac +} + +# `all` expands in place; `binaries` is implied by everything else, so it runs +# once at the front rather than per artifact. +want=() +for t in "${TARGETS[@]}"; do + if [[ "$t" == all ]]; then + for e in $(expand_all); do want+=("$e"); done + else + want+=("$t") + fi +done + +# `arch` is the one artifact that never reads dist/stage — makepkg compiles in +# place. Asking for nothing but that skips the cargo build here. +needs_stage=0 +for t in "${want[@]}"; do + [[ "$t" == arch ]] || needs_stage=1 +done +if [[ $needs_stage -eq 1 ]]; then + build + stage +fi + +for t in "${want[@]}"; do + case "$t" in + binaries) ;; # already done above + tarball) do_tarball ;; + deb) do_deb ;; + rpm) do_rpm ;; + arch) do_arch ;; + app) do_app ;; + dmg) do_dmg ;; + *) die "unknown artifact: $t" ;; + esac +done + +log "done — dist/" +find "$DIST" -maxdepth 1 \( -type f -o -name '*.app' \) -exec du -sh {} + \ + | sed "s|$DIST/| |"