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
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");
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn perceptual_dedup_groups_reencodes() {
|
||||
if !have_curl() { eprintln!("skip: no curl"); return; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue