source: Klonkt/src/services/music/index.js@ 6c4ff7e

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

Audio verhuisde niet mee: archiefformaat v2

Robin verhuisde naar soundfabrics.nl en meldde: de berichten zijn er, de
plaatjes zijn er, een deel van de audio staat er maar speelt niet af, en
daarnaast mist er veel. Drie losse oorzaken, alle drie echt.

Gemeten op de twee live instanties:

berichten 36 -> 36
tracks 140 -> 14, waarvan 13 stuk
playlists 11 -> 0

DE EXPORTER LAS storage_path, DE SPELER NIET. routes/audio.js resolvet met
AUDIO_DIR plus bestandsnaam en kijkt niet naar media.storage_path. Op
sound-fabrics.com wees storage_path voor 124 van de 139 audiorijen nog naar
/srv/prutfolio/storage/audio, van voor de dataverhuizing. De site speelde
gewoon door; de exporter vond niets, markeerde availability: missing, en liet
124 nummers stil achter. Vanuit de exporter gezien was het bestand er echt niet,
dus er kwam ook geen klacht. Nu is er een resolveAudioPath die zoekt zoals de
speler zoekt.

DE IMPORTER MAAKTE EEN TRACK ZONDER BESTAND. Bij gewone bijlagen werd
availability: missing netjes gecontroleerd, in de audio-tak eronder niet. Die
zette een media-rij met size 0 en een audio_tracks-rij neer voor bytes die nooit
zijn weggeschreven. Dat is erger dan ontbreken: het ziet eruit alsof de
verhuizing gelukt is, dus je zegt je oude instantie op. Nu geldt er een harde
regel: geen bestand, geen track.

AUDIO PASTE NIET IN HET FORMAAT. Gehoste audio staat buiten MEDIA_ROOT, achter
een eigen gated route, en het archief droeg alleen bestanden onder media/. De
exporter rekende er met path.relative een /media/../audio/x.mp3 van, en
veiligMediaPad weigert dat pad terecht. Audio kon dus principieel niet
aankomen. Daarbovenop ging alleen mee wat met [[track:]] in een bericht stond,
14 van de 140, en playlists helemaal niet.

De ophaalknop leerde audio erbij. Dat vroeg meer dan verwacht: de bron toont
over AP alleen fedi_open-nummers, en de bestanden zitten achter dezelfde poort.
Beide staan nu open voor precies de doel-actor van de Move, en de ingest haalt
de bytes ONDERTEKEND op. De eerste poging gaf drie keer "nummer niet opgehaald",
want haalBijlage deed een kale fetch en kreeg een 403 van onze eigen poort.

Bewezen op twee draaiende instanties, met een track die opzettelijk een
verouderd pad had. Zip: 3/3 included, playlist-volgorde intact, bestanden op
schijf met de goede grootte, /audio/stream geeft 200. Ophaalknop: 5 berichten
plus 3 tracks met echte bytes, ook de gesloten nummers.

Changed files:
src/config/paths.js

  • AUDIO_ROOT en resolveAudioPath(): een audiobestand zoeken zoals de speler het zoekt, met het opgeslagen pad als terugval

src/services/ArchiveExportService.js

  • FORMAT_VERSION 2
  • audioBibliotheek(): de HELE tracklijst plus de playlists, met de bestanden in een eigen audio/-gebied
  • [[track:]] weg uit de media-tak; die kon nooit aankomen
  • audioOf() wijst naar het archiefpad in plaats van naar een bijlage

src/services/ArchiveImportService.js

  • tracksTerug() en playlistsTerug(); de per-post audio-tak is weg
  • bestanden naar AUDIO_ROOT, en geen rij zonder bestand
  • een playlist verwijst nooit naar een nummer dat niet aankwam
  • nieuwe rapportvelden tracks / tracksMissing / playlists

src/services/MigrationService.js

  • de ingest loopt de tracks-collectie af en haalt de bytes ondertekend op
  • haalBijlage kreeg submap en headers

src/services/ActivityPubService.js

  • signedGetHeaders() losgetrokken uit signedGetJson, want een verhuizing moet ook bytes kunnen ophalen

src/services/music/index.js

  • siteOpenTracks({ alles }): de doel-actor ziet ook de gesloten nummers

src/routes/audio.js

  • isMoveTargetAudio(): een ondertekend verzoek van de doel-actor mag alle audio ophalen, ook wat niet fedi_open is

src/routes/activitypub.js

  • magAlles(): een plek voor de vraag "mag deze aanvrager alles zien"

src/routes/admin-migrate.js

  • AUDIO_ROOT en signedGetHeaders doorgegeven aan de ingest

test/archive-export.test.js

  • de audio-testen naar het v2-contract, plus bibliotheek en ontbrekend
  • resolveAudioPath getoetst op VOLGORDE met een nep-fs

New file:
test/archive-audio.test.js

  • de hele rondgang met audio, inclusief het verouderde pad, de playlist- volgorde en de droogloop

remarks: soundfabrics.nl staat nu met 13 lege tracks; die verdwijnen niet
vanzelf bij een nieuwe import. En een archief van voor deze commit is v1 en
bevat de audio niet, dus opnieuw exporteren is nodig. Suite 985 groen;
tegenproef gedaan op de resolver en op de geen-bestand-geen-track-regel.

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

  • Property mode set to 100644
File size: 24.1 KB
Line 
1/**
2 * De muziekkant van ActivityPub: een track als AS2-object, de collecties
3 * eromheen, en welke post hem uitbrengt.
4 *
5 * Waarom een eigen map (shaer-drc): ActivityPubService was 6400 regels over
6 * tweeentwintig onderwerpen. Dit is het eerste onderwerp dat er als geheel uit
7 * kan, en het is meteen het onderwerp dat gaat GROEIEN -- de typering uit
8 * shaer-cyg (playlist versus album, afgeleid uit wat er in de post staat) landt
9 * hier straks.
10 *
11 * De regel die guardianship al aanhoudt geldt hier ook: deze map importeert
12 * alleen db en ap-core, en NOOIT terug uit ActivityPubService.
13 */
14
15import db from '../../config/database.js';
16import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection } from '../ap-core.js';
17import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js';
18
19// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
20export const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
21 t.position, t.license,
22 m.filename, m.storage_path, m.mime_type, m.size`;
23
24export function playlistOpenTracks(playlistId) {
25 return db.prepare(
26 `SELECT ${TRACK_KOLOMMEN}
27 FROM playlist_tracks pt
28 JOIN audio_tracks t ON t.id = pt.track_id
29 JOIN media m ON m.id = t.media_id
30 WHERE pt.playlist_id = ? AND t.fedi_open = 1
31 ORDER BY pt.position`
32 ).all(playlistId);
33}
34
35/**
36 * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
37 *
38 * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
39 * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
40 * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
41 */
42/**
43 * `alles` bestaat voor FEP-1580. Bij een verhuizing behandelt de bron een
44 * ondertekend verzoek van de DOEL-actor als zichzelf, en dat geldt hier net zo
45 * goed als bij de outbox. Zonder deze tak neemt een verhuizing alleen je
46 * opengezette nummers mee en blijft je hele gesloten bibliotheek achter op een
47 * domein dat je gaat opzeggen. De poort blijft verder dicht: alleen die ene
48 * actor, en alleen omdat moveAccount() een terugverwijzing eiste voordat
49 * moved_to er kwam te staan.
50 */
51export function siteOpenTracks(siteId, { alles = false } = {}) {
52 return db.prepare(
53 `SELECT ${TRACK_KOLOMMEN}
54 FROM audio_tracks t JOIN media m ON m.id = t.media_id
55 WHERE t.site_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
56 ORDER BY t.position, t.created_at, t.id`
57 ).all(siteId);
58}
59
60export function openTrack(siteId, trackId) {
61 return db.prepare(
62 `SELECT ${TRACK_KOLOMMEN}
63 FROM audio_tracks t JOIN media m ON m.id = t.media_id
64 WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
65 ).get(siteId, trackId);
66}
67
68/**
69 * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
70 *
71 * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
72 * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
73 * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
74 * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
75 * precies zo, per stuk, in Create en Delete.
76 *
77 * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
78 * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
79 * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
80 * track wordt getoond binnen een post, en een post over vijf nummers is niet de
81 * pagina van dit ene nummer. Liever geen link dan een link die iets anders
82 * belooft.
83 */
84/**
85 * Bij welke post hoort een track? (shaer-0nh)
86 *
87 * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
88 * een van drie insluitingen in posts.content. Die relatie stond alleen in die
89 * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
90 * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
91 *
92 * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
93 * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
94 * scans. Nu is het er een, en de map gaat mee als optie.
95 *
96 * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
97 * is specifieker dan via een playlist, en die weer specifieker dan via een
98 * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
99 * laatst heeft uitgebracht.
100 */
101export function trackHostPosts(siteId) {
102 const rijen = db.prepare(`
103 SELECT tid, post_id, post_slug, rang, wanneer FROM (
104 SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
105 COALESCE(p.published_at, p.created_at) AS wanneer
106 FROM audio_tracks t
107 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
108 AND p.content LIKE '%[[track:' || t.id || ']]%'
109 WHERE t.site_id = ? AND t.fedi_open = 1
110 UNION ALL
111 SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
112 FROM playlist_tracks pt
113 JOIN audio_tracks t ON t.id = pt.track_id
114 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
115 AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
116 WHERE t.site_id = ? AND t.fedi_open = 1
117 UNION ALL
118 SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
119 FROM audio_tracks t
120 JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
121 AND p.content LIKE '%[[album:' || t.album || ']]%'
122 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
123 ) ORDER BY rang, wanneer DESC
124 `).all(siteId, siteId, siteId);
125 const uit = new Map();
126 for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
127 return uit;
128}
129
130export function buildTrackAudio(base, site, r, opts = {}) {
131 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
132 const fn = r.filename || (r.storage_path || '').split('/').pop();
133 // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
134 // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
135 // Zo doet Funkwhale het ook.
136 // De post waar dit nummer in staat. Meegegeven door de collectie (een
137 // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
138 // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
139 const post = opts.hostPosts !== undefined
140 ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
141 : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
142
143 const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
144 if (Number(r.size)) bestand.size = Number(r.size);
145 // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
146 // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
147 // de getallen hebben -- liever geen veld dan een verzonnen getal.
148 if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
149
150 const a = {
151 ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
152 id: `${actorId(base, site.slug)}/tracks/${encodeURIComponent(r.id)}`,
153 type: 'Audio',
154 name: r.title || 'Audio',
155 attributedTo: actorId(base, site.slug),
156 // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
157 // track moet zelf kunnen zeggen dat hij openbaar is.
158 to: [PUBLIC],
159 // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
160 // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
161 // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
162 // horen, en dat is wat zo'n link betekent.
163 url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
164 };
165 if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab
166 // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
167 // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
168 // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
169 // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
170 // van te maken.
171 if (post) a.context = noteId(base, post.id);
172 if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
173 if (r.created_at) a.published = new Date(r.created_at).toISOString();
174 if (Number(r.position)) a.position = Number(r.position);
175 const lic = licentieUri(r.license);
176 if (lic) a.license = lic;
177 const art = abs(r.cover_url || opts.coverFallback || null);
178 // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
179 // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
180 if (art) {
181 const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
182 a.icon = plaat;
183 a.image = plaat;
184 }
185 return a;
186}
187
188/**
189 * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
190 * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
191 * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
192 * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
193 * een lezer gelooft hem.
194 */
195const LICENTIES = {
196 'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
197 'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
198 'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
199 'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
200 'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
201 'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
202};
203export function licentieUri(waarde) {
204 const s = String(waarde || '').trim();
205 if (!s) return null;
206 if (/^https?:\/\//i.test(s)) return safeUrl(s); // iemand vulde al een URI in
207 return LICENTIES[s.toLowerCase()] || null; // "Alle rechten voorbehouden" heeft er geen
208}
209
210/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
211export function buildTrackCollection(base, site, rows) {
212 // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
213 const posts = site.id ? trackHostPosts(site.id) : null;
214 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
215 return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { extra: { attributedTo: actorId(base, site.slug) } });
216}
217
218// Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
219// (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
220// parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
221// client die hem kent haalt de collectie op. Opgelost uit post.content en
222// ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
223// zonder site-check zou een post van site A naar de collectie van site B
224// kunnen wijzen.
225export function playlistLinkTags(base, site, content, post = null) {
226 const out = [];
227 try {
228 // Zelfde patroon als de renderer en als de afleiding: wat niet insluit,
229 // krijgt ook geen link. Dit stond hier met een eigen patroon dat
230 // underscores accepteerde die nergens anders meetellen.
231 for (const id of ingeslotenPlaylists(content)) {
232 const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(id, site.id);
233 if (!pl) continue;
234 out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
235 }
236 // Losse tracks in een post zijn ook een uitgave (shaer-38y): ze krijgen een
237 // eigen collectie, en de post wijst er langs dezelfde weg naar. Zonder deze
238 // link zou die collectie bestaan maar door niemand te vinden zijn.
239 if (post && post.id) {
240 const eenheid = postMusicType(content, site.id);
241 if (eenheid && !eenheid.collectie && eenheid.tracks?.length && losseTracksVanPost(site.id, eenheid.tracks).length) {
242 out.push({
243 type: 'Link',
244 href: postTracksId(base, site, post.id),
245 mediaType: 'application/activity+json',
246 name: post.title || 'Tracks',
247 });
248 }
249 }
250 } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
251 return out;
252}
253
254// De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
255// Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
256// followers/following): een stub per playlist met naam, hoes en de EERLIJKE
257// telling -- totalItems van de stub telt het open deel, dezelfde regel als de
258// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
259// poort staat.
260export function listPlaylistsAP(base, site, enriched) {
261 const rows = db.prepare(
262 'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
263 ).all(site.id);
264 const colId = `${actorId(base, site.slug)}/playlists`;
265 const items = rows.map((p) => {
266 const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
267 if (!enriched) return uri;
268 const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
269 delete stub['@context']; // genest object draagt de context van zijn omhulsel
270 delete stub.orderedItems; // stub: wie de tracks wil, haalt de collectie op
271 return stub;
272 });
273 return pagedCollection(colId, items, { extra: { attributedTo: actorId(base, site.slug) } });
274}
275
276export function buildPlaylistCollection(base, site, playlist, rows) {
277 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
278 // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
279 // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
280 // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
281 // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
282 // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
283 const hostPosts = site.id ? trackHostPosts(site.id) : null;
284 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
285 const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
286 extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
287 });
288 // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
289 const parts = [];
290 if (playlist.artist) parts.push(playlist.artist);
291 if (playlist.year) parts.push(String(playlist.year));
292 if (parts.length) out.summary = parts.join(' · ');
293 const cover = abs(playlist.cover_url || null);
294 if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
295 return leenVanPost(base, site, out, uitgavePost(site.id, playlist.id));
296}
297
298// ── De post als uitgave (shaer-38y) ───────────────────────────────────────
299
300/** Het AS2-id van de collectie losse tracks van een post. */
301function postTracksId(base, site, postId) {
302 return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`;
303}
304
305/**
306 * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen?
307 *
308 * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN
309 * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de
310 * post niet meer de drager van een identiteit en houdt de playlist de zijne --
311 * dezelfde regel als in de afleiding, hier alleen toegepast.
312 *
313 * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is
314 * uitgebracht.
315 */
316export function uitgavePost(siteId, playlistId) {
317 if (!siteId || !playlistId) return null;
318 try {
319 const rijen = db.prepare(`
320 SELECT id, slug, title, excerpt, content, cover_image_url, tags
321 FROM posts
322 WHERE site_id = ? AND status = 'published'
323 AND content LIKE '%[[playlist:' || ? || ']]%'
324 ORDER BY COALESCE(published_at, created_at) DESC
325 `).all(siteId, playlistId);
326 for (const p of rijen) {
327 const r = postMusicType(p.content, siteId);
328 if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p;
329 }
330 } catch { /* geen lening is geen fout */ }
331 return null;
332}
333
334/**
335 * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3).
336 *
337 * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms
338 * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum.
339 * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem
340 * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige
341 * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat
342 * hij nu krijgt.
343 *
344 * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs`
345 * staan, zodat de eigen naam terug te vinden is als hij afwijkt.
346 */
347function leenVanPost(base, site, obj, post) {
348 if (!post) return obj;
349 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
350
351 if (post.title) {
352 if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name;
353 obj.name = post.title;
354 }
355 // De tekst als `content`, niet als `summary`: in AS2 is summary de korte
356 // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
357 // staan -- dat is een samenvatting, en de posttekst is dat niet.
358 const tekst = tekstVanPost(post);
359 if (tekst) obj.content = tekst;
360
361 const cover = abs(post.cover_image_url || null);
362 if (cover) {
363 obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
364 if (!obj.icon) obj.icon = obj.image; // geen eigen hoes? dan die van de post
365 }
366
367 // Dezelfde lijst als de Note: het tagveld EN de hashtags uit het lijf, waarbij
368 // de geschreven vorm voorgaat. Een eigen lijst hier zou de tags uit de tekst
369 // missen en de rest anders spellen dan dezelfde post elders doet.
370 const tags = buildHashtagList(base, post.tags, post.content, { ruw: true });
371 if (tags.length) obj.tag = tags;
372
373 // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een
374 // losse track: url wijst een mens naar de post, context zegt waar dit object
375 // thuishoort.
376 obj.url = `${base}/${post.slug}`;
377 obj.context = noteId(base, post.id);
378 return obj;
379}
380
381/**
382 * De tekst van een post, als er een is. De excerpt heeft voorrang -- die is
383 * geschreven om samen te vatten. Staat die leeg, dan het lijf zelf: zonder
384 * shortcodes (die zijn de muziek, niet het verhaal erover) en zonder opmaak.
385 * Levert null als er niets overblijft, want een leeg veld is slechter dan geen.
386 */
387function tekstVanPost(post) {
388 const excerpt = String(post.excerpt || '').trim();
389 if (excerpt) return excerpt;
390 const kaal = String(post.content || '')
391 .replace(/\[\[[a-z]+:[^\]]*\]\]/gi, ' ')
392 .replace(/<[^>]+>/g, ' ')
393 .replace(/&nbsp;/gi, ' ')
394 .replace(/&[a-z#0-9]+;/gi, ' ')
395 // Losse hashtags gaan eruit: die staan al in `tag`, en een description die
396 // de tagwolk herhaalt is ruis. Live leverde dit "#DoenweNiet #DoenWeNiet
397 // #devs" op als omschrijving van een post die verder geen tekst heeft.
398 .replace(/(^|\s)#[\p{L}\p{M}\p{N}_]+/gu, ' ')
399 .replace(/\s+/g, ' ')
400 .trim();
401 return kaal || null;
402}
403
404/** De open tracks uit een lijst ids, in de volgorde van die lijst. */
405function losseTracksVanPost(siteId, ids) {
406 if (!siteId || !ids?.length) return [];
407 const gaten = ids.map(() => '?').join(',');
408 const rijen = db.prepare(
409 `SELECT ${TRACK_KOLOMMEN}
410 FROM audio_tracks t JOIN media m ON m.id = t.media_id
411 WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
412 ).all(siteId, ...ids);
413 // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
414 // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
415 const opId = new Map(rijen.map((r) => [r.id, r]));
416 return ids.map((id) => opId.get(id)).filter(Boolean);
417}
418
419/**
420 * De losse tracks van een post als EEN uitgave (shaer-38y).
421 *
422 * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
423 * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
424 * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
425 * heeft ze wel en de losse tracks niet.
426 *
427 * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
428 * post die geen enkele muzikale eenheid IS.
429 */
430export function buildPostTrackCollection(base, site, post) {
431 if (!post || !post.id) return null;
432 const eenheid = postMusicType(post.content, site.id);
433 if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
434
435 const rows = losseTracksVanPost(site.id, eenheid.tracks);
436 if (!rows.length) return null;
437
438 const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
439 const out = pagedCollection(postTracksId(base, site, post.id),
440 rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
441 { extra: { attributedTo: actorId(base, site.slug) } });
442 return leenVanPost(base, site, out, post);
443}
444
445/**
446 * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
447 *
448 * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
449 * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
450 * ophaalt niet denkt dat er iets nieuws is.
451 */
452export function buildTrackCreate(base, site, r, opts = {}) {
453 const audio = buildTrackAudio(base, site, r, opts);
454 const me = actorId(base, site.slug);
455 return {
456 '@context': AP_CONTEXT,
457 id: `${audio.id}#create`,
458 type: 'Create',
459 actor: me,
460 published: audio.published,
461 to: [PUBLIC],
462 cc: [`${me}/followers`],
463 object: audio,
464 };
465}
466
467/**
468 * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
469 * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
470 * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
471 * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
472 */
473export function channelCategory(site) {
474 try {
475 // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
476 // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
477 // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
478 // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
479 // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
480 // muziekkanaal.
481 return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
482 } catch { return null; }
483}
484
485// ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────
486
487/**
488 * Het type van een post afleiden uit de muziek die erin staat.
489 *
490 * DE REGEL ZELF staat in assets/js/shared/post-music-type.js, want de editor
491 * gebruikt hem ook -- daar volgt het type live mee terwijl je schrijft. Twee
492 * kopieen zouden stil uit elkaar lopen, dus is er er een. Hier komt alleen het
493 * stuk bij dat de server kan en de browser niet: de gekozen soort van een
494 * playlist opzoeken.
495 *
496 * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
497 * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
498 * gemaakt, en de post neemt dat over. Op de server is dit vooral voor wat er al
499 * staat -- de posts met type=audio uit de tijd voor die keuze bestond.
500 *
501 * @param {string} content de HTML/tekst van de post
502 * @param {string} siteId nodig om playlists.kind te kunnen opzoeken
503 */
504export function postMusicType(content, siteId) {
505 return afleidenUitInsluitingen(content, (id) => playlistKind(id, siteId));
506}
507
508/**
509 * De gekozen soort van een playlist: 'album' | 'playlist', of null als hij niet
510 * (op deze site) bestaat. Zelfde normalisatie als PlaylistService: alles wat
511 * geen 'playlist' zegt is een album.
512 */
513function playlistKind(id, siteId) {
514 if (!siteId) return null;
515 try {
516 const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
517 if (!r) return null;
518 return r.kind === 'playlist' ? 'playlist' : 'album';
519 } catch { return null; }
520}
Note: See TracBrowser for help on using the repository browser.