From 8f49503e4c3e3f4a044858994e434f545e31659b Mon Sep 17 00:00:00 2001 From: andreasbros Date: Sat, 18 Apr 2026 12:00:06 +0100 Subject: [PATCH 01/10] chore: merge gitignore changes --- .env.example | 5 + .envrc | 1 + .gitignore | 41 +- CLAUDE.md | 39 + Cargo.lock | 4267 +++++++++++++++++++++++ Cargo.toml | 8 + README.md | 97 + SPEC.md | 635 ++++ TASKS.md | 87 + backend/Cargo.toml | 45 + backend/src/cli.rs | 57 + backend/src/config.rs | 320 ++ backend/src/db/downloads.rs | 202 ++ backend/src/db/history.rs | 165 + backend/src/db/migrations.rs | 100 + backend/src/db/mod.rs | 42 + backend/src/db/settings.rs | 49 + backend/src/db/users.rs | 98 + backend/src/embedded.rs | 5 + backend/src/error.rs | 90 + backend/src/lib.rs | 8 + backend/src/main.rs | 193 + backend/src/server/api.rs | 257 ++ backend/src/server/auth.rs | 289 ++ backend/src/server/mod.rs | 120 + backend/src/server/static_files.rs | 44 + backend/src/server/stream.rs | 377 ++ backend/src/torrent/engine.rs | 582 ++++ backend/src/torrent/mod.rs | 6 + backend/src/torrent/provider.rs | 159 + backend/src/torrent/types.rs | 43 + backend/src/transcode/gpu.rs | 128 + backend/src/transcode/hls.rs | 204 ++ backend/src/transcode/mod.rs | 6 + backend/src/transcode/pipeline.rs | 482 +++ backend/src/transcode/probe.rs | 512 +++ backend/tests/api_tests.rs | 605 ++++ backend/tests/auth_tests.rs | 81 + backend/tests/static_binary_test.sh | 39 + backend/tests/stream_lifecycle_tests.rs | 592 ++++ clippy.toml | 1 + flake.lock | 82 + flake.nix | 51 + rust-toolchain.toml | 4 + rustfmt.toml | 1 + scripts/generate-logo.sh | 49 + streamx.default.toml | 25 + ui/index.html | 27 + ui/package.json | 39 + ui/playwright.config.ts | 27 + ui/pnpm-lock.yaml | 4116 ++++++++++++++++++++++ ui/public/icons/favicon.svg | 28 + ui/public/icons/logo.svg | 28 + ui/src/App.tsx | 63 + ui/src/api/client.ts | 186 + ui/src/api/types.ts | 113 + ui/src/components/DebugPane.tsx | 174 + ui/src/components/Layout.tsx | 176 + ui/src/components/NeonBackground.tsx | 302 ++ ui/src/components/VideoPlayer.tsx | 122 + ui/src/hooks/useAuth.ts | 112 + ui/src/hooks/useDebug.ts | 18 + ui/src/hooks/useSearch.ts | 53 + ui/src/hooks/useStream.ts | 52 + ui/src/hooks/useTheme.ts | 25 + ui/src/lib/auth.ts | 38 + ui/src/lib/debug-log.ts | 67 + ui/src/lib/utils.ts | 42 + ui/src/main.tsx | 12 + ui/src/pages/History.tsx | 192 + ui/src/pages/Login.tsx | 135 + ui/src/pages/Player.tsx | 187 + ui/src/pages/Search.tsx | 289 ++ ui/src/pages/Settings.tsx | 94 + ui/src/styles/global.css | 79 + ui/tests/auth.spec.ts | 53 + ui/tests/debug-log.spec.ts | 48 + ui/tests/e2e-movie-play.spec.ts | 120 + ui/tests/e2e-player.spec.ts | 40 + ui/tests/e2e-torrent-play.spec.ts | 145 + ui/tests/hooks.spec.ts | 38 + ui/tests/live.config.ts | 14 + ui/tests/screenshot-live.spec.ts | 30 + ui/tests/screenshot-player.spec.ts | 53 + ui/tests/utils.spec.ts | 81 + ui/tests/vite-player.spec.ts | 41 + ui/tsconfig.json | 27 + ui/tsconfig.node.json | 21 + ui/vite.config.ts | 38 + 89 files changed, 18817 insertions(+), 21 deletions(-) create mode 100644 .env.example create mode 100644 .envrc create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 TASKS.md create mode 100644 backend/Cargo.toml create mode 100644 backend/src/cli.rs create mode 100644 backend/src/config.rs create mode 100644 backend/src/db/downloads.rs create mode 100644 backend/src/db/history.rs create mode 100644 backend/src/db/migrations.rs create mode 100644 backend/src/db/mod.rs create mode 100644 backend/src/db/settings.rs create mode 100644 backend/src/db/users.rs create mode 100644 backend/src/embedded.rs create mode 100644 backend/src/error.rs create mode 100644 backend/src/lib.rs create mode 100644 backend/src/main.rs create mode 100644 backend/src/server/api.rs create mode 100644 backend/src/server/auth.rs create mode 100644 backend/src/server/mod.rs create mode 100644 backend/src/server/static_files.rs create mode 100644 backend/src/server/stream.rs create mode 100644 backend/src/torrent/engine.rs create mode 100644 backend/src/torrent/mod.rs create mode 100644 backend/src/torrent/provider.rs create mode 100644 backend/src/torrent/types.rs create mode 100644 backend/src/transcode/gpu.rs create mode 100644 backend/src/transcode/hls.rs create mode 100644 backend/src/transcode/mod.rs create mode 100644 backend/src/transcode/pipeline.rs create mode 100644 backend/src/transcode/probe.rs create mode 100644 backend/tests/api_tests.rs create mode 100644 backend/tests/auth_tests.rs create mode 100755 backend/tests/static_binary_test.sh create mode 100644 backend/tests/stream_lifecycle_tests.rs create mode 100644 clippy.toml create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100755 scripts/generate-logo.sh create mode 100644 streamx.default.toml create mode 100644 ui/index.html create mode 100644 ui/package.json create mode 100644 ui/playwright.config.ts create mode 100644 ui/pnpm-lock.yaml create mode 100644 ui/public/icons/favicon.svg create mode 100644 ui/public/icons/logo.svg create mode 100644 ui/src/App.tsx create mode 100644 ui/src/api/client.ts create mode 100644 ui/src/api/types.ts create mode 100644 ui/src/components/DebugPane.tsx create mode 100644 ui/src/components/Layout.tsx create mode 100644 ui/src/components/NeonBackground.tsx create mode 100644 ui/src/components/VideoPlayer.tsx create mode 100644 ui/src/hooks/useAuth.ts create mode 100644 ui/src/hooks/useDebug.ts create mode 100644 ui/src/hooks/useSearch.ts create mode 100644 ui/src/hooks/useStream.ts create mode 100644 ui/src/hooks/useTheme.ts create mode 100644 ui/src/lib/auth.ts create mode 100644 ui/src/lib/debug-log.ts create mode 100644 ui/src/lib/utils.ts create mode 100644 ui/src/main.tsx create mode 100644 ui/src/pages/History.tsx create mode 100644 ui/src/pages/Login.tsx create mode 100644 ui/src/pages/Player.tsx create mode 100644 ui/src/pages/Search.tsx create mode 100644 ui/src/pages/Settings.tsx create mode 100644 ui/src/styles/global.css create mode 100644 ui/tests/auth.spec.ts create mode 100644 ui/tests/debug-log.spec.ts create mode 100644 ui/tests/e2e-movie-play.spec.ts create mode 100644 ui/tests/e2e-player.spec.ts create mode 100644 ui/tests/e2e-torrent-play.spec.ts create mode 100644 ui/tests/hooks.spec.ts create mode 100644 ui/tests/live.config.ts create mode 100644 ui/tests/screenshot-live.spec.ts create mode 100644 ui/tests/screenshot-player.spec.ts create mode 100644 ui/tests/utils.spec.ts create mode 100644 ui/tests/vite-player.spec.ts create mode 100644 ui/tsconfig.json create mode 100644 ui/tsconfig.node.json create mode 100644 ui/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..707dc8b --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Torrent search provider URL (used by Elementum-compatible providers) +# STREAMX_TORRENT_PROVIDER_URL= +# Admin credentials for testing (not recommended for production) +# STREAMX_ADMIN_USER=admin +# STREAMX_ADMIN_PASSWORD= diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index ad67955..69102d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,21 +1,20 @@ -# Generated by Cargo -# will have compiled files and executables -debug -target - -# These are backup files generated by rustfmt -**/*.rs.bk - -# MSVC Windows builds of rustc generate these, which store debugging information -*.pdb - -# Generated by cargo mutants -# Contains mutation testing data -**/mutants.out*/ - -# RustRover -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +target/ +node_modules/ +dist/ +ui-dist/ +result +result-* +.direnv/ +*.swp +*.swo +*~ +.env +.env.local +*.db +*.db-journal +downloads/ +cache/ +logs/ +test-results/ +test-results-live/ +backend/test-media/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..614d417 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,39 @@ +# StreamX + +Torrent-based video streaming player. Single static Rust binary serving a React UI. + +## Build + +All tools are managed via Nix. Run `nix develop` to enter the dev shell. + +```bash +nix develop +cd ui && pnpm install && pnpm build +cd backend && cargo build +``` + +## Code standards + +- Rust: `cargo fmt --all`, `cargo clippy -- -D warnings`, `cargo check` must all pass with zero warnings +- No `.unwrap()`, `.expect()`, or `panic!()` anywhere in Rust code +- Error handling via `snafu` with context selectors +- All async code uses `tokio` +- TypeScript: strict mode, no `any` types +- No `dangerouslySetInnerHTML` in React +- All SQL queries must use parameterized statements (rusqlite) +- No hardcoded secrets; JWT secret auto-generated on first run +- No em-dashes in docs or comments +- No static obvious code comments +- Lean documentation + +## Project structure + +- `backend/` - Rust backend (Axum, librqbit, FFmpeg transcoding, SQLite) +- `ui/` - React/TypeScript frontend (Vite, Radix UI, hls.js, framer-motion) +- `flake.nix` - Nix flake for dev shell and builds + +## Testing + +- Rust: `cargo test` (unit + integration) +- Frontend: `pnpm test` (vitest) and `pnpm test:e2e` (Playwright) +- E2E tests use real backend with mock streaming endpoint diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..a8cee5e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4267 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "adler32" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "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_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.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +dependencies = [ + "rustversion", +] + +[[package]] +name = "assert_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e2651f366b7ee3f97729fded1441539b49d5f39eeb05b842689e11e84501b2" +dependencies = [ + "const_panic", +] + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "axum-macros", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "getrandom 0.2.17", + "instant", + "rand 0.8.5", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bcrypt" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b1866ecef4f2d06a0bb77880015fdf2b89e25a1c2e5addacb87e459c86dc67e" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "bincode_derive", + "serde", + "unty", +] + +[[package]] +name = "bincode_derive" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" +dependencies = [ + "virtue", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "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", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "commoncrypto" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d056a8586ba25a1e4d61cb090900e495952c7886786fc55f909ab2f819b69007" +dependencies = [ + "commoncrypto-sys", +] + +[[package]] +name = "commoncrypto-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fed34f46747aa73dfaa578069fd8279d2818ade2b55f38f22a9401c7f4083e2" +dependencies = [ + "libc", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + +[[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 = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array 0.14.7", + "typenum", +] + +[[package]] +name = "crypto-hash" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a77162240fd97248d19a564a565eb563a3f592b386e4136fb300909e67dddca" +dependencies = [ + "commoncrypto", + "hex 0.3.2", + "openssl", + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dary_heap" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04" + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", + "serde", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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 = "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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[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", +] + +[[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 = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[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 = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "governor" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.2", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[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 = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "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 = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805026a5d0141ffc30abb3be3173848ad46a1b1664fe632428479619a3644d77" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include-flate" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a05fb00d9abc625268e0573a519506b264a7d6965de09bac13201bfb44e723d" +dependencies = [ + "include-flate-codegen", + "include-flate-compress", +] + +[[package]] +name = "include-flate-codegen" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c3c319a7527668538a8530c541e74e881e94c4f41e1425622d0a41c16468af" +dependencies = [ + "include-flate-compress", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "include-flate-compress" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0bd9ea81b94169d61c5a397e9faef02153d3711fc62d3270bcde3ac85380d9" +dependencies = [ + "libflate", + "zstd", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array 0.14.7", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "intervaltree" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "270bc34e57047cab801a8c871c124d9dc7132f6473c6401f645524f4e6edd111" +dependencies = [ + "smallvec", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leaky-bucket" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a396bb213c2d09ed6c5495fd082c991b6ab39c9daf4fff59e6727f85c73e4c5" +dependencies = [ + "parking_lot", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libflate" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3248b8d211bd23a104a42d81b4fa8bb8ac4a3b75e7a43d85d2c9ccb6179cd74" +dependencies = [ + "adler32", + "core2", + "crc32fast", + "dary_heap", + "libflate_lz77", +] + +[[package]] +name = "libflate_lz77" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" +dependencies = [ + "core2", + "hashbrown 0.16.1", + "rle-decode-fast", +] + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "librqbit" +version = "8.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dadca8f521242010a4c846ef5f224c217009c92e272709cdc08ba9cdabe62983" +dependencies = [ + "anyhow", + "arc-swap", + "async-compression", + "async-stream", + "async-trait", + "backoff", + "base64", + "bincode 2.0.1", + "bitvec", + "byteorder", + "bytes", + "dashmap", + "futures", + "governor", + "hex 0.4.3", + "http", + "intervaltree", + "itertools", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-clone-to-owned", + "librqbit-core", + "librqbit-dht", + "librqbit-peer-protocol", + "librqbit-sha1-wrapper", + "librqbit-tracker-comms", + "librqbit-upnp", + "memmap2", + "mime_guess", + "parking_lot", + "rand 0.9.2", + "regex", + "reqwest", + "rlimit", + "serde", + "serde_json", + "serde_urlencoded", + "serde_with", + "size_format", + "tokio", + "tokio-socks", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "urlencoding", + "uuid", + "walkdir", +] + +[[package]] +name = "librqbit-bencode" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "606dff526ba81e3eca33e2bb28b53afa2bc0b2c41d252333fa44e6c11abb37da" +dependencies = [ + "anyhow", + "bytes", + "librqbit-buffers", + "librqbit-clone-to-owned", + "librqbit-sha1-wrapper", + "serde", +] + +[[package]] +name = "librqbit-buffers" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c78b907d6171a7191c162b2b60db46d254ebde6a95282b77372af556c1463" +dependencies = [ + "bytes", + "librqbit-clone-to-owned", + "serde", +] + +[[package]] +name = "librqbit-clone-to-owned" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd1e66d773ba9c475ff89286dc1d6f9d167cbb898603797467dd0ea6844c445" +dependencies = [ + "bytes", +] + +[[package]] +name = "librqbit-core" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a02cc6fce6743ad38661ccd6fafc6cf1ae5e0106a9922836b0524dbe752378" +dependencies = [ + "anyhow", + "assert_cfg", + "bytes", + "data-encoding", + "directories", + "hex 0.4.3", + "itertools", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-clone-to-owned", + "parking_lot", + "rand 0.9.2", + "serde", + "tokio", + "tokio-util", + "tracing", + "url", +] + +[[package]] +name = "librqbit-dht" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7cc129194337771a86b0399956c4d9bf1cd97c5f24d14a50be38e170f76a54b" +dependencies = [ + "anyhow", + "backoff", + "byteorder", + "bytes", + "chrono", + "dashmap", + "futures", + "hex 0.4.3", + "indexmap 2.13.0", + "leaky-bucket", + "librqbit-bencode", + "librqbit-clone-to-owned", + "librqbit-core", + "parking_lot", + "rand 0.9.2", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "librqbit-peer-protocol" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a73129497b500505f33d1dc0426319b6a6a208f13fdfaae56224ab8c2346a773" +dependencies = [ + "anyhow", + "bincode 1.3.3", + "bitvec", + "byteorder", + "bytes", + "itertools", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-clone-to-owned", + "librqbit-core", + "serde", +] + +[[package]] +name = "librqbit-sha1-wrapper" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79373a02db73159e4de7ca5d27b6eeae2d540df66c6801db2b01c5513d087524" +dependencies = [ + "assert_cfg", + "crypto-hash", +] + +[[package]] +name = "librqbit-tracker-comms" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08204944c5be677a5de8e1230e0249fce5c14abef23048e26452c6fb03f1b260" +dependencies = [ + "anyhow", + "async-stream", + "byteorder", + "futures", + "librqbit-bencode", + "librqbit-buffers", + "librqbit-core", + "parking_lot", + "rand 0.9.2", + "reqwest", + "serde", + "tokio", + "tokio-util", + "tracing", + "url", + "urlencoding", +] + +[[package]] +name = "librqbit-upnp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "545aad6124c97201055983137e12a19f34acad565120c3cd30596cbd72e8fa86" +dependencies = [ + "anyhow", + "bstr", + "futures", + "httparse", + "network-interface", + "quick-xml", + "reqwest", + "serde", + "tokio", + "tracing", + "url", +] + +[[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 = "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[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 = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + +[[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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "network-interface" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddcb8865ad3d9950f22f42ffa0ef0aecbfbf191867b3122413602b0a360b2a6" +dependencies = [ + "cc", + "libc", + "thiserror 2.0.18", + "winapi", +] + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[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.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" +dependencies = [ + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef" +dependencies = [ + "autocfg", + "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", +] + +[[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 = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[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", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portpicker" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be97d76faf1bfab666e1375477b23fde79eccf0276e9b63b92a39d676a889ba9" +dependencies = [ + "rand 0.8.5", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[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_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[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.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[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 = "rle-decode-fast" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" + +[[package]] +name = "rlimit" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7043b63bd0cd1aaa628e476b80e6d4023a3b50eb32789f2728908107bd0c793a" +dependencies = [ + "libc", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "include-flate", + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[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 = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "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 = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[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 = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64", + "chrono", + "hex 0.4.3", + "indexmap 1.9.3", + "indexmap 2.13.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "size_format" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed5f6ab2122c6dec69dca18c72fa4590a27e581ad20d44960fe74c032a0b23b" +dependencies = [ + "generic-array 0.12.4", + "num", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "snafu" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +dependencies = [ + "futures-core", + "pin-project", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "streamx" +version = "0.1.0" +dependencies = [ + "axum", + "axum-extra", + "base64", + "bcrypt", + "bytes", + "chrono", + "clap", + "http", + "jsonwebtoken", + "librqbit", + "mime_guess", + "portpicker", + "rand 0.8.5", + "reqwest", + "rusqlite", + "rust-embed", + "serde", + "serde_json", + "snafu", + "tempfile", + "tokio", + "tokio-util", + "toml", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", +] + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.2", + "once_cell", + "rustix", + "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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +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.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[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-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[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", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[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", +] + +[[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-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "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", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "typewit" +version = "1.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "unty" +version = "0.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[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", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[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.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[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 = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "virtue" +version = "0.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" + +[[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.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[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 = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +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 = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[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 = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[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", +] + +[[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", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..66ef437 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[workspace] +members = ["backend"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b63241 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# StreamX + +Torrent-based video streaming player. Single static Rust binary serving a React UI. Search for torrents, paste magnet links, stream video in the browser. + +## About + +StreamX starts a web server with a modern UI where you can search for torrents, paste magnet links, and stream video content directly in the browser. All dependencies (including FFmpeg) are statically linked into a single binary. + +- Rust backend: Axum, librqbit (BitTorrent), FFmpeg transcoding, SQLite +- React frontend: Radix UI, video.js, framer-motion +- Auth: bcrypt + JWT, multi-user with search/watch history +- Streaming: sequential torrent download with on-the-fly HLS transcoding + +## How streaming works + +A torrent downloads one movie file (e.g. `Movie.2024.720p.mp4` at ~1GB). BitTorrent downloads this file sequentially so the beginning arrives first. + +Once enough data is available (~30% or the file is complete), FFmpeg converts the movie into HLS segments. Each segment is a 4-second chunk of video (~50-500KB as `.ts` files). A playlist file (`playlist.m3u8`) lists all segments in order. + +The browser loads segments one at a time using video.js (Chrome/Firefox) or Safari's native HLS player. This allows playback to start before the full file is downloaded. + +``` +Torrent peers --> librqbit (sequential download) --> movie.mp4 + | + FFmpeg (passthrough or transcode) + | + HLS segments (segment_0000.ts, segment_0001.ts, ...) + | + playlist.m3u8 + | + video.js / Safari native HLS --> browser playback +``` + +Segment duration is configurable via `hls_segment_duration` in `config.toml` (default: 4 seconds). FFmpeg uses hardware acceleration (VAAPI, NVENC, QSV, VideoToolbox) when available, falling back to CPU (libx264). + +## Local build + +All tools are managed via Nix. + +```bash +nix develop + +# Frontend +cd ui && pnpm install && pnpm build && cd .. + +# Backend +cd backend && cargo build && cd .. + +# Run +cd backend && cargo run +# Open http://127.0.0.1:8999 +``` + +### Development (hot reload) + +```bash +nix develop + +# Terminal 1: frontend dev server +cd ui && pnpm dev + +# Terminal 2: backend +cd backend && cargo run -- --port 8998 +``` + +The vite dev server runs on port 8999 and proxies API requests to the backend on port 8998. + +### Checks + +```bash +cargo fmt --all -- --check +cargo clippy -- -D warnings +cargo check +cd ui && pnpm typecheck +``` + +### CLI commands + +```bash +streamx # Start the server (default port 8999) +streamx --port 9000 # Custom port +streamx --admin-user admin --admin-password password # Create admin on startup +streamx clean # Remove cache and downloads (keeps config + database) +streamx wipe # Remove everything except config.toml +``` + +## Troubleshooting + +**Port 8999 in use:** Check with `ss -tlnp | grep 8999`. Kill the process or use `--port` to pick a different port. + +**Frontend not showing:** Build the UI first with `cd ui && pnpm install && pnpm build`, then restart the backend. + +**Nix flake not found:** Run `git add flake.nix` -- Nix requires tracked files. + +**Video not playing on iPhone/Safari:** Safari uses native HLS. If segments return 401, the auth token may be missing from the playlist URL. Check the debug pane (user menu > Debug Mode). + +**Transcoding fails on GPU:** If VAAPI/NVENC fails for certain codecs (e.g. x265 10-bit), FFmpeg automatically falls back to CPU encoding (libx264). diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..870c0b0 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,635 @@ +# StreamX — Torrent Video Streaming Player + +## Overview + +StreamX is a single static binary torrent-based video streaming player written in Rust. When executed, it starts a web server serving a modern reactive UI where users can search for torrents, paste magnet links, and instantly stream video content in the browser. All dependencies — including FFmpeg libraries — are statically linked into the binary. + +**Project name:** `streamx` +**Binary name:** `streamx` +**Logo:** Square logo with "SX" monogram + +--- + +## Architecture + +``` +streamx (single static binary, ~30-50MB) +├── Axum web server (serves UI + REST API + HLS segments) +├── librqbit (BitTorrent engine — full protocol, DHT, PEX, magnet links) +├── FFmpeg (statically linked — transcoding MKV/x265 → HLS) +├── SQLite (user accounts, search history, watch history) +├── Embedded frontend (rust-embed — React/TypeScript SPA) +└── Config/data directory (~/.streamx/) +``` + +``` +┌──────────────────────────────────────────────────────────┐ +│ streamx binary │ +│ │ +│ ┌────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Axum │ │ librqbit │ │ SQLite (rusqlite)│ │ +│ │ Web Server │ │ BitTorrent │ │ │ │ +│ │ │ │ │ │ - Users/auth │ │ +│ │ - REST API │ │ - Full swarm │ │ - Search history │ │ +│ │ - HLS │ │ - TCP/UDP │ │ - Watch history │ │ +│ │ - Auth │ │ - DHT/PEX │ │ - Settings │ │ +│ │ - Static │ │ - Magnets │ │ │ │ +│ └─────┬──────┘ └──────┬──────┘ └──────────────────┘ │ +│ │ │ │ +│ ┌─────┴────────────────┴──────────────────────────────┐ │ +│ │ FFmpeg (statically linked via ffmpeg-sys-next) │ │ +│ │ Transcode: MKV/x265/x264/AAC → HLS (fMP4 + m3u8) │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Embedded Frontend (rust-embed) │ │ +│ │ React + TypeScript + Radix UI + hls.js │ │ +│ └─────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +--- + +## Project Structure + +``` +streamx/ +├── SPEC.md +├── Cargo.toml # Workspace root +├── Cargo.lock +├── flake.nix # Nix flake — dev shell + build +├── flake.lock +├── .envrc # direnv — auto nix develop +├── rust-toolchain.toml # Pin Rust nightly/stable +├── clippy.toml # Clippy config +├── rustfmt.toml # Fmt config +├── streamx.default.toml # Default config reference +├── scripts/ +│ ├── generate-logo.sh # Bash script to generate SX logo icons +│ └── build-release.sh # Cross-compile release builds +├── backend/ +│ ├── Cargo.toml +│ ├── src/ +│ │ ├── main.rs # Entry point — CLI, config loading, server start +│ │ ├── cli.rs # Clap CLI definition +│ │ ├── config.rs # Config loading (TOML + env vars + CLI args) +│ │ ├── error.rs # Snafu error types +│ │ ├── server/ +│ │ │ ├── mod.rs # Axum router setup +│ │ │ ├── auth.rs # Auth middleware — JWT + bcrypt passwords +│ │ │ ├── api.rs # REST API handlers +│ │ │ ├── stream.rs # HLS streaming endpoint +│ │ │ └── static_files.rs # Serve embedded frontend +│ │ ├── torrent/ +│ │ │ ├── mod.rs # Torrent engine wrapper around librqbit +│ │ │ ├── engine.rs # Start/stop/status torrents +│ │ │ ├── provider.rs # Default torrent search provider +│ │ │ └── types.rs # Torrent-related types +│ │ ├── transcode/ +│ │ │ ├── mod.rs # FFmpeg transcoding pipeline +│ │ │ ├── hls.rs # HLS segment generation +│ │ │ └── probe.rs # Media file probing (codec detection) +│ │ ├── db/ +│ │ │ ├── mod.rs # SQLite connection pool +│ │ │ ├── migrations.rs # Schema migrations (embedded) +│ │ │ ├── users.rs # User CRUD +│ │ │ ├── history.rs # Search + watch history +│ │ │ └── settings.rs # Per-user settings +│ │ └── embedded.rs # rust-embed frontend assets +│ └── tests/ +│ ├── api_tests.rs # API integration tests +│ ├── torrent_tests.rs # Torrent engine unit tests +│ ├── transcode_tests.rs # Transcoding tests +│ ├── auth_tests.rs # Auth flow tests +│ └── e2e_tests.rs # Full end-to-end tests +├── ui/ +│ ├── package.json +│ ├── pnpm-lock.yaml +│ ├── tsconfig.json +│ ├── vite.config.ts +│ ├── playwright.config.ts +│ ├── index.html +│ ├── public/ +│ │ └── icons/ # Generated logo icons +│ ├── src/ +│ │ ├── main.tsx # React entry point +│ │ ├── App.tsx # Root app — router + theme provider +│ │ ├── api/ +│ │ │ ├── client.ts # API client (fetch wrapper with auth) +│ │ │ └── types.ts # API response types +│ │ ├── hooks/ +│ │ │ ├── useAuth.ts # Auth context + hooks +│ │ │ ├── useStream.ts # Stream status polling +│ │ │ ├── useSearch.ts # Search with debounce +│ │ │ └── useTheme.ts # Dark/light theme toggle +│ │ ├── pages/ +│ │ │ ├── Login.tsx # Login / register page +│ │ │ ├── Search.tsx # Search + magnet link input +│ │ │ ├── Player.tsx # Video player page (fullscreen capable) +│ │ │ └── History.tsx # Watch history page +│ │ ├── components/ +│ │ │ ├── VideoPlayer.tsx # hls.js + HTML5 video + custom controls +│ │ │ ├── SearchBar.tsx # Search input with autocomplete +│ │ │ ├── TorrentCard.tsx # Search result card (seeds, size, quality) +│ │ │ ├── ProgressBar.tsx # Download progress overlay +│ │ │ ├── ThemeToggle.tsx # Dark/light switch +│ │ │ └── Layout.tsx # App shell / navigation +│ │ ├── styles/ +│ │ │ └── global.css # Radix UI theme tokens + animations +│ │ └── lib/ +│ │ ├── auth.ts # JWT token management +│ │ └── utils.ts # Shared utilities +│ └── tests/ +│ ├── login.spec.ts # Playwright: auth flow +│ ├── search.spec.ts # Playwright: search + results +│ ├── player.spec.ts # Playwright: video playback +│ └── history.spec.ts # Playwright: watch history +└── README.md +``` + +--- + +## Technology Stack + +### Backend (Rust) + +| Crate | Purpose | Version | +|---|---|---| +| `axum` | Web server + routing | latest | +| `tokio` | Async runtime (full features) | latest | +| `librqbit` | BitTorrent engine | latest | +| `rusqlite` | SQLite with bundled feature | latest | +| `rust-embed` | Embed frontend assets in binary | latest | +| `snafu` | Error handling (no unwrap/panic) | latest | +| `clap` | CLI argument parsing (derive) | latest | +| `serde` / `serde_json` | Serialization | latest | +| `toml` | Config file parsing | latest | +| `bcrypt` | Password hashing | latest | +| `jsonwebtoken` | JWT auth tokens | latest | +| `tower-http` | CORS, compression, tracing middleware | latest | +| `tracing` / `tracing-subscriber` | Structured logging | latest | +| `ffmpeg-sys-next` | Static FFmpeg bindings | latest | +| `uuid` | Unique IDs for streams | latest | +| `tokio-util` | Async utilities | latest | + +### Frontend (TypeScript/React) + +| Package | Purpose | +|---|---| +| `react` + `react-dom` | UI framework | +| `@radix-ui/themes` | Component library (dark/light themes) | +| `@radix-ui/react-*` | Individual Radix primitives as needed | +| `hls.js` | HLS video playback | +| `react-router-dom` | Client-side routing | +| `vite` | Build tool | +| `typescript` | Type safety | +| `@playwright/test` | E2E browser tests | +| `framer-motion` | Animations (dopamine-inducing transitions) | + +### Build / Dev + +| Tool | Purpose | +|---|---| +| Nix (flake) | Reproducible dev environment + builds | +| `pnpm` | Frontend package manager | +| `cargo` | Rust build | +| `musl` | Static linking target for Linux | +| Playwright | UI E2E tests | + +--- + +## Detailed Requirements + +### 1. CLI & Configuration + +**Priority order** (highest wins): +1. CLI arguments +2. Environment variables (prefixed `STREAMX_`) +3. Config file (`~/.streamx/config.toml` or custom path via `--config`) +4. Defaults + +**CLI (clap derive):** +``` +streamx [OPTIONS] + +Options: + -p, --port Listen port [default: 8999] [env: STREAMX_PORT] + -b, --bind Bind address [default: 127.0.0.1] [env: STREAMX_BIND] + -d, --data-dir Data directory [default: ~/.streamx] [env: STREAMX_DATA_DIR] + -c, --config Config file path [env: STREAMX_CONFIG] + --log-level Log level [default: info] [env: STREAMX_LOG_LEVEL] + --open Open browser on start [env: STREAMX_OPEN] + -V, --version Print version + -h, --help Print help +``` + +**Default config file (`~/.streamx/config.toml`):** +```toml +[server] +port = 8999 +bind = "127.0.0.1" +open_browser = true + +[torrent] +download_dir = "~/.streamx/downloads" +max_connections = 200 +sequential = true # Sequential download for streaming +seed_after_complete = true +dht = true +pex = true + +[transcode] +# Auto-detect: if source is browser-compatible (MP4/H264/AAC), stream directly +# Otherwise transcode to HLS +hls_segment_duration = 4 # seconds +video_codec = "h264" +audio_codec = "aac" +preset = "ultrafast" # Prioritize speed over compression + +[auth] +jwt_secret = "" # Auto-generated on first run if empty +session_duration = "7d" + +[ui] +default_theme = "dark" +``` + +### 2. Data Directory (`~/.streamx/`) + +Created automatically on first run: +``` +~/.streamx/ +├── config.toml # User config (created from defaults if missing) +├── streamx.db # SQLite database +├── downloads/ # Torrent downloads (configurable) +├── cache/ # HLS segment cache +└── logs/ # Log files (optional) +``` + +### 3. Authentication & Users + +- First run: no users exist → UI shows registration form to create admin user +- Subsequent runs: login required +- Passwords: bcrypt hashed, stored in SQLite +- Sessions: JWT tokens, configurable expiry +- Multi-user: each user has own search/watch history +- API: all endpoints except `/api/auth/login` and `/api/auth/register` require valid JWT +- **Security:** parameterized SQL queries only (rusqlite), no string interpolation in queries. Input validation on all endpoints. Rate limiting on auth endpoints. + +**SQLite schema:** +```sql +CREATE TABLE users ( + id TEXT PRIMARY KEY, -- UUID + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE search_history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + query TEXT NOT NULL, + result_count INTEGER, + searched_at TEXT NOT NULL +); + +CREATE TABLE watch_history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + magnet_uri TEXT NOT NULL, + title TEXT NOT NULL, + file_name TEXT, + duration_seconds INTEGER, + watched_seconds INTEGER, -- Resume position + poster_url TEXT, + watched_at TEXT NOT NULL +); + +CREATE TABLE active_streams ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + magnet_uri TEXT NOT NULL, + file_index INTEGER NOT NULL, + status TEXT NOT NULL, -- downloading, transcoding, ready, error + progress REAL, + peers INTEGER, + download_speed INTEGER, -- bytes/sec + created_at TEXT NOT NULL +); +``` + +### 4. REST API + +All responses are JSON. Auth via `Authorization: Bearer ` header. + +``` +POST /api/auth/register { username, password } → { token } +POST /api/auth/login { username, password } → { token } +GET /api/auth/me → { user } + +POST /api/search { query } → { results: [{ title, magnet, seeds, leeches, size }] } +GET /api/search/history → { searches: [...] } + +POST /api/stream { magnet_uri, file_index? } → { stream_id, status } +GET /api/stream/:id → { status, progress, peers, speed, files } +GET /api/stream/:id/playlist.m3u8 → HLS master playlist +GET /api/stream/:id/:segment.ts → HLS segment +DELETE /api/stream/:id → stop torrent + cleanup + +GET /api/history → { items: [...] } +PUT /api/history/:id { watched_seconds } → update resume position +DELETE /api/history/:id → remove from history + +GET /api/settings → { theme, ... } +PUT /api/settings { theme, ... } → update +``` + +### 5. Torrent Engine + +- Use `librqbit` as the BitTorrent engine +- **Sequential downloading** enabled by default (critical for streaming — download pieces in order) +- Support magnet links and .torrent files +- Connect to full BitTorrent swarm (TCP/UDP), DHT, PEX +- Default search provider: integrate a provider that returns magnet links (configurable) +- When streaming starts: + 1. Parse magnet / start torrent + 2. List files in torrent + 3. Auto-select largest video file (or user selects) + 4. Begin sequential download + 5. Once enough data buffered (~2-5 seconds), start transcoding/serving + +### 6. Transcoding Pipeline + +**Decision flow:** +``` +File selected → probe with FFmpeg + → MP4 container + H264 + AAC → stream directly (no transcode) + → MKV / x265 / other → transcode to HLS on the fly +``` + +**HLS transcoding:** +- Input: file being downloaded (can start before complete — read available bytes) +- Output: HLS playlist (`.m3u8`) + segments (`.ts`, 4 seconds each) +- FFmpeg flags: `-preset ultrafast -tune zerolatency` for minimum latency +- Segments generated on-demand (don't transcode the whole file upfront) +- Cache segments in `~/.streamx/cache/` +- Clean up cache when stream is stopped + +**Static FFmpeg linking:** +- Use `ffmpeg-sys-next` with static feature flags +- All codecs compiled in: H264, H265/HEVC, AAC, AC3, VP9, AV1 decode +- Output codecs: H264 + AAC only (browser-compatible) +- The binary must contain all FFmpeg libraries — no system FFmpeg dependency + +### 7. UI Design + +**Framework:** React 18+ with TypeScript, Vite build, Radix UI components + +**Theme:** +- Radix UI theme provider with dark (default) and light modes +- Dark theme: deep blacks (#0a0a0a), accent color (electric blue #3b82f6 or purple #8b5cf6) +- Smooth transitions between themes +- CSS variables from Radix UI tokens + +**Pages:** + +**Login/Register:** +- Clean centered card +- Username + password fields (Radix `TextField`) +- Toggle between login and register +- Animated transitions (framer-motion) + +**Search (main page):** +- Large search bar at top (Radix `TextField` with search icon) +- Magnet link paste support (detect magnet: prefix, auto-start) +- Search results as cards in a grid: + - Title, seeds (green), leeches (red), file size, quality badge (4K/1080p/720p) + - Sort by seeds (default), size, name + - Click to start streaming +- Smooth card entrance animations (stagger) +- Search history in sidebar or below + +**Player:** +- Full-screen capable (Fullscreen API) +- Custom video controls overlay (Radix primitives): + - Play/pause (spacebar) + - Seek bar with preview thumbnails (if available) + - Volume control + mute + - Playback speed (0.5x, 1x, 1.25x, 1.5x, 2x) + - Quality selector (if multiple transcoding profiles) + - Fullscreen toggle (F key) + - Picture-in-Picture (PiP) + - Subtitles toggle (if .srt found in torrent) +- Download progress bar at bottom (thin, colored by buffered vs downloaded) +- Overlay stats (toggle with 'i' key): peers, download speed, upload speed, progress +- Auto-hide controls after 3 seconds of no mouse movement +- Keyboard shortcuts: + - Space: play/pause + - Left/Right: seek ±10s + - Up/Down: volume + - F: fullscreen + - M: mute + - Esc: exit fullscreen + +**History:** +- Grid of watched content +- Poster image (if available), title, last watched date +- Resume position indicator +- Click to resume playback + +**Animations (framer-motion):** +- Page transitions (slide/fade) +- Card hover effects (subtle scale + shadow) +- Loading states (skeleton screens with shimmer) +- Progress bar animations (smooth interpolation) +- Toast notifications (slide in/out) +- Player controls fade in/out + +### 8. Logo Generation + +**`scripts/generate-logo.sh`:** +- Uses ImageMagick (`convert` / `magick`) to generate the logo +- Square logo with "SX" monogram +- Dark background, modern sans-serif font +- Generate sizes: 16x16, 32x32, 48x48, 64x64, 128x128, 256x256, 512x512 +- Output to `ui/public/icons/` +- Also generate `favicon.ico` (multi-size) +- Also generate `apple-touch-icon.png` (180x180) + +### 9. Nix Configuration + +**`flake.nix` must provide:** + +**`nix develop`:** +- Rust toolchain (latest stable via `rust-overlay` or `fenix`) +- `cargo`, `rustfmt`, `clippy` +- `pnpm` (for frontend) +- Node.js LTS +- Playwright browsers +- `pkg-config`, `openssl` (if needed) +- musl cross-compilation toolchains +- ImageMagick (for logo generation) + +**`nix build`:** +- Build frontend first (`pnpm build` in `ui/`) +- Copy frontend dist to `backend/ui-dist/` (for `rust-embed`) +- Build Rust binary with musl target for static linking +- Output: single static binary `streamx` + +**Build targets:** +``` +nix build .#streamx-x86_64-linux # Linux x86_64 (musl static) +nix build .#streamx-aarch64-linux # Linux ARM64 (musl static) +nix build .#streamx-x86_64-darwin # macOS Intel +nix build .#streamx-aarch64-darwin # macOS Apple Silicon +``` + +**`scripts/build-release.sh`:** +- Builds all 4 targets +- Creates checksums +- Output to `release/` directory + +### 10. Code Quality Requirements + +**Rust:** +- `cargo fmt` — zero formatting issues (use `rustfmt.toml` with defaults) +- `cargo clippy` — zero warnings, treat warnings as errors (`-D warnings`) +- `cargo check` — clean compilation +- **No `.unwrap()` or `.expect()` anywhere** — use `snafu` for all error handling +- **No `panic!()` macro** — graceful error propagation everywhere +- All errors use `Snafu` derive with context selectors +- All async code uses `tokio` +- All public functions documented +- Unit tests for all modules +- Integration tests for API endpoints +- E2E tests for full workflows + +**Error handling pattern:** +```rust +use snafu::{ResultExt, Snafu}; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Failed to bind server to {address}"))] + ServerBind { address: String, source: std::io::Error }, + + #[snafu(display("Database error"))] + Database { source: rusqlite::Error }, + + #[snafu(display("Torrent engine error: {message}"))] + TorrentEngine { message: String }, + + // ... etc +} + +pub type Result = std::result::Result; +``` + +**TypeScript/Frontend:** +- Strict TypeScript (`strict: true`) +- No `any` types +- ESLint + Prettier clean +- All API calls typed +- Playwright tests for all user flows + +### 11. Testing + +**Rust unit tests (`cargo test`):** +- Config loading (TOML, env vars, CLI args, priority order) +- Auth (register, login, JWT validation, bcrypt) +- Database operations (CRUD for all tables) +- Torrent types and parsing +- Transcoding probe logic +- API handler responses + +**Rust E2E tests:** +- Start server → register user → login → search → start stream → verify HLS endpoint → stop stream +- Multi-user isolation +- Auth rejection for invalid tokens + +**Playwright UI tests:** +- Login flow (register + login + logout) +- Search flow (enter query → see results → click result) +- Player flow (start stream → verify video loads → controls work) +- History flow (watch → appears in history → resume) +- Theme toggle +- Responsive layout + +### 12. Security + +- **SQL injection:** impossible — use `rusqlite` parameterized queries only +- **XSS:** React auto-escapes, no `dangerouslySetInnerHTML` +- **Auth:** bcrypt for passwords (cost factor 12), JWT with HMAC-SHA256 +- **CORS:** restricted to same-origin by default +- **Rate limiting:** auth endpoints limited to 10 requests/minute per IP +- **Input validation:** all API inputs validated and sanitized +- **Path traversal:** file paths canonicalized and checked against allowed directories +- **No secrets in logs:** mask passwords and tokens in tracing output + +--- + +## Build & Run + +```bash +# Development +cd streamx +nix develop # Enter dev shell +cd ui && pnpm install # Install frontend deps +cd ui && pnpm dev # Frontend dev server (hot reload) +cd backend && cargo run # Backend dev server + +# Production build +nix build .#streamx-x86_64-linux + +# Run +./streamx # Defaults: http://127.0.0.1:8999 +./streamx --port 9000 --open # Custom port, open browser +STREAMX_PORT=9000 ./streamx # Via env var +``` + +--- + +## Deployment — Cloudflare Tunnel + +StreamX is exposed to the internet via a Cloudflare Tunnel on the developer's machine. The tunnel is already configured and running as a user-level systemd service. + +**Public URL:** `https://streamx.cbdemo.net/` +**Local URL:** `http://localhost:8999` + +The tunnel config at `~/.cloudflared/config.yml` contains an ingress rule: +```yaml +- hostname: streamx.cbdemo.net + service: http://localhost:8999 +``` + +The tunnel is managed with: +```bash +systemctl --user restart cloudflared # Restart after config changes +systemctl --user status cloudflared # Check status +``` + +The UI must handle being served behind a reverse proxy / tunnel: +- Use relative URLs for all API calls (no hardcoded `localhost`) +- Respect `X-Forwarded-*` headers +- WebSocket support for live progress updates (Cloudflare tunnels support WebSockets) + +--- + +## Implementation Order + +1. **Backend scaffold:** Cargo workspace, CLI (clap), config loading, Snafu errors, tracing +2. **Database:** SQLite setup, migrations, user model, auth (bcrypt + JWT) +3. **Web server:** Axum router, auth middleware, static file serving +4. **Frontend scaffold:** Vite + React + Radix UI + routing, login/register pages +5. **Torrent engine:** librqbit integration, magnet link handling, sequential download +6. **Search:** torrent search provider integration, search API, search UI +7. **Transcoding:** FFmpeg static linking, probe, HLS pipeline +8. **Streaming:** HLS endpoint, connect torrent download → transcode → serve +9. **Player UI:** hls.js integration, custom controls, fullscreen, keyboard shortcuts +10. **History:** watch history, resume position, history UI +11. **Polish:** animations, loading states, error states, themes +12. **Testing:** Rust unit tests, API integration tests, Playwright UI tests +13. **Nix build:** musl static builds for all 4 targets +14. **Logo:** generate-logo.sh script +15. **README:** usage docs, screenshots, build instructions diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..b51a183 --- /dev/null +++ b/TASKS.md @@ -0,0 +1,87 @@ +# StreamX Tasks + +## Done + +- [x] Backend scaffold (CLI, config, Snafu errors, tracing, Axum) +- [x] SQLite database with migrations, user CRUD +- [x] Auth: bcrypt + JWT, rate limiting, case-insensitive usernames +- [x] YTS torrent search (real API, verified with e2e tests) +- [x] librqbit torrent engine (real BitTorrent with DHT, sequential download) +- [x] Demo video streaming (Big Buck Bunny HLS via Mux) +- [x] React frontend (Radix UI, video.js, dark/light themes) +- [x] Login/Register page with neon laser Three.js background +- [x] Search page with poster images, quality badges, sort controls +- [x] Video player: video.js for Chrome/Firefox, native for Safari +- [x] Direct file streaming with HTTP range requests (no HLS needed for MP4) +- [x] librqbit FileStream with piece prioritization for seeking in partial downloads +- [x] Watch history UI + backend API +- [x] Settings page with theme toggle +- [x] Debug pane (collapsible, log levels, auto-scroll) +- [x] Logo (SX SVG with white outline, overlapping letters) +- [x] Nix dev shell (flake.nix with rust-overlay, pnpm, node) +- [x] Playwright e2e tests (API + browser, against real backend) +- [x] Rust integration tests (stream lifecycle, auth, API) +- [x] FFmpeg transcoding: GPU (VAAPI/NVENC/QSV), CPU fallback, HDR tone mapping +- [x] FFmpeg faststart remux for MP4 files with moov at end +- [x] Torrent pause/resume lifecycle (auto-pause on heartbeat timeout) +- [x] Stream recovery after restart (file_path.txt cache, hash-based IDs) +- [x] Non-blocking stream creation (instant response, background torrent add) +- [x] CLI: clean, wipe commands +- [x] CLI: --admin-user/--admin-password +- [x] SQLite in .streamx/db/ folder +- [x] Cloudflare tunnel support +- [x] README with streaming pipeline docs + +## High priority + +- [ ] Download management: partial/ and complete/ directories + - librqbit downloads to `downloads/partial/{torrent_name}/` + - On completion + hash verification, atomic move to `downloads/complete/{torrent_name}/` + - SQL `downloads` table tracking state per file: + ```sql + CREATE TABLE downloads ( + info_hash TEXT PRIMARY KEY, + magnet_uri TEXT NOT NULL, + title TEXT, + file_name TEXT, + file_size INTEGER, + status TEXT NOT NULL, -- initializing, downloading, verifying, complete, paused, error + progress REAL DEFAULT 0, + partial_path TEXT, + complete_path TEXT, + peers INTEGER DEFAULT 0, + download_speed INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + ``` + - Lock-free: SQLite WAL for concurrent reads/writes, atomic rename for file move + - No deadlocks: single writer pattern, no mutex nesting + - All code paths check download state from DB, not in-memory guesses + - file_path.txt replaced by DB lookup +- [ ] Move DHT files to .streamx/dht/ folder +- [ ] Nix static build: `nix build` producing musl-linked binary +- [ ] Frontend embedding: production binary serves built UI assets +- [ ] scripts/build-release.sh: cross-compile for all targets + +## Medium priority + +- [ ] Comprehensive Playwright browser tests for all UI flows +- [ ] Frontend unit tests (vitest): toward 95% coverage +- [ ] Rust unit test coverage: toward 95% +- [ ] Watch history resume: save/restore playback position +- [ ] Search history in UI +- [ ] Subtitle support: extract .srt from torrent, render in player +- [ ] Stream metadata in DB (poster, year, rating, file type) + +## Polish + +- [ ] Skeleton loading states +- [ ] Toast notifications +- [ ] Error states UI +- [ ] Mobile responsive layout +- [ ] Picture-in-Picture +- [ ] Playback speed selector +- [ ] Quality selector +- [ ] scripts/generate-logo.sh +- [ ] Favicon.ico + apple-touch-icon generation diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..426a631 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "streamx" +version.workspace = true +edition.workspace = true +license.workspace = true +default-run = "streamx" + +[[bin]] +name = "streamx" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.8", features = ["macros", "ws"] } +axum-extra = { version = "0.10", features = ["typed-header"] } +tokio = { version = "1", features = ["full"] } +rusqlite = { version = "0.32", features = ["bundled"] } +rust-embed = { version = "8", features = ["compression"] } +snafu = { version = "0.8", features = ["futures"] } +clap = { version = "4", features = ["derive", "env"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +bcrypt = "0.16" +jsonwebtoken = "9" +tower-http = { version = "0.6", features = ["cors", "compression-gzip", "trace", "set-header"] } +tower = { version = "0.5", features = ["util"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +uuid = { version = "1", features = ["v4", "serde"] } +tokio-util = { version = "0.7", features = ["io"] } +mime_guess = "2" +rand = "0.8" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +http = "1" +bytes = "1" +librqbit = "8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +urlencoding = "2" + +[dev-dependencies] +reqwest = { version = "0.12", features = ["json"] } +tempfile = "3" +tokio = { version = "1", features = ["full", "test-util"] } +portpicker = "0.1" diff --git a/backend/src/cli.rs b/backend/src/cli.rs new file mode 100644 index 0000000..c9c3f79 --- /dev/null +++ b/backend/src/cli.rs @@ -0,0 +1,57 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser, Debug, Clone)] +#[command( + name = "streamx", + about = "StreamX - Torrent Video Streaming Player", + version, + author +)] +pub struct Cli { + #[command(subcommand)] + pub command: Option, + + #[arg(short = 'p', long, help = "Listen port", env = "STREAMX_PORT")] + pub port: Option, + + #[arg(short = 'b', long, help = "Bind address", env = "STREAMX_BIND")] + pub bind: Option, + + #[arg(short = 'd', long, help = "Data directory", env = "STREAMX_DATA_DIR")] + pub data_dir: Option, + + #[arg(short = 'c', long, help = "Config file path", env = "STREAMX_CONFIG")] + pub config: Option, + + #[arg( + long, + help = "Log level (trace, debug, info, warn, error)", + env = "STREAMX_LOG_LEVEL" + )] + pub log_level: Option, + + #[arg(long, help = "Open browser on start", env = "STREAMX_OPEN")] + pub open: bool, + + #[arg( + long, + help = "Create admin user on startup (not recommended for production)", + env = "STREAMX_ADMIN_USER" + )] + pub admin_user: Option, + + #[arg( + long, + help = "Admin password (not recommended for production, use interactive setup instead)", + env = "STREAMX_ADMIN_PASSWORD" + )] + pub admin_password: Option, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum Command { + /// Clear HLS cache and torrent downloads (keeps config and database) + Clean, + /// Wipe everything except config file (cache, downloads, database, logs, DHT) + Wipe, +} diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..b729bd3 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,320 @@ +use crate::cli::Cli; +use crate::error::{self, Error, Result}; +use base64::Engine; +use serde::{Deserialize, Serialize}; +use snafu::ResultExt; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + #[serde(default = "default_server")] + pub server: ServerConfig, + #[serde(default = "default_torrent")] + pub torrent: TorrentConfig, + #[serde(default = "default_transcode")] + pub transcode: TranscodeConfig, + #[serde(default = "default_auth")] + pub auth: AuthConfig, + #[serde(default = "default_ui")] + pub ui: UiConfig, + + #[serde(skip)] + pub data_dir: PathBuf, + #[serde(skip)] + pub log_level: String, + #[serde(skip)] + pub open_browser: bool, + #[serde(skip)] + pub admin_user: Option, + #[serde(skip)] + pub admin_password: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerConfig { + #[serde(default = "default_port")] + pub port: u16, + #[serde(default = "default_bind")] + pub bind: String, + #[serde(default = "default_open_browser")] + pub open_browser: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TorrentConfig { + #[serde(default = "default_max_connections")] + pub max_connections: u32, + #[serde(default = "default_true")] + pub sequential: bool, + #[serde(default = "default_true")] + pub seed_after_complete: bool, + #[serde(default = "default_true")] + pub dht: bool, + #[serde(default = "default_true")] + pub pex: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TranscodeConfig { + #[serde(default = "default_segment_duration")] + pub hls_segment_duration: u32, + #[serde(default = "default_video_codec")] + pub video_codec: String, + #[serde(default = "default_audio_codec")] + pub audio_codec: String, + #[serde(default = "default_preset")] + pub preset: String, + #[serde(default = "default_max_concurrent_transcodes")] + pub max_concurrent_transcodes: u32, + #[serde(default = "default_crf")] + pub crf: u32, + #[serde(default)] + pub max_bitrate: Option, + #[serde(default = "default_audio_bitrate")] + pub audio_bitrate: String, + #[serde(default)] + pub threads: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthConfig { + #[serde(default)] + pub jwt_secret: String, + #[serde(default = "default_session_duration")] + pub session_duration: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UiConfig { + #[serde(default = "default_theme")] + pub default_theme: String, +} + +fn default_server() -> ServerConfig { + ServerConfig { + port: default_port(), + bind: default_bind(), + open_browser: default_open_browser(), + } +} + +fn default_torrent() -> TorrentConfig { + TorrentConfig { + max_connections: default_max_connections(), + sequential: true, + seed_after_complete: true, + dht: true, + pex: true, + } +} + +fn default_transcode() -> TranscodeConfig { + TranscodeConfig { + hls_segment_duration: default_segment_duration(), + video_codec: default_video_codec(), + audio_codec: default_audio_codec(), + preset: default_preset(), + max_concurrent_transcodes: default_max_concurrent_transcodes(), + crf: default_crf(), + max_bitrate: None, + audio_bitrate: default_audio_bitrate(), + threads: None, + } +} + +fn default_auth() -> AuthConfig { + AuthConfig { + jwt_secret: String::new(), + session_duration: default_session_duration(), + } +} + +fn default_ui() -> UiConfig { + UiConfig { + default_theme: default_theme(), + } +} + +fn default_port() -> u16 { + 8999 +} + +fn default_bind() -> String { + "127.0.0.1".to_string() +} + +fn default_open_browser() -> bool { + false +} + +fn default_max_connections() -> u32 { + 200 +} + +fn default_true() -> bool { + true +} + +fn default_segment_duration() -> u32 { + 4 +} + +fn default_video_codec() -> String { + "h264".to_string() +} + +fn default_audio_codec() -> String { + "aac".to_string() +} + +fn default_preset() -> String { + "ultrafast".to_string() +} + +fn default_max_concurrent_transcodes() -> u32 { + 2 +} + +fn default_crf() -> u32 { + 23 +} + +fn default_audio_bitrate() -> String { + "192k".to_string() +} + +fn default_session_duration() -> String { + "7d".to_string() +} + +fn default_theme() -> String { + "dark".to_string() +} + +fn generate_jwt_secret() -> String { + let mut bytes = [0u8; 64]; + use rand::RngCore; + rand::thread_rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn default_data_dir() -> Result { + let home = std::env::var("HOME").map_err(|_| Error::Config { + message: "HOME environment variable not set".to_string(), + })?; + Ok(PathBuf::from(home).join(".streamx")) +} + +fn default_config_content() -> String { + r#"[server] +port = 8999 +bind = "127.0.0.1" +open_browser = false + +[torrent] +max_connections = 200 +sequential = true +seed_after_complete = true +dht = true +pex = true + +[transcode] +hls_segment_duration = 4 +video_codec = "h264" +audio_codec = "aac" +preset = "ultrafast" +max_concurrent_transcodes = 2 +crf = 23 +audio_bitrate = "192k" + +[auth] +jwt_secret = "" +session_duration = "7d" + +[ui] +default_theme = "dark" +"# + .to_string() +} + +pub fn load_config(cli: &Cli) -> Result { + let data_dir = match &cli.data_dir { + Some(d) => PathBuf::from(d), + None => default_data_dir()?, + }; + + std::fs::create_dir_all(&data_dir).context(error::IoSnafu)?; + + let config_path = match &cli.config { + Some(p) => PathBuf::from(p), + None => data_dir.join("config.toml"), + }; + + let mut config = load_from_file(&config_path)?; + + if config.auth.jwt_secret.is_empty() { + config.auth.jwt_secret = generate_jwt_secret(); + save_config(&config_path, &config)?; + } + + if let Some(port) = cli.port { + config.server.port = port; + } + if let Some(ref bind) = cli.bind { + config.server.bind = bind.clone(); + } + + config.data_dir = data_dir; + config.log_level = cli.log_level.clone().unwrap_or_else(|| "info".to_string()); + config.open_browser = cli.open || config.server.open_browser; + config.admin_user = cli.admin_user.clone(); + config.admin_password = cli.admin_password.clone(); + + ensure_directories(&config)?; + + Ok(config) +} + +fn load_from_file(path: &Path) -> Result { + if path.exists() { + let content = std::fs::read_to_string(path).context(error::IoSnafu)?; + let config: AppConfig = toml::from_str(&content).map_err(|e| Error::Config { + message: format!("Failed to parse config file: {e}"), + })?; + Ok(config) + } else { + let content = default_config_content(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).context(error::IoSnafu)?; + } + std::fs::write(path, &content).context(error::IoSnafu)?; + let config: AppConfig = toml::from_str(&content).map_err(|e| Error::Config { + message: format!("Failed to parse default config: {e}"), + })?; + Ok(config) + } +} + +fn save_config(path: &Path, config: &AppConfig) -> Result<()> { + let content = toml::to_string_pretty(config).map_err(|e| Error::Config { + message: format!("Failed to serialize config: {e}"), + })?; + std::fs::write(path, content).context(error::IoSnafu)?; + Ok(()) +} + +fn ensure_directories(config: &AppConfig) -> Result<()> { + let cache_dir = config.data_dir.join("cache"); + std::fs::create_dir_all(&cache_dir).context(error::IoSnafu)?; + + let partial_dir = config.data_dir.join("downloads").join("partial"); + std::fs::create_dir_all(&partial_dir).context(error::IoSnafu)?; + + let complete_dir = config.data_dir.join("downloads").join("complete"); + std::fs::create_dir_all(&complete_dir).context(error::IoSnafu)?; + + let dht_dir = config.data_dir.join("dht"); + std::fs::create_dir_all(&dht_dir).context(error::IoSnafu)?; + + Ok(()) +} diff --git a/backend/src/db/downloads.rs b/backend/src/db/downloads.rs new file mode 100644 index 0000000..370a13a --- /dev/null +++ b/backend/src/db/downloads.rs @@ -0,0 +1,202 @@ +use crate::db::Database; +use crate::error::{self, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use snafu::ResultExt; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Download { + pub info_hash: String, + pub magnet_uri: String, + pub title: String, + pub file_name: String, + pub file_index: usize, + pub file_size: u64, + pub status: String, + pub progress: f64, + pub partial_path: Option, + pub complete_path: Option, + pub created_at: String, + pub updated_at: String, +} + +impl Database { + pub async fn upsert_download(&self, dl: &Download) -> Result<()> { + let conn = self.connection().lock().await; + conn.execute( + "INSERT INTO downloads (info_hash, magnet_uri, title, file_name, file_index, file_size, \ + status, progress, partial_path, complete_path, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) \ + ON CONFLICT(info_hash) DO UPDATE SET \ + magnet_uri = ?2, title = ?3, file_name = ?4, file_index = ?5, file_size = ?6, \ + status = ?7, progress = ?8, partial_path = ?9, complete_path = ?10, updated_at = ?12", + rusqlite::params![ + dl.info_hash, + dl.magnet_uri, + dl.title, + dl.file_name, + dl.file_index as i64, + dl.file_size as i64, + dl.status, + dl.progress, + dl.partial_path, + dl.complete_path, + dl.created_at, + dl.updated_at, + ], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } + + pub async fn get_download(&self, info_hash: &str) -> Result> { + let conn = self.connection().lock().await; + let mut stmt = conn + .prepare( + "SELECT info_hash, magnet_uri, title, file_name, file_index, file_size, \ + status, progress, partial_path, complete_path, created_at, updated_at \ + FROM downloads WHERE info_hash = ?1", + ) + .context(error::DatabaseSnafu)?; + + let result = stmt.query_row(rusqlite::params![info_hash], |row| { + Ok(Download { + info_hash: row.get(0)?, + magnet_uri: row.get(1)?, + title: row.get(2)?, + file_name: row.get(3)?, + file_index: row.get::<_, i64>(4)? as usize, + file_size: row.get::<_, i64>(5)? as u64, + status: row.get(6)?, + progress: row.get(7)?, + partial_path: row.get(8)?, + complete_path: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, + }) + }); + + match result { + Ok(dl) => Ok(Some(dl)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e).context(error::DatabaseSnafu), + } + } + + pub async fn update_download_status(&self, info_hash: &str, status: &str) -> Result<()> { + let now = Utc::now().to_rfc3339(); + let conn = self.connection().lock().await; + conn.execute( + "UPDATE downloads SET status = ?1, updated_at = ?2 WHERE info_hash = ?3", + rusqlite::params![status, now, info_hash], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } + + pub async fn update_download_progress( + &self, + info_hash: &str, + progress: f64, + file_size: u64, + ) -> Result<()> { + let now = Utc::now().to_rfc3339(); + let conn = self.connection().lock().await; + conn.execute( + "UPDATE downloads SET progress = ?1, file_size = ?2, updated_at = ?3 WHERE info_hash = ?4", + rusqlite::params![progress, file_size as i64, now, info_hash], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } + + pub async fn update_download_metadata( + &self, + info_hash: &str, + title: &str, + file_name: &str, + file_index: usize, + file_size: u64, + partial_path: Option<&str>, + ) -> Result<()> { + let now = Utc::now().to_rfc3339(); + let conn = self.connection().lock().await; + conn.execute( + "UPDATE downloads SET title = ?1, file_name = ?2, file_index = ?3, file_size = ?4, \ + partial_path = ?5, status = 'downloading', updated_at = ?6 WHERE info_hash = ?7", + rusqlite::params![ + title, + file_name, + file_index as i64, + file_size as i64, + partial_path, + now, + info_hash, + ], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } + + pub async fn update_download_paths( + &self, + info_hash: &str, + partial_path: Option<&str>, + complete_path: Option<&str>, + ) -> Result<()> { + let now = Utc::now().to_rfc3339(); + let conn = self.connection().lock().await; + conn.execute( + "UPDATE downloads SET partial_path = ?1, complete_path = ?2, updated_at = ?3 WHERE info_hash = ?4", + rusqlite::params![partial_path, complete_path, now, info_hash], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } + + pub async fn list_downloads(&self) -> Result> { + let conn = self.connection().lock().await; + let mut stmt = conn + .prepare( + "SELECT info_hash, magnet_uri, title, file_name, file_index, file_size, \ + status, progress, partial_path, complete_path, created_at, updated_at \ + FROM downloads ORDER BY updated_at DESC", + ) + .context(error::DatabaseSnafu)?; + + let entries = stmt + .query_map([], |row| { + Ok(Download { + info_hash: row.get(0)?, + magnet_uri: row.get(1)?, + title: row.get(2)?, + file_name: row.get(3)?, + file_index: row.get::<_, i64>(4)? as usize, + file_size: row.get::<_, i64>(5)? as u64, + status: row.get(6)?, + progress: row.get(7)?, + partial_path: row.get(8)?, + complete_path: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, + }) + }) + .context(error::DatabaseSnafu)? + .collect::, _>>() + .context(error::DatabaseSnafu)?; + + Ok(entries) + } + + pub async fn set_downloading_to_paused(&self) -> Result<()> { + let now = Utc::now().to_rfc3339(); + let conn = self.connection().lock().await; + conn.execute( + "UPDATE downloads SET status = 'paused', updated_at = ?1 \ + WHERE status IN ('downloading', 'initializing')", + rusqlite::params![now], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } +} diff --git a/backend/src/db/history.rs b/backend/src/db/history.rs new file mode 100644 index 0000000..8cdd486 --- /dev/null +++ b/backend/src/db/history.rs @@ -0,0 +1,165 @@ +use crate::db::Database; +use crate::error::{self, Result}; +use chrono::Utc; +use serde::Serialize; +use snafu::ResultExt; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize)] +pub struct SearchEntry { + pub id: String, + pub user_id: String, + pub query: String, + pub result_count: Option, + pub searched_at: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WatchEntry { + pub id: String, + pub user_id: String, + pub magnet_uri: String, + pub title: String, + pub file_name: Option, + pub duration_seconds: Option, + pub watched_seconds: Option, + pub poster_url: Option, + pub watched_at: String, +} + +impl Database { + pub async fn add_search( + &self, + user_id: &str, + query: &str, + result_count: i32, + ) -> Result { + let id = Uuid::new_v4().to_string(); + let searched_at = Utc::now().to_rfc3339(); + + let conn = self.connection().lock().await; + conn.execute( + "INSERT INTO search_history (id, user_id, query, result_count, searched_at) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![id, user_id, query, result_count, searched_at], + ) + .context(error::DatabaseSnafu)?; + + Ok(SearchEntry { + id, + user_id: user_id.to_string(), + query: query.to_string(), + result_count: Some(result_count), + searched_at, + }) + } + + pub async fn get_search_history(&self, user_id: &str) -> Result> { + let conn = self.connection().lock().await; + let mut stmt = conn + .prepare( + "SELECT id, user_id, query, result_count, searched_at \ + FROM search_history WHERE user_id = ?1 \ + ORDER BY searched_at DESC LIMIT 100", + ) + .context(error::DatabaseSnafu)?; + + let entries = stmt + .query_map(rusqlite::params![user_id], |row| { + Ok(SearchEntry { + id: row.get(0)?, + user_id: row.get(1)?, + query: row.get(2)?, + result_count: row.get(3)?, + searched_at: row.get(4)?, + }) + }) + .context(error::DatabaseSnafu)? + .collect::, _>>() + .context(error::DatabaseSnafu)?; + + Ok(entries) + } + + pub async fn add_watch( + &self, + user_id: &str, + magnet_uri: &str, + title: &str, + file_name: Option<&str>, + ) -> Result { + let id = Uuid::new_v4().to_string(); + let watched_at = Utc::now().to_rfc3339(); + + let conn = self.connection().lock().await; + conn.execute( + "INSERT INTO watch_history (id, user_id, magnet_uri, title, file_name, watched_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![id, user_id, magnet_uri, title, file_name, watched_at], + ) + .context(error::DatabaseSnafu)?; + + Ok(WatchEntry { + id, + user_id: user_id.to_string(), + magnet_uri: magnet_uri.to_string(), + title: title.to_string(), + file_name: file_name.map(String::from), + duration_seconds: None, + watched_seconds: None, + poster_url: None, + watched_at, + }) + } + + pub async fn update_watch_position(&self, id: &str, watched_seconds: i64) -> Result<()> { + let conn = self.connection().lock().await; + conn.execute( + "UPDATE watch_history SET watched_seconds = ?1 WHERE id = ?2", + rusqlite::params![watched_seconds, id], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } + + pub async fn get_watch_history(&self, user_id: &str) -> Result> { + let conn = self.connection().lock().await; + let mut stmt = conn + .prepare( + "SELECT id, user_id, magnet_uri, title, file_name, \ + duration_seconds, watched_seconds, poster_url, watched_at \ + FROM watch_history WHERE user_id = ?1 \ + ORDER BY watched_at DESC LIMIT 100", + ) + .context(error::DatabaseSnafu)?; + + let entries = stmt + .query_map(rusqlite::params![user_id], |row| { + Ok(WatchEntry { + id: row.get(0)?, + user_id: row.get(1)?, + magnet_uri: row.get(2)?, + title: row.get(3)?, + file_name: row.get(4)?, + duration_seconds: row.get(5)?, + watched_seconds: row.get(6)?, + poster_url: row.get(7)?, + watched_at: row.get(8)?, + }) + }) + .context(error::DatabaseSnafu)? + .collect::, _>>() + .context(error::DatabaseSnafu)?; + + Ok(entries) + } + + pub async fn delete_watch(&self, id: &str) -> Result<()> { + let conn = self.connection().lock().await; + conn.execute( + "DELETE FROM watch_history WHERE id = ?1", + rusqlite::params![id], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } +} diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs new file mode 100644 index 0000000..ba8770f --- /dev/null +++ b/backend/src/db/migrations.rs @@ -0,0 +1,100 @@ +use crate::error::{self, Result}; +use rusqlite::Connection; +use snafu::ResultExt; + +const MIGRATIONS: &[&str] = &[ + // Migration 0: Create users table + "CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0 + );", + // Migration 1: Create search_history table + "CREATE TABLE IF NOT EXISTS search_history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + query TEXT NOT NULL, + result_count INTEGER, + searched_at TEXT NOT NULL + );", + // Migration 2: Create watch_history table + "CREATE TABLE IF NOT EXISTS watch_history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + magnet_uri TEXT NOT NULL, + title TEXT NOT NULL, + file_name TEXT, + duration_seconds INTEGER, + watched_seconds INTEGER, + poster_url TEXT, + watched_at TEXT NOT NULL + );", + // Migration 3: Create active_streams table + "CREATE TABLE IF NOT EXISTS active_streams ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + magnet_uri TEXT NOT NULL, + file_index INTEGER NOT NULL, + status TEXT NOT NULL, + progress REAL, + peers INTEGER, + download_speed INTEGER, + created_at TEXT NOT NULL + );", + // Migration 4: Create user_settings table + "CREATE TABLE IF NOT EXISTS user_settings ( + user_id TEXT PRIMARY KEY REFERENCES users(id), + theme TEXT NOT NULL DEFAULT 'dark', + updated_at TEXT NOT NULL + );", + // Migration 5: Create schema_version tracking + "CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY + );", + // Migration 6: Create downloads table for torrent state tracking + "CREATE TABLE IF NOT EXISTS downloads ( + info_hash TEXT PRIMARY KEY, + magnet_uri TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + file_name TEXT NOT NULL DEFAULT '', + file_index INTEGER NOT NULL DEFAULT 0, + file_size INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'initializing', + progress REAL NOT NULL DEFAULT 0, + partial_path TEXT, + complete_path TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );", +]; + +pub fn run_migrations(conn: &Connection) -> Result<()> { + conn.execute_batch("CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY);") + .context(error::DatabaseSnafu)?; + + let current_version: i64 = conn + .query_row( + "SELECT COALESCE(MAX(version), -1) FROM schema_version", + [], + |row| row.get(0), + ) + .context(error::DatabaseSnafu)?; + + for (i, migration) in MIGRATIONS.iter().enumerate() { + let version = i as i64; + if version > current_version { + conn.execute_batch(migration) + .context(error::DatabaseSnafu)?; + conn.execute( + "INSERT INTO schema_version (version) VALUES (?1)", + rusqlite::params![version], + ) + .context(error::DatabaseSnafu)?; + tracing::info!(version = version, "Applied migration"); + } + } + + Ok(()) +} diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs new file mode 100644 index 0000000..8f9b10e --- /dev/null +++ b/backend/src/db/mod.rs @@ -0,0 +1,42 @@ +pub mod downloads; +pub mod history; +pub mod migrations; +pub mod settings; +pub mod users; + +use crate::error::{self, Result}; +use rusqlite::Connection; +use snafu::ResultExt; +use std::path::Path; +use std::sync::Arc; +use tokio::sync::Mutex; + +#[derive(Clone)] +pub struct Database { + conn: Arc>, +} + +impl Database { + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path).context(error::DatabaseSnafu)?; + + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") + .context(error::DatabaseSnafu)?; + + let db = Self { + conn: Arc::new(Mutex::new(conn)), + }; + + Ok(db) + } + + pub async fn init(&self) -> Result<()> { + let conn = self.conn.lock().await; + migrations::run_migrations(&conn)?; + Ok(()) + } + + pub fn connection(&self) -> &Arc> { + &self.conn + } +} diff --git a/backend/src/db/settings.rs b/backend/src/db/settings.rs new file mode 100644 index 0000000..0bde802 --- /dev/null +++ b/backend/src/db/settings.rs @@ -0,0 +1,49 @@ +use crate::db::Database; +use crate::error::{self, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use snafu::ResultExt; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserSettings { + pub theme: String, +} + +impl Default for UserSettings { + fn default() -> Self { + Self { + theme: "dark".to_string(), + } + } +} + +impl Database { + pub async fn get_settings(&self, user_id: &str) -> Result { + let conn = self.connection().lock().await; + let mut stmt = conn + .prepare("SELECT theme FROM user_settings WHERE user_id = ?1") + .context(error::DatabaseSnafu)?; + + let result = stmt.query_row(rusqlite::params![user_id], |row| { + Ok(UserSettings { theme: row.get(0)? }) + }); + + match result { + Ok(settings) => Ok(settings), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(UserSettings::default()), + Err(e) => Err(e).context(error::DatabaseSnafu), + } + } + + pub async fn update_settings(&self, user_id: &str, settings: &UserSettings) -> Result<()> { + let updated_at = Utc::now().to_rfc3339(); + let conn = self.connection().lock().await; + conn.execute( + "INSERT INTO user_settings (user_id, theme, updated_at) VALUES (?1, ?2, ?3) \ + ON CONFLICT(user_id) DO UPDATE SET theme = ?2, updated_at = ?3", + rusqlite::params![user_id, settings.theme, updated_at], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } +} diff --git a/backend/src/db/users.rs b/backend/src/db/users.rs new file mode 100644 index 0000000..d96dfde --- /dev/null +++ b/backend/src/db/users.rs @@ -0,0 +1,98 @@ +use crate::db::Database; +use crate::error::{self, Result}; +use chrono::Utc; +use serde::Serialize; +use snafu::ResultExt; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize)] +pub struct User { + pub id: String, + pub username: String, + #[serde(skip_serializing)] + pub password_hash: String, + pub created_at: String, + pub is_admin: bool, +} + +impl Database { + pub async fn create_user(&self, username: &str, password_hash: &str) -> Result { + let id = Uuid::new_v4().to_string(); + let created_at = Utc::now().to_rfc3339(); + let is_admin = self.user_count().await? == 0; + let username_lower = username.to_lowercase(); + + let user = User { + id: id.clone(), + username: username_lower.clone(), + password_hash: password_hash.to_string(), + created_at: created_at.clone(), + is_admin, + }; + + let conn = self.connection().lock().await; + conn.execute( + "INSERT INTO users (id, username, password_hash, created_at, is_admin) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![id, username_lower, password_hash, created_at, is_admin as i32], + ) + .context(error::DatabaseSnafu)?; + + Ok(user) + } + + pub async fn find_user_by_username(&self, username: &str) -> Result> { + let conn = self.connection().lock().await; + let mut stmt = conn + .prepare("SELECT id, username, password_hash, created_at, is_admin FROM users WHERE username = ?1") + .context(error::DatabaseSnafu)?; + + let result = stmt.query_row(rusqlite::params![username], |row| { + Ok(User { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + created_at: row.get(3)?, + is_admin: row.get::<_, i32>(4)? != 0, + }) + }); + + match result { + Ok(user) => Ok(Some(user)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e).context(error::DatabaseSnafu), + } + } + + pub async fn find_user_by_id(&self, id: &str) -> Result> { + let conn = self.connection().lock().await; + let mut stmt = conn + .prepare( + "SELECT id, username, password_hash, created_at, is_admin FROM users WHERE id = ?1", + ) + .context(error::DatabaseSnafu)?; + + let result = stmt.query_row(rusqlite::params![id], |row| { + Ok(User { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + created_at: row.get(3)?, + is_admin: row.get::<_, i32>(4)? != 0, + }) + }); + + match result { + Ok(user) => Ok(Some(user)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e).context(error::DatabaseSnafu), + } + } + + pub async fn user_count(&self) -> Result { + let conn = self.connection().lock().await; + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0)) + .context(error::DatabaseSnafu)?; + Ok(count) + } +} diff --git a/backend/src/embedded.rs b/backend/src/embedded.rs new file mode 100644 index 0000000..b249184 --- /dev/null +++ b/backend/src/embedded.rs @@ -0,0 +1,5 @@ +use rust_embed::Embed; + +#[derive(Embed)] +#[folder = "../ui/dist"] +pub struct Asset; diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..f5793d5 --- /dev/null +++ b/backend/src/error.rs @@ -0,0 +1,90 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use snafu::Snafu; + +#[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] +pub enum Error { + #[snafu(display("Failed to bind server to {address}"))] + ServerBind { + address: String, + source: std::io::Error, + }, + + #[snafu(display("Database error: {source}"))] + Database { source: rusqlite::Error }, + + #[snafu(display("Configuration error: {message}"))] + Config { message: String }, + + #[snafu(display("Authentication error: {message}"))] + Auth { message: String }, + + #[snafu(display("Torrent error: {message}"))] + Torrent { message: String }, + + #[snafu(display("Transcode error: {message}"))] + Transcode { message: String }, + + #[snafu(display("Not found: {message}"))] + NotFound { message: String }, + + #[snafu(display("Bad request: {message}"))] + BadRequest { message: String }, + + #[snafu(display("Unauthorized: {message}"))] + Unauthorized { message: String }, + + #[snafu(display("Internal error: {message}"))] + Internal { message: String }, + + #[snafu(display("IO error: {source}"))] + Io { source: std::io::Error }, + + #[snafu(display("Password hashing error: {message}"))] + PasswordHash { message: String }, + + #[snafu(display("JWT error: {source}"))] + Jwt { source: jsonwebtoken::errors::Error }, + + #[snafu(display("Rate limited"))] + RateLimited, +} + +pub type Result = std::result::Result; + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let (status, message) = match &self { + Error::ServerBind { address, .. } => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to bind to {address}"), + ), + Error::Database { .. } => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Database error".to_string(), + ), + Error::Config { message } => (StatusCode::INTERNAL_SERVER_ERROR, message.clone()), + Error::Auth { message } => (StatusCode::UNAUTHORIZED, message.clone()), + Error::Torrent { message } => (StatusCode::INTERNAL_SERVER_ERROR, message.clone()), + Error::Transcode { message } => (StatusCode::INTERNAL_SERVER_ERROR, message.clone()), + Error::NotFound { message } => (StatusCode::NOT_FOUND, message.clone()), + Error::BadRequest { message } => (StatusCode::BAD_REQUEST, message.clone()), + Error::Unauthorized { message } => (StatusCode::UNAUTHORIZED, message.clone()), + Error::Internal { message } => (StatusCode::INTERNAL_SERVER_ERROR, message.clone()), + Error::Io { .. } => (StatusCode::INTERNAL_SERVER_ERROR, "IO error".to_string()), + Error::PasswordHash { .. } => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Password processing error".to_string(), + ), + Error::Jwt { .. } => (StatusCode::UNAUTHORIZED, "Invalid token".to_string()), + Error::RateLimited => ( + StatusCode::TOO_MANY_REQUESTS, + "Too many requests, please try again later".to_string(), + ), + }; + + let body = serde_json::json!({ "error": message }); + (status, axum::Json(body)).into_response() + } +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..ef55317 --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,8 @@ +pub mod cli; +pub mod config; +pub mod db; +pub mod embedded; +pub mod error; +pub mod server; +pub mod torrent; +pub mod transcode; diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..fcbe9a9 --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,193 @@ +use clap::Parser; +use std::net::SocketAddr; +use streamx::cli; +use streamx::config; +use streamx::db; +use streamx::error::{self, Result}; +use streamx::server; +use streamx::torrent; +use streamx::transcode; +use tracing::info; + +#[tokio::main] +async fn main() -> Result<()> { + let cli = cli::Cli::parse(); + let config = config::load_config(&cli)?; + + if let Some(cmd) = &cli.command { + return run_command(cmd, &config); + } + + let filter = tracing_subscriber::EnvFilter::try_new(&config.log_level) + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(true) + .init(); + + info!( + version = env!("CARGO_PKG_VERSION"), + data_dir = %config.data_dir.display(), + "Starting StreamX" + ); + + let db_dir = config.data_dir.join("db"); + std::fs::create_dir_all(&db_dir).map_err(|e| error::Error::Config { + message: format!("Failed to create database directory: {e}"), + })?; + let db_path = db_dir.join("streamx.db"); + let database = db::Database::open(&db_path)?; + database.init().await?; + info!("Database initialized"); + + if let (Some(admin_user), Some(admin_pass)) = (&config.admin_user, &config.admin_password) { + match database.find_user_by_username(admin_user).await? { + Some(_) => { + info!(username = %admin_user, "Admin user already exists, skipping creation"); + } + None => { + let password_hash = server::auth::hash_password(admin_pass)?; + database.create_user(admin_user, &password_hash).await?; + info!(username = %admin_user, "Admin user created"); + } + } + } + + database.set_downloading_to_paused().await?; + info!("Reset in-flight downloads to paused state"); + + let torrent_engine = + torrent::TorrentEngine::create(&config.torrent, &config.data_dir, database.clone()).await?; + let search_provider = torrent::SearchProvider::new(); + let cache_dir = config.data_dir.join("cache"); + let hls_pipeline = transcode::HlsManager::new(&config.transcode, cache_dir).await?; + + let bind_addr = config.server.bind.clone(); + let port = config.server.port; + let open_browser = config.open_browser; + + let app = server::build_router( + database, + config, + torrent_engine, + search_provider, + hls_pipeline, + ); + + let addr: SocketAddr = + format!("{bind_addr}:{port}") + .parse() + .map_err(|_| error::Error::Config { + message: format!("Invalid bind address: {bind_addr}:{port}"), + })?; + + info!(%addr, "Server listening"); + + if open_browser { + let url = format!("http://{addr}"); + let _ = open_url(&url); + } + + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(|source| error::Error::ServerBind { + address: addr.to_string(), + source, + })?; + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .map_err(|source| error::Error::ServerBind { + address: addr.to_string(), + source, + })?; + + Ok(()) +} + +fn run_command(cmd: &cli::Command, config: &config::AppConfig) -> Result<()> { + let data_dir = &config.data_dir; + + match cmd { + cli::Command::Clean => { + let cache_dir = data_dir.join("cache"); + let downloads_dir = data_dir.join("downloads"); + + if cache_dir.exists() { + std::fs::remove_dir_all(&cache_dir).map_err(|e| error::Error::Io { source: e })?; + println!("Removed cache: {}", cache_dir.display()); + } + if downloads_dir.exists() { + std::fs::remove_dir_all(&downloads_dir) + .map_err(|e| error::Error::Io { source: e })?; + println!("Removed downloads: {}", downloads_dir.display()); + } + println!("Clean complete. Config and database preserved."); + } + cli::Command::Wipe => { + let keep_config = data_dir.join("config.toml"); + let config_backup = if keep_config.exists() { + Some(std::fs::read_to_string(&keep_config).ok()) + } else { + None + }; + + let entries: Vec<_> = std::fs::read_dir(data_dir) + .map_err(|e| error::Error::Io { source: e })? + .filter_map(|e| e.ok()) + .collect(); + + for entry in entries { + let path = entry.path(); + if path + .file_name() + .map(|n| n == "config.toml") + .unwrap_or(false) + { + continue; + } + if path.is_dir() { + std::fs::remove_dir_all(&path).map_err(|e| error::Error::Io { source: e })?; + println!("Removed: {}", path.display()); + } else { + std::fs::remove_file(&path).map_err(|e| error::Error::Io { source: e })?; + println!("Removed: {}", path.display()); + } + } + + if let Some(Some(content)) = config_backup { + std::fs::write(&keep_config, content) + .map_err(|e| error::Error::Io { source: e })?; + } + + println!("Wipe complete. Only config.toml preserved."); + } + } + + Ok(()) +} + +fn open_url(url: &str) -> std::result::Result<(), std::io::Error> { + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open").arg(url).spawn()?; + } + + #[cfg(target_os = "macos")] + { + std::process::Command::new("open").arg(url).spawn()?; + } + + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .args(["/c", "start", url]) + .spawn()?; + } + + Ok(()) +} diff --git a/backend/src/server/api.rs b/backend/src/server/api.rs new file mode 100644 index 0000000..266e0f1 --- /dev/null +++ b/backend/src/server/api.rs @@ -0,0 +1,257 @@ +use crate::error::Error; +use crate::server::auth::Claims; +use crate::server::AppState; +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use axum::Json; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct SearchRequest { + pub query: String, +} + +#[derive(Debug, Serialize)] +pub struct SearchResponse { + pub results: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct CreateStreamRequest { + pub magnet_uri: String, + pub file_index: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateHistoryRequest { + pub watched_seconds: i64, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateSettingsRequest { + pub theme: Option, +} + +pub async fn search( + State(state): State, + claims: Claims, + Json(body): Json, +) -> std::result::Result { + let query = body.query.trim(); + if query.is_empty() || query.len() > 500 { + return Err(Error::BadRequest { + message: "Query must be between 1 and 500 characters".to_string(), + }); + } + + let results = state.search_provider.search(query).await?; + let result_count = results.len() as i32; + + state + .db + .add_search(&claims.user_id, query, result_count) + .await?; + + Ok(Json(SearchResponse { results })) +} + +pub async fn search_history( + State(state): State, + claims: Claims, +) -> std::result::Result { + let searches = state.db.get_search_history(&claims.user_id).await?; + Ok(Json(serde_json::json!({ "searches": searches }))) +} + +pub async fn create_stream( + State(state): State, + claims: Claims, + Json(body): Json, +) -> std::result::Result { + let magnet_uri = body.magnet_uri.trim(); + if magnet_uri.is_empty() || magnet_uri.len() > 2048 { + return Err(Error::BadRequest { + message: "Invalid magnet URI".to_string(), + }); + } + + let download = state + .torrent_engine + .add_magnet(magnet_uri, body.file_index) + .await?; + + let title = if download.title.is_empty() { + &download.info_hash + } else { + &download.title + }; + let _ = state + .db + .add_watch(&claims.user_id, magnet_uri, title, None) + .await; + + Ok(Json(serde_json::json!({ + "stream_id": download.info_hash, + "status": download.status, + "title": download.title, + "file_name": download.file_name, + }))) +} + +pub async fn get_stream( + State(state): State, + _claims: Claims, + Path(id): Path, +) -> std::result::Result { + let download = state + .torrent_engine + .get_download(&id) + .await? + .ok_or_else(|| Error::NotFound { + message: format!("Stream {id} not found"), + })?; + + let (peers, speed) = state.torrent_engine.get_live_stats(&id).await; + + Ok(Json(serde_json::json!({ + "id": download.info_hash, + "status": download.status, + "progress": download.progress, + "title": download.title, + "file_name": download.file_name, + "file_size": download.file_size, + "partial_path": download.partial_path, + "complete_path": download.complete_path, + "peers": peers, + "speed": speed, + }))) +} + +pub async fn pause_stream( + State(state): State, + _claims: Claims, + Path(id): Path, +) -> std::result::Result { + state.torrent_engine.pause(&id).await?; + Ok(Json(serde_json::json!({ "status": "paused" }))) +} + +pub async fn resume_stream( + State(state): State, + _claims: Claims, + Path(id): Path, +) -> std::result::Result { + state.torrent_engine.resume(&id).await?; + Ok(Json(serde_json::json!({ "status": "resumed" }))) +} + +pub async fn delete_stream( + State(state): State, + _claims: Claims, + Path(id): Path, +) -> std::result::Result { + state.hls_pipeline.cleanup(&id).await?; + Ok(Json(serde_json::json!({ "status": "stopped" }))) +} + +pub async fn get_history( + State(state): State, + claims: Claims, +) -> std::result::Result { + let items = state.db.get_watch_history(&claims.user_id).await?; + Ok(Json(serde_json::json!({ "items": items }))) +} + +pub async fn update_history( + State(state): State, + _claims: Claims, + Path(id): Path, + Json(body): Json, +) -> std::result::Result { + if body.watched_seconds < 0 { + return Err(Error::BadRequest { + message: "watched_seconds must be non-negative".to_string(), + }); + } + state + .db + .update_watch_position(&id, body.watched_seconds) + .await?; + Ok(Json(serde_json::json!({ "status": "updated" }))) +} + +pub async fn delete_history( + State(state): State, + _claims: Claims, + Path(id): Path, +) -> std::result::Result { + state.db.delete_watch(&id).await?; + Ok(Json(serde_json::json!({ "status": "deleted" }))) +} + +pub async fn get_settings( + State(state): State, + claims: Claims, +) -> std::result::Result { + let settings = state.db.get_settings(&claims.user_id).await?; + Ok(Json(settings)) +} + +pub async fn update_settings( + State(state): State, + claims: Claims, + Json(body): Json, +) -> std::result::Result { + let mut settings = state.db.get_settings(&claims.user_id).await?; + + if let Some(theme) = body.theme { + let theme = theme.trim().to_string(); + if theme != "dark" && theme != "light" { + return Err(Error::BadRequest { + message: "Theme must be 'dark' or 'light'".to_string(), + }); + } + settings.theme = theme; + } + + state.db.update_settings(&claims.user_id, &settings).await?; + Ok(Json(settings)) +} + +const DEMO_STREAM_ID: &str = "demo"; +const DEMO_HLS_URL: &str = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"; +const DEMO_MP4_URL: &str = + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"; + +pub async fn test_video() -> impl IntoResponse { + axum::response::Redirect::temporary(DEMO_MP4_URL) +} + +pub async fn test_hls_playlist() -> impl IntoResponse { + axum::response::Redirect::temporary(DEMO_HLS_URL) +} + +pub async fn test_segment(Path(_index): Path) -> impl IntoResponse { + axum::response::Redirect::temporary(DEMO_HLS_URL) +} + +pub async fn create_demo_stream() -> impl IntoResponse { + Json(serde_json::json!({ + "stream_id": DEMO_STREAM_ID, + "status": "ready", + })) +} + +pub async fn get_demo_stream() -> impl IntoResponse { + Json(serde_json::json!({ + "id": DEMO_STREAM_ID, + "status": "ready", + "progress": 100.0, + "peers": 0, + "speed": 0, + })) +} + +pub async fn demo_playlist() -> impl IntoResponse { + axum::response::Redirect::temporary(DEMO_HLS_URL) +} diff --git a/backend/src/server/auth.rs b/backend/src/server/auth.rs new file mode 100644 index 0000000..6d6d9be --- /dev/null +++ b/backend/src/server/auth.rs @@ -0,0 +1,289 @@ +use crate::error::{Error, Result}; +use axum::extract::{ConnectInfo, FromRef, FromRequestParts, State}; +use axum::http::request::Parts; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::warn; + +use super::AppState; + +const BCRYPT_COST: u32 = 12; +const RATE_LIMIT_WINDOW_SECS: i64 = 60; +const RATE_LIMIT_MAX_REQUESTS: usize = 10; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub user_id: String, + pub username: String, + pub exp: usize, +} + +#[derive(Debug, Deserialize)] +pub struct RegisterRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct AuthResponse { + pub token: String, +} + +#[derive(Debug, Serialize)] +pub struct MeResponse { + pub id: String, + pub username: String, + pub is_admin: bool, + pub created_at: String, +} + +#[derive(Clone)] +pub struct RateLimiter { + requests: Arc>>>, +} + +impl Default for RateLimiter { + fn default() -> Self { + Self::new() + } +} + +impl RateLimiter { + pub fn new() -> Self { + Self { + requests: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn check(&self, ip: &str) -> Result<()> { + let now = Utc::now().timestamp(); + let cutoff = now - RATE_LIMIT_WINDOW_SECS; + let mut map = self.requests.lock().await; + + let timestamps = map.entry(ip.to_string()).or_default(); + timestamps.retain(|&t| t > cutoff); + + if timestamps.len() >= RATE_LIMIT_MAX_REQUESTS { + warn!(ip = ip, "Rate limit exceeded"); + return Err(Error::RateLimited); + } + + timestamps.push(now); + Ok(()) + } +} + +impl FromRequestParts for Claims +where + S: Send + Sync, + AppState: axum::extract::FromRef, +{ + type Rejection = Error; + + async fn from_request_parts( + parts: &mut Parts, + state: &S, + ) -> std::result::Result { + let app_state = AppState::from_ref(state); + + let token = parts + .headers + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|t| t.to_string()) + .or_else(|| { + parts + .uri + .query() + .and_then(|q| q.split('&').find_map(|pair| pair.strip_prefix("token="))) + .map(|t| t.to_string()) + }) + .ok_or_else(|| Error::Unauthorized { + message: "Missing authorization (header or ?token= query param)".to_string(), + })?; + + validate_jwt(&token, &app_state.jwt_secret) + } +} + +pub fn create_jwt( + user_id: &str, + username: &str, + secret: &str, + duration_hours: i64, +) -> Result { + let expiration = Utc::now() + .checked_add_signed(Duration::hours(duration_hours)) + .ok_or_else(|| Error::Internal { + message: "Failed to compute token expiration".to_string(), + })?; + + let claims = Claims { + user_id: user_id.to_string(), + username: username.to_string(), + exp: expiration.timestamp() as usize, + }; + + let header = Header::default(); + let key = EncodingKey::from_secret(secret.as_bytes()); + + encode(&header, &claims, &key).map_err(|source| Error::Jwt { source }) +} + +pub fn validate_jwt(token: &str, secret: &str) -> Result { + let key = DecodingKey::from_secret(secret.as_bytes()); + let validation = Validation::default(); + + let token_data = + decode::(token, &key, &validation).map_err(|source| Error::Jwt { source })?; + + Ok(token_data.claims) +} + +pub fn hash_password(password: &str) -> Result { + bcrypt::hash(password, BCRYPT_COST).map_err(|e| Error::PasswordHash { + message: e.to_string(), + }) +} + +fn verify_password(password: &str, hash: &str) -> Result { + bcrypt::verify(password, hash).map_err(|e| Error::PasswordHash { + message: e.to_string(), + }) +} + +fn validate_username(username: &str) -> Result<()> { + let username = username.trim(); + if username.len() < 3 || username.len() > 32 { + return Err(Error::BadRequest { + message: "Username must be between 3 and 32 characters".to_string(), + }); + } + if !username.chars().all(|c| c.is_alphanumeric() || c == '_') { + return Err(Error::BadRequest { + message: "Username may only contain letters, numbers, and underscores".to_string(), + }); + } + Ok(()) +} + +fn validate_password(password: &str) -> Result<()> { + if password.len() < 8 || password.len() > 128 { + return Err(Error::BadRequest { + message: "Password must be between 8 and 128 characters".to_string(), + }); + } + Ok(()) +} + +fn parse_session_duration(duration_str: &str) -> Result { + let trimmed = duration_str.trim(); + if let Some(days) = trimmed.strip_suffix('d') { + let d: u64 = days.parse().map_err(|_| Error::Config { + message: format!("Invalid session duration: {trimmed}"), + })?; + Ok(d as i64 * 24) + } else if let Some(hours) = trimmed.strip_suffix('h') { + let h: u64 = hours.parse().map_err(|_| Error::Config { + message: format!("Invalid session duration: {trimmed}"), + })?; + Ok(h as i64) + } else { + Ok(168) + } +} + +pub async fn register( + State(state): State, + ConnectInfo(addr): ConnectInfo, + Json(body): Json, +) -> std::result::Result { + state.rate_limiter.check(&addr.ip().to_string()).await?; + + let username = body.username.trim().to_lowercase(); + let password = &body.password; + + validate_username(&username)?; + validate_password(password)?; + + let existing = state.db.find_user_by_username(&username).await?; + if existing.is_some() { + return Err(Error::BadRequest { + message: "Username already taken".to_string(), + }); + } + + let password_hash = hash_password(password)?; + let user = state.db.create_user(&username, &password_hash).await?; + + let duration_hours = parse_session_duration(&state.config.auth.session_duration)?; + let token = create_jwt(&user.id, &user.username, &state.jwt_secret, duration_hours)?; + + Ok((StatusCode::CREATED, Json(AuthResponse { token }))) +} + +pub async fn login( + State(state): State, + ConnectInfo(addr): ConnectInfo, + Json(body): Json, +) -> std::result::Result { + state.rate_limiter.check(&addr.ip().to_string()).await?; + + let username = body.username.trim().to_lowercase(); + let password = &body.password; + + let user = state + .db + .find_user_by_username(&username) + .await? + .ok_or_else(|| Error::Auth { + message: "Invalid username or password".to_string(), + })?; + + let valid = verify_password(password, &user.password_hash)?; + if !valid { + return Err(Error::Auth { + message: "Invalid username or password".to_string(), + }); + } + + let duration_hours = parse_session_duration(&state.config.auth.session_duration)?; + let token = create_jwt(&user.id, &user.username, &state.jwt_secret, duration_hours)?; + + Ok(Json(AuthResponse { token })) +} + +pub async fn me( + State(state): State, + claims: Claims, +) -> std::result::Result { + let user = state + .db + .find_user_by_id(&claims.user_id) + .await? + .ok_or_else(|| Error::NotFound { + message: "User not found".to_string(), + })?; + + Ok(Json(MeResponse { + id: user.id, + username: user.username, + is_admin: user.is_admin, + created_at: user.created_at, + })) +} diff --git a/backend/src/server/mod.rs b/backend/src/server/mod.rs new file mode 100644 index 0000000..6dbf98f --- /dev/null +++ b/backend/src/server/mod.rs @@ -0,0 +1,120 @@ +pub mod api; +pub mod auth; +pub mod static_files; +pub mod stream; + +use crate::config::AppConfig; +use crate::db::Database; +use crate::torrent::{SearchProvider, TorrentEngine}; +use crate::transcode::HlsManager; +use auth::RateLimiter; +use axum::extract::FromRef; +use axum::routing::{delete, get, post, put}; +use axum::Router; +use std::sync::Arc; +use tower_http::compression::CompressionLayer; +use tower_http::cors::{AllowOrigin, CorsLayer}; +use tower_http::trace::TraceLayer; + +#[derive(Clone, FromRef)] +pub struct AppState { + pub db: Database, + pub config: Arc, + pub jwt_secret: String, + pub torrent_engine: Arc, + pub search_provider: Arc, + pub hls_pipeline: Arc, + pub rate_limiter: RateLimiter, +} + +pub fn build_router( + db: Database, + config: AppConfig, + torrent_engine: TorrentEngine, + search_provider: SearchProvider, + hls_pipeline: HlsManager, +) -> Router { + let jwt_secret = config.auth.jwt_secret.clone(); + + let state = AppState { + db, + config: Arc::new(config), + jwt_secret, + torrent_engine: Arc::new(torrent_engine), + search_provider: Arc::new(search_provider), + hls_pipeline: Arc::new(hls_pipeline), + rate_limiter: RateLimiter::new(), + }; + + let auth_routes = Router::new() + .route("/register", post(auth::register)) + .route("/login", post(auth::login)) + .route("/me", get(auth::me)); + + let search_routes = Router::new() + .route("/", post(api::search)) + .route("/history", get(api::search_history)); + + let stream_routes = Router::new() + .route("/", post(api::create_stream)) + .route("/{id}", get(api::get_stream)) + .route("/{id}", delete(api::delete_stream)) + .route("/{id}/pause", put(api::pause_stream)) + .route("/{id}/resume", put(api::resume_stream)) + .route("/{id}/ws", get(stream::stream_ws)) + .route("/{id}/playlist.m3u8", get(stream::playlist)) + .route("/{id}/file", get(stream::stream_file)) + .route("/{id}/{segment}", get(stream::segment)); + + let history_routes = Router::new() + .route("/", get(api::get_history)) + .route("/{id}", put(api::update_history)) + .route("/{id}", delete(api::delete_history)); + + let settings_routes = Router::new() + .route("/", get(api::get_settings)) + .route("/", put(api::update_settings)); + + let test_routes = Router::new() + .route("/video", get(api::test_video)) + .route("/playlist.m3u8", get(api::test_hls_playlist)) + .route("/segment/{index}", get(api::test_segment)) + .route("/stream", post(api::create_demo_stream)); + + let demo_routes: Router = Router::new() + .route("/", get(api::get_demo_stream)) + .route("/playlist.m3u8", get(api::demo_playlist)); + + let api_routes = Router::new() + .nest("/auth", auth_routes) + .nest("/search", search_routes) + .nest("/stream/demo", demo_routes) + .nest("/stream", stream_routes) + .nest("/history", history_routes) + .nest("/settings", settings_routes) + .nest("/test", test_routes); + + let cors = CorsLayer::new() + .allow_origin(AllowOrigin::mirror_request()) + .allow_methods([ + http::Method::GET, + http::Method::POST, + http::Method::PUT, + http::Method::DELETE, + http::Method::OPTIONS, + ]) + .allow_headers([ + http::header::CONTENT_TYPE, + http::header::AUTHORIZATION, + http::header::ACCEPT, + ]) + .allow_credentials(true); + + Router::new() + .nest("/api", api_routes) + .fallback(static_files::static_handler) + .layer(CompressionLayer::new()) + .layer(cors) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} diff --git a/backend/src/server/static_files.rs b/backend/src/server/static_files.rs new file mode 100644 index 0000000..a5233d1 --- /dev/null +++ b/backend/src/server/static_files.rs @@ -0,0 +1,44 @@ +use crate::embedded::Asset; +use axum::http::{header, StatusCode}; +use axum::response::{Html, IntoResponse, Response}; + +pub async fn static_handler(uri: axum::http::Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + + if let Some(content) = Asset::get(path) { + let mime = mime_guess::from_path(path) + .first_or_octet_stream() + .to_string(); + + ( + StatusCode::OK, + [ + (header::CONTENT_TYPE, mime), + ( + header::CACHE_CONTROL, + "public, max-age=31536000, immutable".to_string(), + ), + ], + content.data.to_vec(), + ) + .into_response() + } else if let Some(index) = Asset::get("index.html") { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "text/html; charset=utf-8".to_string())], + index.data.to_vec(), + ) + .into_response() + } else { + Html( + "\ +

StreamX

\ +

Frontend assets are not available. \ + Build the UI with cd ui && pnpm build \ + and restart the server.

\ + " + .to_string(), + ) + .into_response() + } +} diff --git a/backend/src/server/stream.rs b/backend/src/server/stream.rs new file mode 100644 index 0000000..80e6305 --- /dev/null +++ b/backend/src/server/stream.rs @@ -0,0 +1,377 @@ +use crate::error::Error; +use crate::server::auth::Claims; +use crate::server::AppState; +use crate::transcode::hls::PlaylistResponse; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Path, State}; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Redirect}; +use bytes::Bytes; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use tracing::debug; + +pub async fn stream_ws( + State(state): State, + Path(id): Path, + ws: WebSocketUpgrade, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_stream_ws(socket, state, id)) +} + +async fn handle_stream_ws(mut socket: WebSocket, state: AppState, id: String) { + let _ = state.torrent_engine.resume(&id).await; + + if let Ok(Some(dl)) = state.torrent_engine.get_download(&id).await { + let (peers, speed) = state.torrent_engine.get_live_stats(&id).await; + let msg = serde_json::json!({ + "type": "status", + "data": { + "status": dl.status, + "progress": dl.progress, + "peers": peers, + "speed": speed, + "file_size": dl.file_size, + "title": dl.title, + "file_name": dl.file_name, + } + }); + if socket + .send(Message::Text(msg.to_string().into())) + .await + .is_err() + { + let _ = state.torrent_engine.pause(&id).await; + return; + } + + if dl.status == "complete" { + let file_msg = serde_json::json!({ + "type": "file_ready", + "data": {"url": format!("/api/stream/{id}/file")} + }); + let _ = socket + .send(Message::Text(file_msg.to_string().into())) + .await; + } + } + + let mut interval = tokio::time::interval(std::time::Duration::from_secs(2)); + let mut file_ready_sent = false; + + loop { + tokio::select! { + _ = interval.tick() => { + let dl = match state.torrent_engine.get_download(&id).await { + Ok(Some(d)) => d, + _ => break, + }; + let (peers, speed) = state.torrent_engine.get_live_stats(&id).await; + + let msg = serde_json::json!({ + "type": "status", + "data": { + "status": dl.status, + "progress": dl.progress, + "peers": peers, + "speed": speed, + "file_size": dl.file_size, + "title": dl.title, + } + }); + if socket.send(Message::Text(msg.to_string().into())).await.is_err() { + break; + } + + if !file_ready_sent { + let has_handle = state + .torrent_engine + .get_stream_file_info(&id) + .await + .ok() + .flatten() + .is_some(); + if dl.status == "complete" || (dl.status == "downloading" && has_handle) { + let file_msg = serde_json::json!({ + "type": "file_ready", + "data": {"url": format!("/api/stream/{id}/file")} + }); + if socket.send(Message::Text(file_msg.to_string().into())).await.is_err() { + break; + } + file_ready_sent = true; + } + } + } + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => break, + Some(Ok(Message::Text(_text))) => { + debug!(stream_id = %id, "Received client message (ignored)"); + } + Some(Err(_)) => break, + _ => {} + } + } + } + } + + let _ = state.torrent_engine.pause(&id).await; +} + +pub async fn playlist( + State(state): State, + _claims: Claims, + Path(id): Path, +) -> std::result::Result { + let _download = state + .torrent_engine + .get_download(&id) + .await? + .ok_or_else(|| Error::NotFound { + message: format!("Stream {id} not found"), + })?; + + let response = state.hls_pipeline.generate_playlist(&id, true).await?; + + match response { + PlaylistResponse::Redirect(url) => Ok(Redirect::temporary(&url).into_response()), + PlaylistResponse::Content(content) => Ok(( + [ + (header::CONTENT_TYPE, "application/vnd.apple.mpegurl"), + (header::CACHE_CONTROL, "no-cache, no-store"), + ], + content, + ) + .into_response()), + } +} + +pub async fn segment( + State(state): State, + Path((id, segment_name)): Path<(String, String)>, +) -> std::result::Result { + let data: Bytes = state + .hls_pipeline + .get_segment(&id, &segment_name) + .await? + .ok_or_else(|| Error::NotFound { + message: format!("Segment {segment_name} not found"), + })?; + + let content_type = if segment_name.ends_with(".m4s") || segment_name.ends_with(".mp4") { + "video/mp4" + } else { + "video/mp2t" + }; + + Ok(([(header::CONTENT_TYPE, content_type)], data)) +} + +pub async fn stream_file( + State(state): State, + headers: HeaderMap, + Path(id): Path, +) -> std::result::Result { + let download = state + .torrent_engine + .get_download(&id) + .await? + .ok_or_else(|| Error::NotFound { + message: format!("Stream {id} not found"), + })?; + + match download.status.as_str() { + "complete" => { + if let Some(ref cp) = download.complete_path { + let path = std::path::PathBuf::from(cp); + if tokio::fs::metadata(&path).await.is_ok() { + return serve_file_from_disk(&headers, &path).await; + } + } + if let Some(ref pp) = download.partial_path { + let path = std::path::PathBuf::from(pp); + if tokio::fs::metadata(&path).await.is_ok() { + return serve_file_from_disk(&headers, &path).await; + } + } + Err(Error::NotFound { + message: format!("Complete file not found on disk for stream {id}"), + }) + } + "downloading" | "paused" => { + if download.status == "paused" { + let _ = state.torrent_engine.resume(&id).await; + } else { + let _ = state.torrent_engine.ensure_active(&id).await; + } + + if let Ok(Some((torrent_id, file_index))) = + state.torrent_engine.get_stream_file_info(&id).await + { + let api = librqbit::Api::new(state.torrent_engine.session().clone(), None); + if let Ok(mut file_stream) = + api.api_stream(librqbit::api::TorrentIdOrHash::Id(torrent_id), file_index) + { + let file_size = file_stream.len(); + let range = headers + .get(header::RANGE) + .and_then(|v| v.to_str().ok()) + .and_then(|s| parse_range(s, file_size)); + + return match range { + Some((start, _)) => { + file_stream + .seek(std::io::SeekFrom::Start(start)) + .await + .map_err(|e| Error::Io { source: e })?; + let remaining = file_size - file_stream.position(); + let end = file_size.saturating_sub(1); + let stream = + tokio_util::io::ReaderStream::with_capacity(file_stream, 65536); + let body = axum::body::Body::from_stream(stream); + axum::response::Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, "video/mp4") + .header(header::CONTENT_LENGTH, remaining.to_string()) + .header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{file_size}"), + ) + .header(header::ACCEPT_RANGES, "bytes") + .body(body) + .map_err(|e| Error::Internal { + message: format!("{e}"), + }) + } + None => { + let stream = + tokio_util::io::ReaderStream::with_capacity(file_stream, 65536); + let body = axum::body::Body::from_stream(stream); + axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "video/mp4") + .header(header::CONTENT_LENGTH, file_size.to_string()) + .header(header::ACCEPT_RANGES, "bytes") + .body(body) + .map_err(|e| Error::Internal { + message: format!("{e}"), + }) + } + }; + } + } + + if let Ok(Some(disk_path)) = state.torrent_engine.get_file_path(&id).await { + if tokio::fs::metadata(&disk_path).await.is_ok() { + return serve_file_from_disk(&headers, &disk_path).await; + } + } + + Err(Error::NotFound { + message: format!("Stream {id} file not available yet"), + }) + } + "initializing" => { + let body = serde_json::json!({ + "error": "Download is still initializing, try again shortly" + }); + Ok(axum::response::Response::builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header(header::CONTENT_TYPE, "application/json") + .header(header::RETRY_AFTER, "3") + .body(axum::body::Body::from(body.to_string())) + .map_err(|e| Error::Internal { + message: format!("{e}"), + })?) + } + _ => Err(Error::NotFound { + message: format!("Stream {id} in unexpected state: {}", download.status), + }), + } +} + +async fn serve_file_from_disk( + headers: &HeaderMap, + file_path: &std::path::Path, +) -> std::result::Result { + let metadata = tokio::fs::metadata(file_path) + .await + .map_err(|e| Error::Io { source: e })?; + let file_size = metadata.len(); + + let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or(""); + let content_type = match ext { + "mp4" | "m4v" => "video/mp4", + "mkv" => "video/x-matroska", + "webm" => "video/webm", + "avi" => "video/x-msvideo", + "mov" => "video/quicktime", + _ => "video/mp4", + }; + + let range = headers + .get(header::RANGE) + .and_then(|v| v.to_str().ok()) + .and_then(|s| parse_range(s, file_size)); + + match range { + Some((start, end)) => { + let length = end - start + 1; + let mut file = tokio::fs::File::open(file_path) + .await + .map_err(|e| Error::Io { source: e })?; + file.seek(std::io::SeekFrom::Start(start)) + .await + .map_err(|e| Error::Io { source: e })?; + let stream = tokio_util::io::ReaderStream::new(file.take(length)); + let body = axum::body::Body::from_stream(stream); + axum::response::Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, length.to_string()) + .header( + header::CONTENT_RANGE, + format!("bytes {start}-{end}/{file_size}"), + ) + .header(header::ACCEPT_RANGES, "bytes") + .body(body) + .map_err(|e| Error::Internal { + message: format!("{e}"), + }) + } + None => { + let file = tokio::fs::File::open(file_path) + .await + .map_err(|e| Error::Io { source: e })?; + let stream = tokio_util::io::ReaderStream::new(file); + let body = axum::body::Body::from_stream(stream); + axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_LENGTH, file_size.to_string()) + .header(header::ACCEPT_RANGES, "bytes") + .body(body) + .map_err(|e| Error::Internal { + message: format!("{e}"), + }) + } + } +} + +fn parse_range(range_header: &str, file_size: u64) -> Option<(u64, u64)> { + let range = range_header.strip_prefix("bytes=")?; + let (start_str, end_str) = range.split_once('-')?; + + let start: u64 = start_str.parse().ok()?; + let end: u64 = if end_str.is_empty() { + file_size.checked_sub(1)? + } else { + end_str.parse().ok()? + }; + + if start > end || start >= file_size { + return None; + } + let end = end.min(file_size - 1); + Some((start, end)) +} diff --git a/backend/src/torrent/engine.rs b/backend/src/torrent/engine.rs new file mode 100644 index 0000000..c24f45a --- /dev/null +++ b/backend/src/torrent/engine.rs @@ -0,0 +1,582 @@ +use crate::config::TorrentConfig; +use crate::db::downloads::Download; +use crate::db::Database; +use crate::error::{Error, Result}; +use crate::torrent::types::TorrentFile; +use chrono::Utc; +use librqbit::{ + dht::PersistentDhtConfig, AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, + Session, SessionOptions, TorrentStatsState, +}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::RwLock; +use tracing::{info, warn}; + +struct ActiveHandle { + torrent_id: usize, + handle: Arc, + file_index: usize, +} + +pub struct TorrentEngine { + session: Arc, + handles: Arc>>, + db: Database, + partial_dir: PathBuf, + complete_dir: PathBuf, +} + +impl TorrentEngine { + pub async fn create(config: &TorrentConfig, data_dir: &Path, db: Database) -> Result { + let partial_dir = data_dir.join("downloads").join("partial"); + let complete_dir = data_dir.join("downloads").join("complete"); + let dht_dir = data_dir.join("dht"); + + std::fs::create_dir_all(&partial_dir).map_err(|e| Error::Torrent { + message: format!("Failed to create partial directory: {e}"), + })?; + std::fs::create_dir_all(&complete_dir).map_err(|e| Error::Torrent { + message: format!("Failed to create complete directory: {e}"), + })?; + std::fs::create_dir_all(&dht_dir).map_err(|e| Error::Torrent { + message: format!("Failed to create dht directory: {e}"), + })?; + + let dht_config = PersistentDhtConfig { + config_filename: Some(dht_dir.join("dht.json")), + ..Default::default() + }; + + let opts = SessionOptions { + disable_dht: !config.dht, + disable_dht_persistence: false, + dht_config: Some(dht_config), + listen_port_range: Some(4240..4260), + enable_upnp_port_forwarding: true, + ..Default::default() + }; + + let session = Session::new_with_opts(partial_dir.clone(), opts) + .await + .map_err(|e| Error::Torrent { + message: format!("Failed to initialize torrent session: {e}"), + })?; + + info!( + "Torrent engine initialized with librqbit {}", + librqbit::version() + ); + + let engine = Self { + session, + handles: Arc::new(RwLock::new(HashMap::new())), + db, + partial_dir, + complete_dir, + }; + + engine.spawn_progress_updater(); + + Ok(engine) + } + + pub async fn add_magnet( + &self, + magnet_uri: &str, + file_index: Option, + ) -> Result { + let hash = extract_info_hash(magnet_uri).ok_or_else(|| Error::BadRequest { + message: "Could not extract info hash from magnet URI".to_string(), + })?; + + if let Some(existing) = self.db.get_download(&hash).await? { + if existing.status != "error" { + return Ok(existing); + } + } + + let now = Utc::now().to_rfc3339(); + let dl = Download { + info_hash: hash.clone(), + magnet_uri: magnet_uri.to_string(), + title: String::new(), + file_name: String::new(), + file_index: file_index.unwrap_or(0), + file_size: 0, + status: "initializing".to_string(), + progress: 0.0, + partial_path: None, + complete_path: None, + created_at: now.clone(), + updated_at: now, + }; + self.db.upsert_download(&dl).await?; + + self.spawn_add_torrent(hash.clone(), magnet_uri.to_string(), file_index); + + Ok(dl) + } + + pub async fn get_download(&self, info_hash: &str) -> Result> { + let mut dl = match self.db.get_download(info_hash).await? { + Some(d) => d, + None => return Ok(None), + }; + + let handles = self.handles.read().await; + if let Some(active) = handles.get(info_hash) { + let stats = active.handle.stats(); + let progress = if stats.total_bytes > 0 { + (stats.progress_bytes as f64 / stats.total_bytes as f64) * 100.0 + } else { + 0.0 + }; + dl.progress = progress; + if stats.finished && dl.status == "downloading" { + dl.status = "complete".to_string(); + } + } + + Ok(Some(dl)) + } + + /// Get live stats (peers, speed) for an active download. + pub async fn get_live_stats(&self, info_hash: &str) -> (u32, f64) { + let handles = self.handles.read().await; + if let Some(active) = handles.get(info_hash) { + let stats = active.handle.stats(); + let peers = stats + .live + .as_ref() + .map(|l| l.snapshot.peer_stats.live as u32) + .unwrap_or(0); + let speed = stats + .live + .as_ref() + .map(|l| l.download_speed.mbps * 1024.0 * 1024.0) // MiB/s -> bytes/s + .unwrap_or(0.0); + (peers, speed) + } else { + (0, 0.0) + } + } + + pub async fn ensure_active(&self, info_hash: &str) -> Result<()> { + { + let handles = self.handles.read().await; + if handles.contains_key(info_hash) { + return Ok(()); + } + } + + let dl = self + .db + .get_download(info_hash) + .await? + .ok_or_else(|| Error::NotFound { + message: format!("Download {info_hash} not found"), + })?; + + if dl.status == "complete" { + return Ok(()); + } + + self.spawn_add_torrent( + dl.info_hash.clone(), + dl.magnet_uri.clone(), + Some(dl.file_index), + ); + Ok(()) + } + + pub async fn pause(&self, info_hash: &str) -> Result<()> { + let dl = match self.db.get_download(info_hash).await? { + Some(d) => d, + None => return Ok(()), + }; + if dl.status == "complete" || dl.status == "paused" { + return Ok(()); + } + + let handles = self.handles.read().await; + if let Some(active) = handles.get(info_hash) { + let stats = active.handle.stats(); + if matches!(stats.state, TorrentStatsState::Live) { + if let Err(e) = self.session.pause(&active.handle).await { + warn!(info_hash = %info_hash, "Pause failed (non-fatal): {e}"); + } else { + info!(info_hash = %info_hash, "Torrent paused"); + } + } + } + drop(handles); + + self.db.update_download_status(info_hash, "paused").await?; + Ok(()) + } + + pub async fn resume(&self, info_hash: &str) -> Result<()> { + let dl = match self.db.get_download(info_hash).await? { + Some(d) => d, + None => return Ok(()), + }; + + if dl.status == "complete" || dl.status == "downloading" { + return Ok(()); + } + + let handles = self.handles.read().await; + if let Some(active) = handles.get(info_hash) { + let stats = active.handle.stats(); + if matches!(stats.state, TorrentStatsState::Paused) { + if let Err(e) = self.session.unpause(&active.handle).await { + warn!(info_hash = %info_hash, "Resume failed (non-fatal): {e}"); + } else { + info!(info_hash = %info_hash, "Torrent resumed"); + } + } + drop(handles); + } else { + drop(handles); + if !dl.magnet_uri.is_empty() { + self.spawn_add_torrent( + dl.info_hash.clone(), + dl.magnet_uri.clone(), + Some(dl.file_index), + ); + } + } + + self.db + .update_download_status(info_hash, "downloading") + .await?; + Ok(()) + } + + pub async fn get_file_path(&self, info_hash: &str) -> Result> { + let dl = match self.db.get_download(info_hash).await? { + Some(d) => d, + None => return Ok(None), + }; + + if let Some(ref cp) = dl.complete_path { + let path = PathBuf::from(cp); + if path.exists() { + return Ok(Some(path)); + } + } + + if let Some(ref pp) = dl.partial_path { + let path = PathBuf::from(pp); + if path.exists() { + return Ok(Some(path)); + } + } + + let handles = self.handles.read().await; + if let Some(active) = handles.get(info_hash) { + let file_idx = active.file_index; + let partial_dir = self.partial_dir.clone(); + let file_path = active.handle.with_metadata(|meta| { + meta.file_infos.get(file_idx).map(|fi| { + let relative = fi.relative_filename.to_string_lossy().to_string(); + let full_path = partial_dir.join(&relative); + if full_path.exists() { + return full_path; + } + if let Some(ref name) = meta.name { + let with_name = partial_dir.join(name).join(&relative); + if with_name.exists() { + return with_name; + } + } + full_path + }) + }); + match file_path { + Ok(Some(p)) => return Ok(Some(p)), + _ => return Ok(None), + } + } + + Ok(None) + } + + pub async fn get_stream_file_info(&self, info_hash: &str) -> Result> { + let handles = self.handles.read().await; + let active = match handles.get(info_hash) { + Some(a) => a, + None => return Ok(None), + }; + Ok(Some((active.torrent_id, active.file_index))) + } + + pub fn session(&self) -> &Arc { + &self.session + } + + pub fn partial_dir(&self) -> &PathBuf { + &self.partial_dir + } + + pub fn complete_dir(&self) -> &PathBuf { + &self.complete_dir + } + + fn spawn_add_torrent(&self, info_hash: String, magnet_uri: String, file_index: Option) { + let session = self.session.clone(); + let handles = self.handles.clone(); + let db = self.db.clone(); + let partial_dir = self.partial_dir.clone(); + let complete_dir = self.complete_dir.clone(); + + tokio::spawn(async move { + let opts = AddTorrentOptions { + overwrite: true, + only_files: file_index.map(|i| vec![i]), + ..Default::default() + }; + + let result = tokio::time::timeout( + std::time::Duration::from_secs(60), + session.add_torrent(AddTorrent::from_url(&magnet_uri), Some(opts)), + ) + .await; + + let resp = match result { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + warn!(info_hash = %info_hash, "Failed to add torrent: {e}"); + let _ = db.update_download_status(&info_hash, "error").await; + return; + } + Err(_) => { + warn!(info_hash = %info_hash, "Timed out adding torrent"); + let _ = db.update_download_status(&info_hash, "error").await; + return; + } + }; + + let (tid, handle) = match resp { + AddTorrentResponse::Added(id, handle) + | AddTorrentResponse::AlreadyManaged(id, handle) => (id, handle), + _ => { + let _ = db.update_download_status(&info_hash, "error").await; + return; + } + }; + + let resolved_fi = handle + .with_metadata(|meta| { + meta.file_infos + .iter() + .enumerate() + .filter(|(_, f)| { + TorrentFile::detect_video(&f.relative_filename.to_string_lossy()) + }) + .max_by_key(|(_, f)| f.len) + .map(|(idx, _)| idx) + }) + .ok() + .flatten() + .unwrap_or(file_index.unwrap_or(0)); + + let (title, file_name, file_size, partial_path) = handle + .with_metadata(|meta| { + let name = meta.name.clone().unwrap_or_default(); + let fi = meta.file_infos.get(resolved_fi); + let fname = fi + .map(|f| f.relative_filename.to_string_lossy().to_string()) + .unwrap_or_default(); + let fsize = fi.map(|f| f.len).unwrap_or(0); + let pp = fi.map(|f| { + let rel = f.relative_filename.to_string_lossy().to_string(); + if meta.name.is_some() { + partial_dir + .join(&name) + .join(&rel) + .to_string_lossy() + .to_string() + } else { + partial_dir.join(&rel).to_string_lossy().to_string() + } + }); + (name, fname, fsize, pp) + }) + .unwrap_or_default(); + + let _ = db + .update_download_metadata( + &info_hash, + &title, + &file_name, + resolved_fi, + file_size, + partial_path.as_deref(), + ) + .await; + + handles.write().await.insert( + info_hash.clone(), + ActiveHandle { + torrent_id: tid, + handle: handle.clone(), + file_index: resolved_fi, + }, + ); + + info!(info_hash = %info_hash, title = %title, "Torrent added to session"); + + Self::watch_completion( + info_hash, + handle, + resolved_fi, + db, + partial_dir, + complete_dir, + ); + }); + } + + fn watch_completion( + info_hash: String, + handle: Arc, + file_index: usize, + db: Database, + partial_dir: PathBuf, + complete_dir: PathBuf, + ) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(3)); + loop { + interval.tick().await; + let stats = handle.stats(); + if !stats.finished { + continue; + } + + info!(info_hash = %info_hash, "Download complete, moving to complete directory"); + + let move_result = handle.with_metadata(|meta| { + let fi = match meta.file_infos.get(file_index) { + Some(f) => f, + None => return None, + }; + let rel = fi.relative_filename.to_string_lossy().to_string(); + let src = if let Some(ref name) = meta.name { + partial_dir.join(name).join(&rel) + } else { + partial_dir.join(&rel) + }; + let dst = complete_dir.join(&rel); + Some((src, dst)) + }); + + let (src, dst) = match move_result { + Ok(Some(pair)) => pair, + _ => { + let _ = db.update_download_status(&info_hash, "complete").await; + break; + } + }; + + if !src.exists() { + let _ = db.update_download_status(&info_hash, "complete").await; + break; + } + + if let Some(parent) = dst.parent() { + let _ = std::fs::create_dir_all(parent); + } + + let complete_path = dst.to_string_lossy().to_string(); + + if std::fs::rename(&src, &dst).is_ok() { + info!(info_hash = %info_hash, path = %complete_path, "File moved to complete directory"); + let _ = db + .update_download_paths(&info_hash, None, Some(&complete_path)) + .await; + } else { + match std::fs::copy(&src, &dst) { + Ok(_) => { + let _ = std::fs::remove_file(&src); + info!(info_hash = %info_hash, path = %complete_path, "File copied to complete directory"); + let _ = db + .update_download_paths(&info_hash, None, Some(&complete_path)) + .await; + } + Err(e) => { + warn!(info_hash = %info_hash, "Failed to move file to complete: {e}"); + let _ = db + .update_download_paths( + &info_hash, + Some(&src.to_string_lossy()), + None, + ) + .await; + } + } + } + + let _ = db.update_download_status(&info_hash, "complete").await; + break; + } + }); + } + + fn spawn_progress_updater(&self) { + let handles = self.handles.clone(); + let db = self.db.clone(); + + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); + loop { + interval.tick().await; + let map = handles.read().await; + for (info_hash, active) in map.iter() { + let stats = active.handle.stats(); + if stats.finished { + continue; + } + let progress = if stats.total_bytes > 0 { + (stats.progress_bytes as f64 / stats.total_bytes as f64) * 100.0 + } else { + 0.0 + }; + let _ = db + .update_download_progress(info_hash, progress, stats.total_bytes) + .await; + } + drop(map); + } + }); + } + + pub async fn list_downloads(&self) -> Result> { + self.db.list_downloads().await + } +} + +pub fn extract_info_hash(magnet_uri: &str) -> Option { + let uri = magnet_uri.strip_prefix("magnet:?").unwrap_or(magnet_uri); + uri.split('&') + .find_map(|p| p.strip_prefix("xt=urn:btih:")) + .map(|h| h.to_lowercase()) +} + +impl TorrentFile { + pub fn detect_video(path: &str) -> bool { + let lower = path.to_lowercase(); + lower.ends_with(".mp4") + || lower.ends_with(".mkv") + || lower.ends_with(".avi") + || lower.ends_with(".mov") + || lower.ends_with(".wmv") + || lower.ends_with(".flv") + || lower.ends_with(".webm") + || lower.ends_with(".m4v") + || lower.ends_with(".ts") + } +} diff --git a/backend/src/torrent/mod.rs b/backend/src/torrent/mod.rs new file mode 100644 index 0000000..5873c31 --- /dev/null +++ b/backend/src/torrent/mod.rs @@ -0,0 +1,6 @@ +pub mod engine; +pub mod provider; +pub mod types; + +pub use engine::TorrentEngine; +pub use provider::{SearchProvider, SearchResult}; diff --git a/backend/src/torrent/provider.rs b/backend/src/torrent/provider.rs new file mode 100644 index 0000000..1dce10d --- /dev/null +++ b/backend/src/torrent/provider.rs @@ -0,0 +1,159 @@ +use crate::error::Result; +use serde::{Deserialize, Serialize}; + +const YTS_API_URL: &str = "https://yts.lt/api/v2/list_movies.json"; + +const TRACKERS: &[&str] = &[ + "udp://open.demonii.com:1337/announce", + "udp://tracker.openbittorrent.com:80", + "udp://tracker.coppersurfer.tk:6969", + "udp://glotorrents.pw:6969/announce", + "udp://tracker.opentrackr.org:1337/announce", + "udp://torrent.gresille.org:80/announce", + "udp://p4p.arenabg.com:1337", + "udp://tracker.leechers-paradise.org:6969", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { + pub title: String, + pub magnet: String, + pub seeds: u32, + pub leeches: u32, + pub size: String, + pub size_bytes: u64, + pub quality: Option, + pub year: Option, + pub rating: Option, + pub poster: Option, +} + +#[derive(Debug, Deserialize)] +struct YtsResponse { + status: String, + data: Option, +} + +#[derive(Debug, Deserialize)] +struct YtsData { + movies: Option>, +} + +#[derive(Debug, Deserialize)] +struct YtsMovie { + title: String, + year: u32, + rating: f64, + medium_cover_image: Option, + torrents: Option>, +} + +#[derive(Debug, Deserialize)] +struct YtsTorrent { + hash: String, + quality: String, + seeds: u32, + peers: u32, + size: String, + size_bytes: u64, +} + +pub struct SearchProvider { + client: reqwest::Client, +} + +impl Default for SearchProvider { + fn default() -> Self { + Self::new() + } +} + +impl SearchProvider { + pub fn new() -> Self { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent("Mozilla/5.0 (X11; Linux x86_64) StreamX/0.1") + .build() + .unwrap_or_default(); + Self { client } + } + + pub async fn search(&self, query: &str) -> Result> { + let response = self + .client + .get(YTS_API_URL) + .query(&[("query_term", query), ("sort_by", "seeds"), ("limit", "20")]) + .send() + .await; + + let response = match response { + Ok(r) => r, + Err(err) => { + tracing::warn!("YTS API request failed: {err}"); + return Ok(Vec::new()); + } + }; + + let yts: YtsResponse = match response.json().await { + Ok(parsed) => parsed, + Err(err) => { + tracing::warn!("Failed to parse YTS response: {err}"); + return Ok(Vec::new()); + } + }; + + if yts.status != "ok" { + tracing::warn!("YTS API returned non-ok status: {}", yts.status); + return Ok(Vec::new()); + } + + let movies = match yts.data.and_then(|d| d.movies) { + Some(m) => m, + None => return Ok(Vec::new()), + }; + + let mut results: Vec = movies + .into_iter() + .flat_map(|movie| { + let torrents = movie.torrents.unwrap_or_default(); + torrents.into_iter().map({ + let title = movie.title.clone(); + let year = movie.year; + let rating = movie.rating; + let poster = movie.medium_cover_image.clone(); + move |torrent| { + let display_title = + format!("{title} ({year}) [{quality}]", quality = torrent.quality); + let magnet = build_magnet(&torrent.hash, &display_title); + + SearchResult { + title: display_title, + magnet, + seeds: torrent.seeds, + leeches: torrent.peers, + size: torrent.size.clone(), + size_bytes: torrent.size_bytes, + quality: Some(torrent.quality), + year: Some(year), + rating: Some(rating), + poster: poster.clone(), + } + } + }) + }) + .collect(); + + results.sort_by(|a, b| b.seeds.cmp(&a.seeds)); + + Ok(results) + } +} + +fn build_magnet(hash: &str, title: &str) -> String { + let encoded_title = urlencoding::encode(title); + let trackers: String = TRACKERS + .iter() + .map(|t| format!("&tr={}", urlencoding::encode(t))) + .collect(); + format!("magnet:?xt=urn:btih:{hash}&dn={encoded_title}{trackers}") +} diff --git a/backend/src/torrent/types.rs b/backend/src/torrent/types.rs new file mode 100644 index 0000000..16a37f6 --- /dev/null +++ b/backend/src/torrent/types.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TorrentInfo { + pub name: String, + pub total_size: u64, + pub files: Vec, + pub info_hash: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TorrentFile { + pub index: usize, + pub path: String, + pub size: u64, + pub is_video: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TorrentStatus { + Initializing, + Downloading, + Ready, + Seeding, + Paused, + Error, + Stopped, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamInfo { + pub id: String, + pub magnet_uri: String, + pub file_index: usize, + pub status: TorrentStatus, + pub progress: f64, + pub peers: u32, + pub download_speed: u64, + pub total_bytes: u64, + pub downloaded_bytes: u64, + pub error_message: Option, +} diff --git a/backend/src/transcode/gpu.rs b/backend/src/transcode/gpu.rs new file mode 100644 index 0000000..c98b827 --- /dev/null +++ b/backend/src/transcode/gpu.rs @@ -0,0 +1,128 @@ +use tokio::process::Command; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HwAccel { + Nvenc, + Vaapi, + Qsv, + VideoToolbox, + None, +} + +pub async fn detect_hardware() -> HwAccel { + let available = match query_ffmpeg_hwaccels().await { + Ok(list) => list, + Err(_) => return HwAccel::None, + }; + + if available.contains(&"cuda".to_string()) && nvidia_gpu_present().await { + return HwAccel::Nvenc; + } + + if available.contains(&"vaapi".to_string()) && vaapi_device_exists() { + return HwAccel::Vaapi; + } + + if available.contains(&"qsv".to_string()) { + return HwAccel::Qsv; + } + + if available.contains(&"videotoolbox".to_string()) { + return HwAccel::VideoToolbox; + } + + HwAccel::None +} + +pub fn encoder_for_hw(hw: &HwAccel) -> &'static str { + match hw { + HwAccel::Nvenc => "h264_nvenc", + HwAccel::Vaapi => "h264_vaapi", + HwAccel::Qsv => "h264_qsv", + HwAccel::VideoToolbox => "h264_videotoolbox", + HwAccel::None => "libx264", + } +} + +pub fn hw_decode_flags(hw: &HwAccel) -> Vec { + match hw { + HwAccel::Nvenc => vec![ + "-hwaccel".into(), + "cuda".into(), + "-hwaccel_output_format".into(), + "cuda".into(), + ], + HwAccel::Vaapi => vec![ + "-hwaccel".into(), + "vaapi".into(), + "-hwaccel_device".into(), + "/dev/dri/renderD128".into(), + "-hwaccel_output_format".into(), + "vaapi".into(), + ], + HwAccel::Qsv => vec!["-hwaccel".into(), "qsv".into()], + HwAccel::VideoToolbox => vec!["-hwaccel".into(), "videotoolbox".into()], + HwAccel::None => vec![], + } +} + +async fn query_ffmpeg_hwaccels() -> std::result::Result, ()> { + let output = Command::new("ffmpeg") + .args(["-hide_banner", "-hwaccels"]) + .output() + .await + .map_err(|_| ())?; + + if !output.status.success() { + return Err(()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let accels: Vec = stdout + .lines() + .skip(1) + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect(); + + Ok(accels) +} + +async fn nvidia_gpu_present() -> bool { + Command::new("nvidia-smi") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn vaapi_device_exists() -> bool { + std::path::Path::new("/dev/dri/renderD128").exists() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encoder_selection() { + assert_eq!(encoder_for_hw(&HwAccel::Nvenc), "h264_nvenc"); + assert_eq!(encoder_for_hw(&HwAccel::Vaapi), "h264_vaapi"); + assert_eq!(encoder_for_hw(&HwAccel::Qsv), "h264_qsv"); + assert_eq!(encoder_for_hw(&HwAccel::VideoToolbox), "h264_videotoolbox"); + assert_eq!(encoder_for_hw(&HwAccel::None), "libx264"); + } + + #[test] + fn decode_flags_cpu_empty() { + assert!(hw_decode_flags(&HwAccel::None).is_empty()); + } + + #[test] + fn decode_flags_nvenc() { + let flags = hw_decode_flags(&HwAccel::Nvenc); + assert_eq!(flags.len(), 4); + assert_eq!(flags[0], "-hwaccel"); + assert_eq!(flags[1], "cuda"); + } +} diff --git a/backend/src/transcode/hls.rs b/backend/src/transcode/hls.rs new file mode 100644 index 0000000..e1df433 --- /dev/null +++ b/backend/src/transcode/hls.rs @@ -0,0 +1,204 @@ +use crate::config::TranscodeConfig; +use crate::error::{Error, Result}; +use bytes::Bytes; +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +use super::pipeline::{TranscodeHandle, TranscodePipeline}; +use super::probe; + +const DEMO_HLS_URL: &str = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"; +const SEGMENT_CACHE_MAX: usize = 50; + +struct SegmentCache { + segments: VecDeque<(String, Bytes)>, + max_size: usize, +} + +impl SegmentCache { + fn new(max_size: usize) -> Self { + Self { + segments: VecDeque::with_capacity(max_size), + max_size, + } + } + + fn get(&self, key: &str) -> Option { + self.segments + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + } + + fn insert(&mut self, key: String, data: Bytes) { + if self.segments.iter().any(|(k, _)| k == &key) { + return; + } + if self.segments.len() >= self.max_size { + self.segments.pop_front(); + } + self.segments.push_back((key, data)); + } +} + +pub struct HlsManager { + pipeline: TranscodePipeline, + active: Arc>>, + segment_cache: Arc>, + cache_dir: PathBuf, +} + +pub enum PlaylistResponse { + Content(String), + Redirect(String), +} + +impl HlsManager { + pub async fn new(config: &TranscodeConfig, cache_dir: PathBuf) -> Result { + let pipeline = TranscodePipeline::new(config.clone(), cache_dir.clone()).await?; + Ok(Self { + pipeline, + active: Arc::new(RwLock::new(HashMap::new())), + segment_cache: Arc::new(RwLock::new(SegmentCache::new(SEGMENT_CACHE_MAX))), + cache_dir, + }) + } + + pub async fn start_stream(&self, stream_id: &str, file_path: &str) -> Result<()> { + { + let active = self.active.read().await; + if active.contains_key(stream_id) { + return Ok(()); + } + } + + let playlist_path = self.cache_dir.join(stream_id).join("playlist.m3u8"); + if playlist_path.exists() { + let content = tokio::fs::read_to_string(&playlist_path) + .await + .unwrap_or_default(); + if content.matches("EXTINF:").count() > 10 { + tracing::info!(stream_id, "Valid cached HLS found, skipping transcode"); + return Ok(()); + } + tracing::warn!(stream_id, "Cached HLS has too few segments, re-transcoding"); + let _ = tokio::fs::remove_dir_all(self.cache_dir.join(stream_id)).await; + } + + let info = probe::probe(file_path).await?; + + let handle = if probe::is_browser_compatible(&info) { + tracing::info!(stream_id, "Source is browser-compatible, using passthrough"); + self.pipeline + .start_passthrough(stream_id, file_path) + .await? + } else { + tracing::info!( + stream_id, + video_codec = ?info.video_codec, + audio_codec = ?info.audio_codec, + hdr = ?info.hdr_format, + "Transcoding required" + ); + match self + .pipeline + .start_transcode(stream_id, file_path, &info) + .await + { + Ok(h) => h, + Err(e) => { + tracing::warn!(stream_id, "GPU transcode failed, falling back to CPU: {e}"); + let cache_dir = self.cache_dir.join(stream_id); + let _ = tokio::fs::remove_dir_all(&cache_dir).await; + self.pipeline + .start_transcode_cpu(stream_id, file_path, &info) + .await? + } + } + }; + + self.active + .write() + .await + .insert(stream_id.to_string(), handle); + + Ok(()) + } + + pub async fn generate_playlist( + &self, + stream_id: &str, + _stream_ready: bool, + ) -> Result { + if stream_id == "demo" { + return Ok(PlaylistResponse::Redirect(DEMO_HLS_URL.to_string())); + } + + let path = self.cache_dir.join(stream_id).join("playlist.m3u8"); + match tokio::fs::read_to_string(&path).await { + Ok(content) => Ok(PlaylistResponse::Content(content)), + Err(_) => { + let placeholder = [ + "#EXTM3U", + "#EXT-X-VERSION:3", + "#EXT-X-TARGETDURATION:2", + "#EXT-X-MEDIA-SEQUENCE:0", + "#EXT-X-PLAYLIST-TYPE:EVENT", + "", + ] + .join("\n"); + + Ok(PlaylistResponse::Content(placeholder)) + } + } + } + + pub async fn get_segment(&self, stream_id: &str, segment_name: &str) -> Result> { + if segment_name.contains("..") || segment_name.contains('/') || segment_name.contains('\\') + { + return Err(Error::BadRequest { + message: "Invalid segment name".to_string(), + }); + } + + let cache_key = format!("{stream_id}/{segment_name}"); + + { + let cache = self.segment_cache.read().await; + if let Some(data) = cache.get(&cache_key) { + return Ok(Some(data)); + } + } + + let path = self.cache_dir.join(stream_id).join(segment_name); + match tokio::fs::read(&path).await { + Ok(data) => { + let bytes = Bytes::from(data); + let mut cache = self.segment_cache.write().await; + cache.insert(cache_key, bytes.clone()); + Ok(Some(bytes)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(Error::Transcode { + message: format!("Failed to read segment: {e}"), + }), + } + } + + pub async fn cleanup(&self, stream_id: &str) -> Result<()> { + self.active.write().await.remove(stream_id); + + let dir = self.cache_dir.join(stream_id); + if dir.exists() { + tokio::fs::remove_dir_all(&dir) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to cleanup cache for {stream_id}: {e}"), + })?; + } + + Ok(()) + } +} diff --git a/backend/src/transcode/mod.rs b/backend/src/transcode/mod.rs new file mode 100644 index 0000000..d88e3ab --- /dev/null +++ b/backend/src/transcode/mod.rs @@ -0,0 +1,6 @@ +pub mod gpu; +pub mod hls; +pub mod pipeline; +pub mod probe; + +pub use hls::HlsManager; diff --git a/backend/src/transcode/pipeline.rs b/backend/src/transcode/pipeline.rs new file mode 100644 index 0000000..2695cd9 --- /dev/null +++ b/backend/src/transcode/pipeline.rs @@ -0,0 +1,482 @@ +use crate::config::TranscodeConfig; +use crate::error::{Error, Result}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::process::Command; +use tokio::sync::{watch, Semaphore}; + +use super::gpu::{self, HwAccel}; +use super::probe::MediaInfo; + +pub struct TranscodePipeline { + semaphore: Arc, + hw_accel: HwAccel, + config: TranscodeConfig, + cache_dir: PathBuf, +} + +#[derive(Debug, Clone)] +pub enum TranscodeStatus { + Running, + Complete, + Failed(String), +} + +pub struct TranscodeHandle { + pub stream_id: String, + pub output_dir: PathBuf, + pub playlist_path: PathBuf, + pub status: watch::Receiver, +} + +impl TranscodePipeline { + pub async fn new(config: TranscodeConfig, cache_dir: PathBuf) -> Result { + let hw = gpu::detect_hardware().await; + tracing::info!(?hw, "Detected hardware acceleration"); + + Ok(Self { + semaphore: Arc::new(Semaphore::new(config.max_concurrent_transcodes as usize)), + hw_accel: hw, + config, + cache_dir, + }) + } + + pub async fn start_transcode( + &self, + stream_id: &str, + input_path: &str, + media_info: &MediaInfo, + ) -> Result { + let permit = + self.semaphore + .clone() + .acquire_owned() + .await + .map_err(|_| Error::Transcode { + message: "Transcode semaphore closed".to_string(), + })?; + + let output_dir = self.cache_dir.join(stream_id); + tokio::fs::create_dir_all(&output_dir) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to create output directory: {e}"), + })?; + + let playlist_path = output_dir.join("playlist.m3u8"); + let segment_pattern = output_dir.join("segment_%04d.ts"); + + let mut cmd = self.build_ffmpeg_command( + input_path, + media_info, + &playlist_path, + &segment_pattern, + &output_dir, + ); + + let child = cmd.spawn().map_err(|e| Error::Transcode { + message: format!("Failed to spawn ffmpeg: {e}"), + })?; + + let (status_tx, status_rx) = watch::channel(TranscodeStatus::Running); + let sid = stream_id.to_string(); + + tokio::spawn(async move { + let result = monitor_transcode(child, &sid).await; + let status = match result { + Ok(()) => TranscodeStatus::Complete, + Err(msg) => TranscodeStatus::Failed(msg), + }; + let _ = status_tx.send(status); + drop(permit); + }); + + Ok(TranscodeHandle { + stream_id: stream_id.to_string(), + output_dir, + playlist_path, + status: status_rx, + }) + } + + pub async fn start_transcode_cpu( + &self, + stream_id: &str, + input_path: &str, + media_info: &MediaInfo, + ) -> Result { + let permit = + self.semaphore + .clone() + .acquire_owned() + .await + .map_err(|_| Error::Transcode { + message: "Transcode semaphore closed".to_string(), + })?; + + let output_dir = self.cache_dir.join(stream_id); + tokio::fs::create_dir_all(&output_dir) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to create output directory: {e}"), + })?; + + let playlist_path = output_dir.join("playlist.m3u8"); + let segment_pattern = output_dir.join("segment_%04d.ts"); + + let mut cmd = tokio::process::Command::new("ffmpeg"); + cmd.arg("-y") + .arg("-hide_banner") + .arg("-loglevel") + .arg("warning"); + cmd.arg("-probesize").arg("5000000"); + cmd.arg("-analyzeduration").arg("3000000"); + cmd.arg("-fflags").arg("+genpts+igndts+discardcorrupt"); + cmd.arg("-i").arg(input_path); + cmd.arg("-c:v").arg("libx264"); + cmd.arg("-preset").arg(&self.config.preset); + cmd.arg("-crf").arg(self.config.crf.to_string()); + cmd.arg("-tune").arg("zerolatency"); + cmd.arg("-threads").arg("0"); + + if media_info.has_hdr10 || media_info.has_dolby_vision || media_info.has_hdr10_plus { + cmd.arg("-vf").arg( + "zscale=t=linear:npl=100,format=gbrpf32le,\ + zscale=p=bt709,tonemap=tonemap=hable:desat=0,\ + zscale=t=bt709:m=bt709:r=tv,format=yuv420p", + ); + } + + if media_info.needs_audio_transcode { + cmd.arg("-c:a").arg("aac"); + cmd.arg("-b:a").arg(&self.config.audio_bitrate); + let channels = if media_info.audio_channels.unwrap_or(2) > 2 { + "6" + } else { + "2" + }; + cmd.arg("-ac").arg(channels); + } else { + cmd.arg("-c:a").arg("copy"); + } + + cmd.arg("-sn"); + cmd.arg("-avoid_negative_ts").arg("make_zero"); + cmd.arg("-max_muxing_queue_size").arg("4096"); + cmd.arg("-f").arg("hls"); + cmd.arg("-hls_time").arg("2"); + cmd.arg("-hls_init_time").arg("1"); + cmd.arg("-hls_list_size").arg("0"); + cmd.arg("-hls_segment_type").arg("mpegts"); + cmd.arg("-hls_flags") + .arg("independent_segments+append_list"); + cmd.arg("-hls_playlist_type").arg("vod"); + cmd.arg("-movflags").arg("+faststart"); + cmd.arg("-hls_segment_filename").arg(&segment_pattern); + cmd.arg(&playlist_path); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let child = cmd.spawn().map_err(|e| Error::Transcode { + message: format!("Failed to spawn ffmpeg (CPU fallback): {e}"), + })?; + + let (status_tx, status_rx) = watch::channel(TranscodeStatus::Running); + let sid = stream_id.to_string(); + + tokio::spawn(async move { + let result = monitor_transcode(child, &sid).await; + let status = match result { + Ok(()) => TranscodeStatus::Complete, + Err(msg) => TranscodeStatus::Failed(msg), + }; + let _ = status_tx.send(status); + drop(permit); + }); + + Ok(TranscodeHandle { + stream_id: stream_id.to_string(), + output_dir, + playlist_path, + status: status_rx, + }) + } + + pub async fn start_passthrough( + &self, + stream_id: &str, + input_path: &str, + ) -> Result { + let permit = + self.semaphore + .clone() + .acquire_owned() + .await + .map_err(|_| Error::Transcode { + message: "Transcode semaphore closed".to_string(), + })?; + + let output_dir = self.cache_dir.join(stream_id); + tokio::fs::create_dir_all(&output_dir) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to create output directory: {e}"), + })?; + + let playlist_path = output_dir.join("playlist.m3u8"); + let segment_pattern = output_dir.join("segment_%04d.ts"); + + let _init_filename = "init.mp4"; + + let mut cmd = Command::new("ffmpeg"); + cmd.arg("-y") + .arg("-hide_banner") + .arg("-loglevel") + .arg("warning") + .arg("-probesize") + .arg("5000000") + .arg("-analyzeduration") + .arg("3000000") + .arg("-fflags") + .arg("+genpts+igndts+discardcorrupt") + .arg("-i") + .arg(input_path) + .arg("-c") + .arg("copy") + .arg("-avoid_negative_ts") + .arg("make_zero") + .arg("-max_muxing_queue_size") + .arg("4096") + .arg("-f") + .arg("hls") + .arg("-hls_time") + .arg("2") + .arg("-hls_init_time") + .arg("1") + .arg("-hls_list_size") + .arg("0") + .arg("-hls_segment_type") + .arg("mpegts") + .arg("-hls_flags") + .arg("independent_segments+append_list") + .arg("-hls_playlist_type") + .arg("event") + .arg("-movflags") + .arg("+faststart") + .arg("-hls_segment_filename") + .arg(&segment_pattern) + .arg(&playlist_path); + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + let child = cmd.spawn().map_err(|e| Error::Transcode { + message: format!("Failed to spawn ffmpeg for passthrough: {e}"), + })?; + + let (status_tx, status_rx) = watch::channel(TranscodeStatus::Running); + let sid = stream_id.to_string(); + + tokio::spawn(async move { + let result = monitor_transcode(child, &sid).await; + let status = match result { + Ok(()) => TranscodeStatus::Complete, + Err(msg) => TranscodeStatus::Failed(msg), + }; + let _ = status_tx.send(status); + drop(permit); + }); + + Ok(TranscodeHandle { + stream_id: stream_id.to_string(), + output_dir, + playlist_path, + status: status_rx, + }) + } + + fn build_ffmpeg_command( + &self, + input_path: &str, + media_info: &MediaInfo, + playlist_path: &Path, + segment_pattern: &Path, + _output_dir: &Path, + ) -> Command { + let mut cmd = Command::new("ffmpeg"); + cmd.arg("-y"); + cmd.arg("-hide_banner"); + cmd.arg("-loglevel").arg("warning"); + + cmd.arg("-probesize").arg("5000000"); + cmd.arg("-analyzeduration").arg("3000000"); + cmd.arg("-fflags").arg("+genpts+igndts+discardcorrupt"); + + for flag in gpu::hw_decode_flags(&self.hw_accel) { + cmd.arg(flag); + } + + cmd.arg("-i").arg(input_path); + + let video_encoder = gpu::encoder_for_hw(&self.hw_accel); + cmd.arg("-c:v").arg(video_encoder); + + if media_info.has_hdr10 || media_info.has_dolby_vision || media_info.has_hdr10_plus { + match &self.hw_accel { + HwAccel::Nvenc => { + cmd.arg("-vf").arg( + "scale_cuda=format=nv12,hwdownload,format=nv12,\ + tonemap=hable:desat=0,format=yuv420p,hwupload_cuda", + ); + } + HwAccel::Vaapi => { + cmd.arg("-vf") + .arg("tonemap_vaapi=t=bt709:m=bt709:p=bt709,scale_vaapi=format=nv12"); + } + _ => { + cmd.arg("-vf").arg( + "zscale=t=linear:npl=100,format=gbrpf32le,\ + zscale=p=bt709,tonemap=tonemap=hable:desat=0,\ + zscale=t=bt709:m=bt709:r=tv,format=yuv420p", + ); + } + } + } + + match &self.hw_accel { + HwAccel::Nvenc => { + cmd.arg("-preset").arg("p4"); + cmd.arg("-rc").arg("vbr"); + cmd.arg("-cq").arg(self.config.crf.to_string()); + } + HwAccel::None => { + cmd.arg("-preset").arg(&self.config.preset); + cmd.arg("-crf").arg(self.config.crf.to_string()); + cmd.arg("-tune").arg("zerolatency"); + match self.config.threads { + Some(threads) => { + cmd.arg("-threads").arg(threads.to_string()); + } + None => { + cmd.arg("-threads").arg("0"); + } + } + } + _ => { + cmd.arg("-preset").arg(&self.config.preset); + } + } + + if let Some(ref max_br) = self.config.max_bitrate { + cmd.arg("-maxrate").arg(max_br); + let bufsize_kbps = parse_bitrate(max_br).saturating_mul(2) / 1000; + cmd.arg("-bufsize").arg(format!("{bufsize_kbps}k")); + } + + if media_info.needs_audio_transcode { + cmd.arg("-c:a").arg("aac"); + cmd.arg("-b:a").arg(&self.config.audio_bitrate); + let target_channels = if media_info.audio_channels.unwrap_or(2) > 2 { + "6" + } else { + "2" + }; + cmd.arg("-ac").arg(target_channels); + } else { + cmd.arg("-c:a").arg("copy"); + } + + cmd.arg("-sn"); + + cmd.arg("-avoid_negative_ts").arg("make_zero"); + cmd.arg("-max_muxing_queue_size").arg("4096"); + + let _init_filename = "init.mp4"; + + cmd.arg("-f").arg("hls"); + cmd.arg("-hls_time").arg("2"); + cmd.arg("-hls_init_time").arg("1"); + cmd.arg("-hls_list_size").arg("0"); + cmd.arg("-hls_segment_type").arg("mpegts"); + cmd.arg("-hls_flags") + .arg("independent_segments+append_list"); + cmd.arg("-hls_playlist_type").arg("vod"); + cmd.arg("-movflags").arg("+faststart"); + cmd.arg("-hls_segment_filename").arg(segment_pattern); + cmd.arg(playlist_path); + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + cmd + } +} + +async fn monitor_transcode( + mut child: tokio::process::Child, + stream_id: &str, +) -> std::result::Result<(), String> { + let status = child + .wait() + .await + .map_err(|e| format!("Failed to wait for ffmpeg process: {e}"))?; + + if status.success() { + tracing::info!(stream_id, "Transcode completed successfully"); + Ok(()) + } else { + let stderr = if let Some(mut stderr) = child.stderr.take() { + let mut buf = Vec::new(); + let _ = tokio::io::AsyncReadExt::read_to_end(&mut stderr, &mut buf).await; + String::from_utf8_lossy(&buf).to_string() + } else { + String::new() + }; + let code = status.code().unwrap_or(-1); + let msg = format!("ffmpeg exited with code {code}: {stderr}"); + tracing::error!(stream_id, %msg, "Transcode failed"); + Err(msg) + } +} + +fn parse_bitrate(s: &str) -> u64 { + let s = s.trim(); + if let Some(num_str) = s.strip_suffix('M').or_else(|| s.strip_suffix('m')) { + num_str + .parse::() + .unwrap_or(0) + .saturating_mul(1_000_000) + } else if let Some(num_str) = s.strip_suffix('K').or_else(|| s.strip_suffix('k')) { + num_str.parse::().unwrap_or(0).saturating_mul(1_000) + } else { + s.parse::().unwrap_or(0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_bitrate_megabits() { + assert_eq!(parse_bitrate("8M"), 8_000_000); + assert_eq!(parse_bitrate("4m"), 4_000_000); + } + + #[test] + fn parse_bitrate_kilobits() { + assert_eq!(parse_bitrate("192k"), 192_000); + assert_eq!(parse_bitrate("256K"), 256_000); + } + + #[test] + fn parse_bitrate_plain() { + assert_eq!(parse_bitrate("1000000"), 1_000_000); + } + + #[test] + fn parse_bitrate_invalid() { + assert_eq!(parse_bitrate("invalid"), 0); + } +} diff --git a/backend/src/transcode/probe.rs b/backend/src/transcode/probe.rs new file mode 100644 index 0000000..e5a8a3d --- /dev/null +++ b/backend/src/transcode/probe.rs @@ -0,0 +1,512 @@ +use crate::error::{Error, Result}; +use serde::Deserialize; +use tokio::process::Command; + +#[derive(Debug, Clone)] +pub struct MediaInfo { + pub duration_seconds: Option, + pub video_codec: Option, + pub audio_codec: Option, + pub width: Option, + pub height: Option, + pub container: Option, + pub bit_depth: Option, + pub color_space: Option, + pub color_transfer: Option, + pub color_primaries: Option, + pub hdr_format: HdrFormat, + pub audio_channels: Option, + pub audio_sample_rate: Option, + pub audio_format_name: Option, + pub has_dolby_vision: bool, + pub has_dolby_atmos: bool, + pub has_dts: bool, + pub has_hdr10: bool, + pub has_hdr10_plus: bool, + pub subtitle_tracks: Vec, + pub needs_transcode: bool, + pub needs_audio_transcode: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HdrFormat { + Hdr10, + Hdr10Plus, + DolbyVision, + Hlg, + None, +} + +#[derive(Debug, Clone)] +pub struct SubtitleTrack { + pub index: usize, + pub language: Option, + pub title: Option, + pub codec: String, +} + +#[derive(Debug, Deserialize)] +struct FfprobeOutput { + #[serde(default)] + streams: Vec, + #[serde(default)] + format: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +struct FfprobeStream { + #[serde(default)] + codec_type: Option, + #[serde(default)] + codec_name: Option, + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, + #[serde(default)] + pix_fmt: Option, + #[serde(default)] + color_space: Option, + #[serde(default)] + color_transfer: Option, + #[serde(default)] + color_primaries: Option, + #[serde(default)] + bits_per_raw_sample: Option, + #[serde(default)] + channels: Option, + #[serde(default)] + sample_rate: Option, + #[serde(default)] + index: Option, + #[serde(default)] + side_data_list: Option>, + #[serde(default)] + tags: Option, +} + +#[derive(Debug, Deserialize)] +struct SideData { + #[serde(default)] + side_data_type: Option, +} + +#[derive(Debug, Deserialize)] +struct StreamTags { + #[serde(default)] + language: Option, + #[serde(default)] + title: Option, +} + +#[derive(Debug, Deserialize)] +struct FfprobeFormat { + #[serde(default)] + duration: Option, + #[serde(default)] + format_name: Option, +} + +pub async fn probe(file_path: &str) -> Result { + let output = Command::new("ffprobe") + .args([ + "-v", + "quiet", + "-print_format", + "json", + "-show_format", + "-show_streams", + file_path, + ]) + .output() + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to run ffprobe: {e}"), + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + return Err(Error::Transcode { + message: format!( + "ffprobe failed (exit {}): stderr={stderr} stdout={stdout} file={file_path}", + output.status.code().unwrap_or(-1) + ), + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let probe_data: FfprobeOutput = + serde_json::from_str(&stdout).map_err(|e| Error::Transcode { + message: format!("Failed to parse ffprobe output: {e}"), + })?; + + parse_probe_output(probe_data) +} + +fn parse_probe_output(data: FfprobeOutput) -> Result { + let video_stream = data + .streams + .iter() + .find(|s| s.codec_type.as_deref() == Some("video")); + + let audio_stream = data + .streams + .iter() + .find(|s| s.codec_type.as_deref() == Some("audio")); + + let video_codec = video_stream.and_then(|s| s.codec_name.clone()); + let audio_codec = audio_stream.and_then(|s| s.codec_name.clone()); + + let width = video_stream.and_then(|s| s.width); + let height = video_stream.and_then(|s| s.height); + + let bit_depth = video_stream + .and_then(|s| s.bits_per_raw_sample.as_deref()) + .and_then(|b| b.parse::().ok()); + + let color_space = video_stream.and_then(|s| s.color_space.clone()); + let color_transfer = video_stream.and_then(|s| s.color_transfer.clone()); + let color_primaries = video_stream.and_then(|s| s.color_primaries.clone()); + + let has_dolby_vision = video_stream + .and_then(|s| s.side_data_list.as_ref()) + .map(|side_data| { + side_data.iter().any(|sd| { + sd.side_data_type + .as_deref() + .map(|t| t.contains("DOVI") || t.contains("Dolby Vision")) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + let has_hdr10 = color_transfer.as_deref() == Some("smpte2084") && !has_dolby_vision; + + let has_hdr10_plus = video_stream + .and_then(|s| s.side_data_list.as_ref()) + .map(|side_data| { + side_data.iter().any(|sd| { + sd.side_data_type + .as_deref() + .map(|t| t.contains("HDR10+") || t.contains("HDR Dynamic Metadata")) + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + let is_hlg = color_transfer.as_deref() == Some("arib-std-b67"); + + let hdr_format = if has_dolby_vision { + HdrFormat::DolbyVision + } else if has_hdr10_plus { + HdrFormat::Hdr10Plus + } else if has_hdr10 { + HdrFormat::Hdr10 + } else if is_hlg { + HdrFormat::Hlg + } else { + HdrFormat::None + }; + + let audio_channels = audio_stream.and_then(|s| s.channels); + let audio_sample_rate = audio_stream + .and_then(|s| s.sample_rate.as_deref()) + .and_then(|r| r.parse::().ok()); + let audio_format_name = audio_codec.clone(); + + let has_dts = audio_codec + .as_deref() + .map(|c| c.starts_with("dts")) + .unwrap_or(false); + + let has_dolby_atmos = audio_codec.as_deref() == Some("truehd") + && audio_channels.map(|ch| ch >= 8).unwrap_or(false); + + let duration_seconds = data + .format + .as_ref() + .and_then(|f| f.duration.as_deref()) + .and_then(|d| d.parse::().ok()); + + let container = data.format.as_ref().and_then(|f| f.format_name.clone()); + + let subtitle_tracks: Vec = data + .streams + .iter() + .filter(|s| s.codec_type.as_deref() == Some("subtitle")) + .map(|s| SubtitleTrack { + index: s.index.unwrap_or(0), + language: s.tags.as_ref().and_then(|t| t.language.clone()), + title: s.tags.as_ref().and_then(|t| t.title.clone()), + codec: s.codec_name.clone().unwrap_or_default(), + }) + .collect(); + + let needs_video_transcode = match video_codec.as_deref() { + Some("h264") => hdr_format != HdrFormat::None, + Some(_) => true, + None => false, + }; + + let needs_audio_transcode = match audio_codec.as_deref() { + Some("aac") | Some("mp3") | Some("opus") => false, + Some(_) => true, + None => false, + }; + + let needs_transcode = needs_video_transcode || needs_audio_transcode; + + Ok(MediaInfo { + duration_seconds, + video_codec, + audio_codec, + width, + height, + container, + bit_depth, + color_space, + color_transfer, + color_primaries, + hdr_format, + audio_channels, + audio_sample_rate, + audio_format_name, + has_dolby_vision, + has_dolby_atmos, + has_dts, + has_hdr10, + has_hdr10_plus, + subtitle_tracks, + needs_transcode, + needs_audio_transcode, + }) +} + +pub fn is_browser_compatible(info: &MediaInfo) -> bool { + let compatible_video = info + .video_codec + .as_deref() + .map(|c| c == "h264" || c == "avc1") + .unwrap_or(false); + + let compatible_audio = info + .audio_codec + .as_deref() + .map(|c| c == "aac" || c == "mp3" || c == "mp4a" || c == "opus") + .unwrap_or(true); + + let compatible_container = info + .container + .as_deref() + .map(|c| { + let is_mp4 = c.contains("mp4") || c.contains("mov"); + let is_webm = c == "webm"; + let is_mpegts = c.contains("mpegts"); + is_mp4 || is_webm || is_mpegts + }) + .unwrap_or(false); + + let no_hdr = info.hdr_format == HdrFormat::None; + + compatible_video && compatible_audio && compatible_container && no_hdr +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn browser_compatible_h264_aac_mp4() { + let info = MediaInfo { + duration_seconds: Some(120.0), + video_codec: Some("h264".to_string()), + audio_codec: Some("aac".to_string()), + width: Some(1920), + height: Some(1080), + container: Some("mov,mp4,m4a,3gp,3g2,mj2".to_string()), + bit_depth: Some(8), + color_space: None, + color_transfer: None, + color_primaries: None, + hdr_format: HdrFormat::None, + audio_channels: Some(2), + audio_sample_rate: Some(48000), + audio_format_name: Some("aac".to_string()), + has_dolby_vision: false, + has_dolby_atmos: false, + has_dts: false, + has_hdr10: false, + has_hdr10_plus: false, + subtitle_tracks: vec![], + needs_transcode: false, + needs_audio_transcode: false, + }; + assert!(is_browser_compatible(&info)); + } + + #[test] + fn hevc_needs_transcode() { + let info = MediaInfo { + duration_seconds: Some(120.0), + video_codec: Some("hevc".to_string()), + audio_codec: Some("aac".to_string()), + width: Some(3840), + height: Some(2160), + container: Some("matroska,webm".to_string()), + bit_depth: Some(10), + color_space: Some("bt2020nc".to_string()), + color_transfer: Some("smpte2084".to_string()), + color_primaries: Some("bt2020".to_string()), + hdr_format: HdrFormat::Hdr10, + audio_channels: Some(2), + audio_sample_rate: Some(48000), + audio_format_name: Some("aac".to_string()), + has_dolby_vision: false, + has_dolby_atmos: false, + has_dts: false, + has_hdr10: true, + has_hdr10_plus: false, + subtitle_tracks: vec![], + needs_transcode: true, + needs_audio_transcode: false, + }; + assert!(!is_browser_compatible(&info)); + } + + #[test] + fn truehd_audio_needs_transcode() { + let data = FfprobeOutput { + streams: vec![ + FfprobeStream { + codec_type: Some("video".to_string()), + codec_name: Some("hevc".to_string()), + width: Some(3840), + height: Some(2160), + pix_fmt: Some("yuv420p10le".to_string()), + color_space: None, + color_transfer: None, + color_primaries: None, + bits_per_raw_sample: Some("10".to_string()), + channels: None, + sample_rate: None, + index: Some(0), + side_data_list: None, + tags: None, + }, + FfprobeStream { + codec_type: Some("audio".to_string()), + codec_name: Some("truehd".to_string()), + width: None, + height: None, + pix_fmt: None, + color_space: None, + color_transfer: None, + color_primaries: None, + bits_per_raw_sample: None, + channels: Some(8), + sample_rate: Some("48000".to_string()), + index: Some(1), + side_data_list: None, + tags: None, + }, + ], + format: Some(FfprobeFormat { + duration: Some("7200.0".to_string()), + format_name: Some("matroska,webm".to_string()), + }), + }; + + let info = parse_probe_output(data).unwrap(); + assert!(info.needs_transcode); + assert!(info.needs_audio_transcode); + assert!(info.has_dolby_atmos); + assert_eq!(info.audio_channels, Some(8)); + } + + #[test] + fn dolby_vision_detection() { + let data = FfprobeOutput { + streams: vec![FfprobeStream { + codec_type: Some("video".to_string()), + codec_name: Some("hevc".to_string()), + width: Some(3840), + height: Some(2160), + pix_fmt: Some("yuv420p10le".to_string()), + color_space: Some("bt2020nc".to_string()), + color_transfer: Some("smpte2084".to_string()), + color_primaries: Some("bt2020".to_string()), + bits_per_raw_sample: Some("10".to_string()), + channels: None, + sample_rate: None, + index: Some(0), + side_data_list: Some(vec![SideData { + side_data_type: Some("DOVI configuration record".to_string()), + }]), + tags: None, + }], + format: Some(FfprobeFormat { + duration: Some("7200.0".to_string()), + format_name: Some("matroska,webm".to_string()), + }), + }; + + let info = parse_probe_output(data).unwrap(); + assert!(info.has_dolby_vision); + assert!(!info.has_hdr10); + assert_eq!(info.hdr_format, HdrFormat::DolbyVision); + } + + #[test] + fn subtitle_tracks_parsed() { + let data = FfprobeOutput { + streams: vec![ + FfprobeStream { + codec_type: Some("video".to_string()), + codec_name: Some("h264".to_string()), + width: Some(1920), + height: Some(1080), + pix_fmt: None, + color_space: None, + color_transfer: None, + color_primaries: None, + bits_per_raw_sample: None, + channels: None, + sample_rate: None, + index: Some(0), + side_data_list: None, + tags: None, + }, + FfprobeStream { + codec_type: Some("subtitle".to_string()), + codec_name: Some("subrip".to_string()), + width: None, + height: None, + pix_fmt: None, + color_space: None, + color_transfer: None, + color_primaries: None, + bits_per_raw_sample: None, + channels: None, + sample_rate: None, + index: Some(2), + side_data_list: None, + tags: Some(StreamTags { + language: Some("eng".to_string()), + title: Some("English".to_string()), + }), + }, + ], + format: Some(FfprobeFormat { + duration: Some("120.0".to_string()), + format_name: Some("matroska,webm".to_string()), + }), + }; + + let info = parse_probe_output(data).unwrap(); + assert_eq!(info.subtitle_tracks.len(), 1); + assert_eq!(info.subtitle_tracks[0].codec, "subrip"); + assert_eq!(info.subtitle_tracks[0].language.as_deref(), Some("eng")); + } +} diff --git a/backend/tests/api_tests.rs b/backend/tests/api_tests.rs new file mode 100644 index 0000000..810fe7f --- /dev/null +++ b/backend/tests/api_tests.rs @@ -0,0 +1,605 @@ +use reqwest::StatusCode; +use serde_json::Value; +use std::net::SocketAddr; + +struct TestServer { + base_url: String, + _tmp: tempfile::TempDir, +} + +async fn start_test_server() -> TestServer { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().to_path_buf(); + + std::fs::create_dir_all(data_dir.join("downloads")).unwrap(); + std::fs::create_dir_all(data_dir.join("cache")).unwrap(); + + let port = portpicker::pick_unused_port().unwrap(); + + let config = streamx::config::AppConfig { + server: streamx::config::ServerConfig { + port, + bind: "127.0.0.1".to_string(), + open_browser: false, + }, + torrent: streamx::config::TorrentConfig { + max_connections: 200, + sequential: true, + seed_after_complete: true, + dht: true, + pex: true, + }, + transcode: streamx::config::TranscodeConfig { + hls_segment_duration: 4, + video_codec: "h264".to_string(), + audio_codec: "aac".to_string(), + preset: "ultrafast".to_string(), + max_concurrent_transcodes: 2, + crf: 23, + max_bitrate: None, + audio_bitrate: "192k".to_string(), + threads: None, + }, + auth: streamx::config::AuthConfig { + jwt_secret: "test-secret-key-for-integration-tests".to_string(), + session_duration: "7d".to_string(), + }, + ui: streamx::config::UiConfig { + default_theme: "dark".to_string(), + }, + data_dir: data_dir.clone(), + log_level: "warn".to_string(), + open_browser: false, + admin_user: None, + admin_password: None, + }; + + let db_path = data_dir.join("streamx.db"); + let database = streamx::db::Database::open(&db_path).unwrap(); + database.init().await.unwrap(); + + database.set_downloading_to_paused().await.unwrap(); + let torrent_engine = + streamx::torrent::TorrentEngine::create(&config.torrent, &data_dir, database.clone()) + .await + .unwrap(); + let search_provider = streamx::torrent::SearchProvider::new(); + let cache_dir = data_dir.join("cache"); + let hls_pipeline = streamx::transcode::HlsManager::new(&config.transcode, cache_dir) + .await + .unwrap(); + + let app = streamx::server::build_router( + database, + config, + torrent_engine, + search_provider, + hls_pipeline, + ); + + let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + + tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + TestServer { + base_url: format!("http://127.0.0.1:{port}"), + _tmp: tmp, + } +} + +async fn register_user(base_url: &str, username: &str, password: &str) -> reqwest::Response { + let client = reqwest::Client::new(); + client + .post(format!("{base_url}/api/auth/register")) + .json(&serde_json::json!({ + "username": username, + "password": password, + })) + .send() + .await + .unwrap() +} + +async fn login_user(base_url: &str, username: &str, password: &str) -> reqwest::Response { + let client = reqwest::Client::new(); + client + .post(format!("{base_url}/api/auth/login")) + .json(&serde_json::json!({ + "username": username, + "password": password, + })) + .send() + .await + .unwrap() +} + +async fn get_token(base_url: &str, username: &str, password: &str) -> String { + let resp = register_user(base_url, username, password).await; + let body: Value = resp.json().await.unwrap(); + body["token"].as_str().unwrap().to_string() +} + +#[tokio::test] +async fn register_user_and_get_token() { + let server = start_test_server().await; + let resp = register_user(&server.base_url, "testuser", "password123").await; + + assert_eq!(resp.status(), StatusCode::CREATED); + + let body: Value = resp.json().await.unwrap(); + assert!(body["token"].is_string()); + assert!(!body["token"].as_str().unwrap().is_empty()); +} + +#[tokio::test] +async fn login_with_registered_user() { + let server = start_test_server().await; + register_user(&server.base_url, "loginuser", "password123").await; + + let resp = login_user(&server.base_url, "loginuser", "password123").await; + + assert_eq!(resp.status(), StatusCode::OK); + + let body: Value = resp.json().await.unwrap(); + assert!(body["token"].is_string()); +} + +#[tokio::test] +async fn me_returns_user_info() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "meuser", "password123").await; + + let client = reqwest::Client::new(); + let resp = client + .get(format!("{}/api/auth/me", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["username"], "meuser"); + assert!(body["id"].is_string()); + assert!(body["created_at"].is_string()); +} + +#[tokio::test] +async fn unauthorized_request_returns_401() { + let server = start_test_server().await; + + let client = reqwest::Client::new(); + let resp = client + .get(format!("{}/api/auth/me", server.base_url)) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn register_duplicate_user_returns_error() { + let server = start_test_server().await; + register_user(&server.base_url, "dupuser", "password123").await; + + let resp = register_user(&server.base_url, "dupuser", "password456").await; + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let body: Value = resp.json().await.unwrap(); + let error_msg = body["error"].as_str().unwrap(); + assert!(error_msg.contains("already taken")); +} + +#[tokio::test] +async fn invalid_username_too_short_returns_400() { + let server = start_test_server().await; + let resp = register_user(&server.base_url, "ab", "password123").await; + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let body: Value = resp.json().await.unwrap(); + let error_msg = body["error"].as_str().unwrap(); + assert!(error_msg.contains("Username")); +} + +#[tokio::test] +async fn invalid_password_too_short_returns_400() { + let server = start_test_server().await; + let resp = register_user(&server.base_url, "validuser", "short").await; + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + let body: Value = resp.json().await.unwrap(); + let error_msg = body["error"].as_str().unwrap(); + assert!(error_msg.contains("Password")); +} + +#[tokio::test] +async fn search_endpoint_returns_results() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "searchuser", "password123").await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/api/search", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "query": "test query" })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let body: Value = resp.json().await.unwrap(); + assert!(body["results"].is_array()); +} + +#[tokio::test] +async fn create_stream_endpoint() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "streamuser", "password123").await; + + let client = reqwest::Client::new(); + let resp = client + .post(format!("{}/api/stream", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "magnet_uri": "magnet:?xt=urn:btih:0000000000000000000000000000000000000000&dn=test", + })) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let body: Value = resp.json().await.unwrap(); + assert!(body["stream_id"].is_string()); + assert_eq!(body["status"], "initializing"); +} + +#[tokio::test] +async fn get_stream_status() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "statususer", "password123").await; + + let client = reqwest::Client::new(); + + let create_resp = client + .post(format!("{}/api/stream", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "magnet_uri": "magnet:?xt=urn:btih:1111111111111111111111111111111111111111&dn=test", + })) + .send() + .await + .unwrap(); + + let create_body: Value = create_resp.json().await.unwrap(); + let stream_id = create_body["stream_id"].as_str().unwrap(); + + let resp = client + .get(format!("{}/api/stream/{stream_id}", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["id"], stream_id); + assert_eq!(body["status"], "initializing"); +} + +#[tokio::test] +async fn delete_stream() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "delstreamuser", "password123").await; + + let client = reqwest::Client::new(); + + let create_resp = client + .post(format!("{}/api/stream", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "magnet_uri": "magnet:?xt=urn:btih:2222222222222222222222222222222222222222&dn=test", + })) + .send() + .await + .unwrap(); + + let create_body: Value = create_resp.json().await.unwrap(); + let stream_id = create_body["stream_id"].as_str().unwrap(); + + let resp = client + .delete(format!("{}/api/stream/{stream_id}", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["status"], "stopped"); + + let get_resp = client + .get(format!("{}/api/stream/{stream_id}", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + + // Download persists in DB after HLS cleanup + assert_eq!(get_resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn watch_history_crud() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "histuser", "password123").await; + + let client = reqwest::Client::new(); + + let create_resp = client + .post(format!("{}/api/stream", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ + "magnet_uri": "magnet:?xt=urn:btih:3333333333333333333333333333333333333333&dn=histtest", + })) + .send() + .await + .unwrap(); + assert_eq!(create_resp.status(), StatusCode::OK); + + let list_resp = client + .get(format!("{}/api/history", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + + assert_eq!(list_resp.status(), StatusCode::OK); + let list_body: Value = list_resp.json().await.unwrap(); + let items = list_body["items"].as_array().unwrap(); + assert!(!items.is_empty()); + + let entry_id = items[0]["id"].as_str().unwrap(); + + let update_resp = client + .put(format!("{}/api/history/{entry_id}", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "watched_seconds": 120 })) + .send() + .await + .unwrap(); + assert_eq!(update_resp.status(), StatusCode::OK); + + let del_resp = client + .delete(format!("{}/api/history/{entry_id}", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(del_resp.status(), StatusCode::OK); +} + +#[tokio::test] +async fn settings_crud() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "settingsuser", "password123").await; + + let client = reqwest::Client::new(); + + let get_resp = client + .get(format!("{}/api/settings", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + + assert_eq!(get_resp.status(), StatusCode::OK); + let body: Value = get_resp.json().await.unwrap(); + assert_eq!(body["theme"], "dark"); + + let update_resp = client + .put(format!("{}/api/settings", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "theme": "light" })) + .send() + .await + .unwrap(); + + assert_eq!(update_resp.status(), StatusCode::OK); + let update_body: Value = update_resp.json().await.unwrap(); + assert_eq!(update_body["theme"], "light"); + + let verify_resp = client + .get(format!("{}/api/settings", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + + let verify_body: Value = verify_resp.json().await.unwrap(); + assert_eq!(verify_body["theme"], "light"); +} + +#[tokio::test] +async fn test_video_endpoint_returns_mp4() { + let server = start_test_server().await; + + let client = reqwest::Client::new(); + let resp = client + .get(format!("{}/api/test/video", server.base_url)) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let content_type = resp + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap(); + assert!(content_type.contains("video/mp4")); + + let body = resp.bytes().await.unwrap(); + assert!(!body.is_empty()); + assert_eq!(&body[4..8], b"ftyp"); +} + +#[tokio::test] +async fn test_hls_playlist_endpoint() { + let server = start_test_server().await; + + let client = reqwest::Client::new(); + let resp = client + .get(format!("{}/api/test/playlist.m3u8", server.base_url)) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let content_type = resp + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap(); + assert!(content_type.contains("mpegurl")); + + let body = resp.text().await.unwrap(); + assert!(body.contains("#EXTM3U")); + assert!(body.contains("#EXT-X-TARGETDURATION")); +} + +#[tokio::test] +async fn admin_user_creation_via_config() { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().to_path_buf(); + + std::fs::create_dir_all(data_dir.join("downloads")).unwrap(); + std::fs::create_dir_all(data_dir.join("cache")).unwrap(); + + let port = portpicker::pick_unused_port().unwrap(); + + let config = streamx::config::AppConfig { + server: streamx::config::ServerConfig { + port, + bind: "127.0.0.1".to_string(), + open_browser: false, + }, + torrent: streamx::config::TorrentConfig { + max_connections: 200, + sequential: true, + seed_after_complete: true, + dht: true, + pex: true, + }, + transcode: streamx::config::TranscodeConfig { + hls_segment_duration: 4, + video_codec: "h264".to_string(), + audio_codec: "aac".to_string(), + preset: "ultrafast".to_string(), + max_concurrent_transcodes: 2, + crf: 23, + max_bitrate: None, + audio_bitrate: "192k".to_string(), + threads: None, + }, + auth: streamx::config::AuthConfig { + jwt_secret: "admin-test-secret".to_string(), + session_duration: "7d".to_string(), + }, + ui: streamx::config::UiConfig { + default_theme: "dark".to_string(), + }, + data_dir: data_dir.clone(), + log_level: "warn".to_string(), + open_browser: false, + admin_user: Some("myadmin".to_string()), + admin_password: Some("adminpass123".to_string()), + }; + + let db_path = data_dir.join("streamx.db"); + let database = streamx::db::Database::open(&db_path).unwrap(); + database.init().await.unwrap(); + + let admin_user = config.admin_user.as_ref().unwrap(); + let admin_pass = config.admin_password.as_ref().unwrap(); + let password_hash = streamx::server::auth::hash_password(admin_pass).unwrap(); + database + .create_user(admin_user, &password_hash) + .await + .unwrap(); + + database.set_downloading_to_paused().await.unwrap(); + let torrent_engine = + streamx::torrent::TorrentEngine::create(&config.torrent, &data_dir, database.clone()) + .await + .unwrap(); + let search_provider = streamx::torrent::SearchProvider::new(); + let cache_dir = data_dir.join("cache"); + let hls_pipeline = streamx::transcode::HlsManager::new(&config.transcode, cache_dir) + .await + .unwrap(); + + let app = streamx::server::build_router( + database, + config, + torrent_engine, + search_provider, + hls_pipeline, + ); + + let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + + tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + let base_url = format!("http://127.0.0.1:{port}"); + let resp = login_user(&base_url, "myadmin", "adminpass123").await; + assert_eq!(resp.status(), StatusCode::OK); + + let body: Value = resp.json().await.unwrap(); + let token = body["token"].as_str().unwrap(); + + let client = reqwest::Client::new(); + let me_resp = client + .get(format!("{base_url}/api/auth/me")) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + + assert_eq!(me_resp.status(), StatusCode::OK); + let me_body: Value = me_resp.json().await.unwrap(); + assert_eq!(me_body["username"], "myadmin"); + assert_eq!(me_body["is_admin"], true); +} diff --git a/backend/tests/auth_tests.rs b/backend/tests/auth_tests.rs new file mode 100644 index 0000000..dbb6140 --- /dev/null +++ b/backend/tests/auth_tests.rs @@ -0,0 +1,81 @@ +use streamx::server::auth; + +#[test] +fn bcrypt_hash_and_verify() { + let password = "my_secret_password"; + let hash = auth::hash_password(password).unwrap(); + + assert_ne!(hash, password); + assert!(bcrypt::verify(password, &hash).unwrap()); +} + +#[test] +fn bcrypt_verify_wrong_password_fails() { + let hash = auth::hash_password("correct_password").unwrap(); + assert!(!bcrypt::verify("wrong_password", &hash).unwrap()); +} + +#[test] +fn jwt_create_and_validate() { + let secret = "test-jwt-secret"; + let token = auth::create_jwt("user-123", "testuser", secret, 24).unwrap(); + + let claims = auth::validate_jwt(&token, secret).unwrap(); + assert_eq!(claims.user_id, "user-123"); + assert_eq!(claims.username, "testuser"); +} + +#[test] +fn jwt_with_wrong_secret_fails() { + let token = auth::create_jwt("user-123", "testuser", "secret-a", 24).unwrap(); + let result = auth::validate_jwt(&token, "secret-b"); + assert!(result.is_err()); +} + +#[test] +fn jwt_with_expired_token_fails() { + let secret = "test-jwt-secret"; + let token = auth::create_jwt("user-123", "testuser", secret, -1).unwrap(); + + let result = auth::validate_jwt(&token, secret); + assert!(result.is_err()); +} + +#[tokio::test] +async fn rate_limiter_allows_under_limit() { + let limiter = auth::RateLimiter::new(); + let ip = "192.168.1.1"; + + for _ in 0..10 { + let result = limiter.check(ip).await; + assert!(result.is_ok()); + } +} + +#[tokio::test] +async fn rate_limiter_blocks_over_limit() { + let limiter = auth::RateLimiter::new(); + let ip = "10.0.0.1"; + + for _ in 0..10 { + limiter.check(ip).await.unwrap(); + } + + let result = limiter.check(ip).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn rate_limiter_tracks_ips_independently() { + let limiter = auth::RateLimiter::new(); + + for _ in 0..10 { + limiter.check("1.1.1.1").await.unwrap(); + } + + let result_blocked = limiter.check("1.1.1.1").await; + assert!(result_blocked.is_err()); + + let result_other = limiter.check("2.2.2.2").await; + assert!(result_other.is_ok()); +} diff --git a/backend/tests/static_binary_test.sh b/backend/tests/static_binary_test.sh new file mode 100755 index 0000000..11931c4 --- /dev/null +++ b/backend/tests/static_binary_test.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$SCRIPT_DIR" + +echo "Building release binary..." +cargo build --release + +BINARY="target/release/streamx" + +if [ ! -f "$BINARY" ]; then + echo "FAIL: Binary not found at $BINARY" + exit 1 +fi + +echo "Binary exists at $BINARY" + +file_output=$(file "$BINARY") +echo "file output: $file_output" + +if echo "$file_output" | grep -q "statically linked"; then + echo "PASS: Binary is statically linked" +elif ldd "$BINARY" 2>&1 | grep -q "not a dynamic executable"; then + echo "PASS: ldd confirms binary is static" +else + echo "NOTE: Binary is dynamically linked (expected for non-musl builds)" + echo " For a static binary, build with: cargo build --release --target x86_64-unknown-linux-musl" +fi + +echo "Verifying binary runs..." +if "$BINARY" --help > /dev/null 2>&1; then + echo "PASS: Binary executes successfully (--help exits 0)" +else + echo "FAIL: Binary failed to execute" + exit 1 +fi + +echo "All checks passed." diff --git a/backend/tests/stream_lifecycle_tests.rs b/backend/tests/stream_lifecycle_tests.rs new file mode 100644 index 0000000..27ad3f2 --- /dev/null +++ b/backend/tests/stream_lifecycle_tests.rs @@ -0,0 +1,592 @@ +use reqwest::{Client, StatusCode}; +use serde_json::Value; +use std::net::SocketAddr; + +// --------------------------------------------------------------------------- +// Test-server helpers (same pattern as api_tests.rs) +// --------------------------------------------------------------------------- + +struct TestServer { + base_url: String, + _tmp: tempfile::TempDir, +} + +async fn start_test_server() -> TestServer { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().to_path_buf(); + + std::fs::create_dir_all(data_dir.join("downloads")).unwrap(); + std::fs::create_dir_all(data_dir.join("cache")).unwrap(); + + let port = portpicker::pick_unused_port().unwrap(); + + let config = streamx::config::AppConfig { + server: streamx::config::ServerConfig { + port, + bind: "127.0.0.1".to_string(), + open_browser: false, + }, + torrent: streamx::config::TorrentConfig { + max_connections: 200, + sequential: true, + seed_after_complete: true, + dht: true, + pex: true, + }, + transcode: streamx::config::TranscodeConfig { + hls_segment_duration: 4, + video_codec: "h264".to_string(), + audio_codec: "aac".to_string(), + preset: "ultrafast".to_string(), + max_concurrent_transcodes: 2, + crf: 23, + max_bitrate: None, + audio_bitrate: "192k".to_string(), + threads: None, + }, + auth: streamx::config::AuthConfig { + jwt_secret: "test-secret-key-for-integration-tests".to_string(), + session_duration: "7d".to_string(), + }, + ui: streamx::config::UiConfig { + default_theme: "dark".to_string(), + }, + data_dir: data_dir.clone(), + log_level: "warn".to_string(), + open_browser: false, + admin_user: None, + admin_password: None, + }; + + let db_path = data_dir.join("streamx.db"); + let database = streamx::db::Database::open(&db_path).unwrap(); + database.init().await.unwrap(); + + database.set_downloading_to_paused().await.unwrap(); + let torrent_engine = + streamx::torrent::TorrentEngine::create(&config.torrent, &data_dir, database.clone()) + .await + .unwrap(); + let search_provider = streamx::torrent::SearchProvider::new(); + let cache_dir = data_dir.join("cache"); + let hls_pipeline = streamx::transcode::HlsManager::new(&config.transcode, cache_dir) + .await + .unwrap(); + + let app = streamx::server::build_router( + database, + config, + torrent_engine, + search_provider, + hls_pipeline, + ); + + let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + + tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + + TestServer { + base_url: format!("http://127.0.0.1:{port}"), + _tmp: tmp, + } +} + +async fn get_token(base_url: &str, username: &str, password: &str) -> String { + let client = Client::new(); + let resp = client + .post(format!("{base_url}/api/auth/register")) + .json(&serde_json::json!({ + "username": username, + "password": password, + })) + .send() + .await + .unwrap(); + let body: Value = resp.json().await.unwrap(); + body["token"].as_str().unwrap().to_string() +} + +/// Build a reqwest client that does NOT follow redirects, so we can inspect +/// the 307 status directly. +fn no_redirect_client() -> Client { + Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap() +} + +const MAGNET_BBB: &str = + "magnet:?xt=urn:btih:dd8255ecdc7ca55fb0bbf81323d87062db1f6d1c&dn=Big+Buck+Bunny"; + +// --------------------------------------------------------------------------- +// 1. Basic stream lifecycle +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn basic_stream_lifecycle() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "lifecycle_user", "password123").await; + + let client = Client::new(); + + // POST /api/search to find "sintel" + let search_resp = client + .post(format!("{}/api/search", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "query": "sintel" })) + .send() + .await + .unwrap(); + assert_eq!(search_resp.status(), StatusCode::OK); + let search_body: Value = search_resp.json().await.unwrap(); + assert!(search_body["results"].is_array()); + + // POST /api/stream with a magnet URI to start a stream + let create_resp = client + .post(format!("{}/api/stream", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "magnet_uri": MAGNET_BBB })) + .send() + .await + .unwrap(); + assert_eq!(create_resp.status(), StatusCode::OK); + let create_body: Value = create_resp.json().await.unwrap(); + let stream_id = create_body["stream_id"].as_str().unwrap(); + assert!(!stream_id.is_empty()); + assert_eq!(create_body["status"], "initializing"); + + // GET /api/stream/:id returns status + let status_resp = client + .get(format!("{}/api/stream/{stream_id}", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(status_resp.status(), StatusCode::OK); + let status_body: Value = status_resp.json().await.unwrap(); + assert_eq!(status_body["id"], stream_id); + + // DELETE /api/stream/:id cleans up HLS + let delete_resp = client + .delete(format!("{}/api/stream/{stream_id}", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(delete_resp.status(), StatusCode::OK); + let delete_body: Value = delete_resp.json().await.unwrap(); + assert_eq!(delete_body["status"], "stopped"); + + // GET /api/stream/:id still returns the download (DB persists it) + let still_resp = client + .get(format!("{}/api/stream/{stream_id}", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(still_resp.status(), StatusCode::OK); +} + +// --------------------------------------------------------------------------- +// 2. Demo stream always works +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn demo_stream_always_works() { + let server = start_test_server().await; + + let client = Client::new(); + + // GET /api/stream/demo returns ready status with 100% progress + let demo_resp = client + .get(format!("{}/api/stream/demo", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(demo_resp.status(), StatusCode::OK); + let demo_body: Value = demo_resp.json().await.unwrap(); + assert_eq!(demo_body["id"], "demo"); + assert_eq!(demo_body["status"], "ready"); + assert_eq!(demo_body["progress"], 100.0); + + // GET /api/stream/demo/playlist.m3u8 returns 307 redirect + let no_redir = no_redirect_client(); + let playlist_resp = no_redir + .get(format!("{}/api/stream/demo/playlist.m3u8", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(playlist_resp.status(), StatusCode::TEMPORARY_REDIRECT); + + // POST /api/test/stream creates demo stream + let create_demo = client + .post(format!("{}/api/test/stream", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(create_demo.status(), StatusCode::OK); + let demo_create_body: Value = create_demo.json().await.unwrap(); + assert_eq!(demo_create_body["stream_id"], "demo"); + assert_eq!(demo_create_body["status"], "ready"); +} + +// --------------------------------------------------------------------------- +// 3. Pause and resume +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn pause_and_resume_stream() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "pause_user", "password123").await; + + let client = Client::new(); + + // Start a stream + let create_resp = client + .post(format!("{}/api/stream", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "magnet_uri": MAGNET_BBB })) + .send() + .await + .unwrap(); + assert_eq!(create_resp.status(), StatusCode::OK); + let create_body: Value = create_resp.json().await.unwrap(); + let stream_id = create_body["stream_id"].as_str().unwrap(); + + // PUT /api/stream/:id/pause returns success + let pause_resp = client + .put(format!("{}/api/stream/{stream_id}/pause", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(pause_resp.status(), StatusCode::OK); + let pause_body: Value = pause_resp.json().await.unwrap(); + assert_eq!(pause_body["status"], "paused"); + + // GET /api/stream/:id still returns the stream (not deleted) + let status_resp = client + .get(format!("{}/api/stream/{stream_id}", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(status_resp.status(), StatusCode::OK); + let status_body: Value = status_resp.json().await.unwrap(); + assert_eq!(status_body["id"], stream_id); + + // PUT /api/stream/:id/resume returns success + let resume_resp = client + .put(format!("{}/api/stream/{stream_id}/resume", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(resume_resp.status(), StatusCode::OK); + let resume_body: Value = resume_resp.json().await.unwrap(); + assert_eq!(resume_body["status"], "resumed"); + + // PUT /api/stream/:id/resume again (idempotent, should not error) + let resume_again = client + .put(format!("{}/api/stream/{stream_id}/resume", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(resume_again.status(), StatusCode::OK); + + // PUT /api/stream/:id/pause again (idempotent, should not error) + let pause_again = client + .put(format!("{}/api/stream/{stream_id}/pause", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(pause_again.status(), StatusCode::OK); +} + +// --------------------------------------------------------------------------- +// 4. Pause non-existent stream is a no-op (returns 200) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn pause_nonexistent_stream_returns_ok() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "nostream_user", "password123").await; + + let client = Client::new(); + + let pause_resp = client + .put(format!( + "{}/api/stream/fake-id-12345/pause", + server.base_url + )) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(pause_resp.status(), StatusCode::OK); + + let resume_resp = client + .put(format!( + "{}/api/stream/fake-id-12345/resume", + server.base_url + )) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(resume_resp.status(), StatusCode::OK); +} + +// --------------------------------------------------------------------------- +// 5. Double start same magnet +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn double_start_same_magnet() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "double_user", "password123").await; + + let client = Client::new(); + + let resp1 = client + .post(format!("{}/api/stream", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "magnet_uri": MAGNET_BBB })) + .send() + .await + .unwrap(); + assert_eq!(resp1.status(), StatusCode::OK); + let body1: Value = resp1.json().await.unwrap(); + let id1 = body1["stream_id"].as_str().unwrap().to_string(); + + let resp2 = client + .post(format!("{}/api/stream", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "magnet_uri": MAGNET_BBB })) + .send() + .await + .unwrap(); + assert_eq!(resp2.status(), StatusCode::OK); + let body2: Value = resp2.json().await.unwrap(); + let id2 = body2["stream_id"].as_str().unwrap().to_string(); + + // Both should succeed with the same stream ID (same info_hash, DB deduplicates) + assert!(!id1.is_empty()); + assert!(!id2.is_empty()); + assert_eq!(id1, id2); +} + +// --------------------------------------------------------------------------- +// 6. Stream without auth returns 401 +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn stream_without_auth_returns_401() { + let server = start_test_server().await; + + let client = Client::new(); + + // POST /api/stream without token + let create_resp = client + .post(format!("{}/api/stream", server.base_url)) + .json(&serde_json::json!({ "magnet_uri": MAGNET_BBB })) + .send() + .await + .unwrap(); + assert_eq!(create_resp.status(), StatusCode::UNAUTHORIZED); + + // GET /api/stream/:id without token + let get_resp = client + .get(format!("{}/api/stream/some-fake-id", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(get_resp.status(), StatusCode::UNAUTHORIZED); + + // PUT /api/stream/:id/pause without token + let pause_resp = client + .put(format!("{}/api/stream/some-fake-id/pause", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(pause_resp.status(), StatusCode::UNAUTHORIZED); +} + +// --------------------------------------------------------------------------- +// 7. Test endpoints don't require auth +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_endpoints_no_auth_required() { + let server = start_test_server().await; + + let no_redir = no_redirect_client(); + + // GET /api/test/video returns 307 (no auth needed) + let video_resp = no_redir + .get(format!("{}/api/test/video", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(video_resp.status(), StatusCode::TEMPORARY_REDIRECT); + + // GET /api/test/playlist.m3u8 returns 307 (no auth needed) + let playlist_resp = no_redir + .get(format!("{}/api/test/playlist.m3u8", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(playlist_resp.status(), StatusCode::TEMPORARY_REDIRECT); + + // GET /api/stream/demo returns 200 (no auth needed) + let demo_resp = no_redir + .get(format!("{}/api/stream/demo", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(demo_resp.status(), StatusCode::OK); + let demo_body: Value = demo_resp.json().await.unwrap(); + assert_eq!(demo_body["status"], "ready"); +} + +// --------------------------------------------------------------------------- +// 8. Stream playlist returns valid HLS +// Note: for a freshly started torrent there is no actual file to +// transcode, so the HLS pipeline may not yet have a playlist. We test +// the demo playlist redirect instead, which always works. +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn stream_playlist_returns_valid_hls() { + let server = start_test_server().await; + + // Use the default redirect-following client to fetch the demo HLS + // playlist through the external URL the server redirects to. + let client = Client::new(); + + let resp = client + .get(format!("{}/api/test/playlist.m3u8", server.base_url)) + .send() + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let content_type = resp + .headers() + .get("content-type") + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!( + content_type.contains("mpegurl"), + "Expected mpegurl content-type, got: {content_type}" + ); + + let body = resp.text().await.unwrap(); + assert!(body.contains("#EXTM3U"), "Playlist must start with #EXTM3U"); +} + +// --------------------------------------------------------------------------- +// 9. History is recorded on stream start +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn history_recorded_on_stream_start() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "hist_user", "password123").await; + + let client = Client::new(); + + // Start a stream + let create_resp = client + .post(format!("{}/api/stream", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "magnet_uri": MAGNET_BBB })) + .send() + .await + .unwrap(); + assert_eq!(create_resp.status(), StatusCode::OK); + + // GET /api/history returns at least 1 item + let hist_resp = client + .get(format!("{}/api/history", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(hist_resp.status(), StatusCode::OK); + let hist_body: Value = hist_resp.json().await.unwrap(); + let items = hist_body["items"].as_array().unwrap(); + assert!( + !items.is_empty(), + "History should have at least 1 item after starting a stream" + ); +} + +// --------------------------------------------------------------------------- +// 10. Settings persist +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn settings_persist() { + let server = start_test_server().await; + let token = get_token(&server.base_url, "settings_user", "password123").await; + + let client = Client::new(); + + // PUT /api/settings with theme "light" + let update1 = client + .put(format!("{}/api/settings", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "theme": "light" })) + .send() + .await + .unwrap(); + assert_eq!(update1.status(), StatusCode::OK); + + // GET /api/settings returns "light" + let get1 = client + .get(format!("{}/api/settings", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(get1.status(), StatusCode::OK); + let body1: Value = get1.json().await.unwrap(); + assert_eq!(body1["theme"], "light"); + + // PUT /api/settings with theme "dark" + let update2 = client + .put(format!("{}/api/settings", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&serde_json::json!({ "theme": "dark" })) + .send() + .await + .unwrap(); + assert_eq!(update2.status(), StatusCode::OK); + + // GET /api/settings returns "dark" + let get2 = client + .get(format!("{}/api/settings", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(get2.status(), StatusCode::OK); + let body2: Value = get2.json().await.unwrap(); + assert_eq!(body2["theme"], "dark"); +} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..cc4ad18 --- /dev/null +++ b/clippy.toml @@ -0,0 +1 @@ +msrv = "1.75" diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..8665cc3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1773821835, + "narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1773889863, + "narHash": "sha256-tSsmZOHBgq4qfu5MNCAEsKZL1cI4avNLw2oUTXWeb74=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "dbfd51be2692cb7022e301d14c139accb4ee63f0", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..0f1d7df --- /dev/null +++ b/flake.nix @@ -0,0 +1,51 @@ +{ + description = "StreamX - Torrent Video Streaming Player"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + rust-overlay.url = "github:oxalica/rust-overlay"; + rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + # For faster CI/dev builds, consider using a binary cache: + # - Cachix: `cachix use ` after `cachix create ` + # then add `cachix push ` to your CI pipeline + # - Self-hosted nix binary cache: configure `nix.settings.substituters` + # and `nix.settings.trusted-public-keys` in your NixOS/nix config + # - GitHub Actions cache: use DeterminateSystems/magic-nix-cache-action + + outputs = { self, nixpkgs, rust-overlay, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ (import rust-overlay) ]; + pkgs = import nixpkgs { inherit system overlays; }; + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ]; + targets = [ "x86_64-unknown-linux-musl" ]; + }; + in + { + devShells.default = pkgs.mkShell { + nativeBuildInputs = with pkgs; [ + pkg-config + ]; + + buildInputs = with pkgs; [ + rustToolchain + openssl + openssl.dev + pnpm + nodejs_22 + imagemagick + sqlite + ]; + + shellHook = '' + export RUST_LOG=info + export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH" + ''; + }; + } + ); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d4cb081 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy", "rust-analyzer"] +targets = ["x86_64-unknown-linux-musl"] diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..3a26366 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = "2021" diff --git a/scripts/generate-logo.sh b/scripts/generate-logo.sh new file mode 100755 index 0000000..40c0ad3 --- /dev/null +++ b/scripts/generate-logo.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# +# generate-logo.sh - Generate PNG icons and favicon.ico from the SVG logo. +# Requires ImageMagick (convert / magick). +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ICONS_DIR="$PROJECT_ROOT/ui/public/icons" +SVG_LOGO="$ICONS_DIR/logo.svg" +SVG_FAVICON="$ICONS_DIR/favicon.svg" + +# Detect ImageMagick command (v7 uses "magick", v6 uses "convert") +if command -v magick &>/dev/null; then + CONVERT="magick" +elif command -v convert &>/dev/null; then + CONVERT="convert" +else + echo "Error: ImageMagick is not installed. Please install it first." >&2 + exit 1 +fi + +echo "Using ImageMagick command: $CONVERT" +echo "Output directory: $ICONS_DIR" +echo "" + +# Generate PNG icons at various sizes +SIZES=(16 32 48 64 128 256 512) +for size in "${SIZES[@]}"; do + echo "Generating icon-${size}.png ..." + $CONVERT -background none -density 384 "$SVG_LOGO" -resize "${size}x${size}" \ + "$ICONS_DIR/icon-${size}.png" +done + +# Generate favicon.ico (multi-size: 16, 32, 48) +echo "Generating favicon.ico ..." +$CONVERT "$ICONS_DIR/icon-16.png" "$ICONS_DIR/icon-32.png" "$ICONS_DIR/icon-48.png" \ + "$ICONS_DIR/favicon.ico" + +# Generate apple-touch-icon.png (180x180 with opaque dark background) +echo "Generating apple-touch-icon.png (180x180 with #0a0a0a background) ..." +$CONVERT -background "#0a0a0a" -density 384 "$SVG_LOGO" -resize 180x180 \ + -gravity center -extent 180x180 \ + "$ICONS_DIR/apple-touch-icon.png" + +echo "" +echo "Done! Generated files:" +ls -lh "$ICONS_DIR" diff --git a/streamx.default.toml b/streamx.default.toml new file mode 100644 index 0000000..2760dff --- /dev/null +++ b/streamx.default.toml @@ -0,0 +1,25 @@ +[server] +port = 8999 +bind = "127.0.0.1" +open_browser = true + +[torrent] +download_dir = "~/.streamx/downloads" +max_connections = 200 +sequential = true +seed_after_complete = true +dht = true +pex = true + +[transcode] +hls_segment_duration = 4 +video_codec = "h264" +audio_codec = "aac" +preset = "ultrafast" + +[auth] +jwt_secret = "" +session_duration = "7d" + +[ui] +default_theme = "dark" diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..c688a68 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,27 @@ + + + + + + + + + + + + + StreamX + + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..5fd19c3 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,39 @@ +{ + "name": "streamx-ui", + "private": true, + "version": "0.2.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest", + "test:e2e": "playwright test", + "lint": "eslint src --ext ts,tsx", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.0.0", + "@radix-ui/themes": "^3.0.0", + "@radix-ui/react-icons": "^1.3.0", + "video.js": "^8.0.0", + "hls.js": "^1.5.0", + "framer-motion": "^12.0.0", + "three": "^0.172.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/video.js": "^7.3.0", + "@vitejs/plugin-react": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "@playwright/test": "^1.49.0", + "vitest": "^3.0.0", + "@testing-library/react": "^16.0.0", + "@types/three": "^0.172.0", + "jsdom": "^25.0.0" + } +} diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 0000000..779fb16 --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "@playwright/test"; + +const port = Number(process.env.STREAMX_TEST_PORT) || 9876; +const tmpDir = `/tmp/streamx-test-${Date.now()}`; + +export default defineConfig({ + testDir: "./tests", + timeout: 30000, + retries: 0, + workers: 1, + use: { + baseURL: `http://localhost:${port}`, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], + webServer: { + command: `cd ../backend && cargo run -- --port ${port} --data-dir ${tmpDir} --admin-user admin --admin-password password`, + port, + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml new file mode 100644 index 0000000..50f44ae --- /dev/null +++ b/ui/pnpm-lock.yaml @@ -0,0 +1,4116 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-icons': + specifier: ^1.3.0 + version: 1.3.2(react@19.2.4) + '@radix-ui/themes': + specifier: ^3.0.0 + version: 3.3.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + framer-motion: + specifier: ^12.0.0 + version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + hls.js: + specifier: ^1.5.0 + version: 1.6.15 + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + react-router-dom: + specifier: ^7.0.0 + version: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + three: + specifier: ^0.172.0 + version: 0.172.0 + video.js: + specifier: ^8.0.0 + version: 8.23.7 + devDependencies: + '@playwright/test': + specifier: ^1.49.0 + version: 1.58.2 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + '@types/three': + specifier: ^0.172.0 + version: 0.172.0 + '@types/video.js': + specifier: ^7.3.0 + version: 7.3.58 + '@vitejs/plugin-react': + specifier: ^4.0.0 + version: 4.7.0(vite@6.4.1) + jsdom: + specifier: ^25.0.0 + version: 25.0.1 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.1 + vitest: + specifier: ^3.0.0 + version: 3.2.4(jsdom@25.0.1) + +packages: + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + + '@radix-ui/colors@3.0.0': + resolution: {integrity: sha512-FUOsGBkHrYJwCSEtWRCIfQbZG7q1e6DgxCIOe1SUQzDe/7rXXeA47s8yCn6fuTNQAj1Zq4oTFi9Yjp3wzElcxg==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.8': + resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-icons@1.3.2': + resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==} + peerDependencies: + react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.8': + resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.3': + resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@radix-ui/themes@3.3.0': + resolution: {integrity: sha512-I0/h2CRNTpYNB7Mi3xFIvSsQq5a108d7kK8dTO5zp5b9HR5QJXKag6B8tjpz2ITkVYkFdkGk45doNkSr7OxwNw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: 16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: 16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.172.0': + resolution: {integrity: sha512-LrUtP3FEG26Zg5WiF0nbg8VoXiKokBLTcqM2iLvM9vzcfEiYmmBAPGdBgV0OYx9fvWlY3R/3ERTZcD9X5sc0NA==} + + '@types/video.js@7.3.58': + resolution: {integrity: sha512-1CQjuSrgbv1/dhmcfQ83eVyYbvGyqhTvb2Opxr0QCV+iJ4J6/J+XWQ3Om59WiwCd1MN3rDUHasx5XRrpUtewYQ==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + + '@videojs/http-streaming@3.17.4': + resolution: {integrity: sha512-XAvdG2dolBuV2Fx8bu1kjmQ2D4TonGzZH68Pgv/O9xMSFWdZtITSMFismeQLEAtMmGwze8qNJp3RgV+jStrJqg==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + video.js: ^8.19.0 + + '@videojs/vhs-utils@4.1.1': + resolution: {integrity: sha512-5iLX6sR2ownbv4Mtejw6Ax+naosGvoT9kY+gcuHzANyUZZ+4NpeNdKMUhb6ag0acYej1Y7cmr/F2+4PrggMiVA==} + engines: {node: '>=8', npm: '>=5'} + + '@videojs/xhr@2.7.0': + resolution: {integrity: sha512-giab+EVRanChIupZK7gXjHy90y3nncA2phIOyG3Ne5fvpiMJzvqYwiTOnEVW2S4CoYcuKJkomat7bMXA/UoUZQ==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + '@webgpu/types@0.1.69': + resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + + '@xmldom/xmldom@0.8.11': + resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + engines: {node: '>=10.0.0'} + + aes-decrypter@4.0.2: + resolution: {integrity: sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + baseline-browser-mapping@2.10.9: + resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001780: + resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-walk@0.1.2: + resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.321: + resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + global@4.4.0: + resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hls.js@1.6.15: + resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + is-function@1.0.2: + resolution: {integrity: sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + m3u8-parser@7.2.0: + resolution: {integrity: sha512-CRatFqpjVtMiMaKXxNvuI3I++vUumIXVVT/JpCpdU/FynV/ceVw1qpPyyBNindL+JlPMSesx+WX1QJaZEJSaMQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + meshoptimizer@0.18.1: + resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-document@2.19.2: + resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==} + + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + + mpd-parser@1.3.1: + resolution: {integrity: sha512-1FuyEWI5k2HcmhS1HkKnUAQV7yFPfXPht2DnRRGtoiiAAW+ESTbtEXIDpRkwdU+XyrQuwrIym7UkoPKsZ0SyFw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mux.js@7.1.0: + resolution: {integrity: sha512-NTxawK/BBELJrYsZThEulyUMDVlLizKdxyAsMuzoCD1eFj97BVaA8D/CvKsKu6FOLYkFojN5CbM9h++ZTZtknA==} + engines: {node: '>=8', npm: '>=5'} + hasBin: true + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkcs7@1.0.4: + resolution: {integrity: sha512-afRERtHn54AlwaF2/+LFszyAANTCggGilmcmILUzEjvs3XgFZT+xE6+QWQcAGmu4xajy+Xtj7acLOPdx5/eXWQ==} + hasBin: true + + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + radix-ui@1.4.3: + resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.13.1: + resolution: {integrity: sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.13.1: + resolution: {integrity: sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + three@0.172.0: + resolution: {integrity: sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + video.js@8.23.7: + resolution: {integrity: sha512-cG4HOygYt+Z8j6Sf5DuK6OgEOoM+g9oGP6vpqoZRaD13aHE4PMITbyjJUXZcIQbgB0wJEadBRaVm5lJIzo2jAA==} + + videojs-contrib-quality-levels@4.1.0: + resolution: {integrity: sha512-TfrXJJg1Bv4t6TOCMEVMwF/CoS8iENYsWNKip8zfhB5kTcegiFYezEA0eHAJPU64ZC8NQbxQgOwAsYU8VXbOWA==} + engines: {node: '>=16', npm: '>=8'} + peerDependencies: + video.js: ^8 + + videojs-font@4.2.0: + resolution: {integrity: sha512-YPq+wiKoGy2/M7ccjmlvwi58z2xsykkkfNMyIg4xb7EZQQNwB71hcSsB3o75CqQV7/y5lXkXhI/rsGAS7jfEmQ==} + + videojs-vtt.js@0.15.5: + resolution: {integrity: sha512-yZbBxvA7QMYn15Lr/ZfhhLPrNpI/RmCSCqgIff57GC2gIrV5YfyzLfLyZMj0NnZSAz8syB4N0nHXpZg9MyrMOQ==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.11': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + + '@radix-ui/colors@3.0.0': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-icons@1.3.2(react@19.2.4)': + dependencies: + react: 19.2.4 + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@radix-ui/themes@3.3.0(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/colors': 3.0.0 + classnames: 2.5.1 + radix-ui: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@tweenjs/tween.js@23.1.3': {} + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/stats.js@0.17.4': {} + + '@types/three@0.172.0': + dependencies: + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + '@webgpu/types': 0.1.69 + fflate: 0.8.2 + meshoptimizer: 0.18.1 + + '@types/video.js@7.3.58': {} + + '@types/webxr@0.5.24': {} + + '@videojs/http-streaming@3.17.4(video.js@8.23.7)': + dependencies: + '@babel/runtime': 7.29.2 + '@videojs/vhs-utils': 4.1.1 + aes-decrypter: 4.0.2 + global: 4.4.0 + m3u8-parser: 7.2.0 + mpd-parser: 1.3.1 + mux.js: 7.1.0 + video.js: 8.23.7 + + '@videojs/vhs-utils@4.1.1': + dependencies: + '@babel/runtime': 7.29.2 + global: 4.4.0 + + '@videojs/xhr@2.7.0': + dependencies: + '@babel/runtime': 7.29.2 + global: 4.4.0 + is-function: 1.0.2 + + '@vitejs/plugin-react@4.7.0(vite@6.4.1)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@6.4.1)': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1 + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@webgpu/types@0.1.69': {} + + '@xmldom/xmldom@0.8.11': {} + + aes-decrypter@4.0.2: + dependencies: + '@babel/runtime': 7.29.2 + '@videojs/vhs-utils': 4.1.1 + global: 4.4.0 + pkcs7: 1.0.4 + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + baseline-browser-mapping@2.10.9: {} + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.9 + caniuse-lite: 1.0.30001780 + electron-to-chromium: 1.5.321 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + caniuse-lite@1.0.30001780: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + classnames@2.5.1: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-node-es@1.1.0: {} + + dom-accessibility-api@0.5.16: {} + + dom-walk@0.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.321: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fflate@0.8.2: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + global@4.4.0: + dependencies: + min-document: 2.19.2 + process: 0.11.10 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hls.js@1.6.15: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + is-function@1.0.2: {} + + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + + js-tokens@9.0.1: {} + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + m3u8-parser@7.2.0: + dependencies: + '@babel/runtime': 7.29.2 + '@videojs/vhs-utils': 4.1.1 + global: 4.4.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + meshoptimizer@0.18.1: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-document@2.19.2: + dependencies: + dom-walk: 0.1.2 + + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + + mpd-parser@1.3.1: + dependencies: + '@babel/runtime': 7.29.2 + '@videojs/vhs-utils': 4.1.1 + '@xmldom/xmldom': 0.8.11 + global: 4.4.0 + + ms@2.1.3: {} + + mux.js@7.1.0: + dependencies: + '@babel/runtime': 7.29.2 + global: 4.4.0 + + nanoid@3.3.11: {} + + node-releases@2.0.36: {} + + nwsapi@2.2.23: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pkcs7@1.0.4: + dependencies: + '@babel/runtime': 7.29.2 + + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + process@0.11.10: {} + + punycode@2.3.1: {} + + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-refresh@0.17.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-router-dom@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-router: 7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + + react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + cookie: 1.1.1 + react: 19.2.4 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react@19.2.4: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + symbol-tree@3.2.4: {} + + three@0.172.0: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tslib@2.8.1: {} + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + video.js@8.23.7: + dependencies: + '@babel/runtime': 7.29.2 + '@videojs/http-streaming': 3.17.4(video.js@8.23.7) + '@videojs/vhs-utils': 4.1.1 + '@videojs/xhr': 2.7.0 + aes-decrypter: 4.0.2 + global: 4.4.0 + m3u8-parser: 7.2.0 + mpd-parser: 1.3.1 + mux.js: 7.1.0 + videojs-contrib-quality-levels: 4.1.0(video.js@8.23.7) + videojs-font: 4.2.0 + videojs-vtt.js: 0.15.5 + + videojs-contrib-quality-levels@4.1.0(video.js@8.23.7): + dependencies: + global: 4.4.0 + video.js: 8.23.7 + + videojs-font@4.2.0: {} + + videojs-vtt.js@0.15.5: + dependencies: + global: 4.4.0 + + vite-node@3.2.4: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1 + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.4.1: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + + vitest@3.2.4(jsdom@25.0.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1 + vite-node: 3.2.4 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 25.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.19.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} diff --git a/ui/public/icons/favicon.svg b/ui/public/icons/favicon.svg new file mode 100644 index 0000000..9389732 --- /dev/null +++ b/ui/public/icons/favicon.svg @@ -0,0 +1,28 @@ + + + SX + SX + diff --git a/ui/public/icons/logo.svg b/ui/public/icons/logo.svg new file mode 100644 index 0000000..cfc011b --- /dev/null +++ b/ui/public/icons/logo.svg @@ -0,0 +1,28 @@ + + + SX + SX + diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..3a4d5b4 --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,63 @@ +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { Theme } from "@radix-ui/themes"; +import { AuthProvider, useAuth } from "./hooks/useAuth"; +import { useTheme } from "./hooks/useTheme"; +import { Layout } from "./components/Layout"; +import { Login } from "./pages/Login"; +import { Search } from "./pages/Search"; +import { Player } from "./pages/Player"; +import { History } from "./pages/History"; +import { Settings } from "./pages/Settings"; +import type { ReactNode } from "react"; + +function RequireAuth({ children }: { children: ReactNode }) { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) return null; + if (!isAuthenticated) return ; + + return <>{children}; +} + +function AppRoutes({ + theme, + setTheme, + toggleTheme, +}: { + theme: "dark" | "light"; + setTheme: (t: "dark" | "light") => void; + toggleTheme: () => void; +}) { + return ( + + } /> + + + + } + > + } /> + } /> + } /> + } /> + + } /> + + ); +} + +export function App() { + const { theme, setTheme, toggleTheme } = useTheme(); + + return ( + + + + + + + + ); +} diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts new file mode 100644 index 0000000..ffe8867 --- /dev/null +++ b/ui/src/api/client.ts @@ -0,0 +1,186 @@ +import { getToken } from "../lib/auth"; +import { debugLog } from "../lib/debug-log"; +import type { + ApiError, + LoginRequest, + LoginResponse, + RegisterRequest, + SearchRequest, + SearchResponse, + SearchHistoryResponse, + Settings, + StreamRequest, + StreamResponse, + StreamStatus, + User, + WatchHistoryResponse, +} from "./types"; + +class ApiClient { + private async request( + path: string, + options: RequestInit = {} + ): Promise { + const headers = new Headers(options.headers); + + if (!headers.has("Content-Type") && options.body) { + headers.set("Content-Type", "application/json"); + } + + const token = getToken(); + if (token) { + headers.set("Authorization", `Bearer ${token}`); + } + + debugLog.debug("API", "Request", { + method: options.method ?? "GET", + url: path, + }); + + const response = await fetch(path, { + ...options, + headers, + }); + + if (!response.ok) { + let errorData: ApiError; + try { + errorData = (await response.json()) as ApiError; + } catch { + errorData = { + error: "request_failed", + message: `Request failed with status ${response.status}`, + }; + } + debugLog.error("API", "Request failed", { + url: path, + status: response.status, + error: errorData, + }); + throw new ApiRequestError( + errorData.message || errorData.error, + response.status, + errorData + ); + } + + debugLog.debug("API", "Response", { + status: response.status, + url: path, + }); + + if (response.status === 204) { + return undefined as T; + } + + return response.json() as Promise; + } + + async login(data: LoginRequest): Promise { + return this.request("/api/auth/login", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async register(data: RegisterRequest): Promise { + return this.request("/api/auth/register", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async me(): Promise { + return this.request("/api/auth/me"); + } + + async search(data: SearchRequest): Promise { + return this.request("/api/search", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async searchHistory(): Promise { + return this.request("/api/search/history"); + } + + async startStream(data: StreamRequest): Promise { + return this.request("/api/stream", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async streamStatus(streamId: string): Promise { + return this.request(`/api/stream/${streamId}`); + } + + async stopStream(streamId: string): Promise { + return this.request(`/api/stream/${streamId}`, { + method: "DELETE", + }); + } + + async pauseStream(streamId: string): Promise { + await this.request(`/api/stream/${streamId}/pause`, { method: "PUT" }); + } + + async resumeStream(streamId: string): Promise { + await this.request(`/api/stream/${streamId}/resume`, { method: "PUT" }); + } + + async watchHistory(): Promise { + return this.request("/api/history"); + } + + async updateWatchPosition( + id: string, + watchedSeconds: number + ): Promise { + return this.request(`/api/history/${id}`, { + method: "PUT", + body: JSON.stringify({ watched_seconds: watchedSeconds }), + }); + } + + async deleteHistoryItem(id: string): Promise { + return this.request(`/api/history/${id}`, { + method: "DELETE", + }); + } + + async getSettings(): Promise { + return this.request("/api/settings"); + } + + async updateSettings(data: Settings): Promise { + return this.request("/api/settings", { + method: "PUT", + body: JSON.stringify(data), + }); + } + + getPlaylistUrl(streamId: string): string { + const token = getToken(); + const base = `/api/stream/${streamId}/playlist.m3u8`; + return token ? `${base}?token=${encodeURIComponent(token)}` : base; + } + + getFileUrl(streamId: string): string { + return `/api/stream/${streamId}/file`; + } +} + +export class ApiRequestError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly data: ApiError + ) { + super(message); + this.name = "ApiRequestError"; + } +} + +export const api = new ApiClient(); diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts new file mode 100644 index 0000000..d8fff25 --- /dev/null +++ b/ui/src/api/types.ts @@ -0,0 +1,113 @@ +export interface User { + id: string; + username: string; + is_admin: boolean; + created_at: string; +} + +export interface LoginRequest { + username: string; + password: string; +} + +export interface LoginResponse { + token: string; +} + +export interface RegisterRequest { + username: string; + password: string; +} + +export interface SearchRequest { + query: string; +} + +export interface SearchResult { + title: string; + magnet: string; + seeds: number; + leeches: number; + size: string; + size_bytes: number; + quality?: string; + year?: number; + rating?: number; + poster?: string; +} + +export interface SearchResponse { + results: SearchResult[]; +} + +export interface StreamRequest { + magnet_uri: string; + file_index?: number; +} + +export interface StreamFile { + index: number; + name: string; + size: number; +} + +export interface StreamResponse { + stream_id: string; + status: StreamStatusType; +} + +export type StreamStatusType = + | "initializing" + | "downloading" + | "transcoding" + | "ready" + | "complete" + | "paused" + | "error"; + +export interface StreamStatus { + status: StreamStatusType; + progress: number; + peers?: number; + speed?: number; + title?: string; + file_name?: string; + file_size?: number; + files?: StreamFile[]; + browser_compatible?: boolean; +} + +export interface WatchHistoryItem { + id: string; + magnet_uri: string; + title: string; + file_name: string | null; + duration_seconds: number | null; + watched_seconds: number | null; + poster_url: string | null; + watched_at: string; +} + +export interface Settings { + theme: "dark" | "light"; +} + +export interface ApiError { + error: string; + message: string; +} + +export interface SearchHistoryItem { + id: string; + query: string; + result_count: number | null; + searched_at: string; +} + +export interface SearchHistoryResponse { + searches: SearchHistoryItem[]; +} + +export interface WatchHistoryResponse { + items: WatchHistoryItem[]; +} diff --git a/ui/src/components/DebugPane.tsx b/ui/src/components/DebugPane.tsx new file mode 100644 index 0000000..874db63 --- /dev/null +++ b/ui/src/components/DebugPane.tsx @@ -0,0 +1,174 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { + Flex, + Text, + Button, + Badge, + ScrollArea, + Select, + Code, + IconButton, +} from "@radix-ui/themes"; +import { TrashIcon, Cross2Icon, ChevronUpIcon } from "@radix-ui/react-icons"; +import { debugLog } from "../lib/debug-log"; +import type { LogLevel, LogEntry } from "../lib/debug-log"; + +const LEVEL_COLORS: Record = { + debug: "gray", + info: "blue", + warn: "amber", + error: "red", +}; + +function LogLine({ entry }: { entry: LogEntry }) { + const time = new Date(entry.timestamp).toLocaleTimeString("en", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + return ( + + + {time} + + + {entry.level.charAt(0).toUpperCase()} + + + {entry.source} + + {entry.message} + {entry.data !== undefined && ( + + {typeof entry.data === "string" ? entry.data : JSON.stringify(entry.data)} + + )} + + ); +} + +interface DebugPaneProps { + onClose: () => void; +} + +export function DebugPane({ onClose }: DebugPaneProps) { + const [entries, setEntries] = useState([]); + const [filter, setFilter] = useState("all"); + const [expanded, setExpanded] = useState(true); + const scrollRef = useRef(null); + const autoScrollRef = useRef(true); + + const updateEntries = useCallback(() => { + setEntries(debugLog.getEntries()); + }, []); + + useEffect(() => { + updateEntries(); + return debugLog.subscribe(updateEntries); + }, [updateEntries]); + + useEffect(() => { + if (autoScrollRef.current && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [entries]); + + const handleScroll = () => { + if (!scrollRef.current) return; + const el = scrollRef.current; + autoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + }; + + const filtered = filter === "all" ? entries : entries.filter((e) => e.level === filter); + const errorCount = entries.filter((e) => e.level === "error").length; + + return ( +
+ setExpanded(!expanded)} + > + + + Debug + {entries.length} + {errorCount > 0 && ( + {errorCount} err + )} + + e.stopPropagation()}> + {expanded && ( + <> + setFilter(v as LogLevel | "all")} + > + + + All + Debug + Info + Warn + Error + + + + + + )} + + + + + + + {expanded && ( + +
+ {filtered.length === 0 ? ( + + No log entries + + ) : ( + filtered.map((entry, i) => ) + )} +
+
+ )} +
+ ); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx new file mode 100644 index 0000000..858bcc7 --- /dev/null +++ b/ui/src/components/Layout.tsx @@ -0,0 +1,176 @@ +import { Outlet, NavLink, useLocation } from "react-router-dom"; +import { + Box, + Flex, + Text, + Container, + DropdownMenu, + IconButton, + Badge, + Separator, +} from "@radix-ui/themes"; +import { + MagnifyingGlassIcon, + CounterClockwiseClockIcon, + SunIcon, + MoonIcon, + PersonIcon, + GearIcon, + ExitIcon, + CodeIcon, +} from "@radix-ui/react-icons"; +import { AnimatePresence, motion } from "framer-motion"; +import { useAuth } from "../hooks/useAuth"; +import { useDebug } from "../hooks/useDebug"; +import { DebugPane } from "./DebugPane"; + +interface LayoutProps { + theme: "dark" | "light"; + toggleTheme: () => void; +} + +const navLinkStyle = (isActive: boolean) => ({ + textDecoration: "none", + color: "inherit", + opacity: isActive ? 1 : 0.7, + fontWeight: isActive ? 600 : 400, +}); + +export function Layout({ theme, toggleTheme }: LayoutProps) { + const { user, logout } = useAuth(); + const { debug, setDebug } = useDebug(); + const location = useLocation(); + + return ( + + +
+ + + + + + StreamX + + StreamX + + + + + + + + navLinkStyle(isActive)} + > + + + Search + + + navLinkStyle(isActive)} + > + + + History + + + + + + + + {theme === "dark" ? : } + + + + + + + + + + + + {user?.username ?? "User"} + {user?.is_admin && ( + + Admin + + )} + + + + + + + Settings + + + setDebug(!debug)}> + + Debug Mode + {debug && ( + + ON + + )} + + + + + Logout + + + + + + +
+
+ + + + {location.pathname.startsWith("/player") ? ( + + ) : ( + + + + + + )} + + + + {debug && setDebug(false)} />} +
+ ); +} diff --git a/ui/src/components/NeonBackground.tsx b/ui/src/components/NeonBackground.tsx new file mode 100644 index 0000000..3307dc7 --- /dev/null +++ b/ui/src/components/NeonBackground.tsx @@ -0,0 +1,302 @@ +import { useEffect, useRef } from "react"; +import * as THREE from "three"; +import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js"; +import { RenderPass } from "three/addons/postprocessing/RenderPass.js"; +import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js"; + +const BEAM_COUNT = 20; +const ELECTRIC_BLUE = new THREE.Color(0x3b82f6); +const NEON_PURPLE = new THREE.Color(0x8b5cf6); +const DEEP_PURPLE = new THREE.Color(0x6d28d9); +const HOT_CYAN = new THREE.Color(0x22d3ee); + +const PALETTE = [ELECTRIC_BLUE, NEON_PURPLE, DEEP_PURPLE, HOT_CYAN] as const; + +interface LaserBeam { + mesh: THREE.Mesh; + speed: THREE.Vector3; + life: number; + maxLife: number; + pulseOffset: number; + pulseSpeed: number; + baseIntensity: number; + color: THREE.Color; +} + +interface Sparkle { + mesh: THREE.Mesh; + life: number; + maxLife: number; + speed: THREE.Vector3; +} + +function pickFromPalette(): THREE.Color { + const idx = Math.floor(Math.random() * PALETTE.length) % PALETTE.length; + return PALETTE[idx] ?? ELECTRIC_BLUE; +} + +function randomColor(): THREE.Color { + const a = pickFromPalette(); + const b = pickFromPalette(); + return a.clone().lerp(b, Math.random()); +} + +function createBeamGeometry(): THREE.TubeGeometry { + const startX = (Math.random() - 0.5) * 14; + const startY = (Math.random() - 0.5) * 10; + const startZ = -2 + Math.random() * -6; + + const midX = startX + (Math.random() - 0.5) * 8; + const midY = startY + (Math.random() - 0.5) * 6; + const midZ = startZ + (Math.random() - 0.5) * 3; + + const endX = midX + (Math.random() - 0.5) * 8; + const endY = midY + (Math.random() - 0.5) * 6; + const endZ = startZ + (Math.random() - 0.5) * 3; + + const curve = new THREE.CatmullRomCurve3([ + new THREE.Vector3(startX, startY, startZ), + new THREE.Vector3(midX, midY, midZ), + new THREE.Vector3(endX, endY, endZ), + ]); + + const radius = 0.01 + Math.random() * 0.04; + return new THREE.TubeGeometry(curve, 64, radius, 8, false); +} + +function spawnBeam(scene: THREE.Scene): LaserBeam { + const color = randomColor(); + const intensity = 2 + Math.random() * 4; + + const material = new THREE.MeshBasicMaterial({ + color: color, + transparent: true, + opacity: 0, + toneMapped: false, + }); + material.color.multiplyScalar(intensity); + + const geometry = createBeamGeometry(); + const mesh = new THREE.Mesh(geometry, material); + scene.add(mesh); + + const angle = Math.random() * Math.PI * 2; + const spd = 0.002 + Math.random() * 0.008; + + return { + mesh, + speed: new THREE.Vector3( + Math.cos(angle) * spd, + Math.sin(angle) * spd, + 0 + ), + life: 0, + maxLife: 300 + Math.random() * 500, + pulseOffset: Math.random() * Math.PI * 2, + pulseSpeed: 0.5 + Math.random() * 2, + baseIntensity: intensity, + color: color.clone(), + }; +} + +function spawnSparkle( + scene: THREE.Scene, + position: THREE.Vector3 +): Sparkle { + const geo = new THREE.SphereGeometry(0.015 + Math.random() * 0.02, 6, 6); + const color = randomColor(); + const mat = new THREE.MeshBasicMaterial({ + color: color.multiplyScalar(4), + transparent: true, + opacity: 1, + toneMapped: false, + }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.copy(position); + scene.add(mesh); + + return { + mesh, + life: 0, + maxLife: 30 + Math.random() * 40, + speed: new THREE.Vector3( + (Math.random() - 0.5) * 0.03, + (Math.random() - 0.5) * 0.03, + (Math.random() - 0.5) * 0.01 + ), + }; +} + +function disposeBeam(scene: THREE.Scene, beam: LaserBeam) { + scene.remove(beam.mesh); + beam.mesh.geometry.dispose(); + (beam.mesh.material as THREE.Material).dispose(); +} + +function disposeSparkle(scene: THREE.Scene, sparkle: Sparkle) { + scene.remove(sparkle.mesh); + sparkle.mesh.geometry.dispose(); + (sparkle.mesh.material as THREE.Material).dispose(); +} + +export function NeonBackground() { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x0a0a0a); + + const camera = new THREE.PerspectiveCamera( + 60, + window.innerWidth / window.innerHeight, + 0.1, + 100 + ); + camera.position.z = 5; + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(window.innerWidth, window.innerHeight); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.toneMapping = THREE.ACESFilmicToneMapping; + renderer.toneMappingExposure = 1.0; + container.appendChild(renderer.domElement); + + const composer = new EffectComposer(renderer); + const renderPass = new RenderPass(scene, camera); + composer.addPass(renderPass); + + const bloomPass = new UnrealBloomPass( + new THREE.Vector2(window.innerWidth, window.innerHeight), + 1.8, + 0.6, + 0.1 + ); + composer.addPass(bloomPass); + + const beams: LaserBeam[] = []; + const sparkles: Sparkle[] = []; + + for (let i = 0; i < BEAM_COUNT; i++) { + const beam = spawnBeam(scene); + beam.life = Math.random() * beam.maxLife * 0.8; + beams.push(beam); + } + + let frameId: number; + + const animate = () => { + frameId = requestAnimationFrame(animate); + + for (let i = beams.length - 1; i >= 0; i--) { + const beam = beams[i]!; + beam.life++; + + beam.mesh.position.x += beam.speed.x; + beam.mesh.position.y += beam.speed.y; + + const progress = beam.life / beam.maxLife; + const fadeIn = Math.min(progress * 5, 1); + const fadeOut = Math.max(1 - (progress - 0.7) / 0.3, 0); + const envelope = progress < 0.7 ? fadeIn : fadeIn * fadeOut; + + const pulse = + 0.6 + + 0.4 * + Math.sin(beam.life * 0.02 * beam.pulseSpeed + beam.pulseOffset); + + const mat = beam.mesh.material as THREE.MeshBasicMaterial; + mat.opacity = envelope * pulse; + mat.color + .copy(beam.color) + .multiplyScalar(beam.baseIntensity * pulse); + + if (Math.random() < 0.008 && envelope > 0.3) { + const tubeGeo = beam.mesh.geometry as THREE.TubeGeometry; + const params = tubeGeo.parameters; + if (params.path) { + const t = Math.random(); + const point = params.path.getPoint(t); + point.add(beam.mesh.position); + sparkles.push(spawnSparkle(scene, point)); + } + } + + if (beam.life >= beam.maxLife) { + disposeBeam(scene, beam); + beams.splice(i, 1); + + if (beams.length < BEAM_COUNT) { + beams.push(spawnBeam(scene)); + } + } + } + + for (let i = sparkles.length - 1; i >= 0; i--) { + const s = sparkles[i]!; + s.life++; + s.mesh.position.add(s.speed); + const sMat = s.mesh.material as THREE.MeshBasicMaterial; + sMat.opacity = 1 - s.life / s.maxLife; + s.mesh.scale.setScalar(1 - s.life / s.maxLife); + + if (s.life >= s.maxLife) { + disposeSparkle(scene, s); + sparkles.splice(i, 1); + } + } + + while (beams.length < BEAM_COUNT) { + beams.push(spawnBeam(scene)); + } + + composer.render(); + }; + + animate(); + + const handleResize = () => { + camera.aspect = window.innerWidth / window.innerHeight; + camera.updateProjectionMatrix(); + renderer.setSize(window.innerWidth, window.innerHeight); + composer.setSize(window.innerWidth, window.innerHeight); + }; + + window.addEventListener("resize", handleResize); + + return () => { + cancelAnimationFrame(frameId); + window.removeEventListener("resize", handleResize); + + for (const beam of beams) { + disposeBeam(scene, beam); + } + for (const s of sparkles) { + disposeSparkle(scene, s); + } + + composer.dispose(); + renderer.dispose(); + if (container.contains(renderer.domElement)) { + container.removeChild(renderer.domElement); + } + }; + }, []); + + return ( +
+ ); +} diff --git a/ui/src/components/VideoPlayer.tsx b/ui/src/components/VideoPlayer.tsx new file mode 100644 index 0000000..b1edd8b --- /dev/null +++ b/ui/src/components/VideoPlayer.tsx @@ -0,0 +1,122 @@ +import { useEffect, useRef, useState } from "react"; +import type VideoJsPlayerType from "video.js/dist/types/player"; + +interface Props { + src: string; + type?: string; + onTimeUpdate?: (time: number) => void; +} + +function isSafari(): boolean { + const ua = navigator.userAgent; + return /Safari/.test(ua) && !/Chrome/.test(ua); +} + +function isHlsUrl(url: string): boolean { + return url.includes(".m3u8"); +} + +export function VideoPlayer({ src, type, onTimeUpdate }: Props) { + const videoRef = useRef(null); + const playerRef = useRef(null); + const [initError, setInitError] = useState(null); + + useEffect(() => { + if (!videoRef.current) return; + const videoEl = videoRef.current.querySelector("video"); + if (!videoEl) return; + + const safari = isSafari(); + const isHls = isHlsUrl(src); + const isDirectFile = !isHls; + + if (isDirectFile || (safari && isHls)) { + videoEl.src = src; + videoEl.load(); + + const onTime = () => { + if (onTimeUpdate && videoEl.currentTime > 0) { + onTimeUpdate(videoEl.currentTime); + } + }; + videoEl.addEventListener("timeupdate", onTime); + + return () => { + videoEl.removeEventListener("timeupdate", onTime); + videoEl.src = ""; + }; + } + + let disposed = false; + + import("video.js").then((mod) => { + if (disposed) return; + const videojs = mod.default; + try { + const player = videojs(videoEl, { + controls: true, + responsive: true, + fluid: true, + playbackRates: [0.5, 1, 1.25, 1.5, 2], + html5: { + vhs: { + overrideNative: !safari, + }, + nativeAudioTracks: safari, + nativeVideoTracks: safari, + }, + sources: [{ + src, + type: type || "application/x-mpegURL", + }], + }); + + playerRef.current = player; + + if (onTimeUpdate) { + player.on("timeupdate", () => { + const ct = player.currentTime(); + if (typeof ct === "number") onTimeUpdate(ct); + }); + } + + player.on("error", () => { + const err = player.error(); + if (err && safari && isHls) { + player.dispose(); + playerRef.current = null; + videoEl.src = src; + videoEl.load(); + videoEl.play().catch(() => {}); + } + }); + } catch (err) { + setInitError(String(err)); + } + }).catch((err) => { + setInitError(`Failed to load video.js: ${err}`); + }); + + return () => { + disposed = true; + if (playerRef.current) { + playerRef.current.dispose(); + playerRef.current = null; + } + }; + }, [src]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+ {initError &&
{initError}
} +
+
+
+ ); +} diff --git a/ui/src/hooks/useAuth.ts b/ui/src/hooks/useAuth.ts new file mode 100644 index 0000000..6441c13 --- /dev/null +++ b/ui/src/hooks/useAuth.ts @@ -0,0 +1,112 @@ +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + type ReactNode, +} from "react"; +import { createElement } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; +import { api, ApiRequestError } from "../api/client"; +import { getToken, setToken, removeToken, isTokenExpired } from "../lib/auth"; +import type { User } from "../api/types"; + +interface AuthState { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + login: (username: string, password: string) => Promise; + register: (username: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(null); + +const PUBLIC_ROUTES = ["/login"]; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const navigate = useNavigate(); + const location = useLocation(); + + const checkAuth = useCallback(async () => { + const token = getToken(); + if (!token || isTokenExpired(token)) { + removeToken(); + setUser(null); + setIsLoading(false); + return; + } + + try { + const userData = await api.me(); + setUser(userData); + } catch (err) { + if (err instanceof ApiRequestError && err.status === 401) { + removeToken(); + setUser(null); + } + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + useEffect(() => { + if (!isLoading && !user && !PUBLIC_ROUTES.includes(location.pathname)) { + navigate("/login", { replace: true }); + } + }, [isLoading, user, location.pathname, navigate]); + + const login = useCallback( + async (username: string, password: string) => { + const response = await api.login({ username, password }); + setToken(response.token); + const userData = await api.me(); + setUser(userData); + navigate("/", { replace: true }); + }, + [navigate] + ); + + const register = useCallback( + async (username: string, password: string) => { + const response = await api.register({ username, password }); + setToken(response.token); + const userData = await api.me(); + setUser(userData); + navigate("/", { replace: true }); + }, + [navigate] + ); + + const logout = useCallback(() => { + removeToken(); + setUser(null); + navigate("/login", { replace: true }); + }, [navigate]); + + const value: AuthState = { + user, + isAuthenticated: !!user, + isLoading, + login, + register, + logout, + }; + + return createElement(AuthContext.Provider, { value }, children); +} + +export function useAuth(): AuthState { + const context = useContext(AuthContext); + if (!context) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/ui/src/hooks/useDebug.ts b/ui/src/hooks/useDebug.ts new file mode 100644 index 0000000..4815bb7 --- /dev/null +++ b/ui/src/hooks/useDebug.ts @@ -0,0 +1,18 @@ +import { useState, useCallback } from "react"; + +const STORAGE_KEY = "streamx_debug"; + +function getStored(): boolean { + return localStorage.getItem(STORAGE_KEY) === "true"; +} + +export function useDebug() { + const [debug, setDebugState] = useState(getStored); + + const setDebug = useCallback((value: boolean) => { + localStorage.setItem(STORAGE_KEY, String(value)); + setDebugState(value); + }, []); + + return { debug, setDebug } as const; +} diff --git a/ui/src/hooks/useSearch.ts b/ui/src/hooks/useSearch.ts new file mode 100644 index 0000000..acf6f5a --- /dev/null +++ b/ui/src/hooks/useSearch.ts @@ -0,0 +1,53 @@ +import { useState, useRef, useCallback } from "react"; +import { api } from "../api/client"; +import type { SearchResult } from "../api/types"; + +export function useSearch() { + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const timerRef = useRef | null>(null); + const abortRef = useRef(null); + + const search = useCallback((query: string) => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + if (abortRef.current) { + abortRef.current.abort(); + } + + if (!query.trim()) { + setResults([]); + setError(null); + setIsLoading(false); + return; + } + + setIsLoading(true); + + timerRef.current = setTimeout(async () => { + const controller = new AbortController(); + abortRef.current = controller; + + try { + const data = await api.search({ query: query.trim() }); + if (!controller.signal.aborted) { + setResults(data.results); + setError(null); + } + } catch (err) { + if (!controller.signal.aborted) { + setError(err instanceof Error ? err.message : "Search failed"); + setResults([]); + } + } finally { + if (!controller.signal.aborted) { + setIsLoading(false); + } + } + }, 300); + }, []); + + return { results, isLoading, error, search }; +} diff --git a/ui/src/hooks/useStream.ts b/ui/src/hooks/useStream.ts new file mode 100644 index 0000000..24b12b2 --- /dev/null +++ b/ui/src/hooks/useStream.ts @@ -0,0 +1,52 @@ +import { useState, useEffect, useRef } from "react"; +import type { StreamStatus } from "../api/types"; + +export function useStream(streamId: string | null) { + const [status, setStatus] = useState(null); + const [fileUrl, setFileUrl] = useState(null); + const [error, setError] = useState(null); + const wsRef = useRef(null); + + useEffect(() => { + if (!streamId) { + setStatus(null); + setFileUrl(null); + setError(null); + return; + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/api/stream/${streamId}/ws`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + switch (msg.type) { + case "status": + setStatus(msg.data); + break; + case "file_ready": + setFileUrl(msg.data.url); + break; + case "error": + setError(msg.data.message); + break; + } + } catch { + // ignore malformed messages + } + }; + + ws.onerror = () => setError("WebSocket connection failed"); + ws.onclose = () => {}; + + return () => { + ws.close(); + wsRef.current = null; + }; + }, [streamId]); + + return { status, fileUrl, error }; +} diff --git a/ui/src/hooks/useTheme.ts b/ui/src/hooks/useTheme.ts new file mode 100644 index 0000000..5c5ce9d --- /dev/null +++ b/ui/src/hooks/useTheme.ts @@ -0,0 +1,25 @@ +import { useState, useCallback } from "react"; + +type ThemeValue = "dark" | "light"; +const STORAGE_KEY = "streamx_theme"; + +function getStoredTheme(): ThemeValue { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === "light" || stored === "dark") return stored; + return "dark"; +} + +export function useTheme() { + const [theme, setThemeState] = useState(getStoredTheme); + + const setTheme = useCallback((value: ThemeValue) => { + localStorage.setItem(STORAGE_KEY, value); + setThemeState(value); + }, []); + + const toggleTheme = useCallback(() => { + setTheme(theme === "dark" ? "light" : "dark"); + }, [theme, setTheme]); + + return { theme, setTheme, toggleTheme } as const; +} diff --git a/ui/src/lib/auth.ts b/ui/src/lib/auth.ts new file mode 100644 index 0000000..105668c --- /dev/null +++ b/ui/src/lib/auth.ts @@ -0,0 +1,38 @@ +const TOKEN_KEY = "streamx_token"; + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); +} + +export function removeToken(): void { + localStorage.removeItem(TOKEN_KEY); +} + +interface JwtPayload { + exp?: number; + sub?: string; + username?: string; +} + +function decodePayload(token: string): JwtPayload | null { + try { + const parts = token.split("."); + const payload = parts[1]; + if (!payload) return null; + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(decoded) as JwtPayload; + } catch { + return null; + } +} + +export function isTokenExpired(token: string): boolean { + const payload = decodePayload(token); + if (!payload?.exp) return true; + const nowSeconds = Math.floor(Date.now() / 1000); + return payload.exp < nowSeconds; +} diff --git a/ui/src/lib/debug-log.ts b/ui/src/lib/debug-log.ts new file mode 100644 index 0000000..64c6746 --- /dev/null +++ b/ui/src/lib/debug-log.ts @@ -0,0 +1,67 @@ +type LogLevel = "info" | "warn" | "error" | "debug"; + +interface LogEntry { + timestamp: number; + level: LogLevel; + source: string; + message: string; + data?: unknown; +} + +class DebugLogger { + private entries: LogEntry[] = []; + private listeners: Set<() => void> = new Set(); + private maxEntries = 500; + + log(level: LogLevel, source: string, message: string, data?: unknown) { + const entry: LogEntry = { + timestamp: Date.now(), + level, + source, + message, + data, + }; + this.entries.push(entry); + if (this.entries.length > this.maxEntries) { + this.entries = this.entries.slice(-this.maxEntries); + } + const consoleFn = + level === "error" + ? console.error + : level === "warn" + ? console.warn + : console.log; + consoleFn(`[${source}] ${message}`, data ?? ""); + this.listeners.forEach((fn) => fn()); + } + + info(source: string, msg: string, data?: unknown) { + this.log("info", source, msg, data); + } + warn(source: string, msg: string, data?: unknown) { + this.log("warn", source, msg, data); + } + error(source: string, msg: string, data?: unknown) { + this.log("error", source, msg, data); + } + debug(source: string, msg: string, data?: unknown) { + this.log("debug", source, msg, data); + } + + getEntries(): LogEntry[] { + return [...this.entries]; + } + + clear() { + this.entries = []; + this.listeners.forEach((fn) => fn()); + } + + subscribe(fn: () => void): () => void { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } +} + +export const debugLog = new DebugLogger(); +export type { LogEntry, LogLevel }; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts new file mode 100644 index 0000000..fa02f98 --- /dev/null +++ b/ui/src/lib/utils.ts @@ -0,0 +1,42 @@ +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const k = 1024; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + const unit = units[i] ?? "TB"; + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${unit}`; +} + +export function formatDuration(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + + if (h > 0) { + return `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; + } + return `${m}:${s.toString().padStart(2, "0")}`; +} + +export function formatSpeed(bytesPerSec: number): string { + return `${formatBytes(bytesPerSec)}/s`; +} + +export function classNames( + ...classes: (string | undefined | null | false)[] +): string { + return classes.filter(Boolean).join(" "); +} + +export function detectQuality(title: string): string | null { + const lower = title.toLowerCase(); + if (lower.includes("2160p") || lower.includes("4k")) return "4K"; + if (lower.includes("1080p")) return "1080p"; + if (lower.includes("720p")) return "720p"; + if (lower.includes("480p")) return "480p"; + return null; +} + +export function isMagnetLink(text: string): boolean { + return text.trim().startsWith("magnet:"); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..5963b37 --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "@radix-ui/themes/styles.css"; +import "video.js/dist/video-js.css"; +import "./styles/global.css"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/ui/src/pages/History.tsx b/ui/src/pages/History.tsx new file mode 100644 index 0000000..6c27c5b --- /dev/null +++ b/ui/src/pages/History.tsx @@ -0,0 +1,192 @@ +import { useEffect, useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Flex, + Text, + Card, + Grid, + Button, + Box, + Progress, + Skeleton, +} from "@radix-ui/themes"; +import { + PlayIcon, + TrashIcon, + CounterClockwiseClockIcon, +} from "@radix-ui/react-icons"; +import { api } from "../api/client"; +import { formatDuration } from "../lib/utils"; +import type { WatchHistoryItem } from "../api/types"; + +export function History() { + const navigate = useNavigate(); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchHistory = useCallback(async () => { + try { + const data = await api.watchHistory(); + setItems(data.items); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load history"); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + fetchHistory(); + }, [fetchHistory]); + + const handleDelete = async (id: string) => { + try { + await api.deleteHistoryItem(id); + setItems((prev) => prev.filter((item) => item.id !== id)); + } catch (err) { + console.error("Failed to delete history item:", err); + } + }; + + const handleResume = (item: WatchHistoryItem) => { + navigate(`/player/${item.id}`); + }; + + if (isLoading) { + return ( + + + Watch History + + + {Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + ))} + + + ); + } + + return ( + + + Watch History + + + {error && ( + + {error} + + )} + + {items.length === 0 ? ( + + + + Nothing watched yet + + + + ) : ( + + {items.map((item) => { + const progress = + item.duration_seconds && item.watched_seconds + ? (item.watched_seconds / item.duration_seconds) * 100 + : 0; + + return ( + + + {item.poster_url ? ( + + {item.title} + + ) : ( + + + + )} + + + {item.title} + + + {item.duration_seconds != null && ( + + + + {item.watched_seconds != null + ? formatDuration(item.watched_seconds) + : "0:00"}{" "} + / {formatDuration(item.duration_seconds)} + + + )} + + + {new Date(item.watched_at).toLocaleDateString()} + + + + + + + + + ); + })} + + )} + + ); +} diff --git a/ui/src/pages/Login.tsx b/ui/src/pages/Login.tsx new file mode 100644 index 0000000..bdd809e --- /dev/null +++ b/ui/src/pages/Login.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { + Box, + Card, + Flex, + Text, + TextField, + Button, + Tabs, +} from "@radix-ui/themes"; +import { PersonIcon, LockClosedIcon } from "@radix-ui/react-icons"; +import { motion } from "framer-motion"; +import { NeonBackground } from "../components/NeonBackground"; +import { useAuth } from "../hooks/useAuth"; + +export function Login() { + const { login, register } = useAuth(); + const [tab, setTab] = useState<"login" | "register">("login"); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + if (tab === "login") { + await login(username, password); + } else { + await register(username, password); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Authentication failed"); + } finally { + setLoading(false); + } + }; + + return ( + + + + + + + StreamX + + StreamX + + + Torrent video streaming + + + + { + setTab(v as "login" | "register"); + setError(null); + }} + > + + Sign In + Create Account + + + +
+ + setUsername(e.target.value)} + required + > + + + + + + setPassword(e.target.value)} + required + > + + + + + + {error && ( + + {error} + + )} + + + +
+
+
+
+
+ ); +} diff --git a/ui/src/pages/Player.tsx b/ui/src/pages/Player.tsx new file mode 100644 index 0000000..3578743 --- /dev/null +++ b/ui/src/pages/Player.tsx @@ -0,0 +1,187 @@ +import { useEffect, useState, useCallback } from "react"; +import { useParams, useNavigate, useSearchParams } from "react-router-dom"; +import { + Box, + Flex, + Text, + Button, + Card, + Badge, + Progress, +} from "@radix-ui/themes"; +import { ArrowLeftIcon } from "@radix-ui/react-icons"; +import { VideoPlayer } from "../components/VideoPlayer"; +import { useStream } from "../hooks/useStream"; +import { api } from "../api/client"; +import { formatBytes, formatSpeed } from "../lib/utils"; + +const DEMO_HLS_URL = + "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"; + +function StatusBadge({ status }: { status: string }) { + const colorMap: Record = { + ready: "green", + complete: "green", + transcoding: "blue", + downloading: "amber", + initializing: "amber", + paused: "orange", + error: "red", + }; + return ( + + {status} + + ); +} + +export function Player() { + const { id: routeId } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [streamId, setStreamId] = useState( + routeId?.startsWith("pending-") ? null : (routeId ?? null) + ); + const isDemo = routeId === "demo"; + const magnet = searchParams.get("magnet"); + + useEffect(() => { + if (!magnet || isDemo) return; + + let cancelled = false; + const tryStart = async () => { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await api.startStream({ magnet_uri: magnet }); + if (!cancelled && res.stream_id) { + setStreamId(res.stream_id); + navigate(`/player/${res.stream_id}`, { replace: true }); + } + return; + } catch (err) { + console.error(`Start stream attempt ${attempt + 1} failed:`, err); + if (attempt < 2) await new Promise((r) => setTimeout(r, 3000)); + } + } + }; + tryStart(); + return () => { cancelled = true; }; + }, [magnet, isDemo, navigate]); + + const { status, fileUrl, error } = useStream(isDemo ? null : streamId); + + const videoUrl = isDemo ? DEMO_HLS_URL : fileUrl; + const videoReady = isDemo || fileUrl !== null; + + const handleTimeUpdate = useCallback( + (time: number) => { + if (!streamId || isDemo) return; + if (Math.floor(time) % 10 === 0 && time > 0) { + api.updateWatchPosition(streamId, time).catch(() => {}); + } + }, + [streamId, isDemo] + ); + + return ( + + + + {isDemo && ( + + Demo + + )} + + + + {videoReady && videoUrl ? ( + + ) : ( + + + {error + ? error + : status + ? "Waiting for file..." + : "Connecting..."} + + + )} + + + {!isDemo && status && ( + + + + + Stream Status + + + + + {status.status !== "ready" && status.status !== "complete" && ( + + + + {(status.progress ?? 0).toFixed(1)}% + + + )} + + + + Peers: {status.peers ?? 0} + + + Speed: {formatSpeed(status.speed ?? 0)} + + {status.file_size != null && status.file_size > 0 && ( + + Size:{" "} + + {formatBytes(status.file_size)} + + + )} + {status.files && status.files.length > 0 && !status.file_size && ( + + Size:{" "} + + {formatBytes( + status.files.reduce((sum, f) => sum + f.size, 0) + )} + + + )} + + + + )} + + {error && !isDemo && ( + + {error} + + )} + + ); +} diff --git a/ui/src/pages/Search.tsx b/ui/src/pages/Search.tsx new file mode 100644 index 0000000..e4cfb7e --- /dev/null +++ b/ui/src/pages/Search.tsx @@ -0,0 +1,289 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { + Box, + Flex, + Text, + TextField, + Button, + Card, + Badge, + Select, + Skeleton, +} from "@radix-ui/themes"; +import { + MagnifyingGlassIcon, + PlayIcon, + Link2Icon, +} from "@radix-ui/react-icons"; +import { useSearch } from "../hooks/useSearch"; +import { isMagnetLink, detectQuality, formatBytes } from "../lib/utils"; +import type { SearchResult } from "../api/types"; + +type SortKey = "seeds" | "size" | "year" | "rating"; + +const DEMO_STREAM_ID = "demo"; + +function sortResults(results: SearchResult[], key: SortKey): SearchResult[] { + return [...results].sort((a, b) => { + switch (key) { + case "seeds": + return b.seeds - a.seeds; + case "size": + return b.size_bytes - a.size_bytes; + case "year": + return (b.year ?? 0) - (a.year ?? 0); + case "rating": + return (b.rating ?? 0) - (a.rating ?? 0); + } + }); +} + +function ResultCard({ + result, + onSelect, +}: { + result: SearchResult; + onSelect: (r: SearchResult) => void; +}) { + const quality = result.quality ?? detectQuality(result.title); + const [imgError, setImgError] = useState(false); + + return ( + onSelect(result)} + style={{ cursor: "pointer" }} + > + + {result.poster && !imgError ? ( + setImgError(true)} + style={{ + borderRadius: 4, + objectFit: "cover", + flexShrink: 0, + background: "var(--gray-a3)", + }} + /> + ) : ( + + + + )} + + + + {result.title} + + + + {quality && ( + + {quality} + + )} + {result.year && ( + + {result.year} + + )} + {result.rating != null && result.rating > 0 && ( + + {result.rating.toFixed(1)} + + )} + + + + + {result.seeds} + + + {result.leeches} + + + {result.size || formatBytes(result.size_bytes)} + + + + + + ); +} + +function SkeletonCard() { + return ( + + + + + + + + + + + ); +} + +export function Search() { + const navigate = useNavigate(); + const { results, isLoading, error, search } = useSearch(); + const [query, setQuery] = useState(""); + const [sortKey, setSortKey] = useState("seeds"); + const [starting] = useState(false); + + const handleInputChange = (value: string) => { + setQuery(value); + if (!isMagnetLink(value)) { + search(value); + } + }; + + const startAndNavigate = (magnetUri: string) => { + const tempId = `pending-${Date.now()}`; + navigate(`/player/${tempId}?magnet=${encodeURIComponent(magnetUri)}`); + }; + + const handleMagnetSubmit = () => { + if (!isMagnetLink(query)) return; + startAndNavigate(query.trim()); + }; + + const handleResultSelect = (result: SearchResult) => { + startAndNavigate(result.magnet); + }; + + const sorted = sortResults(results, sortKey); + const isMagnet = isMagnetLink(query); + + return ( + + + + handleInputChange(e.target.value)} + > + + {isMagnet ? : } + + + + + {isMagnet && ( + + )} + + + {!query && ( + + + + Search for something to watch + + + + + )} + + {error && ( + + {error} + + )} + + {query && !isMagnet && results.length > 0 && ( + + + {results.length} result{results.length !== 1 ? "s" : ""} + + setSortKey(v as SortKey)} + > + + + Most Seeds + Largest + Newest + Best Rated + + + + )} + + {isLoading && !isMagnet && ( + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} + + )} + + {!isLoading && sorted.length > 0 && ( + + {sorted.map((result, i) => ( + + ))} + + )} + + {query && !isMagnet && !isLoading && results.length === 0 && ( + + + No results found + + + )} + + {starting && ( + + + Starting stream... + + + )} + + ); + + function handleDemo() { + navigate(`/player/${DEMO_STREAM_ID}`); + } +} diff --git a/ui/src/pages/Settings.tsx b/ui/src/pages/Settings.tsx new file mode 100644 index 0000000..7fa24d6 --- /dev/null +++ b/ui/src/pages/Settings.tsx @@ -0,0 +1,94 @@ +import { + Flex, + Text, + Card, + Button, + Badge, + RadioGroup, + Separator, +} from "@radix-ui/themes"; +import { ExitIcon } from "@radix-ui/react-icons"; +import { useAuth } from "../hooks/useAuth"; + +interface SettingsProps { + theme: "dark" | "light"; + setTheme: (t: "dark" | "light") => void; +} + +export function Settings({ theme, setTheme }: SettingsProps) { + const { user, logout } = useAuth(); + + return ( + + + Settings + + + + + + Appearance + + setTheme(v as "dark" | "light")}> + + + + + Dark + + + + + + Light + + + + + + + + + + + Account + + + + + + Username: + + + {user?.username ?? "Unknown"} + + {user?.is_admin && ( + + Admin + + )} + + + {user?.created_at && ( + + + Member since: + + + {new Date(user.created_at).toLocaleDateString()} + + + )} + + + + + + + + + ); +} diff --git a/ui/src/styles/global.css b/ui/src/styles/global.css new file mode 100644 index 0000000..728c57a --- /dev/null +++ b/ui/src/styles/global.css @@ -0,0 +1,79 @@ +*, *::before, *::after { + box-sizing: border-box; +} + +body, html { + margin: 0; + padding: 0; +} + +@keyframes shimmer { + 0% { background-position: -200% 0; } + 100% { background-position: 200% 0; } +} + +.video-js { + font-family: inherit; + border-radius: 8px; + overflow: hidden; +} + +.video-js .vjs-big-play-button { + width: 72px; + height: 72px; + border-radius: 50%; + border: none; + background: rgba(59, 130, 246, 0.9); + backdrop-filter: blur(8px); + line-height: 72px; + font-size: 32px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + margin: 0; + transition: transform 0.15s, background 0.15s; +} + +.video-js .vjs-big-play-button:hover { + background: rgba(59, 130, 246, 1); + transform: translate(-50%, -50%) scale(1.08); +} + +.video-js .vjs-big-play-button .vjs-icon-placeholder::before { + font-size: 32px; + line-height: 72px; +} + +.video-js .vjs-control-bar { + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); + height: 40px; + padding: 0 8px; +} + +.video-js .vjs-play-progress, +.video-js .vjs-volume-level { + background: #3b82f6; +} + +.video-js .vjs-slider { + background: rgba(255, 255, 255, 0.15); +} + +.video-js .vjs-load-progress { + background: rgba(255, 255, 255, 0.1); +} + +.video-js .vjs-load-progress div { + background: rgba(255, 255, 255, 0.15); +} + +.video-js .vjs-time-tooltip { + background: rgba(0, 0, 0, 0.85); + border-radius: 4px; + font-size: 11px; +} + +.video-js .vjs-playback-rate .vjs-playback-rate-value { + font-size: 12px; + line-height: 40px; +} diff --git a/ui/tests/auth.spec.ts b/ui/tests/auth.spec.ts new file mode 100644 index 0000000..38e55bc --- /dev/null +++ b/ui/tests/auth.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { getToken, setToken, removeToken, isTokenExpired } from "../src/lib/auth"; + +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { + store[key] = value; + }, + removeItem: (key: string) => { + delete store[key]; + }, + clear: () => { + store = {}; + }, + }; +})(); + +Object.defineProperty(globalThis, "localStorage", { value: localStorageMock }); + +describe("auth token management", () => { + beforeEach(() => { + localStorageMock.clear(); + }); + + it("stores and retrieves a token", () => { + setToken("abc123"); + expect(getToken()).toBe("abc123"); + }); + + it("returns null when no token", () => { + expect(getToken()).toBeNull(); + }); + + it("removes token", () => { + setToken("abc123"); + removeToken(); + expect(getToken()).toBeNull(); + }); + + it("detects expired tokens", () => { + const expiredPayload = btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) - 100 })); + const token = `header.${expiredPayload}.signature`; + expect(isTokenExpired(token)).toBe(true); + }); + + it("detects valid tokens", () => { + const validPayload = btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 })); + const token = `header.${validPayload}.signature`; + expect(isTokenExpired(token)).toBe(false); + }); +}); diff --git a/ui/tests/debug-log.spec.ts b/ui/tests/debug-log.spec.ts new file mode 100644 index 0000000..ecf54ab --- /dev/null +++ b/ui/tests/debug-log.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { debugLog } from "../src/lib/debug-log"; + +describe("debugLog", () => { + beforeEach(() => { + debugLog.clear(); + vi.restoreAllMocks(); + }); + + it("logs entries at different levels", () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + + debugLog.info("test", "info message"); + debugLog.warn("test", "warn message"); + debugLog.error("test", "error message"); + debugLog.debug("test", "debug message"); + + const entries = debugLog.getEntries(); + expect(entries).toHaveLength(4); + expect(entries[0]?.level).toBe("info"); + expect(entries[1]?.level).toBe("warn"); + expect(entries[2]?.level).toBe("error"); + expect(entries[3]?.level).toBe("debug"); + }); + + it("notifies subscribers", () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + const callback = vi.fn(); + const unsubscribe = debugLog.subscribe(callback); + + debugLog.info("test", "hello"); + expect(callback).toHaveBeenCalledTimes(1); + + unsubscribe(); + debugLog.info("test", "world"); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("clears entries", () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + debugLog.info("test", "message"); + expect(debugLog.getEntries()).toHaveLength(1); + debugLog.clear(); + expect(debugLog.getEntries()).toHaveLength(0); + }); +}); diff --git a/ui/tests/e2e-movie-play.spec.ts b/ui/tests/e2e-movie-play.spec.ts new file mode 100644 index 0000000..9bbac44 --- /dev/null +++ b/ui/tests/e2e-movie-play.spec.ts @@ -0,0 +1,120 @@ +import { test, expect } from "@playwright/test"; + +async function getToken(request: any): Promise { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "password" }, + }); + const body = await res.json(); + return body.token; +} + +test.describe("Movie playback e2e", () => { + test("demo Big Buck Bunny plays video frames", async ({ page, request }) => { + test.setTimeout(30000); + + await page.goto("/login"); + await page.fill('input[placeholder*="ser"]', "admin"); + await page.fill('input[placeholder*="ass"]', "password"); + await page.click('button[type="submit"]'); + await page.waitForURL("**/", { timeout: 10000 }); + + await page.goto("/player/demo"); + + await page.waitForSelector(".vjs-big-play-button", { timeout: 10000 }); + await page.click(".vjs-big-play-button"); + + const played = await page.evaluate(() => { + return new Promise<{ currentTime: number; paused: boolean; readyState: number }>((resolve) => { + const check = () => { + const v = document.querySelector("video"); + if (v && v.currentTime > 1) { + resolve({ currentTime: v.currentTime, paused: v.paused, readyState: v.readyState }); + } else { + setTimeout(check, 500); + } + }; + check(); + setTimeout(() => { + const v = document.querySelector("video"); + resolve({ + currentTime: v?.currentTime ?? 0, + paused: v?.paused ?? true, + readyState: v?.readyState ?? 0, + }); + }, 15000); + }); + }); + + console.log("Video state:", JSON.stringify(played)); + expect(played.currentTime).toBeGreaterThan(1); + expect(played.paused).toBe(false); + + await page.screenshot({ path: "test-results/demo-verified-playing.png" }); + }); + + test("torrent movie downloads segments and plays", async ({ request }) => { + test.setTimeout(180000); + + const token = await getToken(request); + const headers = { Authorization: `Bearer ${token}` }; + + const searchRes = await request.post("/api/search", { + headers, + data: { query: "night of the living dead 1968" }, + }); + const results = (await searchRes.json()).results; + expect(results.length).toBeGreaterThan(0); + + const movie = results.find( + (r: any) => r.quality === "720p" && r.title.includes("1968") + ) || results[0]; + + const streamRes = await request.post("/api/stream", { + headers, + data: { magnet_uri: movie.magnet }, + }); + expect(streamRes.ok()).toBeTruthy(); + const { stream_id } = await streamRes.json(); + console.log(`Stream started: ${stream_id}`); + + let hasSegments = false; + for (let i = 0; i < 60; i++) { + await new Promise((r) => setTimeout(r, 3000)); + + const statusRes = await request.get(`/api/stream/${stream_id}`, { headers }); + if (!statusRes.ok()) continue; + const status = await statusRes.json(); + console.log( + `[${i}] progress=${status.progress.toFixed(1)}% peers=${status.peers} speed=${status.speed} status=${status.status}` + ); + + const playlistRes = await request.get( + `/api/stream/${stream_id}/playlist.m3u8?token=${token}` + ); + if (playlistRes.ok()) { + const playlist = await playlistRes.text(); + if (playlist.includes("segment_")) { + console.log("HLS segments detected in playlist"); + hasSegments = true; + + const lines = playlist.split("\n"); + const segmentLine = lines.find((l: string) => l.includes("segment_")); + if (segmentLine) { + const segRes = await request.get( + `/api/stream/${stream_id}/${segmentLine.trim()}?token=${token}` + ); + expect(segRes.ok()).toBeTruthy(); + const segBody = await segRes.body(); + console.log(`Segment ${segmentLine.trim()} size: ${segBody.length} bytes`); + expect(segBody.length).toBeGreaterThan(100); + } + break; + } + } + } + + expect(hasSegments).toBe(true); + + await request.delete(`/api/stream/${stream_id}`, { headers }); + }); +}); diff --git a/ui/tests/e2e-player.spec.ts b/ui/tests/e2e-player.spec.ts new file mode 100644 index 0000000..6f3a984 --- /dev/null +++ b/ui/tests/e2e-player.spec.ts @@ -0,0 +1,40 @@ +import { test, expect } from "@playwright/test"; + +async function loginAsAdmin(request: any) { + const res = await request.post("/api/auth/login", { + data: { username: "admin", password: "password" }, + }); + return (await res.json()).token; +} + +test.describe("Video Player", () => { + test("demo player page renders video element", async ({ page, request }) => { + await loginAsAdmin(request); + + await page.goto("/login"); + await page.fill('input[placeholder*="ser"]', "admin"); + await page.fill('input[placeholder*="ass"]', "password"); + await page.click('button[type="submit"]'); + await page.waitForURL("**/", { timeout: 10000 }); + + await page.goto("/player/demo"); + await page.waitForTimeout(3000); + + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + + await page.screenshot({ path: "test-results/player-demo.png" }); + + const state = await page.evaluate(() => ({ + videoCount: document.querySelectorAll("video").length, + vjsCount: document.querySelectorAll(".video-js").length, + bodyText: document.body.innerText.substring(0, 500), + consoleErrors: (window as any).__errors || [], + })); + + console.log("Player state:", JSON.stringify(state)); + console.log("Page errors:", errors); + + expect(state.videoCount).toBeGreaterThan(0); + }); +}); diff --git a/ui/tests/e2e-torrent-play.spec.ts b/ui/tests/e2e-torrent-play.spec.ts new file mode 100644 index 0000000..25eb717 --- /dev/null +++ b/ui/tests/e2e-torrent-play.spec.ts @@ -0,0 +1,145 @@ +import { test, expect } from "@playwright/test"; + +// Uses default playwright.config.ts which starts a fresh backend on port 9876 + +async function login(page: import("@playwright/test").Page) { + await page.goto("/login"); + await page.fill('input[placeholder*="ser"]', "admin"); + await page.fill('input[placeholder*="ass"]', "password"); + await page.click('button[type="submit"]'); + await page.waitForURL("**/", { timeout: 10000 }); +} + +test("search and play a torrent shows player with stream status", async ({ + page, +}) => { + test.setTimeout(60000); + + const logs: string[] = []; + page.on("console", (msg) => logs.push(`[${msg.type()}] ${msg.text()}`)); + + await login(page); + + // Search for a well-seeded torrent (Food Inc has 22+ seeds) + await page.fill('input[placeholder*="Search"]', "inc"); + await page.waitForTimeout(2000); + await page.screenshot({ path: "test-results/torrent-01-search.png" }); + + // Click Food Inc result + const result = page.locator("text=Food, Inc"); + const target = result; + await expect(target.first()).toBeVisible({ timeout: 10000 }); + await target.first().click(); + await page.waitForURL("**/player/**", { timeout: 15000 }); + + console.log("Player URL:", page.url()); + await page.screenshot({ path: "test-results/torrent-02-player-initial.png" }); + + // Verify the player page is showing (not stuck on login or search) + const playerContent = page.locator("text=Stream Status"); + const waitingText = page.locator("text=Waiting for file"); + const connectingText = page.locator("text=Connecting"); + const videoBox = page.locator("video, .video-js"); + + // Wait for either the stream status card or the video to appear + let streamStatusVisible = false; + let videoVisible = false; + + for (let i = 0; i < 15; i++) { + await page.waitForTimeout(3000); + + const state = await page.evaluate(() => { + const v = document.querySelector("video"); + const statusCard = document.body.innerText.includes("Stream Status"); + const peersText = document.body.innerText.match(/Peers:\s*(\d+)/); + const speedText = document.body.innerText.match(/Speed:\s*([\d.]+)/); + const progressText = document.body.innerText.match(/([\d.]+)%/); + const waitingVisible = document.body.innerText.includes("Waiting for file") || document.body.innerText.includes("Waiting for stream"); + const connectingVisible = document.body.innerText.includes("Connecting"); + return { + hasVideo: !!v, + currentTime: v?.currentTime ?? 0, + readyState: v?.readyState ?? 0, + paused: v?.paused ?? true, + statusCard, + peers: peersText ? peersText[1] : null, + speed: speedText ? speedText[1] : null, + progress: progressText ? progressText[1] : null, + waitingVisible, + connectingVisible, + bodySnippet: document.body.innerText.substring(0, 300), + }; + }); + + console.log( + `[${i}] video=${state.hasVideo} time=${state.currentTime.toFixed(1)} ready=${state.readyState} ` + + `statusCard=${state.statusCard} peers=${state.peers} speed=${state.speed} progress=${state.progress} ` + + `waiting=${state.waitingVisible} connecting=${state.connectingVisible}` + ); + + // Verify no NaN appears in the UI + expect(state.bodySnippet).not.toContain("NaN"); + + if (state.statusCard) { + streamStatusVisible = true; + // Verify peers shows a number (not NaN) + if (state.peers !== null) { + const peersNum = parseInt(state.peers, 10); + expect(Number.isNaN(peersNum)).toBe(false); + } + // Verify speed shows a number (not NaN) + if (state.speed !== null) { + const speedNum = parseFloat(state.speed); + expect(Number.isNaN(speedNum)).toBe(false); + } + } + + if (state.hasVideo) { + videoVisible = true; + // Try to play if paused + if (state.currentTime === 0) { + await page.evaluate(() => { + const v = document.querySelector("video"); + if (v) v.play().catch(() => {}); + }); + const playBtn = page.locator(".vjs-big-play-button"); + if (await playBtn.isVisible().catch(() => false)) { + await playBtn.click().catch(() => {}); + } + console.log("Triggered play"); + } + + if (state.currentTime > 0.5) { + await page.screenshot({ + path: "test-results/torrent-03-playing.png", + }); + console.log( + "VIDEO IS PLAYING at", + state.currentTime.toFixed(1), + "seconds" + ); + break; + } + } + + // If we have stream status visible AND video element, that's good enough + if (streamStatusVisible && videoVisible) { + await page.screenshot({ + path: "test-results/torrent-03-stream-status.png", + }); + console.log("Stream status and video element both visible - success"); + break; + } + } + + await page.screenshot({ path: "test-results/torrent-04-final.png" }); + + if (!videoVisible) { + const recentLogs = logs.filter( + (l) => + l.includes("error") || l.includes("Error") || l.includes("fail") + ); + console.log("ERROR LOGS:", recentLogs.slice(-10)); + } + expect(videoVisible).toBe(true); +}); diff --git a/ui/tests/hooks.spec.ts b/ui/tests/hooks.spec.ts new file mode 100644 index 0000000..c1dac8b --- /dev/null +++ b/ui/tests/hooks.spec.ts @@ -0,0 +1,38 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; + +describe("useTheme", () => { + beforeEach(() => { + localStorage.clear(); + vi.resetModules(); + }); + + it("defaults to dark theme", async () => { + const { useTheme } = await import("../src/hooks/useTheme"); + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe("dark"); + }); + + it("reads stored theme preference", async () => { + localStorage.setItem("streamx_theme", "light"); + const { useTheme } = await import("../src/hooks/useTheme"); + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe("light"); + }); +}); + +describe("useDebug", () => { + beforeEach(() => { + localStorage.clear(); + vi.resetModules(); + }); + + it("defaults to false", async () => { + const { useDebug } = await import("../src/hooks/useDebug"); + const { result } = renderHook(() => useDebug()); + expect(result.current.debug).toBe(false); + }); +}); diff --git a/ui/tests/live.config.ts b/ui/tests/live.config.ts new file mode 100644 index 0000000..4425580 --- /dev/null +++ b/ui/tests/live.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: ".", + timeout: 180000, + workers: 1, + use: { + baseURL: "http://localhost:8999", + viewport: { width: 1440, height: 900 }, + screenshot: "on", + }, + projects: [{ name: "chromium", use: { browserName: "chromium" } }], + outputDir: "../test-results", +}); diff --git a/ui/tests/screenshot-live.spec.ts b/ui/tests/screenshot-live.spec.ts new file mode 100644 index 0000000..6eedf98 --- /dev/null +++ b/ui/tests/screenshot-live.spec.ts @@ -0,0 +1,30 @@ +import { test, expect } from "@playwright/test"; + +test.use({ baseURL: "http://localhost:8999" }); + +test("screenshot of movie playing on live server", async ({ page }) => { + test.setTimeout(30000); + + await page.goto("/login"); + await page.fill('input[placeholder*="ser"]', "admin"); + await page.fill('input[placeholder*="ass"]', "password"); + await page.click('button[type="submit"]'); + await page.waitForURL("**/", { timeout: 10000 }); + + await page.goto("/player/demo"); + await page.waitForTimeout(3000); + + const playBtn = page.locator(".vjs-big-play-button"); + if (await playBtn.isVisible()) { + await playBtn.click(); + await page.waitForTimeout(5000); + } + + await page.screenshot({ path: "test-results/demo-playing-live.png" }); + + const playing = await page.evaluate(() => { + const v = document.querySelector("video"); + return v ? { paused: v.paused, currentTime: v.currentTime, duration: v.duration, readyState: v.readyState } : null; + }); + console.log("Video state:", JSON.stringify(playing)); +}); diff --git a/ui/tests/screenshot-player.spec.ts b/ui/tests/screenshot-player.spec.ts new file mode 100644 index 0000000..f348739 --- /dev/null +++ b/ui/tests/screenshot-player.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from "@playwright/test"; + +test.use({ baseURL: "http://localhost:8999" }); + +test("screenshot of movie playing", async ({ page }) => { + test.setTimeout(60000); + + page.on("console", (msg) => { + if (msg.type() === "error") console.log("BROWSER ERROR:", msg.text()); + }); + + await page.goto("/login"); + await page.fill('input[placeholder*="ser"]', "admin"); + await page.fill('input[placeholder*="ass"]', "password"); + await page.click('button[type="submit"]'); + await page.waitForURL("**/", { timeout: 10000 }); + + await page.fill('input[placeholder*="Search"]', "night of the living dead 1968"); + await page.waitForTimeout(2000); + + const firstResult = page.locator('[class*="Card"]').first(); + await firstResult.click(); + await page.waitForURL("**/player/**", { timeout: 10000 }); + + await page.waitForTimeout(5000); + await page.screenshot({ path: "test-results/movie-waiting.png" }); + + for (let i = 0; i < 30; i++) { + const hasVideo = await page.locator(".video-js").count(); + if (hasVideo > 0) { + await page.waitForTimeout(2000); + const playBtn = page.locator(".vjs-big-play-button"); + if (await playBtn.isVisible()) { + await playBtn.click(); + await page.waitForTimeout(3000); + } + await page.screenshot({ path: "test-results/movie-playing.png" }); + console.log("VIDEO PLAYER VISIBLE - screenshot taken"); + return; + } + await page.waitForTimeout(2000); + console.log(`Waiting for player... attempt ${i + 1}`); + } + + await page.screenshot({ path: "test-results/movie-final.png" }); + const state = await page.evaluate(() => ({ + videoCount: document.querySelectorAll("video").length, + vjsCount: document.querySelectorAll(".video-js").length, + text: document.body.innerText.substring(0, 300), + })); + console.log("Final state:", JSON.stringify(state)); + expect(state.videoCount).toBeGreaterThan(0); +}); diff --git a/ui/tests/utils.spec.ts b/ui/tests/utils.spec.ts new file mode 100644 index 0000000..1c6b93a --- /dev/null +++ b/ui/tests/utils.spec.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { + formatBytes, + formatDuration, + formatSpeed, + isMagnetLink, + detectQuality, + classNames, +} from "../src/lib/utils"; + +describe("formatBytes", () => { + it("formats zero bytes", () => { + expect(formatBytes(0)).toBe("0 B"); + }); + + it("formats megabytes", () => { + expect(formatBytes(1048576)).toBe("1.0 MB"); + }); + + it("formats gigabytes", () => { + expect(formatBytes(1073741824)).toBe("1.0 GB"); + }); +}); + +describe("formatDuration", () => { + it("formats seconds only", () => { + expect(formatDuration(45)).toBe("0:45"); + }); + + it("formats minutes and seconds", () => { + expect(formatDuration(125)).toBe("2:05"); + }); + + it("formats hours", () => { + expect(formatDuration(3661)).toBe("1:01:01"); + }); +}); + +describe("formatSpeed", () => { + it("formats bytes per second", () => { + expect(formatSpeed(1048576)).toBe("1.0 MB/s"); + }); +}); + +describe("isMagnetLink", () => { + it("detects magnet links", () => { + expect(isMagnetLink("magnet:?xt=urn:btih:abc")).toBe(true); + }); + + it("rejects non-magnet text", () => { + expect(isMagnetLink("hello world")).toBe(false); + }); + + it("handles whitespace", () => { + expect(isMagnetLink(" magnet:?xt=urn:btih:abc")).toBe(true); + }); +}); + +describe("detectQuality", () => { + it("detects 1080p", () => { + expect(detectQuality("Movie.2024.1080p.BluRay")).toBe("1080p"); + }); + + it("detects 4K", () => { + expect(detectQuality("Movie.2024.2160p.WEB")).toBe("4K"); + }); + + it("detects 720p", () => { + expect(detectQuality("Movie.720p")).toBe("720p"); + }); + + it("returns null for unknown quality", () => { + expect(detectQuality("Movie.2024")).toBeNull(); + }); +}); + +describe("classNames", () => { + it("joins truthy strings", () => { + expect(classNames("a", false, "b", null, "c")).toBe("a b c"); + }); +}); diff --git a/ui/tests/vite-player.spec.ts b/ui/tests/vite-player.spec.ts new file mode 100644 index 0000000..45bc8b2 --- /dev/null +++ b/ui/tests/vite-player.spec.ts @@ -0,0 +1,41 @@ +import { test, expect } from "@playwright/test"; + +test.use({ + baseURL: "http://localhost:8999", +}); + +test("demo player via Watch Demo button", async ({ page }) => { + const logs: string[] = []; + page.on("console", (msg) => logs.push(`[${msg.type()}] ${msg.text()}`)); + page.on("pageerror", (err) => logs.push(`[ERROR] ${err.message}`)); + + await page.goto("/login"); + await page.waitForTimeout(1000); + await page.fill('input[placeholder*="ser"]', "admin"); + await page.fill('input[placeholder*="ass"]', "password"); + await page.click('button[type="submit"]'); + await page.waitForURL("**/", { timeout: 10000 }); + + await page.screenshot({ path: "test-results/01-search-page.png" }); + + const demoBtn = page.locator("button", { hasText: "Watch Demo" }); + await expect(demoBtn).toBeVisible({ timeout: 5000 }); + await demoBtn.click(); + + await page.waitForTimeout(3000); + await page.screenshot({ path: "test-results/02-player-page.png" }); + + const url = page.url(); + console.log("Current URL:", url); + console.log("Console logs:", logs.slice(-10)); + + const state = await page.evaluate(() => ({ + videoCount: document.querySelectorAll("video").length, + vjsCount: document.querySelectorAll(".video-js").length, + textContent: document.body.innerText.substring(0, 300), + })); + + console.log("State:", JSON.stringify(state)); + expect(url).toContain("/player/demo"); + expect(state.videoCount).toBeGreaterThan(0); +}); diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..5d8a7f9 --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json new file mode 100644 index 0000000..265eed9 --- /dev/null +++ b/ui/tsconfig.node.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "composite": true, + "noEmit": false, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["vite.config.ts", "playwright.config.ts"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..451c72d --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + port: 8999, + host: "0.0.0.0", + allowedHosts: ["streamx.cbdemo.net"], + headers: { + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate", + "Pragma": "no-cache", + "Expires": "0", + "Surrogate-Control": "no-store", + }, + proxy: { + "/api": { + target: "http://localhost:8998", + changeOrigin: true, + ws: true, + }, + }, + }, + preview: { + port: 8999, + host: "0.0.0.0", + }, + build: { + outDir: "dist", + sourcemap: true, + }, +}); From 4e4cb13c3cc429487e7c76afbaa309e5cd83996a Mon Sep 17 00:00:00 2001 From: andreasbros Date: Mon, 23 Mar 2026 20:24:24 +0000 Subject: [PATCH 02/10] feat: working torrents download and video stream playback with UI and Admin pages; HLS realtime transcode is buggy; --- Cargo.lock | 441 ++++++- README.md | 181 ++- TASKS.md | 6 + backend/Cargo.toml | 7 +- backend/src/cli.rs | 7 + backend/src/config.rs | 114 +- backend/src/db/downloads.rs | 20 + backend/src/db/favourites.rs | 137 +++ backend/src/db/history.rs | 103 +- backend/src/db/metadata.rs | 129 ++ backend/src/db/migrations.rs | 38 + backend/src/db/mod.rs | 2 + backend/src/lib.rs | 1 + backend/src/logging.rs | 122 ++ backend/src/main.rs | 137 ++- backend/src/server/admin.rs | 502 ++++++++ backend/src/server/api.rs | 476 +++++++- backend/src/server/mod.rs | 70 ++ backend/src/server/proxy.rs | 162 +++ backend/src/server/stream.rs | 207 +++- backend/src/torrent/engine.rs | 89 +- backend/src/torrent/metadata.rs | 199 +++ backend/src/torrent/mod.rs | 3 +- backend/src/torrent/provider.rs | 1473 ++++++++++++++++++++++- backend/src/transcode/hls.rs | 636 +++++++++- backend/src/transcode/pipeline.rs | 842 ++++++++++--- backend/tests/api_tests.rs | 28 +- backend/tests/stream_lifecycle_tests.rs | 14 +- backend/tests/transcode_tests.rs | 298 +++++ ui/src/App.tsx | 32 +- ui/src/api/client.ts | 115 +- ui/src/api/types.ts | 141 ++- ui/src/components/AudioPlayerBar.tsx | 127 ++ ui/src/components/DebugPane.tsx | 41 +- ui/src/components/DrawerMenu.tsx | 264 ++++ ui/src/components/FavouriteButton.tsx | 73 ++ ui/src/components/Layout.tsx | 149 +-- ui/src/components/TrailerModal.tsx | 133 ++ ui/src/components/VideoPlayer.tsx | 402 +++++-- ui/src/hooks/useAdminMonitor.ts | 165 +++ ui/src/hooks/useAudioPlayer.ts | 164 +++ ui/src/hooks/useDebug.ts | 19 +- ui/src/hooks/useFavourites.ts | 101 ++ ui/src/hooks/useSearch.ts | 17 +- ui/src/hooks/useSearchTv.ts | 38 + ui/src/hooks/useStream.ts | 114 +- ui/src/lib/utils.ts | 8 + ui/src/pages/Admin.tsx | 466 +++++++ ui/src/pages/Browse.tsx | 180 +++ ui/src/pages/Favourites.tsx | 196 +++ ui/src/pages/History.tsx | 255 ++-- ui/src/pages/Movie.tsx | 236 ++++ ui/src/pages/MusicSearch.tsx | 254 ++++ ui/src/pages/MusicVideoSearch.tsx | 229 ++++ ui/src/pages/Player.tsx | 931 ++++++++++++-- ui/src/pages/Search.tsx | 704 +++++++++-- ui/src/pages/Settings.tsx | 15 + ui/src/pages/SurroundSound.tsx | 333 +++++ ui/src/pages/TvSearch.tsx | 229 ++++ ui/src/pages/TvShow.tsx | 289 +++++ ui/src/styles/global.css | 67 +- ui/tests/e2e-movie-play.spec.ts | 23 +- ui/tests/e2e-navigation.spec.ts | 145 +++ ui/tests/e2e-torrent-play.spec.ts | 40 +- ui/vite.config.ts | 4 + 65 files changed, 12223 insertions(+), 920 deletions(-) create mode 100644 backend/src/db/favourites.rs create mode 100644 backend/src/db/metadata.rs create mode 100644 backend/src/logging.rs create mode 100644 backend/src/server/admin.rs create mode 100644 backend/src/server/proxy.rs create mode 100644 backend/src/torrent/metadata.rs create mode 100644 backend/tests/transcode_tests.rs create mode 100644 ui/src/components/AudioPlayerBar.tsx create mode 100644 ui/src/components/DrawerMenu.tsx create mode 100644 ui/src/components/FavouriteButton.tsx create mode 100644 ui/src/components/TrailerModal.tsx create mode 100644 ui/src/hooks/useAdminMonitor.ts create mode 100644 ui/src/hooks/useAudioPlayer.ts create mode 100644 ui/src/hooks/useFavourites.ts create mode 100644 ui/src/hooks/useSearchTv.ts create mode 100644 ui/src/pages/Admin.tsx create mode 100644 ui/src/pages/Browse.tsx create mode 100644 ui/src/pages/Favourites.tsx create mode 100644 ui/src/pages/Movie.tsx create mode 100644 ui/src/pages/MusicSearch.tsx create mode 100644 ui/src/pages/MusicVideoSearch.tsx create mode 100644 ui/src/pages/SurroundSound.tsx create mode 100644 ui/src/pages/TvSearch.tsx create mode 100644 ui/src/pages/TvShow.tsx create mode 100644 ui/tests/e2e-navigation.spec.ts diff --git a/Cargo.lock b/Cargo.lock index a8cee5e..41f4f44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -108,9 +108,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ "rustversion", ] @@ -587,6 +587,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -615,6 +624,29 @@ dependencies = [ "winapi", ] +[[package]] +name = "cssparser" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "darling" version = "0.23.0" @@ -686,6 +718,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -728,12 +771,33 @@ dependencies = [ "syn", ] +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ego-tree" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" + [[package]] name = "either" version = "1.15.0" @@ -847,6 +911,16 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.32" @@ -941,6 +1015,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "generic-array" version = "0.12.4" @@ -960,6 +1043,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1128,6 +1220,18 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever", + "match_token", +] + [[package]] name = "http" version = "1.4.0" @@ -1280,9 +1384,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", "potential_utf", @@ -1299,6 +1403,7 @@ checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -1306,10 +1411,11 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "8b24a59706036ba941c9476a55cd57b82b77f38a3c667d637ee7cabbc85eaedc" dependencies = [ + "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -1320,29 +1426,31 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "f5a97b8ac6235e69506e8dacfb2adf38461d2ce6d3e9bd9c94c4cbc3cd4400a4" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", + "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" [[package]] name = "icu_provider" @@ -1352,6 +1460,8 @@ checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -1508,9 +1618,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" @@ -1869,6 +1979,37 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1965,6 +2106,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nonzero_ext" version = "0.3.0" @@ -2160,6 +2307,58 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -2237,6 +2436,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -2355,7 +2560,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2692,9 +2897,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", @@ -2761,6 +2966,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3d051b884f40e309de6c149734eab57aa8cc1347992710dc80bcc1c2194c15" +dependencies = [ + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2784,6 +3004,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.27" @@ -2896,6 +3135,15 @@ dependencies = [ "syn", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2961,6 +3209,12 @@ dependencies = [ "time", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "size_format" version = "1.0.2" @@ -3042,8 +3296,11 @@ dependencies = [ "bytes", "chrono", "clap", + "dashmap", + "futures", "http", "jsonwebtoken", + "libc", "librqbit", "mime_guess", "portpicker", @@ -3051,6 +3308,7 @@ dependencies = [ "reqwest", "rusqlite", "rust-embed", + "scraper", "serde", "serde_json", "snafu", @@ -3061,11 +3319,37 @@ dependencies = [ "tower", "tower-http", "tracing", + "tracing-appender", "tracing-subscriber", "urlencoding", "uuid", ] +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.11.1" @@ -3149,6 +3433,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3236,6 +3531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -3455,6 +3751,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -3565,6 +3873,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[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" @@ -3941,7 +4255,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -3959,14 +4282,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "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-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -3975,48 +4315,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -4216,6 +4604,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/README.md b/README.md index 8b63241..579723b 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,152 @@ # StreamX -Torrent-based video streaming player. Single static Rust binary serving a React UI. Search for torrents, paste magnet links, stream video in the browser. +Torrent-based streaming player. Single static Rust binary serving a React UI. Search for torrents, paste magnet links, stream video in the browser. ## About -StreamX starts a web server with a modern UI where you can search for torrents, paste magnet links, and stream video content directly in the browser. All dependencies (including FFmpeg) are statically linked into a single binary. +StreamX starts a web server with a modern UI where you can search for torrents, paste magnet links, and stream video content directly in the browser. - Rust backend: Axum, librqbit (BitTorrent), FFmpeg transcoding, SQLite -- React frontend: Radix UI, video.js, framer-motion +- React frontend: Radix UI, hls.js, framer-motion - Auth: bcrypt + JWT, multi-user with search/watch history - Streaming: sequential torrent download with on-the-fly HLS transcoding -## How streaming works +## How it works -A torrent downloads one movie file (e.g. `Movie.2024.720p.mp4` at ~1GB). BitTorrent downloads this file sequentially so the beginning arrives first. +### Provider system -Once enough data is available (~30% or the file is complete), FFmpeg converts the movie into HLS segments. Each segment is a 4-second chunk of video (~50-500KB as `.ts` files). A playlist file (`playlist.m3u8`) lists all segments in order. +All torrent sources are configured as **providers** in `config.toml`. Each provider has a `kind` (movies, tv, music, music-videos), a `url`, and a `format` that controls how queries are made. -The browser loads segments one at a time using video.js (Chrome/Firefox) or Safari's native HLS player. This allows playback to start before the full file is downloaded. +| Format | Supports | How it works | +|---|---|---| +| `yts` | Movies (browse + search) | Queries YTS JSON API. Returns rich metadata (posters, ratings, trailers). | +| `torrentio` | Movies, TV (search only) | Resolves text queries to IMDB IDs via [Cinemeta](https://v3-cinemeta.strem.io), then fetches streams from [Torrentio](https://torrentio.strem.fun). No API key needed. | +| `apibay` | TV, Music, Music Videos (browse + search) | Queries The Pirate Bay API. | +| `eztv` | TV (browse + search) | Queries EZTV API. Returns structured season/episode data. | +| `scrape` | Music, Music Videos (browse + search) | Scrapes 1337x HTML pages. | + +### Home page (Browse) + +The home page shows movie categories (Latest, Popular, Top Rated, by genre). The UI calls `GET /api/search/browse` with sort/filter/page params. This requires a format with catalog support -- **YTS** for movies, **apibay/eztv** for TV. + +Torrentio has no catalog (it's IMDB-ID based), so browse returns empty with `format = "torrentio"`. Keep YTS as your movies provider to populate the home page. + +### Search + +- **Movies** (`POST /api/search`): With YTS, queries the API directly. With Torrentio, searches Cinemeta for IMDB matches, then fetches Torrentio streams and Cinemeta detail concurrently for each result. +- **TV** (`POST /api/tv/search`): With Torrentio, searches Cinemeta for series, then probes Torrentio for episodes across seasons (up to 15 seasons x 30 episodes, fetched concurrently) to build structured season/episode results. With eztv/apibay, queries their APIs directly. +- **Music / Music Videos** (`POST /api/music/search`, `/api/music-videos/search`): Uses apibay or 1337x scraping. Torrentio does not support these. + +### Streaming pipeline ``` Torrent peers --> librqbit (sequential download) --> movie.mp4 | - FFmpeg (passthrough or transcode) - | - HLS segments (segment_0000.ts, segment_0001.ts, ...) - | - playlist.m3u8 + FFmpeg (passthrough or multi-variant transcode) | - video.js / Safari native HLS --> browser playback + master.m3u8 (adaptive bitrate) + / | \ + 360p/ 720p/ source/ + playlist playlist playlist + segments segments segments + \ | / + hls.js / Safari native HLS --> browser playback ``` -Segment duration is configurable via `hls_segment_duration` in `config.toml` (default: 4 seconds). FFmpeg uses hardware acceleration (VAAPI, NVENC, QSV, VideoToolbox) when available, falling back to CPU (libx264). +1. User selects a torrent variant (quality/source) +2. Backend downloads via librqbit in sequential mode so the beginning arrives first +3. For browser-compatible formats (H.264/AAC in MP4): **direct HTTP range requests** on the torrent stream. librqbit blocks until pieces arrive, the browser buffers naturally. +4. For incompatible formats (MKV, HEVC/x265, AC3): **multi-variant HLS transcoding**. Multiple FFmpeg processes run in parallel, one per quality tier, producing a master playlist with adaptive bitrate streaming. The player picks the best tier for the connection speed. +5. Browser plays via hls.js (Chrome/Firefox) or Safari's native HLS player + +### Adaptive bitrate + +Transcodes produce multiple quality tiers filtered by source resolution: + +| Tier | Height | Video Bitrate | Audio Bitrate | +|--------|--------|---------------|---------------| +| 360p | 360 | 800k | 128k | +| 720p | 720 | 2500k | 192k | +| 1080p | 1080 | 5000k | 256k | +| source | native | 8000k | 320k | + +Tiers with height >= source are skipped (except "source" which is always included). A 480p source produces 360p + source. A 4K source produces all four tiers. hls.js handles automatic quality switching based on bandwidth -- mobile clients get 360p, desktop gets source quality. + +Passthrough (browser-compatible source) skips all of this and uses a single flat playlist. + +### Audio preservation + +Surround audio (5.1, 7.1) is preserved through transcoding. FFmpeg keeps the original channel layout when encoding to AAC, with a safety cap at 8 channels. + +### Piped transcoding + +For active downloads (not yet complete), the torrent stream is written to a temp file. Once 1MB arrives, FFmpeg processes start reading from it. Each FFmpeg instance blocks on EOF when the file hasn't grown enough, providing natural backpressure that matches the download pace. + +## Configuration + +`~/.streamx/config.toml` (created on first run): + +```toml +[server] +port = 8999 +bind = "127.0.0.1" + +[torrent] +max_connections = 200 +sequential = true + +[transcode] +video_codec = "h264" +preset = "ultrafast" +crf = 23 + +[auth] +session_duration = "7d" + +# Movies: YTS for browse + search +[[providers]] +id = 1 +kind = "movies" +url = "https://yts.bz" +api_url = "https://yts.bz/api/v2/list_movies.json" + +# TV: Torrentio for structured season/episode search +[[providers]] +id = 2 +kind = "tv" +url = "https://torrentio.strem.fun/providers=eztv,1337x,thepiratebay" +format = "torrentio" + +# Music videos +[[providers]] +id = 3 +kind = "music-videos" +url = "https://apibay.org" +format = "apibay" +category = "601" + +# Music +[[providers]] +id = 4 +kind = "music" +url = "https://apibay.org" +format = "apibay" +category = "101" + +# Optional: route torrent traffic through SOCKS5 proxy +# [vpn] +# socks5 = "socks5://user:pass@host:port" +``` + +### Torrentio provider selection + +The Torrentio URL path controls which upstream sources it aggregates: + +``` +https://torrentio.strem.fun/providers=eztv,1337x,thepiratebay +``` + +Available: `yts`, `eztv`, `1337x`, `thepiratebay`, `torrentgalaxy`, `nyaasi`, and others. See [torrentio.strem.fun](https://torrentio.strem.fun) for the full list. ## Local build @@ -43,11 +158,11 @@ nix develop # Frontend cd ui && pnpm install && pnpm build && cd .. -# Backend -cd backend && cargo build && cd .. +# Backend (release build embeds the UI) +cd backend && cargo build --release && cd .. # Run -cd backend && cargo run +./target/release/streamx # Open http://127.0.0.1:8999 ``` @@ -63,7 +178,7 @@ cd ui && pnpm dev cd backend && cargo run -- --port 8998 ``` -The vite dev server runs on port 8999 and proxies API requests to the backend on port 8998. +The vite dev server runs on port 8999 and proxies API requests to the backend on 8998. ### Checks @@ -84,14 +199,36 @@ streamx clean # Remove cache and downloads (keeps config + database streamx wipe # Remove everything except config.toml ``` +## Project structure + +``` +backend/ + src/ + config.rs Configuration loading, env var expansion + server/ HTTP routes, auth, image proxy + torrent/ + engine.rs librqbit torrent management + provider.rs Search/browse dispatch, format-specific clients + metadata.rs Cinemeta client (IMDB ID resolution, no API key) + transcode/ FFmpeg HLS pipeline (GPU detection, probe, segmenting) + db/ SQLite (users, history, downloads, favourites) +ui/ + src/ + pages/ Browse, Search, TvSearch, Player, etc. + hooks/ useSearch, useStream, useAudioPlayer, etc. + api/ API client and types + components/ VideoPlayer, Layout, DebugPane, etc. +flake.nix Nix flake for dev shell and builds +``` + ## Troubleshooting -**Port 8999 in use:** Check with `ss -tlnp | grep 8999`. Kill the process or use `--port` to pick a different port. +**Port in use:** `ss -tlnp | grep 8999`. Kill the process or use `--port`. -**Frontend not showing:** Build the UI first with `cd ui && pnpm install && pnpm build`, then restart the backend. +**Frontend not showing:** Build the UI first (`cd ui && pnpm install && pnpm build`), then restart the backend. **Nix flake not found:** Run `git add flake.nix` -- Nix requires tracked files. -**Video not playing on iPhone/Safari:** Safari uses native HLS. If segments return 401, the auth token may be missing from the playlist URL. Check the debug pane (user menu > Debug Mode). +**Video not playing on Safari:** Safari uses native HLS. If segments return 401, the auth token may be missing. Check the debug pane (user menu > Debug Mode). -**Transcoding fails on GPU:** If VAAPI/NVENC fails for certain codecs (e.g. x265 10-bit), FFmpeg automatically falls back to CPU encoding (libx264). +**Transcoding fails on GPU:** FFmpeg automatically falls back to CPU encoding if hardware acceleration fails. diff --git a/TASKS.md b/TASKS.md index b51a183..ff15304 100644 --- a/TASKS.md +++ b/TASKS.md @@ -34,6 +34,12 @@ ## High priority +- [x] Safari HEVC/x265 fallback: auto-detect unsupported codecs via canPlayType + file extension, fall back to HLS CPU transcode +- [ ] HLS seek to any position: when user seeks beyond transcoded range, restart FFmpeg with `-ss {position}` + - Frontend detects seek beyond available range + - Requests `/api/stream/{id}/playlist.m3u8?seek={seconds}` + - Backend kills current FFmpeg, starts new from that position + - Returns new playlist, frontend switches to it - [ ] Download management: partial/ and complete/ directories - librqbit downloads to `downloads/partial/{torrent_name}/` - On completion + hash verification, atomic move to `downloads/complete/{torrent_name}/` diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 426a631..51b2a62 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -25,6 +25,7 @@ jsonwebtoken = "9" tower-http = { version = "0.6", features = ["cors", "compression-gzip", "trace", "set-header"] } tower = { version = "0.5", features = ["util"] } tracing = "0.1" +tracing-appender = "0.2" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } uuid = { version = "1", features = ["v4", "serde"] } tokio-util = { version = "0.7", features = ["io"] } @@ -35,8 +36,12 @@ chrono = { version = "0.4", features = ["serde"] } http = "1" bytes = "1" librqbit = "8" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "socks"] } urlencoding = "2" +scraper = "0.22" +dashmap = "6" +futures = "0.3" +libc = "0.2" [dev-dependencies] reqwest = { version = "0.12", features = ["json"] } diff --git a/backend/src/cli.rs b/backend/src/cli.rs index c9c3f79..83500e8 100644 --- a/backend/src/cli.rs +++ b/backend/src/cli.rs @@ -30,6 +30,13 @@ pub struct Cli { )] pub log_level: Option, + #[arg( + long, + help = "Log directory (enables file logging with daily rotation)", + env = "STREAMX_LOG_DIR" + )] + pub log_dir: Option, + #[arg(long, help = "Open browser on start", env = "STREAMX_OPEN")] pub open: bool, diff --git a/backend/src/config.rs b/backend/src/config.rs index b729bd3..0d98502 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -17,12 +17,18 @@ pub struct AppConfig { pub auth: AuthConfig, #[serde(default = "default_ui")] pub ui: UiConfig, + #[serde(default)] + pub providers: Vec, + #[serde(default)] + pub vpn: Option, #[serde(skip)] pub data_dir: PathBuf, #[serde(skip)] pub log_level: String, #[serde(skip)] + pub log_dir: Option, + #[serde(skip)] pub open_browser: bool, #[serde(skip)] pub admin_user: Option, @@ -30,6 +36,16 @@ pub struct AppConfig { pub admin_password: Option, } +impl AppConfig { + pub fn provider_by_kind(&self, kind: &str) -> Option<&ProviderConfig> { + self.providers.iter().find(|p| p.kind == kind) + } + + pub fn provider_by_id(&self, id: u32) -> Option<&ProviderConfig> { + self.providers.iter().find(|p| p.id == id) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { #[serde(default = "default_port")] @@ -74,6 +90,12 @@ pub struct TranscodeConfig { pub audio_bitrate: String, #[serde(default)] pub threads: Option, + #[serde(default)] + pub gpu: bool, + #[serde(default = "default_true")] + pub hls_downscale: bool, + #[serde(default = "default_hls_max_height")] + pub hls_max_height: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -84,6 +106,76 @@ pub struct AuthConfig { pub session_duration: String, } +/// A content provider (movies, tv, music, etc). +/// Users supply the URL; we never hardcode external domains. +/// `format`: "yts", "eztv", "apibay", "scrape" (default based on kind) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProviderConfig { + pub id: u32, + pub kind: String, + pub url: String, + #[serde(default)] + pub api_url: Option, + #[serde(default)] + pub format: Option, + #[serde(default)] + pub category: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VpnConfig { + pub socks5: String, +} + +impl VpnConfig { + /// Resolve the SOCKS5 URL, expanding `${ENV_VAR}` patterns and + /// injecting credentials from `STREAMX_SOCKS5_PROXY_USERNAME` / + /// `STREAMX_SOCKS5_PROXY_PASSWORD` env vars if the URL has no auth. + pub fn resolved_url(&self) -> String { + let mut url = expand_env_vars(&self.socks5); + + // If URL has no credentials, inject from env vars + if !url.contains('@') { + let user = std::env::var("STREAMX_SOCKS5_PROXY_USERNAME").unwrap_or_default(); + let pass = std::env::var("STREAMX_SOCKS5_PROXY_PASSWORD").unwrap_or_default(); + if !user.is_empty() { + let auth = if pass.is_empty() { + user + } else { + format!("{}:{}", user, pass) + }; + // socks5://host:port -> socks5://user:pass@host:port + if let Some(idx) = url.find("://") { + url = format!("{}://{}@{}", &url[..idx], auth, &url[idx + 3..]); + } + } + } + + url + } +} + +fn expand_env_vars(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '$' && chars.peek() == Some(&'{') { + chars.next(); // consume '{' + let mut var_name = String::new(); + for ch in chars.by_ref() { + if ch == '}' { + break; + } + var_name.push(ch); + } + result.push_str(&std::env::var(&var_name).unwrap_or_default()); + } else { + result.push(c); + } + } + result +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UiConfig { #[serde(default = "default_theme")] @@ -119,6 +211,9 @@ fn default_transcode() -> TranscodeConfig { max_bitrate: None, audio_bitrate: default_audio_bitrate(), threads: None, + gpu: false, + hls_downscale: true, + hls_max_height: default_hls_max_height(), } } @@ -172,7 +267,7 @@ fn default_preset() -> String { } fn default_max_concurrent_transcodes() -> u32 { - 2 + 4 } fn default_crf() -> u32 { @@ -187,6 +282,10 @@ fn default_session_duration() -> String { "7d".to_string() } +fn default_hls_max_height() -> u32 { + 1080 +} + fn default_theme() -> String { "dark".to_string() } @@ -226,6 +325,9 @@ preset = "ultrafast" max_concurrent_transcodes = 2 crf = 23 audio_bitrate = "192k" +gpu = false +hls_downscale = true +hls_max_height = 1080 [auth] jwt_secret = "" @@ -264,8 +366,13 @@ pub fn load_config(cli: &Cli) -> Result { config.server.bind = bind.clone(); } - config.data_dir = data_dir; + config.data_dir = data_dir.clone(); config.log_level = cli.log_level.clone().unwrap_or_else(|| "info".to_string()); + config.log_dir = cli + .log_dir + .as_ref() + .map(PathBuf::from) + .or_else(|| Some(data_dir.join("logs"))); config.open_browser = cli.open || config.server.open_browser; config.admin_user = cli.admin_user.clone(); config.admin_password = cli.admin_password.clone(); @@ -313,6 +420,9 @@ fn ensure_directories(config: &AppConfig) -> Result<()> { let complete_dir = config.data_dir.join("downloads").join("complete"); std::fs::create_dir_all(&complete_dir).context(error::IoSnafu)?; + let posters_dir = config.data_dir.join("downloads").join("posters"); + std::fs::create_dir_all(&posters_dir).context(error::IoSnafu)?; + let dht_dir = config.data_dir.join("dht"); std::fs::create_dir_all(&dht_dir).context(error::IoSnafu)?; diff --git a/backend/src/db/downloads.rs b/backend/src/db/downloads.rs index 370a13a..4a2494c 100644 --- a/backend/src/db/downloads.rs +++ b/backend/src/db/downloads.rs @@ -188,6 +188,26 @@ impl Database { Ok(entries) } + pub async fn delete_download(&self, info_hash: &str) -> Result<()> { + let conn = self.connection().lock().await; + conn.execute( + "DELETE FROM downloads WHERE info_hash = ?1", + rusqlite::params![info_hash], + ) + .context(error::DatabaseSnafu)?; + conn.execute( + "DELETE FROM media_metadata WHERE info_hash = ?1", + rusqlite::params![info_hash], + ) + .context(error::DatabaseSnafu)?; + conn.execute( + "DELETE FROM watch_history WHERE magnet_uri IN (SELECT magnet_uri FROM downloads WHERE info_hash = ?1)", + rusqlite::params![info_hash], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } + pub async fn set_downloading_to_paused(&self) -> Result<()> { let now = Utc::now().to_rfc3339(); let conn = self.connection().lock().await; diff --git a/backend/src/db/favourites.rs b/backend/src/db/favourites.rs new file mode 100644 index 0000000..5d93145 --- /dev/null +++ b/backend/src/db/favourites.rs @@ -0,0 +1,137 @@ +use crate::db::Database; +use crate::error::{self, Result}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use snafu::ResultExt; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FavouriteItem { + pub id: String, + pub user_id: String, + pub content_type: String, + pub title: String, + pub year: Option, + pub rating: Option, + pub poster_url: Option, + pub info_hash: Option, + pub metadata_json: Option, + pub created_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct AddFavouriteRequest { + pub content_type: Option, + pub title: String, + pub year: Option, + pub rating: Option, + pub poster_url: Option, + pub info_hash: Option, + pub metadata_json: Option, +} + +impl Database { + pub async fn add_favourite( + &self, + user_id: &str, + req: &AddFavouriteRequest, + ) -> Result { + let id = Uuid::new_v4().to_string(); + let created_at = Utc::now().to_rfc3339(); + let content_type = req.content_type.as_deref().unwrap_or("movie"); + + let conn = self.connection().lock().await; + conn.execute( + "INSERT INTO favourites (id, user_id, content_type, title, year, rating, poster_url, info_hash, metadata_json, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + id, + user_id, + content_type, + req.title, + req.year, + req.rating, + req.poster_url, + req.info_hash, + req.metadata_json, + created_at, + ], + ) + .context(error::DatabaseSnafu)?; + + Ok(FavouriteItem { + id, + user_id: user_id.to_string(), + content_type: content_type.to_string(), + title: req.title.clone(), + year: req.year, + rating: req.rating, + poster_url: req.poster_url.clone(), + info_hash: req.info_hash.clone(), + metadata_json: req.metadata_json.clone(), + created_at, + }) + } + + pub async fn get_favourites( + &self, + user_id: &str, + content_type: Option<&str>, + ) -> Result> { + let conn = self.connection().lock().await; + + let (sql, params): (&str, Vec>) = match content_type { + Some(ct) => ( + "SELECT id, user_id, content_type, title, year, rating, poster_url, info_hash, metadata_json, created_at \ + FROM favourites WHERE user_id = ?1 AND content_type = ?2 \ + ORDER BY created_at DESC LIMIT 500", + vec![ + Box::new(user_id.to_string()) as Box, + Box::new(ct.to_string()), + ], + ), + None => ( + "SELECT id, user_id, content_type, title, year, rating, poster_url, info_hash, metadata_json, created_at \ + FROM favourites WHERE user_id = ?1 \ + ORDER BY created_at DESC LIMIT 500", + vec![Box::new(user_id.to_string()) as Box], + ), + }; + + let mut stmt = conn.prepare(sql).context(error::DatabaseSnafu)?; + let params_refs: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + + let entries = stmt + .query_map(params_refs.as_slice(), |row| { + Ok(FavouriteItem { + id: row.get(0)?, + user_id: row.get(1)?, + content_type: row.get(2)?, + title: row.get(3)?, + year: row.get(4)?, + rating: row.get(5)?, + poster_url: row.get(6)?, + info_hash: row.get(7)?, + metadata_json: row.get(8)?, + created_at: row.get(9)?, + }) + }) + .context(error::DatabaseSnafu)? + .collect::, _>>() + .context(error::DatabaseSnafu)?; + + Ok(entries) + } + + pub async fn delete_favourite(&self, id: &str, user_id: &str) -> Result { + let conn = self.connection().lock().await; + let rows = conn + .execute( + "DELETE FROM favourites WHERE id = ?1 AND user_id = ?2", + rusqlite::params![id, user_id], + ) + .context(error::DatabaseSnafu)?; + Ok(rows > 0) + } +} diff --git a/backend/src/db/history.rs b/backend/src/db/history.rs index 8cdd486..45ddc82 100644 --- a/backend/src/db/history.rs +++ b/backend/src/db/history.rs @@ -27,6 +27,27 @@ pub struct WatchEntry { pub watched_at: String, } +/// Enriched watch entry that includes info_hash and title from the downloads table. +#[derive(Debug, Clone, Serialize)] +pub struct EnrichedWatchEntry { + pub id: String, + pub magnet_uri: String, + pub title: String, + pub file_name: Option, + pub duration_seconds: Option, + pub watched_seconds: Option, + pub poster_url: Option, + pub watched_at: String, + pub info_hash: Option, + pub file_size: Option, + pub year: Option, + pub rating: Option, + pub runtime: Option, + pub genres: Option, + pub summary: Option, + pub imdb_code: Option, +} + impl Database { pub async fn add_search( &self, @@ -86,15 +107,16 @@ impl Database { magnet_uri: &str, title: &str, file_name: Option<&str>, + poster_url: Option<&str>, ) -> Result { let id = Uuid::new_v4().to_string(); let watched_at = Utc::now().to_rfc3339(); let conn = self.connection().lock().await; conn.execute( - "INSERT INTO watch_history (id, user_id, magnet_uri, title, file_name, watched_at) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - rusqlite::params![id, user_id, magnet_uri, title, file_name, watched_at], + "INSERT INTO watch_history (id, user_id, magnet_uri, title, file_name, poster_url, watched_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![id, user_id, magnet_uri, title, file_name, poster_url, watched_at], ) .context(error::DatabaseSnafu)?; @@ -106,7 +128,7 @@ impl Database { file_name: file_name.map(String::from), duration_seconds: None, watched_seconds: None, - poster_url: None, + poster_url: poster_url.map(String::from), watched_at, }) } @@ -153,6 +175,79 @@ impl Database { Ok(entries) } + pub async fn get_watch_history_enriched( + &self, + user_id: &str, + ) -> Result> { + let conn = self.connection().lock().await; + let mut stmt = conn + .prepare( + "SELECT w.id, w.magnet_uri, w.title, w.file_name, \ + w.duration_seconds, w.watched_seconds, w.poster_url, w.watched_at, \ + d.info_hash, d.title AS dl_title, d.file_name AS dl_file_name, d.file_size, \ + m.local_poster, m.year, m.rating, m.runtime, m.genres, m.summary, m.imdb_code \ + FROM ( \ + SELECT *, ROW_NUMBER() OVER (PARTITION BY magnet_uri ORDER BY watched_at DESC) AS rn \ + FROM watch_history WHERE user_id = ?1 \ + ) w \ + LEFT JOIN downloads d ON w.magnet_uri = d.magnet_uri \ + LEFT JOIN media_metadata m ON d.info_hash = m.info_hash \ + WHERE w.rn = 1 \ + ORDER BY w.watched_at DESC LIMIT 100", + ) + .context(error::DatabaseSnafu)?; + + let entries = stmt + .query_map(rusqlite::params![user_id], |row| { + let watch_title: String = row.get(2)?; + let dl_title: Option = row.get(9)?; + let watch_file_name: Option = row.get(3)?; + let dl_file_name: Option = row.get(10)?; + let info_hash: Option = row.get(8)?; + + // Prefer download title over watch_history title (which may be the info_hash) + let title = match &dl_title { + Some(t) if !t.is_empty() => t.clone(), + _ => watch_title, + }; + + // Prefer download file_name over watch_history file_name + let file_name = match &dl_file_name { + Some(f) if !f.is_empty() => Some(f.clone()), + _ => watch_file_name, + }; + + // Prefer local poster over remote poster URL + let watch_poster: Option = row.get(6)?; + let local_poster: Option = row.get(12)?; + let poster_url = local_poster.or(watch_poster); + + Ok(EnrichedWatchEntry { + id: row.get(0)?, + magnet_uri: row.get(1)?, + title, + file_name, + duration_seconds: row.get(4)?, + watched_seconds: row.get(5)?, + poster_url, + watched_at: row.get(7)?, + info_hash, + file_size: row.get(11)?, + year: row.get(13)?, + rating: row.get(14)?, + runtime: row.get(15)?, + genres: row.get(16)?, + summary: row.get(17)?, + imdb_code: row.get(18)?, + }) + }) + .context(error::DatabaseSnafu)? + .collect::, _>>() + .context(error::DatabaseSnafu)?; + + Ok(entries) + } + pub async fn delete_watch(&self, id: &str) -> Result<()> { let conn = self.connection().lock().await; conn.execute( diff --git a/backend/src/db/metadata.rs b/backend/src/db/metadata.rs new file mode 100644 index 0000000..1ffe2f9 --- /dev/null +++ b/backend/src/db/metadata.rs @@ -0,0 +1,129 @@ +use crate::db::Database; +use crate::error::{self, Result}; +use serde::{Deserialize, Serialize}; +use snafu::ResultExt; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MediaMetadata { + pub info_hash: String, + pub title: String, + pub year: Option, + pub rating: Option, + pub runtime: Option, + pub genres: Option, + pub language: Option, + pub mpa_rating: Option, + pub summary: Option, + pub imdb_code: Option, + pub trailer_code: Option, + pub video_codec: Option, + pub audio_channels: Option, + pub bit_depth: Option, + pub source_type: Option, + pub poster_small: Option, + pub poster_medium: Option, + pub poster_large: Option, + pub backdrop: Option, + pub local_poster: Option, + pub created_at: String, +} + +impl Database { + pub async fn upsert_metadata(&self, meta: &MediaMetadata) -> Result<()> { + let conn = self.connection().lock().await; + conn.execute( + "INSERT INTO media_metadata (info_hash, title, year, rating, runtime, genres, \ + language, mpa_rating, summary, imdb_code, trailer_code, video_codec, \ + audio_channels, bit_depth, source_type, poster_small, poster_medium, \ + poster_large, backdrop, local_poster, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21) \ + ON CONFLICT(info_hash) DO UPDATE SET \ + title = ?2, year = ?3, rating = ?4, runtime = ?5, genres = ?6, \ + language = ?7, mpa_rating = ?8, summary = ?9, imdb_code = ?10, \ + trailer_code = ?11, video_codec = ?12, audio_channels = ?13, \ + bit_depth = ?14, source_type = ?15, poster_small = ?16, \ + poster_medium = ?17, poster_large = ?18, backdrop = ?19, \ + local_poster = COALESCE(?20, local_poster)", + rusqlite::params![ + meta.info_hash, + meta.title, + meta.year, + meta.rating, + meta.runtime, + meta.genres, + meta.language, + meta.mpa_rating, + meta.summary, + meta.imdb_code, + meta.trailer_code, + meta.video_codec, + meta.audio_channels, + meta.bit_depth, + meta.source_type, + meta.poster_small, + meta.poster_medium, + meta.poster_large, + meta.backdrop, + meta.local_poster, + meta.created_at, + ], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } + + pub async fn get_metadata(&self, info_hash: &str) -> Result> { + let conn = self.connection().lock().await; + let mut stmt = conn + .prepare( + "SELECT info_hash, title, year, rating, runtime, genres, language, \ + mpa_rating, summary, imdb_code, trailer_code, video_codec, \ + audio_channels, bit_depth, source_type, poster_small, poster_medium, \ + poster_large, backdrop, local_poster, created_at \ + FROM media_metadata WHERE info_hash = ?1", + ) + .context(error::DatabaseSnafu)?; + + let result = stmt.query_row(rusqlite::params![info_hash], |row| { + Ok(MediaMetadata { + info_hash: row.get(0)?, + title: row.get(1)?, + year: row.get(2)?, + rating: row.get(3)?, + runtime: row.get(4)?, + genres: row.get(5)?, + language: row.get(6)?, + mpa_rating: row.get(7)?, + summary: row.get(8)?, + imdb_code: row.get(9)?, + trailer_code: row.get(10)?, + video_codec: row.get(11)?, + audio_channels: row.get(12)?, + bit_depth: row.get(13)?, + source_type: row.get(14)?, + poster_small: row.get(15)?, + poster_medium: row.get(16)?, + poster_large: row.get(17)?, + backdrop: row.get(18)?, + local_poster: row.get(19)?, + created_at: row.get(20)?, + }) + }); + + match result { + Ok(m) => Ok(Some(m)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e).context(error::DatabaseSnafu), + } + } + + pub async fn update_local_poster(&self, info_hash: &str, local_poster: &str) -> Result<()> { + let conn = self.connection().lock().await; + conn.execute( + "UPDATE media_metadata SET local_poster = ?1 WHERE info_hash = ?2", + rusqlite::params![local_poster, info_hash], + ) + .context(error::DatabaseSnafu)?; + Ok(()) + } +} diff --git a/backend/src/db/migrations.rs b/backend/src/db/migrations.rs index ba8770f..4311d67 100644 --- a/backend/src/db/migrations.rs +++ b/backend/src/db/migrations.rs @@ -68,6 +68,44 @@ const MIGRATIONS: &[&str] = &[ created_at TEXT NOT NULL, updated_at TEXT NOT NULL );", + // Migration 7: Create media_metadata table for rich metadata and local posters + "CREATE TABLE IF NOT EXISTS media_metadata ( + info_hash TEXT PRIMARY KEY, + title TEXT NOT NULL DEFAULT '', + year INTEGER, + rating REAL, + runtime INTEGER, + genres TEXT, + language TEXT, + mpa_rating TEXT, + summary TEXT, + imdb_code TEXT, + trailer_code TEXT, + video_codec TEXT, + audio_channels TEXT, + bit_depth TEXT, + source_type TEXT, + poster_small TEXT, + poster_medium TEXT, + poster_large TEXT, + backdrop TEXT, + local_poster TEXT, + created_at TEXT NOT NULL + );", + // Migration 8: Create favourites table + "CREATE TABLE IF NOT EXISTS favourites ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + content_type TEXT NOT NULL DEFAULT 'movie', + title TEXT NOT NULL, + year INTEGER, + rating REAL, + poster_url TEXT, + info_hash TEXT, + metadata_json TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_favourites_user ON favourites(user_id, content_type);", ]; pub fn run_migrations(conn: &Connection) -> Result<()> { diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 8f9b10e..34ebe9b 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -1,5 +1,7 @@ pub mod downloads; +pub mod favourites; pub mod history; +pub mod metadata; pub mod migrations; pub mod settings; pub mod users; diff --git a/backend/src/lib.rs b/backend/src/lib.rs index ef55317..8cef56b 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; pub mod db; pub mod embedded; pub mod error; +pub mod logging; pub mod server; pub mod torrent; pub mod transcode; diff --git a/backend/src/logging.rs b/backend/src/logging.rs new file mode 100644 index 0000000..17e2024 --- /dev/null +++ b/backend/src/logging.rs @@ -0,0 +1,122 @@ +use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::sync::broadcast; +use tracing_subscriber::Layer; + +const HISTORY_SIZE: usize = 500; +static SEQ: AtomicU64 = AtomicU64::new(1); + +pub struct LogHistory { + entries: Mutex>, +} + +impl LogHistory { + fn new() -> Self { + Self { + entries: Mutex::new(VecDeque::with_capacity(HISTORY_SIZE)), + } + } + + fn push(&self, entry: String) { + if let Ok(mut entries) = self.entries.lock() { + if entries.len() >= HISTORY_SIZE { + entries.pop_front(); + } + entries.push_back(entry); + } + } + + pub fn recent(&self) -> Vec { + self.entries + .lock() + .map(|e| e.iter().cloned().collect()) + .unwrap_or_default() + } +} + +pub struct BroadcastLayer { + tx: broadcast::Sender, + history: Arc, +} + +impl BroadcastLayer { + pub fn new(tx: broadcast::Sender) -> (Self, Arc) { + let history = Arc::new(LogHistory::new()); + ( + Self { + tx, + history: history.clone(), + }, + history, + ) + } +} + +impl Layer for BroadcastLayer +where + S: tracing::Subscriber, +{ + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + let meta = event.metadata(); + let level = meta.level().as_str(); + let target = meta.target(); + + let mut visitor = MessageVisitor { + message: String::new(), + }; + event.record(&mut visitor); + + let ts = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + + let json = match serde_json::to_string(&serde_json::json!({ + "seq": seq, + "ts": ts, + "level": level, + "target": target, + "message": visitor.message, + })) { + Ok(j) => j, + Err(_) => return, + }; + + self.history.push(json.clone()); + + if self.tx.receiver_count() > 0 { + let _ = self.tx.send(json); + } + } +} + +struct MessageVisitor { + message: String, +} + +impl tracing::field::Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.message = format!("{value:?}"); + } else if self.message.is_empty() { + self.message = format!("{}: {value:?}", field.name()); + } else { + self.message + .push_str(&format!(" {}: {value:?}", field.name())); + } + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + if field.name() == "message" { + self.message = value.to_string(); + } else if self.message.is_empty() { + self.message = format!("{}: {value}", field.name()); + } else { + self.message + .push_str(&format!(" {}: {value}", field.name())); + } + } +} diff --git a/backend/src/main.rs b/backend/src/main.rs index fcbe9a9..203d59a 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -4,10 +4,13 @@ use streamx::cli; use streamx::config; use streamx::db; use streamx::error::{self, Result}; +use streamx::logging::BroadcastLayer; use streamx::server; use streamx::torrent; use streamx::transcode; use tracing::info; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; #[tokio::main] async fn main() -> Result<()> { @@ -21,10 +24,35 @@ async fn main() -> Result<()> { let filter = tracing_subscriber::EnvFilter::try_new(&config.log_level) .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); - tracing_subscriber::fmt() - .with_env_filter(filter) - .with_target(true) - .init(); + let (log_tx, _) = tokio::sync::broadcast::channel::(1000); + let (broadcast_layer, log_history) = BroadcastLayer::new(log_tx.clone()); + + let _log_guard: Option; + if let Some(ref log_dir) = config.log_dir { + std::fs::create_dir_all(log_dir).map_err(|e| error::Error::Config { + message: format!("Failed to create log directory: {e}"), + })?; + let file_appender = tracing_appender::rolling::daily(log_dir, "streamx.log"); + let (non_blocking, guard) = tracing_appender::non_blocking(file_appender); + _log_guard = Some(guard); + tracing_subscriber::registry() + .with(filter) + .with( + tracing_subscriber::fmt::layer() + .with_target(true) + .with_ansi(false) + .with_writer(non_blocking), + ) + .with(broadcast_layer) + .init(); + } else { + _log_guard = None; + tracing_subscriber::registry() + .with(filter) + .with(tracing_subscriber::fmt::layer().with_target(true)) + .with(broadcast_layer) + .init(); + } info!( version = env!("CARGO_PKG_VERSION"), @@ -57,9 +85,25 @@ async fn main() -> Result<()> { database.set_downloading_to_paused().await?; info!("Reset in-flight downloads to paused state"); - let torrent_engine = - torrent::TorrentEngine::create(&config.torrent, &config.data_dir, database.clone()).await?; - let search_provider = torrent::SearchProvider::new(); + let socks5 = config.vpn.as_ref().map(|v| v.resolved_url()); + if let Some(ref url) = socks5 { + // Log without credentials + let safe = if let Some(at) = url.find('@') { + let proto_end = url.find("://").unwrap_or(0) + 3; + format!("{}***@{}", &url[..proto_end], &url[at + 1..]) + } else { + url.clone() + }; + info!(proxy = %safe, "VPN SOCKS5 proxy configured"); + } + let torrent_engine = torrent::TorrentEngine::create( + &config.torrent, + &config.data_dir, + database.clone(), + socks5.clone(), + ) + .await?; + let search_provider = torrent::SearchProvider::new(config.providers.clone(), socks5); let cache_dir = config.data_dir.join("cache"); let hls_pipeline = transcode::HlsManager::new(&config.transcode, cache_dir).await?; @@ -73,6 +117,8 @@ async fn main() -> Result<()> { torrent_engine, search_provider, hls_pipeline, + log_tx, + log_history, ); let addr: SocketAddr = @@ -82,6 +128,9 @@ async fn main() -> Result<()> { message: format!("Invalid bind address: {bind_addr}:{port}"), })?; + // Kill orphaned FFmpeg processes from previous server instances + kill_orphaned_ffmpeg(); + info!(%addr, "Server listening"); if open_browser { @@ -96,10 +145,27 @@ async fn main() -> Result<()> { source, })?; + // Graceful shutdown: catch SIGTERM/SIGINT, kill FFmpeg children + let shutdown = async { + let mut sigterm = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler"); + let mut sigint = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .expect("failed to install SIGINT handler"); + tokio::select! { + _ = sigterm.recv() => info!("Received SIGTERM"), + _ = sigint.recv() => info!("Received SIGINT"), + } + info!("Shutting down, killing FFmpeg children..."); + kill_all_streamx_ffmpeg(); + }; + axum::serve( listener, app.into_make_service_with_connect_info::(), ) + .with_graceful_shutdown(shutdown) .await .map_err(|source| error::Error::ServerBind { address: addr.to_string(), @@ -109,6 +175,63 @@ async fn main() -> Result<()> { Ok(()) } +fn kill_orphaned_ffmpeg() { + let our_pid = std::process::id().to_string(); + if let Ok(entries) = std::fs::read_dir("/proc") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let pid: u32 = match name_str.parse() { + Ok(p) => p, + Err(_) => continue, + }; + let cmdline = match std::fs::read_to_string(entry.path().join("cmdline")) { + Ok(c) => c, + Err(_) => continue, + }; + if !cmdline.contains("ffmpeg") || !cmdline.contains(".streamx/cache") { + continue; + } + // Check if it's our child (skip those) + if let Ok(stat) = std::fs::read_to_string(entry.path().join("stat")) { + let ppid = stat.split_whitespace().nth(3).unwrap_or(""); + if ppid == our_pid { + continue; + } + } + tracing::warn!(pid, "Killing orphaned FFmpeg process"); + unsafe { libc::kill(pid as i32, libc::SIGTERM); } + } + } +} + +fn kill_all_streamx_ffmpeg() { + if let Ok(entries) = std::fs::read_dir("/proc") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let pid: i32 = match name_str.parse() { + Ok(p) => p, + Err(_) => continue, + }; + let cmdline = match std::fs::read_to_string(entry.path().join("cmdline")) { + Ok(c) => c, + Err(_) => continue, + }; + if cmdline.contains("ffmpeg") && cmdline.contains(".streamx/cache") { + tracing::info!(pid, "Sending SIGTERM to FFmpeg process"); + unsafe { libc::kill(pid, libc::SIGTERM); } + } + } + } +} + fn run_command(cmd: &cli::Command, config: &config::AppConfig) -> Result<()> { let data_dir = &config.data_dir; diff --git a/backend/src/server/admin.rs b/backend/src/server/admin.rs new file mode 100644 index 0000000..4346f4b --- /dev/null +++ b/backend/src/server/admin.rs @@ -0,0 +1,502 @@ +use crate::error::Error; +use crate::server::auth::Claims; +use crate::server::AppState; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use serde::Serialize; +use std::sync::atomic::Ordering; + +#[derive(Serialize)] +struct SystemStats { + disk: DiskStats, + process: ProcessStats, + users: UserStats, + streams: Vec, + downloads: Vec, +} + +#[derive(Serialize)] +struct DiskStats { + total_bytes: u64, + free_bytes: u64, + cache_bytes: u64, + downloads_bytes: u64, +} + +#[derive(Serialize)] +struct ProcessStats { + rss_bytes: u64, + cpu_percent: f32, + ffmpeg_count: u32, +} + +#[derive(Serialize)] +struct UserStats { + active_connections: u32, +} + +#[derive(Serialize)] +struct ActiveStream { + stream_id: String, + quality: String, + status: String, + title: String, + file_size: u64, + cache_bytes: u64, + last_activity: String, +} + +#[derive(Serialize)] +struct ActiveDownload { + stream_id: String, + title: String, + file_name: String, + file_size: u64, + progress: f64, + speed: u64, + peers: u32, + status: String, + created_at: String, + updated_at: String, +} + +pub async fn admin_monitor_ws( + State(state): State, + claims: Claims, + ws: WebSocketUpgrade, +) -> Result { + let user = state + .db + .find_user_by_id(&claims.user_id) + .await? + .ok_or_else(|| Error::Unauthorized { + message: "User not found".to_string(), + })?; + + if !user.is_admin { + return Err(Error::Unauthorized { + message: "Admin access required".to_string(), + }); + } + + Ok(ws.on_upgrade(move |socket| handle_admin_ws(socket, state))) +} + +async fn handle_admin_ws(mut socket: WebSocket, state: AppState) { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(2)); + let mut prev_cpu_ticks: Option<(u64, u64)> = None; + let mut tick_count: u32 = 0; + let mut cached_disk = DiskStats { + total_bytes: 0, + free_bytes: 0, + cache_bytes: 0, + downloads_bytes: 0, + }; + + loop { + tokio::select! { + _ = interval.tick() => { + // Disk stats: recompute dir sizes every 10 seconds (expensive) + if tick_count % 5 == 0 { + let data_dir = state.config.data_dir.clone(); + cached_disk = tokio::task::spawn_blocking(move || { + collect_disk_stats(&data_dir) + }) + .await + .unwrap_or(cached_disk); + } + + let (rss, cpu, prev) = collect_process_stats(prev_cpu_ticks); + prev_cpu_ticks = Some(prev); + + let ffmpeg_count = count_ffmpeg_children(); + + let active_ws = state.ws_connections.load(Ordering::Relaxed); + let active_transcodes = state.hls_pipeline.active_streams().await; + + // Detect running FFmpeg output paths to mark active transcodes + let running_paths = detect_running_ffmpeg_outputs(); + + let mut streams = Vec::with_capacity(active_transcodes.len()); + for info in &active_transcodes { + let (title, file_size) = match state + .torrent_engine + .get_download(&info.stream_id) + .await + { + Ok(Some(dl)) => (dl.title, dl.file_size), + _ => (String::new(), 0), + }; + // Override "cached" to "running" if FFmpeg is actively writing to this tier + let status = if info.status == "cached" { + let pattern = format!("{}/{}/", info.stream_id, info.quality); + if running_paths.iter().any(|p| p.contains(&pattern)) { + "running".to_string() + } else { + info.status.clone() + } + } else { + info.status.clone() + }; + streams.push(ActiveStream { + stream_id: info.stream_id.clone(), + quality: info.quality.clone(), + status, + title, + file_size, + cache_bytes: info.cache_bytes, + last_activity: info.last_activity.clone(), + }); + } + + let downloads = match state.torrent_engine.list_downloads().await { + Ok(mut all) => { + // Sort by created_at descending (most recent first) + all.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + all.truncate(20); + let mut dls = Vec::with_capacity(all.len()); + for dl in all { + let is_active = + dl.status == "downloading" || dl.status == "initializing"; + let (peers, speed) = if is_active { + state.torrent_engine.get_live_stats(&dl.info_hash).await + } else { + (0, 0.0) + }; + dls.push(ActiveDownload { + stream_id: dl.info_hash, + title: dl.title, + file_name: dl.file_name, + file_size: dl.file_size, + progress: dl.progress, + speed: speed as u64, + peers, + status: dl.status, + created_at: dl.created_at, + updated_at: dl.updated_at, + }); + } + dls + } + Err(_) => Vec::new(), + }; + + let stats = SystemStats { + disk: DiskStats { ..cached_disk }, + process: ProcessStats { + rss_bytes: rss, + cpu_percent: cpu, + ffmpeg_count, + }, + users: UserStats { + active_connections: active_ws, + }, + streams, + downloads, + }; + + let json = match serde_json::to_string(&stats) { + Ok(j) => j, + Err(_) => continue, + }; + + if socket.send(Message::Text(json.into())).await.is_err() { + break; + } + + tick_count += 1; + } + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => break, + Some(Err(_)) => break, + _ => {} + } + } + } + } +} + +fn collect_disk_stats(data_dir: &std::path::Path) -> DiskStats { + let (total, free) = disk_space(data_dir); + let cache_bytes = dir_size(&data_dir.join("cache")); + let downloads_bytes = dir_size(&data_dir.join("downloads")); + + DiskStats { + total_bytes: total, + free_bytes: free, + cache_bytes, + downloads_bytes, + } +} + +fn disk_space(path: &std::path::Path) -> (u64, u64) { + let c_path = match std::ffi::CString::new(path.to_string_lossy().as_bytes()) { + Ok(p) => p, + Err(_) => return (0, 0), + }; + + unsafe { + let mut stat: libc::statvfs = std::mem::zeroed(); + if libc::statvfs(c_path.as_ptr(), &mut stat) == 0 { + let total = stat.f_blocks as u64 * stat.f_frsize as u64; + let free = stat.f_bavail as u64 * stat.f_frsize as u64; + (total, free) + } else { + (0, 0) + } + } +} + +fn dir_size(path: &std::path::Path) -> u64 { + if !path.exists() { + return 0; + } + let mut total = 0u64; + let mut stack = vec![path.to_path_buf()]; + while let Some(dir) = stack.pop() { + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() { + if meta.is_dir() { + stack.push(entry.path()); + } else { + total += meta.len(); + } + } + } + } + } + total +} + +fn collect_process_stats(prev: Option<(u64, u64)>) -> (u64, f32, (u64, u64)) { + let rss = read_rss_bytes(); + let (utime, stime) = read_cpu_ticks(); + let total_now = utime + stime; + let wall_now = wall_clock_ticks(); + + let cpu = if let Some((prev_total, prev_wall)) = prev { + let dt = total_now.saturating_sub(prev_total) as f32; + let dw = wall_now.saturating_sub(prev_wall).max(1) as f32; + (dt / dw * 100.0).min(100.0 * num_cpus() as f32) + } else { + 0.0 + }; + + (rss, cpu, (total_now, wall_now)) +} + +fn read_rss_bytes() -> u64 { + let stat = match std::fs::read_to_string("/proc/self/stat") { + Ok(s) => s, + Err(_) => return 0, + }; + // Field 23 (0-indexed) is RSS in pages + let rss_pages: u64 = stat + .split_whitespace() + .nth(23) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) as u64 }; + rss_pages * page_size +} + +fn read_cpu_ticks() -> (u64, u64) { + let stat = match std::fs::read_to_string("/proc/self/stat") { + Ok(s) => s, + Err(_) => return (0, 0), + }; + let fields: Vec<&str> = stat.split_whitespace().collect(); + let utime: u64 = fields.get(13).and_then(|s| s.parse().ok()).unwrap_or(0); + let stime: u64 = fields.get(14).and_then(|s| s.parse().ok()).unwrap_or(0); + (utime, stime) +} + +fn wall_clock_ticks() -> u64 { + let uptime = std::fs::read_to_string("/proc/uptime").unwrap_or_default(); + let secs: f64 = uptime + .split_whitespace() + .next() + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0); + let ticks_per_sec = unsafe { libc::sysconf(libc::_SC_CLK_TCK) as f64 }; + (secs * ticks_per_sec) as u64 +} + +fn num_cpus() -> u32 { + std::thread::available_parallelism() + .map(|n| n.get() as u32) + .unwrap_or(1) +} + +fn count_ffmpeg_children() -> u32 { + let mut count = 0u32; + if let Ok(entries) = std::fs::read_dir("/proc") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let cmdline_path = entry.path().join("cmdline"); + if let Ok(cmdline) = std::fs::read_to_string(&cmdline_path) { + // Count any FFmpeg process writing to a streamx cache directory + if cmdline.contains("ffmpeg") && cmdline.contains(".streamx/cache") { + count += 1; + } + } + } + } + count +} + +fn detect_running_ffmpeg_outputs() -> Vec { + let mut paths = Vec::new(); + let entries = match std::fs::read_dir("/proc") { + Ok(e) => e, + Err(_) => return paths, + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let cmdline_path = entry.path().join("cmdline"); + if let Ok(cmdline) = std::fs::read_to_string(&cmdline_path) { + let args: Vec<&str> = cmdline.split('\0').collect(); + if args.first().map(|a| a.contains("ffmpeg")).unwrap_or(false) { + // Last non-empty arg is typically the output path + if let Some(output) = args.iter().rev().find(|a| !a.is_empty() && a.contains('/')) + { + paths.push(output.to_string()); + } + } + } + } + paths +} + +pub async fn kill_transcode( + State(state): State, + claims: Claims, + Path(stream_id): Path, +) -> Result { + let user = state + .db + .find_user_by_id(&claims.user_id) + .await? + .ok_or_else(|| Error::Unauthorized { + message: "User not found".to_string(), + })?; + + if !user.is_admin { + return Err(Error::Unauthorized { + message: "Admin access required".to_string(), + }); + } + + // Kill any FFmpeg processes writing to this stream's cache + let mut killed = 0u32; + if let Ok(entries) = std::fs::read_dir("/proc") { + for entry in entries.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if !name_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let pid: i32 = match name_str.parse() { + Ok(p) => p, + Err(_) => continue, + }; + let cmdline = match std::fs::read_to_string(entry.path().join("cmdline")) { + Ok(c) => c, + Err(_) => continue, + }; + if cmdline.contains("ffmpeg") && cmdline.contains(&stream_id) { + tracing::info!(stream_id = %stream_id, pid, "Admin killing FFmpeg process"); + unsafe { libc::kill(pid, libc::SIGTERM); } + killed += 1; + } + } + } + + // Also remove from active transcodes + state.hls_pipeline.cleanup(&stream_id).await.ok(); + + Ok(axum::Json(serde_json::json!({ "killed": killed }))) +} + +pub async fn admin_logs_ws( + State(state): State, + claims: Claims, + axum::extract::Query(params): axum::extract::Query>, + ws: WebSocketUpgrade, +) -> Result { + let user = state + .db + .find_user_by_id(&claims.user_id) + .await? + .ok_or_else(|| Error::Unauthorized { + message: "User not found".to_string(), + })?; + + if !user.is_admin { + return Err(Error::Unauthorized { + message: "Admin access required".to_string(), + }); + } + + let after_seq: u64 = params + .get("after") + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + Ok(ws.on_upgrade(move |socket| handle_logs_ws(socket, state, after_seq))) +} + +async fn handle_logs_ws(mut socket: WebSocket, state: AppState, after_seq: u64) { + // Send history entries newer than the client's last seen seq + for line in state.log_history.recent() { + let seq = serde_json::from_str::(&line) + .ok() + .and_then(|v| v.get("seq")?.as_u64()) + .unwrap_or(0); + if seq <= after_seq { + continue; + } + if socket.send(Message::Text(line.into())).await.is_err() { + return; + } + } + + let mut rx = state.log_tx.subscribe(); + + loop { + tokio::select! { + result = rx.recv() => { + match result { + Ok(line) => { + if socket.send(Message::Text(line.into())).await.is_err() { + break; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::debug!("Log subscriber lagged, skipped {n} messages"); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + msg = socket.recv() => { + match msg { + Some(Ok(Message::Close(_))) | None => break, + Some(Err(_)) => break, + _ => {} + } + } + } + } +} diff --git a/backend/src/server/api.rs b/backend/src/server/api.rs index 266e0f1..8de90f6 100644 --- a/backend/src/server/api.rs +++ b/backend/src/server/api.rs @@ -1,5 +1,8 @@ +use crate::db::favourites::AddFavouriteRequest; +use crate::db::metadata::MediaMetadata; use crate::error::Error; use crate::server::auth::Claims; +use crate::server::proxy; use crate::server::AppState; use axum::extract::{Path, State}; use axum::response::IntoResponse; @@ -13,13 +16,33 @@ pub struct SearchRequest { #[derive(Debug, Serialize)] pub struct SearchResponse { - pub results: Vec, + pub results: Vec, } #[derive(Debug, Deserialize)] pub struct CreateStreamRequest { pub magnet_uri: String, pub file_index: Option, + pub poster_url: Option, + // Rich metadata from search results + pub title: Option, + pub year: Option, + pub rating: Option, + pub runtime: Option, + pub genres: Option>, + pub language: Option, + pub video_codec: Option, + pub audio_channels: Option, + pub source_type: Option, + pub summary: Option, + pub imdb_code: Option, + pub mpa_rating: Option, + pub bit_depth: Option, + pub trailer_code: Option, + pub poster_small: Option, + pub poster_medium: Option, + pub poster_large: Option, + pub backdrop: Option, } #[derive(Debug, Deserialize)] @@ -55,6 +78,35 @@ pub async fn search( Ok(Json(SearchResponse { results })) } +pub async fn browse( + State(state): State, + _claims: Claims, + axum::extract::Query(params): axum::extract::Query>, +) -> std::result::Result { + let sort_by = params + .get("sort_by") + .map(|s| s.as_str()) + .unwrap_or("date_added"); + let genre = params.get("genre").map(|s| s.as_str()); + let minimum_rating = params.get("minimum_rating").and_then(|s| s.parse().ok()); + let limit = params + .get("limit") + .and_then(|s| s.parse().ok()) + .unwrap_or(10u32) + .min(20); + let page = params + .get("page") + .and_then(|s| s.parse().ok()) + .unwrap_or(1u32); + + let results = state + .search_provider + .browse(sort_by, genre, minimum_rating, limit, page) + .await?; + + Ok(Json(serde_json::json!({ "results": results }))) +} + pub async fn search_history( State(state): State, claims: Claims, @@ -87,9 +139,80 @@ pub async fn create_stream( }; let _ = state .db - .add_watch(&claims.user_id, magnet_uri, title, None) + .add_watch( + &claims.user_id, + magnet_uri, + title, + None, + body.poster_url.as_deref(), + ) .await; + // Store rich metadata if we have any + let info_hash = download.info_hash.clone(); + let has_metadata = body.title.is_some() || body.poster_url.is_some(); + if has_metadata { + let meta = MediaMetadata { + info_hash: info_hash.clone(), + title: body.title.clone().unwrap_or_else(|| download.title.clone()), + year: body.year.map(|y| y as i32), + rating: body.rating, + runtime: body.runtime.map(|r| r as i32), + genres: body.genres.as_ref().map(|g| g.join(",")), + language: body.language.clone(), + mpa_rating: body.mpa_rating.clone(), + summary: body.summary.clone(), + imdb_code: body.imdb_code.clone(), + trailer_code: body.trailer_code.clone(), + video_codec: body.video_codec.clone(), + audio_channels: body.audio_channels.clone(), + bit_depth: body.bit_depth.clone(), + source_type: body.source_type.clone(), + poster_small: body.poster_small.clone(), + poster_medium: body.poster_medium.clone(), + poster_large: body.poster_large.clone(), + backdrop: body.backdrop.clone(), + local_poster: None, + created_at: chrono::Utc::now().to_rfc3339(), + }; + let _ = state.db.upsert_metadata(&meta).await; + } + + // Spawn background poster download + let poster_url = body.poster_large.or(body.poster_medium).or(body.poster_url); + if let Some(url) = poster_url { + // Resolve proxy URLs back to absolute upstream URLs for downloading + let download_url = proxy::resolve_proxy_url(&url, &state.config.providers); + let db = state.db.clone(); + let data_dir = state.config.data_dir.clone(); + let hash = info_hash.clone(); + tokio::spawn(async move { + let posters_dir = data_dir.join("downloads").join("posters"); + if let Err(e) = tokio::fs::create_dir_all(&posters_dir).await { + tracing::warn!(info_hash = %hash, "Failed to create posters dir: {e}"); + return; + } + let dest = posters_dir.join(format!("{hash}.jpg")); + if dest.exists() { + let _ = db + .update_local_poster(&hash, &format!("/api/posters/{hash}.jpg")) + .await; + return; + } + match download_poster(&download_url, &dest).await { + Ok(()) => { + tracing::info!(info_hash = %hash, "Poster downloaded"); + let _ = db + .update_local_poster(&hash, &format!("/api/posters/{hash}.jpg")) + .await; + } + Err(e) => { + tracing::warn!(info_hash = %hash, "Failed to download poster: {e}"); + } + } + }); + } + Ok(Json(serde_json::json!({ "stream_id": download.info_hash, "status": download.status, @@ -98,6 +221,16 @@ pub async fn create_stream( }))) } +async fn download_poster( + url: &str, + dest: &std::path::Path, +) -> std::result::Result<(), Box> { + let resp = reqwest::get(url).await?; + let bytes = resp.bytes().await?; + tokio::fs::write(dest, &bytes).await?; + Ok(()) +} + pub async fn get_stream( State(state): State, _claims: Claims, @@ -150,15 +283,44 @@ pub async fn delete_stream( _claims: Claims, Path(id): Path, ) -> std::result::Result { + // Stop torrent + let _ = state.torrent_engine.pause(&id).await; + + // Delete files on disk + if let Ok(Some(dl)) = state.torrent_engine.get_download(&id).await { + if let Some(ref p) = dl.partial_path { + let path = std::path::PathBuf::from(p); + if path.exists() { + let parent = path.parent().map(|p| p.to_path_buf()); + let _ = tokio::fs::remove_file(&path).await; + if let Some(dir) = parent { + let _ = tokio::fs::remove_dir(&dir).await; + } + } + } + if let Some(ref p) = dl.complete_path { + let path = std::path::PathBuf::from(p); + if path.exists() { + let _ = tokio::fs::remove_file(&path).await; + } + } + } + + // Clean HLS cache state.hls_pipeline.cleanup(&id).await?; - Ok(Json(serde_json::json!({ "status": "stopped" }))) + + // Delete DB records + state.db.delete_download(&id).await?; + + tracing::info!(stream_id = %id, "Stream deleted"); + Ok(Json(serde_json::json!({ "status": "deleted" }))) } pub async fn get_history( State(state): State, claims: Claims, ) -> std::result::Result { - let items = state.db.get_watch_history(&claims.user_id).await?; + let items = state.db.get_watch_history_enriched(&claims.user_id).await?; Ok(Json(serde_json::json!({ "items": items }))) } @@ -255,3 +417,309 @@ pub async fn get_demo_stream() -> impl IntoResponse { pub async fn demo_playlist() -> impl IntoResponse { axum::response::Redirect::temporary(DEMO_HLS_URL) } + +static TRAILER_SEARCH_CACHE: std::sync::LazyLock> = + std::sync::LazyLock::new(dashmap::DashMap::new); + +pub async fn trailer_search( + State(state): State, + axum::extract::Query(params): axum::extract::Query>, +) -> std::result::Result, Error> { + let query = params.get("q").ok_or_else(|| Error::BadRequest { + message: "Missing query parameter 'q'".to_string(), + })?; + + if let Some(cached) = TRAILER_SEARCH_CACHE.get(query) { + return Ok(Json(serde_json::json!({ "youtube_id": cached.value() }))); + } + + let search_url = format!( + "https://www.youtube.com/results?search_query={}", + urlencoding::encode(query) + ); + + let html = state + .http_client + .get(&search_url) + .header("Accept-Language", "en") + .send() + .await + .map_err(|e| Error::Internal { + message: format!("YouTube search failed: {e}"), + })? + .text() + .await + .map_err(|e| Error::Internal { + message: format!("Failed to read YouTube response: {e}"), + })?; + + // Extract unique video IDs from YouTube's embedded JSON ("videoId":"XXXXXXXXXXX") + let mut seen = std::collections::HashSet::new(); + let candidates: Vec = html + .split("\"videoId\":\"") + .skip(1) + .filter_map(|s| { + let id = s.split('"').next()?; + if id.len() == 11 + && id + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + && seen.insert(id.to_string()) + { + Some(id.to_string()) + } else { + None + } + }) + .take(10) + .collect(); + + // Also extract video titles for fuzzy matching ("title":{"runs":[{"text":"..."}]}) + let titles: Vec = html + .split("\"title\":{\"runs\":[{\"text\":\"") + .skip(1) + .filter_map(|s| s.split('"').next().map(String::from)) + .take(10) + .collect(); + + // Find best match: prefer results whose title contains the original search terms + let query_lower = query.to_lowercase(); + let query_words: Vec<&str> = query_lower + .split_whitespace() + .filter(|w| w.len() > 2 && *w != "official" && *w != "trailer") + .collect(); + + let best_id = candidates + .iter() + .enumerate() + .max_by_key(|(i, _)| { + let title = titles.get(*i).map(|t| t.to_lowercase()).unwrap_or_default(); + let word_hits = query_words.iter().filter(|w| title.contains(**w)).count(); + let has_trailer = title.contains("trailer") || title.contains("official"); + // Score: word matches * 10 + trailer keyword bonus + position penalty + (word_hits * 10) + if has_trailer { 5 } else { 0 } + (10_usize.saturating_sub(*i)) + }) + .map(|(_, id)| id.as_str()); + + match best_id { + Some(id) => { + tracing::info!(query, youtube_id = id, "Trailer search matched"); + TRAILER_SEARCH_CACHE.insert(query.clone(), id.to_string()); + Ok(Json(serde_json::json!({ "youtube_id": id }))) + } + None => Err(Error::NotFound { + message: "No video found".to_string(), + }), + } +} + +pub async fn add_favourite( + State(state): State, + claims: Claims, + Json(body): Json, +) -> std::result::Result { + if body.title.trim().is_empty() || body.title.len() > 500 { + return Err(Error::BadRequest { + message: "Title must be between 1 and 500 characters".to_string(), + }); + } + let item = state.db.add_favourite(&claims.user_id, &body).await?; + Ok(Json(item)) +} + +pub async fn get_favourites( + State(state): State, + claims: Claims, + axum::extract::Query(params): axum::extract::Query>, +) -> std::result::Result { + let content_type = params.get("type").map(|s| s.as_str()); + let items = state + .db + .get_favourites(&claims.user_id, content_type) + .await?; + Ok(Json(serde_json::json!({ "items": items }))) +} + +pub async fn delete_favourite( + State(state): State, + claims: Claims, + Path(id): Path, +) -> std::result::Result { + let deleted = state.db.delete_favourite(&id, &claims.user_id).await?; + if !deleted { + return Err(Error::NotFound { + message: "Favourite not found".to_string(), + }); + } + Ok(Json(serde_json::json!({ "status": "deleted" }))) +} + +pub async fn search_tv( + State(state): State, + _claims: Claims, + Json(body): Json, +) -> std::result::Result { + let query = body.query.trim(); + if query.is_empty() || query.len() > 500 { + return Err(Error::BadRequest { + message: "Query must be between 1 and 500 characters".to_string(), + }); + } + let results = state.search_provider.search_tv(query).await?; + Ok(Json(serde_json::json!({ "results": results }))) +} + +pub async fn browse_tv( + State(state): State, + _claims: Claims, + axum::extract::Query(params): axum::extract::Query>, +) -> std::result::Result { + let page = params + .get("page") + .and_then(|s| s.parse().ok()) + .unwrap_or(1u32); + let limit = params + .get("limit") + .and_then(|s| s.parse().ok()) + .unwrap_or(20u32) + .min(100); + let results = state.search_provider.browse_tv(page, limit).await?; + Ok(Json(serde_json::json!({ "results": results }))) +} + +pub async fn get_tv_show( + State(state): State, + _claims: Claims, + axum::extract::Path(imdb_id): axum::extract::Path, + axum::extract::Query(params): axum::extract::Query>, +) -> std::result::Result { + if !imdb_id.starts_with("tt") || imdb_id.len() < 4 { + return Err(Error::BadRequest { + message: "Invalid IMDB ID".to_string(), + }); + } + let season = params.get("season").and_then(|s| s.parse::().ok()); + if season.is_some() { + let seasons = state + .search_provider + .fetch_show_episodes(&imdb_id, season) + .await?; + Ok(Json(serde_json::json!({ "seasons": seasons }))) + } else { + let season_numbers = state.search_provider.discover_seasons(&imdb_id).await?; + Ok(Json(serde_json::json!({ "seasons": season_numbers }))) + } +} + +pub async fn search_music_videos( + State(state): State, + _claims: Claims, + Json(body): Json, +) -> std::result::Result { + let query = body.query.trim(); + if query.is_empty() || query.len() > 500 { + return Err(Error::BadRequest { + message: "Query must be between 1 and 500 characters".to_string(), + }); + } + let results = state.search_provider.search_music_videos(query).await?; + Ok(Json(serde_json::json!({ "results": results }))) +} + +pub async fn browse_music_videos( + State(state): State, + _claims: Claims, + axum::extract::Query(params): axum::extract::Query>, +) -> std::result::Result { + let page = params + .get("page") + .and_then(|s| s.parse().ok()) + .unwrap_or(1u32); + let results = state.search_provider.browse_music_videos(page).await?; + Ok(Json(serde_json::json!({ "results": results }))) +} + +pub async fn search_music( + State(state): State, + _claims: Claims, + Json(body): Json, +) -> std::result::Result { + let query = body.query.trim(); + if query.is_empty() || query.len() > 500 { + return Err(Error::BadRequest { + message: "Query must be between 1 and 500 characters".to_string(), + }); + } + let results = state.search_provider.search_music(query).await?; + Ok(Json(serde_json::json!({ "results": results }))) +} + +pub async fn browse_music( + State(state): State, + _claims: Claims, + axum::extract::Query(params): axum::extract::Query>, +) -> std::result::Result { + let page = params + .get("page") + .and_then(|s| s.parse().ok()) + .unwrap_or(1u32); + let results = state.search_provider.browse_music(page).await?; + Ok(Json(serde_json::json!({ "results": results }))) +} + +pub async fn resolve_magnet( + State(state): State, + _claims: Claims, + Json(body): Json>, +) -> std::result::Result { + let detail_url = body.get("detail_url").ok_or_else(|| Error::BadRequest { + message: "detail_url is required".to_string(), + })?; + let magnet = state.search_provider.get_magnet(detail_url).await?; + match magnet { + Some(m) => Ok(Json(serde_json::json!({ "magnet": m }))), + None => Err(Error::NotFound { + message: "Could not resolve magnet link".to_string(), + }), + } +} + +pub async fn serve_poster( + State(state): State, + Path(filename): Path, +) -> std::result::Result { + // Sanitize filename to prevent path traversal + if filename.contains("..") || filename.contains('/') || filename.contains('\\') { + return Err(Error::BadRequest { + message: "Invalid filename".to_string(), + }); + } + + let poster_path = state + .config + .data_dir + .join("downloads") + .join("posters") + .join(&filename); + + if !poster_path.exists() { + return Err(Error::NotFound { + message: "Poster not found".to_string(), + }); + } + + let bytes = tokio::fs::read(&poster_path) + .await + .map_err(|e| Error::Io { source: e })?; + + Ok(( + [ + (axum::http::header::CONTENT_TYPE, "image/jpeg".to_string()), + ( + axum::http::header::CACHE_CONTROL, + "public, max-age=31536000, immutable".to_string(), + ), + ], + bytes, + )) +} diff --git a/backend/src/server/mod.rs b/backend/src/server/mod.rs index 6dbf98f..aed1026 100644 --- a/backend/src/server/mod.rs +++ b/backend/src/server/mod.rs @@ -1,5 +1,7 @@ +pub mod admin; pub mod api; pub mod auth; +pub mod proxy; pub mod static_files; pub mod stream; @@ -11,6 +13,7 @@ use auth::RateLimiter; use axum::extract::FromRef; use axum::routing::{delete, get, post, put}; use axum::Router; +use std::sync::atomic::AtomicU32; use std::sync::Arc; use tower_http::compression::CompressionLayer; use tower_http::cors::{AllowOrigin, CorsLayer}; @@ -25,6 +28,10 @@ pub struct AppState { pub search_provider: Arc, pub hls_pipeline: Arc, pub rate_limiter: RateLimiter, + pub http_client: reqwest::Client, + pub ws_connections: Arc, + pub log_tx: tokio::sync::broadcast::Sender, + pub log_history: std::sync::Arc, } pub fn build_router( @@ -33,9 +40,22 @@ pub fn build_router( torrent_engine: TorrentEngine, search_provider: SearchProvider, hls_pipeline: HlsManager, + log_tx: tokio::sync::broadcast::Sender, + log_history: std::sync::Arc, ) -> Router { let jwt_secret = config.auth.jwt_secret.clone(); + let mut http_builder = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .user_agent("Mozilla/5.0 (X11; Linux x86_64) StreamX/0.1"); + if let Some(ref vpn) = config.vpn { + if let Ok(proxy) = reqwest::Proxy::all(vpn.resolved_url()) { + http_builder = http_builder.proxy(proxy); + tracing::info!("HTTP client using SOCKS5 proxy"); + } + } + let http_client = http_builder.build().unwrap_or_default(); + let state = AppState { db, config: Arc::new(config), @@ -44,6 +64,10 @@ pub fn build_router( search_provider: Arc::new(search_provider), hls_pipeline: Arc::new(hls_pipeline), rate_limiter: RateLimiter::new(), + http_client, + ws_connections: Arc::new(AtomicU32::new(0)), + log_tx, + log_history, }; let auth_routes = Router::new() @@ -53,9 +77,11 @@ pub fn build_router( let search_routes = Router::new() .route("/", post(api::search)) + .route("/browse", get(api::browse)) .route("/history", get(api::search_history)); let stream_routes = Router::new() + .route("/url/playlist.m3u8", get(stream::url_playlist)) .route("/", post(api::create_stream)) .route("/{id}", get(api::get_stream)) .route("/{id}", delete(api::delete_stream)) @@ -64,6 +90,11 @@ pub fn build_router( .route("/{id}/ws", get(stream::stream_ws)) .route("/{id}/playlist.m3u8", get(stream::playlist)) .route("/{id}/file", get(stream::stream_file)) + .route( + "/{id}/{variant}/playlist.m3u8", + get(stream::variant_playlist), + ) + .route("/{id}/{variant}/{segment}", get(stream::variant_segment)) .route("/{id}/{segment}", get(stream::segment)); let history_routes = Router::new() @@ -75,6 +106,30 @@ pub fn build_router( .route("/", get(api::get_settings)) .route("/", put(api::update_settings)); + let favourites_routes = Router::new() + .route("/", post(api::add_favourite)) + .route("/", get(api::get_favourites)) + .route("/{id}", delete(api::delete_favourite)); + + let tv_routes = Router::new() + .route("/search", post(api::search_tv)) + .route("/browse", get(api::browse_tv)) + .route("/show/{imdb_id}", get(api::get_tv_show)); + + let music_video_routes = Router::new() + .route("/search", post(api::search_music_videos)) + .route("/browse", get(api::browse_music_videos)) + .route("/resolve-magnet", post(api::resolve_magnet)); + + let music_routes = Router::new() + .route("/search", post(api::search_music)) + .route("/browse", get(api::browse_music)) + .route("/resolve-magnet", post(api::resolve_magnet)); + + let trailer_routes = Router::new().route("/search", get(api::trailer_search)); + + let poster_routes = Router::new().route("/{filename}", get(api::serve_poster)); + let test_routes = Router::new() .route("/video", get(api::test_video)) .route("/playlist.m3u8", get(api::test_hls_playlist)) @@ -85,13 +140,25 @@ pub fn build_router( .route("/", get(api::get_demo_stream)) .route("/playlist.m3u8", get(api::demo_playlist)); + let admin_routes = Router::new() + .route("/monitor", get(admin::admin_monitor_ws)) + .route("/logs", get(admin::admin_logs_ws)) + .route("/kill/{stream_id}", delete(admin::kill_transcode)); + let api_routes = Router::new() + .nest("/admin", admin_routes) .nest("/auth", auth_routes) .nest("/search", search_routes) .nest("/stream/demo", demo_routes) .nest("/stream", stream_routes) .nest("/history", history_routes) + .nest("/favourites", favourites_routes) + .nest("/tv", tv_routes) + .nest("/music-videos", music_video_routes) + .nest("/music", music_routes) .nest("/settings", settings_routes) + .nest("/trailer", trailer_routes) + .nest("/posters", poster_routes) .nest("/test", test_routes); let cors = CorsLayer::new() @@ -110,8 +177,11 @@ pub fn build_router( ]) .allow_credentials(true); + let proxy_routes = Router::new().route("/{id}/{*path}", get(proxy::proxy_image)); + Router::new() .nest("/api", api_routes) + .nest("/proxy", proxy_routes) .fallback(static_files::static_handler) .layer(CompressionLayer::new()) .layer(cors) diff --git a/backend/src/server/proxy.rs b/backend/src/server/proxy.rs new file mode 100644 index 0000000..a364738 --- /dev/null +++ b/backend/src/server/proxy.rs @@ -0,0 +1,162 @@ +use crate::error::Error; +use crate::server::AppState; +use axum::extract::{Path, State}; +use axum::response::IntoResponse; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; + +fn cache_key(url: &str) -> String { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + url.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +fn ext_from_path(path: &str) -> &str { + if path.ends_with(".png") { + "png" + } else if path.ends_with(".webp") { + "webp" + } else if path.ends_with(".gif") { + "gif" + } else { + "jpg" + } +} + +fn content_type_for(ext: &str) -> &str { + match ext { + "png" => "image/png", + "webp" => "image/webp", + "gif" => "image/gif", + _ => "image/jpeg", + } +} + +pub const CINEMETA_PROXY_ID: u32 = 0; +const CINEMETA_IMAGE_BASE: &str = "https://images.metahub.space"; + +fn img_cache_dir(state: &AppState) -> PathBuf { + state.config.data_dir.join("cache").join("img") +} + +fn base_url_for_proxy(state: &AppState, provider_id: u32) -> Option { + if provider_id == CINEMETA_PROXY_ID { + return Some(CINEMETA_IMAGE_BASE.to_string()); + } + state + .config + .provider_by_id(provider_id) + .map(|p| p.url.clone()) +} + +/// Single handler for all providers. Route: /proxy/{provider_id}/{*path} +pub async fn proxy_image( + State(state): State, + Path((provider_id, path)): Path<(u32, String)>, +) -> std::result::Result { + let base_url = base_url_for_proxy(&state, provider_id).ok_or_else(|| Error::NotFound { + message: "Unknown provider".to_string(), + })?; + + if path.contains("..") { + return Err(Error::BadRequest { + message: "Invalid path".to_string(), + }); + } + + let upstream_url = format!("{}/{}", base_url, path); + let ext = ext_from_path(&path); + let key = cache_key(&upstream_url); + let cache_dir = img_cache_dir(&state); + let cache_path = cache_dir.join(format!("{key}.{ext}")); + + // Serve from disk cache + if cache_path.exists() { + let bytes = tokio::fs::read(&cache_path) + .await + .map_err(|e| Error::Io { source: e })?; + return Ok(( + [ + ( + axum::http::header::CONTENT_TYPE, + content_type_for(ext).to_string(), + ), + ( + axum::http::header::CACHE_CONTROL, + "public, max-age=31536000, immutable".to_string(), + ), + ], + bytes, + )); + } + + // Fetch upstream + let resp = state + .http_client + .get(&upstream_url) + .send() + .await + .map_err(|_| Error::NotFound { + message: "Failed to fetch image".to_string(), + })?; + + if !resp.status().is_success() { + return Err(Error::NotFound { + message: "Image not found upstream".to_string(), + }); + } + + let bytes = resp.bytes().await.map_err(|_| Error::Internal { + message: "Failed to read image bytes".to_string(), + })?; + + let _ = tokio::fs::create_dir_all(&cache_dir).await; + let _ = tokio::fs::write(&cache_path, &bytes).await; + + Ok(( + [ + ( + axum::http::header::CONTENT_TYPE, + content_type_for(ext).to_string(), + ), + ( + axum::http::header::CACHE_CONTROL, + "public, max-age=31536000, immutable".to_string(), + ), + ], + bytes.to_vec(), + )) +} + +/// Convert an external URL to a proxy URL using the given provider ID. +pub fn to_proxy_url(url: &str, provider_id: u32) -> String { + if url.starts_with("/proxy/") || url.starts_with("/api/") { + return url.to_string(); + } + if let Some(proto_end) = url.find("://") { + if let Some(slash) = url[proto_end + 3..].find('/') { + let path = &url[proto_end + 3 + slash + 1..]; + return format!("/proxy/{}/{}", provider_id, path); + } + } + url.to_string() +} + +/// Convert a proxy URL back to an absolute upstream URL. +/// Looks up the provider base URL from config. +pub fn resolve_proxy_url(proxy_url: &str, providers: &[crate::config::ProviderConfig]) -> String { + if let Some(rest) = proxy_url.strip_prefix("/proxy/") { + if let Some(slash) = rest.find('/') { + if let Ok(id) = rest[..slash].parse::() { + let path = &rest[slash + 1..]; + if id == CINEMETA_PROXY_ID { + return format!("{CINEMETA_IMAGE_BASE}/{path}"); + } + if let Some(provider) = providers.iter().find(|p| p.id == id) { + return format!("{}/{}", provider.url, path); + } + } + } + } + proxy_url.to_string() +} diff --git a/backend/src/server/stream.rs b/backend/src/server/stream.rs index 80e6305..46ca58a 100644 --- a/backend/src/server/stream.rs +++ b/backend/src/server/stream.rs @@ -19,8 +19,41 @@ pub async fn stream_ws( } async fn handle_stream_ws(mut socket: WebSocket, state: AppState, id: String) { + state + .ws_connections + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); let _ = state.torrent_engine.resume(&id).await; + let metadata = state.db.get_metadata(&id).await.ok().flatten(); + let video_codec = metadata.as_ref().and_then(|m| m.video_codec.clone()); + + // Send metadata once on connection + if let Some(ref meta) = metadata { + let meta_msg = serde_json::json!({ + "type": "metadata", + "data": { + "title": meta.title, + "year": meta.year, + "rating": meta.rating, + "runtime": meta.runtime, + "genres": meta.genres, + "language": meta.language, + "mpa_rating": meta.mpa_rating, + "summary": meta.summary, + "imdb_code": meta.imdb_code, + "video_codec": meta.video_codec, + "audio_channels": meta.audio_channels, + "bit_depth": meta.bit_depth, + "source_type": meta.source_type, + "poster_large": meta.poster_large, + "local_poster": meta.local_poster, + } + }); + let _ = socket + .send(Message::Text(meta_msg.to_string().into())) + .await; + } + if let Ok(Some(dl)) = state.torrent_engine.get_download(&id).await { let (peers, speed) = state.torrent_engine.get_live_stats(&id).await; let msg = serde_json::json!({ @@ -33,6 +66,7 @@ async fn handle_stream_ws(mut socket: WebSocket, state: AppState, id: String) { "file_size": dl.file_size, "title": dl.title, "file_name": dl.file_name, + "video_codec": video_codec, } }); if socket @@ -76,6 +110,8 @@ async fn handle_stream_ws(mut socket: WebSocket, state: AppState, id: String) { "speed": speed, "file_size": dl.file_size, "title": dl.title, + "file_name": dl.file_name, + "video_codec": video_codec, } }); if socket.send(Message::Text(msg.to_string().into())).await.is_err() { @@ -115,15 +151,64 @@ async fn handle_stream_ws(mut socket: WebSocket, state: AppState, id: String) { } } + state + .ws_connections + .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); let _ = state.torrent_engine.pause(&id).await; } +pub async fn url_playlist( + State(state): State, + _claims: Claims, + axum::extract::Query(params): axum::extract::Query>, +) -> std::result::Result { + let url = params.get("url").ok_or_else(|| Error::BadRequest { + message: "Missing 'url' parameter".to_string(), + })?; + let quality = params.get("quality").map(|s| s.as_str()).unwrap_or("source"); + + // Use a hash of the URL as stream_id + let stream_id = format!("url-{:x}", md5_hash(url)); + + if let Err(e) = state + .hls_pipeline + .start_stream_url(&stream_id, url, quality) + .await + { + tracing::warn!(stream_id = %stream_id, "Failed to start URL transcode: {e}"); + } + + let response = state.hls_pipeline.generate_playlist(&stream_id, quality).await?; + + match response { + PlaylistResponse::Redirect(redir_url) => Ok(Redirect::temporary(&redir_url).into_response()), + PlaylistResponse::Content(content) => Ok(( + [ + (header::CONTENT_TYPE, "application/vnd.apple.mpegurl"), + (header::CACHE_CONTROL, "no-cache, no-store"), + ], + content, + ) + .into_response()), + } +} + +fn md5_hash(s: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() +} + pub async fn playlist( State(state): State, _claims: Claims, Path(id): Path, + axum::extract::Query(params): axum::extract::Query>, ) -> std::result::Result { - let _download = state + let quality = params.get("quality").map(|s| s.as_str()).unwrap_or("source"); + + let download = state .torrent_engine .get_download(&id) .await? @@ -131,7 +216,76 @@ pub async fn playlist( message: format!("Stream {id} not found"), })?; - let response = state.hls_pipeline.generate_playlist(&id, true).await?; + let file_path = download + .complete_path + .as_deref() + .or(download.partial_path.as_deref()); + + // Always try file-based transcode first (works for both complete and partial/sequential downloads) + // FFmpeg handles growing files naturally - it reads what's available and waits for more + let mut started = false; + if let Some(path) = file_path { + if tokio::fs::metadata(path).await.is_ok() { + if let Err(e) = state.hls_pipeline.start_stream(&id, path, quality).await { + tracing::warn!(stream_id = %id, quality, "Failed to start HLS transcode: {e}"); + } else { + started = true; + } + } + } + if !started { + if let Ok(Some(path)) = state.torrent_engine.get_file_path(&id).await { + if let Err(e) = state + .hls_pipeline + .start_stream(&id, path.to_str().unwrap_or_default(), quality) + .await + { + tracing::warn!(stream_id = %id, quality, "Failed to start HLS transcode: {e}"); + } else { + started = true; + } + } + } + + // Fallback to piped transcode only if file-based didn't work + if !started && download.status != "complete" { + if download.status == "paused" { + let _ = state.torrent_engine.resume(&id).await; + } else { + let _ = state.torrent_engine.ensure_active(&id).await; + } + + let probe_path = file_path.map(String::from).or_else(|| { + let rt = tokio::runtime::Handle::current(); + rt.block_on(async { state.torrent_engine.get_file_path(&id).await.ok().flatten() }) + .map(|p| p.to_string_lossy().to_string()) + }); + + if let Some(ref probe_path) = probe_path { + if let Ok(Some((torrent_id, file_index))) = + state.torrent_engine.get_stream_file_info(&id).await + { + let api = librqbit::Api::new(state.torrent_engine.session().clone(), None); + if let Ok(file_stream) = + api.api_stream(librqbit::api::TorrentIdOrHash::Id(torrent_id), file_index) + { + if let Err(e) = state + .hls_pipeline + .start_stream_piped(&id, probe_path, file_stream, quality) + .await + { + tracing::warn!(stream_id = %id, "Failed to start piped HLS transcode: {e}"); + } + } else if let Err(e) = state.hls_pipeline.start_stream(&id, probe_path, quality).await { + tracing::warn!(stream_id = %id, "Failed to start HLS transcode (fallback): {e}"); + } + } else if let Err(e) = state.hls_pipeline.start_stream(&id, probe_path, quality).await { + tracing::warn!(stream_id = %id, "Failed to start HLS transcode: {e}"); + } + } + } + + let response = state.hls_pipeline.generate_playlist(&id, quality).await?; match response { PlaylistResponse::Redirect(url) => Ok(Redirect::temporary(&url).into_response()), @@ -167,6 +321,49 @@ pub async fn segment( Ok(([(header::CONTENT_TYPE, content_type)], data)) } +pub async fn variant_playlist( + State(state): State, + Path((id, variant)): Path<(String, String)>, +) -> std::result::Result { + let content = state + .hls_pipeline + .get_variant_playlist(&id, &variant) + .await? + .ok_or_else(|| Error::NotFound { + message: format!("Variant playlist {variant} not found for stream {id}"), + })?; + + Ok(( + [ + (header::CONTENT_TYPE, "application/vnd.apple.mpegurl"), + (header::CACHE_CONTROL, "no-cache, no-store"), + ], + content, + ) + .into_response()) +} + +pub async fn variant_segment( + State(state): State, + Path((id, variant, segment_name)): Path<(String, String, String)>, +) -> std::result::Result { + let data: Bytes = state + .hls_pipeline + .get_variant_segment(&id, &variant, &segment_name) + .await? + .ok_or_else(|| Error::NotFound { + message: format!("Variant segment {variant}/{segment_name} not found"), + })?; + + let content_type = if segment_name.ends_with(".m4s") || segment_name.ends_with(".mp4") { + "video/mp4" + } else { + "video/mp2t" + }; + + Ok(([(header::CONTENT_TYPE, content_type)], data)) +} + pub async fn stream_file( State(state): State, headers: HeaderMap, @@ -219,13 +416,13 @@ pub async fn stream_file( .and_then(|s| parse_range(s, file_size)); return match range { - Some((start, _)) => { + Some((start, end)) => { file_stream .seek(std::io::SeekFrom::Start(start)) .await .map_err(|e| Error::Io { source: e })?; - let remaining = file_size - file_stream.position(); - let end = file_size.saturating_sub(1); + let length = end - start + 1; + let remaining = length; let stream = tokio_util::io::ReaderStream::with_capacity(file_stream, 65536); let body = axum::body::Body::from_stream(stream); diff --git a/backend/src/torrent/engine.rs b/backend/src/torrent/engine.rs index c24f45a..f1bc7f3 100644 --- a/backend/src/torrent/engine.rs +++ b/backend/src/torrent/engine.rs @@ -6,7 +6,7 @@ use crate::torrent::types::TorrentFile; use chrono::Utc; use librqbit::{ dht::PersistentDhtConfig, AddTorrent, AddTorrentOptions, AddTorrentResponse, ManagedTorrent, - Session, SessionOptions, TorrentStatsState, + PeerConnectionOptions, Session, SessionOptions, TorrentStatsState, }; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -29,7 +29,12 @@ pub struct TorrentEngine { } impl TorrentEngine { - pub async fn create(config: &TorrentConfig, data_dir: &Path, db: Database) -> Result { + pub async fn create( + config: &TorrentConfig, + data_dir: &Path, + db: Database, + socks5: Option, + ) -> Result { let partial_dir = data_dir.join("downloads").join("partial"); let complete_dir = data_dir.join("downloads").join("complete"); let dht_dir = data_dir.join("dht"); @@ -49,12 +54,21 @@ impl TorrentEngine { ..Default::default() }; + let peer_opts = PeerConnectionOptions { + connect_timeout: Some(std::time::Duration::from_secs(5)), + read_write_timeout: Some(std::time::Duration::from_secs(10)), + ..Default::default() + }; + let opts = SessionOptions { disable_dht: !config.dht, disable_dht_persistence: false, dht_config: Some(dht_config), - listen_port_range: Some(4240..4260), + listen_port_range: Some(4240..4300), enable_upnp_port_forwarding: true, + socks_proxy_url: socks5, + peer_opts: Some(peer_opts), + fastresume: true, ..Default::default() }; @@ -333,30 +347,55 @@ impl TorrentEngine { let complete_dir = self.complete_dir.clone(); tokio::spawn(async move { - let opts = AddTorrentOptions { - overwrite: true, - only_files: file_index.map(|i| vec![i]), - ..Default::default() - }; - - let result = tokio::time::timeout( - std::time::Duration::from_secs(60), - session.add_torrent(AddTorrent::from_url(&magnet_uri), Some(opts)), - ) - .await; + // Adaptive timeout: start at 30s, double on each retry up to 3 attempts + let _ = db.update_download_status(&info_hash, "initializing").await; + let mut resp = None; + for attempt in 0..3u32 { + let opts = AddTorrentOptions { + overwrite: true, + only_files: file_index.map(|i| vec![i]), + ..Default::default() + }; + let timeout_secs = 30u64 << attempt; // 30s, 60s, 120s + let result = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + session.add_torrent( + AddTorrent::from_url(&magnet_uri), + Some(opts), + ), + ) + .await; - let resp = match result { - Ok(Ok(r)) => r, - Ok(Err(e)) => { - warn!(info_hash = %info_hash, "Failed to add torrent: {e}"); - let _ = db.update_download_status(&info_hash, "error").await; - return; - } - Err(_) => { - warn!(info_hash = %info_hash, "Timed out adding torrent"); - let _ = db.update_download_status(&info_hash, "error").await; - return; + match result { + Ok(Ok(r)) => { + resp = Some(r); + break; + } + Ok(Err(e)) => { + warn!(info_hash = %info_hash, attempt, "Failed to add torrent: {e}"); + if attempt == 2 { + let _ = db.update_download_status(&info_hash, "error").await; + return; + } + } + Err(_) => { + warn!( + info_hash = %info_hash, + attempt, + timeout_secs, + "Timed out adding torrent, retrying with longer timeout" + ); + if attempt == 2 { + let _ = db.update_download_status(&info_hash, "error").await; + return; + } + } } + } + + let resp = match resp { + Some(r) => r, + None => return, }; let (tid, handle) = match resp { diff --git a/backend/src/torrent/metadata.rs b/backend/src/torrent/metadata.rs new file mode 100644 index 0000000..2da635c --- /dev/null +++ b/backend/src/torrent/metadata.rs @@ -0,0 +1,199 @@ +use dashmap::DashMap; +use serde::Deserialize; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::error::Result; + +const CACHE_TTL: Duration = Duration::from_secs(3600); +const BASE_URL: &str = "https://v3-cinemeta.strem.io"; + +#[derive(Debug, Deserialize)] +struct CatalogResponse { + metas: Option>, +} + +#[derive(Debug, Deserialize)] +struct DetailResponse { + meta: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CinemetaMeta { + pub id: String, + #[serde(rename = "type")] + pub media_type: Option, + pub name: Option, + pub poster: Option, + pub background: Option, + #[serde(rename = "releaseInfo")] + pub release_info: Option, + pub description: Option, + #[serde(rename = "imdbRating")] + pub imdb_rating: Option, + pub genres: Option>, + pub runtime: Option, +} + +impl CinemetaMeta { + pub fn parse_year(&self) -> Option { + self.release_info + .as_deref() + .and_then(|r| r.chars().take(4).collect::().parse().ok()) + } + + pub fn parse_rating(&self) -> Option { + self.imdb_rating + .as_deref() + .filter(|r| !r.is_empty()) + .and_then(|r| r.parse().ok()) + } + + pub fn parse_runtime(&self) -> Option { + self.runtime + .as_deref() + .and_then(|r| r.split_whitespace().next()) + .and_then(|n| n.parse().ok()) + } + + pub fn genres_list(&self) -> Vec { + self.genres.clone().unwrap_or_default() + } + + pub fn title(&self) -> String { + self.name.clone().unwrap_or_default() + } +} + +pub struct CinemetaClient { + client: reqwest::Client, + search_cache: Arc)>>, + detail_cache: Arc>, + catalog_cache: Arc)>>, +} + +impl CinemetaClient { + pub fn new(client: reqwest::Client) -> Self { + Self { + client, + search_cache: Arc::new(DashMap::new()), + detail_cache: Arc::new(DashMap::new()), + catalog_cache: Arc::new(DashMap::new()), + } + } + + /// Browse top/popular content from Cinemeta catalog. + /// `media_type` should be "movie" or "series". + /// `skip` is the offset for pagination (0, 50, 100, ...). + pub async fn catalog(&self, media_type: &str, skip: u32) -> Result> { + let cache_key = format!("catalog:{media_type}:{skip}"); + if let Some(entry) = self.catalog_cache.get(&cache_key) { + if entry.0.elapsed() < CACHE_TTL { + return Ok(entry.1.clone()); + } + } + + let url = if skip == 0 { + format!("{BASE_URL}/catalog/{media_type}/top.json") + } else { + format!("{BASE_URL}/catalog/{media_type}/top/skip={skip}.json") + }; + + let response = match self.client.get(&url).send().await { + Ok(r) => r, + Err(err) => { + tracing::warn!("Cinemeta catalog request failed: {err}"); + return Ok(Vec::new()); + } + }; + + let catalog: CatalogResponse = match response.json().await { + Ok(parsed) => parsed, + Err(err) => { + tracing::warn!("Failed to parse Cinemeta catalog response: {err}"); + return Ok(Vec::new()); + } + }; + + let results = catalog.metas.unwrap_or_default(); + self.catalog_cache + .insert(cache_key, (Instant::now(), results.clone())); + Ok(results) + } + + /// Search for movies or series by text query. + /// `media_type` should be "movie" or "series". + pub async fn search(&self, media_type: &str, query: &str) -> Result> { + let cache_key = format!("{media_type}:{}", query.to_lowercase()); + if let Some(entry) = self.search_cache.get(&cache_key) { + if entry.0.elapsed() < CACHE_TTL { + return Ok(entry.1.clone()); + } + } + + let encoded = urlencoding::encode(query); + let url = format!("{BASE_URL}/catalog/{media_type}/top/search={encoded}.json"); + + let response = match self.client.get(&url).send().await { + Ok(r) => r, + Err(err) => { + tracing::warn!("Cinemeta search failed: {err}"); + return Ok(Vec::new()); + } + }; + + let catalog: CatalogResponse = match response.json().await { + Ok(parsed) => parsed, + Err(err) => { + tracing::warn!("Failed to parse Cinemeta search response: {err}"); + return Ok(Vec::new()); + } + }; + + let results = catalog.metas.unwrap_or_default(); + self.search_cache + .insert(cache_key, (Instant::now(), results.clone())); + Ok(results) + } + + /// Get detailed metadata for a specific IMDB ID. + /// `media_type` should be "movie" or "series". + pub async fn get_detail( + &self, + media_type: &str, + imdb_id: &str, + ) -> Result> { + let cache_key = format!("{media_type}:{imdb_id}"); + if let Some(entry) = self.detail_cache.get(&cache_key) { + if entry.0.elapsed() < CACHE_TTL { + return Ok(Some(entry.1.clone())); + } + } + + let url = format!("{BASE_URL}/meta/{media_type}/{imdb_id}.json"); + + let response = match self.client.get(&url).send().await { + Ok(r) => r, + Err(err) => { + tracing::warn!("Cinemeta detail request failed: {err}"); + return Ok(None); + } + }; + + let detail: DetailResponse = match response.json().await { + Ok(parsed) => parsed, + Err(err) => { + tracing::warn!("Failed to parse Cinemeta detail response: {err}"); + return Ok(None); + } + }; + + if let Some(meta) = detail.meta { + self.detail_cache + .insert(cache_key, (Instant::now(), meta.clone())); + Ok(Some(meta)) + } else { + Ok(None) + } + } +} diff --git a/backend/src/torrent/mod.rs b/backend/src/torrent/mod.rs index 5873c31..71d6f32 100644 --- a/backend/src/torrent/mod.rs +++ b/backend/src/torrent/mod.rs @@ -1,6 +1,7 @@ pub mod engine; +pub mod metadata; pub mod provider; pub mod types; pub use engine::TorrentEngine; -pub use provider::{SearchProvider, SearchResult}; +pub use provider::{SearchProvider, SearchResult, SearchResultGroup, TvSearchResultGroup}; diff --git a/backend/src/torrent/provider.rs b/backend/src/torrent/provider.rs index 1dce10d..ad188b9 100644 --- a/backend/src/torrent/provider.rs +++ b/backend/src/torrent/provider.rs @@ -1,7 +1,20 @@ +use crate::config::ProviderConfig; use crate::error::Result; +use crate::server::proxy::{self, CINEMETA_PROXY_ID}; +use crate::torrent::metadata::CinemetaClient; use serde::{Deserialize, Serialize}; -const YTS_API_URL: &str = "https://yts.lt/api/v2/list_movies.json"; +fn proxy_opt(url: &Option, proxy_id: u32) -> Option { + url.as_ref().map(|u| proxy::to_proxy_url(u, proxy_id)) +} + +fn proxy_posters(group: &mut SearchResultGroup, proxy_id: u32) { + group.poster = proxy_opt(&group.poster, proxy_id); + group.poster_small = proxy_opt(&group.poster_small, proxy_id); + group.poster_medium = proxy_opt(&group.poster_medium, proxy_id); + group.poster_large = proxy_opt(&group.poster_large, proxy_id); + group.backdrop = proxy_opt(&group.backdrop, proxy_id); +} const TRACKERS: &[&str] = &[ "udp://open.demonii.com:1337/announce", @@ -15,17 +28,37 @@ const TRACKERS: &[&str] = &[ ]; #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SearchResult { +pub struct SearchResultGroup { pub title: String, + pub year: Option, + pub rating: Option, + pub runtime: Option, + pub genres: Vec, + pub language: Option, + pub mpa_rating: Option, + pub summary: Option, + pub imdb_code: Option, + pub trailer_code: Option, + pub poster: Option, + pub poster_small: Option, + pub poster_medium: Option, + pub poster_large: Option, + pub backdrop: Option, + pub variants: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchResult { pub magnet: String, pub seeds: u32, pub leeches: u32, pub size: String, pub size_bytes: u64, pub quality: Option, - pub year: Option, - pub rating: Option, - pub poster: Option, + pub video_codec: Option, + pub audio_channels: Option, + pub bit_depth: Option, + pub source_type: Option, } #[derive(Debug, Deserialize)] @@ -44,7 +77,18 @@ struct YtsMovie { title: String, year: u32, rating: f64, + runtime: Option, + genres: Option>, + language: Option, + mpa_rating: Option, + #[serde(default)] + summary: Option, + imdb_code: Option, + yt_trailer_code: Option, + small_cover_image: Option, medium_cover_image: Option, + large_cover_image: Option, + background_image: Option, torrents: Option>, } @@ -56,32 +100,189 @@ struct YtsTorrent { peers: u32, size: String, size_bytes: u64, + #[serde(rename = "type")] + source_type: Option, + video_codec: Option, + bit_depth: Option, + audio_channels: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MusicVideoResult { + pub title: String, + pub magnet: Option, + pub seeds: u32, + pub leeches: u32, + pub size: String, + pub detail_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TvSearchResultGroup { + pub show_name: String, + pub imdb_id: Option, + pub seasons: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TvSeason { + pub season: u32, + pub episodes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TvEpisode { + pub episode: u32, + pub title: Option, + pub variants: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TvTorrent { + pub magnet: String, + pub seeds: u32, + pub leeches: u32, + pub size_bytes: u64, + pub quality: Option, + pub filename: String, +} + +#[derive(Debug, Deserialize)] +struct ApibayTorrent { + name: String, + info_hash: String, + seeders: String, + leechers: String, + size: String, + #[allow(dead_code)] + added: String, + #[allow(dead_code)] + category: String, +} + +#[derive(Debug, Deserialize)] +struct EztvResponse { + #[allow(dead_code)] + torrents_count: Option, + torrents: Option>, +} + +#[derive(Debug, Deserialize)] +struct EztvTorrent { + title: String, + imdb_id: Option, + season: Option, + episode: Option, + magnet_url: String, + seeds: u32, + peers: u32, + size_bytes: Option, + filename: Option, +} + +#[derive(Debug, Deserialize)] +struct TorrentioResponse { + streams: Option>, +} + +#[derive(Debug, Deserialize)] +struct TorrentioStream { + #[allow(dead_code)] + name: Option, + title: Option, + #[serde(rename = "infoHash")] + info_hash: String, + #[serde(rename = "fileIdx")] + #[allow(dead_code)] + file_idx: Option, } pub struct SearchProvider { client: reqwest::Client, + providers: Vec, + cinemeta: CinemetaClient, } impl Default for SearchProvider { fn default() -> Self { - Self::new() + Self::new(Vec::new(), None) } } impl SearchProvider { - pub fn new() -> Self { - let client = reqwest::Client::builder() + pub fn new(providers: Vec, socks5: Option) -> Self { + let mut builder = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) - .user_agent("Mozilla/5.0 (X11; Linux x86_64) StreamX/0.1") - .build() - .unwrap_or_default(); - Self { client } + .user_agent("Mozilla/5.0 (X11; Linux x86_64) StreamX/0.1"); + if let Some(ref url) = socks5 { + if let Ok(proxy) = reqwest::Proxy::all(url) { + builder = builder.proxy(proxy); + } + } + let client = builder.build().unwrap_or_default(); + let cinemeta = CinemetaClient::new(client.clone()); + Self { + client, + providers, + cinemeta, + } + } + + fn provider(&self, kind: &str) -> Option<&ProviderConfig> { + self.providers.iter().find(|p| p.kind == kind) } - pub async fn search(&self, query: &str) -> Result> { + fn providers_by_kind(&self, kind: &str) -> Vec { + self.providers + .iter() + .filter(|p| p.kind == kind) + .cloned() + .collect() + } + + pub async fn search(&self, query: &str) -> Result> { + let providers = self.providers_by_kind("movies"); + if providers.is_empty() { + return Ok(Vec::new()); + } + + let futs: Vec<_> = providers + .iter() + .map(|p| { + let p = p.clone(); + async move { + let fmt = p.format.as_deref().unwrap_or("yts"); + match fmt { + "torrentio" => self.search_torrentio_movies(query, &p).await, + _ => self.search_yts(query, &p).await, + } + } + }) + .collect(); + + let all_results = futures::future::join_all(futs).await; + + let mut groups: Vec = all_results + .into_iter() + .flat_map(|r| r.unwrap_or_default()) + .collect(); + + merge_movie_groups(&mut groups); + Ok(groups) + } + + async fn search_yts( + &self, + query: &str, + provider: &ProviderConfig, + ) -> Result> { + let api_url = provider + .api_url + .clone() + .unwrap_or_else(|| format!("{}/api/v2/list_movies.json", provider.url)); let response = self .client - .get(YTS_API_URL) + .get(&api_url) .query(&[("query_term", query), ("sort_by", "seeds"), ("limit", "20")]) .send() .await; @@ -112,41 +313,1206 @@ impl SearchProvider { None => return Ok(Vec::new()), }; - let mut results: Vec = movies + let mut groups: Vec = movies .into_iter() - .flat_map(|movie| { + .map(|movie| { + let title = movie.title.clone(); + let year = movie.year; + let poster = movie + .large_cover_image + .clone() + .or_else(|| movie.medium_cover_image.clone()); + let torrents = movie.torrents.unwrap_or_default(); - torrents.into_iter().map({ - let title = movie.title.clone(); - let year = movie.year; - let rating = movie.rating; - let poster = movie.medium_cover_image.clone(); - move |torrent| { + let mut variants: Vec = torrents + .into_iter() + .map(|torrent| { let display_title = format!("{title} ({year}) [{quality}]", quality = torrent.quality); let magnet = build_magnet(&torrent.hash, &display_title); SearchResult { - title: display_title, magnet, seeds: torrent.seeds, leeches: torrent.peers, - size: torrent.size.clone(), + size: torrent.size, size_bytes: torrent.size_bytes, quality: Some(torrent.quality), - year: Some(year), - rating: Some(rating), - poster: poster.clone(), + video_codec: torrent.video_codec, + audio_channels: torrent.audio_channels, + bit_depth: torrent.bit_depth.map(|b| b.to_string()), + source_type: torrent.source_type, + } + }) + .collect(); + + variants.sort_by(|a, b| b.seeds.cmp(&a.seeds)); + + SearchResultGroup { + title: movie.title, + year: Some(year), + rating: Some(movie.rating), + runtime: movie.runtime, + genres: movie.genres.unwrap_or_default(), + language: movie.language, + mpa_rating: movie.mpa_rating, + summary: movie.summary, + imdb_code: movie.imdb_code, + trailer_code: movie.yt_trailer_code, + poster, + poster_small: movie.small_cover_image, + poster_medium: movie.medium_cover_image, + poster_large: movie.large_cover_image, + backdrop: movie.background_image, + variants, + } + }) + .collect(); + + groups.sort_by(|a, b| { + let best_a = a.variants.iter().map(|v| v.seeds).max().unwrap_or(0); + let best_b = b.variants.iter().map(|v| v.seeds).max().unwrap_or(0); + best_b.cmp(&best_a) + }); + + for group in &mut groups { + proxy_posters(group, provider.id); + } + + Ok(groups) + } + + pub async fn browse( + &self, + sort_by: &str, + genre: Option<&str>, + minimum_rating: Option, + limit: u32, + page: u32, + ) -> Result> { + // Browse only works with catalog-based providers (not torrentio) + let provider = match self + .providers_by_kind("movies") + .into_iter() + .find(|p| p.format.as_deref().unwrap_or("yts") != "torrentio") + { + Some(p) => p, + None => return Ok(Vec::new()), + }; + let api_url = provider + .api_url + .clone() + .unwrap_or_else(|| format!("{}/api/v2/list_movies.json", provider.url)); + let limit_str = limit.to_string(); + let page_str = page.to_string(); + let mut params: Vec<(&str, &str)> = vec![ + ("sort_by", sort_by), + ("limit", &limit_str), + ("page", &page_str), + ("order_by", "desc"), + ]; + let rating_str; + if let Some(r) = minimum_rating { + rating_str = r.to_string(); + params.push(("minimum_rating", &rating_str)); + } + if let Some(g) = genre { + params.push(("genre", g)); + } + + let response = match self.client.get(&api_url).query(¶ms).send().await { + Ok(r) => r, + Err(err) => { + tracing::warn!("YTS browse request failed: {err}"); + return Ok(Vec::new()); + } + }; + + let yts: YtsResponse = match response.json().await { + Ok(parsed) => parsed, + Err(err) => { + tracing::warn!("Failed to parse YTS browse response: {err}"); + return Ok(Vec::new()); + } + }; + + if yts.status != "ok" { + return Ok(Vec::new()); + } + + let movies = match yts.data.and_then(|d| d.movies) { + Some(m) => m, + None => return Ok(Vec::new()), + }; + + let groups: Vec = movies + .into_iter() + .map(|movie| { + let title = movie.title.clone(); + let year = movie.year; + let poster = movie + .large_cover_image + .clone() + .or_else(|| movie.medium_cover_image.clone()); + let torrents = movie.torrents.unwrap_or_default(); + let mut variants: Vec = torrents + .into_iter() + .map(|torrent| { + let display_title = + format!("{title} ({year}) [{quality}]", quality = torrent.quality); + let magnet = build_magnet(&torrent.hash, &display_title); + SearchResult { + magnet, + seeds: torrent.seeds, + leeches: torrent.peers, + size: torrent.size, + size_bytes: torrent.size_bytes, + quality: Some(torrent.quality), + video_codec: torrent.video_codec, + audio_channels: torrent.audio_channels, + bit_depth: torrent.bit_depth.map(|b| b.to_string()), + source_type: torrent.source_type, + } + }) + .collect(); + variants.sort_by(|a, b| b.seeds.cmp(&a.seeds)); + SearchResultGroup { + title: movie.title, + year: Some(year), + rating: Some(movie.rating), + runtime: movie.runtime, + genres: movie.genres.unwrap_or_default(), + language: movie.language, + mpa_rating: movie.mpa_rating, + summary: movie.summary, + imdb_code: movie.imdb_code, + trailer_code: movie.yt_trailer_code, + poster, + poster_small: movie.small_cover_image, + poster_medium: movie.medium_cover_image, + poster_large: movie.large_cover_image, + backdrop: movie.background_image, + variants, + } + }) + .collect(); + + let mut groups = groups; + for group in &mut groups { + proxy_posters(group, provider.id); + } + + Ok(groups) + } + + pub async fn search_tv(&self, query: &str) -> Result> { + let provider = match self.provider("tv") { + Some(p) => p.clone(), + None => return Ok(Vec::new()), + }; + let fmt = provider.format.as_deref().unwrap_or("eztv"); + match fmt { + "torrentio" => self.search_tv_torrentio(query, &provider).await, + "apibay" => self.search_tv_apibay(query, &provider).await, + _ => self.search_tv_eztv(query, &provider).await, + } + } + + pub async fn browse_tv(&self, page: u32, limit: u32) -> Result> { + let provider = match self.provider("tv") { + Some(p) => p.clone(), + None => return Ok(Vec::new()), + }; + let fmt = provider.format.as_deref().unwrap_or("eztv"); + match fmt { + "torrentio" => self.browse_tv_torrentio(page).await, + "apibay" => self.browse_tv_apibay(page, &provider).await, + _ => self.browse_tv_eztv(page, limit, &provider).await, + } + } + + async fn search_tv_eztv( + &self, + query: &str, + provider: &ProviderConfig, + ) -> Result> { + let api_url = format!("{}/api/get-torrents", provider.url); + let response = match self + .client + .get(&api_url) + .query(&[("search", query), ("limit", "100")]) + .send() + .await + { + Ok(r) => r, + Err(err) => { + tracing::warn!("TV search request failed: {err}"); + return Ok(Vec::new()); + } + }; + let eztv: EztvResponse = match response.json().await { + Ok(parsed) => parsed, + Err(err) => { + tracing::warn!("Failed to parse TV response: {err}"); + return Ok(Vec::new()); + } + }; + let torrents = match eztv.torrents { + Some(t) if !t.is_empty() => t, + _ => return Ok(Vec::new()), + }; + Ok(group_eztv_torrents(torrents)) + } + + async fn browse_tv_eztv( + &self, + page: u32, + limit: u32, + provider: &ProviderConfig, + ) -> Result> { + let api_url = format!("{}/api/get-torrents", provider.url); + let response = match self + .client + .get(&api_url) + .query(&[ + ("page", page.to_string()), + ("limit", limit.min(100).to_string()), + ]) + .send() + .await + { + Ok(r) => r, + Err(err) => { + tracing::warn!("TV browse request failed: {err}"); + return Ok(Vec::new()); + } + }; + let eztv: EztvResponse = match response.json().await { + Ok(parsed) => parsed, + Err(err) => { + tracing::warn!("Failed to parse TV browse response: {err}"); + return Ok(Vec::new()); + } + }; + let torrents = match eztv.torrents { + Some(t) if !t.is_empty() => t, + _ => return Ok(Vec::new()), + }; + Ok(group_eztv_torrents(torrents)) + } + + async fn search_tv_apibay( + &self, + query: &str, + provider: &ProviderConfig, + ) -> Result> { + let cat = provider.category.as_deref().unwrap_or("205"); + let api_url = provider + .api_url + .as_deref() + .map_or_else(|| format!("{}/q.php", provider.url), String::from); + let results = self.fetch_apibay(&api_url, query, cat).await?; + Ok(apibay_to_tv_groups(results)) + } + + async fn browse_tv_apibay( + &self, + _page: u32, + provider: &ProviderConfig, + ) -> Result> { + let cat = provider.category.as_deref().unwrap_or("205"); + let api_url = provider + .api_url + .as_deref() + .map_or_else(|| format!("{}/q.php", provider.url), String::from); + let results = self.fetch_apibay(&api_url, "", cat).await?; + Ok(apibay_to_tv_groups(results)) + } + + async fn browse_tv_torrentio(&self, page: u32) -> Result> { + let skip = (page.saturating_sub(1)) * 50; + let catalog = self.cinemeta.catalog("series", skip).await?; + let groups = catalog + .into_iter() + .map(|meta| TvSearchResultGroup { + show_name: meta.title(), + imdb_id: Some(meta.id), + seasons: Vec::new(), + }) + .collect(); + Ok(groups) + } + + /// Fetch episodes for a specific show by IMDB ID using Torrentio. + /// If `season` is Some, fetch only that season. Otherwise fetch all. + pub async fn fetch_show_episodes( + &self, + imdb_id: &str, + season: Option, + ) -> Result> { + let provider = match self.provider("tv") { + Some(p) => p.clone(), + None => return Ok(Vec::new()), + }; + let fmt = provider.format.as_deref().unwrap_or("eztv"); + if fmt != "torrentio" { + return Ok(Vec::new()); + } + match season { + Some(s) => { + let eps = self + .fetch_torrentio_season_episodes(&provider.url, imdb_id, s) + .await; + if eps.is_empty() { + Ok(Vec::new()) + } else { + Ok(vec![TvSeason { + season: s, + episodes: eps, + }]) + } + } + None => Ok(self + .fetch_torrentio_show_seasons(&provider.url, imdb_id) + .await), + } + } + + /// Discover which seasons exist for a show by probing E01 sequentially. + pub async fn discover_seasons(&self, imdb_id: &str) -> Result> { + let provider = match self.provider("tv") { + Some(p) => p.clone(), + None => return Ok(Vec::new()), + }; + let fmt = provider.format.as_deref().unwrap_or("eztv"); + if fmt != "torrentio" { + return Ok(Vec::new()); + } + let mut seasons = Vec::new(); + let mut consecutive_misses = 0; + for s in 1..=20u32 { + let id = format!("{imdb_id}:{s}:1"); + let streams = self + .fetch_torrentio_streams(&provider.url, "series", &id) + .await + .unwrap_or_default(); + if !streams.is_empty() { + seasons.push(s); + consecutive_misses = 0; + } else { + consecutive_misses += 1; + if consecutive_misses >= 3 { + break; + } + } + } + Ok(seasons) + } + + async fn fetch_torrentio_season_episodes( + &self, + base_url: &str, + imdb_id: &str, + season_num: u32, + ) -> Vec { + const MAX_EPISODES: u32 = 30; + const BATCH_SIZE: u32 = 10; + + let mut episodes = Vec::new(); + for batch_start in (1..=MAX_EPISODES).step_by(BATCH_SIZE as usize) { + let batch_end = (batch_start + BATCH_SIZE - 1).min(MAX_EPISODES); + let futs: Vec<_> = (batch_start..=batch_end) + .map(|ep_num| { + let id = format!("{imdb_id}:{season_num}:{ep_num}"); + async move { + let streams = self + .fetch_torrentio_streams(base_url, "series", &id) + .await + .unwrap_or_default(); + (ep_num, streams) + } + }) + .collect(); + let batch_results = futures::future::join_all(futs).await; + let any_found = batch_results.iter().any(|(_, s)| !s.is_empty()); + for (ep_num, streams) in batch_results { + if streams.is_empty() { + continue; + } + let mut variants: Vec = streams + .into_iter() + .map(|stream| { + let title_text = stream.title.as_deref().unwrap_or(""); + let seeds = parse_torrentio_seeds(title_text); + let size_str = parse_torrentio_size(title_text); + let size_bytes = parse_size_to_bytes(&size_str); + let quality = extract_quality(title_text); + let filename = title_text.lines().next().unwrap_or("").trim().to_string(); + let display = format!( + "S{season_num:02}E{ep_num:02} [{}]", + quality.as_deref().unwrap_or("unknown") + ); + let magnet = build_magnet(&stream.info_hash, &display); + TvTorrent { + magnet, + seeds, + leeches: 0, + size_bytes, + quality, + filename, } + }) + .collect(); + variants.sort_by(|a, b| b.seeds.cmp(&a.seeds)); + episodes.push(TvEpisode { + episode: ep_num, + title: None, + variants, + }); + } + if !any_found { + break; + } + } + episodes.sort_by_key(|e| e.episode); + episodes + } + + async fn fetch_torrentio_streams( + &self, + base_url: &str, + content_type: &str, + id: &str, + ) -> Result> { + let url = format!( + "{}/stream/{}/{}.json", + base_url.trim_end_matches('/'), + content_type, + id + ); + let response = match self.client.get(&url).send().await { + Ok(r) => r, + Err(err) => { + tracing::warn!("Torrentio request failed for {id}: {err}"); + return Ok(Vec::new()); + } + }; + let body: TorrentioResponse = match response.json().await { + Ok(parsed) => parsed, + Err(err) => { + tracing::warn!("Failed to parse Torrentio response for {id}: {err}"); + return Ok(Vec::new()); + } + }; + Ok(body.streams.unwrap_or_default()) + } + + async fn search_torrentio_movies( + &self, + query: &str, + provider: &ProviderConfig, + ) -> Result> { + let search_results = self.cinemeta.search("movie", query).await?; + if search_results.is_empty() { + return Ok(Vec::new()); + } + + let items: Vec<_> = search_results.into_iter().take(10).collect(); + + // Fetch Torrentio streams and Cinemeta details concurrently for all movies + let futs: Vec<_> = items + .iter() + .map(|meta| { + let base_url = provider.url.clone(); + let imdb_id = meta.id.clone(); + async move { + let (streams, detail) = futures::future::join( + self.fetch_torrentio_streams(&base_url, "movie", &imdb_id), + self.cinemeta.get_detail("movie", &imdb_id), + ) + .await; + (streams, detail) + } + }) + .collect(); + + let all_results = futures::future::join_all(futs).await; + + let mut groups = Vec::new(); + for (meta, (streams_result, detail_result)) in items.into_iter().zip(all_results) { + let streams = streams_result.unwrap_or_default(); + if streams.is_empty() { + continue; + } + + let detail = detail_result.ok().flatten(); + let title = meta.title(); + + let mut variants: Vec = streams + .into_iter() + .map(|stream| { + let title_text = stream.title.as_deref().unwrap_or(""); + let seeds = parse_torrentio_seeds(title_text); + let size_str = parse_torrentio_size(title_text); + let size_bytes = parse_size_to_bytes(&size_str); + let quality = extract_quality(title_text); + let video_codec = extract_codec(title_text); + let display_title = + format!("{} [{}]", title, quality.as_deref().unwrap_or("unknown")); + let magnet = build_magnet(&stream.info_hash, &display_title); + + SearchResult { + magnet, + seeds, + leeches: 0, + size: if size_str.is_empty() { + format_size(size_bytes) + } else { + size_str + }, + size_bytes, + quality, + video_codec, + audio_channels: None, + bit_depth: None, + source_type: None, } }) + .collect(); + + variants.sort_by(|a, b| b.seeds.cmp(&a.seeds)); + + // Use detail for richer metadata, fall back to search result + let d = detail.as_ref().unwrap_or(&meta); + let mut group = SearchResultGroup { + title: d.title(), + year: d.parse_year(), + rating: d.parse_rating(), + runtime: d.parse_runtime(), + genres: d.genres_list(), + language: None, + mpa_rating: None, + summary: d.description.clone(), + imdb_code: Some(meta.id.clone()), + trailer_code: None, + poster: d.poster.clone(), + poster_small: d.poster.clone(), + poster_medium: d.poster.clone(), + poster_large: d.poster.clone(), + backdrop: d.background.clone(), + variants, + }; + proxy_posters(&mut group, CINEMETA_PROXY_ID); + groups.push(group); + } + + groups.sort_by(|a, b| { + let best_a = a.variants.iter().map(|v| v.seeds).max().unwrap_or(0); + let best_b = b.variants.iter().map(|v| v.seeds).max().unwrap_or(0); + best_b.cmp(&best_a) + }); + + Ok(groups) + } + + async fn search_tv_torrentio( + &self, + query: &str, + _provider: &ProviderConfig, + ) -> Result> { + let search_results = self.cinemeta.search("series", query).await?; + let groups = search_results + .into_iter() + .take(10) + .map(|meta| TvSearchResultGroup { + show_name: meta.title(), + imdb_id: Some(meta.id), + seasons: Vec::new(), }) .collect(); + Ok(groups) + } + + async fn fetch_torrentio_show_seasons(&self, base_url: &str, imdb_id: &str) -> Vec { + let season_nums = self.discover_seasons(imdb_id).await.unwrap_or_default(); + let mut seasons = Vec::new(); + for s in season_nums { + let episodes = self + .fetch_torrentio_season_episodes(base_url, imdb_id, s) + .await; + if !episodes.is_empty() { + seasons.push(TvSeason { + season: s, + episodes, + }); + } + } + seasons + } - results.sort_by(|a, b| b.seeds.cmp(&a.seeds)); + pub async fn search_music_videos(&self, query: &str) -> Result> { + let provider = match self.provider("music-videos") { + Some(p) => p.clone(), + None => return Ok(Vec::new()), + }; + let fmt = provider.format.as_deref().unwrap_or("apibay"); + match fmt { + "scrape" => { + let search_url = format!( + "{}/category-search/{}/Music-videos/1/", + provider.url, + urlencoding::encode(query) + ); + self.scrape_list(&provider.url, &search_url).await + } + _ => { + let cat = provider.category.as_deref().unwrap_or("601"); + let api_url = provider + .api_url + .as_deref() + .map_or_else(|| format!("{}/q.php", provider.url), String::from); + let results = self.fetch_apibay(&api_url, query, cat).await?; + Ok(apibay_to_music_results(results)) + } + } + } + + pub async fn browse_music_videos(&self, page: u32) -> Result> { + let provider = match self.provider("music-videos") { + Some(p) => p.clone(), + None => return Ok(Vec::new()), + }; + let fmt = provider.format.as_deref().unwrap_or("apibay"); + match fmt { + "scrape" => { + let url = format!("{}/cat/Music-videos/{}/", provider.url, page); + self.scrape_list(&provider.url, &url).await + } + _ => { + let cat = provider.category.as_deref().unwrap_or("601"); + let api_url = provider + .api_url + .as_deref() + .map_or_else(|| format!("{}/q.php", provider.url), String::from); + let results = self.fetch_apibay(&api_url, "", cat).await?; + Ok(apibay_to_music_results(results)) + } + } + } + + pub async fn search_music(&self, query: &str) -> Result> { + let provider = match self.provider("music") { + Some(p) => p.clone(), + None => return Ok(Vec::new()), + }; + let fmt = provider.format.as_deref().unwrap_or("apibay"); + match fmt { + "scrape" => { + let search_url = format!( + "{}/category-search/{}/Music/1/", + provider.url, + urlencoding::encode(query) + ); + self.scrape_list(&provider.url, &search_url).await + } + _ => { + let cat = provider.category.as_deref().unwrap_or("101"); + let api_url = provider + .api_url + .as_deref() + .map_or_else(|| format!("{}/q.php", provider.url), String::from); + let results = self.fetch_apibay(&api_url, query, cat).await?; + Ok(apibay_to_music_results(results)) + } + } + } + + pub async fn browse_music(&self, page: u32) -> Result> { + let provider = match self.provider("music") { + Some(p) => p.clone(), + None => return Ok(Vec::new()), + }; + let fmt = provider.format.as_deref().unwrap_or("apibay"); + match fmt { + "scrape" => { + let url = format!("{}/cat/Music/{}/", provider.url, page); + self.scrape_list(&provider.url, &url).await + } + _ => { + let cat = provider.category.as_deref().unwrap_or("101"); + let api_url = provider + .api_url + .as_deref() + .map_or_else(|| format!("{}/q.php", provider.url), String::from); + let results = self.fetch_apibay(&api_url, "", cat).await?; + Ok(apibay_to_music_results(results)) + } + } + } + + async fn fetch_apibay( + &self, + api_url: &str, + query: &str, + cat: &str, + ) -> Result> { + let mut params = vec![("cat", cat.to_string())]; + if query.is_empty() { + params.push(("q", "top100".to_string())); + } else { + params.push(("q", query.to_string())); + } + let response = match self.client.get(api_url).query(¶ms).send().await { + Ok(r) => r, + Err(err) => { + tracing::warn!("Apibay request failed: {err}"); + return Ok(Vec::new()); + } + }; + let torrents: Vec = match response.json().await { + Ok(parsed) => parsed, + Err(err) => { + tracing::warn!("Failed to parse apibay response: {err}"); + return Ok(Vec::new()); + } + }; + // Filter out the "no results" sentinel (id=0, name="No results") + Ok(torrents + .into_iter() + .filter(|t| t.info_hash != "0000000000000000000000000000000000000000") + .collect()) + } + + async fn scrape_list(&self, base_url: &str, url: &str) -> Result> { + let html = match self.client.get(url).send().await { + Ok(r) => match r.text().await { + Ok(t) => t, + Err(err) => { + tracing::warn!("Failed to read 1337x response: {err}"); + return Ok(Vec::new()); + } + }, + Err(err) => { + tracing::warn!("1337x request failed: {err}"); + return Ok(Vec::new()); + } + }; + + let document = scraper::Html::parse_document(&html); + let row_sel = scraper::Selector::parse("tbody tr") + .unwrap_or_else(|_| scraper::Selector::parse("tr").unwrap_or_else(|_| unreachable!())); + let name_sel = scraper::Selector::parse("td.name a:nth-child(2)").unwrap_or_else(|_| { + scraper::Selector::parse("td a").unwrap_or_else(|_| unreachable!()) + }); + let seeds_sel = scraper::Selector::parse("td.seeds") + .unwrap_or_else(|_| scraper::Selector::parse("td").unwrap_or_else(|_| unreachable!())); + let leeches_sel = scraper::Selector::parse("td.leeches") + .unwrap_or_else(|_| scraper::Selector::parse("td").unwrap_or_else(|_| unreachable!())); + let size_sel = scraper::Selector::parse("td.size") + .unwrap_or_else(|_| scraper::Selector::parse("td").unwrap_or_else(|_| unreachable!())); + + let mut results = Vec::new(); + + for row in document.select(&row_sel) { + let name_el = match row.select(&name_sel).next() { + Some(el) => el, + None => continue, + }; + + let title = name_el.text().collect::().trim().to_string(); + if title.is_empty() { + continue; + } + + let detail_path = name_el.value().attr("href").unwrap_or(""); + let detail_url = if detail_path.starts_with('/') { + format!("{}{}", base_url, detail_path) + } else { + detail_path.to_string() + }; + + let seeds: u32 = row + .select(&seeds_sel) + .next() + .map(|el| el.text().collect::().trim().parse().unwrap_or(0)) + .unwrap_or(0); + + let leeches: u32 = row + .select(&leeches_sel) + .next() + .map(|el| el.text().collect::().trim().parse().unwrap_or(0)) + .unwrap_or(0); + + let size = row + .select(&size_sel) + .next() + .map(|el| { + // 1337x puts size in first text node (before the span) + el.text().next().unwrap_or("").trim().to_string() + }) + .unwrap_or_default(); + + results.push(MusicVideoResult { + title, + magnet: None, + seeds, + leeches, + size, + detail_url, + }); + } Ok(results) } + + pub async fn get_magnet(&self, detail_url: &str) -> Result> { + // Validate URL to prevent SSRF - must start with a known provider URL + let is_allowed = self + .providers + .iter() + .any(|p| detail_url.starts_with(&p.url)); + if !is_allowed { + return Ok(None); + } + + let html = match self.client.get(detail_url).send().await { + Ok(r) => match r.text().await { + Ok(t) => t, + Err(_) => return Ok(None), + }, + Err(_) => return Ok(None), + }; + + let document = scraper::Html::parse_document(&html); + let magnet_sel = scraper::Selector::parse("a[href^='magnet:']") + .unwrap_or_else(|_| scraper::Selector::parse("a").unwrap_or_else(|_| unreachable!())); + + let magnet = document + .select(&magnet_sel) + .next() + .and_then(|el| el.value().attr("href")) + .map(String::from); + + Ok(magnet) + } +} + +fn group_eztv_torrents(torrents: Vec) -> Vec { + use std::collections::HashMap; + + // Group by show name + let mut shows: HashMap> = HashMap::new(); + for t in &torrents { + let show_name = extract_show_name(&t.title); + shows.entry(show_name).or_default().push(t); + } + + let mut groups: Vec = shows + .into_iter() + .map(|(show_name, show_torrents)| { + let imdb_id = show_torrents + .iter() + .find_map(|t| t.imdb_id.as_ref()) + .cloned(); + + // Group by season -> episode + let mut season_map: HashMap>> = HashMap::new(); + for t in show_torrents { + let season = t + .season + .as_deref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(1); + let episode = t + .episode + .as_deref() + .and_then(|e| e.parse::().ok()) + .unwrap_or(0); + + let size_bytes = t + .size_bytes + .as_deref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + + let quality = extract_quality(&t.title); + + let variant = TvTorrent { + magnet: t.magnet_url.clone(), + seeds: t.seeds, + leeches: t.peers, + size_bytes, + quality, + filename: t.filename.clone().unwrap_or_default(), + }; + + season_map + .entry(season) + .or_default() + .entry(episode) + .or_default() + .push(variant); + } + + let mut seasons: Vec = season_map + .into_iter() + .map(|(season_num, episodes_map)| { + let mut episodes: Vec = episodes_map + .into_iter() + .map(|(ep_num, mut variants)| { + variants.sort_by(|a, b| b.seeds.cmp(&a.seeds)); + TvEpisode { + episode: ep_num, + title: None, + variants, + } + }) + .collect(); + episodes.sort_by_key(|e| e.episode); + TvSeason { + season: season_num, + episodes, + } + }) + .collect(); + seasons.sort_by_key(|s| s.season); + + TvSearchResultGroup { + show_name, + imdb_id, + seasons, + } + }) + .collect(); + + // Sort by total seeds across all torrents + groups.sort_by(|a, b| { + let seeds_a: u32 = a + .seasons + .iter() + .flat_map(|s| &s.episodes) + .flat_map(|e| &e.variants) + .map(|v| v.seeds) + .sum(); + let seeds_b: u32 = b + .seasons + .iter() + .flat_map(|s| &s.episodes) + .flat_map(|e| &e.variants) + .map(|v| v.seeds) + .sum(); + seeds_b.cmp(&seeds_a) + }); + + groups +} + +fn apibay_to_tv_groups(torrents: Vec) -> Vec { + use std::collections::HashMap; + + let mut shows: HashMap> = HashMap::new(); + for t in &torrents { + let show_name = extract_show_name(&t.name); + shows.entry(show_name).or_default().push(t); + } + + let mut groups: Vec = shows + .into_iter() + .map(|(show_name, show_torrents)| { + let mut season_map: std::collections::HashMap> = HashMap::new(); + for t in &show_torrents { + let (season, _episode) = parse_season_episode(&t.name); + let magnet = build_magnet(&t.info_hash, &t.name); + let variant = TvTorrent { + magnet, + seeds: t.seeders.parse().unwrap_or(0), + leeches: t.leechers.parse().unwrap_or(0), + size_bytes: t.size.parse().unwrap_or(0), + quality: extract_quality(&t.name), + filename: t.name.clone(), + }; + season_map.entry(season).or_default().push(variant); + } + + let mut seasons: Vec = season_map + .into_iter() + .map(|(season_num, variants)| TvSeason { + season: season_num, + episodes: vec![TvEpisode { + episode: 0, + title: None, + variants, + }], + }) + .collect(); + seasons.sort_by_key(|s| s.season); + + TvSearchResultGroup { + show_name, + imdb_id: None, + seasons, + } + }) + .collect(); + + groups.sort_by(|a, b| { + let seeds_a: u32 = a + .seasons + .iter() + .flat_map(|s| &s.episodes) + .flat_map(|e| &e.variants) + .map(|v| v.seeds) + .sum(); + let seeds_b: u32 = b + .seasons + .iter() + .flat_map(|s| &s.episodes) + .flat_map(|e| &e.variants) + .map(|v| v.seeds) + .sum(); + seeds_b.cmp(&seeds_a) + }); + + groups +} + +fn apibay_to_music_results(torrents: Vec) -> Vec { + torrents + .into_iter() + .map(|t| { + let magnet = build_magnet(&t.info_hash, &t.name); + let size_bytes: u64 = t.size.parse().unwrap_or(0); + MusicVideoResult { + title: t.name, + magnet: Some(magnet), + seeds: t.seeders.parse().unwrap_or(0), + leeches: t.leechers.parse().unwrap_or(0), + size: format_size(size_bytes), + detail_url: String::new(), + } + }) + .collect() +} + +fn format_size(bytes: u64) -> String { + if bytes >= 1_073_741_824 { + format!("{:.1} GB", bytes as f64 / 1_073_741_824.0) + } else if bytes >= 1_048_576 { + format!("{:.1} MB", bytes as f64 / 1_048_576.0) + } else if bytes >= 1024 { + format!("{:.0} KB", bytes as f64 / 1024.0) + } else { + format!("{bytes} B") + } +} + +fn parse_season_episode(title: &str) -> (u32, u32) { + // S01E02 pattern + let upper = title.to_uppercase(); + for (i, _) in upper.char_indices() { + if i + 6 <= upper.len() && upper[i..].starts_with('S') { + let rest = &upper[i + 1..]; + if let Some(e_pos) = rest.find('E') { + if let (Ok(s), Ok(e)) = ( + rest[..e_pos].parse::(), + rest[e_pos + 1..] + .split(|c: char| !c.is_ascii_digit()) + .next() + .unwrap_or("0") + .parse::(), + ) { + return (s, e); + } + } + } + } + (1, 0) +} + +fn extract_show_name(title: &str) -> String { + // Pattern: "Show Name S01E02 ..." -> "Show Name" + if let Some(idx) = title.find(" S0") { + return title[..idx].trim().to_string(); + } + if let Some(idx) = title.find(" s0") { + return title[..idx].trim().to_string(); + } + if let Some(idx) = title.find(" S1") { + return title[..idx].trim().to_string(); + } + // Pattern: "Show Name 1x02 ..." + for (i, _) in title.char_indices() { + if i > 0 && i + 3 < title.len() { + let slice = &title[i..]; + if slice.starts_with(' ') + && slice.len() > 4 + && slice.as_bytes()[1].is_ascii_digit() + && slice.as_bytes()[2] == b'x' + && slice.as_bytes()[3].is_ascii_digit() + { + return title[..i].trim().to_string(); + } + } + } + title.trim().to_string() +} + +fn extract_quality(title: &str) -> Option { + let lower = title.to_lowercase(); + if lower.contains("2160p") || lower.contains("4k") { + Some("2160p".to_string()) + } else if lower.contains("1080p") { + Some("1080p".to_string()) + } else if lower.contains("720p") { + Some("720p".to_string()) + } else if lower.contains("480p") { + Some("480p".to_string()) + } else { + None + } +} + +fn merge_movie_groups(groups: &mut Vec) { + use std::collections::HashMap; + let mut by_imdb: HashMap = HashMap::new(); + let mut merged: Vec = Vec::new(); + + for group in groups.drain(..) { + if let Some(ref imdb) = group.imdb_code { + if let Some(&idx) = by_imdb.get(imdb) { + merged[idx].variants.extend(group.variants); + // Deduplicate by info_hash (magnet contains the hash) + let mut seen = std::collections::HashSet::new(); + merged[idx].variants.retain(|v| { + let hash = extract_magnet_hash(&v.magnet); + seen.insert(hash) + }); + merged[idx].variants.sort_by(|a, b| b.seeds.cmp(&a.seeds)); + continue; + } + by_imdb.insert(imdb.clone(), merged.len()); + } + merged.push(group); + } + + // Sort by best seeds + merged.sort_by(|a, b| { + let best_a = a.variants.iter().map(|v| v.seeds).max().unwrap_or(0); + let best_b = b.variants.iter().map(|v| v.seeds).max().unwrap_or(0); + best_b.cmp(&best_a) + }); + + *groups = merged; +} + +fn extract_magnet_hash(magnet: &str) -> String { + magnet + .strip_prefix("magnet:?xt=urn:btih:") + .unwrap_or(magnet) + .split('&') + .next() + .unwrap_or("") + .to_lowercase() } fn build_magnet(hash: &str, title: &str) -> String { @@ -157,3 +1523,54 @@ fn build_magnet(hash: &str, title: &str) -> String { .collect(); format!("magnet:?xt=urn:btih:{hash}&dn={encoded_title}{trackers}") } + +fn parse_torrentio_seeds(title: &str) -> u32 { + if let Some(idx) = title.find('\u{1F464}') { + let after = &title[idx + '\u{1F464}'.len_utf8()..]; + let trimmed = after.trim_start(); + let num_str: String = trimmed.chars().take_while(|c| c.is_ascii_digit()).collect(); + num_str.parse().unwrap_or(0) + } else { + 0 + } +} + +fn parse_torrentio_size(title: &str) -> String { + if let Some(idx) = title.find('\u{1F4BE}') { + let after = &title[idx + '\u{1F4BE}'.len_utf8()..]; + let trimmed = after.trim_start(); + let size: String = trimmed.chars().take_while(|c| *c != '\n').collect(); + size.trim().to_string() + } else { + String::new() + } +} + +fn parse_size_to_bytes(size_str: &str) -> u64 { + let parts: Vec<&str> = size_str.split_whitespace().collect(); + if parts.len() != 2 { + return 0; + } + let num: f64 = match parts[0].parse() { + Ok(n) => n, + Err(_) => return 0, + }; + match parts[1].to_uppercase().as_str() { + "TB" => (num * 1_099_511_627_776.0) as u64, + "GB" => (num * 1_073_741_824.0) as u64, + "MB" => (num * 1_048_576.0) as u64, + "KB" => (num * 1024.0) as u64, + _ => 0, + } +} + +fn extract_codec(title: &str) -> Option { + let lower = title.to_lowercase(); + if lower.contains("h.265") || lower.contains("hevc") || lower.contains("x265") { + Some("H.265".to_string()) + } else if lower.contains("h.264") || lower.contains("x264") { + Some("H.264".to_string()) + } else { + None + } +} diff --git a/backend/src/transcode/hls.rs b/backend/src/transcode/hls.rs index e1df433..86f1763 100644 --- a/backend/src/transcode/hls.rs +++ b/backend/src/transcode/hls.rs @@ -6,11 +6,30 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; +pub struct ActiveStreamInfo { + pub stream_id: String, + pub quality: String, + pub status: String, + pub cache_bytes: u64, + pub last_activity: String, +} + use super::pipeline::{TranscodeHandle, TranscodePipeline}; use super::probe; const DEMO_HLS_URL: &str = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"; const SEGMENT_CACHE_MAX: usize = 50; +const TRANSCODE_HISTORY_MAX: usize = 100; + +#[derive(Clone)] +pub struct TranscodeHistoryEntry { + pub stream_id: String, + pub quality: String, + pub status: String, + pub cache_bytes: u64, + pub started_at: String, + pub finished_at: String, +} struct SegmentCache { segments: VecDeque<(String, Bytes)>, @@ -47,6 +66,8 @@ pub struct HlsManager { pipeline: TranscodePipeline, active: Arc>>, segment_cache: Arc>, + transcode_history: Arc>>, + last_access: Arc>, cache_dir: PathBuf, } @@ -58,33 +79,123 @@ pub enum PlaylistResponse { impl HlsManager { pub async fn new(config: &TranscodeConfig, cache_dir: PathBuf) -> Result { let pipeline = TranscodePipeline::new(config.clone(), cache_dir.clone()).await?; - Ok(Self { + let manager = Self { pipeline, + transcode_history: Arc::new(std::sync::Mutex::new(VecDeque::with_capacity( + TRANSCODE_HISTORY_MAX, + ))), active: Arc::new(RwLock::new(HashMap::new())), segment_cache: Arc::new(RwLock::new(SegmentCache::new(SEGMENT_CACHE_MAX))), + last_access: Arc::new(dashmap::DashMap::new()), cache_dir, - }) + }; + + // Spawn watchdog to stop idle transcodes after 120s of no access + let active = manager.active.clone(); + let last_access = manager.last_access.clone(); + let history = manager.transcode_history.clone(); + let cache_dir_wd = manager.cache_dir.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + interval.tick().await; + let idle_keys: Vec = { + let active_map = active.read().await; + active_map + .keys() + .filter(|key| { + let idle = last_access + .get(*key) + .map(|t| t.elapsed() > std::time::Duration::from_secs(120)) + .unwrap_or(true); + idle + }) + .cloned() + .collect() + }; + + for key in idle_keys { + tracing::info!(stream_key = %key, "Stopping idle transcode (no access for 120s)"); + // Kill the FFmpeg process by dropping the handle + let handle = active.write().await.remove(&key); + if let Some(h) = handle { + let status = h.status.borrow().clone(); + let (sid, quality) = key.split_once('/').unwrap_or((&key, "source")); + let tier_dir = cache_dir_wd.join(sid).join(quality); + let status_str = match &status { + super::pipeline::TranscodeStatus::Running => "stopped", + super::pipeline::TranscodeStatus::Complete => "complete", + super::pipeline::TranscodeStatus::Failed(_) => "failed", + }; + if let Ok(mut hist) = history.lock() { + if hist.len() >= TRANSCODE_HISTORY_MAX { + hist.pop_front(); + } + hist.push_back(TranscodeHistoryEntry { + stream_id: sid.to_string(), + quality: quality.to_string(), + status: status_str.to_string(), + cache_bytes: tier_dir_size(&tier_dir), + started_at: String::new(), + finished_at: chrono::Utc::now() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + }); + } + } + last_access.remove(&key); + } + } + }); + + Ok(manager) } - pub async fn start_stream(&self, stream_id: &str, file_path: &str) -> Result<()> { + pub async fn start_stream( + &self, + stream_id: &str, + file_path: &str, + quality: &str, + ) -> Result<()> { + let active_key = format!("{stream_id}/{quality}"); { let active = self.active.read().await; - if active.contains_key(stream_id) { + if let Some(handle) = active.get(&active_key) { + let status = handle.status.borrow().clone(); + match status { + crate::transcode::pipeline::TranscodeStatus::Failed(_) => { + drop(active); + self.active.write().await.remove(&active_key); + tracing::info!(stream_id, quality, "Removed failed transcode, will retry"); + } + _ => return Ok(()), + } + } + } + + let stream_dir = self.cache_dir.join(stream_id); + + // Check for cached variant playlist + let variant_playlist = stream_dir.join(quality).join("playlist.m3u8"); + if variant_playlist.exists() { + let content = tokio::fs::read_to_string(&variant_playlist) + .await + .unwrap_or_default(); + if content.matches("EXTINF:").count() > 10 { + tracing::info!(stream_id, quality, "Valid cached quality found, skipping"); return Ok(()); } } - let playlist_path = self.cache_dir.join(stream_id).join("playlist.m3u8"); - if playlist_path.exists() { - let content = tokio::fs::read_to_string(&playlist_path) + // Check for passthrough (flat playlist.m3u8) + let flat_playlist = stream_dir.join("playlist.m3u8"); + if flat_playlist.exists() { + let content = tokio::fs::read_to_string(&flat_playlist) .await .unwrap_or_default(); if content.matches("EXTINF:").count() > 10 { - tracing::info!(stream_id, "Valid cached HLS found, skipping transcode"); + tracing::info!(stream_id, "Valid cached passthrough found, skipping"); return Ok(()); } - tracing::warn!(stream_id, "Cached HLS has too few segments, re-transcoding"); - let _ = tokio::fs::remove_dir_all(self.cache_dir.join(stream_id)).await; } let info = probe::probe(file_path).await?; @@ -97,62 +208,246 @@ impl HlsManager { } else { tracing::info!( stream_id, + quality, video_codec = ?info.video_codec, audio_codec = ?info.audio_codec, - hdr = ?info.hdr_format, - "Transcoding required" + "Transcoding at requested quality" ); - match self - .pipeline - .start_transcode(stream_id, file_path, &info) - .await - { - Ok(h) => h, - Err(e) => { - tracing::warn!(stream_id, "GPU transcode failed, falling back to CPU: {e}"); - let cache_dir = self.cache_dir.join(stream_id); - let _ = tokio::fs::remove_dir_all(&cache_dir).await; - self.pipeline - .start_transcode_cpu(stream_id, file_path, &info) - .await? + if self.pipeline.gpu_enabled() { + match self + .pipeline + .start_transcode(stream_id, file_path, &info, quality) + .await + { + Ok(h) => h, + Err(e) => { + tracing::warn!(stream_id, "GPU transcode failed, falling back to CPU: {e}"); + let qdir = self.cache_dir.join(stream_id).join(quality); + let _ = tokio::fs::remove_dir_all(&qdir).await; + self.pipeline + .start_transcode_cpu(stream_id, file_path, &info, quality) + .await? + } } + } else { + self.pipeline + .start_transcode_cpu(stream_id, file_path, &info, quality) + .await? } }; self.active .write() .await - .insert(stream_id.to_string(), handle); + .insert(active_key, handle); + + Ok(()) + } + + /// Start HLS transcoding from an async reader (torrent stream). + /// `file_path` is used only for probing (the beginning is on disk for sequential downloads). + /// The actual data is read from `reader` via pipe to FFmpeg stdin. + pub async fn start_stream_piped( + &self, + stream_id: &str, + file_path: &str, + reader: R, + quality: &str, + ) -> Result<()> { + let active_key = format!("{stream_id}/{quality}"); + { + let active = self.active.read().await; + if let Some(handle) = active.get(&active_key) { + let status = handle.status.borrow().clone(); + match status { + crate::transcode::pipeline::TranscodeStatus::Failed(_) => { + drop(active); + self.active.write().await.remove(&active_key); + tracing::info!(stream_id, quality, "Removed failed piped transcode, will retry"); + } + _ => return Ok(()), + } + } + } + + let stream_dir = self.cache_dir.join(stream_id); + + // Check cached variant + let variant_playlist = stream_dir.join(quality).join("playlist.m3u8"); + if variant_playlist.exists() { + let content = tokio::fs::read_to_string(&variant_playlist) + .await + .unwrap_or_default(); + if content.matches("EXTINF:").count() > 10 { + tracing::info!(stream_id, quality, "Valid cached quality found, skipping piped"); + return Ok(()); + } + } + + // Check passthrough + let flat_playlist = stream_dir.join("playlist.m3u8"); + if flat_playlist.exists() { + let content = tokio::fs::read_to_string(&flat_playlist) + .await + .unwrap_or_default(); + if content.matches("EXTINF:").count() > 10 { + tracing::info!(stream_id, "Valid cached passthrough found, skipping piped"); + return Ok(()); + } + } + + let info = probe::probe(file_path).await?; + + let handle = if probe::is_browser_compatible(&info) { + tracing::info!(stream_id, "Source is browser-compatible, piped passthrough"); + self.pipeline + .start_passthrough_piped(stream_id, reader) + .await? + } else { + tracing::info!( + stream_id, + quality, + video_codec = ?info.video_codec, + audio_codec = ?info.audio_codec, + "Piped transcode at requested quality" + ); + self.pipeline + .start_transcode_piped(stream_id, reader, &info, quality) + .await? + }; + + self.active + .write() + .await + .insert(active_key, handle); + + Ok(()) + } + + /// Start HLS transcoding from a remote HTTPS URL. + /// FFmpeg reads the URL directly as input. + pub async fn start_stream_url( + &self, + stream_id: &str, + url: &str, + quality: &str, + ) -> Result<()> { + let active_key = format!("{stream_id}/{quality}"); + { + let active = self.active.read().await; + if let Some(handle) = active.get(&active_key) { + let status = handle.status.borrow().clone(); + match status { + crate::transcode::pipeline::TranscodeStatus::Failed(_) => { + drop(active); + self.active.write().await.remove(&active_key); + } + _ => return Ok(()), + } + } + } + + let stream_dir = self.cache_dir.join(stream_id); + let variant_playlist = stream_dir.join(quality).join("playlist.m3u8"); + if variant_playlist.exists() { + let content = tokio::fs::read_to_string(&variant_playlist) + .await + .unwrap_or_default(); + if content.matches("EXTINF:").count() > 10 { + tracing::info!(stream_id, quality, "Valid cached URL transcode found"); + return Ok(()); + } + } + + // Probe the URL + let info = probe::probe(url).await?; + + let handle = if probe::is_browser_compatible(&info) { + tracing::info!(stream_id, "URL source is browser-compatible, passthrough"); + self.pipeline.start_passthrough(stream_id, url).await? + } else { + tracing::info!( + stream_id, + quality, + video_codec = ?info.video_codec, + "Transcoding URL source" + ); + if self.pipeline.gpu_enabled() { + match self + .pipeline + .start_transcode(stream_id, url, &info, quality) + .await + { + Ok(h) => h, + Err(e) => { + tracing::warn!(stream_id, "GPU transcode failed for URL, CPU fallback: {e}"); + self.pipeline + .start_transcode_cpu(stream_id, url, &info, quality) + .await? + } + } + } else { + self.pipeline + .start_transcode_cpu(stream_id, url, &info, quality) + .await? + } + }; + self.active.write().await.insert(active_key, handle); Ok(()) } pub async fn generate_playlist( &self, stream_id: &str, - _stream_ready: bool, + quality: &str, ) -> Result { if stream_id == "demo" { return Ok(PlaylistResponse::Redirect(DEMO_HLS_URL.to_string())); } - let path = self.cache_dir.join(stream_id).join("playlist.m3u8"); - match tokio::fs::read_to_string(&path).await { - Ok(content) => Ok(PlaylistResponse::Content(content)), - Err(_) => { - let placeholder = [ - "#EXTM3U", - "#EXT-X-VERSION:3", - "#EXT-X-TARGETDURATION:2", - "#EXT-X-MEDIA-SEQUENCE:0", - "#EXT-X-PLAYLIST-TYPE:EVENT", - "", - ] - .join("\n"); + // Touch last access for watchdog + let access_key = format!("{stream_id}/{quality}"); + self.last_access + .insert(access_key, std::time::Instant::now()); + + let stream_dir = self.cache_dir.join(stream_id); - Ok(PlaylistResponse::Content(placeholder)) + // For passthrough (browser-compatible), serve flat playlist + let flat_path = stream_dir.join("playlist.m3u8"); + if flat_path.exists() { + if let Ok(content) = tokio::fs::read_to_string(&flat_path).await { + return Ok(PlaylistResponse::Content(content)); } } + + // Serve the variant playlist, rewriting segment paths to include quality prefix + let variant_path = stream_dir.join(quality).join("playlist.m3u8"); + if let Ok(content) = tokio::fs::read_to_string(&variant_path).await { + let rewritten = content + .lines() + .map(|line| { + if !line.starts_with('#') && !line.is_empty() { + format!("{quality}/{line}") + } else { + line.to_string() + } + }) + .collect::>() + .join("\n"); + return Ok(PlaylistResponse::Content(rewritten)); + } + + let placeholder = [ + "#EXTM3U", + "#EXT-X-VERSION:3", + "#EXT-X-TARGETDURATION:2", + "#EXT-X-MEDIA-SEQUENCE:0", + "", + ] + .join("\n"); + + Ok(PlaylistResponse::Content(placeholder)) } pub async fn get_segment(&self, stream_id: &str, segment_name: &str) -> Result> { @@ -175,6 +470,11 @@ impl HlsManager { let path = self.cache_dir.join(stream_id).join(segment_name); match tokio::fs::read(&path).await { Ok(data) => { + if segment_name.ends_with(".ts") && !is_valid_ts_segment(&data) { + tracing::warn!(stream_id, segment_name, "Corrupt TS segment detected, deleting"); + let _ = tokio::fs::remove_file(&path).await; + return Ok(None); + } let bytes = Bytes::from(data); let mut cache = self.segment_cache.write().await; cache.insert(cache_key, bytes.clone()); @@ -187,6 +487,216 @@ impl HlsManager { } } + pub async fn get_variant_segment( + &self, + stream_id: &str, + variant: &str, + segment_name: &str, + ) -> Result> { + // Touch last access for watchdog + let access_key = format!("{stream_id}/{variant}"); + self.last_access + .insert(access_key, std::time::Instant::now()); + + if !variant.chars().all(|c| c.is_alphanumeric()) { + return Err(Error::BadRequest { + message: "Invalid variant name".to_string(), + }); + } + if segment_name.contains("..") || segment_name.contains('/') || segment_name.contains('\\') + { + return Err(Error::BadRequest { + message: "Invalid segment name".to_string(), + }); + } + + let cache_key = format!("{stream_id}/{variant}/{segment_name}"); + + { + let cache = self.segment_cache.read().await; + if let Some(data) = cache.get(&cache_key) { + return Ok(Some(data)); + } + } + + let path = self + .cache_dir + .join(stream_id) + .join(variant) + .join(segment_name); + match tokio::fs::read(&path).await { + Ok(data) => { + if segment_name.ends_with(".ts") && !is_valid_ts_segment(&data) { + tracing::warn!(stream_id, variant, segment_name, "Corrupt TS segment detected, deleting"); + let _ = tokio::fs::remove_file(&path).await; + return Ok(None); + } + let bytes = Bytes::from(data); + let mut cache = self.segment_cache.write().await; + cache.insert(cache_key, bytes.clone()); + Ok(Some(bytes)) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(Error::Transcode { + message: format!("Failed to read variant segment: {e}"), + }), + } + } + + pub async fn get_variant_playlist( + &self, + stream_id: &str, + variant: &str, + ) -> Result> { + if !variant.chars().all(|c| c.is_alphanumeric()) { + return Err(Error::BadRequest { + message: "Invalid variant name".to_string(), + }); + } + + let path = self + .cache_dir + .join(stream_id) + .join(variant) + .join("playlist.m3u8"); + match tokio::fs::read_to_string(&path).await { + Ok(content) => Ok(Some(content)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(Error::Transcode { + message: format!("Failed to read variant playlist: {e}"), + }), + } + } + + pub async fn active_streams(&self) -> Vec { + let active = self.active.read().await; + let cache_dir = self.cache_dir.clone(); + + // Move completed/failed to history + let mut finished_keys = Vec::new(); + for (key, handle) in active.iter() { + let status = handle.status.borrow().clone(); + match status { + crate::transcode::pipeline::TranscodeStatus::Complete + | crate::transcode::pipeline::TranscodeStatus::Failed(_) => { + let (sid, quality) = key.split_once('/').unwrap_or((key, "source")); + let tier_dir = cache_dir.join(sid).join(quality); + let status_str = match &status { + crate::transcode::pipeline::TranscodeStatus::Complete => "complete", + _ => "failed", + }; + let entry = TranscodeHistoryEntry { + stream_id: sid.to_string(), + quality: quality.to_string(), + status: status_str.to_string(), + cache_bytes: tier_dir_size(&tier_dir), + started_at: String::new(), + finished_at: chrono::Utc::now() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + }; + if let Ok(mut history) = self.transcode_history.lock() { + if history.len() >= TRANSCODE_HISTORY_MAX { + history.pop_front(); + } + history.push_back(entry); + } + finished_keys.push(key.clone()); + } + _ => {} + } + } + drop(active); + + // Remove finished from active + if !finished_keys.is_empty() { + let mut active = self.active.write().await; + for key in &finished_keys { + active.remove(key); + } + } + + // Build list: active first, then history (newest first) + let active = self.active.read().await; + let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let mut result: Vec = active + .iter() + .map(|(key, _handle)| { + let (sid, quality) = key.split_once('/').unwrap_or((key, "source")); + let tier_dir = cache_dir.join(sid).join(quality); + ActiveStreamInfo { + stream_id: sid.to_string(), + quality: quality.to_string(), + status: "running".to_string(), + cache_bytes: tier_dir_size(&tier_dir), + last_activity: now.clone(), + } + }) + .collect(); + + if let Ok(history) = self.transcode_history.lock() { + for entry in history.iter().rev() { + result.push(ActiveStreamInfo { + stream_id: entry.stream_id.clone(), + quality: entry.quality.clone(), + status: entry.status.clone(), + cache_bytes: entry.cache_bytes, + last_activity: entry.finished_at.clone(), + }); + } + } + + // Scan cache directory for existing transcodes not in active/history + let mut seen: std::collections::HashSet = result + .iter() + .map(|s| format!("{}/{}", s.stream_id, s.quality)) + .collect(); + + if let Ok(entries) = std::fs::read_dir(&cache_dir) { + for entry in entries.flatten() { + if !entry.path().is_dir() { + continue; + } + let stream_id = entry.file_name().to_string_lossy().to_string(); + if let Ok(tier_entries) = std::fs::read_dir(entry.path()) { + for tier in tier_entries.flatten() { + if !tier.path().is_dir() { + continue; + } + let quality = tier.file_name().to_string_lossy().to_string(); + let playlist = tier.path().join("playlist.m3u8"); + if !playlist.exists() { + continue; + } + let key = format!("{stream_id}/{quality}"); + if seen.contains(&key) { + continue; + } + seen.insert(key); + let mtime = std::fs::metadata(&playlist) + .ok() + .and_then(|m| m.modified().ok()) + .map(|t| { + let dt: chrono::DateTime = t.into(); + dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true) + }) + .unwrap_or_default(); + result.push(ActiveStreamInfo { + stream_id: stream_id.clone(), + quality, + status: "cached".to_string(), + cache_bytes: tier_dir_size(&tier.path()), + last_activity: mtime, + }); + } + } + } + } + + // Sort by last_activity descending (running first since they have "now" timestamp) + result.sort_by(|a, b| b.last_activity.cmp(&a.last_activity)); + result + } + pub async fn cleanup(&self, stream_id: &str) -> Result<()> { self.active.write().await.remove(stream_id); @@ -202,3 +712,47 @@ impl HlsManager { Ok(()) } } + +/// Check MPEG-TS segment integrity by verifying sync bytes. +/// Each TS packet is 188 bytes and must start with 0x47. +/// Returns true if valid, false if corrupt or empty. +fn is_valid_ts_segment(data: &[u8]) -> bool { + if data.is_empty() { + return false; + } + // Check first sync byte + if data[0] != 0x47 { + return false; + } + // Spot-check a few packets (first, middle, last) + let pkt_size = 188; + let check_offsets = [ + 0, + pkt_size, + pkt_size * 2, + (data.len() / pkt_size / 2) * pkt_size, + (data.len() / pkt_size).saturating_sub(1) * pkt_size, + ]; + for offset in check_offsets { + if offset < data.len() && data[offset] != 0x47 { + return false; + } + } + true +} + +fn tier_dir_size(path: &std::path::Path) -> u64 { + let entries = match std::fs::read_dir(path) { + Ok(e) => e, + Err(_) => return 0, + }; + let mut total = 0u64; + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() { + if meta.is_file() { + total += meta.len(); + } + } + } + total +} diff --git a/backend/src/transcode/pipeline.rs b/backend/src/transcode/pipeline.rs index 2695cd9..e1279d9 100644 --- a/backend/src/transcode/pipeline.rs +++ b/backend/src/transcode/pipeline.rs @@ -2,6 +2,7 @@ use crate::config::TranscodeConfig; use crate::error::{Error, Result}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use tokio::io::AsyncRead; use tokio::process::Command; use tokio::sync::{watch, Semaphore}; @@ -25,10 +26,101 @@ pub enum TranscodeStatus { pub struct TranscodeHandle { pub stream_id: String, pub output_dir: PathBuf, - pub playlist_path: PathBuf, + pub master_playlist_path: PathBuf, pub status: watch::Receiver, } +struct QualityTier { + label: &'static str, + height: Option, + video_bitrate: &'static str, + audio_bitrate: &'static str, +} + +const QUALITY_TIERS: &[QualityTier] = &[ + QualityTier { + label: "360p", + height: Some(360), + video_bitrate: "800k", + audio_bitrate: "128k", + }, + QualityTier { + label: "720p", + height: Some(720), + video_bitrate: "2500k", + audio_bitrate: "192k", + }, + QualityTier { + label: "1080p", + height: Some(1080), + video_bitrate: "5000k", + audio_bitrate: "256k", + }, + QualityTier { + label: "source", + height: None, + video_bitrate: "8000k", + audio_bitrate: "320k", + }, +]; + +fn select_tier(label: &str) -> &'static QualityTier { + QUALITY_TIERS + .iter() + .find(|t| t.label == label) + .unwrap_or(&QUALITY_TIERS[QUALITY_TIERS.len() - 1]) +} + +/// Available quality labels for a given source height. +pub fn available_qualities(source_height: u32) -> Vec<&'static str> { + let mut labels: Vec<&str> = QUALITY_TIERS + .iter() + .filter(|t| match t.height { + Some(h) => h < source_height, + None => true, + }) + .map(|t| t.label) + .collect(); + if labels.is_empty() { + labels.push("source"); + } + labels +} + +fn generate_master_playlist(tiers: &[&QualityTier], source_height: u32) -> String { + let mut lines = vec!["#EXTM3U".to_string()]; + + for tier in tiers { + let height = tier.height.unwrap_or(source_height); + let width = (height as f64 * 16.0 / 9.0).round() as u32; + let width = width + (width % 2); + let bandwidth = parse_bitrate(tier.video_bitrate) + parse_bitrate(tier.audio_bitrate); + + lines.push(format!( + "#EXT-X-STREAM-INF:BANDWIDTH={bandwidth},RESOLUTION={width}x{height},NAME=\"{label}\"", + label = tier.label + )); + lines.push(format!("{}/playlist.m3u8", tier.label)); + } + + lines.push(String::new()); + lines.join("\n") +} + +fn apply_audio_args(cmd: &mut Command, media_info: &MediaInfo, audio_bitrate: &str) { + if media_info.needs_audio_transcode { + cmd.arg("-c:a").arg("aac"); + cmd.arg("-b:a").arg(audio_bitrate); + if let Some(ch) = media_info.audio_channels { + if ch > 8 { + cmd.arg("-ac").arg("8"); + } + } + } else { + cmd.arg("-c:a").arg("copy"); + } +} + impl TranscodePipeline { pub async fn new(config: TranscodeConfig, cache_dir: PathBuf) -> Result { let hw = gpu::detect_hardware().await; @@ -42,11 +134,16 @@ impl TranscodePipeline { }) } + pub fn gpu_enabled(&self) -> bool { + self.config.gpu && self.hw_accel != HwAccel::None + } + pub async fn start_transcode( &self, stream_id: &str, input_path: &str, media_info: &MediaInfo, + quality: &str, ) -> Result { let permit = self.semaphore @@ -64,39 +161,110 @@ impl TranscodePipeline { message: format!("Failed to create output directory: {e}"), })?; - let playlist_path = output_dir.join("playlist.m3u8"); - let segment_pattern = output_dir.join("segment_%04d.ts"); + let source_height = media_info.height.unwrap_or(1080); + let tier = select_tier(quality); + let tiers = vec![tier]; + + for tier in &tiers { + tokio::fs::create_dir_all(output_dir.join(tier.label)) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to create tier directory {}: {e}", tier.label), + })?; + } + + let master_content = generate_master_playlist(&tiers, source_height); + let master_path = output_dir.join("master.m3u8"); + tokio::fs::write(&master_path, &master_content) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to write master playlist: {e}"), + })?; - let mut cmd = self.build_ffmpeg_command( - input_path, - media_info, - &playlist_path, - &segment_pattern, - &output_dir, + tracing::info!( + stream_id, + tiers = tiers.iter().map(|t| t.label).collect::>().join(","), + "Starting multi-variant transcode (GPU)" ); - let child = cmd.spawn().map_err(|e| Error::Transcode { - message: format!("Failed to spawn ffmpeg: {e}"), - })?; + let (agg_tx, agg_rx) = watch::channel(TranscodeStatus::Running); + let tier_count = tiers.len(); + let (results_tx, mut results_rx) = + tokio::sync::mpsc::channel::>(tier_count); - let (status_tx, status_rx) = watch::channel(TranscodeStatus::Running); - let sid = stream_id.to_string(); + for tier in &tiers { + let mut cmd = match self.build_variant_command_gpu(input_path, media_info, tier, &output_dir) { + Ok(c) => c, + Err(msg) => { + return Err(Error::Transcode { message: msg }); + } + }; + + let mut child = cmd.spawn().map_err(|e| Error::Transcode { + message: format!("Failed to spawn ffmpeg (GPU): {e}"), + })?; + + // Wait briefly to catch immediate GPU failures (e.g. "No usable encoding profile") + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + match child.try_wait() { + Ok(Some(exit_status)) if !exit_status.success() => { + let stderr_msg = if let Some(mut stderr) = child.stderr.take() { + let mut buf = Vec::new(); + let _ = tokio::io::AsyncReadExt::read_to_end(&mut stderr, &mut buf).await; + String::from_utf8_lossy(&buf).to_string() + } else { + String::new() + }; + drop(permit); + return Err(Error::Transcode { + message: format!("GPU transcode failed immediately: {stderr_msg}"), + }); + } + _ => {} + } + + let sid = stream_id.to_string(); + let label = tier.label.to_string(); + let tx = results_tx.clone(); + + tokio::spawn(async move { + let result = monitor_transcode(child, &sid).await; + if let Err(ref msg) = result { + tracing::error!(stream_id = %sid, tier = %label, %msg, "Variant transcode failed"); + } else { + tracing::info!(stream_id = %sid, tier = %label, "Variant transcode complete"); + } + let _ = tx.send(result).await; + }); + } + + drop(results_tx); tokio::spawn(async move { - let result = monitor_transcode(child, &sid).await; - let status = match result { - Ok(()) => TranscodeStatus::Complete, - Err(msg) => TranscodeStatus::Failed(msg), + let mut all_ok = true; + let mut first_err = String::new(); + while let Some(result) = results_rx.recv().await { + if let Err(msg) = result { + all_ok = false; + if first_err.is_empty() { + first_err = msg; + } + } + } + let status = if all_ok { + TranscodeStatus::Complete + } else { + TranscodeStatus::Failed(first_err) }; - let _ = status_tx.send(status); + let _ = agg_tx.send(status); drop(permit); }); Ok(TranscodeHandle { stream_id: stream_id.to_string(), output_dir, - playlist_path, - status: status_rx, + master_playlist_path: master_path, + status: agg_rx, }) } @@ -105,6 +273,117 @@ impl TranscodePipeline { stream_id: &str, input_path: &str, media_info: &MediaInfo, + quality: &str, + ) -> Result { + let permit = + self.semaphore + .clone() + .acquire_owned() + .await + .map_err(|_| Error::Transcode { + message: "Transcode semaphore closed".to_string(), + })?; + + let output_dir = self.cache_dir.join(stream_id); + tokio::fs::create_dir_all(&output_dir) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to create output directory: {e}"), + })?; + + let source_height = media_info.height.unwrap_or(1080); + let tier = select_tier(quality); + let tiers = vec![tier]; + + for tier in &tiers { + tokio::fs::create_dir_all(output_dir.join(tier.label)) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to create tier directory {}: {e}", tier.label), + })?; + } + + let master_content = generate_master_playlist(&tiers, source_height); + let master_path = output_dir.join("master.m3u8"); + tokio::fs::write(&master_path, &master_content) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to write master playlist: {e}"), + })?; + + tracing::info!( + stream_id, + tiers = tiers.iter().map(|t| t.label).collect::>().join(","), + "Starting multi-variant transcode (CPU)" + ); + + let (agg_tx, agg_rx) = watch::channel(TranscodeStatus::Running); + let tier_count = tiers.len(); + let (results_tx, mut results_rx) = + tokio::sync::mpsc::channel::>(tier_count); + + for tier in &tiers { + let mut cmd = self.build_variant_command_cpu(input_path, media_info, tier, &output_dir); + let sid = stream_id.to_string(); + let label = tier.label.to_string(); + let tx = results_tx.clone(); + + tokio::spawn(async move { + let child = match cmd.spawn() { + Ok(child) => child, + Err(e) => { + let _ = tx + .send(Err(format!( + "Failed to spawn ffmpeg (CPU) for {label}: {e}" + ))) + .await; + return; + } + }; + let result = monitor_transcode(child, &sid).await; + if let Err(ref msg) = result { + tracing::error!(stream_id = %sid, tier = %label, %msg, "Variant transcode failed"); + } else { + tracing::info!(stream_id = %sid, tier = %label, "Variant transcode complete"); + } + let _ = tx.send(result).await; + }); + } + + drop(results_tx); + + tokio::spawn(async move { + let mut all_ok = true; + let mut first_err = String::new(); + while let Some(result) = results_rx.recv().await { + if let Err(msg) = result { + all_ok = false; + if first_err.is_empty() { + first_err = msg; + } + } + } + let status = if all_ok { + TranscodeStatus::Complete + } else { + TranscodeStatus::Failed(first_err) + }; + let _ = agg_tx.send(status); + drop(permit); + }); + + Ok(TranscodeHandle { + stream_id: stream_id.to_string(), + output_dir, + master_playlist_path: master_path, + status: agg_rx, + }) + } + + pub async fn start_passthrough( + &self, + stream_id: &str, + input_path: &str, ) -> Result { let permit = self.semaphore @@ -125,61 +404,48 @@ impl TranscodePipeline { let playlist_path = output_dir.join("playlist.m3u8"); let segment_pattern = output_dir.join("segment_%04d.ts"); - let mut cmd = tokio::process::Command::new("ffmpeg"); + let mut cmd = Command::new("ffmpeg"); cmd.arg("-y") .arg("-hide_banner") .arg("-loglevel") - .arg("warning"); - cmd.arg("-probesize").arg("5000000"); - cmd.arg("-analyzeduration").arg("3000000"); - cmd.arg("-fflags").arg("+genpts+igndts+discardcorrupt"); - cmd.arg("-i").arg(input_path); - cmd.arg("-c:v").arg("libx264"); - cmd.arg("-preset").arg(&self.config.preset); - cmd.arg("-crf").arg(self.config.crf.to_string()); - cmd.arg("-tune").arg("zerolatency"); - cmd.arg("-threads").arg("0"); - - if media_info.has_hdr10 || media_info.has_dolby_vision || media_info.has_hdr10_plus { - cmd.arg("-vf").arg( - "zscale=t=linear:npl=100,format=gbrpf32le,\ - zscale=p=bt709,tonemap=tonemap=hable:desat=0,\ - zscale=t=bt709:m=bt709:r=tv,format=yuv420p", - ); - } - - if media_info.needs_audio_transcode { - cmd.arg("-c:a").arg("aac"); - cmd.arg("-b:a").arg(&self.config.audio_bitrate); - let channels = if media_info.audio_channels.unwrap_or(2) > 2 { - "6" - } else { - "2" - }; - cmd.arg("-ac").arg(channels); - } else { - cmd.arg("-c:a").arg("copy"); - } + .arg("warning") + .arg("-probesize") + .arg("5000000") + .arg("-analyzeduration") + .arg("3000000") + .arg("-fflags") + .arg("+genpts+igndts+discardcorrupt") + .arg("-i") + .arg(input_path) + .arg("-c") + .arg("copy") + .arg("-avoid_negative_ts") + .arg("make_zero") + .arg("-max_muxing_queue_size") + .arg("4096") + .arg("-f") + .arg("hls") + .arg("-hls_time") + .arg("2") + .arg("-hls_init_time") + .arg("1") + .arg("-hls_list_size") + .arg("0") + .arg("-hls_segment_type") + .arg("mpegts") + .arg("-hls_flags") + .arg("independent_segments+append_list") + .arg("-movflags") + .arg("+faststart") + .arg("-hls_segment_filename") + .arg(&segment_pattern) + .arg(&playlist_path); - cmd.arg("-sn"); - cmd.arg("-avoid_negative_ts").arg("make_zero"); - cmd.arg("-max_muxing_queue_size").arg("4096"); - cmd.arg("-f").arg("hls"); - cmd.arg("-hls_time").arg("2"); - cmd.arg("-hls_init_time").arg("1"); - cmd.arg("-hls_list_size").arg("0"); - cmd.arg("-hls_segment_type").arg("mpegts"); - cmd.arg("-hls_flags") - .arg("independent_segments+append_list"); - cmd.arg("-hls_playlist_type").arg("vod"); - cmd.arg("-movflags").arg("+faststart"); - cmd.arg("-hls_segment_filename").arg(&segment_pattern); - cmd.arg(&playlist_path); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); let child = cmd.spawn().map_err(|e| Error::Transcode { - message: format!("Failed to spawn ffmpeg (CPU fallback): {e}"), + message: format!("Failed to spawn ffmpeg for passthrough: {e}"), })?; let (status_tx, status_rx) = watch::channel(TranscodeStatus::Running); @@ -198,15 +464,171 @@ impl TranscodePipeline { Ok(TranscodeHandle { stream_id: stream_id.to_string(), output_dir, - playlist_path, + master_playlist_path: playlist_path, status: status_rx, }) } - pub async fn start_passthrough( + /// Transcode from an async reader (e.g. torrent stream) piped into FFmpeg stdin. + /// Writes the stream to a temp file, then spawns multi-variant transcodes from it. + pub async fn start_transcode_piped( &self, stream_id: &str, - input_path: &str, + reader: R, + media_info: &MediaInfo, + quality: &str, + ) -> Result { + let permit = + self.semaphore + .clone() + .acquire_owned() + .await + .map_err(|_| Error::Transcode { + message: "Transcode semaphore closed".to_string(), + })?; + + let output_dir = self.cache_dir.join(stream_id); + tokio::fs::create_dir_all(&output_dir) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to create output directory: {e}"), + })?; + + let input_tmp = output_dir.join("input.tmp"); + + let source_height = media_info.height.unwrap_or(1080); + let tier = select_tier(quality); + let tiers = vec![tier]; + + for tier in &tiers { + tokio::fs::create_dir_all(output_dir.join(tier.label)) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to create tier directory {}: {e}", tier.label), + })?; + } + + let master_content = generate_master_playlist(&tiers, source_height); + let master_path = output_dir.join("master.m3u8"); + tokio::fs::write(&master_path, &master_content) + .await + .map_err(|e| Error::Transcode { + message: format!("Failed to write master playlist: {e}"), + })?; + + tracing::info!( + stream_id, + tiers = tiers.iter().map(|t| t.label).collect::>().join(","), + "Starting piped multi-variant transcode" + ); + + // Write the stream to a temp file; FFmpeg reads from it and blocks on EOF + let tmp_path = input_tmp.clone(); + let sid_pipe = stream_id.to_string(); + tokio::spawn(async move { + let mut reader = reader; + match tokio::fs::File::create(&tmp_path).await { + Ok(mut file) => match tokio::io::copy(&mut reader, &mut file).await { + Ok(bytes) => { + tracing::info!(stream_id = %sid_pipe, bytes, "Finished writing piped input to temp file"); + } + Err(e) => { + tracing::warn!(stream_id = %sid_pipe, "Error writing piped input: {e}"); + } + }, + Err(e) => { + tracing::error!(stream_id = %sid_pipe, "Failed to create temp file: {e}"); + } + } + }); + + // Wait for initial data before starting transcodes + let tmp_check = input_tmp.clone(); + let sid_wait = stream_id.to_string(); + let mut waited = 0u32; + loop { + match tokio::fs::metadata(&tmp_check).await { + Ok(meta) if meta.len() >= 1_048_576 => break, + _ => { + if waited > 300 { + return Err(Error::Transcode { + message: format!( + "Timed out waiting for piped input data for {sid_wait}" + ), + }); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + waited += 1; + } + } + } + + let input_path_str = input_tmp.to_string_lossy().to_string(); + + let (agg_tx, agg_rx) = watch::channel(TranscodeStatus::Running); + let tier_count = tiers.len(); + let (results_tx, mut results_rx) = + tokio::sync::mpsc::channel::>(tier_count); + + for tier in &tiers { + let mut cmd = + self.build_variant_command_cpu(&input_path_str, media_info, tier, &output_dir); + let sid = stream_id.to_string(); + let label = tier.label.to_string(); + let tx = results_tx.clone(); + + tokio::spawn(async move { + let child = match cmd.spawn() { + Ok(child) => child, + Err(e) => { + let _ = tx + .send(Err(format!( + "Failed to spawn ffmpeg (piped) for {label}: {e}" + ))) + .await; + return; + } + }; + let result = monitor_transcode(child, &sid).await; + let _ = tx.send(result).await; + }); + } + + drop(results_tx); + + tokio::spawn(async move { + let mut all_ok = true; + let mut first_err = String::new(); + while let Some(result) = results_rx.recv().await { + if let Err(msg) = result { + all_ok = false; + if first_err.is_empty() { + first_err = msg; + } + } + } + let status = if all_ok { + TranscodeStatus::Complete + } else { + TranscodeStatus::Failed(first_err) + }; + let _ = agg_tx.send(status); + drop(permit); + }); + + Ok(TranscodeHandle { + stream_id: stream_id.to_string(), + output_dir, + master_playlist_path: master_path, + status: agg_rx, + }) + } + + /// Passthrough (no transcode) from an async reader piped into FFmpeg stdin. + pub async fn start_passthrough_piped( + &self, + stream_id: &str, + reader: R, ) -> Result { let permit = self.semaphore @@ -227,8 +649,6 @@ impl TranscodePipeline { let playlist_path = output_dir.join("playlist.m3u8"); let segment_pattern = output_dir.join("segment_%04d.ts"); - let _init_filename = "init.mp4"; - let mut cmd = Command::new("ffmpeg"); cmd.arg("-y") .arg("-hide_banner") @@ -241,7 +661,7 @@ impl TranscodePipeline { .arg("-fflags") .arg("+genpts+igndts+discardcorrupt") .arg("-i") - .arg(input_path) + .arg("pipe:0") .arg("-c") .arg("copy") .arg("-avoid_negative_ts") @@ -260,20 +680,37 @@ impl TranscodePipeline { .arg("mpegts") .arg("-hls_flags") .arg("independent_segments+append_list") - .arg("-hls_playlist_type") - .arg("event") .arg("-movflags") .arg("+faststart") .arg("-hls_segment_filename") .arg(&segment_pattern) .arg(&playlist_path); + cmd.stdin(std::process::Stdio::piped()); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); - let child = cmd.spawn().map_err(|e| Error::Transcode { - message: format!("Failed to spawn ffmpeg for passthrough: {e}"), + let mut child = cmd.spawn().map_err(|e| Error::Transcode { + message: format!("Failed to spawn ffmpeg (passthrough piped): {e}"), + })?; + + let stdin = child.stdin.take().ok_or_else(|| Error::Transcode { + message: "Failed to get ffmpeg stdin handle".to_string(), })?; + let sid_pipe = stream_id.to_string(); + tokio::spawn(async move { + let mut reader = reader; + let mut stdin = stdin; + match tokio::io::copy(&mut reader, &mut stdin).await { + Ok(bytes) => { + tracing::info!(stream_id = %sid_pipe, bytes, "Finished piping to ffmpeg stdin (passthrough)"); + } + Err(e) => { + tracing::warn!(stream_id = %sid_pipe, "Error piping to ffmpeg stdin (passthrough): {e}"); + } + } + drop(stdin); + }); let (status_tx, status_rx) = watch::channel(TranscodeStatus::Running); let sid = stream_id.to_string(); @@ -291,108 +728,195 @@ impl TranscodePipeline { Ok(TranscodeHandle { stream_id: stream_id.to_string(), output_dir, - playlist_path, + master_playlist_path: playlist_path, status: status_rx, }) } - fn build_ffmpeg_command( + fn build_variant_command_gpu( &self, input_path: &str, media_info: &MediaInfo, - playlist_path: &Path, - segment_pattern: &Path, - _output_dir: &Path, - ) -> Command { - let mut cmd = Command::new("ffmpeg"); - cmd.arg("-y"); - cmd.arg("-hide_banner"); - cmd.arg("-loglevel").arg("warning"); + tier: &QualityTier, + output_dir: &Path, + ) -> std::result::Result { + let is_hevc = media_info + .video_codec + .as_deref() + .map(|c| { + let l = c.to_lowercase(); + l.contains("hevc") || l.contains("h265") || l.contains("hev1") || l.contains("hvc1") + }) + .unwrap_or(false); + // Source tier + HEVC: copy video (no re-encode) + if tier.height.is_none() && is_hevc { + return Ok(self.build_variant_command_cpu(input_path, media_info, tier, output_dir)); + } + + let mut cmd = Command::new("ffmpeg"); + cmd.arg("-y").arg("-hide_banner").arg("-loglevel").arg("warning"); cmd.arg("-probesize").arg("5000000"); cmd.arg("-analyzeduration").arg("3000000"); cmd.arg("-fflags").arg("+genpts+igndts+discardcorrupt"); - for flag in gpu::hw_decode_flags(&self.hw_accel) { - cmd.arg(flag); - } - - cmd.arg("-i").arg(input_path); - - let video_encoder = gpu::encoder_for_hw(&self.hw_accel); - cmd.arg("-c:v").arg(video_encoder); - - if media_info.has_hdr10 || media_info.has_dolby_vision || media_info.has_hdr10_plus { - match &self.hw_accel { - HwAccel::Nvenc => { - cmd.arg("-vf").arg( - "scale_cuda=format=nv12,hwdownload,format=nv12,\ - tonemap=hable:desat=0,format=yuv420p,hwupload_cuda", - ); - } - HwAccel::Vaapi => { - cmd.arg("-vf") - .arg("tonemap_vaapi=t=bt709:m=bt709:p=bt709,scale_vaapi=format=nv12"); - } - _ => { - cmd.arg("-vf").arg( - "zscale=t=linear:npl=100,format=gbrpf32le,\ - zscale=p=bt709,tonemap=tonemap=hable:desat=0,\ - zscale=t=bt709:m=bt709:r=tv,format=yuv420p", - ); + // VAAPI: hybrid mode (CPU decode + GPU encode) - works for all input codecs + // Full HW decode (-hwaccel vaapi) fails on HEVC 10-bit on many GPUs + match &self.hw_accel { + HwAccel::Vaapi => { + cmd.arg("-init_hw_device").arg("vaapi=va:/dev/dri/renderD128"); + cmd.arg("-filter_hw_device").arg("va"); + } + other => { + for flag in gpu::hw_decode_flags(other) { + cmd.arg(flag); } } } + cmd.arg("-i").arg(input_path); + cmd.arg("-c:v").arg(gpu::encoder_for_hw(&self.hw_accel)); + + // Scale + format conversion per accelerator match &self.hw_accel { + HwAccel::Vaapi => { + // CPU scale → nv12 → hwupload to VAAPI + let vf = if let Some(h) = tier.height { + format!("scale=-2:{h},format=nv12,hwupload") + } else { + "format=nv12,hwupload".to_string() + }; + cmd.arg("-vf").arg(vf); + cmd.arg("-global_quality").arg(self.config.crf.to_string()); + } HwAccel::Nvenc => { + if let Some(h) = tier.height { + cmd.arg("-vf").arg(format!("scale_cuda=w=-2:h={h}:format=nv12")); + } cmd.arg("-preset").arg("p4"); cmd.arg("-rc").arg("vbr"); cmd.arg("-cq").arg(self.config.crf.to_string()); + cmd.arg("-maxrate").arg(tier.video_bitrate); + let bs = parse_bitrate(tier.video_bitrate).saturating_mul(2) / 1000; + cmd.arg("-bufsize").arg(format!("{bs}k")); } - HwAccel::None => { + _ => { + if let Some(h) = tier.height { + cmd.arg("-vf").arg(format!("scale=-2:{h}")); + } cmd.arg("-preset").arg(&self.config.preset); cmd.arg("-crf").arg(self.config.crf.to_string()); - cmd.arg("-tune").arg("zerolatency"); + cmd.arg("-tune").arg("film"); match self.config.threads { - Some(threads) => { - cmd.arg("-threads").arg(threads.to_string()); - } - None => { - cmd.arg("-threads").arg("0"); - } + Some(t) => { cmd.arg("-threads").arg(t.to_string()); } + None => { cmd.arg("-threads").arg("0"); } } - } - _ => { - cmd.arg("-preset").arg(&self.config.preset); + cmd.arg("-maxrate").arg(tier.video_bitrate); + let bs = parse_bitrate(tier.video_bitrate).saturating_mul(2) / 1000; + cmd.arg("-bufsize").arg(format!("{bs}k")); } } - if let Some(ref max_br) = self.config.max_bitrate { - cmd.arg("-maxrate").arg(max_br); - let bufsize_kbps = parse_bitrate(max_br).saturating_mul(2) / 1000; - cmd.arg("-bufsize").arg(format!("{bufsize_kbps}k")); - } + apply_audio_args(&mut cmd, media_info, tier.audio_bitrate); - if media_info.needs_audio_transcode { - cmd.arg("-c:a").arg("aac"); - cmd.arg("-b:a").arg(&self.config.audio_bitrate); - let target_channels = if media_info.audio_channels.unwrap_or(2) > 2 { - "6" - } else { - "2" - }; - cmd.arg("-ac").arg(target_channels); + cmd.arg("-sn"); + cmd.arg("-avoid_negative_ts").arg("make_zero"); + cmd.arg("-max_muxing_queue_size").arg("4096"); + + let tier_dir = output_dir.join(tier.label); + let playlist_path = tier_dir.join("playlist.m3u8"); + let segment_pattern = tier_dir.join("segment_%04d.ts"); + + cmd.arg("-f").arg("hls"); + cmd.arg("-hls_time").arg("2"); + cmd.arg("-hls_init_time").arg("1"); + cmd.arg("-hls_list_size").arg("0"); + cmd.arg("-hls_segment_type").arg("mpegts"); + cmd.arg("-hls_flags") + .arg("independent_segments+append_list"); + cmd.arg("-movflags").arg("+faststart"); + cmd.arg("-hls_segment_filename").arg(&segment_pattern); + cmd.arg(&playlist_path); + + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + + Ok(cmd) + } + + fn build_variant_command_cpu( + &self, + input_path: &str, + media_info: &MediaInfo, + tier: &QualityTier, + output_dir: &Path, + ) -> Command { + let mut cmd = tokio::process::Command::new("ffmpeg"); + cmd.arg("-y") + .arg("-hide_banner") + .arg("-loglevel") + .arg("warning"); + cmd.arg("-probesize").arg("5000000"); + cmd.arg("-analyzeduration").arg("3000000"); + cmd.arg("-fflags").arg("+genpts+igndts+discardcorrupt"); + cmd.arg("-i").arg(input_path); + + // For "source" tier with HEVC input: copy video (no re-encode, preserves 4K/HDR) + // For scaled tiers or non-HEVC: transcode to H.264 + let is_hevc = media_info + .video_codec + .as_deref() + .map(|c| { + let l = c.to_lowercase(); + l.contains("hevc") || l.contains("h265") || l.contains("hev1") || l.contains("hvc1") + }) + .unwrap_or(false); + let copy_video = tier.height.is_none() && is_hevc; + + if copy_video { + cmd.arg("-c:v").arg("copy"); } else { - cmd.arg("-c:a").arg("copy"); + cmd.arg("-c:v").arg("libx264"); + cmd.arg("-preset").arg(&self.config.preset); + cmd.arg("-crf").arg(self.config.crf.to_string()); + cmd.arg("-tune").arg("film"); + match self.config.threads { + Some(threads) => { cmd.arg("-threads").arg(threads.to_string()); } + None => { cmd.arg("-threads").arg("0"); } + } + + let has_hdr = + media_info.has_hdr10 || media_info.has_dolby_vision || media_info.has_hdr10_plus; + + if has_hdr { + let scale = if let Some(h) = tier.height { + format!(",scale=-2:{h}") + } else { + String::new() + }; + cmd.arg("-vf").arg(format!( + "zscale=t=linear:npl=100,format=gbrpf32le,\ + zscale=p=bt709,tonemap=tonemap=hable:desat=0,\ + zscale=t=bt709:m=bt709:r=tv,format=yuv420p{scale}" + )); + } else if let Some(h) = tier.height { + cmd.arg("-vf").arg(format!("scale=-2:{h}")); + } + + cmd.arg("-maxrate").arg(tier.video_bitrate); + let bufsize_kbps = parse_bitrate(tier.video_bitrate).saturating_mul(2) / 1000; + cmd.arg("-bufsize").arg(format!("{bufsize_kbps}k")); } - cmd.arg("-sn"); + apply_audio_args(&mut cmd, media_info, tier.audio_bitrate); + cmd.arg("-sn"); cmd.arg("-avoid_negative_ts").arg("make_zero"); cmd.arg("-max_muxing_queue_size").arg("4096"); - let _init_filename = "init.mp4"; + let tier_dir = output_dir.join(tier.label); + let playlist_path = tier_dir.join("playlist.m3u8"); + let segment_pattern = tier_dir.join("segment_%04d.ts"); cmd.arg("-f").arg("hls"); cmd.arg("-hls_time").arg("2"); @@ -401,11 +925,9 @@ impl TranscodePipeline { cmd.arg("-hls_segment_type").arg("mpegts"); cmd.arg("-hls_flags") .arg("independent_segments+append_list"); - cmd.arg("-hls_playlist_type").arg("vod"); cmd.arg("-movflags").arg("+faststart"); - cmd.arg("-hls_segment_filename").arg(segment_pattern); - cmd.arg(playlist_path); - + cmd.arg("-hls_segment_filename").arg(&segment_pattern); + cmd.arg(&playlist_path); cmd.stdout(std::process::Stdio::piped()); cmd.stderr(std::process::Stdio::piped()); @@ -479,4 +1001,30 @@ mod tests { fn parse_bitrate_invalid() { assert_eq!(parse_bitrate("invalid"), 0); } + + #[test] + fn select_tier_by_label() { + assert_eq!(select_tier("source").label, "source"); + assert_eq!(select_tier("720p").label, "720p"); + assert_eq!(select_tier("360p").label, "360p"); + assert_eq!(select_tier("1080p").label, "1080p"); + assert_eq!(select_tier("invalid").label, "source"); + } + + #[test] + fn available_qualities_for_heights() { + assert_eq!(available_qualities(2160), vec!["360p", "720p", "1080p", "source"]); + assert_eq!(available_qualities(1080), vec!["360p", "720p", "source"]); + assert_eq!(available_qualities(480), vec!["360p", "source"]); + assert_eq!(available_qualities(360), vec!["source"]); + } + + #[test] + fn master_playlist_format() { + let tier = select_tier("720p"); + let playlist = generate_master_playlist(&[tier], 720); + assert!(playlist.contains("#EXTM3U")); + assert!(playlist.contains("#EXT-X-STREAM-INF:")); + assert!(playlist.contains("720p/playlist.m3u8")); + } } diff --git a/backend/tests/api_tests.rs b/backend/tests/api_tests.rs index 810fe7f..36db3e7 100644 --- a/backend/tests/api_tests.rs +++ b/backend/tests/api_tests.rs @@ -39,6 +39,9 @@ async fn start_test_server() -> TestServer { max_bitrate: None, audio_bitrate: "192k".to_string(), threads: None, + gpu: false, + hls_downscale: true, + hls_max_height: 1080, }, auth: streamx::config::AuthConfig { jwt_secret: "test-secret-key-for-integration-tests".to_string(), @@ -47,8 +50,11 @@ async fn start_test_server() -> TestServer { ui: streamx::config::UiConfig { default_theme: "dark".to_string(), }, + providers: vec![], + vpn: None, data_dir: data_dir.clone(), log_level: "warn".to_string(), + log_dir: None, open_browser: false, admin_user: None, admin_password: None, @@ -60,21 +66,25 @@ async fn start_test_server() -> TestServer { database.set_downloading_to_paused().await.unwrap(); let torrent_engine = - streamx::torrent::TorrentEngine::create(&config.torrent, &data_dir, database.clone()) + streamx::torrent::TorrentEngine::create(&config.torrent, &data_dir, database.clone(), None) .await .unwrap(); - let search_provider = streamx::torrent::SearchProvider::new(); + let search_provider = streamx::torrent::SearchProvider::new(vec![], None); let cache_dir = data_dir.join("cache"); let hls_pipeline = streamx::transcode::HlsManager::new(&config.transcode, cache_dir) .await .unwrap(); + let (log_tx, _) = tokio::sync::broadcast::channel::(1000); + let (_, log_history) = streamx::logging::BroadcastLayer::new(log_tx.clone()); let app = streamx::server::build_router( database, config, torrent_engine, search_provider, hls_pipeline, + log_tx, + log_history, ); let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); @@ -523,6 +533,9 @@ async fn admin_user_creation_via_config() { max_bitrate: None, audio_bitrate: "192k".to_string(), threads: None, + gpu: false, + hls_downscale: true, + hls_max_height: 1080, }, auth: streamx::config::AuthConfig { jwt_secret: "admin-test-secret".to_string(), @@ -531,8 +544,11 @@ async fn admin_user_creation_via_config() { ui: streamx::config::UiConfig { default_theme: "dark".to_string(), }, + providers: vec![], + vpn: None, data_dir: data_dir.clone(), log_level: "warn".to_string(), + log_dir: None, open_browser: false, admin_user: Some("myadmin".to_string()), admin_password: Some("adminpass123".to_string()), @@ -552,21 +568,25 @@ async fn admin_user_creation_via_config() { database.set_downloading_to_paused().await.unwrap(); let torrent_engine = - streamx::torrent::TorrentEngine::create(&config.torrent, &data_dir, database.clone()) + streamx::torrent::TorrentEngine::create(&config.torrent, &data_dir, database.clone(), None) .await .unwrap(); - let search_provider = streamx::torrent::SearchProvider::new(); + let search_provider = streamx::torrent::SearchProvider::new(vec![], None); let cache_dir = data_dir.join("cache"); let hls_pipeline = streamx::transcode::HlsManager::new(&config.transcode, cache_dir) .await .unwrap(); + let (log_tx, _) = tokio::sync::broadcast::channel::(1000); + let (_, log_history) = streamx::logging::BroadcastLayer::new(log_tx.clone()); let app = streamx::server::build_router( database, config, torrent_engine, search_provider, hls_pipeline, + log_tx, + log_history, ); let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); diff --git a/backend/tests/stream_lifecycle_tests.rs b/backend/tests/stream_lifecycle_tests.rs index 27ad3f2..1427dc8 100644 --- a/backend/tests/stream_lifecycle_tests.rs +++ b/backend/tests/stream_lifecycle_tests.rs @@ -43,6 +43,9 @@ async fn start_test_server() -> TestServer { max_bitrate: None, audio_bitrate: "192k".to_string(), threads: None, + gpu: false, + hls_downscale: true, + hls_max_height: 1080, }, auth: streamx::config::AuthConfig { jwt_secret: "test-secret-key-for-integration-tests".to_string(), @@ -51,8 +54,11 @@ async fn start_test_server() -> TestServer { ui: streamx::config::UiConfig { default_theme: "dark".to_string(), }, + providers: vec![], + vpn: None, data_dir: data_dir.clone(), log_level: "warn".to_string(), + log_dir: None, open_browser: false, admin_user: None, admin_password: None, @@ -64,21 +70,25 @@ async fn start_test_server() -> TestServer { database.set_downloading_to_paused().await.unwrap(); let torrent_engine = - streamx::torrent::TorrentEngine::create(&config.torrent, &data_dir, database.clone()) + streamx::torrent::TorrentEngine::create(&config.torrent, &data_dir, database.clone(), None) .await .unwrap(); - let search_provider = streamx::torrent::SearchProvider::new(); + let search_provider = streamx::torrent::SearchProvider::new(vec![], None); let cache_dir = data_dir.join("cache"); let hls_pipeline = streamx::transcode::HlsManager::new(&config.transcode, cache_dir) .await .unwrap(); + let (log_tx, _) = tokio::sync::broadcast::channel::(1000); + let (_, log_history) = streamx::logging::BroadcastLayer::new(log_tx.clone()); let app = streamx::server::build_router( database, config, torrent_engine, search_provider, hls_pipeline, + log_tx, + log_history, ); let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); diff --git a/backend/tests/transcode_tests.rs b/backend/tests/transcode_tests.rs new file mode 100644 index 0000000..31a37a8 --- /dev/null +++ b/backend/tests/transcode_tests.rs @@ -0,0 +1,298 @@ +/// Integration tests for FFmpeg HLS transcoding pipeline. +/// Uses an HEVC 4K 10-bit test file to validate all quality/encoder combinations. +/// Place a test file at ~/.streamx/downloads/complete/test-hevc-4k-10bit.mkv +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn test_file() -> Option { + let candidates = [ + dirs::home_dir() + .unwrap_or_default() + .join(".streamx/downloads/complete/test-hevc-4k-10bit.mkv"), + ]; + candidates.into_iter().find(|p| p.exists()) +} + +fn has_vaapi() -> bool { + Path::new("/dev/dri/renderD128").exists() +} + +fn run_ffmpeg(args: &[&str], output: &Path) -> (bool, String) { + let result = Command::new("ffmpeg") + .args(args) + .arg(output.to_str().unwrap_or("")) + .output(); + + match result { + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + (out.status.success(), stderr) + } + Err(e) => (false, format!("Failed to run ffmpeg: {e}")), + } +} + +fn count_segments(playlist: &Path) -> usize { + std::fs::read_to_string(playlist) + .unwrap_or_default() + .matches("EXTINF:") + .count() +} + +fn has_endlist(playlist: &Path) -> bool { + std::fs::read_to_string(playlist) + .unwrap_or_default() + .contains("EXT-X-ENDLIST") +} + +fn is_valid_ts(segment: &Path) -> bool { + if let Ok(data) = std::fs::read(segment) { + !data.is_empty() && data[0] == 0x47 && (data.len() < 188 || data[188] == 0x47) + } else { + false + } +} + +fn setup_output_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join("streamx_transcode_tests").join(name); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +// ============================================================ +// Source quality: HEVC video copy (no re-encode) +// ============================================================ + +#[test] +fn source_hevc_copy() { + let file = match test_file() { + Some(f) => f, + None => { eprintln!("SKIP: test file not found"); return; } + }; + let dir = setup_output_dir("source_hevc_copy"); + let playlist = dir.join("playlist.m3u8"); + let seg_pattern = dir.join("segment_%04d.ts"); + + let (ok, stderr) = run_ffmpeg(&[ + "-y", "-hide_banner", "-loglevel", "error", + "-i", file.to_str().unwrap(), "-t", "10", + "-c:v", "copy", + "-c:a", "aac", "-b:a", "320k", + "-sn", + "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", + "-hls_segment_type", "mpegts", + "-hls_flags", "independent_segments", + "-hls_segment_filename", seg_pattern.to_str().unwrap(), + ], &playlist); + + assert!(ok, "HEVC copy failed: {stderr}"); + assert!(count_segments(&playlist) >= 2, "Too few segments"); + assert!(has_endlist(&playlist), "Missing EXT-X-ENDLIST"); + assert!(is_valid_ts(&dir.join("segment_0000.ts")), "Invalid TS segment"); +} + +// ============================================================ +// CPU libx264 at various resolutions +// ============================================================ + +#[test] +fn cpu_1080p() { + let file = match test_file() { + Some(f) => f, + None => { eprintln!("SKIP: test file not found"); return; } + }; + let dir = setup_output_dir("cpu_1080p"); + let playlist = dir.join("playlist.m3u8"); + let seg_pattern = dir.join("segment_%04d.ts"); + + let (ok, stderr) = run_ffmpeg(&[ + "-y", "-hide_banner", "-loglevel", "error", + "-i", file.to_str().unwrap(), "-t", "5", + "-c:v", "libx264", "-preset", "fast", "-crf", "20", "-tune", "film", "-threads", "2", + "-vf", "scale=-2:1080", + "-maxrate", "5000k", "-bufsize", "10000k", + "-c:a", "aac", "-b:a", "256k", + "-sn", + "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", + "-hls_segment_type", "mpegts", + "-hls_flags", "independent_segments", + "-hls_segment_filename", seg_pattern.to_str().unwrap(), + ], &playlist); + + assert!(ok, "CPU 1080p failed: {stderr}"); + assert!(count_segments(&playlist) >= 1, "Too few segments"); + assert!(is_valid_ts(&dir.join("segment_0000.ts")), "Invalid TS segment"); +} + +#[test] +fn cpu_720p() { + let file = match test_file() { + Some(f) => f, + None => { eprintln!("SKIP: test file not found"); return; } + }; + let dir = setup_output_dir("cpu_720p"); + let playlist = dir.join("playlist.m3u8"); + let seg_pattern = dir.join("segment_%04d.ts"); + + let (ok, stderr) = run_ffmpeg(&[ + "-y", "-hide_banner", "-loglevel", "error", + "-i", file.to_str().unwrap(), "-t", "5", + "-c:v", "libx264", "-preset", "fast", "-crf", "20", "-tune", "film", "-threads", "2", + "-vf", "scale=-2:720", + "-maxrate", "2500k", "-bufsize", "5000k", + "-c:a", "aac", "-b:a", "192k", + "-sn", + "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", + "-hls_segment_type", "mpegts", + "-hls_flags", "independent_segments", + "-hls_segment_filename", seg_pattern.to_str().unwrap(), + ], &playlist); + + assert!(ok, "CPU 720p failed: {stderr}"); + assert!(count_segments(&playlist) >= 1, "Too few segments"); + assert!(is_valid_ts(&dir.join("segment_0000.ts")), "Invalid TS segment"); +} + +#[test] +fn cpu_360p() { + let file = match test_file() { + Some(f) => f, + None => { eprintln!("SKIP: test file not found"); return; } + }; + let dir = setup_output_dir("cpu_360p"); + let playlist = dir.join("playlist.m3u8"); + let seg_pattern = dir.join("segment_%04d.ts"); + + let (ok, stderr) = run_ffmpeg(&[ + "-y", "-hide_banner", "-loglevel", "error", + "-i", file.to_str().unwrap(), "-t", "5", + "-c:v", "libx264", "-preset", "fast", "-crf", "22", "-tune", "film", "-threads", "2", + "-vf", "scale=-2:360", + "-maxrate", "800k", "-bufsize", "1600k", + "-c:a", "aac", "-b:a", "128k", + "-sn", + "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", + "-hls_segment_type", "mpegts", + "-hls_flags", "independent_segments", + "-hls_segment_filename", seg_pattern.to_str().unwrap(), + ], &playlist); + + assert!(ok, "CPU 360p failed: {stderr}"); + assert!(count_segments(&playlist) >= 1, "Too few segments"); + assert!(is_valid_ts(&dir.join("segment_0000.ts")), "Invalid TS segment"); +} + +// ============================================================ +// VAAPI hybrid: CPU decode + GPU encode +// ============================================================ + +#[test] +fn vaapi_hybrid_1080p() { + if !has_vaapi() { + eprintln!("SKIP: no VAAPI device"); + return; + } + let file = match test_file() { + Some(f) => f, + None => { eprintln!("SKIP: test file not found"); return; } + }; + let dir = setup_output_dir("vaapi_hybrid_1080p"); + let playlist = dir.join("playlist.m3u8"); + let seg_pattern = dir.join("segment_%04d.ts"); + + let (ok, stderr) = run_ffmpeg(&[ + "-y", "-hide_banner", "-loglevel", "error", + "-init_hw_device", "vaapi=va:/dev/dri/renderD128", + "-filter_hw_device", "va", + "-i", file.to_str().unwrap(), "-t", "5", + "-vf", "scale=-2:1080,format=nv12,hwupload", + "-c:v", "h264_vaapi", "-global_quality", "20", + "-c:a", "aac", "-b:a", "256k", + "-sn", + "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", + "-hls_segment_type", "mpegts", + "-hls_flags", "independent_segments", + "-hls_segment_filename", seg_pattern.to_str().unwrap(), + ], &playlist); + + assert!(ok, "VAAPI hybrid 1080p failed: {stderr}"); + assert!(count_segments(&playlist) >= 1, "Too few segments"); +} + +#[test] +fn vaapi_hybrid_720p() { + if !has_vaapi() { + eprintln!("SKIP: no VAAPI device"); + return; + } + let file = match test_file() { + Some(f) => f, + None => { eprintln!("SKIP: test file not found"); return; } + }; + let dir = setup_output_dir("vaapi_hybrid_720p"); + let playlist = dir.join("playlist.m3u8"); + let seg_pattern = dir.join("segment_%04d.ts"); + + let (ok, stderr) = run_ffmpeg(&[ + "-y", "-hide_banner", "-loglevel", "error", + "-init_hw_device", "vaapi=va:/dev/dri/renderD128", + "-filter_hw_device", "va", + "-i", file.to_str().unwrap(), "-t", "5", + "-vf", "scale=-2:720,format=nv12,hwupload", + "-c:v", "h264_vaapi", "-global_quality", "20", + "-c:a", "aac", "-b:a", "192k", + "-sn", + "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", + "-hls_segment_type", "mpegts", + "-hls_flags", "independent_segments", + "-hls_segment_filename", seg_pattern.to_str().unwrap(), + ], &playlist); + + assert!(ok, "VAAPI hybrid 720p failed: {stderr}"); + assert!(count_segments(&playlist) >= 1, "Too few segments"); +} + +// ============================================================ +// VAAPI full hardware (expected to FAIL on HEVC 10-bit input) +// ============================================================ + +#[test] +fn vaapi_full_hw_fails_on_hevc_10bit() { + if !has_vaapi() { + eprintln!("SKIP: no VAAPI device"); + return; + } + let file = match test_file() { + Some(f) => f, + None => { eprintln!("SKIP: test file not found"); return; } + }; + let dir = setup_output_dir("vaapi_full_hw_fail"); + let playlist = dir.join("playlist.m3u8"); + let seg_pattern = dir.join("segment_%04d.ts"); + + let (ok, _stderr) = run_ffmpeg(&[ + "-y", "-hide_banner", "-loglevel", "error", + "-hwaccel", "vaapi", + "-hwaccel_device", "/dev/dri/renderD128", + "-hwaccel_output_format", "vaapi", + "-i", file.to_str().unwrap(), "-t", "5", + "-vf", "scale_vaapi=w=-2:h=1080:format=nv12", + "-c:v", "h264_vaapi", "-global_quality", "20", + "-c:a", "aac", "-b:a", "256k", + "-sn", + "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", + "-hls_segment_type", "mpegts", + "-hls_flags", "independent_segments", + "-hls_segment_filename", seg_pattern.to_str().unwrap(), + ], &playlist); + + // This SHOULD fail on hardware that can't decode HEVC 10-bit + assert!(!ok, "Expected VAAPI full HW to fail on HEVC 10-bit 4K, but it succeeded"); +} + +mod dirs { + pub fn home_dir() -> Option { + std::env::var("HOME").ok().map(std::path::PathBuf::from) + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 3a4d5b4..f122d29 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,13 +1,24 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; import { Theme } from "@radix-ui/themes"; import { AuthProvider, useAuth } from "./hooks/useAuth"; +import { FavouritesProvider } from "./hooks/useFavourites"; +import { AudioPlayerProvider } from "./hooks/useAudioPlayer"; import { useTheme } from "./hooks/useTheme"; import { Layout } from "./components/Layout"; import { Login } from "./pages/Login"; import { Search } from "./pages/Search"; import { Player } from "./pages/Player"; import { History } from "./pages/History"; +import { Browse } from "./pages/Browse"; +import { Movie } from "./pages/Movie"; +import { Favourites } from "./pages/Favourites"; +import { TvSearch } from "./pages/TvSearch"; +import { TvShow } from "./pages/TvShow"; +import { MusicVideoSearch } from "./pages/MusicVideoSearch"; +import { MusicSearch } from "./pages/MusicSearch"; import { Settings } from "./pages/Settings"; +import { SurroundSound } from "./pages/SurroundSound"; +import { Admin } from "./pages/Admin"; import type { ReactNode } from "react"; function RequireAuth({ children }: { children: ReactNode }) { @@ -22,11 +33,9 @@ function RequireAuth({ children }: { children: ReactNode }) { function AppRoutes({ theme, setTheme, - toggleTheme, }: { theme: "dark" | "light"; setTheme: (t: "dark" | "light") => void; - toggleTheme: () => void; }) { return ( @@ -34,14 +43,27 @@ function AppRoutes({ - + + + + + } > } /> + } /> + } /> } /> + } /> + } /> + } /> + } /> } /> + } /> + } /> } /> + } /> } /> @@ -49,13 +71,13 @@ function AppRoutes({ } export function App() { - const { theme, setTheme, toggleTheme } = useTheme(); + const { theme, setTheme } = useTheme(); return ( - + diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index ffe8867..c59d613 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -2,9 +2,13 @@ import { getToken } from "../lib/auth"; import { debugLog } from "../lib/debug-log"; import type { ApiError, + FavouriteItem, + FavouritesResponse, LoginRequest, LoginResponse, + MusicVideoSearchResponse, RegisterRequest, + ResolveMagnetResponse, SearchRequest, SearchResponse, SearchHistoryResponse, @@ -12,6 +16,8 @@ import type { StreamRequest, StreamResponse, StreamStatus, + TvSeason, + TvSearchResponse, User, WatchHistoryResponse, } from "./types"; @@ -101,6 +107,16 @@ class ApiClient { }); } + async browse(params: { sort_by?: string; genre?: string; minimum_rating?: number; limit?: number; page?: number }): Promise { + const q = new URLSearchParams(); + if (params.sort_by) q.set("sort_by", params.sort_by); + if (params.genre) q.set("genre", params.genre); + if (params.minimum_rating) q.set("minimum_rating", String(params.minimum_rating)); + if (params.limit) q.set("limit", String(params.limit)); + if (params.page) q.set("page", String(params.page)); + return this.request(`/api/search/browse?${q.toString()}`); + } + async searchHistory(): Promise { return this.request("/api/search/history"); } @@ -140,7 +156,7 @@ class ApiClient { ): Promise { return this.request(`/api/history/${id}`, { method: "PUT", - body: JSON.stringify({ watched_seconds: watchedSeconds }), + body: JSON.stringify({ watched_seconds: Math.floor(watchedSeconds) }), }); } @@ -161,15 +177,106 @@ class ApiClient { }); } - getPlaylistUrl(streamId: string): string { + async searchTv(data: SearchRequest): Promise { + return this.request("/api/tv/search", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async browseTv(params: { page?: number; limit?: number }): Promise { + const q = new URLSearchParams(); + if (params.page) q.set("page", String(params.page)); + if (params.limit) q.set("limit", String(params.limit)); + return this.request(`/api/tv/browse?${q.toString()}`); + } + + async getTvShowSeasons(imdbId: string): Promise<{ seasons: number[] }> { + return this.request<{ seasons: number[] }>(`/api/tv/show/${imdbId}`); + } + + async getTvShowEpisodes(imdbId: string, season: number): Promise<{ seasons: TvSeason[] }> { + return this.request<{ seasons: TvSeason[] }>(`/api/tv/show/${imdbId}?season=${season}`); + } + + async searchMusicVideos(data: SearchRequest): Promise { + return this.request("/api/music-videos/search", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async browseMusicVideos(params: { page?: number }): Promise { + const q = new URLSearchParams(); + if (params.page) q.set("page", String(params.page)); + return this.request(`/api/music-videos/browse?${q.toString()}`); + } + + async searchMusic(data: SearchRequest): Promise { + return this.request("/api/music/search", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async browseMusic(params: { page?: number }): Promise { + const q = new URLSearchParams(); + if (params.page) q.set("page", String(params.page)); + return this.request(`/api/music/browse?${q.toString()}`); + } + + async resolveMagnet(detailUrl: string, apiBase: string): Promise { + return this.request(`/api/${apiBase}/resolve-magnet`, { + method: "POST", + body: JSON.stringify({ detail_url: detailUrl }), + }); + } + + async getFavourites(type?: string): Promise { + const q = new URLSearchParams(); + if (type) q.set("type", type); + const qs = q.toString(); + return this.request(`/api/favourites${qs ? `?${qs}` : ""}`); + } + + async addFavourite(data: Omit): Promise { + return this.request("/api/favourites", { + method: "POST", + body: JSON.stringify(data), + }); + } + + async deleteFavourite(id: string): Promise { + return this.request(`/api/favourites/${id}`, { + method: "DELETE", + }); + } + + getPlaylistUrl(streamId: string, quality?: string): string { const token = getToken(); - const base = `/api/stream/${streamId}/playlist.m3u8`; - return token ? `${base}?token=${encodeURIComponent(token)}` : base; + const params = new URLSearchParams(); + if (token) params.set("token", token); + if (quality) params.set("quality", quality); + const qs = params.toString(); + return `/api/stream/${streamId}/playlist.m3u8${qs ? `?${qs}` : ""}`; + } + + getUrlPlaylistUrl(url: string, quality?: string): string { + const token = getToken(); + const params = new URLSearchParams(); + params.set("url", url); + if (token) params.set("token", token); + if (quality) params.set("quality", quality); + return `/api/stream/url/playlist.m3u8?${params.toString()}`; } getFileUrl(streamId: string): string { return `/api/stream/${streamId}/file`; } + + async deleteStream(streamId: string): Promise { + await this.request(`/api/stream/${streamId}`, { method: "DELETE" }); + } } export class ApiRequestError extends Error { diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index d8fff25..125c9a9 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -23,26 +23,65 @@ export interface SearchRequest { query: string; } -export interface SearchResult { +export interface SearchResultGroup { title: string; + year?: number; + rating?: number; + runtime?: number; + genres: string[]; + language?: string; + mpa_rating?: string; + summary?: string; + imdb_code?: string; + trailer_code?: string; + poster?: string; + poster_small?: string; + poster_medium?: string; + poster_large?: string; + backdrop?: string; + variants: SearchResult[]; +} + +export interface SearchResult { magnet: string; seeds: number; leeches: number; size: string; size_bytes: number; quality?: string; - year?: number; - rating?: number; - poster?: string; + video_codec?: string; + audio_channels?: string; + bit_depth?: string; + source_type?: string; } export interface SearchResponse { - results: SearchResult[]; + results: SearchResultGroup[]; } export interface StreamRequest { magnet_uri: string; file_index?: number; + poster_url?: string; + // Rich metadata + title?: string; + year?: number; + rating?: number; + runtime?: number; + genres?: string[]; + language?: string; + video_codec?: string; + audio_channels?: string; + source_type?: string; + summary?: string; + imdb_code?: string; + mpa_rating?: string; + bit_depth?: string; + trailer_code?: string; + poster_small?: string; + poster_medium?: string; + poster_large?: string; + backdrop?: string; } export interface StreamFile { @@ -74,7 +113,7 @@ export interface StreamStatus { file_name?: string; file_size?: number; files?: StreamFile[]; - browser_compatible?: boolean; + video_codec?: string; } export interface WatchHistoryItem { @@ -86,6 +125,32 @@ export interface WatchHistoryItem { watched_seconds: number | null; poster_url: string | null; watched_at: string; + info_hash: string | null; + file_size: number | null; + year: number | null; + rating: number | null; + runtime: number | null; + genres: string | null; + summary: string | null; + imdb_code: string | null; +} + +export interface StreamMetadata { + title?: string; + year?: number; + rating?: number; + runtime?: number; + genres?: string; + language?: string; + mpa_rating?: string; + summary?: string; + imdb_code?: string; + video_codec?: string; + audio_channels?: string; + bit_depth?: string; + source_type?: string; + poster_large?: string; + local_poster?: string; } export interface Settings { @@ -111,3 +176,67 @@ export interface SearchHistoryResponse { export interface WatchHistoryResponse { items: WatchHistoryItem[]; } + +export interface TvTorrent { + magnet: string; + seeds: number; + leeches: number; + size_bytes: number; + quality: string | null; + filename: string; +} + +export interface TvEpisode { + episode: number; + title: string | null; + variants: TvTorrent[]; +} + +export interface TvSeason { + season: number; + episodes: TvEpisode[]; +} + +export interface TvSearchResultGroup { + show_name: string; + imdb_id: string | null; + seasons: TvSeason[]; +} + +export interface TvSearchResponse { + results: TvSearchResultGroup[]; +} + +export interface MusicVideoResult { + title: string; + magnet: string | null; + seeds: number; + leeches: number; + size: string; + detail_url: string; +} + +export interface MusicVideoSearchResponse { + results: MusicVideoResult[]; +} + +export interface ResolveMagnetResponse { + magnet: string; +} + +export interface FavouriteItem { + id: string; + user_id: string; + content_type: string; + title: string; + year: number | null; + rating: number | null; + poster_url: string | null; + info_hash: string | null; + metadata_json: string | null; + created_at: string; +} + +export interface FavouritesResponse { + items: FavouriteItem[]; +} diff --git a/ui/src/components/AudioPlayerBar.tsx b/ui/src/components/AudioPlayerBar.tsx new file mode 100644 index 0000000..85bb75e --- /dev/null +++ b/ui/src/components/AudioPlayerBar.tsx @@ -0,0 +1,127 @@ +import { Flex, Text } from "@radix-ui/themes"; +import { PlayIcon, PauseIcon, Cross2Icon } from "@radix-ui/react-icons"; +import { useAudioPlayer } from "../hooks/useAudioPlayer"; + +function formatTime(seconds: number): string { + if (!isFinite(seconds) || seconds < 0) return "0:00"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; +} + +export function AudioPlayerBar() { + const { currentTrack, isPlaying, duration, currentTime, pause, resume, stop, seek } = + useAudioPlayer(); + + if (!currentTrack) return null; + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + + return ( +
+ {/* Thin progress line */} +
+
+ {/* Clickable seek area */} +
{ + const rect = e.currentTarget.getBoundingClientRect(); + const ratio = (e.clientX - rect.left) / rect.width; + seek(ratio * duration); + }} + style={{ + position: "absolute", + top: -8, + bottom: -8, + left: 0, + right: 0, + cursor: "pointer", + }} + /> +
+ + + {/* Artwork placeholder */} + + {currentTrack.artworkUrl ? ( + + ) : ( + + )} + + + {/* Title */} + + + {currentTrack.title} + + + {formatTime(currentTime)} / {formatTime(duration)} + + + + {/* Controls */} + +
+ {isPlaying ? ( + + ) : ( + + )} +
+
+ +
+
+
+
+ ); +} diff --git a/ui/src/components/DebugPane.tsx b/ui/src/components/DebugPane.tsx index 874db63..e6725c8 100644 --- a/ui/src/components/DebugPane.tsx +++ b/ui/src/components/DebugPane.tsx @@ -4,12 +4,11 @@ import { Text, Button, Badge, - ScrollArea, Select, Code, IconButton, } from "@radix-ui/themes"; -import { TrashIcon, Cross2Icon, ChevronUpIcon } from "@radix-ui/react-icons"; +import { TrashIcon, Cross2Icon, ChevronUpIcon, CopyIcon } from "@radix-ui/react-icons"; import { debugLog } from "../lib/debug-log"; import type { LogLevel, LogEntry } from "../lib/debug-log"; @@ -115,6 +114,20 @@ export function DebugPane({ onClose }: DebugPaneProps) { {errorCount > 0 && ( {errorCount} err )} + { + e.stopPropagation(); + const text = entries.map((en) => { + const t = new Date(en.timestamp).toLocaleTimeString("en", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const d = en.data !== undefined ? ` ${typeof en.data === "string" ? en.data : JSON.stringify(en.data)}` : ""; + return `${t} ${en.level.charAt(0).toUpperCase()} ${en.source} ${en.message}${d}`; + }).join("\n"); + navigator.clipboard.writeText(text).then( + () => debugLog.info("debug", "Log copied to clipboard"), + () => debugLog.warn("debug", "Copy failed") + ); + }}> + + e.stopPropagation()}> {expanded && ( @@ -157,17 +170,19 @@ export function DebugPane({ onClose }: DebugPaneProps) { {expanded && ( - -
- {filtered.length === 0 ? ( - - No log entries - - ) : ( - filtered.map((entry, i) => ) - )} -
-
+
+ {filtered.length === 0 ? ( + + No log entries + + ) : ( + filtered.map((entry, i) => ) + )} +
)}
); diff --git a/ui/src/components/DrawerMenu.tsx b/ui/src/components/DrawerMenu.tsx new file mode 100644 index 0000000..37cc520 --- /dev/null +++ b/ui/src/components/DrawerMenu.tsx @@ -0,0 +1,264 @@ +import { NavLink } from "react-router-dom"; +import { + Flex, + Text, + Badge, + Separator, +} from "@radix-ui/themes"; +import { + VideoIcon, + CounterClockwiseClockIcon, + GearIcon, + ExitIcon, + CodeIcon, + StarFilledIcon, +} from "@radix-ui/react-icons"; +import { AnimatePresence, motion } from "framer-motion"; +import { useAuth } from "../hooks/useAuth"; +import { useDebug } from "../hooks/useDebug"; + +interface DrawerMenuProps { + open: boolean; + onClose: () => void; +} + +const menuLinkStyle: React.CSSProperties = { + textDecoration: "none", + color: "inherit", + display: "flex", + alignItems: "center", + gap: 12, + padding: "10px 20px", + borderRadius: 6, + transition: "background 0.1s", +}; + +function MenuLink({ + to, + icon, + label, + disabled, + onClose, +}: { + to: string; + icon: React.ReactNode; + label: string; + disabled?: boolean; + onClose: () => void; +}) { + if (disabled) { + return ( +
+ {icon} + {label} + + Soon + +
+ ); + } + + return ( + ({ + ...menuLinkStyle, + background: isActive ? "var(--gray-a3)" : "transparent", + fontWeight: isActive ? 600 : 400, + })} + > + {icon} + {label} + + ); +} + +function FilmIcon() { + return ( + + + + ); +} + +function MonitorIcon() { + return ( + + + + ); +} + +function NoteIcon() { + return ( + + + + ); +} + +function SurroundIcon() { + return ( + + + + ); +} + +export function DrawerMenu({ open, onClose }: DrawerMenuProps) { + const { user, logout } = useAuth(); + const { debug, setDebug } = useDebug(); + + return ( + + {open && ( + <> + + + {/* User profile */} + +
+ {(user?.username ?? "U").charAt(0).toUpperCase()} +
+ + + {user?.username ?? "User"} + + {user?.is_admin && ( + + Admin + + )} + +
+ + + + {/* Navigation */} + + } label="Movies" onClose={onClose} /> + } label="TV Shows" onClose={onClose} /> + } label="Music Videos" onClose={onClose} /> + } label="Music" onClose={onClose} /> + + + + } label="Surround Sound" onClose={onClose} /> + } label="Favourites" onClose={onClose} /> + } label="History" onClose={onClose} /> + + + + } label="Settings" onClose={onClose} /> + {user?.is_admin && ( + } label="Admin" onClose={onClose} /> + )} + +
setDebug(!debug)} + style={{ + ...menuLinkStyle, + cursor: "pointer", + }} + > + + Debug Mode + {debug && ( + + ON + + )} +
+ +
+ + + +
{ + onClose(); + logout(); + }} + style={{ + ...menuLinkStyle, + cursor: "pointer", + color: "var(--red-11)", + }} + > + + + Logout + +
+ + + + )} + + ); +} diff --git a/ui/src/components/FavouriteButton.tsx b/ui/src/components/FavouriteButton.tsx new file mode 100644 index 0000000..307cecc --- /dev/null +++ b/ui/src/components/FavouriteButton.tsx @@ -0,0 +1,73 @@ +import { useFavourites } from "../hooks/useFavourites"; +import type { SearchResultGroup } from "../api/types"; + +interface FavouriteButtonProps { + group: SearchResultGroup; + size?: number; + style?: React.CSSProperties; +} + +export function FavouriteButton({ group, size = 28, style }: FavouriteButtonProps) { + const { isFavourite, addFavourite, removeFavouriteByTitle } = useFavourites(); + const active = isFavourite(group.title, group.year); + + const handleClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (active) { + await removeFavouriteByTitle(group.title, group.year); + } else { + await addFavourite({ + content_type: "movie", + title: group.title, + year: group.year ?? null, + rating: group.rating ?? null, + poster_url: group.poster_medium ?? group.poster ?? null, + info_hash: null, + metadata_json: JSON.stringify({ + genres: group.genres, + summary: group.summary, + imdb_code: group.imdb_code, + trailer_code: group.trailer_code, + poster_small: group.poster_small, + poster_medium: group.poster_medium, + poster_large: group.poster_large, + backdrop: group.backdrop, + variants: group.variants, + }), + }); + } + }; + + return ( +
+ + + +
+ ); +} diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 858bcc7..b90c68c 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,48 +1,38 @@ +import { useState } from "react"; import { Outlet, NavLink, useLocation } from "react-router-dom"; import { Box, Flex, Text, Container, - DropdownMenu, IconButton, - Badge, - Separator, } from "@radix-ui/themes"; import { - MagnifyingGlassIcon, - CounterClockwiseClockIcon, - SunIcon, - MoonIcon, - PersonIcon, - GearIcon, - ExitIcon, - CodeIcon, + HamburgerMenuIcon, } from "@radix-ui/react-icons"; import { AnimatePresence, motion } from "framer-motion"; -import { useAuth } from "../hooks/useAuth"; import { useDebug } from "../hooks/useDebug"; +import { useAudioPlayer } from "../hooks/useAudioPlayer"; import { DebugPane } from "./DebugPane"; +import { DrawerMenu } from "./DrawerMenu"; +import { AudioPlayerBar } from "./AudioPlayerBar"; -interface LayoutProps { - theme: "dark" | "light"; - toggleTheme: () => void; -} - -const navLinkStyle = (isActive: boolean) => ({ - textDecoration: "none", - color: "inherit", - opacity: isActive ? 1 : 0.7, - fontWeight: isActive ? 600 : 400, -}); - -export function Layout({ theme, toggleTheme }: LayoutProps) { - const { user, logout } = useAuth(); +export function Layout() { const { debug, setDebug } = useDebug(); + const { currentTrack } = useAudioPlayer(); const location = useLocation(); + const [drawerOpen, setDrawerOpen] = useState(false); + const hasAudioPlayer = currentTrack !== null; + + const isPlayer = location.pathname.startsWith("/player"); return ( + {!isPlayer && ( +
+ )} + setDrawerOpen(false)} /> +
- - - - - StreamX - - StreamX - - - - - + + setDrawerOpen(true)} + aria-label="Open menu" + > + + - - navLinkStyle(isActive)} - > - - - Search - - - navLinkStyle(isActive)} - > - - - History - - + + + StreamX + + StreamX + - - - - - {theme === "dark" ? : } - - - - - - - - - - - - {user?.username ?? "User"} - {user?.is_admin && ( - - Admin - - )} - - - - - - - Settings - - - setDebug(!debug)}> - - Debug Mode - {debug && ( - - ON - - )} - - - - - Logout - - - - +
- + - {location.pathname.startsWith("/player") ? ( + {isPlayer ? ( ) : ( @@ -169,7 +90,7 @@ export function Layout({ theme, toggleTheme }: LayoutProps) { )} - + {debug && setDebug(false)} />} ); diff --git a/ui/src/components/TrailerModal.tsx b/ui/src/components/TrailerModal.tsx new file mode 100644 index 0000000..ebe1bb7 --- /dev/null +++ b/ui/src/components/TrailerModal.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from "react"; +import { Cross2Icon } from "@radix-ui/react-icons"; + +interface Props { + youtubeId?: string; + searchQuery?: string; + onClose: () => void; +} + +export function TrailerModal({ youtubeId, searchQuery, onClose }: Props) { + const [resolvedId, setResolvedId] = useState(youtubeId ?? null); + const [loading, setLoading] = useState(!youtubeId); + + useEffect(() => { + if (youtubeId || !searchQuery) return; + let cancelled = false; + fetch(`/api/trailer/search?q=${encodeURIComponent(searchQuery)}`) + .then(async (resp) => { + if (cancelled) return; + if (!resp.ok) { + window.open( + `https://www.youtube.com/results?search_query=${encodeURIComponent(searchQuery)}`, + "_blank", + "noopener" + ); + onClose(); + return; + } + const data = await resp.json(); + if (data.youtube_id) { + setResolvedId(data.youtube_id); + } else { + window.open( + `https://www.youtube.com/results?search_query=${encodeURIComponent(searchQuery)}`, + "_blank", + "noopener" + ); + onClose(); + return; + } + setLoading(false); + }) + .catch(() => { + if (!cancelled) { + window.open( + `https://www.youtube.com/results?search_query=${encodeURIComponent(searchQuery)}`, + "_blank", + "noopener" + ); + onClose(); + } + }); + return () => { cancelled = true; }; + }, [youtubeId, searchQuery]); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [onClose]); + + return ( +
+
e.stopPropagation()} + style={{ + position: "relative", + width: "100%", + maxWidth: 900, + aspectRatio: "16/9", + borderRadius: 12, + overflow: "hidden", + background: "#000", + }} + > + {resolvedId && ( +