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
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