Federation: read-only browsing of a peer instance's library (3.5)
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 <noreply@anthropic.com>
This commit is contained in:
parent
d797f2a698
commit
033572d3bd
10 changed files with 899 additions and 13 deletions
|
|
@ -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
|
||||
|
|
|
|||
330
Cargo.lock
generated
330
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
21
ROADMAP.md
21
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
|
||||
|
||||
|
|
|
|||
145
src/app.rs
145
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<crate::autotag::GroupSuggestion>,
|
||||
// Federation (read-only remote libraries).
|
||||
remotes: Vec<std::sync::Arc<crate::remote::RemoteClient>>,
|
||||
/// Currently-selected peer index, if the Remotes screen is showing one.
|
||||
remote_selected: Option<usize>,
|
||||
/// Last fetched remote library; `None` until a peer is loaded.
|
||||
remote_library: Option<crate::remote::RemoteLibrary>,
|
||||
/// 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<Receiver<Result<crate::remote::RemoteLibrary, String>>>,
|
||||
stats_report: Option<crate::stats::StatsReport>,
|
||||
// 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<std::sync::Arc<crate::remote::RemoteClient>> = 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<usize> = None;
|
||||
let mut play_url: Option<String> = 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RemoteSection>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// `[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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ mod maintenance;
|
|||
mod platform;
|
||||
mod plex;
|
||||
mod pot_provider;
|
||||
mod remote;
|
||||
mod stats;
|
||||
mod theme;
|
||||
mod tray;
|
||||
|
|
|
|||
277
src/remote.rs
Normal file
277
src/remote.rs
Normal file
|
|
@ -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<String>,
|
||||
client: reqwest::blocking::Client,
|
||||
/// Cached read-only feed token (fetched once via `/api/feed-info`).
|
||||
feed_token: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub thumb_url: Option<String>,
|
||||
pub duration_secs: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct RemoteChannel {
|
||||
pub name: String,
|
||||
pub videos: Vec<RemoteVideo>,
|
||||
}
|
||||
|
||||
/// A peer's library flattened to channels → videos for read-only display.
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct RemoteLibrary {
|
||||
pub channels: Vec<RemoteChannel>,
|
||||
}
|
||||
|
||||
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<reqwest::blocking::Response, String> {
|
||||
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<String, String> {
|
||||
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<Value, String> {
|
||||
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<RemoteLibrary, String> {
|
||||
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<Value>>| {
|
||||
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");
|
||||
}
|
||||
}
|
||||
51
src/web.rs
51
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<std::sync::Arc<crate::remote::RemoteClient>>,
|
||||
}
|
||||
|
||||
/// 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<Arc<WebState>>) -> impl IntoRe
|
|||
Json(report)
|
||||
}
|
||||
|
||||
/// `GET /api/remotes` — list configured federation peers for the UI switcher.
|
||||
async fn get_remotes(State(state): State<Arc<WebState>>) -> 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<Arc<WebState>>,
|
||||
Path(id): Path<usize>,
|
||||
) -> 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::<String>(16);
|
||||
// Build the federation peers from config before `config` is moved in.
|
||||
let remotes: Vec<std::sync::Arc<crate::remote::RemoteClient>> = 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))
|
||||
|
|
|
|||
|
|
@ -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=`<div class="sidebar-label">Library</div>`;
|
||||
let h='';
|
||||
// Federation: peers to browse read-only, plus an exit row when in one.
|
||||
if(remotes.length){
|
||||
h+=`<div class="sidebar-label" style="display:flex;align-items:center;gap:6px">🌐 Remotes</div>`;
|
||||
if(remoteMode!==null)h+=`<div class="ch-item" onclick="exitRemote()">← Back to my library</div>`;
|
||||
remotes.forEach(r=>{h+=`<div class="ch-item${remoteMode===r.id?' active':''}" onclick="enterRemote(${r.id})" title="${esc(r.url)}">🌐 ${esc(r.name)}</div>`;});
|
||||
}
|
||||
h+=`<div class="sidebar-label">${remoteMode!==null?'Remote library (read-only)':'Library'}</div>`;
|
||||
if(contVids.length)h+=`<div class="ch-item${showContinue?' active':''}" onclick="setContinue()">▶ Continue (${contVids.length})</div>`;
|
||||
if(hasDated)h+=`<div class="ch-item${showRecent?' active':''}" onclick="setRecent()">🕒 Recent additions</div>`;
|
||||
if(favCount)h+=`<div class="ch-item${showFavourites?' active':''}" onclick="setFavourites()">★ Favourites (${favCount})</div>`;
|
||||
|
|
@ -341,11 +381,14 @@ function renderSidebar(){
|
|||
const pl=ch.playlists[pi];
|
||||
s+=`<div class="ch-sub${activePlaylistIdx===pi?' active':''}" onclick="setView(${i},${pi})">└ ${esc(pl.name)} (${pl.videos.length})</div>`;
|
||||
}
|
||||
s+=`<div class="ch-sub" style="color:var(--accent)" onclick="downloadChannelByIdx(${i})">⬇ Check for new videos</div>`;
|
||||
s+=`<div class="ch-sub" onclick="openChannelOptions(${i})">⚙ Channel options…</div>`;
|
||||
s+=`<div class="ch-sub" onclick="openMoveToFolder(${i})">📁 Move to folder…</div>`;
|
||||
const hasNote=!!channelNote(ch.platform,ch.name);
|
||||
s+=`<div class="ch-sub" onclick="editChannelNote(${i})"${hasNote?' style="color:var(--accent)"':''}>📝 ${hasNote?'Edit note…':'Add note…'}</div>`;
|
||||
// Mutating sub-actions are local-only; hide them when browsing a remote.
|
||||
if(remoteMode===null){
|
||||
s+=`<div class="ch-sub" style="color:var(--accent)" onclick="downloadChannelByIdx(${i})">⬇ Check for new videos</div>`;
|
||||
s+=`<div class="ch-sub" onclick="openChannelOptions(${i})">⚙ Channel options…</div>`;
|
||||
s+=`<div class="ch-sub" onclick="openMoveToFolder(${i})">📁 Move to folder…</div>`;
|
||||
const hasNote=!!channelNote(ch.platform,ch.name);
|
||||
s+=`<div class="ch-sub" onclick="editChannelNote(${i})"${hasNote?' style="color:var(--accent)"':''}>📝 ${hasNote?'Edit note…':'Add note…'}</div>`;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
};
|
||||
|
|
@ -2654,6 +2697,7 @@ loadFilterPresets();
|
|||
renderSidebar();
|
||||
renderGrid();
|
||||
})();
|
||||
loadRemotes();
|
||||
loadSourceNotice();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue