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:
11
PROGRESS.md
11
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
|
## 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
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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); }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user