Skip to content

Commit fa0d7e3

Browse files
ralyodioclaude
andauthored
feat(directory): a category leads with what has just been published (#218)
/podcasts opened on whichever show the importer reached last and showed no dates at all, so a directory of 318,000 podcasts fronted shows that had published nothing for six weeks and gave a reader no way to tell. Two changes to the same complaint: * the river. `view=latest` — now the default for podcasts — is the newest episodes across every show, picked by `latestItems`: the 400 feeds with the newest material, then their items through feed_items_feed_pub_idx, rather than the feeds-to-items aggregate 0030 measured at 215 seconds. New index feeds_category_published_idx makes that pick affordable for the rare categories the river was built for. * the roster. `view=shows` is ordered by when each feed last published and says so on every row ("Latest episode Aug 12, 2026", with the gap on hover). Feeds with no publish date yet — 42,000 still waiting on a first crawl, including everything submitted this week — keep their arrival order and follow the dated ones, so the pager still reaches them and a submitter can still watch their feed turn up. listFeedsByPublished is deliberately two queries rather than one `nulls last`: that ordering cannot be served by an index here, and sorting a category of 318,000 rows on every request is not a page load. Also on this branch: per-category follow filters, and the directory feed and following feed follow the view they advertise. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ca121ad commit fa0d7e3

18 files changed

Lines changed: 1473 additions & 125 deletions

File tree

‎apps/web/next.config.mjs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,21 @@ const nextConfig = {
257257
destination: '/api/fb/:page/feed/:format',
258258
},
259259

260+
// The playlist spellings, for the categories that publish their own
261+
// entries at this address rather than the feeds that carry them — the
262+
// ones marked `river` in CATEGORIES, which today is podcasts alone.
263+
// /podcasts.m3u is the newest episode of every show in one file.
264+
//
265+
// Named before the rule below rather than folded into its format list,
266+
// because every other category's `.m3u` must keep 404ing: a directory
267+
// entry is a feed, and a playlist of feeds is an empty file with a
268+
// confident name. apps/web/test/subscribe.test.js asserts this list is
269+
// exactly the river categories.
270+
{
271+
source: '/:kind(podcasts).:format(m3u|pls)',
272+
destination: '/api/directory/:kind/feed/:format',
273+
},
274+
260275
// One category of it. The segments are the category pages' own paths,
261276
// duplicated from CATEGORIES in apps/web/src/lib/categories.js — this
262277
// file is evaluated before the workspace resolves, so it cannot import
@@ -314,6 +329,11 @@ const nextConfig = {
314329
// type looking for it, including us.
315330
{ source: '/crawlstatus', destination: '/crawlstats', permanent: true },
316331

332+
// The river lives at /following; /follow is what it gets called. Query
333+
// preserved by default, which is the half that matters now the page
334+
// takes a `?kind=` filter — /follow?kind=podcast has to arrive intact.
335+
{ source: '/follow', destination: '/following', permanent: true },
336+
317337
// /register is also where an OAuth client looks for dynamic registration
318338
// when it has found no metadata anywhere else, and it POSTs JSON to do
319339
// it. A redirect to a page of HTML is a confusing answer to that, so a

‎apps/web/src/app/CategoryIndex.jsx‎

Lines changed: 237 additions & 53 deletions
Large diffs are not rendered by default.

‎apps/web/src/app/api/directory/[kind]/feed/[format]/route.js‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,12 @@ export async function GET(req, { params }) {
1818
const { kind, format } = await params;
1919
const url = new URL(req.url);
2020

21-
return directoryRiver({ kind, format, limit: url.searchParams.get('limit') });
21+
return directoryRiver({
22+
kind,
23+
format,
24+
limit: url.searchParams.get('limit'),
25+
// The caller's own query does survive the rewrite, which is what lets
26+
// `/podcasts.rss?view=shows` ask for the other half of the page.
27+
view: url.searchParams.get('view'),
28+
});
2229
}

‎apps/web/src/app/api/following/feed/[format]/route.js‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@ import {
88

99
import { db, siteUrl } from '../../../../../lib/db.js';
1010
import { fetchFeedAds } from '../../../../../lib/feedAds.js';
11-
import { RIVER_LIMIT, following, followingFeedUrl } from '../../../../../lib/following.js';
11+
import { CATEGORIES } from '../../../../../lib/categories.js';
12+
import {
13+
RIVER_LIMIT,
14+
following,
15+
followingFeedUrl,
16+
riverKinds,
17+
} from '../../../../../lib/following.js';
1218

1319
export const dynamic = 'force-dynamic';
1420

@@ -42,7 +48,12 @@ export async function GET(req, { params }) {
4248
return fail(format, 404, `unsupported format: ${format}`, 'Supported: rss, atom, json, xml');
4349
}
4450

45-
const token = new URL(req.url).searchParams.get('t') ?? '';
51+
const query = new URL(req.url).searchParams;
52+
const token = query.get('t') ?? '';
53+
// The same filter the page offers as chips, so a reader who narrowed the
54+
// river to podcasts and then took the feed away gets the podcasts. Anything
55+
// unrecognised is the whole river, exactly as on the page.
56+
const kinds = riverKinds(query.get('kind'));
4657
const client = db();
4758
const user = await accounts.userByFeedToken(client, token);
4859

@@ -60,6 +71,7 @@ export async function GET(req, { params }) {
6071

6172
const { feeds, topics, authors, items } = await following(client, String(user.id), {
6273
limit: RIVER_LIMIT,
74+
kinds,
6375
});
6476

6577
const origin = siteUrl();
@@ -84,14 +96,21 @@ export async function GET(req, { params }) {
8496
const body = buildSyndication(
8597
format,
8698
{
87-
title: 'Following — RSS Amplifier',
88-
description: `Recent posts from the ${count(topics.length, 'topic')}, ${count(
99+
// A filtered river says so in its own title: a reader subscribed to both
100+
// this and the whole thing must be able to tell them apart in a list of
101+
// feeds, and "Following" twice over cannot.
102+
title: kinds
103+
? `Following: ${CATEGORIES[kinds[0]].heading} — RSS Amplifier`
104+
: 'Following — RSS Amplifier',
105+
description: `Recent ${
106+
kinds ? CATEGORIES[kinds[0]].item : 'posts'
107+
} from the ${count(topics.length, 'topic')}, ${count(
89108
authors.length,
90109
'person',
91110
'people',
92111
)} and ${count(feeds.length, 'blog')} this RSS Amplifier account follows.`,
93-
link: `${origin}/following`,
94-
selfUrl: followingFeedUrl(origin, token, format),
112+
link: `${origin}/following${kinds ? `?kind=${kinds[0]}` : ''}`,
113+
selfUrl: followingFeedUrl(origin, token, format, kinds),
95114
},
96115
interleaveAds(rows, ads),
97116
);

‎apps/web/src/app/following/page.jsx‎

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ import { FILTER_FROM } from '../../lib/listFilter.js';
99
import { db, siteUrl } from '../../lib/db.js';
1010
import { currentUser, hasSessionCookie } from '../../lib/auth.js';
1111
import { postThumb } from '../../lib/thumbs.js';
12+
import { CATEGORIES } from '../../lib/categories.js';
1213
import {
1314
RIVER_LIMIT,
1415
RIVER_AUTHORS,
1516
RIVER_TOPICS,
1617
following as loadFollowing,
1718
followingFeedUrl,
19+
kindsAvailable,
20+
riverKinds,
1821
topicLabel,
1922
} from '../../lib/following.js';
2023

@@ -67,7 +70,14 @@ export async function generateMetadata() {
6770
* about this" — but they have the same answer shape, and a reader who has asked
6871
* both wants one list back rather than two pages to check.
6972
*
70-
* @param {{ searchParams: Promise<{ feed?: string, rotated?: string }> }} props
73+
* `?kind=` narrows the river to one category of the directory — podcasts,
74+
* videos, blogs — without touching what is followed. That distinction is the
75+
* whole design of it: the lists above the river are the inventory of follows
76+
* and stay whole, because a filter that hid follows would look like an
77+
* unfollow, and the river below is the reading, which is what somebody
78+
* narrowing to "podcasts" actually means.
79+
*
80+
* @param {{ searchParams: Promise<{ feed?: string, rotated?: string, kind?: string }> }} props
7181
*/
7282
export default async function FollowingPage({ searchParams }) {
7383
const params = await searchParams;
@@ -82,14 +92,17 @@ export default async function FollowingPage({ searchParams }) {
8292

8393
const client = db();
8494
const userId = String(user.id);
95+
const kinds = riverKinds(params.kind);
8596

8697
const [{ feeds, topics, authors, items, topicsUsed, authorsUsed }, token] = await Promise.all([
87-
loadFollowing(client, userId, { limit: RIVER_LIMIT }),
98+
loadFollowing(client, userId, { limit: RIVER_LIMIT, kinds }),
8899
accounts.feedToken(client, userId),
89100
]);
90101

91102
const origin = siteUrl();
92103
const nothing = feeds.length === 0 && topics.length === 0 && authors.length === 0;
104+
const filters = kindsAvailable({ feeds, topics, authors });
105+
const filtered = kinds?.[0] ?? null;
93106

94107
return (
95108
<>
@@ -220,20 +233,32 @@ export default async function FollowingPage({ searchParams }) {
220233
asking your reader to sign in: it grants read of these posts and nothing else — it cannot
221234
sign in, and it cannot change what you follow. Treat it as private anyway, because it says
222235
what you read.
236+
{/* The filter travels with the address, so "just the podcasts" is
237+
something to subscribe to rather than something to remember to do
238+
each visit. Said out loud because the URL below changes under the
239+
reader as they press the chips, and silently handing them a
240+
different feed than the one they copied last time would be worse. */}
241+
{filtered && (
242+
<>
243+
{' '}
244+
While the river is filtered, these addresses carry the filter: they are your{' '}
245+
{CATEGORIES[filtered].heading.toLowerCase()} alone.
246+
</>
247+
)}
223248
</p>
224249

225250
{token ? (
226251
<>
227252
<p className="format-links">
228253
<span>Subscribe:</span>
229254
{['rss', 'atom', 'json'].map((ext) => (
230-
<a key={ext} href={followingFeedUrl(origin, token, ext)}>
255+
<a key={ext} href={followingFeedUrl(origin, token, ext, kinds)}>
231256
{`.${ext}`}
232257
</a>
233258
))}
234259
</p>
235260
<p className="hint">
236-
<code>{followingFeedUrl(origin, token, 'rss')}</code>
261+
<code>{followingFeedUrl(origin, token, 'rss', kinds)}</code>
237262
</p>
238263
<form action="/api/following/token" method="post" className="submit-actions">
239264
<input type="hidden" name="action" value="rotate" />
@@ -251,6 +276,34 @@ export default async function FollowingPage({ searchParams }) {
251276

252277
<h2>Latest</h2>
253278

279+
{/* By kind, not by follow. Plain links in the same nav the topic pages
280+
use for their sub-groups, so it works with JavaScript off and so each
281+
filtered river is an address a reader can bookmark or hand to
282+
somebody. Only the kinds this account actually follows are offered —
283+
see kindsAvailable — and the bar is left out entirely when there is
284+
only one, because a filter with a single setting filters nothing. */}
285+
{filters.length > 1 && (
286+
<nav className="topic-groups" aria-label="Filter the river by kind">
287+
<a
288+
href="/following"
289+
aria-current={filtered ? undefined : 'page'}
290+
className={filtered ? undefined : 'is-current'}
291+
>
292+
Everything
293+
</a>
294+
{filters.map((kind) => (
295+
<a
296+
key={kind}
297+
href={`/following?kind=${kind}`}
298+
aria-current={kind === filtered ? 'page' : undefined}
299+
className={kind === filtered ? 'is-current' : undefined}
300+
>
301+
{CATEGORIES[kind].heading}
302+
</a>
303+
))}
304+
</nav>
305+
)}
306+
254307
{/* The river is the reason to come back to this page, and it is the one
255308
list here long enough that finding a post in it means scrolling. */}
256309
{items.length >= FILTER_FROM && (
@@ -259,9 +312,20 @@ export default async function FollowingPage({ searchParams }) {
259312

260313
{items.length === 0 ? (
261314
<p className="empty">
262-
{nothing
263-
? 'Nothing to show until you follow something.'
264-
: 'Nothing published recently by anything you follow.'}
315+
{nothing ? (
316+
'Nothing to show until you follow something.'
317+
) : filtered ? (
318+
// Names the filter as the reason. Without this a reader who
319+
// narrowed to podcasts last week and came back to the bookmark
320+
// would read an empty page as everything they follow having gone
321+
// quiet at once.
322+
<>
323+
Nothing published recently under {CATEGORIES[filtered].heading.toLowerCase()}.{' '}
324+
<a href="/following">Show everything</a>.
325+
</>
326+
) : (
327+
'Nothing published recently by anything you follow.'
328+
)}
265329
</p>
266330
) : (
267331
items.map((p) => {

‎apps/web/src/app/podcasts/page.jsx‎

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { siteUrl } from '../../lib/db.js';
2-
import { feedAlternates } from '../../lib/subscribe.js';
3-
import CategoryIndex, { CATEGORIES, pageNumber } from '../CategoryIndex.jsx';
2+
import CategoryIndex, { CATEGORIES, listHref, pageNumber, viewName } from '../CategoryIndex.jsx';
43

54
export const dynamic = 'force-dynamic';
65

@@ -10,26 +9,38 @@ const CATEGORY = CATEGORIES.podcast;
109
* @param {{ searchParams: Promise<Record<string, string|string[]|undefined>> }} props
1110
*/
1211
export async function generateMetadata({ searchParams }) {
13-
const page = pageNumber((await searchParams).page);
12+
const params = await searchParams;
13+
const page = pageNumber(params.page);
14+
const view = viewName(params.view, CATEGORY);
15+
16+
const what = view === 'shows' ? 'every show' : 'the newest episodes';
17+
const title = view === 'shows' ? 'Podcasts · every show' : CATEGORY.title;
1418

1519
return {
16-
title: page === 1 ? CATEGORY.title : `${CATEGORY.title} · page ${page}`,
17-
description: CATEGORY.lede,
18-
alternates: {
19-
canonical:
20-
page === 1 ? `${siteUrl()}${CATEGORY.path}` : `${siteUrl()}${CATEGORY.path}?page=${page}`,
21-
// The category's own feed: what has just been added to it. Announced on
22-
// every page of the listing rather than only the first, because it is the
23-
// same feed either way and a reader deep in the directory is exactly the
24-
// one who wants telling when more arrives.
25-
types: feedAlternates(`${siteUrl()}${CATEGORY.path}`, CATEGORY.heading),
26-
},
20+
title: page === 1 ? title : `${title} · page ${page}`,
21+
description:
22+
view === 'shows'
23+
? 'Every podcast in the directory, newest show first.'
24+
: CATEGORY.lede,
25+
// Each page of each view canonicalises to itself: they hold different
26+
// episodes and different shows, so collapsing them would ask crawlers to
27+
// drop everything but the first sixty rows of one of them.
28+
alternates: { canonical: `${siteUrl()}${listHref(CATEGORY.path, view, page)}` },
29+
openGraph: { title, description: `Podcasts on RSS Amplifier — ${what}.` },
2730
};
2831
}
2932

3033
/**
3134
* @param {{ searchParams: Promise<Record<string, string|string[]|undefined>> }} props
3235
*/
3336
export default async function PodcastsPage({ searchParams }) {
34-
return <CategoryIndex kind="podcast" page={pageNumber((await searchParams).page)} />;
37+
const params = await searchParams;
38+
39+
return (
40+
<CategoryIndex
41+
kind="podcast"
42+
page={pageNumber(params.page)}
43+
view={viewName(params.view, CATEGORY)}
44+
/>
45+
);
3546
}

‎apps/web/src/lib/categories.js‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,16 @@ export const CATEGORIES = {
4545
noun: 'podcasts',
4646
one: 'podcast',
4747
title: 'Podcasts',
48-
lede: 'Shows with audio in their feed and a publisher who filled in the podcast namespaces, newest first. Episodes play in the reader while you read the show notes.',
48+
lede: 'Every show with audio in its feed and a publisher who filled in the podcast namespaces. The newest episodes across all of them first — press play on any of them here.',
4949
schemaType: 'PodcastSeries',
50+
entrySchemaType: 'PodcastEpisode',
5051
item: 'episodes',
5152
entry: 'episode',
53+
// The category leads with what has just been published rather than with
54+
// the feeds that published it. Only podcasts for now: it is the category
55+
// where "what is new" is unarguably the question being asked, and the one
56+
// whose entries the site can play where they stand. See CategoryIndex.
57+
river: true,
5258
},
5359
music: {
5460
path: '/music',
@@ -122,3 +128,52 @@ export const CATEGORIES = {
122128
export const CATEGORY_SEGMENTS = new Map(
123129
Object.entries(CATEGORIES).map(([kind, meta]) => [meta.path.replace(/^\//, ''), kind]),
124130
);
131+
132+
/**
133+
* Read `?view=` as one of the two ways to look at a category.
134+
*
135+
* 'latest' is what has just been published across the whole category; 'shows'
136+
* is the directory of who is in it. Only categories that say `river` in the
137+
* table above offer the first, and for those it is the default — somebody
138+
* opening /podcasts came for episodes, not for a list of feeds in the order we
139+
* happened to index them.
140+
*
141+
* Anything unrecognised falls back the way a bad `?page=` does, and for the
142+
* same reason: this is a parameter in a URL people edit, share and guess at,
143+
* and a directory listing has no business refusing to render over one.
144+
*
145+
* @param {unknown} raw
146+
* @param {{ river?: boolean }} [category]
147+
* @returns {'latest'|'shows'}
148+
*/
149+
export function viewName(raw, category = {}) {
150+
if (!category.river) return 'shows';
151+
152+
// A repeated parameter arrives as an array. Taking the first spelling rather
153+
// than letting `String(['a','b'])` coerce to "a,b" — which matches nothing
154+
// and would silently fall back — so `?view=shows&view=shows` means what it
155+
// plainly says.
156+
const value = String((Array.isArray(raw) ? raw[0] : raw) ?? '').toLowerCase();
157+
return value === 'shows' ? 'shows' : 'latest';
158+
}
159+
160+
/**
161+
* A link to one page of one view of a category.
162+
*
163+
* Page 1 of the default view is the bare path, never `?page=1` and never
164+
* `?view=latest`: the same listing under two URLs is a duplicate-content signal
165+
* to the crawlers this directory exists for.
166+
*
167+
* @param {string} path
168+
* @param {'latest'|'shows'} view
169+
* @param {number} page
170+
* @returns {string}
171+
*/
172+
export function listHref(path, view, page) {
173+
const params = new URLSearchParams();
174+
if (view === 'shows') params.set('view', 'shows');
175+
if (page > 1) params.set('page', String(page));
176+
177+
const query = params.toString();
178+
return query ? `${path}?${query}` : path;
179+
}

0 commit comments

Comments
 (0)