From 033572d3bd6535c33dd44448c88b69315ce327c8 Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 10 Jun 2026 17:51:44 -0700 Subject: [PATCH] Federation: read-only browsing of a peer instance's library (3.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configure peers as [[remote]] entries (name, url, optional password) and browse their libraries from this instance, read-only. - remote.rs: RemoteClient (blocking reqwest + rustls + cookie jar). Fetches the peer's /api/library, logging in first if the peer has a password and retrying on a 401. Media is not proxied: the library JSON's media URLs (/files/, /music-files/, /api/transcode/, /api/sub-vtt/) are rewritten to absolute peer URLs with the peer's read-only feed token appended, so video streams straight from the peer to the browser/mpv while only the small JSON passes through us. - web.rs: auth_middleware now also accepts the feed token for GET /api/transcode/ and /api/sub-vtt/ (read-only media, same class as /files/), so a transcoded peer streams remotely. New GET /api/remotes and /api/remotes/:id/library (the RemoteClient runs under spawn_blocking). - web UI: a 🌐 Remotes sidebar switcher; remote mode is read-only (hides the download bar + per-card mutating actions, keeps Play). - desktop: a 🌐 Remotes screen that fetches a peer's library off-thread and plays via mpv on the tokenized URL. - config.rs: [[remote]] section list; reqwest dep added (rustls/blocking/ cookies, no system OpenSSL). Verified end-to-end against a second local instance, including a transcoded stream returning HTTP 200 via the token. ROADMAP 3.5 + CLAUDE.md updated. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 5 +- Cargo.lock | 330 +++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 6 + ROADMAP.md | 21 ++- src/app.rs | 145 +++++++++++++++++++ src/config.rs | 20 +++ src/main.rs | 1 + src/remote.rs | 277 +++++++++++++++++++++++++++++++++++ src/web.rs | 51 ++++++- src/web_ui/index.html | 56 ++++++- 10 files changed, 899 insertions(+), 13 deletions(-) create mode 100644 src/remote.rs diff --git a/CLAUDE.md b/CLAUDE.md index e183231..40803ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,7 +161,10 @@ both the transcript indexer and the transcript viewers. - `app.rs` and `web.rs` are large (~3–4k lines) because each owns a full UI; new desktop code goes in `app.rs`, web handlers in `web.rs`, shared logic in the focused modules (`downloader`, `database`, `library`, `platform`, `fingerprint`, - `vtt`, …). + `vtt`, `autotag`, `remote`, …). `autotag` suggests folder groups for unfiled + channels; `remote` is federation β€” a `RemoteClient` that browses a peer + instance's library read-only (media via the peer's feed token, see + `auth_middleware`). - Tray (`ksni`) and the `rfd` xdg-portal dialog backend are Linux-only/no-GTK by design (it's why packaging avoids a GTK dep). They're now **target-gated** in `Cargo.toml` (`cfg(target_os = "linux")`); off Linux `rfd` uses its native diff --git a/Cargo.lock b/Cargo.lock index 3b902e2..2e8b9df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -863,6 +863,35 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1356,6 +1385,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1449,8 +1479,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1460,9 +1492,11 @@ 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]] @@ -1773,6 +1807,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -1781,13 +1832,21 @@ 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", "tokio", "tower-service", + "tracing", ] [[package]] @@ -1946,6 +2005,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "itoa" version = "1.0.18" @@ -2146,6 +2211,12 @@ version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac-notification-sys" version = "0.6.12" @@ -2917,6 +2988,22 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pxfm" version = "0.1.29" @@ -2957,6 +3044,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "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.4", + "ring", + "rustc-hash 2.1.2", + "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 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -3115,6 +3257,48 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "cookie", + "cookie_store", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "rfd" version = "0.15.4" @@ -3139,6 +3323,20 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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 = "rusqlite" version = "0.31.0" @@ -3200,6 +3398,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3540,6 +3773,9 @@ 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" @@ -3633,10 +3869,12 @@ 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]] @@ -3645,6 +3883,16 @@ 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.3" @@ -3655,6 +3903,21 @@ dependencies = [ "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.52.3" @@ -3684,6 +3947,16 @@ dependencies = [ "syn 2.0.117", ] +[[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-stream" version = "0.1.18" @@ -3834,6 +4107,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -3878,6 +4169,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -3958,6 +4255,12 @@ 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 = "url" version = "2.5.8" @@ -4024,6 +4327,15 @@ dependencies = [ "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" @@ -4308,6 +4620,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wgpu" version = "22.1.0" @@ -5101,6 +5422,7 @@ dependencies = [ "r2d2", "r2d2_sqlite", "rand 0.8.6", + "reqwest", "rfd", "rusqlite", "serde", @@ -5109,7 +5431,7 @@ dependencies = [ "tokio-stream", "tokio-util", "toml", - "tower-http", + "tower-http 0.5.2", ] [[package]] @@ -5314,6 +5636,12 @@ dependencies = [ "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.4" diff --git a/Cargo.toml b/Cargo.toml index 5efda8b..01dc3ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,12 @@ tokio-stream = "0.1" tower-http = { version = "0.5", features = ["cors", "fs", "compression-gzip"] } argon2 = "0.5" rand = "0.8" +# HTTP client for federation (remote-library mode). Pure-Rust TLS (rustls, no +# system OpenSSL) to match the bundled-everything posture. Blocking API + a +# cookie store so RemoteClient can log into a password-protected peer and +# reuse the session; the web layer calls it under spawn_blocking, desktop +# calls it directly. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "blocking", "json", "cookies"] } libc = "0.2.186" # Platform-specific desktop deps. On Linux the tray (ksni β€” StatusNotifierItem diff --git a/ROADMAP.md b/ROADMAP.md index a98f781..7ef3f41 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -167,11 +167,24 @@ folder and assigns), desktop shows "Move all β†’ group". Pure arithmetic over the in-memory library, so it's recomputed on demand with no job. Never moves anything until you apply. -### 3.5 Federation / multi-host +### 3.5 Federation / multi-host β€” DONE -A "remote library" mode where one yt-offline instance can browse -another's library (read-only) over the same axum API. Useful for a -"home archive + travel laptop" setup. +A read-only "remote library" mode: configure peers as `[[remote]]` entries +(name, url, optional password) and browse their libraries from this +instance. `remote.rs`'s `RemoteClient` (blocking reqwest, rustls, cookie +jar) fetches the peer's `/api/library`, logging in first if the peer has a +password. Media is **not** proxied β€” the library JSON's media URLs +(`/files/`, `/music-files/`, `/api/transcode/`, `/api/sub-vtt/`) are +rewritten to absolute peer URLs with the peer's read-only **feed token** +appended, which the peer's auth middleware now accepts for those GET paths. +So only the small library JSON travels through us; video streams straight +from the peer to the browser/mpv. Surfaced in both UIs: the web sidebar +gets a 🌐 Remotes switcher (read-only mode hides the download bar + per-card +mutating actions); desktop gets a 🌐 Remotes screen that fetches off-thread +and plays via mpv on the tokenized URL. Verified end-to-end against a second +local instance (incl. a transcoded stream returning HTTP 200 with the token). +Follow-ups: an in-UI "add remote" editor (today it's config-file only) and a +desktop thumbnail/grid view to match the web fidelity. ### 3.6 Comment viewer enhancements β€” DONE diff --git a/src/app.rs b/src/app.rs index 53ffb1e..3141545 100644 --- a/src/app.rs +++ b/src/app.rs @@ -41,6 +41,8 @@ enum Screen { Settings, Stats, Maintenance, + /// Read-only browser for a federated peer's library (see `crate::remote`). + Remotes, } /// One video in a perceptual-similarity group (desktop dedup review). @@ -232,6 +234,17 @@ pub struct App { /// Auto-tag grouping suggestions, recomputed when the Maintenance screen /// opens and after a group is applied. Empty when there's nothing to suggest. autotag_suggestions: Vec, + // Federation (read-only remote libraries). + remotes: Vec>, + /// Currently-selected peer index, if the Remotes screen is showing one. + remote_selected: Option, + /// Last fetched remote library; `None` until a peer is loaded. + remote_library: Option, + /// Status/error line for the Remotes screen. + remote_status: String, + /// Receiver for the background fetch of a peer's library (network I/O is + /// done off the UI thread). `None` when no fetch is in flight. + remote_rx: Option>>, stats_report: Option, // Per-channel download-options dialog state show_channel_options: bool, @@ -436,6 +449,10 @@ impl App { downloader.youtube_player_clients = config.backup.youtube_player_clients.clone(); downloader.sponsorblock_mode = config.backup.sponsorblock_mode.clone(); downloader.fetch_comments = config.backup.fetch_comments; + // Federation peers, built once from config (read-only remote libraries). + let remotes: Vec> = config.remotes.iter() + .map(|r| std::sync::Arc::new(crate::remote::RemoteClient::new(r))) + .collect(); downloader.convert_defaults = config.convert.clone(); let config_bind = config.web.bind.clone(); let password_set = db.get_setting("password_hash").ok().flatten().is_some(); @@ -577,6 +594,11 @@ impl App { backup_open_rx, health_report: None, autotag_suggestions: Vec::new(), + remotes, + remote_selected: None, + remote_library: None, + remote_status: String::new(), + remote_rx: None, stats_report: None, show_channel_options: false, channel_options_target: None, @@ -1454,6 +1476,15 @@ impl App { self.current_screen = Screen::Maintenance; } } + if !self.remotes.is_empty() + && ui.selectable_label(self.current_screen == Screen::Remotes, "🌐 Remotes").clicked() + { + if self.current_screen == Screen::Remotes { + self.current_screen = Screen::Library; + } else { + self.current_screen = Screen::Remotes; + } + } if ui.selectable_label(self.current_screen == Screen::Settings, "βš™ Settings").clicked() { if self.current_screen == Screen::Settings { self.current_screen = Screen::Library; @@ -2082,6 +2113,25 @@ impl App { ctx.request_repaint(); } + // Receive a background remote-library fetch (federation). + let remote_result = self.remote_rx.as_ref().and_then(|rx| rx.try_recv().ok()); + if let Some(res) = remote_result { + self.remote_rx = None; + match res { + Ok(lib) => { + let n: usize = lib.channels.iter().map(|c| c.videos.len()).sum(); + self.remote_status = + format!("{} channels Β· {} videos", lib.channels.len(), n); + self.remote_library = Some(lib); + } + Err(e) => { + self.remote_status = format!("Error: {e}"); + self.remote_library = None; + } + } + ctx.request_repaint(); + } + // Drain the background dedup result if it's ready. let mut dedup_done = None; if let Some(rx) = &self.dedup_rx { @@ -2354,6 +2404,100 @@ impl App { self.autotag_suggestions = crate::autotag::suggest(&self.library); } + /// Kick off a background fetch of peer `idx`'s library (network I/O off + /// the UI thread). The result is delivered over `remote_rx`, drained in + /// `update()`. The screen requests repaints while a fetch is in flight. + fn start_remote_fetch(&mut self, idx: usize) { + let Some(client) = self.remotes.get(idx).cloned() else { return }; + self.remote_selected = Some(idx); + self.remote_library = None; + self.remote_status = format!("Connecting to {}…", client.name); + let (tx, rx) = std::sync::mpsc::channel(); + self.remote_rx = Some(rx); + std::thread::spawn(move || { + let _ = tx.send(client.library()); + }); + } + + /// Launch the configured player on a remote (absolute, tokenized) URL. + /// mpv streams it straight from the peer; no resume tracking (that's local). + fn play_remote_url(&mut self, url: &str) { + let cmd = self.config.player.command.clone(); + match Command::new(&cmd).arg(url).spawn() { + Ok(_) => self.remote_status = "Launched player".to_string(), + Err(e) => self.remote_status = format!("Player error ({cmd}): {e}"), + } + } + + /// Read-only browser for a federated peer's library. + fn remotes_screen(&mut self, ctx: &egui::Context) { + // Keep polling while a background fetch is in flight. + if self.remote_rx.is_some() { + ctx.request_repaint(); + } + let mut select_remote: Option = None; + let mut play_url: Option = None; + egui::CentralPanel::default().show(ctx, |ui| { + ui.horizontal(|ui| { + if ui.button("← Library").clicked() { + self.current_screen = Screen::Library; + } + ui.heading("🌐 Remote libraries"); + }); + ui.label(egui::RichText::new( + "Browse another yt-offline instance read-only. Playback streams from the peer.") + .weak().small()); + ui.separator(); + ui.horizontal_wrapped(|ui| { + for (i, r) in self.remotes.iter().enumerate() { + let sel = self.remote_selected == Some(i); + if ui.selectable_label(sel, format!("🌐 {}", r.name)).clicked() { + select_remote = Some(i); + } + } + }); + if !self.remote_status.is_empty() { + ui.label(egui::RichText::new(&self.remote_status).weak().small()); + } + ui.separator(); + egui::ScrollArea::vertical().show(ui, |ui| { + if let Some(lib) = &self.remote_library { + if lib.channels.is_empty() { + ui.label(egui::RichText::new("This peer's library is empty.").weak()); + } + for ch in &lib.channels { + egui::CollapsingHeader::new(format!("{} ({})", ch.name, ch.videos.len())) + .show(ui, |ui| { + for v in &ch.videos { + ui.horizontal(|ui| { + let playable = v.video_url.is_some(); + if ui.add_enabled(playable, egui::Button::new("β–Ά")).clicked() { + if let Some(u) = &v.video_url { + play_url = Some(u.clone()); + } + } + let dur = v.duration_secs + .map(format_duration) + .unwrap_or_default(); + ui.label(format!("{} {}", v.title, dur)); + }); + } + }); + } + } else if self.remote_selected.is_none() { + ui.label(egui::RichText::new( + "Pick a peer above to browse its library.").weak()); + } + }); + }); + if let Some(i) = select_remote { + self.start_remote_fetch(i); + } + if let Some(u) = play_url { + self.play_remote_url(&u); + } + } + fn stats_screen(&mut self, ctx: &egui::Context) { let report = match &self.stats_report { Some(r) => r.clone(), @@ -4380,6 +4524,7 @@ impl eframe::App for App { Screen::Settings => self.settings_screen(ctx), Screen::Stats => self.stats_screen(ctx), Screen::Maintenance => self.maintenance_screen(ctx), + Screen::Remotes => self.remotes_screen(ctx), } } } diff --git a/src/config.rs b/src/config.rs index f17f0da..4bb67aa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,6 +24,25 @@ pub struct Config { pub subtitles: SubtitlesSection, #[serde(default)] pub convert: ConvertSection, + /// Federation: other yt-offline instances whose libraries can be browsed + /// read-only from this one (see [`crate::remote`]). Each is a + /// `[[remote]]` table in config.toml. + #[serde(default, rename = "remote")] + pub remotes: Vec, +} + +/// One `[[remote]]` entry β€” a peer yt-offline instance to browse read-only. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RemoteSection { + /// Display name in the UI's remote switcher. + pub name: String, + /// Base URL of the peer's web server, e.g. `http://woofbox:8081` or + /// `https://archive.example`. No trailing slash needed. + pub url: String, + /// Password for the peer, if it has one set. `None`/absent = open peer + /// (e.g. bound to a trusted tailnet). Stored plaintext like `cookies`. + #[serde(default)] + pub password: Option, } /// `[convert]` table β€” global post-download format-conversion defaults. @@ -289,6 +308,7 @@ impl Config { plex: PlexSection::default(), subtitles: SubtitlesSection::default(), convert: ConvertSection::default(), + remotes: Vec::new(), } } } diff --git a/src/main.rs b/src/main.rs index d9de112..b4ca3b8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ mod maintenance; mod platform; mod plex; mod pot_provider; +mod remote; mod stats; mod theme; mod tray; diff --git a/src/remote.rs b/src/remote.rs new file mode 100644 index 0000000..0079318 --- /dev/null +++ b/src/remote.rs @@ -0,0 +1,277 @@ +//! Federation β€” read-only browsing of a *peer* yt-offline instance's library. +//! +//! A [`RemoteClient`] talks to another instance over its existing web API: +//! it fetches `/api/library`, logging in first if the peer has a password +//! (the blocking reqwest client keeps the session cookie). Media is **not** +//! proxied β€” instead the library JSON's `/files/…` URLs are rewritten to +//! absolute peer URLs with the peer's read-only **feed token** appended, which +//! the peer's auth middleware already accepts for `GET /files/` without a +//! login. So the browser (or mpv) streams video straight from the peer while +//! only the small library JSON travels through us. +//! +//! The client is blocking; the async web layer calls it via +//! `spawn_blocking`, the desktop app calls it directly. Roadmap 3.5. + +use std::sync::Mutex; +use std::time::Duration; + +use serde::Serialize; +use serde_json::Value; + +use crate::config::RemoteSection; + +/// A connection to one peer instance. Cheap to hold; the underlying reqwest +/// client owns a connection pool + cookie jar reused across calls. +pub struct RemoteClient { + pub name: String, + /// Base URL with any trailing slash trimmed. + base: String, + password: Option, + client: reqwest::blocking::Client, + /// Cached read-only feed token (fetched once via `/api/feed-info`). + feed_token: Mutex>, +} + +/// Reduced per-video view for the desktop remote browser (the web UI consumes +/// the full proxied JSON directly). URLs are already absolute + tokenized. +#[derive(Clone, Serialize)] +pub struct RemoteVideo { + pub id: String, + pub title: String, + pub channel: String, + pub video_url: Option, + pub thumb_url: Option, + pub duration_secs: Option, +} + +#[derive(Clone, Serialize)] +pub struct RemoteChannel { + pub name: String, + pub videos: Vec, +} + +/// A peer's library flattened to channels β†’ videos for read-only display. +#[derive(Clone, Serialize)] +pub struct RemoteLibrary { + pub channels: Vec, +} + +impl RemoteClient { + pub fn new(cfg: &RemoteSection) -> Self { + let client = reqwest::blocking::Client::builder() + .cookie_store(true) + .timeout(Duration::from_secs(30)) + .user_agent("yt-offline-federation") + .build() + .unwrap_or_else(|_| reqwest::blocking::Client::new()); + RemoteClient { + name: cfg.name.clone(), + base: cfg.url.trim_end_matches('/').to_string(), + password: cfg.password.clone().filter(|p| !p.is_empty()), + client, + feed_token: Mutex::new(None), + } + } + + pub fn base_url(&self) -> &str { + &self.base + } + + /// POST the peer's `/api/login` with the configured password. No-op for an + /// open peer. On success the session cookie lands in the client's jar. + fn login(&self) -> Result<(), String> { + let Some(pw) = &self.password else { return Ok(()) }; + let resp = self + .client + .post(format!("{}/api/login", self.base)) + .json(&serde_json::json!({ "password": pw })) + .send() + .map_err(|e| format!("login request failed: {e}"))?; + if resp.status().is_success() { + Ok(()) + } else { + Err(format!("login rejected: HTTP {}", resp.status().as_u16())) + } + } + + /// GET a peer path, logging in + retrying once on a 401 when a password is + /// configured (covers both first contact and an expired session). + fn authed_get(&self, path: &str) -> Result { + let url = format!("{}{}", self.base, path); + let resp = self + .client + .get(&url) + .send() + .map_err(|e| format!("request to {} failed: {e}", self.name))?; + if resp.status().as_u16() == 401 && self.password.is_some() { + self.login()?; + return self + .client + .get(&url) + .send() + .map_err(|e| format!("request to {} failed: {e}", self.name)); + } + Ok(resp) + } + + /// Fetch + cache the peer's read-only feed token, used to tokenize media URLs. + fn feed_token(&self) -> Result { + if let Some(t) = self.feed_token.lock().unwrap().clone() { + return Ok(t); + } + let resp = self.authed_get("/api/feed-info")?; + if !resp.status().is_success() { + return Err(format!("feed-info: HTTP {}", resp.status().as_u16())); + } + let v: Value = resp.json().map_err(|e| format!("feed-info parse: {e}"))?; + let token = v.get("token").and_then(Value::as_str).unwrap_or("").to_string(); + *self.feed_token.lock().unwrap() = Some(token.clone()); + Ok(token) + } + + /// Fetch the peer's `/api/library`, with every `/files/` + `/music-files/` + /// URL rewritten to an absolute, token-bearing peer URL the browser/mpv can + /// load directly. Returns the full JSON so the web UI can reuse its grid. + pub fn library_json(&self) -> Result { + let resp = self.authed_get("/api/library")?; + if !resp.status().is_success() { + return Err(format!("library: HTTP {}", resp.status().as_u16())); + } + let mut v: Value = resp.json().map_err(|e| format!("library parse: {e}"))?; + // Best-effort: if the token can't be fetched, media just won't load, + // but the listing is still useful. + let token = self.feed_token().unwrap_or_default(); + rewrite_media_urls(&mut v, &self.base, &token); + Ok(v) + } + + /// Parse the (already-rewritten) library JSON into the reduced shape the + /// desktop browser renders. + pub fn library(&self) -> Result { + let v = self.library_json()?; + Ok(parse_library(&v)) + } +} + +/// Whether a relative URL points at peer media the read-only feed token grants +/// (raw files, music, transcoded streams, and subtitle tracks) β€” i.e. the paths +/// the peer's auth middleware lets a token'd GET through. Mirror this list with +/// the one in `web::auth_middleware`. +fn is_peer_media_path(s: &str) -> bool { + s.starts_with("/files/") + || s.starts_with("/music-files/") + || s.starts_with("/api/transcode/") + || s.starts_with("/api/sub-vtt/") +} + +/// Recursively rewrite media URLs in a library JSON value: any string under a +/// key named `url` or ending in `_url` that points at peer media (see +/// [`is_peer_media_path`]) becomes `{base}{path}?token={token}` so it resolves +/// on the peer without a login. Other URLs (external channel links) are left +/// untouched. +fn rewrite_media_urls(v: &mut Value, base: &str, token: &str) { + match v { + Value::Object(map) => { + for (k, val) in map.iter_mut() { + if let Value::String(s) = val { + let is_url_key = k == "url" || k.ends_with("_url"); + if is_url_key && is_peer_media_path(s) { + let sep = if s.contains('?') { '&' } else { '?' }; + *s = format!("{base}{s}{sep}token={token}"); + } + } else { + rewrite_media_urls(val, base, token); + } + } + } + Value::Array(arr) => { + for val in arr.iter_mut() { + rewrite_media_urls(val, base, token); + } + } + _ => {} + } +} + +/// Flatten the web library JSON into channels β†’ videos (incl. playlist videos). +fn parse_library(v: &Value) -> RemoteLibrary { + let mut channels = Vec::new(); + let Some(chs) = v.get("channels").and_then(Value::as_array) else { + return RemoteLibrary { channels }; + }; + for ch in chs { + let name = ch.get("name").and_then(Value::as_str).unwrap_or("?").to_string(); + let mut videos = Vec::new(); + // Top-level videos plus any nested playlist videos. + let mut collect = |arr: Option<&Vec>| { + if let Some(arr) = arr { + for vid in arr { + videos.push(RemoteVideo { + id: vid.get("id").and_then(Value::as_str).unwrap_or("").to_string(), + title: vid.get("title").and_then(Value::as_str).unwrap_or("(untitled)").to_string(), + channel: name.clone(), + video_url: vid.get("video_url").and_then(Value::as_str).map(String::from), + thumb_url: vid.get("thumb_url").and_then(Value::as_str).map(String::from), + duration_secs: vid.get("duration_secs").and_then(Value::as_f64), + }); + } + } + }; + collect(ch.get("videos").and_then(Value::as_array)); + if let Some(playlists) = ch.get("playlists").and_then(Value::as_array) { + for pl in playlists { + collect(pl.get("videos").and_then(Value::as_array)); + } + } + channels.push(RemoteChannel { name, videos }); + } + RemoteLibrary { channels } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn rewrites_only_media_urls() { + let mut v = json!({ + "channels": [{ + "name": "Chan", + "thumb_url": "/files/channels/chan/folder.jpg", + "channel_url": "https://youtube.com/@chan", + "videos": [{ + "id": "abc", + "title": "Vid", + "video_url": "/api/transcode/abc", + "thumb_url": "/files/channels/chan/abc.webp", + "subtitles": [{"url": "/api/sub-vtt/x.vtt"}] + }] + }] + }); + rewrite_media_urls(&mut v, "http://peer:8081", "TOK"); + let vid = &v["channels"][0]["videos"][0]; + // Transcode streams, raw files, and subtitle tracks are all tokenized. + assert_eq!(vid["video_url"], "http://peer:8081/api/transcode/abc?token=TOK"); + assert_eq!(vid["thumb_url"], "http://peer:8081/files/channels/chan/abc.webp?token=TOK"); + assert_eq!(vid["subtitles"][0]["url"], "http://peer:8081/api/sub-vtt/x.vtt?token=TOK"); + assert_eq!(v["channels"][0]["thumb_url"], "http://peer:8081/files/channels/chan/folder.jpg?token=TOK"); + // External channel links are left alone. + assert_eq!(v["channels"][0]["channel_url"], "https://youtube.com/@chan"); + } + + #[test] + fn parses_channels_and_playlist_videos() { + let v = json!({ + "channels": [{ + "name": "Chan", + "videos": [{"id": "a", "title": "A", "video_url": "u1"}], + "playlists": [{"videos": [{"id": "b", "title": "B", "video_url": "u2"}]}] + }] + }); + let lib = parse_library(&v); + assert_eq!(lib.channels.len(), 1); + assert_eq!(lib.channels[0].videos.len(), 2); + assert_eq!(lib.channels[0].videos[1].id, "b"); + } +} diff --git a/src/web.rs b/src/web.rs index 3a3483a..8ed9687 100644 --- a/src/web.rs +++ b/src/web.rs @@ -143,6 +143,10 @@ pub struct WebState { /// GET access to `/feed*` and the media mounts even when a password is /// set. Persisted in the `feed_token` setting; stable until regenerated. pub feed_token: String, + /// Federation peers (read-only remote libraries), built once from + /// `config.remotes` at startup. Indexed by position for the `/api/remotes` + /// endpoints. Empty when no `[[remote]]` is configured. + pub remotes: Vec>, } /// Live state for the background perceptual-dedup job (see @@ -1165,7 +1169,14 @@ async fn auth_middleware( // read-only feed token. Scoped to reads of feeds + media only β€” never // `/api/*` mutations. if req.method().as_str() == "GET" - && (path.starts_with("/feed") || path.starts_with("/files/") || path.starts_with("/music-files/")) + && (path.starts_with("/feed") + || path.starts_with("/files/") + || path.starts_with("/music-files/") + // Read-only media streams a federated peer needs when transcode is + // on (/api/transcode) or for subtitle playback (/api/sub-vtt). Both + // are GET-only re-reads of already-token-readable media. + || path.starts_with("/api/transcode/") + || path.starts_with("/api/sub-vtt/")) && req.uri().query().is_some_and(|q| query_has_token(q, &state.feed_token)) { return next.run(req).await; @@ -2112,6 +2123,35 @@ async fn get_maintenance_scan(State(state): State>) -> impl IntoRe Json(report) } +/// `GET /api/remotes` β€” list configured federation peers for the UI switcher. +async fn get_remotes(State(state): State>) -> impl IntoResponse { + let list: Vec<_> = state + .remotes + .iter() + .enumerate() + .map(|(i, r)| serde_json::json!({ "id": i, "name": r.name, "url": r.base_url() })) + .collect(); + Json(list) +} + +/// `GET /api/remotes/:id/library` β€” proxy a peer's library (read-only). Media +/// URLs come back absolute + token-bearing so the browser loads them straight +/// from the peer; only this JSON travels through us. The blocking +/// [`crate::remote::RemoteClient`] runs on a blocking task off the async pool. +async fn get_remote_library( + State(state): State>, + Path(id): Path, +) -> Response { + let Some(remote) = state.remotes.get(id).cloned() else { + return (StatusCode::NOT_FOUND, "no such remote").into_response(); + }; + match tokio::task::spawn_blocking(move || remote.library_json()).await { + Ok(Ok(v)) => Json(v).into_response(), + Ok(Err(e)) => (StatusCode::BAD_GATEWAY, format!("remote error: {e}")).into_response(), + Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "remote task failed").into_response(), + } +} + /// `GET /api/autotag/suggest` β€” heuristic folder-grouping suggestions for /// unfiled channels (see [`crate::autotag`]). Pure arithmetic over the /// in-memory library, so it's computed on demand rather than as a job. @@ -3033,6 +3073,12 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { // subscribe and the broadcast is lossy by design (subscribers that lag // get a Lagged error and just resubscribe). let (progress_tx, _initial_rx) = tokio::sync::broadcast::channel::(16); + // Build the federation peers from config before `config` is moved in. + let remotes: Vec> = config + .remotes + .iter() + .map(|r| std::sync::Arc::new(crate::remote::RemoteClient::new(r))) + .collect(); let state = Arc::new(WebState { library: Mutex::new(library), downloader: Mutex::new(downloader), @@ -3054,6 +3100,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { library_body_cache: Mutex::new(None), dedup: std::sync::Arc::new(DedupState::default()), feed_token, + remotes, }); // Broadcast progress snapshots to WebSocket subscribers. Ticks fast @@ -3169,6 +3216,8 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { .route("/api/maintenance/remove", post(post_maintenance_remove)) .route("/api/autotag/suggest", get(get_autotag_suggest)) .route("/api/autotag/apply", post(post_autotag_apply)) + .route("/api/remotes", get(get_remotes)) + .route("/api/remotes/:id/library", get(get_remote_library)) .route("/api/maintenance/dedup/scan", post(post_dedup_scan)) .route("/api/maintenance/dedup/status", get(get_dedup_status)) .route("/api/maintenance/repair/:id", post(post_maintenance_repair)) diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 1fe74ab..70aa7b4 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -74,6 +74,10 @@ .card-foot{padding:4px 8px 8px;display:flex;gap:6px} .card-foot button{font-size:11px;padding:4px 8px} .card-foot .play{background:var(--accent);border-color:var(--accent);color:#fff;flex:1} + /* Remote (federated) browsing is read-only: hide the download bar and every + per-card mutating action, leaving just Play. */ + .remote-mode .dl-new{display:none} + .remote-mode .card-foot button:not(.play){display:none} .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.85);display:flex;align-items:center;justify-content:center;z-index:100;padding:10px} .modal{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:14px;max-width:95vw;max-height:95vh;display:flex;flex-direction:column;gap:10px;width:100%;overflow:hidden} .modal-hdr{display:flex;align-items:center;gap:8px;flex-shrink:0} @@ -281,7 +285,36 @@ async function api(path,opts){const r=await fetch(path,opts);if(!r.ok)throw new // can short-circuit with 304 Not Modified when nothing has changed. Saves // megabytes of JSON for large libraries on every periodic refresh. let libraryEtag=null; +let remotes=[], remoteMode=null; // remoteMode = peer id when browsing a remote, else null +async function loadRemotes(){ + try{remotes=await(await api('/api/remotes')).json();}catch(e){remotes=[];} + renderSidebar(); +} +async function enterRemote(id){ + const r=remotes.find(x=>x.id===id);if(!r)return; + setStatus('Connecting to '+r.name+'…'); + try{ + const data=await(await api('/api/remotes/'+id+'/library')).json(); + remoteMode=id; + library=data.channels||[]; + librarySnapshotFolders=data.folders||[]; + channelUrls=library.map(ch=>ch.channel_url||null); + document.body.classList.add('remote-mode'); + resetViewSelectors();selected.clear();closeSidebar(); + renderSidebar();renderGrid(); + setStatus('Viewing '+r.name+' (read-only)'); + }catch(e){setStatus('Remote error: '+e.message);} +} +function exitRemote(){ + remoteMode=null; + document.body.classList.remove('remote-mode'); + libraryEtag=null; // force a fresh local fetch + resetViewSelectors();selected.clear();closeSidebar(); + loadLibrary(); + setStatus('Back to your library'); +} async function loadLibrary(){ + if(remoteMode!==null)return; // a remote is in view; don't clobber it with local polling try{ const opts=libraryEtag?{headers:{'If-None-Match':libraryEtag}}:{}; const r=await api('/api/library',opts); @@ -315,7 +348,14 @@ function renderSidebar(){ const bmkCount=allVids.filter(v=>v.bookmark).length; const waitCount=allVids.filter(v=>v.waiting).length; const anySmart=showFavourites||showBookmarks||showWaiting; - let h=``; + let h=''; + // Federation: peers to browse read-only, plus an exit row when in one. + if(remotes.length){ + h+=``; + if(remoteMode!==null)h+=`
← Back to my library
`; + remotes.forEach(r=>{h+=`
🌐 ${esc(r.name)}
`;}); + } + h+=``; if(contVids.length)h+=`
β–Ά Continue (${contVids.length})
`; if(hasDated)h+=`
πŸ•’ Recent additions
`; if(favCount)h+=`
β˜… Favourites (${favCount})
`; @@ -341,11 +381,14 @@ function renderSidebar(){ const pl=ch.playlists[pi]; s+=`
β”” ${esc(pl.name)} (${pl.videos.length})
`; } - s+=`
⬇ Check for new videos
`; - s+=`
βš™ Channel options…
`; - s+=`
πŸ“ Move to folder…
`; - const hasNote=!!channelNote(ch.platform,ch.name); - s+=`
πŸ“ ${hasNote?'Edit note…':'Add note…'}
`; + // Mutating sub-actions are local-only; hide them when browsing a remote. + if(remoteMode===null){ + s+=`
⬇ Check for new videos
`; + s+=`
βš™ Channel options…
`; + s+=`
πŸ“ Move to folder…
`; + const hasNote=!!channelNote(ch.platform,ch.name); + s+=`
πŸ“ ${hasNote?'Edit note…':'Add note…'}
`; + } } return s; }; @@ -2654,6 +2697,7 @@ loadFilterPresets(); renderSidebar(); renderGrid(); })(); +loadRemotes(); loadSourceNotice();