Podcast / RSS feeds for the library (web, part 1)

Subscribe to the whole library or a single channel in any podcast/media
app.

- src/feed.rs: pure RSS 2.0 + iTunes-namespace renderer with absolute
  enclosure URLs, MIME-by-extension, HH:MM:SS durations, and a
  chrono-free RFC-2822 date (civil-date algorithm). Unit-tested
  (mime / escape / duration / date / render).
- web.rs: GET /feed.xml (whole library, newest first, capped at 300) and
  GET /feed/:platform/:handle (one channel); /api/feed-info returns the
  token for the UI. Enclosure + thumbnail URLs are absolute (derived from
  Host + X-Forwarded-Proto) and carry the feed token.
- Auth: a stable read-only `feed_token` (persisted setting) lets a
  tokenized `?token=` GET reach /feed*, /files, and /music-files even when
  a password is set — podcast clients can't do the cookie login. Scoped to
  reads of feeds + media; never /api mutations.
- Web UI: a "📡 Podcast feed" section in Settings (library URL + Copy) and
  a "📡 Feed URL" button in each channel's options dialog.

Integration tests cover the served RSS (enclosure path, MIME, pubDate),
the channel feed + 404, and the full token gate (401 without token once a
password is set, 200 with it, /files reachable with it). 127 tests pass.

Desktop URL display follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-07 07:49:48 -07:00
parent 4800a0fc22
commit 2692fa2c9e
5 changed files with 480 additions and 0 deletions

View file

@ -1041,6 +1041,7 @@ async function openChannelOptions(idx){
<textarea id="opt-extra" rows="3" placeholder="--no-mtime
--ignore-config" style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-family:monospace;font-size:12px">${esc(extras)}</textarea></div>
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
<button onclick="copyChannelFeed('${esc(platform)}','${esc(handle)}',this)" title="Copy this channel's podcast feed URL" style="margin-right:auto">📡 Feed URL</button>
<button onclick="clearChannelOptions('${esc(platform)}','${esc(handle)}',this)">Clear all</button>
<button onclick="this.closest('.modal-bg').remove()">Cancel</button>
<button class="primary" onclick="saveChannelOptions('${esc(platform)}','${esc(handle)}',this)">Save</button>
@ -1508,6 +1509,24 @@ function renderComments(){
}
}
/* ── Podcast feed URLs ──────────────────────────────────────────── */
function copyFeedUrl(id,btn){
const el=document.getElementById(id);if(!el||!el.value)return;
el.select();
const done=()=>{if(btn){const t=btn.textContent;btn.textContent='✓ Copied';setTimeout(()=>btn.textContent=t,1200)}};
if(navigator.clipboard)navigator.clipboard.writeText(el.value).then(done).catch(()=>{document.execCommand('copy');done()});
else{document.execCommand('copy');done()}
}
async function copyChannelFeed(platform,handle,btn){
try{
const {token}=await(await api('/api/feed-info')).json();
const url=`${location.origin}/feed/${encodeURIComponent(platform)}/${encodeURIComponent(handle)}?token=${token}`;
if(navigator.clipboard)await navigator.clipboard.writeText(url);
if(btn){const t=btn.textContent;btn.textContent='✓ Copied';setTimeout(()=>btn.textContent=t,1200)}
setStatus('Channel feed URL copied to clipboard');
}catch(e){setStatus('Error: '+e.message)}
}
/* ── Full-text library search ───────────────────────────────────── */
let ftsSeq=0;
function openSearch(){
@ -1665,6 +1684,15 @@ async function openSettings(){
}else if(ck.exists&&ck.days_left!=null&&ck.days_left<=3){
cookieWarn=`<div class="settings-hint" style="color:#facc15">⚠ Login cookies expire in ${ck.days_left}d. Refresh soon to avoid captcha errors.</div>`;
}
let feed={token:''};try{feed=await(await api('/api/feed-info')).json()}catch{}
const feedUrl=feed.token?`${location.origin}/feed.xml?token=${feed.token}`:'';
const feedRow=`<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">📡 Podcast feed</div>
<div class="settings-hint" style="margin-bottom:6px">Subscribe to your whole library in any podcast / media app. The token in the URL grants read-only access to the feed and its media — so it works even with a password set. Treat it like a share link.</div>
<div class="settings-row" style="gap:6px">
<input type="text" id="cf-feed-url" readonly value="${esc(feedUrl)}" onclick="this.select()" style="flex:1;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-size:12px">
<button onclick="copyFeedUrl('cf-feed-url',this)">Copy</button>
</div>`;
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};
bg.innerHTML=`<div class="modal" style="max-width:420px;overflow-y:auto">
<div class="modal-hdr" style="position:sticky;top:0;background:var(--panel);z-index:1;padding-bottom:6px"><h2>Settings</h2></div>
@ -1688,6 +1716,7 @@ async function openSettings(){
<div class="settings-hint">Gates the whole UI and all API access. Leave empty to disable on save; changing it logs out other sessions.</div>
</div>
${bindRows}
${feedRow}
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<label>Cookies (cookies.txt)</label>
<div class="settings-hint" id="cookies-status">${cookiesStatus}</div>