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

@@ -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)+
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
(mine.length?`${mine.length} feed${mine.length===1?'':'s'} · ${saved} saved`
:`${eps} eps · ${saved} saved`)+
:`${eps} items · ${saved} saved`)+
`</small></div>`+
(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>`;
@@ -559,11 +559,11 @@ function renderFeed(){
${artHTML(f.image,f.title||f.id)}
<div class="meta">
<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>
${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
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>`:''}
<div class="acts">
<button class="btn primary" data-a="scan"><i>⟳</i>Scan now</button>
@@ -579,7 +579,7 @@ function renderFeed(){
${['all','unread','downloaded','flagged'].map(t=>
`<button data-f="${t}" class="${S.filter===t?'on':''}">${t[0].toUpperCase()+t.slice(1)}</button>`).join('')}
</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>
</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)};
}
/// 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)+
`<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.orphaned?'<span class="tag">gone</span>':'')+
`<span class="badge${c.unread?'':' zero'}">${c.unread}</span>`;
@@ -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='<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 num=[e.season?`S${e.season}`:'',e.episode?`E${e.episode}`:''].filter(Boolean).join('');
box.innerHTML=`
<button class="btn" id="dback">← Episodes</button>
<button class="btn" id="dback">← Items</button>
<h3 class="dt">${esc(e.title||'(untitled)')}</h3>
<div class="dmeta">
${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;
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=()=>{
<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>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>
<button class="btn primary" id="nsave">Add feed</button></div>`);
$('#nurl').focus();
@@ -1159,12 +1159,12 @@ async function prefsModal(){
<input type="text" id="gtypes" value="${esc((g.media_types||[]).join(', '))}"
placeholder="audio, video">
<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>
<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
episodes are never touched.</span></div>
<div class="field"><label>Delete episodes older than (days, 0 = keep)</label>
<span class="hint">Over this, the oldest played items are deleted first. Starred
items are never touched.</span></div>
<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>
<div class="field"><label>Download folder</label>
<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??''}">
<span class="hint">Blank follows the global default (${globalMax}). The rest wait for
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="sexp" ${f.allow_explicit?'checked':''}> Allow episodes marked explicit</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 items marked explicit</label>
<div class="field"><label>Feed URL</label>
<div class="inline">
<input type="text" id="surl" value="${esc(f.url)}" spellcheck="false">
<button type="button" class="btn" id="scopy">Copy</button>
</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>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn primary" id="ssave">Save</button></div>`);
@@ -1249,8 +1249,8 @@ function settingsModal(f){
}
function downloadLatestModal(f){
openModal(`<h3>Download latest episodes</h3>
<div class="field"><label>How many of the newest undownloaded episodes?</label>
openModal(`<h3>Download latest items</h3>
<div class="field"><label>How many of the newest undownloaded items?</label>
<input type="number" id="dcount" min="1" max="100" value="5">
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
@@ -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); }
};
}