Keep every enclosure on an item, and view without downloading

The rss crate keeps at most one enclosure per item and, when a feed ships
several, silently keeps the last -- so a two-file item lost its first
file. enclosures_by_item reads them from the XML in document order,
unescaping attributes so a URL's & survives. The row summarises the
one you would act on and counts the rest; the pane below lists them all.

Non-media enclosures gain a View link opening in a new tab: the
publisher's URL, or the local copy once downloaded. A direct link, not a
proxy, so the daemon does not become a fetch-anything relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-10 18:01:25 +00:00
parent 6d6b4dd1e4
commit dd9c0881ee
11 changed files with 212 additions and 21 deletions

16
Cargo.lock generated
View File

@@ -187,7 +187,7 @@ dependencies = [
"chrono", "chrono",
"derive_builder", "derive_builder",
"diligent-date-parser", "diligent-date-parser",
"quick-xml", "quick-xml 0.41.0",
] ]
[[package]] [[package]]
@@ -1568,6 +1568,7 @@ dependencies = [
"librqbit", "librqbit",
"opml", "opml",
"percent-encoding", "percent-encoding",
"quick-xml 0.42.0",
"reqwest", "reqwest",
"rss", "rss",
"rusqlite", "rusqlite",
@@ -2037,7 +2038,7 @@ dependencies = [
"httparse", "httparse",
"librqbit-dualstack-sockets", "librqbit-dualstack-sockets",
"network-interface", "network-interface",
"quick-xml", "quick-xml 0.41.0",
"reqwest", "reqwest",
"serde", "serde",
"serde_derive", "serde_derive",
@@ -2537,6 +2538,15 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "quick-xml"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.11" version = "0.11.11"
@@ -2858,7 +2868,7 @@ checksum = "f505d3e5e7b06b4dc0245b13294f8ef9a1a0f70284708be1e11c5b7b7441034e"
dependencies = [ dependencies = [
"atom_syndication", "atom_syndication",
"derive_builder", "derive_builder",
"quick-xml", "quick-xml 0.41.0",
] ]
[[package]] [[package]]

View File

@@ -16,6 +16,7 @@ infer = "0.22.0"
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] } librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
opml = "1.1.6" opml = "1.1.6"
percent-encoding = "2.3.2" 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"] } reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "gzip", "stream", "json", "charset", "system-proxy"] }
rss = "2.1.1" rss = "2.1.1"
rusqlite = { version = "0.40.2", features = ["bundled"] } rusqlite = { version = "0.40.2", features = ["bundled"] }

View File

@@ -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/<id>` 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 `&amp;`, 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 ## 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 Reported: "Abort Retry Fail still shows downloaded and audio playback UI". The media-type filter

View File

@@ -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 between the two panes drags and the position is remembered. An OPML subscription's page instead
lists the feeds inside it. 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 ## Log view
The **Log** button in the sidebar shows the running daemon's output live, in four tabs: The **Log** button in the sidebar shows the running daemon's output live, in four tabs:

View File

@@ -32,7 +32,7 @@ pub struct Entry {
pub enclosures: Vec<Enclosure>, pub enclosures: Vec<Enclosure>,
} }
#[derive(Debug, Default, PartialEq)] #[derive(Debug, Default, Clone, PartialEq)]
pub struct Enclosure { pub struct Enclosure {
pub url: String, pub url: String,
pub mime: Option<String>, pub mime: Option<String>,
@@ -120,7 +120,7 @@ pub fn opml_title(bytes: &[u8]) -> Option<String> {
/// RSS first, then Atom -- the same split the original made on `parsedFeed.version`. /// RSS first, then Atom -- the same split the original made on `parsedFeed.version`.
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> { pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
match rss::Channel::read_from(bytes) { 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) { Err(rss_err) => match atom_syndication::Feed::read_from(bytes) {
Ok(feed) => Ok(from_atom(feed)), Ok(feed) => Ok(from_atom(feed)),
Err(atom_err) => Err(anyhow!("not RSS ({rss_err}) and not Atom ({atom_err})")), Err(atom_err) => Err(anyhow!("not RSS ({rss_err}) and not Atom ({atom_err})")),
@@ -128,7 +128,78 @@ pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
} }
} }
fn from_rss(ch: rss::Channel) -> ParsedFeed { /// Every `<enclosure>` of every `<item>`, 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<Vec<Enclosure>> {
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<Enclosure>> = Vec::new();
let mut current: Option<Vec<Enclosure>> = None;
let read_enclosure = |e: &quick_xml::events::BytesStart| -> Option<Enclosure> {
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 "&amp;" 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() {
// <item/> 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 let explicit = ch
.itunes_ext() .itunes_ext()
.and_then(|it| it.explicit()) .and_then(|it| it.explicit())
@@ -137,9 +208,12 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
let entries = ch let entries = ch
.items() .items()
.iter() .iter()
.filter_map(|item| { .enumerate()
let enclosures: Vec<Enclosure> = item .filter_map(|(idx, item)| {
.enclosure() // 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<Enclosure> = per_item.get(idx).cloned().unwrap_or_else(|| {
item.enclosure()
.into_iter() .into_iter()
.map(|e| Enclosure { .map(|e| Enclosure {
url: e.url().trim().to_owned(), url: e.url().trim().to_owned(),
@@ -147,7 +221,8 @@ fn from_rss(ch: rss::Channel) -> ParsedFeed {
length: e.length().parse().ok(), length: e.length().parse().ok(),
}) })
.filter(|e| !e.url.is_empty()) .filter(|e| !e.url.is_empty())
.collect(); .collect()
});
let guid = pick_guid( let guid = pick_guid(
item.guid().map(|g| g.value()), item.guid().map(|g| g.value()),
@@ -442,6 +517,36 @@ mod tests {
assert!(!is_opml(include_bytes!("../tests/data/atom.xml"))); 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#"<?xml version="1.0"?>
<rss version="2.0"><channel><title>M</title><link>https://x</link><description>d</description>
<item><title>Two files</title><guid>m1</guid>
<enclosure url="https://x/a.mp3?v=1&amp;t=2" length="111" type="audio/mpeg"/>
<enclosure url="https://x/b.mp4" length="222" type="video/mp4"/>
</item>
<item><title>One file</title><guid>m2</guid>
<enclosure url="https://x/c.mp3" length="333" type="audio/mpeg"/></item>
<item><title>None</title><guid>m3</guid></item>
</channel></rss>"#;
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] #[test]
fn durations_parse_from_seconds_or_a_clock() { fn durations_parse_from_seconds_or_a_clock() {
assert_eq!(parse_duration("5649"), Some(5649)); assert_eq!(parse_duration("5649"), Some(5649));
@@ -464,3 +569,4 @@ mod tests {
assert_eq!(pick_guid(None, None, None, None), None); assert_eq!(pick_guid(None, None, None, None), None);
} }
} }

View File

@@ -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 // 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. // 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. // 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(); await expect(page.getByText('Test Show')).toBeVisible();
const errors = []; const errors = [];
page.on('pageerror', e => errors.push(e.message)); 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 audio')).toHaveCount(0);
await expect(page.locator('#detail .encbox')).toContainText('image'); await expect(page.locator('#detail .encbox')).toContainText('image');
await expect(page.locator('#detail .encbox')).toContainText('downloaded'); 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(); 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 }) => { test('the filter tabs change what is listed', async ({ page }) => {

BIN
tests/ui/fixtures/art2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
tests/ui/fixtures/ep2.mp3 Normal file

Binary file not shown.

View File

@@ -0,0 +1,9 @@
<?xml version="1.0"?>
<rss version="2.0"><channel><title>Multi Show</title><link>http://127.0.0.1:8792/</link>
<description>An item with more than one file.</description>
<item><title>Two Files</title><guid>mu-1</guid>
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
<description>Audio and a picture.</description>
<enclosure url="http://127.0.0.1:8792/ep2.mp3" length="40000" type="audio/mpeg"/>
<enclosure url="http://127.0.0.1:8792/art2.jpg" length="3020" type="image/jpeg"/>
</item></channel></rss>

View File

@@ -40,6 +40,10 @@ url = "http://127.0.0.1:8792/pics.xml"
auto_download = true auto_download = true
media_types = ["image"] media_types = ["image"]
[feeds.multi-show]
url = "http://127.0.0.1:8792/multi.xml"
auto_download = true
[feeds.test-subscriptions] [feeds.test-subscriptions]
url = "http://127.0.0.1:8792/subs.opml" url = "http://127.0.0.1:8792/subs.opml"
auto_download = false auto_download = false

View File

@@ -617,9 +617,13 @@ function renderEntries(){
} }
} }
function epEl(e){ 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 has=!!(enc&&enc.path);
const playable=isPlayable(enc); const playable=isPlayable(enc);
const others=e.enclosures.length-1;
const el=document.createElement('div'); const el=document.createElement('div');
el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+ el.className='ep'+(e.read?' read':'')+(S.sel===e.guid?' sel':'')+
(player.guid===e.guid?' playing':''); (player.guid===e.guid?' playing':'');
@@ -638,6 +642,7 @@ function epEl(e){
${enc?`<span class="dot"></span><span class="chip ${esc(enc.state)}">${ ${enc?`<span class="dot"></span><span class="chip ${esc(enc.state)}">${
has?'downloaded':(enc.state==='skipped'?kindOf(enc):esc(enc.state))}</span>`:''} has?'downloaded':(enc.state==='skipped'?kindOf(enc):esc(enc.state))}</span>`:''}
${enc&&enc.length?`<span>${mb(enc.length)}</span>`:''} ${enc&&enc.length?`<span>${mb(enc.length)}</span>`:''}
${others>0?`<span class="dot"></span><span>+${others} more file${others===1?'':'s'}</span>`:''}
${e.flagged?'<span class="dot"></span><span>★ kept</span>':''} ${e.flagged?'<span class="dot"></span><span>★ kept</span>':''}
</div> </div>
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''} ${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
@@ -751,10 +756,11 @@ function showDetail(e){
function encBox(x){ function encBox(x){
const size=x.length?mb(x.length):''; const size=x.length?mb(x.length):'';
if(x.path && !isPlayable(x)){ 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 `<div class="encbox"> return `<div class="encbox">
<span class="chip done">${esc(kindOf(x))}</span> <span class="chip done">${esc(kindOf(x))}</span>
<span class="meta" style="flex:1">downloaded${size?' \u00b7 '+size:''}</span> <span class="meta" style="flex:1">downloaded${size?' \u00b7 '+size:''}</span>
<a class="btn" href="/media/${x.id}" target="_blank" rel="noopener noreferrer">View</a>
<a class="btn" href="/media/${x.id}" download>Save</a> <a class="btn" href="/media/${x.id}" download>Save</a>
<button class="btn danger" data-a="del" data-enc="${x.id}">Delete file</button> <button class="btn danger" data-a="del" data-enc="${x.id}">Delete file</button>
</div>`; </div>`;
@@ -767,10 +773,15 @@ function encBox(x){
<button class="btn danger" data-a="del" data-enc="${x.id}">Delete file</button> <button class="btn danger" data-a="del" data-enc="${x.id}">Delete file</button>
</div>`; </div>`;
} }
// 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 `<div class="encbox"> return `<div class="encbox">
<span class="chip ${esc(x.state)}">${x.state==='skipped'?kindOf(x):esc(x.state)}</span> <span class="chip ${esc(x.state)}">${x.state==='skipped'?kindOf(x):esc(x.state)}</span>
<span class="meta" style="flex:1">${esc(kindOf(x))}${size?' \u00b7 '+size:''}</span> <span class="meta" style="flex:1">${esc(kindOf(x))}${size?' \u00b7 '+size:''}</span>
${x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''} ${x.last_error?`<span class="err">${esc(x.last_error)}</span>`:''}
${viewable?`<a class="btn" href="${esc(x.url)}" target="_blank" rel="noopener noreferrer">View</a>`:''}
<button class="btn" data-a="get" data-enc="${x.id}">Download</button> <button class="btn" data-a="get" data-enc="${x.id}">Download</button>
</div>`; </div>`;
} }