diff --git a/Cargo.lock b/Cargo.lock index fd9d8e8..da92361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,7 +187,7 @@ dependencies = [ "chrono", "derive_builder", "diligent-date-parser", - "quick-xml", + "quick-xml 0.41.0", ] [[package]] @@ -1568,6 +1568,7 @@ dependencies = [ "librqbit", "opml", "percent-encoding", + "quick-xml 0.42.0", "reqwest", "rss", "rusqlite", @@ -2037,7 +2038,7 @@ dependencies = [ "httparse", "librqbit-dualstack-sockets", "network-interface", - "quick-xml", + "quick-xml 0.41.0", "reqwest", "serde", "serde_derive", @@ -2537,6 +2538,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -2858,7 +2868,7 @@ checksum = "f505d3e5e7b06b4dc0245b13294f8ef9a1a0f70284708be1e11c5b7b7441034e" dependencies = [ "atom_syndication", "derive_builder", - "quick-xml", + "quick-xml 0.41.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e1bb06c..c98620e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ infer = "0.22.0" librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] } opml = "1.1.6" percent-encoding = "2.3.2" +quick-xml = "0.42.0" reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] } rss = "2.1.1" rusqlite = { version = "0.40.2", features = ["bundled"] } diff --git a/PROGRESS.md b/PROGRESS.md index a917748..0273a5b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -56,6 +56,35 @@ and until now nothing set them. --- +## 2026-09-10 — Multiple enclosures per item, and viewing without downloading + +**View without downloading.** A non-audio/video enclosure now carries a View link opening in a new +tab: the publisher's own URL when nothing is downloaded, the local copy at `/media/` when it is. +Deliberately a direct link rather than a proxy — relaying arbitrary URLs through the daemon would +make it a fetch-anything service. + +**Multiple enclosures.** Probed before assuming, and the result was worse than expected: the `rss` +crate models an item as having at most one enclosure (which is what RSS 2.0 says) and when a feed +carries several it silently keeps **the last**, dropping the rest. So a two-file item lost its first +file entirely. + +`enclosures_by_item()` reads them straight from the XML with quick-xml, in document order, +unescaping attribute values — a feed URL's `&` arrives as `&`, so skipping that would corrupt +every query string. It falls back to the parsed enclosure if the scan and the parser disagree on +item count. Atom already collected all `rel="enclosure"` links. The row now summarises the enclosure +you would act on (playable, else downloaded, else first) and says "+N more files"; the pane below +lists them all. + +**A real limitation surfaced by a broken fixture.** Two browser tests failed with zero enclosures in +the detail pane. Not the new scanner — verified by running it against the fixture files directly, +which was right every time. The cause: my fixtures pointed two feeds at the *same* enclosure URL, +and `enclosures.url` is UNIQUE across the whole database, so whichever feed is scanned first claims +it and the other's entry gets nothing. That is the dedupe working as designed, but it means **two +feeds legitimately sharing a media URL will only ever show it under one of them** — worth knowing, +and worth revisiting if a network feed and a show feed ever overlap. + +--- + ## 2026-09-10 — A file is not the same as a playable file Reported: "Abort Retry Fail still shows downloaded and audio playback UI". The media-type filter diff --git a/README.md b/README.md index 9809172..2289550 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,10 @@ its enclosures in the pane below, which is where you play, download or delete th between the two panes drags and the position is remembered. An OPML subscription's page instead lists the feeds inside it. +An item may carry several enclosures. All of them appear in the pane below; the row summarises the +one you would act on and notes how many others there are. Anything that is not audio or video gets a +View link — opening the publisher's copy, or the local one once downloaded — rather than a player. + ## Log view The **Log** button in the sidebar shows the running daemon's output live, in four tabs: diff --git a/src/feed.rs b/src/feed.rs index 0d001ad..f65cc4e 100644 --- a/src/feed.rs +++ b/src/feed.rs @@ -32,7 +32,7 @@ pub struct Entry { pub enclosures: Vec, } -#[derive(Debug, Default, PartialEq)] +#[derive(Debug, Default, Clone, PartialEq)] pub struct Enclosure { pub url: String, pub mime: Option, @@ -120,7 +120,7 @@ pub fn opml_title(bytes: &[u8]) -> Option { /// RSS first, then Atom -- the same split the original made on `parsedFeed.version`. pub fn parse(bytes: &[u8]) -> Result { match rss::Channel::read_from(bytes) { - Ok(ch) => Ok(from_rss(ch)), + Ok(ch) => Ok(from_rss(ch, bytes)), Err(rss_err) => match atom_syndication::Feed::read_from(bytes) { Ok(feed) => Ok(from_atom(feed)), Err(atom_err) => Err(anyhow!("not RSS ({rss_err}) and not Atom ({atom_err})")), @@ -128,7 +128,78 @@ pub fn parse(bytes: &[u8]) -> Result { } } -fn from_rss(ch: rss::Channel) -> ParsedFeed { +/// Every `` of every ``, in document order. +/// +/// The `rss` crate models an item as having at most one enclosure -- which is what RSS 2.0 +/// says -- and when a feed carries several it keeps only the *last*, silently losing the +/// rest. Feeds do ship several, so read them from the XML directly. +fn enclosures_by_item(bytes: &[u8]) -> Vec> { + use quick_xml::events::Event; + + let mut reader = quick_xml::Reader::from_reader(bytes); + reader.config_mut().trim_text(true); + let mut buf = Vec::new(); + let mut out: Vec> = Vec::new(); + let mut current: Option> = None; + + let read_enclosure = |e: &quick_xml::events::BytesStart| -> Option { + let (mut url, mut mime, mut length) = (String::new(), None, None); + for attr in e.attributes().flatten() { + // Values arrive escaped: a feed URL's "&" is "&" in the document. + let val = quick_xml::escape::unescape(&attr.value) + .map(|v| v.trim().to_string()) + .unwrap_or_default(); + match attr.key.local_name().as_ref() { + "url" => url = val, + "type" => mime = Some(val).filter(|v| !v.is_empty()), + "length" => length = val.parse().ok(), + _ => {} + } + } + (!url.is_empty()).then_some(Enclosure { url, mime, length }) + }; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => match e.name().local_name().as_ref() { + "item" => current = Some(Vec::new()), + "enclosure" => { + if let (Some(list), Some(enc)) = (current.as_mut(), read_enclosure(&e)) { + list.push(enc); + } + } + _ => {} + }, + Ok(Event::Empty(e)) => match e.name().local_name().as_ref() { + // with no children still counts, so the indexes stay aligned. + "item" => out.push(Vec::new()), + "enclosure" => { + if let (Some(list), Some(enc)) = (current.as_mut(), read_enclosure(&e)) { + list.push(enc); + } + } + _ => {} + }, + Ok(Event::End(e)) => { + if e.name().local_name().as_ref() == "item" + && let Some(list) = current.take() + { + out.push(list); + } + } + Ok(Event::Eof) | Err(_) => break, + _ => {} + } + buf.clear(); + } + if let Some(list) = current.take() { + out.push(list); + } + out +} + +fn from_rss(ch: rss::Channel, bytes: &[u8]) -> ParsedFeed { + let per_item = enclosures_by_item(bytes); let explicit = ch .itunes_ext() .and_then(|it| it.explicit()) @@ -137,17 +208,21 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed { let entries = ch .items() .iter() - .filter_map(|item| { - let enclosures: Vec = item - .enclosure() - .into_iter() - .map(|e| Enclosure { - url: e.url().trim().to_owned(), - mime: non_empty(Some(e.mime_type())), - length: e.length().parse().ok(), - }) - .filter(|e| !e.url.is_empty()) - .collect(); + .enumerate() + .filter_map(|(idx, item)| { + // Straight from the XML, so an item with several keeps all of them. Falls + // back to the parsed one if the scan and the parser disagree on item count. + let enclosures: Vec = per_item.get(idx).cloned().unwrap_or_else(|| { + item.enclosure() + .into_iter() + .map(|e| Enclosure { + url: e.url().trim().to_owned(), + mime: non_empty(Some(e.mime_type())), + length: e.length().parse().ok(), + }) + .filter(|e| !e.url.is_empty()) + .collect() + }); let guid = pick_guid( item.guid().map(|g| g.value()), @@ -442,6 +517,36 @@ mod tests { assert!(!is_opml(include_bytes!("../tests/data/atom.xml"))); } + #[test] + fn an_item_may_carry_several_enclosures() { + // The rss crate keeps only one per item -- the last -- so these come from the XML. + let xml = br#" + Mhttps://xd + Two filesm1 + + + + One filem2 + + Nonem3 + "#; + let f = parse(xml).unwrap(); + assert_eq!(f.entries.len(), 3); + + let two = &f.entries[0].enclosures; + assert_eq!(two.len(), 2, "both enclosures survive"); + assert_eq!( + two[0].url, "https://x/a.mp3?v=1&t=2", + "document order, and the escaped ampersand is decoded" + ); + assert_eq!(two[0].length, Some(111)); + assert_eq!(two[1].url, "https://x/b.mp4"); + assert_eq!(two[1].mime.as_deref(), Some("video/mp4")); + + assert_eq!(f.entries[1].enclosures.len(), 1); + assert_eq!(f.entries[2].enclosures.len(), 0, "an item may have none"); + } + #[test] fn durations_parse_from_seconds_or_a_clock() { assert_eq!(parse_duration("5649"), Some(5649)); @@ -464,3 +569,4 @@ mod tests { assert_eq!(pick_guid(None, None, None, None), None); } } + diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index d3e7e8e..b650f08 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -11,7 +11,7 @@ test('the page loads and lists the configured feeds', async ({ page }) => { // Regression: a ReferenceError in the script left the shell rendered and the sidebar // empty, with every handler below the error dead. Server-side checks all passed. // Three top-level feeds in the fixture config; the OPML's child is inside a closed folder. - await expect(page.locator('.feed')).toHaveCount(3, { timeout: 15_000 }); + await expect(page.locator('.feed')).toHaveCount(4, { timeout: 15_000 }); await expect(page.getByText('Test Show')).toBeVisible(); const errors = []; page.on('pageerror', e => errors.push(e.message)); @@ -99,8 +99,25 @@ test('a downloaded file that is not audio gets no player', async ({ page }) => { await expect(page.locator('#detail audio')).toHaveCount(0); await expect(page.locator('#detail .encbox')).toContainText('image'); await expect(page.locator('#detail .encbox')).toContainText('downloaded'); - // Still offered as a file, just not as an episode. + // Still offered as a file, just not as an episode: viewable and keepable. await expect(page.locator('#detail .btn', { hasText: 'Save' })).toBeVisible(); + const view = page.locator('#detail a', { hasText: 'View' }); + await expect(view).toHaveAttribute('target', '_blank'); + await expect(view).toHaveAttribute('rel', /noopener/); + await expect(view).toHaveAttribute('href', /\/media\/\d+/); +}); + +test('an item with several enclosures lists them all', async ({ page }) => { + await page.locator('.feed', { hasText: 'Multi Show' }).click(); + const row = page.locator('.ep', { hasText: 'Two Files' }); + await expect(row).toBeVisible({ timeout: 20_000 }); + // The row says there is more than one without listing them. + await expect(row).toContainText('+1 more file'); + + await row.click(); + // The pane below lists every one: the audio and the image. + await expect(page.locator('#detail .encbox')).toHaveCount(2); + await expect(page.locator('#detail .encbox').nth(1)).toContainText('image'); }); test('the filter tabs change what is listed', async ({ page }) => { diff --git a/tests/ui/fixtures/art2.jpg b/tests/ui/fixtures/art2.jpg new file mode 100644 index 0000000..ad3c9ef Binary files /dev/null and b/tests/ui/fixtures/art2.jpg differ diff --git a/tests/ui/fixtures/ep2.mp3 b/tests/ui/fixtures/ep2.mp3 new file mode 100644 index 0000000..3365e26 Binary files /dev/null and b/tests/ui/fixtures/ep2.mp3 differ diff --git a/tests/ui/fixtures/multi.xml b/tests/ui/fixtures/multi.xml new file mode 100644 index 0000000..671c76f --- /dev/null +++ b/tests/ui/fixtures/multi.xml @@ -0,0 +1,9 @@ + +Multi Showhttp://127.0.0.1:8792/ +An item with more than one file. +Two Filesmu-1 + Mon, 01 Sep 2026 10:00:00 +0000 + Audio and a picture. + + + \ No newline at end of file diff --git a/tests/ui/global-setup.js b/tests/ui/global-setup.js index d8048b0..c09e795 100644 --- a/tests/ui/global-setup.js +++ b/tests/ui/global-setup.js @@ -40,6 +40,10 @@ url = "http://127.0.0.1:8792/pics.xml" auto_download = true media_types = ["image"] +[feeds.multi-show] +url = "http://127.0.0.1:8792/multi.xml" +auto_download = true + [feeds.test-subscriptions] url = "http://127.0.0.1:8792/subs.opml" auto_download = false diff --git a/web/index.html b/web/index.html index 0681971..d2e5884 100644 --- a/web/index.html +++ b/web/index.html @@ -617,9 +617,13 @@ function renderEntries(){ } } function epEl(e){ - const enc=e.enclosures[0]; + // An item may carry several files. The row summarises the one you would act on -- + // the playable one, else anything already downloaded, else the first -- and says how + // many others there are; the pane below lists them all. + const enc=e.enclosures.find(isPlayable) || e.enclosures.find(x=>x.path) || e.enclosures[0]; const has=!!(enc&&enc.path); const playable=isPlayable(enc); + const others=e.enclosures.length-1; const el=document.createElement('div'); el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+ (player.guid===e.guid?' playing':''); @@ -638,6 +642,7 @@ function epEl(e){ ${enc?`${ has?'downloaded':(enc.state==='skipped'?kindOf(enc):esc(enc.state))}`:''} ${enc&&enc.length?`${mb(enc.length)}`:''} + ${others>0?`+${others} more file${others===1?'':'s'}`:''} ${e.flagged?'★ kept':''} ${enc&&!has?`
`:''} @@ -751,10 +756,11 @@ function showDetail(e){ function encBox(x){ const size=x.length?mb(x.length):''; if(x.path && !isPlayable(x)){ - // On disk, but not audio or video: offer the file, not a player. + // On disk, but not audio or video: view it, keep it, or remove it -- no player. return `
${esc(kindOf(x))} downloaded${size?' \u00b7 '+size:''} + View Save
`; @@ -767,10 +773,15 @@ function encBox(x){ `; } + // Nothing on disk. For an image or a PDF you usually just want to look at it, so link + // straight to the publisher's copy in a new tab -- no download, and nothing proxied + // through here, which would make ipx a fetch-anything relay. + const viewable = !isPlayable(x) && x.state !== 'pending'; return `
${x.state==='skipped'?kindOf(x):esc(x.state)} ${esc(kindOf(x))}${size?' \u00b7 '+size:''} ${x.last_error?`${esc(x.last_error)}`:''} + ${viewable?`View`:''}
`; }