Do not offer a player for a file that is not audio or video
The media-type filter stopped new image enclosures being fetched, but ones already on disk still got a play button and an <audio> element, because the UI tested for a path rather than for a playable type. Four places did this, including play() itself, which picked the first downloaded enclosure whatever it was. isPlayable() checks audio/* or video/*, falling back to the extension when a feed declares no type. A downloaded non-media file now shows as its kind with Save and Delete, so it stays available without posing as an episode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
20
PROGRESS.md
20
PROGRESS.md
@@ -56,6 +56,26 @@ and until now nothing set them.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
stopped *new* image enclosures being fetched, but those 20 JPEGs were already on disk from before
|
||||||
|
it existed — and the UI gave a play button and an `<audio>` element to anything with a `path`. An
|
||||||
|
audio element pointed at a JPEG is just a broken player.
|
||||||
|
|
||||||
|
`isPlayable()` now separates having a file from being playable: audio/* or video/*, falling back to
|
||||||
|
the file extension when a feed declares no type. Four places used the wrong test — the row's play
|
||||||
|
button, the artwork's play overlay, `encBox`, and `play()` itself, which picked the first downloaded
|
||||||
|
enclosure regardless of what it was. A downloaded non-media file now shows as
|
||||||
|
`image · downloaded · Save · Delete file`, so it is still there and still retrievable, just not
|
||||||
|
pretending to be an episode.
|
||||||
|
|
||||||
|
Covered by a new browser test with a fixture blog whose entry carries a JPEG enclosure and a feed
|
||||||
|
configured to download it: no play button on the row, no `<audio>` in the detail pane, and the Save
|
||||||
|
button still present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-09-10 — Three-pane layout
|
## 2026-09-10 — Three-pane layout
|
||||||
|
|
||||||
Ray asked for feeds beside, items above, and the selected item's text with its enclosures below —
|
Ray asked for feeds beside, items above, and the selected item's text with its enclosures below —
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ test.beforeEach(async ({ page }) => {
|
|||||||
test('the page loads and lists the configured feeds', async ({ page }) => {
|
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.
|
||||||
await expect(page.locator('.feed')).toHaveCount(2, { timeout: 15_000 });
|
// 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.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));
|
||||||
@@ -84,6 +85,24 @@ test('the three panes are there and the item text lands in the bottom one', asyn
|
|||||||
await expect(page.locator('#detail audio')).toHaveCount(0);
|
await expect(page.locator('#detail audio')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a downloaded file that is not audio gets no player', async ({ page }) => {
|
||||||
|
// Regression: anything with a file got an <audio> element and a play button, so a blog's
|
||||||
|
// header image rendered as a broken player.
|
||||||
|
await page.locator('.feed', { hasText: 'Picture Blog' }).click();
|
||||||
|
const row = page.locator('.ep', { hasText: 'An Article' });
|
||||||
|
await expect(row).toBeVisible({ timeout: 20_000 });
|
||||||
|
|
||||||
|
await expect(row.locator('[data-a="play"]')).toHaveCount(0);
|
||||||
|
await row.click();
|
||||||
|
|
||||||
|
await expect(page.locator('#detail .dt')).toHaveText('An Article');
|
||||||
|
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.
|
||||||
|
await expect(page.locator('#detail .btn', { hasText: 'Save' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('the filter tabs change what is listed', async ({ page }) => {
|
test('the filter tabs change what is listed', async ({ page }) => {
|
||||||
await page.getByText('Test Show').click();
|
await page.getByText('Test Show').click();
|
||||||
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
await expect(page.locator('.ep').first()).toBeVisible({ timeout: 20_000 });
|
||||||
|
|||||||
BIN
tests/ui/fixtures/art.jpg
Normal file
BIN
tests/ui/fixtures/art.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
8
tests/ui/fixtures/pics.xml
Normal file
8
tests/ui/fixtures/pics.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<rss version="2.0"><channel><title>Picture Blog</title><link>http://127.0.0.1:8792/</link>
|
||||||
|
<description>A text blog whose entries carry a header image, as Substack does.</description>
|
||||||
|
<item><title>An Article</title><guid>pic-1</guid>
|
||||||
|
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
||||||
|
<description><p>Words, not audio.</p></description>
|
||||||
|
<enclosure url="http://127.0.0.1:8792/art.jpg" length="3020" type="image/jpeg"/></item>
|
||||||
|
</channel></rss>
|
||||||
@@ -34,6 +34,12 @@ token = "${TOKEN}"
|
|||||||
url = "http://127.0.0.1:8792/show.xml"
|
url = "http://127.0.0.1:8792/show.xml"
|
||||||
auto_download = true
|
auto_download = true
|
||||||
|
|
||||||
|
# Downloads its image, so the UI has a file that is not playable to deal with.
|
||||||
|
[feeds.picture-blog]
|
||||||
|
url = "http://127.0.0.1:8792/pics.xml"
|
||||||
|
auto_download = true
|
||||||
|
media_types = ["image"]
|
||||||
|
|
||||||
[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
|
||||||
|
|||||||
@@ -619,6 +619,7 @@ function renderEntries(){
|
|||||||
function epEl(e){
|
function epEl(e){
|
||||||
const enc=e.enclosures[0];
|
const enc=e.enclosures[0];
|
||||||
const has=!!(enc&&enc.path);
|
const has=!!(enc&&enc.path);
|
||||||
|
const playable=isPlayable(enc);
|
||||||
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':'');
|
||||||
@@ -643,8 +644,8 @@ function epEl(e){
|
|||||||
${enc&&enc.last_error?`<div class="line" style="color:var(--bad)">${esc(enc.last_error)}</div>`:''}
|
${enc&&enc.last_error?`<div class="line" style="color:var(--bad)">${esc(enc.last_error)}</div>`:''}
|
||||||
</div>
|
</div>
|
||||||
<div class="rowacts">
|
<div class="rowacts">
|
||||||
${has?`<button class="iconbtn" data-a="play" title="Play">▶</button>`:
|
${playable?`<button class="iconbtn" data-a="play" title="Play">▶</button>`:
|
||||||
(enc?`<button class="iconbtn" data-a="get" title="Download this ${
|
(enc&&!has?`<button class="iconbtn" data-a="get" title="Download this ${
|
||||||
enc.state==='skipped'?kindOf(enc):'file'}">⤓</button>`:'')}
|
enc.state==='skipped'?kindOf(enc):'file'}">⤓</button>`:'')}
|
||||||
<button class="iconbtn" data-a="flag" title="${e.flagged?'Stop keeping':'Keep (never auto-delete)'}">${e.flagged?'★':'☆'}</button>
|
<button class="iconbtn" data-a="flag" title="${e.flagged?'Stop keeping':'Keep (never auto-delete)'}">${e.flagged?'★':'☆'}</button>
|
||||||
<button class="iconbtn" data-a="read" title="Mark ${e.read?'unread':'read'}">${e.read?'○':'●'}</button>
|
<button class="iconbtn" data-a="read" title="Mark ${e.read?'unread':'read'}">${e.read?'○':'●'}</button>
|
||||||
@@ -652,7 +653,7 @@ function epEl(e){
|
|||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const art=$('.art',el);
|
const art=$('.art',el);
|
||||||
if(art&&has){
|
if(art&&playable){
|
||||||
art.insertAdjacentHTML('beforeend','<span class="ovl">▶</span>');
|
art.insertAdjacentHTML('beforeend','<span class="ovl">▶</span>');
|
||||||
art.onclick=ev=>{ ev.stopPropagation(); play(e); };
|
art.onclick=ev=>{ ev.stopPropagation(); play(e); };
|
||||||
}
|
}
|
||||||
@@ -660,6 +661,19 @@ function epEl(e){
|
|||||||
$$('.rowacts .iconbtn',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
|
$$('.rowacts .iconbtn',el).forEach(b=>b.onclick=ev=>{ev.stopPropagation();epAction(b.dataset.a,e,el)});
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
/// Whether the browser can play it. Having a file is not the same as being playable:
|
||||||
|
/// blog feeds put article images in enclosures, and an <audio> element pointed at a JPEG
|
||||||
|
/// is just a broken player.
|
||||||
|
function isPlayable(x){
|
||||||
|
if(!x || !x.path) return false;
|
||||||
|
const m=(x.mime||'').toLowerCase();
|
||||||
|
if(m.startsWith('audio/')||m.startsWith('video/')) return true;
|
||||||
|
if(m) return false;
|
||||||
|
// No declared type: fall back to the file's extension.
|
||||||
|
return /\.(mp3|m4a|m4b|aac|ogg|oga|opus|flac|wav|mp4|m4v|mov|webm|mkv)$/i
|
||||||
|
.test((x.path||x.url||'').split('?')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
/// What an enclosure is, for a row that is not an episode: "image", "pdf", "document".
|
/// What an enclosure is, for a row that is not an episode: "image", "pdf", "document".
|
||||||
function kindOf(enc){
|
function kindOf(enc){
|
||||||
const m=(enc.mime||'').toLowerCase();
|
const m=(enc.mime||'').toLowerCase();
|
||||||
@@ -736,6 +750,15 @@ function showDetail(e){
|
|||||||
/// One enclosure: a player when the file is here, otherwise what it is and a way to get it.
|
/// One enclosure: a player when the file is here, otherwise what it is and a way to get it.
|
||||||
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)){
|
||||||
|
// On disk, but not audio or video: offer the file, not a 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}" download>Save</a>
|
||||||
|
<button class="btn danger" data-a="del" data-enc="${x.id}">Delete file</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
if(x.path){
|
if(x.path){
|
||||||
return `<div class="encbox">
|
return `<div class="encbox">
|
||||||
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
|
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
|
||||||
@@ -793,8 +816,11 @@ function markPlayed(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function play(e){
|
function play(e){
|
||||||
const enc=e.enclosures.find(x=>x.path);
|
const enc=e.enclosures.find(isPlayable);
|
||||||
if(!enc){ toast('Not downloaded yet',true); return; }
|
if(!enc){
|
||||||
|
toast(e.enclosures.some(x=>x.path) ? 'That file is not audio or video' : 'Not downloaded yet', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const resuming = player.guid===e.guid;
|
const resuming = player.guid===e.guid;
|
||||||
if(!resuming){
|
if(!resuming){
|
||||||
player.guid=e.guid; player.feed=e.feed_id; player.entry=e;
|
player.guid=e.guid; player.feed=e.feed_id; player.entry=e;
|
||||||
|
|||||||
Reference in New Issue
Block a user