Directory lists the feeds inside an OPML, not the OPML

Popular still counts an OPML as one feed, since everyone subscribed to it
counts for every feed inside and they would bury the rest. The directory is
for finding a show, so it lists them one by one and never the OPML. A feed
inside an OPML that looks private is hidden with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
This commit is contained in:
2026-09-12 02:26:52 +00:00
parent 2af57065c6
commit 457a58dcc5
6 changed files with 55 additions and 30 deletions

View File

@@ -10,6 +10,11 @@ The long form, with what was wrong before and how it was found, is in
## [Unreleased] ## [Unreleased]
### Changed
- Directory lists the feeds inside an OPML one by one, and no longer the OPML itself, so you can
subscribe to just the shows you want. Popular still counts an OPML as one feed.
## [0.5.1] - 2026-09-12 ## [0.5.1] - 2026-09-12
### Fixed ### Fixed

View File

@@ -115,7 +115,7 @@ else a `401`.
| `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning | | `POST /api/enclosures/{id}/download`, `DELETE /api/enclosures/{id}` | `?force=true` overrides the shared-file warning |
| `POST /api/fetch` | | | `POST /api/fetch` | |
| `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML | | `GET /api/opml`, `POST /api/opml` | export your subscriptions; subscribe to every feed in an OPML |
| `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds, and every listable feed A to Z, with everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id | | `GET /api/popular`, `GET /api/directory`, `POST /api/popular/{id}` | the ten most subscribed feeds with an OPML counted as one, and every listable feed A to Z with an OPML's feeds in place of the OPML, with everyone counted (id, title, art, count, whether it is yours; never a URL, never a private feed); subscribe by id |
| `GET /api/settings`, `PATCH /api/settings` | admin-only to write | | `GET /api/settings`, `PATCH /api/settings` | admin-only to write |
| `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed | | `GET /api/users`, `POST /api/users`, `PATCH /api/users/{id}`, `DELETE /api/users/{id}` | admin-only; the only admin cannot be demoted or removed |
| `GET /api/events` | SSE, the same broadcast the socket carries | | `GET /api/events` | SSE, the same broadcast the socket carries |

View File

@@ -73,12 +73,14 @@ re-subscribing does not pull the back catalogue again.
**Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also **Popular** and **Directory** sit at the top of the feed list, above your own feeds. Popular, also
shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you shown in the Add feed dialog, lists the ten feeds with the most subscribers on this server, you
included. Directory lists every one of them A to Z. Your own feeds are marked Subscribed. included. Directory lists every one of them A to Z. Your own feeds are marked Subscribed.
It shows a title, artwork and a count, never a URL or who reads it. Feeds from an It shows a title, artwork and a count, never a URL or who reads it. An OPML subscription is one
OPML subscription are left out, since they come with the OPML. So is anything that looks private: a feed in Popular, since everyone subscribed to it counts for every feed inside; Directory lists the
login configured for the feed, credentials in its URL, or a key such as `auth=` or `token=` in the feeds inside it one by one instead, and never the OPML, so you can take just the shows you want.
query, or a feed from a paid-feed service such as Patreon or Supercast, which put the key in the Anything that looks private is left out: a login configured for the feed, credentials in its URL,
path. Those are someone's paid subscriptions, and listing them would let anyone here read what they or a key such as `auth=` or `token=` in the query, or a feed from a paid-feed service such as
pay for. Patreon or Supercast, which put the key in the path, and any feed inside an OPML that looks private
itself. Those are someone's paid subscriptions, and listing them would let anyone here read what
they pay for.
An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none An admin can do the same from **Settings → Manage users…**: add someone (with a password, or none
for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its for someone the proxy signs in), tick or untick Admin, or remove an account. Removing one takes its

View File

@@ -579,25 +579,36 @@ struct PopularRow {
} }
/// Every feed that may be listed, with everyone counted, you included, most subscribers /// Every feed that may be listed, with everyone counted, you included, most subscribers
/// first. Popular is the top of it, the directory is all of it, and it is all that /// first. Popular is the top of one list and the directory is all of the other, and between
/// `subscribe_popular` will subscribe you to. /// them they are all that `subscribe_popular` will subscribe you to.
fn popular(state: &WebState, user_id: i64) -> Result<Vec<PopularRow>> { ///
/// `folders` says how an OPML appears. Popular lists the OPML once and not the feeds inside:
/// everyone subscribed to it counts for every one of them, and eighty of those would bury
/// everything anyone chose on purpose. The directory, which is for finding a show, lists the
/// feeds inside and never the OPML.
fn popular(state: &WebState, user_id: i64, folders: bool) -> Result<Vec<PopularRow>> {
let db = &state.ctx.db; let db = &state.ctx.db;
let mine: std::collections::HashSet<String> = let mine: std::collections::HashSet<String> =
db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect(); db.subscriptions_for(user_id)?.into_iter().map(|s| s.feed_id).collect();
let counts = db.subscriber_counts()?; let counts = db.subscriber_counts()?;
let catalogue = crate::subscriptions(&state.ctx)?;
let by_id: std::collections::HashMap<&str, &crate::config::Feed> =
catalogue.iter().map(|s| (s.id.as_str(), &s.cfg)).collect();
let is_folder: std::collections::HashSet<&str> =
catalogue.iter().filter_map(|s| s.cfg.group.as_deref()).collect();
let mut out = vec![]; let mut out = vec![];
for s in crate::subscriptions(&state.ctx)? { for s in &catalogue {
let n = counts.get(&s.id).copied().unwrap_or(0); let n = counts.get(&s.id).copied().unwrap_or(0);
// A feed from an OPML rides on the OPML: everyone subscribed to it counts every feed let inside = s.managed || s.cfg.group.is_some();
// inside, which would bury everything anyone chose on purpose. let hidden = if folders { inside } else { is_folder.contains(s.id.as_str()) };
let from_opml = s.managed || s.cfg.group.is_some(); // A feed inside an OPML that looks private is as private as the OPML.
if n == 0 || from_opml || looks_private(&s.cfg) { let folder = s.cfg.group.as_deref().and_then(|g| by_id.get(g));
if n == 0 || hidden || looks_private(&s.cfg) || folder.is_some_and(|f| looks_private(f)) {
continue; continue;
} }
let sum = db.feed_summary(&s.id)?; let sum = db.feed_summary(&s.id)?;
let subscribed = mine.contains(&s.id); let subscribed = mine.contains(&s.id);
out.push(PopularRow { id: s.id, title: sum.title, image: sum.image, subscribers: n, subscribed }); out.push(PopularRow { id: s.id.clone(), title: sum.title, image: sum.image, subscribers: n, subscribed });
} }
out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b)))); out.sort_by(|a, b| b.subscribers.cmp(&a.subscribers).then_with(|| sort_name(a).cmp(&sort_name(b))));
Ok(out) Ok(out)
@@ -607,17 +618,17 @@ async fn get_popular(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> { ) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id)?; let mut rows = popular(&state, user.id, true)?;
rows.truncate(10); rows.truncate(10);
Ok(Json(rows)) Ok(Json(rows))
} }
/// Every feed that may be listed, A to Z. /// Every feed that may be listed, A to Z, with the feeds inside an OPML in place of the OPML.
async fn get_directory( async fn get_directory(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
) -> Result<Json<Vec<PopularRow>>, ApiError> { ) -> Result<Json<Vec<PopularRow>>, ApiError> {
let mut rows = popular(&state, user.id)?; let mut rows = popular(&state, user.id, false)?;
rows.sort_by_key(sort_name); rows.sort_by_key(sort_name);
Ok(Json(rows)) Ok(Json(rows))
} }
@@ -626,15 +637,16 @@ fn sort_name(p: &PopularRow) -> String {
p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase() p.title.clone().unwrap_or_else(|| p.id.clone()).to_lowercase()
} }
/// Subscribes by id, because the list never shows a URL. Checked against the same list, so /// Subscribes by id, because the lists never show a URL. Checked against the same lists, so
/// a guessed id cannot reach a private feed. /// a guessed id cannot reach a private feed.
async fn subscribe_popular( async fn subscribe_popular(
State(state): State<WebState>, State(state): State<WebState>,
user: crate::db::User, user: crate::db::User,
Path(id): Path<String>, Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
if !popular(&state, user.id)?.iter().any(|p| p.id == id) { let listed = |folders| popular(&state, user.id, folders).map(|rows| rows.iter().any(|p| p.id == id));
return Err(ApiError::bad_request(format!("{id:?} is not on the popular list"))); if !(listed(true)? || listed(false)?) {
return Err(ApiError::bad_request(format!("{id:?} is not in the directory")));
} }
state.ctx.db.subscribe(user.id, &id)?; state.ctx.db.subscribe(user.id, &id)?;
Ok(Json(serde_json::json!({ "id": id }))) Ok(Json(serde_json::json!({ "id": id })))

View File

@@ -623,19 +623,26 @@ test('Popular lists what everyone here reads, but never a private feed', async (
expect(listed).not.toContain('.xml'); expect(listed).not.toContain('.xml');
expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400); expect((await piper.request.post('/api/popular/paid-show')).status()).toBe(400);
// Popular is the top ten of the directory, and the directory is every listed feed, A to Z. // The directory is every listed feed A to Z, with an OPML's feeds in place of the OPML.
// Popular is the most subscribed, with an OPML as one feed, so its feeds cannot bury the rest.
const dir = await (await piper.request.get('/api/directory')).json(); const dir = await (await piper.request.get('/api/directory')).json();
const top = await (await piper.request.get('/api/popular')).json(); const top = await (await piper.request.get('/api/popular')).json();
const names = dir.map(p => (p.title || p.id).toLowerCase()); const names = dir.map(p => (p.title || p.id).toLowerCase());
expect(names).toEqual([...names].sort()); expect(names).toEqual([...names].sort());
expect(top.length).toBe(Math.min(10, dir.length)); const ids = dir.map(p => p.id);
expect(top.every(t => dir.some(d => d.id === t.id))).toBe(true); expect(ids).not.toContain('test-subscriptions');
expect(dir.map(p => p.id)).not.toContain('paid-show'); expect(ids).toEqual(expect.arrayContaining(['grouped-show', 'aardvark-radio']));
expect(top.length).toBeLessThanOrEqual(10);
expect(top.map(t => t.id)).not.toContain('grouped-show');
expect(top.filter(t => t.id !== 'test-subscriptions').every(t => ids.includes(t.id))).toBe(true);
expect(ids).not.toContain('paid-show');
// Subscribe from the directory this time; the popular list shares the same rows. // Subscribe from the directory this time; the popular list shares the same rows.
await piper.locator('#feedlist .place', { hasText: 'Directory' }).click(); await piper.locator('#feedlist .place', { hasText: 'Directory' }).click();
await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`); await expect(piper.locator('#count')).toContainText(`Directory: ${dir.length} feed`);
await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible(); await expect(offered.filter({ hasText: 'Test Show' })).toBeVisible();
await expect(offered.filter({ hasText: /Grouped Show|grouped-show/ })).toBeVisible();
await expect(offered.filter({ hasText: /Test Subscriptions/ })).toHaveCount(0);
await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0); await expect(offered.filter({ hasText: /Paid Show|paid-show/ })).toHaveCount(0);
const row = async () => const row = async () =>

View File

@@ -775,9 +775,9 @@ const unreadFirst=(a,b)=>(b.unread>0)-(a.unread>0);
// an id starting with ':' can never be a feed's, since feed ids are slugs. // an id starting with ':' can never be a feed's, since feed ids are slugs.
const VIEWS={ const VIEWS={
':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory', ':directory':{title:'Directory',icon:ICON.directory,url:'/api/directory',
blurb:'Every feed anyone on this server subscribes to, A to Z.'}, blurb:'Every feed anyone on this server subscribes to, A to Z. The feeds inside an OPML are listed one by one, not the OPML.'},
':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular', ':popular':{title:'Popular',icon:ICON.popular,url:'/api/popular',
blurb:'The ten feeds with the most subscribers here.'}, blurb:'The ten feeds with the most subscribers here. An OPML counts as one feed.'},
':all':{title:'All Subscriptions',icon:ICON.all}, ':all':{title:'All Subscriptions',icon:ICON.all},
}; };
function renderFeeds(){ function renderFeeds(){
@@ -1592,8 +1592,7 @@ async function renderListed(v){
<div class="fhead slim"> <div class="fhead slim">
<div class="art">${v.icon}</div> <div class="art">${v.icon}</div>
<div class="meta"><h2>${v.title}</h2> <div class="meta"><h2>${v.title}</h2>
<div class="sub">${v.blurb} Everyone counts, you included. Feeds inside an OPML <div class="sub">${v.blurb} Everyone counts, you included. Private feeds are never listed.</div></div>
subscription, and private feeds, are never listed.</div></div>
</div> </div>
<div class="childlist" id="popular"><p class="hint">Loading…</p></div>`; <div class="childlist" id="popular"><p class="hint">Loading…</p></div>`;
$('#count').textContent=v.title; $('#count').textContent=v.title;