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:
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
29
PROGRESS.md
29
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/<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 `&`, 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
134
src/feed.rs
134
src/feed.rs
@@ -32,7 +32,7 @@ pub struct Entry {
|
||||
pub enclosures: Vec<Enclosure>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Enclosure {
|
||||
pub url: 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`.
|
||||
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
|
||||
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<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 "&" 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
|
||||
.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<Enclosure> = 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<Enclosure> = 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#"<?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&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]
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
BIN
tests/ui/fixtures/art2.jpg
Normal file
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
BIN
tests/ui/fixtures/ep2.mp3
Normal file
Binary file not shown.
9
tests/ui/fixtures/multi.xml
Normal file
9
tests/ui/fixtures/multi.xml
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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?`<span class="dot"></span><span class="chip ${esc(enc.state)}">${
|
||||
has?'downloaded':(enc.state==='skipped'?kindOf(enc):esc(enc.state))}</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>':''}
|
||||
</div>
|
||||
${enc&&!has?`<div class="dlbar" data-bar="${enc.id}"><i></i></div>`:''}
|
||||
@@ -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 `<div class="encbox">
|
||||
<span class="chip done">${esc(kindOf(x))}</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>
|
||||
<button class="btn danger" data-a="del" data-enc="${x.id}">Delete file</button>
|
||||
</div>`;
|
||||
@@ -767,10 +773,15 @@ function encBox(x){
|
||||
<button class="btn danger" data-a="del" data-enc="${x.id}">Delete file</button>
|
||||
</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">
|
||||
<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>
|
||||
${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>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user