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)+
`
${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)+
`
`+
(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='
${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(`