source: Klonkt/src/routes/feed.js@ 59d963f

main
Last change on this file since 59d963f was 59d963f, checked in by Robin <roboburr@…>, 4 weeks ago

De hele site als muziekkanaal: /tracks.xml (Robins vraag, 10-8)

Hij vroeg om een "hele site, open tracks"-library, in de hoop dat Funkwhale er
iets mee zou kunnen. Als AS2-object doet zo'n ding daar niets: een
Funkwhale-KANAAL (federation/actors/tnd, opgehaald en nagelopen) noemt nergens
een library, en hun library is een eigen actor met inbox en sleutel -- de optie
die op 7 augustus bewust is afgewezen.

Maar de vorm waarin Funkwhale een kanaal WEL uitgeeft is RSS 2.0 met de
iTunes-namespace en een enclosure per item. Naast onze feed gelegd:

Funkwhale 1 item, 1 enclosure audio/mpeg itunes + atom
Klonkt 8 items, 4 enclosures audio/mpeg itunes + atom

Dezelfde vorm dus, en die had Klonkt al. Het enige verschil was dat onze feed
ook gewone posts draagt, en een muziekkanaal met blogberichten ertussen is er
geen. Vandaar een tweede ingang op dezelfde bouwer in plaats van een nieuw
object: /tracks.xml, alleen de open tracks.

Twee dingen die geen detail zijn. De self-link wijst naar ZICHZELF -- anders
stuurt een lezer die opnieuw ophaalt zichzelf naar de gemengde variant en staan
de posts er alsnog in. En de titel krijgt " -- muziek" erachter, want twee
feeds met dezelfde naam zijn in een podcast-app niet te scheiden.

Wat NIET verandert: de actor blijft /feed.xml adverteren. Die is door Apple
Podcasts gevalideerd en werkt; een tweede rss-link erbij zou een podcast-app
laten kiezen tussen twee kanaalfeeds. /tracks.xml is een URL om te plakken.

En dicht blijft dicht: dezelfde poortregel als overal, siteOpenTracks beslist.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 10.0 KB
Line 
1/**
2 * RSS / Atom feeds + sitemap.xml
3 *
4 * GET /feed.xml -> RSS 2.0
5 * GET /atom.xml -> Atom 1.0
6 * GET /sitemap.xml -> XML sitemap (only if site.robots_index is on)
7 *
8 * All are site-scoped (using res.locals.site) and only include
9 * status='published' posts.
10 */
11
12import express from 'express';
13import db from '../config/database.js';
14import { siteOpenTracks } from '../services/ActivityPubService.js';
15
16const router = express.Router();
17
18function escapeXml(s) {
19 if (s == null) return '';
20 return String(s)
21 .replace(/&/g, '&amp;')
22 .replace(/</g, '&lt;')
23 .replace(/>/g, '&gt;')
24 .replace(/"/g, '&quot;')
25 .replace(/'/g, '&apos;');
26}
27
28function siteOrigin(req) {
29 const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
30 const host = req.headers['x-forwarded-host'] || req.get('host');
31 return `${proto}://${host}`;
32}
33
34function postsForFeed(siteId, limit = 30) {
35 return db.prepare(`
36 SELECT p.id, p.slug, p.title, p.excerpt, p.content, p.published_at, p.updated_at,
37 u.username AS author_username, u.email AS author_email
38 FROM posts p JOIN users u ON u.id = p.author_id
39 WHERE p.site_id = ? AND p.status = 'published'
40 ORDER BY p.published_at DESC
41 LIMIT ?
42 `).all(siteId, limit);
43}
44
45// ==================== RSS 2.0 ====================
46/**
47 * Twee feeds, een bouwer.
48 *
49 * /feed.xml de site: posts EN open tracks door elkaar, chronologisch
50 * /tracks.xml alleen de open tracks -- de hele site als muziekkanaal
51 *
52 * WAAROM DIE TWEEDE (Robins vraag, 10-8). Hij vroeg naar een "hele site, open
53 * tracks"-library voor Funkwhale. Als AS2-object helpt zo'n ding daar niet: een
54 * Funkwhale-KANAAL wijst nergens naar een library, en hun library is een eigen
55 * actor met inbox en sleutel -- de optie die op 7 augustus is afgewezen. Maar de
56 * vorm waarin Funkwhale een kanaal WEL uitgeeft is RSS met iTunes en een
57 * enclosure per item, en dat is precies wat Klonkt hier al doet. Naast een echte
58 * kanaalfeed gelegd (audio.pepemoss.com/api/v1/channels/tnd/rss): dezelfde
59 * namespaces, dezelfde enclosure.
60 *
61 * Het enige verschil was dat onze feed ook gewone posts draagt. Een muziekkanaal
62 * met blogberichten ertussen is er geen, dus die feed staat er nu apart -- een
63 * URL om te plakken, zonder dat er iets naar buiten geduwd wordt.
64 */
65function stuurFeed(req, res, { alleenTracks }) {
66 const site = res.locals.site;
67 if (!site) return res.status(404).send('No site');
68
69 const origin = siteOrigin(req);
70 const base = origin + (res.locals.siteUrlBase || '');
71 const posts = alleenTracks ? [] : postsForFeed(site.id);
72
73 // De tracks die deze site aan de federatie heeft opengezet, elk als eigen
74 // item met een <enclosure> (shaer-0nh). Dat laatste is wat een podcast-app
75 // zoekt: zonder enclosure is een feed voor hem leeg, hoe veel items er ook
76 // in staan -- en de actor adverteert deze feed nu juist als kanaal-feed.
77 //
78 // EEN ITEM PER TRACK, want RSS 2.0 staat maar EEN enclosure per item toe.
79 // Een album in een item proppen zou betekenen dat er van vijf nummers vier
80 // verdwijnen. Dezelfde keuze als in de outbox: de post is het bericht, de
81 // track is de publicatie.
82 //
83 // Welke tracks open zijn beslist de AP-service, niet deze route: dat is een
84 // poortregel en die hoort op een plek te staan.
85 const tracks = siteOpenTracks(site.id);
86 const lastBuild = posts[0]?.published_at || tracks[0]?.created_at || new Date().toISOString();
87
88 // De itunes-velden waar een podcast-app een kanaal aan herkent. Funkwhale
89 // bouwde onze kanaalpagina langs de RSS-kant op en liet de categorie leeg,
90 // want die leest hij hier -- niet uit `category` op de AP-actor.
91 //
92 // De categorie volgt DEZELFDE regel als daar: alleen 'Music' als er ook
93 // werkelijk publieke muziek is. Een blog zonder open track is naar buiten
94 // toe geen muziekkanaal, en gated muziek telt niet mee -- afwezig is
95 // afwezig, ook in een categorie.
96 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
97 const kanaalKunst = abs(site.profile_photo || site.og_image_default || null);
98 const kanaalTags = [
99 ` <itunes:author>${escapeXml(site.author || site.title || '')}</itunes:author>`,
100 site.description || site.tagline
101 ? ` <itunes:summary>${escapeXml(site.description || site.tagline)}</itunes:summary>` : null,
102 kanaalKunst ? ` <itunes:image href="${escapeXml(kanaalKunst)}" />` : null,
103 tracks.length ? ' <itunes:category text="Music" />' : null,
104 ].filter(Boolean).join('\n');
105
106 const wanneer = (d) => { const t = Date.parse(d); return Number.isNaN(t) ? 0 : t; };
107 const items = [
108 ...posts.map((p) => ({ op: wanneer(p.published_at), xml: ` <item>
109 <title>${escapeXml(p.title || '(untitled)')}</title>
110 <link>${escapeXml(base + '/' + p.slug)}</link>
111 <guid isPermaLink="true">${escapeXml(base + '/' + p.slug)}</guid>
112 <pubDate>${new Date(p.published_at).toUTCString()}</pubDate>
113 <author>${escapeXml((p.author_email || 'noreply@localhost') + ' (' + p.author_username + ')')}</author>
114 <description>${escapeXml(p.excerpt || '')}</description>
115 </item>` })),
116 ...tracks.map((t) => {
117 const fn = t.filename || (t.storage_path || '').split('/').pop();
118 // Geen <link>: Klonkt heeft geen trackpagina, en een post over vijf
119 // nummers is niet de pagina van dit ene nummer. De guid is daarom geen
120 // permalink maar de stabiele AP-id van de track.
121 return { op: wanneer(t.created_at), xml: ` <item>
122 <title>${escapeXml(t.title || 'Audio')}</title>
123 <guid isPermaLink="false">${escapeXml(`${base}/ap/users/${encodeURIComponent(site.slug)}/tracks/${encodeURIComponent(t.id)}`)}</guid>
124 <pubDate>${new Date(t.created_at || Date.now()).toUTCString()}</pubDate>
125 <description>${escapeXml(t.artist || '')}</description>
126 <enclosure url="${escapeXml(`${base}/audio/stream/${encodeURIComponent(fn)}`)}" length="${Number(t.size) || 0}" type="${escapeXml(t.mime_type || 'audio/mpeg')}" />${t.duration ? `
127 <itunes:duration>${Math.round(t.duration)}</itunes:duration>` : ''}${t.artist ? `
128 <itunes:author>${escapeXml(t.artist)}</itunes:author>` : ''}${abs(t.cover_url) ? `
129 <itunes:image href="${escapeXml(abs(t.cover_url))}" />` : ''}
130 </item>` };
131 }),
132 ].sort((a, b) => b.op - a.op).map((x) => x.xml).join('\n');
133
134 res.set('Content-Type', 'application/rss+xml; charset=utf-8');
135 res.send(`<?xml version="1.0" encoding="UTF-8"?>
136<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
137 <channel>
138 <title>${escapeXml(alleenTracks ? `${site.title} \u2014 muziek` : site.title)}</title>
139 <link>${escapeXml(base + '/')}</link>
140 <description>${escapeXml(site.description || site.tagline || '')}</description>
141 <language>${escapeXml(site.language || 'nl')}</language>
142 <lastBuildDate>${new Date(lastBuild).toUTCString()}</lastBuildDate>
143 <atom:link href="${escapeXml(base + (alleenTracks ? '/tracks.xml' : '/feed.xml'))}" rel="self" type="application/rss+xml" />
144${kanaalTags}
145${items}
146 </channel>
147</rss>`);
148}
149
150router.get('/feed.xml', (req, res) => stuurFeed(req, res, { alleenTracks: false }));
151// De hele site als muziekkanaal: alleen wat op de federatie openstaat.
152router.get('/tracks.xml', (req, res) => stuurFeed(req, res, { alleenTracks: true }));
153
154// ==================== Atom 1.0 ====================
155router.get('/atom.xml', (req, res) => {
156 const site = res.locals.site;
157 if (!site) return res.status(404).send('No site');
158
159 const origin = siteOrigin(req);
160 const base = origin + (res.locals.siteUrlBase || '');
161 const posts = postsForFeed(site.id);
162 const updated = posts[0]?.updated_at || posts[0]?.published_at || new Date().toISOString();
163
164 res.set('Content-Type', 'application/atom+xml; charset=utf-8');
165 res.send(`<?xml version="1.0" encoding="UTF-8"?>
166<feed xmlns="http://www.w3.org/2005/Atom">
167 <title>${escapeXml(site.title)}</title>
168 <link href="${escapeXml(base + '/')}" />
169 <link href="${escapeXml(base + '/atom.xml')}" rel="self" />
170 <id>${escapeXml(base + '/')}</id>
171 <updated>${new Date(updated).toISOString()}</updated>
172 <subtitle>${escapeXml(site.description || site.tagline || '')}</subtitle>
173${posts.map(p => ` <entry>
174 <title>${escapeXml(p.title || '(untitled)')}</title>
175 <link href="${escapeXml(base + '/' + p.slug)}" />
176 <id>${escapeXml(base + '/' + p.slug)}</id>
177 <updated>${new Date(p.updated_at || p.published_at).toISOString()}</updated>
178 <published>${new Date(p.published_at).toISOString()}</published>
179 <author><name>${escapeXml(p.author_username)}</name></author>
180 <summary>${escapeXml(p.excerpt || '')}</summary>
181 </entry>`).join('\n')}
182</feed>`);
183});
184
185// ==================== Sitemap.xml ====================
186router.get('/sitemap.xml', (req, res) => {
187 const site = res.locals.site;
188 if (!site) return res.status(404).send('No site');
189
190 // Honour the per-site robots_index flag.
191 if (site.robots_index === 0) {
192 res.set('X-Robots-Tag', 'noindex');
193 return res.status(404).send('No sitemap');
194 }
195
196 const origin = siteOrigin(req);
197 const base = origin + (res.locals.siteUrlBase || '');
198 const posts = db.prepare(`
199 SELECT slug, COALESCE(updated_at, published_at) AS lastmod
200 FROM posts
201 WHERE site_id = ? AND status = 'published'
202 ORDER BY lastmod DESC
203 LIMIT 1000
204 `).all(site.id);
205
206 const urls = [
207 { loc: base + '/', lastmod: posts[0]?.lastmod, priority: '1.0' },
208 { loc: base + '/archive', priority: '0.7' },
209 ...posts.map(p => ({
210 loc: base + '/' + p.slug,
211 lastmod: p.lastmod,
212 priority: '0.8',
213 })),
214 ];
215
216 res.set('Content-Type', 'application/xml; charset=utf-8');
217 res.send(`<?xml version="1.0" encoding="UTF-8"?>
218<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
219${urls.map(u => ` <url>
220 <loc>${escapeXml(u.loc)}</loc>${u.lastmod ? `\n <lastmod>${new Date(u.lastmod).toISOString().slice(0,10)}</lastmod>` : ''}
221 <priority>${u.priority}</priority>
222 </url>`).join('\n')}
223</urlset>`);
224});
225
226export default router;
Note: See TracBrowser for help on using the repository browser.