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:
Luna 2026-06-10 17:51:44 -07:00
parent d797f2a698
commit 033572d3bd
10 changed files with 899 additions and 13 deletions

View file

@ -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),
}
}
}

View file

@ -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(),
}
}
}

View file

@ -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
View 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");
}
}

View file

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

View file

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