From 772dda515423d12e1530cd12ec5b557264515dd0 Mon Sep 17 00:00:00 2001 From: rays Date: Thu, 10 Sep 2026 19:12:58 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh --- PROGRESS.md | 11 +++++++++ tests/ui/app.spec.js | 12 +++++----- web/index.html | 54 ++++++++++++++++++++++---------------------- 3 files changed, 44 insertions(+), 33 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index af550ab..fd2ac12 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 A scan of 85 feeds fires 85 `feed_done` events, and the UI refreshed on every one: a full sidebar diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index ca051bd..89949db 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -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 expect(page.locator('#list')).toBeVisible(); 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 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 }) => { await page.getByText('Test Show').click(); 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 expect(page.locator('#count')).toContainText('episode'); - expect(await page.locator('.ep').count()).toBeGreaterThanOrEqual(unread); + await page.locator('.tabs button', { hasText: 'Unread' }).first().click(); + expect(await page.locator('.ep').count()).toBeLessThanOrEqual(all); 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 }) => { diff --git a/web/index.html b/web/index.html index 00404f3..2bc8d0f 100644 --- a/web/index.html +++ b/web/index.html @@ -191,7 +191,7 @@ a.btn{text-decoration:none;color:inherit} .tabs button.on{background:var(--raise);color:var(--fg);font-weight:600} .toolbar .grow{flex:1;min-width:150px;max-width:320px} -/* ---------- episodes ---------- */ +/* ---------- items ---------- */ .ep{ 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; @@ -532,7 +532,7 @@ function renderFeeds(){ artHTML(f.image,f.title||f.id)+ `
${esc(f.title||f.id)}`+ (mine.length?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved` - :`${eps} eps · ${saved} saved`)+ + :`${eps} items · ${saved} saved`)+ `
`+ (f.orphaned?'gone':'')+ `${unread>999?'999+':unread}`; @@ -559,11 +559,11 @@ function renderFeed(){ ${artHTML(f.image,f.title||f.id)}

${esc(f.title||f.id)}

-
${f.entries} episodes · ${f.downloaded} downloaded · checked ${ago(f.last_checked)} +
${f.entries} items · ${f.downloaded} downloaded · checked ${ago(f.last_checked)} · every ${everyText(f.every_mins)}${f.next_check?` · next ${due(f.next_check)}`:''}
${f.last_error?`
${esc(f.last_error)}
`:''} ${f.orphaned?`
This feed is no longer listed in its - OPML subscription. It was kept rather than removed because it has downloaded episodes.
`:''} + OPML subscription. It was kept rather than removed because it has downloaded items.
`:''} ${f.group?`
From the OPML subscription ${esc(f.group)}
`:''}
@@ -579,7 +579,7 @@ function renderFeed(){ ${['all','unread','downloaded','flagged'].map(t=> ``).join('')}
- +
`; @@ -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)}; } -/// 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. function renderGroup(f,kids){ const unread=kids.reduce((n,c)=>n+c.unread,0); @@ -638,7 +638,7 @@ function renderGroup(f,kids){ el.className='childrow'; el.innerHTML = artHTML(c.image,c.title||c.id)+ `
${esc(c.title||c.id)}`+ - `${c.entries} eps · ${c.downloaded} downloaded`+ + `${c.entries} items · ${c.downloaded} downloaded`+ (c.last_error?` · error`:'')+`
`+ (c.orphaned?'gone':'')+ `${c.unread}`; @@ -658,7 +658,7 @@ async function feedAction(a,f){ if(a==='dl') downloadLatestModal(f); } -/* ---------------- episodes ---------------- */ +/* ---------------- items ---------------- */ async function loadEntries(append){ if(!S.feed) return; const p=new URLSearchParams({offset:S.offset,limit:LIMIT,filter:S.filter}); @@ -670,7 +670,7 @@ async function loadEntries(append){ } function renderEntries(){ 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; box.innerHTML=''; if(!S.entries.length){ @@ -750,7 +750,7 @@ function isPlayable(x){ .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){ const m=(enc.mime||'').toLowerCase(); if(m.startsWith('image/')) return 'image'; @@ -799,11 +799,11 @@ function dragSplit(){ function showDetail(e){ const box=$('#detail'); if(!box) return; document.body.classList.toggle('reading',!!e); - if(!e){ box.innerHTML='

Pick an episode to read it.

'; return; } + if(!e){ box.innerHTML='

Pick an item to read it.

'; return; } 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(''); box.innerHTML=` - +

${esc(e.title||'(untitled)')}

${f?`${esc(f.title||f.id)}`:''} @@ -873,10 +873,10 @@ async function epAction(a,e,el,encId){ if(!enc) return; S.busy.add(enc.id); await api(`/api/enclosures/${enc.id}/download`,{method:'POST'}); - toast('Queued: '+(e.title||'episode')); + toast('Queued: '+(e.title||'item')); } 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'}); toast('Deleted'); loadEntries(); loadFeeds(true); } @@ -887,8 +887,8 @@ async function epAction(a,e,el,encId){ const audio=$('#audio'); 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%. -// NOT on play: doing that made the episode vanish from the default Unread list the instant +// Marked read when an item has actually been listened to -- at the end, or past 90%. +// 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. function markPlayed(){ if(!player.guid||player.marked) return; @@ -1085,7 +1085,7 @@ $('#addFeed').onclick=()=>{
- Only episodes matching a keyword are downloaded.
+ Only items matching a keyword are downloaded.
`); $('#nurl').focus(); @@ -1159,12 +1159,12 @@ async function prefsModal(){ 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. + put article images in enclosures, and those are not worth keeping. Empty takes everything.
- Over this, the oldest played episodes are deleted first. Starred - episodes are never touched.
-
+ Over this, the oldest played items are deleted first. Starred + items are never touched.
+
${esc(g.download_dir)}
@@ -1217,14 +1217,14 @@ function settingsModal(f){ Blank follows the global default (${globalMax}). The rest wait for the next scan. - - + +
- Editing this keeps every episode and download — handy when an auth token + 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.
`); @@ -1249,8 +1249,8 @@ function settingsModal(f){ } function downloadLatestModal(f){ - openModal(`

Download latest episodes

-
+ openModal(`

Download latest items

+
Queued immediately, ignoring the per-scan limit.
@@ -1260,7 +1260,7 @@ function downloadLatestModal(f){ try{ const r=await api(`/api/feeds/${encodeURIComponent(f.id)}/download-latest`, {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); } }; }