Call them items, not episodes

Half the library is text feeds, so the UI no longer assumes a podcast:
counts, search, the empty detail pane, the phone back button, retention
and per-feed settings, and the download dialog all say item. S1E1 badges
and the episode column stay -- those are the itunes:episode field, which
only appears when a feed publishes it.

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 19:12:58 +00:00
parent 3817dbebdd
commit 772dda5154
3 changed files with 44 additions and 33 deletions

View File

@@ -56,6 +56,17 @@ and until now nothing set them.
--- ---
## 2026-09-10 — Items, not episodes
Half the library is text feeds, so the UI no longer calls everything an episode: counts, the search
box, the empty detail pane, the phone back button, the retention and per-feed settings, and the
download dialog all say **item** now. The sidebar's `20 eps` reads `20 items`.
`S1E1` badges stay -- those come from `itunes:episode`/`itunes:season` and only appear when a feed
actually publishes them -- as does the `episode` column, which is that same field.
---
## 2026-09-10 — Scanning stopped strobing ## 2026-09-10 — Scanning stopped strobing
A scan of 85 feeds fires 85 `feed_done` events, and the UI refreshed on every one: a full sidebar A scan of 85 feeds fires 85 `feed_done` events, and the UI refreshed on every one: a full sidebar

View File

@@ -65,7 +65,7 @@ test('the three panes are there and the item text lands in the bottom one', asyn
await page.getByText('Test Show').click(); await page.getByText('Test Show').click();
await expect(page.locator('#list')).toBeVisible(); await expect(page.locator('#list')).toBeVisible();
await expect(page.locator('#grab')).toBeVisible(); // the draggable divider await expect(page.locator('#grab')).toBeVisible(); // the draggable divider
await expect(page.locator('#detail')).toContainText('Pick an episode'); await expect(page.locator('#detail')).toContainText('Pick an item');
await page.locator('.ep', { hasText: 'First Episode' }).click(); await page.locator('.ep', { hasText: 'First Episode' }).click();
await expect(page.locator('#detail .dt')).toHaveText('First Episode'); await expect(page.locator('#detail .dt')).toHaveText('First Episode');
@@ -123,14 +123,14 @@ test('an item with several enclosures lists them all', async ({ page }) => {
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 });
const unread = await page.locator('.ep').count(); const all = await page.locator('.ep').count(); // All is the default tab
await expect(page.locator('#count')).toContainText('item');
await page.locator('.tabs button', { hasText: 'All' }).first().click(); await page.locator('.tabs button', { hasText: 'Unread' }).first().click();
await expect(page.locator('#count')).toContainText('episode'); expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all);
expect(await page.locator('.ep').count()).toBeGreaterThanOrEqual(unread);
await page.locator('.tabs button', { hasText: 'Flagged' }).first().click(); await page.locator('.tabs button', { hasText: 'Flagged' }).first().click();
await expect(page.locator('#count')).toContainText('0 episodes'); await expect(page.locator('#count')).toContainText('0 items');
}); });
test('a feed URL is editable and has a copy button', async ({ page }) => { test('a feed URL is editable and has a copy button', async ({ page }) => {

View File

@@ -191,7 +191,7 @@ a.btn{text-decoration:none;color:inherit}
.tabs button.on{background:var(--raise);color:var(--fg);font-weight:600} .tabs button.on{background:var(--raise);color:var(--fg);font-weight:600}
.toolbar .grow{flex:1;min-width:150px;max-width:320px} .toolbar .grow{flex:1;min-width:150px;max-width:320px}
/* ---------- episodes ---------- */ /* ---------- items ---------- */
.ep{ .ep{
display:flex;gap:11px;padding:8px 10px;border-radius:var(--r);align-items:center; display:flex;gap:11px;padding:8px 10px;border-radius:var(--r);align-items:center;
border:1px solid transparent;margin-bottom:2px;position:relative;cursor:pointer; border:1px solid transparent;margin-bottom:2px;position:relative;cursor:pointer;
@@ -532,7 +532,7 @@ function renderFeeds(){
artHTML(f.image,f.title||f.id)+ artHTML(f.image,f.title||f.id)+
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+ `<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
(mine.length?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved` (mine.length?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved`
:`${eps} eps · ${saved} saved`)+ :`${eps} items · ${saved} saved`)+
`</small></div>`+ `</small></div>`+
(f.orphaned?'<span class="tag" title="No longer listed in the OPML, kept because it has downloads">gone</span>':'')+ (f.orphaned?'<span class="tag" title="No longer listed in the OPML, kept because it has downloads">gone</span>':'')+
`<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`; `<span class="badge${unread?'':' zero'}" title="${unread} unread">${unread>999?'999+':unread}</span>`;
@@ -559,11 +559,11 @@ function renderFeed(){
${artHTML(f.image,f.title||f.id)} ${artHTML(f.image,f.title||f.id)}
<div class="meta"> <div class="meta">
<h2>${esc(f.title||f.id)}</h2> <h2>${esc(f.title||f.id)}</h2>
<div class="sub">${f.entries} episodes · ${f.downloaded} downloaded · checked ${ago(f.last_checked)} <div class="sub">${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)}
· every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}</div> · every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}</div>
${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''} ${f.last_error?`<div class="sub" style="color:var(--bad)">${esc(f.last_error)}</div>`:''}
${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its ${f.orphaned?`<div class="sub" style="color:var(--warn)">This feed is no longer listed in its
OPML subscription. It was kept rather than removed because it has downloaded episodes.</div>`:''} OPML subscription. It was kept rather than removed because it has downloaded items.</div>`:''}
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''} ${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
<div class="acts"> <div class="acts">
<button class="btn primary" data-a="scan"><i>⟳</i>Scan now</button> <button class="btn primary" data-a="scan"><i>⟳</i>Scan now</button>
@@ -579,7 +579,7 @@ function renderFeed(){
${['all','unread','downloaded','flagged'].map(t=> ${['all','unread','downloaded','flagged'].map(t=>
`<button data-f="${t}" class="${S.filter===t?'on':''}">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')} `<button data-f="${t}" class="${S.filter===t?'on':''}">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')}
</div> </div>
<input type="search" class="grow" id="epSearch" placeholder="Search episodes…" value="${esc(S.q)}"> <input type="search" class="grow" id="epSearch" placeholder="Search items…" value="${esc(S.q)}">
<span style="color:var(--faint);font-size:12.5px" id="count"></span> <span style="color:var(--faint);font-size:12.5px" id="count"></span>
</div> </div>
`; `;
@@ -597,7 +597,7 @@ function renderFeed(){
let t; $('#epSearch').oninput=e=>{clearTimeout(t);t=setTimeout(()=>{S.q=e.target.value;S.offset=0;loadEntries()},250)}; let t; $('#epSearch').oninput=e=>{clearTimeout(t);t=setTimeout(()=>{S.q=e.target.value;S.offset=0;loadEntries()},250)};
} }
/// An OPML subscription's page lists the feeds inside it rather than episodes, but keeps /// An OPML subscription's page lists the feeds inside it rather than items, but keeps
/// every action a normal feed has -- it is still an ordinary feed entry underneath. /// every action a normal feed has -- it is still an ordinary feed entry underneath.
function renderGroup(f,kids){ function renderGroup(f,kids){
const unread=kids.reduce((n,c)=>n+c.unread,0); const unread=kids.reduce((n,c)=>n+c.unread,0);
@@ -638,7 +638,7 @@ function renderGroup(f,kids){
el.className='childrow'; el.className='childrow';
el.innerHTML = artHTML(c.image,c.title||c.id)+ el.innerHTML = artHTML(c.image,c.title||c.id)+
`<div class="txt"><b>${esc(c.title||c.id)}</b>`+ `<div class="txt"><b>${esc(c.title||c.id)}</b>`+
`<small class="meta">${c.entries} eps · ${c.downloaded} downloaded`+ `<small class="meta">${c.entries} items · ${c.downloaded} downloaded`+
(c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+ (c.last_error?` · <span style="color:var(--bad)">error</span>`:'')+`</small></div>`+
(c.orphaned?'<span class="tag">gone</span>':'')+ (c.orphaned?'<span class="tag">gone</span>':'')+
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`; `<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
@@ -658,7 +658,7 @@ async function feedAction(a,f){
if(a==='dl') downloadLatestModal(f); if(a==='dl') downloadLatestModal(f);
} }
/* ---------------- episodes ---------------- */ /* ---------------- items ---------------- */
async function loadEntries(append){ async function loadEntries(append){
if(!S.feed) return; if(!S.feed) return;
const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter}); const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter});
@@ -670,7 +670,7 @@ async function loadEntries(append){
} }
function renderEntries(){ function renderEntries(){
const box=$('#eps'); if(!box) return; const box=$('#eps'); if(!box) return;
const c=$('#count'); if(c) c.textContent=`${S.total} episode${S.total===1?'':'s'}`; const c=$('#count'); if(c) c.textContent=`${S.total} item${S.total===1?'':'s'}`;
const pane=$('#list'), top=pane?pane.scrollTop:0; const pane=$('#list'), top=pane?pane.scrollTop:0;
box.innerHTML=''; box.innerHTML='';
if(!S.entries.length){ if(!S.entries.length){
@@ -750,7 +750,7 @@ function isPlayable(x){
.test((x.path||x.url||'').split('?')[0]); .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 media: "image", "pdf", "document".
function kindOf(enc){ function kindOf(enc){
const m=(enc.mime||'').toLowerCase(); const m=(enc.mime||'').toLowerCase();
if(m.startsWith('image/')) return 'image'; if(m.startsWith('image/')) return 'image';
@@ -799,11 +799,11 @@ function dragSplit(){
function showDetail(e){ function showDetail(e){
const box=$('#detail'); if(!box) return; const box=$('#detail'); if(!box) return;
document.body.classList.toggle('reading',!!e); document.body.classList.toggle('reading',!!e);
if(!e){ box.innerHTML='<p class="empty">Pick an episode to read it.</p>'; return; } if(!e){ box.innerHTML='<p class="empty">Pick an item to read it.</p>'; return; }
const f=S.feeds.find(x=>x.id===e.feed_id); const f=S.feeds.find(x=>x.id===e.feed_id);
const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join(''); const num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
box.innerHTML=` box.innerHTML=`
<button class="btn" id="dback">← Episodes</button> <button class="btn" id="dback">← Items</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3> <h3 class="dt">${esc(e.title||'(untitled)')}</h3>
<div class="dmeta"> <div class="dmeta">
${f?`<span>${esc(f.title||f.id)}</span><span class="dot"></span>`:''} ${f?`<span>${esc(f.title||f.id)}</span><span class="dot"></span>`:''}
@@ -873,10 +873,10 @@ async function epAction(a,e,el,encId){
if(!enc) return; if(!enc) return;
S.busy.add(enc.id); S.busy.add(enc.id);
await api(`/api/enclosures/${enc.id}/download`,{method:'POST'}); await api(`/api/enclosures/${enc.id}/download`,{method:'POST'});
toast('Queued: '+(e.title||'episode')); toast('Queued: '+(e.title||'item'));
} }
if(a==='del'){ if(a==='del'){
if(!confirm('Delete the downloaded file?\n\nThe episode stays listed and will not be downloaded again automatically.')) return; if(!confirm('Delete the downloaded file?\n\nThe item stays listed and will not be downloaded again automatically.')) return;
await api(`/api/enclosures/${enc.id}`,{method:'DELETE'}); await api(`/api/enclosures/${enc.id}`,{method:'DELETE'});
toast('Deleted'); loadEntries(); loadFeeds(true); toast('Deleted'); loadEntries(); loadFeeds(true);
} }
@@ -887,8 +887,8 @@ async function epAction(a,e,el,encId){
const audio=$('#audio'); const audio=$('#audio');
const player={guid:null,feed:null,entry:null,saveAt:0,marked:false}; const player={guid:null,feed:null,entry:null,saveAt:0,marked:false};
// Marked read when an episode has actually been listened to -- at the end, or past 90%. // Marked read when an item has actually been listened to -- at the end, or past 90%.
// NOT on play: doing that made the episode vanish from the default Unread list the instant // NOT on play: doing that made the item vanish from the Unread list the instant
// you pressed play, which looks exactly like it went missing. // you pressed play, which looks exactly like it went missing.
function markPlayed(){ function markPlayed(){
if(!player.guid||player.marked) return; if(!player.guid||player.marked) return;
@@ -1085,7 +1085,7 @@ $('#addFeed').onclick=()=>{
<div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss"></div> <div class="field"><label>Feed URL</label><input type="text" id="nurl" placeholder="https://example.com/rss"></div>
<div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div> <div class="field"><label>Folder (optional)</label><input type="text" id="nfolder" placeholder="Defaults to the feed title"></div>
<div class="field"><label>Keywords (optional, comma separated)</label> <div class="field"><label>Keywords (optional, comma separated)</label>
<input type="text" id="nkw"><span class="hint">Only episodes matching a keyword are downloaded.</span></div> <input type="text" id="nkw"><span class="hint">Only items matching a keyword are downloaded.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button> <div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="nsave">Add feed</button></div>`); <button class="btn primary" id="nsave">Add feed</button></div>`);
$('#nurl').focus(); $('#nurl').focus();
@@ -1159,12 +1159,12 @@ async function prefsModal(){
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}" <input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}"
placeholder="audio, video"> placeholder="audio, video">
<span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds <span class="hint">Anything else is still listed and can be downloaded by hand — blog feeds
put article images in enclosures, and those are not episodes. Empty takes everything.</span></div> put article images in enclosures, and those are not worth keeping. Empty takes everything.</span></div>
<div class="field"><label>Disk quota (GB, 0 = unlimited)</label> <div class="field"><label>Disk quota (GB, 0 = unlimited)</label>
<input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}"> <input type="number" id="gquota" min="0" step="0.5" value="${g.max_total_gb}">
<span class="hint">Over this, the oldest played episodes are deleted first. Starred <span class="hint">Over this, the oldest played items are deleted first. Starred
episodes are never touched.</span></div> items are never touched.</span></div>
<div class="field"><label>Delete episodes older than (days, 0 = keep)</label> <div class="field"><label>Delete items older than (days, 0 = keep)</label>
<input type="number" id="gage" min="0" value="${g.max_age_days}"></div> <input type="number" id="gage" min="0" value="${g.max_age_days}"></div>
<div class="field"><label>Download folder</label> <div class="field"><label>Download folder</label>
<span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div> <span class="hint" style="overflow-wrap:anywhere">${esc(g.download_dir)}</span></div>
@@ -1217,14 +1217,14 @@ function settingsModal(f){
<input type="number" id="smax" min="0" value="${f.max_new_per_check??''}"> <input type="number" id="smax" min="0" value="${f.max_new_per_check??''}">
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for <span class="hint">Blank follows the global default (${globalMax}). The rest wait for
the next scan.</span></div> the next scan.</span></div>
<label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new episodes automatically</label> <label class="check"><input type="checkbox" id="sauto" ${f.auto_download?'checked':''}> Download new items automatically</label>
<label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow episodes marked explicit</label> <label class="check"><input type="checkbox" id="sexp" ${f.allow_explicit?'checked':''}> Allow items marked explicit</label>
<div class="field"><label>Feed URL</label> <div class="field"><label>Feed URL</label>
<div class="inline"> <div class="inline">
<input type="text" id="surl" value="${esc(f.url)}" spellcheck="false"> <input type="text" id="surl" value="${esc(f.url)}" spellcheck="false">
<button type="button" class="btn" id="scopy">Copy</button> <button type="button" class="btn" id="scopy">Copy</button>
</div> </div>
<span class="hint">Editing this keeps every episode and download — handy when an auth token <span class="hint">Editing this keeps every item and download — handy when an auth token
in the URL is rotated. The feed is re-checked from scratch on the next scan.</span></div> in the URL is rotated. The feed is re-checked from scratch on the next scan.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button> <div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="ssave">Save</button></div>`); <button class="btn primary" id="ssave">Save</button></div>`);
@@ -1249,8 +1249,8 @@ function settingsModal(f){
} }
function downloadLatestModal(f){ function downloadLatestModal(f){
openModal(`<h3>Download latest episodes</h3> openModal(`<h3>Download latest items</h3>
<div class="field"><label>How many of the newest undownloaded episodes?</label> <div class="field"><label>How many of the newest undownloaded items?</label>
<input type="number" id="dcount" min="1" max="100" value="5"> <input type="number" id="dcount" min="1" max="100" value="5">
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div> <span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button> <div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
@@ -1260,7 +1260,7 @@ function downloadLatestModal(f){
try{ try{
const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`, const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`,
{method:'POST',body:JSON.stringify({count:n})}); {method:'POST',body:JSON.stringify({count:n})});
closeModal(); toast(`Queued ${r.queued} episode${r.queued===1?'':'s'}`); closeModal(); toast(`Queued ${r.queued} item${r.queued===1?'':'s'}`);
}catch(e){ toast(e.message,true); } }catch(e){ toast(e.message,true); }
}; };
} }