Subscribe to an OPML, not just import one

A feed whose body sniffs as OPML is treated as a subscription list and
re-read on every scan, as iPodderX did. Listed feeds become real config
entries grouped under it, inherit its settings, land in one nested folder,
and are scanned in the same run.

When a feed leaves the OPML: removed if nothing was downloaded, kept and
flagged otherwise, so a downloaded file is never orphaned.

folder_for sanitized the whole folder string and would have flattened the
nesting; each segment is sanitized separately now, and a traversal still
cannot escape the download directory. Db::memory() also runs migrate(),
which it did not, so a migration-only column passed tests while missing in
production.

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 15:01:03 +00:00
parent 5f6e2a8dc1
commit c86d698363
9 changed files with 355 additions and 16 deletions

View File

@@ -56,6 +56,36 @@ and until now nothing set them.
---
## 2026-09-10 — Subscribing to an OPML, not just importing one
Asked whether this version could do what iPodderX did: subscribe to an OPML and get a folder of the
feeds inside it. It could not — `ipx import` was one-shot from a file and nothing ever re-read it.
The original checked for a URL ending in `.opml` and re-fetched it on every scan
(`iPXClass.py:34-40`).
Now: a feed whose body sniffs as OPML is a subscription list. Sniffing the body rather than the URL
extension also catches an OPML served from a `.rss` or extension-less URL, which the original missed.
Every scan re-reads it; listed feeds become real config entries with `group = "<opml-id>"` inheriting
the parent's settings, land in one nested folder, and are scanned in the same run rather than waiting
an interval.
Removal policy, Ray's rule: **a downloaded file is never orphaned.** Gone from the OPML with nothing
downloaded -> unsubscribed and removed. Gone but with downloads -> kept and flagged `orphaned`, with
the UI saying why. Verified end to end on a local fixture: the feed with 1 download was kept and
flagged, the one with 0 was removed, the file untouched.
`folder_for` had to change: it sanitized the whole folder string, so `sub/Show` would have collapsed
into one directory. Each path segment is sanitized separately now, with a test that `../../etc/Show`
cannot climb out of the download directory.
Also: `Db::memory()` now runs `migrate()` like a real open. It did not, so a column added only in the
migration passed the tests while being absent in production — which is exactly backwards.
Live result: Ray's `lists.opml.org` subscription expanded into 82 child feeds, all grouped, 1431
entries, no feed errors, worker still responsive mid-scan.
---
## 2026-09-10 — Log view in the app, and Docker
**Log view.** A ring buffer (2000 lines) fed by a `tracing` layer, exposed at `/api/logs` with a

View File

@@ -101,6 +101,28 @@ Events: `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `downl
Progress is throttled to whole percents. The stream is a broadcast, so a client attached to a busy
daemon also sees that daemon's other work.
## OPML
Two different things, both supported:
**Importing and exporting** a file copies subscriptions in or out once — `ipx import subs.opml`,
`ipx export subs.opml`, or the OPML button in the UI.
**Subscribing to an OPML URL** is a live subscription, as iPodderX had. Add the OPML's URL like any
other feed; every scan re-reads it and keeps your feed list in step. Feeds it lists are added under
that subscription (`group = "<opml-id>"` in the config, grouped in the sidebar) and downloaded into
one nested folder. New ones are scanned in the same run rather than waiting for the next interval.
An OPML is recognised by its content, so a URL without a `.opml` extension still works.
When a feed drops out of the OPML upstream:
| it has downloads | what happens |
|---|---|
| no | unsubscribed and removed from the config |
| yes | kept, flagged in the UI as no longer listed |
A downloaded file is never left behind with nothing explaining where it came from.
## Web UI
```toml

View File

@@ -97,6 +97,10 @@ pub struct Feed {
pub allow_explicit: bool,
#[serde(default = "yes")]
pub auto_download: bool,
/// Set on feeds that came from a subscribed OPML: the id of the OPML feed they
/// belong to. The OPML is re-read on every scan and this list kept in step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
/// Overrides the global schedule for this feed. Same forms: "every 6h", "2d".
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedule: Option<String>,
@@ -393,7 +397,7 @@ mod tests {
let mut taken = BTreeMap::new();
taken.insert("the-daily".to_string(), Feed {
url: "u".into(), folder: None, schedule: None, keywords: vec![], allow_explicit: false,
url: "u".into(), folder: None, group: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None,
});
@@ -405,6 +409,7 @@ mod tests {
let mut f = Feed {
url: "https://x/y".into(),
folder: None,
group: None,
schedule: None,
keywords: vec![],
allow_explicit: false,

View File

@@ -21,7 +21,9 @@ CREATE TABLE IF NOT EXISTS feeds (
last_modified TEXT,
last_checked INTEGER,
ttl_mins INTEGER,
last_error TEXT
last_error TEXT,
-- Came from a subscribed OPML that no longer lists it, but has downloads, so kept.
orphaned INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS entries (
@@ -68,6 +70,7 @@ CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid);
fn migrate(conn: &Connection) -> Result<()> {
let wanted: &[(&str, &str, &str)] = &[
("feeds", "image", "TEXT"),
("feeds", "orphaned", "INTEGER NOT NULL DEFAULT 0"),
("entries", "image", "TEXT"),
("entries", "duration", "INTEGER"),
("entries", "episode", "INTEGER"),
@@ -92,6 +95,9 @@ fn migrate(conn: &Connection) -> Result<()> {
pub struct FeedSummary {
pub title: Option<String>,
pub image: Option<String>,
/// Came from a subscribed OPML that no longer lists it, but it has downloads, so it
/// was kept rather than removed.
pub orphaned: bool,
pub last_checked: Option<i64>,
pub last_error: Option<String>,
pub entries: i64,
@@ -119,6 +125,9 @@ impl Db {
pub fn memory() -> Result<Self> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(SCHEMA)?;
// Same path as a real open, so a column added only in migrate() cannot pass the
// tests while being missing in production (or the reverse).
migrate(&conn)?;
Ok(Self { conn: Mutex::new(conn) })
}
@@ -132,7 +141,8 @@ impl Db {
let conn = self.conn.lock().unwrap();
let mut sum: FeedSummary = conn
.query_row(
"SELECT title, image, last_checked, last_error FROM feeds WHERE id = ?1",
"SELECT title, image, last_checked, last_error, coalesce(orphaned, 0)
FROM feeds WHERE id = ?1",
[feed_id],
|r| {
Ok(FeedSummary {
@@ -140,6 +150,7 @@ impl Db {
image: r.get(1)?,
last_checked: r.get(2)?,
last_error: r.get(3)?,
orphaned: r.get::<_, i64>(4)? != 0,
..Default::default()
})
},
@@ -703,6 +714,27 @@ impl Db {
Ok(())
}
/// How many files this feed has on disk. Decides whether a feed dropped from an OPML
/// can be removed or must be kept.
pub fn downloaded_count(&self, feed_id: &str) -> Result<i64> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row(
"SELECT count(*) FROM enclosures WHERE feed_id = ?1 AND path IS NOT NULL",
[feed_id],
|r| r.get(0),
)?)
}
pub fn set_orphaned(&self, feed_id: &str, on: bool) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO feeds (id, url, orphaned) VALUES (?1, '', ?2)
ON CONFLICT(id) DO UPDATE SET orphaned = excluded.orphaned",
rusqlite::params![feed_id, on as i64],
)?;
Ok(())
}
/// Nothing can be in flight the moment the daemon starts, so any row still marked
/// `downloading` is a leftover from a restart or a crash. Left alone it would sit
/// there forever: the pending queue skips it and nothing else ever revisits it.

View File

@@ -268,17 +268,27 @@ fn unique_path(dir: &Path, name: &str) -> PathBuf {
}
/// Download folder for a feed: per-feed name, or per-day when organize = "date".
///
/// A folder may name more than one level ("Subscriptions/Some Show") -- feeds from a
/// subscribed OPML nest under it -- so each segment is sanitized separately rather than
/// letting the sanitizer eat the separator.
pub fn folder_for(cfg: &Config, id: &str, feed_cfg: &FeedCfg, title: Option<&str>) -> String {
match cfg.general.organize {
Organize::Date => chrono::Local::now().format("%m-%d-%Y").to_string(),
Organize::Feed => sanitize(
feed_cfg
Organize::Feed => {
let raw = feed_cfg
.folder
.as_deref()
.or(title)
.filter(|s| !s.trim().is_empty())
.unwrap_or(id),
),
.unwrap_or(id);
raw.split('/')
.map(str::trim)
.filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
.map(sanitize)
.collect::<Vec<_>>()
.join("/")
}
}
}
@@ -370,6 +380,22 @@ mod tests {
);
}
#[test]
fn a_folder_can_nest_without_the_sanitizer_eating_the_separator() {
let mut cfg = Config::default();
cfg.general.download_dir = "/tmp".into();
let mut f = crate::config::Feed {
url: "u".into(), folder: Some("Subscriptions/Some | Show".into()), group: None,
schedule: None, keywords: vec![], allow_explicit: false, auto_download: true,
max_new_per_check: None, username: None, password: None, password_env: None,
};
assert_eq!(folder_for(&cfg, "id", &f, None), "Subscriptions/Some - Show");
// A traversal in a folder name must not climb out of the download directory.
f.folder = Some("../../etc/Show".into());
assert_eq!(folder_for(&cfg, "id", &f, None), "etc/Show");
}
#[test]
fn keyword_matching_is_or_across_keywords_and_and_within_one() {
let kws = vec!["deep dive".to_string(), "interview".to_string()];

View File

@@ -87,6 +87,36 @@ pub async fn fetch(
Ok(Fetched::Body { bytes, etag, last_modified })
}
/// True when a body is an OPML document rather than a feed.
///
/// The original matched on the URL ending in ".opml" (iPXClass.py:34), which misses an
/// OPML served from a URL without that extension. Sniffing the body catches both.
pub fn is_opml(bytes: &[u8]) -> bool {
let head = &bytes[..bytes.len().min(1024)];
let text = String::from_utf8_lossy(head).to_lowercase();
text.contains("<opml")
}
/// The feeds listed in an OPML document, as (title, xml_url), walking nested folders.
pub fn parse_opml(bytes: &[u8]) -> Result<Vec<(String, String)>> {
let text = String::from_utf8_lossy(bytes);
let doc = opml::OPML::from_str(&text)
.map_err(|e| anyhow!("that does not parse as OPML: {e}"))?;
let mut out = vec![];
crate::collect_outlines(&doc.body.outlines, &mut out);
Ok(out)
}
/// The <head><title> of an OPML document.
pub fn opml_title(bytes: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(bytes);
let doc = opml::OPML::from_str(&text).ok()?;
doc.head
.and_then(|h| h.title)
.map(|t| t.trim().to_owned())
.filter(|t| !t.is_empty())
}
/// RSS first, then Atom -- the same split the original made on `parsedFeed.version`.
pub fn parse(bytes: &[u8]) -> Result<ParsedFeed> {
match rss::Channel::read_from(bytes) {
@@ -391,6 +421,27 @@ mod tests {
assert_eq!((p1.season, p1.episode), (Some(8), Some(1)));
}
#[test]
fn opml_is_recognised_and_its_feeds_listed() {
let xml = br#"<opml version="2.0"><head><title>My Subscriptions</title></head><body>
<outline text="Folder">
<outline type="rss" text="Alpha" xmlUrl="https://a.example/rss"/>
<outline type="rss" text="Beta" xmlUrl="https://b.example/rss"/>
</outline>
<outline text="Not a feed"/>
</body></opml>"#;
assert!(is_opml(xml));
assert_eq!(opml_title(xml).as_deref(), Some("My Subscriptions"));
let feeds = parse_opml(xml).unwrap();
assert_eq!(feeds.len(), 2, "nested folders are walked, non-feed outlines skipped");
assert_eq!(feeds[0], ("Alpha".into(), "https://a.example/rss".into()));
// A feed must never be mistaken for a subscription list.
assert!(!is_opml(include_bytes!("../tests/data/rss2.xml")));
assert!(!is_opml(include_bytes!("../tests/data/atom.xml")));
}
#[test]
fn durations_parse_from_seconds_or_a_clock() {
assert_eq!(parse_duration("5649"), Some(5649));

View File

@@ -89,6 +89,8 @@ pub struct Ctx {
pub torrents: tokio::sync::OnceCell<torrent::Torrents>,
/// Caps how many torrents run at once when they are detached.
pub torrent_slots: Arc<tokio::sync::Semaphore>,
/// Needed because a subscribed OPML rewrites the feed list as it syncs.
pub config_path: PathBuf,
/// Run torrents off the command worker. A torrent takes minutes to fetch metadata and
/// then seeds for up to an hour, and the worker is sequential -- inline, one torrent
/// stops every feed scan, every HTTP download and every status command behind it.
@@ -170,6 +172,7 @@ async fn main() -> Result<()> {
out: if is_daemon { Emitter::socket(events.clone(), false) } else { Emitter::terminal() },
torrents: tokio::sync::OnceCell::new(),
torrent_slots: Arc::new(tokio::sync::Semaphore::new(2)),
config_path: config_path.clone(),
detach_torrents: is_daemon,
});
@@ -382,6 +385,7 @@ pub async fn add_one(
let probe = config::Feed {
url: url.to_owned(),
folder: folder.clone(),
group: None,
schedule: None,
keywords: keywords.clone(),
allow_explicit: false,
@@ -393,6 +397,11 @@ pub async fn add_one(
};
let title = match feed::fetch(&ctx.client, &probe, None, None).await {
// An OPML subscription is named from its own <head><title>, not by trying to
// parse it as a feed and falling back to the hostname.
Ok(feed::Fetched::Body { bytes, .. }) if feed::is_opml(&bytes) => {
feed::opml_title(&bytes).unwrap_or_else(|| url_stem(url))
}
Ok(feed::Fetched::Body { bytes, .. }) => feed::parse(&bytes)
.ok()
.and_then(|f| f.title)
@@ -448,6 +457,7 @@ async fn import(ctx: &Ctx, config_path: &std::path::Path, file: &std::path::Path
config::Feed {
url,
folder: None,
group: None,
schedule: None,
keywords: vec![],
allow_explicit: false,
@@ -546,6 +556,7 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
}
let mut scanned = 0;
let mut fresh: Vec<String> = vec![];
for (id, feed_cfg) in cfg
.feeds
.iter()
@@ -567,17 +578,29 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
scanned += 1;
ctx.out.emit(Event::FeedStart { feed: id.clone() });
match scan_one(ctx, id, feed_cfg, &state).await {
Ok(Some(s)) => ctx.out.emit(Event::FeedDone {
Ok(Outcome::Feed(s)) => ctx.out.emit(Event::FeedDone {
feed: id.clone(),
new: s.new_entries,
downloaded: s.downloaded,
failed: s.failed,
torrents: s.torrents,
}),
Ok(None) => ctx.out.emit(Event::FeedSkip {
Ok(Outcome::NotModified) => ctx.out.emit(Event::FeedSkip {
feed: id.clone(),
reason: "not modified".into(),
}),
Ok(Outcome::Opml { added, removed, kept, total }) => {
ctx.out.emit(Event::FeedSkip {
feed: id.clone(),
reason: format!(
"OPML: {total} feed(s) listed, {} added, {removed} unsubscribed, {kept} kept without a listing",
added.len()
),
});
// Read them in the same pass, as the original did, rather than making
// the user wait a whole interval for a newly listed show.
fresh.extend(added);
}
Err(e) => {
// One bad feed must not end the scan.
let msg = format!("{e:#}");
@@ -586,6 +609,31 @@ async fn fetch(ctx: &Arc<Ctx>, only: Option<&str>, force: bool) -> Result<()> {
}
}
}
// Feeds a subscribed OPML just introduced: scan them now, in this run.
if !fresh.is_empty() {
let cfg = ctx.cfg();
for id in &fresh {
let Some(feed_cfg) = cfg.feeds.get(id) else { continue };
scanned += 1;
ctx.out.emit(Event::FeedStart { feed: id.clone() });
let state = ctx.db.http_state(id)?;
match scan_one(ctx, id, feed_cfg, &state).await {
Ok(Outcome::Feed(s)) => ctx.out.emit(Event::FeedDone {
feed: id.clone(),
new: s.new_entries,
downloaded: s.downloaded,
failed: s.failed,
torrents: s.torrents,
}),
Ok(_) => {}
Err(e) => {
let msg = format!("{e:#}");
ctx.out.emit(Event::FeedError { feed: id.clone(), msg: msg.clone() });
ctx.db.set_feed_error(id, &feed_cfg.url, &msg)?;
}
}
}
}
ctx.out.emit(Event::ScanDone { feeds: scanned });
Ok(())
}
@@ -611,13 +659,20 @@ struct Scan {
torrents: usize,
}
/// Ok(None) means 304.
/// What a scan of one feed turned out to be.
enum Outcome {
NotModified,
Feed(Scan),
/// The URL served an OPML document, so it is a subscription list rather than a feed.
Opml { added: Vec<String>, removed: usize, kept: usize, total: usize },
}
async fn scan_one(
ctx: &Arc<Ctx>,
id: &str,
feed_cfg: &config::Feed,
state: &db::HttpState,
) -> Result<Option<Scan>> {
) -> Result<Outcome> {
let fetched = feed::fetch(
&ctx.client,
feed_cfg,
@@ -629,11 +684,18 @@ async fn scan_one(
let (bytes, etag, last_modified) = match fetched {
feed::Fetched::NotModified => {
ctx.db.touch_feed(id, &feed_cfg.url)?;
return Ok(None);
return Ok(Outcome::NotModified);
}
feed::Fetched::Body { bytes, etag, last_modified } => (bytes, etag, last_modified),
};
// A subscribed OPML is a list of feeds, not a feed. The original matched on a ".opml"
// URL; sniffing the body also catches one served from a URL without that extension.
if feed::is_opml(&bytes) {
ctx.db.touch_feed(id, &feed_cfg.url)?;
return sync_opml(ctx, id, feed_cfg, &bytes).await;
}
let parsed = feed::parse(&bytes)?;
ctx.db.record_feed(
id,
@@ -738,7 +800,88 @@ async fn scan_one(
}
}
Ok(Some(scan))
Ok(Outcome::Feed(scan))
}
/// Brings the feed list in step with a subscribed OPML.
///
/// New entries are added under the OPML's group and folder. An entry that has gone from
/// the OPML is unsubscribed *only if nothing was ever downloaded for it* -- otherwise it
/// is kept and flagged, because dropping it would orphan files on disk with nothing in
/// the UI to explain them.
async fn sync_opml(
ctx: &Arc<Ctx>,
parent_id: &str,
parent: &config::Feed,
bytes: &[u8],
) -> Result<Outcome> {
let listed = feed::parse_opml(bytes)?;
let mut cfg = (*ctx.cfg()).clone();
let base_folder = parent
.folder
.clone()
.or_else(|| ctx.db.feed_summary(parent_id).ok().and_then(|s| s.title))
.unwrap_or_else(|| parent_id.to_owned());
let mut added = vec![];
for (title, url) in &listed {
if let Some((id, _)) = cfg.feeds.iter().find(|(_, f)| &f.url == url) {
// Already subscribed. If it had been flagged as gone, it is back.
let id = id.clone();
let _ = ctx.db.set_orphaned(&id, false);
continue;
}
let id = config::unique_slug(title, &cfg.feeds);
cfg.feeds.insert(
id.clone(),
config::Feed {
url: url.clone(),
// Nested, so the whole subscription lands in one folder.
folder: Some(format!("{base_folder}/{title}")),
group: Some(parent_id.to_owned()),
schedule: parent.schedule.clone(),
keywords: parent.keywords.clone(),
allow_explicit: parent.allow_explicit,
auto_download: parent.auto_download,
max_new_per_check: parent.max_new_per_check,
username: parent.username.clone(),
password: parent.password.clone(),
password_env: parent.password_env.clone(),
},
);
added.push(id);
}
// Anything in this group the OPML no longer lists.
let gone: Vec<String> = cfg
.feeds
.iter()
.filter(|(_, f)| f.group.as_deref() == Some(parent_id))
.filter(|(_, f)| !listed.iter().any(|(_, u)| u == &f.url))
.map(|(id, _)| id.clone())
.collect();
let mut removed = 0;
let mut kept = 0;
for id in gone {
if ctx.db.downloaded_count(&id).unwrap_or(1) > 0 {
// Never orphan a downloaded file: keep the feed and say why in the UI.
ctx.db.set_orphaned(&id, true)?;
kept += 1;
tracing::info!(feed = %id, "dropped from the OPML but has downloads; keeping it");
} else {
cfg.feeds.remove(&id);
removed += 1;
tracing::info!(feed = %id, "dropped from the OPML with nothing downloaded; unsubscribed");
}
}
if !added.is_empty() || removed > 0 {
cfg.save(&ctx.config_path)?;
ctx.reload_cfg(&ctx.config_path)?;
}
Ok(Outcome::Opml { added, removed, kept, total: listed.len() })
}
/// Why this enclosure should not be downloaded, if it should not be.

View File

@@ -155,6 +155,10 @@ struct FeedRow {
allow_explicit: bool,
auto_download: bool,
max_new_per_check: Option<usize>,
/// The OPML subscription this feed came from, if any.
group: Option<String>,
/// In a group, but the OPML no longer lists it. Kept because it has downloads.
orphaned: bool,
schedule: Option<String>,
/// The feed's own override in minutes, so the UI need not re-parse the string.
schedule_mins: Option<u64>,
@@ -184,6 +188,8 @@ async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiE
allow_explicit: feed.allow_explicit,
auto_download: feed.auto_download,
max_new_per_check: feed.max_new_per_check,
group: feed.group.clone(),
orphaned: s.orphaned,
schedule: feed.schedule.clone(),
schedule_mins: feed
.schedule
@@ -271,7 +277,7 @@ mod tests {
fn feed(url: &str) -> crate::config::Feed {
crate::config::Feed {
url: url.into(), folder: None, schedule: None, keywords: vec![], allow_explicit: false,
url: url.into(), folder: None, group: None, schedule: None, keywords: vec![], allow_explicit: false,
auto_download: true, max_new_per_check: None, username: None,
password: None, password_env: None,
}
@@ -722,6 +728,7 @@ async fn import_opml(
crate::config::Feed {
url,
folder: None,
group: None,
schedule: None,
keywords: vec![],
allow_explicit: false,

View File

@@ -92,6 +92,13 @@ input:focus,select:focus{outline:0;border-color:var(--accent)}
}
.feed:hover{background:var(--panel2)}
.feed.sel{background:var(--raise)}
.feed.child{margin-left:14px}
.feed.child .art{width:28px;height:28px;font-size:11px}
.feed.group>.txt>b::after{content:" ⌄";color:var(--faint);font-weight:400}
.tag{
font-size:10px;text-transform:uppercase;letter-spacing:.04em;font-weight:700;
padding:1px 5px;border-radius:4px;background:var(--raise);color:var(--warn);flex:none;
}
.art{
border-radius:7px;object-fit:cover;background:var(--raise);flex:none;
display:grid;place-items:center;color:var(--faint);font-weight:700;overflow:hidden;
@@ -404,11 +411,24 @@ function renderFeeds(){
const list=$('#feedlist'); list.innerHTML='';
const shown=S.feeds.filter(f=>!q||(f.title||f.id).toLowerCase().includes(q));
if(!shown.length){ list.innerHTML='<p class="empty" style="padding:20px 8px">No feeds.</p>'; return; }
// Feeds from a subscribed OPML sit under it, so the group reads as one thing.
const byId=Object.fromEntries(shown.map(f=>[f.id,f]));
const order=[];
for(const f of shown){
if(f.group && byId[f.group]) continue; // drawn under its parent instead
order.push([f,0]);
for(const c of shown) if(c.group===f.id) order.push([c,1]);
}
for(const [f,depth] of order){
const kids=shown.filter(c=>c.group===f.id).length;
const el=document.createElement('div');
el.className='feed'+(S.feed===f.id?' sel':'');
el.className='feed'+(S.feed===f.id?' sel':'')+(depth?' child':'')+(kids?' group':'');
el.innerHTML = artHTML(f.image,f.title||f.id)+
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>${f.entries} eps · ${f.downloaded} saved</small></div>`+
`<div class="txt"><b>${esc(f.title||f.id)}</b><small>`+
(kids?`${kids} feed${kids===1?'':'s'}`:`${f.entries} eps · ${f.downloaded} 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${f.unread?'':' zero'}">${f.unread}</span>`;
el.onclick=()=>{ selectFeed(f.id); $('#sidebar').classList.remove('open'); };
list.appendChild(el);
@@ -431,6 +451,9 @@ function renderFeed(){
<div class="sub">${f.entries} episodes · ${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>`:''}
${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">Scan now</button>
<button class="btn" data-a="dl">Download latest…</button>