Podcast / RSS feeds for the library (web, part 1)
Subscribe to the whole library or a single channel in any podcast/media app. - src/feed.rs: pure RSS 2.0 + iTunes-namespace renderer with absolute enclosure URLs, MIME-by-extension, HH:MM:SS durations, and a chrono-free RFC-2822 date (civil-date algorithm). Unit-tested (mime / escape / duration / date / render). - web.rs: GET /feed.xml (whole library, newest first, capped at 300) and GET /feed/:platform/:handle (one channel); /api/feed-info returns the token for the UI. Enclosure + thumbnail URLs are absolute (derived from Host + X-Forwarded-Proto) and carry the feed token. - Auth: a stable read-only `feed_token` (persisted setting) lets a tokenized `?token=` GET reach /feed*, /files, and /music-files even when a password is set — podcast clients can't do the cookie login. Scoped to reads of feeds + media; never /api mutations. - Web UI: a "📡 Podcast feed" section in Settings (library URL + Copy) and a "📡 Feed URL" button in each channel's options dialog. Integration tests cover the served RSS (enclosure path, MIME, pubDate), the channel feed + 404, and the full token gate (401 without token once a password is set, 200 with it, /files reachable with it). 127 tests pass. Desktop URL display follows. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4800a0fc22
commit
2692fa2c9e
5 changed files with 480 additions and 0 deletions
242
src/feed.rs
Normal file
242
src/feed.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
//! RSS 2.0 + iTunes podcast feed rendering for the library.
|
||||||
|
//!
|
||||||
|
//! Turns a set of archived videos into a podcast/media feed any podcast app
|
||||||
|
//! can subscribe to. Pure string-building — the web layer ([`crate::web`])
|
||||||
|
//! gathers the items from the live library and serves the result; everything
|
||||||
|
//! here is deterministic and unit-tested.
|
||||||
|
//!
|
||||||
|
//! Enclosure URLs are absolute (`scheme://host/files/...`) because podcast
|
||||||
|
//! clients fetch media out-of-band, with no notion of the page they came from.
|
||||||
|
|
||||||
|
/// One episode in the feed.
|
||||||
|
pub struct FeedItem {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
/// Absolute URL to the media file (the `/files/...` mount).
|
||||||
|
pub enclosure_url: String,
|
||||||
|
pub enclosure_len: u64,
|
||||||
|
pub enclosure_type: String,
|
||||||
|
/// Stable GUID — the yt-dlp video id.
|
||||||
|
pub guid: String,
|
||||||
|
/// RFC-2822 publication date.
|
||||||
|
pub pub_date: String,
|
||||||
|
/// `HH:MM:SS`, or empty when unknown.
|
||||||
|
pub duration: String,
|
||||||
|
/// Absolute thumbnail URL, or empty.
|
||||||
|
pub image_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed-level metadata wrapping the items.
|
||||||
|
pub struct Feed {
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
/// Absolute link back to the web UI.
|
||||||
|
pub link: String,
|
||||||
|
pub items: Vec<FeedItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MIME type for a media file, by extension. Falls back to a generic
|
||||||
|
/// `application/octet-stream` so an unknown container still produces a valid
|
||||||
|
/// enclosure.
|
||||||
|
pub fn mime_for(path: &std::path::Path) -> &'static str {
|
||||||
|
match path.extension().and_then(|e| e.to_str()).map(|s| s.to_ascii_lowercase()).as_deref() {
|
||||||
|
Some("mp4") | Some("m4v") => "video/mp4",
|
||||||
|
Some("mkv") => "video/x-matroska",
|
||||||
|
Some("webm") => "video/webm",
|
||||||
|
Some("mov") => "video/quicktime",
|
||||||
|
Some("avi") => "video/x-msvideo",
|
||||||
|
Some("ts") => "video/mp2t",
|
||||||
|
Some("m4a") => "audio/mp4",
|
||||||
|
Some("mp3") => "audio/mpeg",
|
||||||
|
Some("opus") | Some("ogg") => "audio/ogg",
|
||||||
|
Some("flac") => "audio/flac",
|
||||||
|
Some("wav") => "audio/wav",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Escape the five XML special characters for safe inclusion in element text
|
||||||
|
/// or attribute values.
|
||||||
|
pub fn xml_escape(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'&' => out.push_str("&"),
|
||||||
|
'<' => out.push_str("<"),
|
||||||
|
'>' => out.push_str(">"),
|
||||||
|
'"' => out.push_str("""),
|
||||||
|
'\'' => out.push_str("'"),
|
||||||
|
// Strip control chars that are illegal in XML 1.0 (e.g. stray
|
||||||
|
// bytes from a mangled title), keeping tab/newline/return.
|
||||||
|
c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {}
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a duration in seconds as `H:MM:SS` (or `M:SS`). Empty for `None`.
|
||||||
|
pub fn fmt_duration(secs: Option<f64>) -> String {
|
||||||
|
let Some(s) = secs else { return String::new() };
|
||||||
|
if !s.is_finite() || s < 0.0 { return String::new(); }
|
||||||
|
let total = s as u64;
|
||||||
|
let (h, m, sec) = (total / 3600, (total % 3600) / 60, total % 60);
|
||||||
|
if h > 0 { format!("{h}:{m:02}:{sec:02}") } else { format!("{m}:{sec:02}") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert yt-dlp's `YYYYMMDD` upload date (or a UNIX mtime fallback) into an
|
||||||
|
/// RFC-2822 date string for `<pubDate>`. Defaults to the Unix epoch when both
|
||||||
|
/// are missing so every item still has a valid, stable date.
|
||||||
|
pub fn pub_date(upload_date: Option<&str>, mtime_unix: Option<u64>) -> String {
|
||||||
|
if let Some(d) = upload_date {
|
||||||
|
if d.len() == 8 && d.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
// Treat as midnight UTC on that calendar day.
|
||||||
|
if let Ok(days) = ymd_to_unix_days(&d[0..4], &d[4..6], &d[6..8]) {
|
||||||
|
return rfc2822(days * 86_400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rfc2822(mtime_unix.unwrap_or(0) as i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Days since the Unix epoch for a Y/M/D, via a standard civil-date algorithm
|
||||||
|
/// (Howard Hinnant's `days_from_civil`). Avoids a chrono dependency.
|
||||||
|
fn ymd_to_unix_days(y: &str, m: &str, d: &str) -> Result<i64, ()> {
|
||||||
|
let y: i64 = y.parse().map_err(|_| ())?;
|
||||||
|
let m: i64 = m.parse().map_err(|_| ())?;
|
||||||
|
let d: i64 = d.parse().map_err(|_| ())?;
|
||||||
|
if !(1..=12).contains(&m) || !(1..=31).contains(&d) { return Err(()); }
|
||||||
|
let y = if m <= 2 { y - 1 } else { y };
|
||||||
|
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||||
|
let yoe = (y - era * 400) as i64;
|
||||||
|
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||||
|
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||||
|
Ok(era * 146_097 + doe - 719_468)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a UNIX timestamp as an RFC-2822 date in GMT (e.g.
|
||||||
|
/// `Sun, 07 Jun 2026 00:00:00 GMT`). Self-contained, no chrono.
|
||||||
|
fn rfc2822(ts: i64) -> String {
|
||||||
|
const WD: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"]; // epoch = Thursday
|
||||||
|
const MON: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
let days = ts.div_euclid(86_400);
|
||||||
|
let secs = ts.rem_euclid(86_400);
|
||||||
|
let (h, mi, s) = (secs / 3600, (secs % 3600) / 60, secs % 60);
|
||||||
|
let wd = WD[(days.rem_euclid(7)) as usize];
|
||||||
|
let (y, m, d) = civil_from_days(days);
|
||||||
|
format!("{wd}, {d:02} {mon} {y:04} {h:02}:{mi:02}:{s:02} GMT", mon = MON[(m - 1) as usize])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inverse of [`ymd_to_unix_days`] — civil date from days-since-epoch.
|
||||||
|
fn civil_from_days(z: i64) -> (i64, i64, i64) {
|
||||||
|
let z = z + 719_468;
|
||||||
|
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||||
|
let doe = z - era * 146_097;
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the feed as an RSS 2.0 document with the iTunes podcast namespace.
|
||||||
|
pub fn render(feed: &Feed) -> String {
|
||||||
|
let mut s = String::with_capacity(1024 + feed.items.len() * 512);
|
||||||
|
s.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
|
||||||
|
s.push('\n');
|
||||||
|
s.push_str(r#"<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">"#);
|
||||||
|
s.push_str("\n<channel>\n");
|
||||||
|
s.push_str(&format!("<title>{}</title>\n", xml_escape(&feed.title)));
|
||||||
|
s.push_str(&format!("<link>{}</link>\n", xml_escape(&feed.link)));
|
||||||
|
s.push_str(&format!("<description>{}</description>\n", xml_escape(&feed.description)));
|
||||||
|
s.push_str("<language>en</language>\n");
|
||||||
|
s.push_str("<itunes:explicit>no</itunes:explicit>\n");
|
||||||
|
for it in &feed.items {
|
||||||
|
s.push_str("<item>\n");
|
||||||
|
s.push_str(&format!("<title>{}</title>\n", xml_escape(&it.title)));
|
||||||
|
s.push_str(&format!("<description>{}</description>\n", xml_escape(&it.description)));
|
||||||
|
s.push_str(&format!(
|
||||||
|
"<enclosure url=\"{}\" length=\"{}\" type=\"{}\"/>\n",
|
||||||
|
xml_escape(&it.enclosure_url), it.enclosure_len, xml_escape(&it.enclosure_type),
|
||||||
|
));
|
||||||
|
s.push_str(&format!("<guid isPermaLink=\"false\">{}</guid>\n", xml_escape(&it.guid)));
|
||||||
|
s.push_str(&format!("<pubDate>{}</pubDate>\n", xml_escape(&it.pub_date)));
|
||||||
|
if !it.duration.is_empty() {
|
||||||
|
s.push_str(&format!("<itunes:duration>{}</itunes:duration>\n", xml_escape(&it.duration)));
|
||||||
|
}
|
||||||
|
if !it.image_url.is_empty() {
|
||||||
|
s.push_str(&format!("<itunes:image href=\"{}\"/>\n", xml_escape(&it.image_url)));
|
||||||
|
}
|
||||||
|
s.push_str("</item>\n");
|
||||||
|
}
|
||||||
|
s.push_str("</channel>\n</rss>\n");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mime_by_extension() {
|
||||||
|
assert_eq!(mime_for(Path::new("a.mp4")), "video/mp4");
|
||||||
|
assert_eq!(mime_for(Path::new("a.MKV")), "video/x-matroska");
|
||||||
|
assert_eq!(mime_for(Path::new("a.opus")), "audio/ogg");
|
||||||
|
assert_eq!(mime_for(Path::new("a.weird")), "application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn escapes_xml() {
|
||||||
|
assert_eq!(xml_escape("a & b <c> \"d\" 'e'"), "a & b <c> "d" 'e'");
|
||||||
|
assert_eq!(xml_escape("tab\tkeep\u{0}drop"), "tab\tkeepdrop");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duration_formats() {
|
||||||
|
assert_eq!(fmt_duration(Some(65.0)), "1:05");
|
||||||
|
assert_eq!(fmt_duration(Some(3661.0)), "1:01:01");
|
||||||
|
assert_eq!(fmt_duration(None), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upload_date_to_rfc2822() {
|
||||||
|
// 2024-01-02 → a Tuesday.
|
||||||
|
assert_eq!(pub_date(Some("20240102"), None), "Tue, 02 Jan 2024 00:00:00 GMT");
|
||||||
|
// The Unix epoch is a Thursday.
|
||||||
|
assert_eq!(pub_date(None, Some(0)), "Thu, 01 Jan 1970 00:00:00 GMT");
|
||||||
|
// Garbage upload date falls back to the mtime.
|
||||||
|
assert_eq!(pub_date(Some("notadate"), Some(0)), "Thu, 01 Jan 1970 00:00:00 GMT");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_valid_rss() {
|
||||||
|
let feed = Feed {
|
||||||
|
title: "yt-offline — Demo & co".into(),
|
||||||
|
description: "archive".into(),
|
||||||
|
link: "http://host/".into(),
|
||||||
|
items: vec![FeedItem {
|
||||||
|
title: "Episode <1>".into(),
|
||||||
|
description: "desc".into(),
|
||||||
|
enclosure_url: "http://host/files/channels/Demo/a.mkv".into(),
|
||||||
|
enclosure_len: 12345,
|
||||||
|
enclosure_type: "video/x-matroska".into(),
|
||||||
|
guid: "vidABC".into(),
|
||||||
|
pub_date: "Tue, 02 Jan 2024 00:00:00 GMT".into(),
|
||||||
|
duration: "1:05".into(),
|
||||||
|
image_url: "http://host/files/channels/Demo/a.webp".into(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let xml = render(&feed);
|
||||||
|
assert!(xml.starts_with("<?xml"));
|
||||||
|
assert!(xml.contains("<title>yt-offline — Demo & co</title>"));
|
||||||
|
assert!(xml.contains("<title>Episode <1></title>"));
|
||||||
|
assert!(xml.contains(r#"<enclosure url="http://host/files/channels/Demo/a.mkv" length="12345" type="video/x-matroska"/>"#));
|
||||||
|
assert!(xml.contains("<itunes:duration>1:05</itunes:duration>"));
|
||||||
|
assert!(xml.contains(r#"<guid isPermaLink="false">vidABC</guid>"#));
|
||||||
|
assert!(xml.trim_end().ends_with("</rss>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ mod disk_space;
|
||||||
mod download_options;
|
mod download_options;
|
||||||
mod downloader;
|
mod downloader;
|
||||||
mod error_class;
|
mod error_class;
|
||||||
|
mod feed;
|
||||||
mod fingerprint;
|
mod fingerprint;
|
||||||
mod library;
|
mod library;
|
||||||
mod maintenance;
|
mod maintenance;
|
||||||
|
|
|
||||||
147
src/web.rs
147
src/web.rs
|
|
@ -138,6 +138,11 @@ pub struct WebState {
|
||||||
/// so `/api/maintenance/dedup/status` can be polled. `Arc` so the worker
|
/// so `/api/maintenance/dedup/status` can be polled. `Arc` so the worker
|
||||||
/// thread can hold a handle past the request that started it.
|
/// thread can hold a handle past the request that started it.
|
||||||
pub dedup: std::sync::Arc<DedupState>,
|
pub dedup: std::sync::Arc<DedupState>,
|
||||||
|
/// Read-only capability token for the podcast/RSS feeds. Podcast clients
|
||||||
|
/// can't do the cookie login, so a tokenized feed URL (`?token=…`) grants
|
||||||
|
/// 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Live state for the background perceptual-dedup job (see
|
/// Live state for the background perceptual-dedup job (see
|
||||||
|
|
@ -1079,6 +1084,16 @@ async fn auth_middleware(
|
||||||
if is_authed(&state, req.headers()) {
|
if is_authed(&state, req.headers()) {
|
||||||
return next.run(req).await;
|
return next.run(req).await;
|
||||||
}
|
}
|
||||||
|
// Podcast clients can't perform the cookie login, so a tokenized GET to a
|
||||||
|
// feed or the media mounts is allowed when the query carries the
|
||||||
|
// 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/"))
|
||||||
|
&& req.uri().query().is_some_and(|q| query_has_token(q, &state.feed_token))
|
||||||
|
{
|
||||||
|
return next.run(req).await;
|
||||||
|
}
|
||||||
if path == "/" {
|
if path == "/" {
|
||||||
return (
|
return (
|
||||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
||||||
|
|
@ -1089,6 +1104,15 @@ async fn auth_middleware(
|
||||||
(StatusCode::UNAUTHORIZED, "authentication required").into_response()
|
(StatusCode::UNAUTHORIZED, "authentication required").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the query string carries `token=<expected>` (the feed capability
|
||||||
|
/// token). Empty `expected` never matches.
|
||||||
|
fn query_has_token(query: &str, expected: &str) -> bool {
|
||||||
|
if expected.is_empty() { return false; }
|
||||||
|
query.split('&').any(|kv| {
|
||||||
|
kv.strip_prefix("token=").is_some_and(|v| v == expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Handlers ──────────────────────────────────────────────────────────────────
|
// ── Handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn get_index() -> impl IntoResponse {
|
async fn get_index() -> impl IntoResponse {
|
||||||
|
|
@ -2013,6 +2037,118 @@ struct RemoveRequest {
|
||||||
paths: Vec<PathBuf>,
|
paths: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Newest-first cap on items in a feed — enough for any podcast client's
|
||||||
|
/// back-catalogue without rendering a multi-thousand-item document.
|
||||||
|
const FEED_LIMIT: usize = 300;
|
||||||
|
|
||||||
|
/// Absolute origin (`scheme://host`) of the request, honoring a reverse
|
||||||
|
/// proxy's `X-Forwarded-Proto`. Feed enclosure URLs must be absolute.
|
||||||
|
fn request_base_url(headers: &HeaderMap) -> String {
|
||||||
|
let host = headers.get(header::HOST).and_then(|h| h.to_str().ok()).unwrap_or("localhost");
|
||||||
|
let scheme = headers.get("x-forwarded-proto").and_then(|h| h.to_str().ok())
|
||||||
|
.map(|s| s.split(',').next().unwrap_or(s).trim())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or("http");
|
||||||
|
format!("{scheme}://{host}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build podcast items from videos (newest first, capped). Each item's media +
|
||||||
|
/// thumbnail get absolute, token-bearing `/files/` URLs so a podcast client
|
||||||
|
/// can fetch them even behind a password.
|
||||||
|
fn build_feed_items(
|
||||||
|
mut vids: Vec<&library::Video>, root: &StdPath, base: &str, token: &str, limit: usize,
|
||||||
|
) -> Vec<crate::feed::FeedItem> {
|
||||||
|
vids.retain(|v| v.video_path.is_some());
|
||||||
|
vids.sort_by(|a, b| {
|
||||||
|
b.upload_date.as_deref().unwrap_or("").cmp(a.upload_date.as_deref().unwrap_or(""))
|
||||||
|
.then(b.mtime_unix.unwrap_or(0).cmp(&a.mtime_unix.unwrap_or(0)))
|
||||||
|
});
|
||||||
|
vids.truncate(limit);
|
||||||
|
let tok = if token.is_empty() { String::new() } else { format!("?token={token}") };
|
||||||
|
vids.into_iter().filter_map(|v| {
|
||||||
|
let vp = v.video_path.as_ref()?;
|
||||||
|
let rel = file_url(root, vp)?;
|
||||||
|
let image_url = v.thumb_path.as_ref()
|
||||||
|
.and_then(|t| file_url(root, t))
|
||||||
|
.map(|r| format!("{base}{r}{tok}"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
// A short description excerpt (best-effort; the media is the point).
|
||||||
|
let description = v.description_path.as_ref()
|
||||||
|
.and_then(|p| std::fs::read_to_string(p).ok())
|
||||||
|
.map(|s| s.chars().take(800).collect::<String>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
Some(crate::feed::FeedItem {
|
||||||
|
title: v.title.clone(),
|
||||||
|
description,
|
||||||
|
enclosure_url: format!("{base}{rel}{tok}"),
|
||||||
|
enclosure_len: v.file_size.unwrap_or(0),
|
||||||
|
enclosure_type: crate::feed::mime_for(vp).to_string(),
|
||||||
|
guid: v.id.clone(),
|
||||||
|
pub_date: crate::feed::pub_date(v.upload_date.as_deref(), v.mtime_unix),
|
||||||
|
duration: crate::feed::fmt_duration(v.duration_secs),
|
||||||
|
image_url,
|
||||||
|
})
|
||||||
|
}).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed_response(xml: String) -> Response {
|
||||||
|
(
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/rss+xml; charset=utf-8"),
|
||||||
|
(header::CACHE_CONTROL, "no-cache"),
|
||||||
|
],
|
||||||
|
xml,
|
||||||
|
).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /feed.xml` — the whole library as one podcast feed, newest first.
|
||||||
|
async fn get_feed_all(State(state): State<Arc<WebState>>, headers: HeaderMap) -> Response {
|
||||||
|
let base = request_base_url(&headers);
|
||||||
|
let lib = state.library.lock_recover();
|
||||||
|
let root = state.library_root.as_path();
|
||||||
|
let all: Vec<&library::Video> = lib.iter()
|
||||||
|
.flat_map(|c| c.videos.iter().chain(c.playlists.iter().flat_map(|p| p.videos.iter())))
|
||||||
|
.collect();
|
||||||
|
let items = build_feed_items(all, root, &base, &state.feed_token, FEED_LIMIT);
|
||||||
|
let feed = crate::feed::Feed {
|
||||||
|
title: "yt-offline — Library".into(),
|
||||||
|
description: "Your archived videos as a podcast feed.".into(),
|
||||||
|
link: format!("{base}/"),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
feed_response(crate::feed::render(&feed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /feed/:platform/:handle` — one channel's videos as a podcast feed.
|
||||||
|
async fn get_feed_channel(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path((platform, handle)): Path<(String, String)>,
|
||||||
|
) -> Response {
|
||||||
|
let base = request_base_url(&headers);
|
||||||
|
let lib = state.library.lock_recover();
|
||||||
|
let root = state.library_root.as_path();
|
||||||
|
let Some(ch) = lib.iter().find(|c| c.platform.dir_name() == platform && c.name == handle) else {
|
||||||
|
return (StatusCode::NOT_FOUND, "no such channel").into_response();
|
||||||
|
};
|
||||||
|
let vids: Vec<&library::Video> = ch.videos.iter()
|
||||||
|
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter())).collect();
|
||||||
|
let items = build_feed_items(vids, root, &base, &state.feed_token, FEED_LIMIT);
|
||||||
|
let feed = crate::feed::Feed {
|
||||||
|
title: format!("yt-offline — {}", ch.name),
|
||||||
|
description: format!("Archived videos from {}.", ch.name),
|
||||||
|
link: format!("{base}/"),
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
feed_response(crate::feed::render(&feed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/feed-info` — the read-only feed token, so the (authed) UI can
|
||||||
|
/// build tokenized feed URLs to copy.
|
||||||
|
async fn get_feed_info(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
||||||
|
Json(serde_json::json!({ "token": state.feed_token }))
|
||||||
|
}
|
||||||
|
|
||||||
/// `POST /api/maintenance/dedup/scan` — start (or no-op if already running)
|
/// `POST /api/maintenance/dedup/scan` — start (or no-op if already running)
|
||||||
/// the background perceptual-dedup pass. Fingerprints any new/changed videos
|
/// the background perceptual-dedup pass. Fingerprints any new/changed videos
|
||||||
/// (cached by mtime), then groups by visual similarity. Returns immediately;
|
/// (cached by mtime), then groups by visual similarity. Returns immediately;
|
||||||
|
|
@ -2727,6 +2863,13 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
|
|
||||||
let password_required_initial = db.get_setting("password_hash").ok().flatten().is_some();
|
let password_required_initial = db.get_setting("password_hash").ok().flatten().is_some();
|
||||||
let flags = db.get_video_flags().unwrap_or_default();
|
let flags = db.get_video_flags().unwrap_or_default();
|
||||||
|
// Stable read-only feed token: load the stored one, or mint + persist a new
|
||||||
|
// one on first run so the same URL keeps working across restarts.
|
||||||
|
let feed_token = db.get_setting("feed_token").ok().flatten().unwrap_or_else(|| {
|
||||||
|
let t = generate_session_token();
|
||||||
|
let _ = db.set_setting("feed_token", Some(&t));
|
||||||
|
t
|
||||||
|
});
|
||||||
// Capacity 16 is plenty — only a small number of browser tabs ever
|
// Capacity 16 is plenty — only a small number of browser tabs ever
|
||||||
// subscribe and the broadcast is lossy by design (subscribers that lag
|
// subscribe and the broadcast is lossy by design (subscribers that lag
|
||||||
// get a Lagged error and just resubscribe).
|
// get a Lagged error and just resubscribe).
|
||||||
|
|
@ -2751,6 +2894,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
login_attempts: Mutex::new(HashMap::new()),
|
login_attempts: Mutex::new(HashMap::new()),
|
||||||
library_body_cache: Mutex::new(None),
|
library_body_cache: Mutex::new(None),
|
||||||
dedup: std::sync::Arc::new(DedupState::default()),
|
dedup: std::sync::Arc::new(DedupState::default()),
|
||||||
|
feed_token,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Broadcast progress snapshots to WebSocket subscribers. Ticks fast
|
// Broadcast progress snapshots to WebSocket subscribers. Ticks fast
|
||||||
|
|
@ -2854,6 +2998,9 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
|
||||||
.route("/api/chapters/:id", get(get_chapters))
|
.route("/api/chapters/:id", get(get_chapters))
|
||||||
.route("/api/comments/:id", get(get_comments))
|
.route("/api/comments/:id", get(get_comments))
|
||||||
.route("/api/search", get(get_search))
|
.route("/api/search", get(get_search))
|
||||||
|
.route("/api/feed-info", get(get_feed_info))
|
||||||
|
.route("/feed.xml", get(get_feed_all))
|
||||||
|
.route("/feed/:platform/:handle", get(get_feed_channel))
|
||||||
.route("/api/metadata/:id", get(get_metadata))
|
.route("/api/metadata/:id", get(get_metadata))
|
||||||
.route("/api/settings", get(get_settings).post(post_settings))
|
.route("/api/settings", get(get_settings).post(post_settings))
|
||||||
.route("/api/transcode/:id", get(get_transcode))
|
.route("/api/transcode/:id", get(get_transcode))
|
||||||
|
|
|
||||||
|
|
@ -1041,6 +1041,7 @@ async function openChannelOptions(idx){
|
||||||
<textarea id="opt-extra" rows="3" placeholder="--no-mtime
|
<textarea id="opt-extra" rows="3" placeholder="--no-mtime
|
||||||
--ignore-config" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-family:monospace;font-size:12px">${esc(extras)}</textarea></div>
|
--ignore-config" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-family:monospace;font-size:12px">${esc(extras)}</textarea></div>
|
||||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
|
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
|
||||||
|
<button onclick="copyChannelFeed('${esc(platform)}','${esc(handle)}',this)" title="Copy this channel's podcast feed URL" style="margin-right:auto">📡 Feed URL</button>
|
||||||
<button onclick="clearChannelOptions('${esc(platform)}','${esc(handle)}',this)">Clear all</button>
|
<button onclick="clearChannelOptions('${esc(platform)}','${esc(handle)}',this)">Clear all</button>
|
||||||
<button onclick="this.closest('.modal-bg').remove()">Cancel</button>
|
<button onclick="this.closest('.modal-bg').remove()">Cancel</button>
|
||||||
<button class="primary" onclick="saveChannelOptions('${esc(platform)}','${esc(handle)}',this)">Save</button>
|
<button class="primary" onclick="saveChannelOptions('${esc(platform)}','${esc(handle)}',this)">Save</button>
|
||||||
|
|
@ -1508,6 +1509,24 @@ function renderComments(){
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Podcast feed URLs ──────────────────────────────────────────── */
|
||||||
|
function copyFeedUrl(id,btn){
|
||||||
|
const el=document.getElementById(id);if(!el||!el.value)return;
|
||||||
|
el.select();
|
||||||
|
const done=()=>{if(btn){const t=btn.textContent;btn.textContent='✓ Copied';setTimeout(()=>btn.textContent=t,1200)}};
|
||||||
|
if(navigator.clipboard)navigator.clipboard.writeText(el.value).then(done).catch(()=>{document.execCommand('copy');done()});
|
||||||
|
else{document.execCommand('copy');done()}
|
||||||
|
}
|
||||||
|
async function copyChannelFeed(platform,handle,btn){
|
||||||
|
try{
|
||||||
|
const {token}=await(await api('/api/feed-info')).json();
|
||||||
|
const url=`${location.origin}/feed/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}?token=${token}`;
|
||||||
|
if(navigator.clipboard)await navigator.clipboard.writeText(url);
|
||||||
|
if(btn){const t=btn.textContent;btn.textContent='✓ Copied';setTimeout(()=>btn.textContent=t,1200)}
|
||||||
|
setStatus('Channel feed URL copied to clipboard');
|
||||||
|
}catch(e){setStatus('Error: '+e.message)}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Full-text library search ───────────────────────────────────── */
|
/* ── Full-text library search ───────────────────────────────────── */
|
||||||
let ftsSeq=0;
|
let ftsSeq=0;
|
||||||
function openSearch(){
|
function openSearch(){
|
||||||
|
|
@ -1665,6 +1684,15 @@ async function openSettings(){
|
||||||
}else if(ck.exists&&ck.days_left!=null&&ck.days_left<=3){
|
}else if(ck.exists&&ck.days_left!=null&&ck.days_left<=3){
|
||||||
cookieWarn=`<div class="settings-hint" style="color:#facc15">⚠ Login cookies expire in ${ck.days_left}d. Refresh soon to avoid captcha errors.</div>`;
|
cookieWarn=`<div class="settings-hint" style="color:#facc15">⚠ Login cookies expire in ${ck.days_left}d. Refresh soon to avoid captcha errors.</div>`;
|
||||||
}
|
}
|
||||||
|
let feed={token:''};try{feed=await(await api('/api/feed-info')).json()}catch{}
|
||||||
|
const feedUrl=feed.token?`${location.origin}/feed.xml?token=${feed.token}`:'';
|
||||||
|
const feedRow=`<hr style="border-color:var(--border);margin:12px 0">
|
||||||
|
<div style="font-weight:700;margin-bottom:8px">📡 Podcast feed</div>
|
||||||
|
<div class="settings-hint" style="margin-bottom:6px">Subscribe to your whole library in any podcast / media app. The token in the URL grants read-only access to the feed and its media — so it works even with a password set. Treat it like a share link.</div>
|
||||||
|
<div class="settings-row" style="gap:6px">
|
||||||
|
<input type="text" id="cf-feed-url" readonly value="${esc(feedUrl)}" onclick="this.select()" style="flex:1;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-size:12px">
|
||||||
|
<button onclick="copyFeedUrl('cf-feed-url',this)">Copy</button>
|
||||||
|
</div>`;
|
||||||
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
|
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
|
||||||
bg.innerHTML=`<div class="modal" style="max-width:420px;overflow-y:auto">
|
bg.innerHTML=`<div class="modal" style="max-width:420px;overflow-y:auto">
|
||||||
<div class="modal-hdr" style="position:sticky;top:0;background:var(--panel);z-index:1;padding-bottom:6px"><h2>Settings</h2></div>
|
<div class="modal-hdr" style="position:sticky;top:0;background:var(--panel);z-index:1;padding-bottom:6px"><h2>Settings</h2></div>
|
||||||
|
|
@ -1688,6 +1716,7 @@ async function openSettings(){
|
||||||
<div class="settings-hint">Gates the whole UI and all API access. Leave empty to disable on save; changing it logs out other sessions.</div>
|
<div class="settings-hint">Gates the whole UI and all API access. Leave empty to disable on save; changing it logs out other sessions.</div>
|
||||||
</div>
|
</div>
|
||||||
${bindRows}
|
${bindRows}
|
||||||
|
${feedRow}
|
||||||
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
|
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
|
||||||
<label>Cookies (cookies.txt)</label>
|
<label>Cookies (cookies.txt)</label>
|
||||||
<div class="settings-hint" id="cookies-status">${cookiesStatus}</div>
|
<div class="settings-hint" id="cookies-status">${cookiesStatus}</div>
|
||||||
|
|
|
||||||
61
tests/api.rs
61
tests/api.rs
|
|
@ -367,6 +367,67 @@ fn full_text_search_indexes_titles_and_descriptions() {
|
||||||
"unrelated query must not hit");
|
"unrelated query must not hit");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn podcast_feed_serves_rss_with_enclosures() {
|
||||||
|
if !have_curl() { eprintln!("skip: no curl"); return; }
|
||||||
|
let s = Server::start();
|
||||||
|
let chan = s.dir.join("ch/channels/Demo");
|
||||||
|
std::fs::create_dir_all(&chan).unwrap();
|
||||||
|
std::fs::write(chan.join("Cool Talk [vidXYZ].mp4"), b"fakevideo").unwrap();
|
||||||
|
std::fs::write(chan.join("Cool Talk [vidXYZ].info.json"),
|
||||||
|
br#"{"duration":125.0,"upload_date":"20240102"}"#).unwrap();
|
||||||
|
assert_eq!(s.post("/api/rescan", "").0, 200);
|
||||||
|
|
||||||
|
let (code, body) = s.get("/feed.xml");
|
||||||
|
assert_eq!(code, 200, "{body}");
|
||||||
|
assert!(body.contains("<rss"), "is RSS: {body}");
|
||||||
|
assert!(body.contains("<title>Cool Talk</title>"), "item title present: {body}");
|
||||||
|
assert!(body.contains("<enclosure"), "has an enclosure: {body}");
|
||||||
|
assert!(body.contains("/files/channels/Demo/Cool%20Talk%20%5BvidXYZ%5D.mp4"),
|
||||||
|
"enclosure points at the media file: {body}");
|
||||||
|
assert!(body.contains(r#"type="video/mp4""#), "correct MIME: {body}");
|
||||||
|
assert!(body.contains("Tue, 02 Jan 2024"), "pubDate from upload_date: {body}");
|
||||||
|
// The channel feed works too.
|
||||||
|
assert_eq!(s.get("/feed/channels/Demo").0, 200);
|
||||||
|
// Unknown channel → 404.
|
||||||
|
assert_eq!(s.get("/feed/channels/Nope").0, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn podcast_feed_token_gates_access_when_password_set() {
|
||||||
|
if !have_curl() { eprintln!("skip: no curl"); return; }
|
||||||
|
let s = Server::start();
|
||||||
|
let chan = s.dir.join("ch/channels/Demo");
|
||||||
|
std::fs::create_dir_all(&chan).unwrap();
|
||||||
|
std::fs::write(chan.join("Talk [vidT].mp4"), b"x").unwrap();
|
||||||
|
assert_eq!(s.post("/api/rescan", "").0, 200);
|
||||||
|
|
||||||
|
// Grab the feed token (UI is unauthenticated until a password is set).
|
||||||
|
let (_, info) = s.get("/api/feed-info");
|
||||||
|
let token = field(&info, "token").expect("feed token").to_string();
|
||||||
|
assert!(!token.is_empty());
|
||||||
|
|
||||||
|
// Set a password — now the UI + API require auth.
|
||||||
|
let (code, _) = s.post("/api/settings", &format!(
|
||||||
|
r#"{{"transcode":false,"scheduler_enabled":false,"scheduler_interval_hours":24,
|
||||||
|
"max_concurrent":3,"use_bundled_ytdlp":false,"use_pot_provider":false,
|
||||||
|
"subtitles_enabled":false,"subtitles_auto":false,"subtitles_embed":false,
|
||||||
|
"subtitle_langs":"","subtitle_format":"","youtube_player_clients":"",
|
||||||
|
"sponsorblock_mode":"mark","convert_mode":"","convert_crf":23,"convert_preset":"",
|
||||||
|
"convert_audio_format":"","convert_keep_original":false,
|
||||||
|
"new_download_password":"hunter2"}}"#));
|
||||||
|
assert_eq!(code, 200);
|
||||||
|
|
||||||
|
// Feed without the token is now rejected…
|
||||||
|
assert_eq!(s.get("/feed.xml").0, 401, "feed must be gated once a password is set");
|
||||||
|
// …but the tokenized URL still works (a podcast client can't log in).
|
||||||
|
assert_eq!(s.get(&format!("/feed.xml?token={token}")).0, 200);
|
||||||
|
// A wrong token is rejected.
|
||||||
|
assert_eq!(s.get("/feed.xml?token=bogus").0, 401);
|
||||||
|
// The media mount is reachable with the token too (so enclosures load).
|
||||||
|
assert_eq!(s.get(&format!("/files/channels/Demo/Talk%20%5BvidT%5D.mp4?token={token}")).0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn perceptual_dedup_groups_reencodes() {
|
fn perceptual_dedup_groups_reencodes() {
|
||||||
if !have_curl() { eprintln!("skip: no curl"); return; }
|
if !have_curl() { eprintln!("skip: no curl"); return; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue