source: Klonkt/src/services/music/index.js@ 98acb97

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

Hoezen en playlists reisden nog niet mee

Robin, na de vorige ronde: "de audio tracks hadden images, die zijn niet
meegegaan, de playlists zijn ook nog niet meegegaan via peer to peer."

Allebei terecht, en de eerste is dezelfde fout als bij de audio zelf, een laag
hoger. cover_url ging wel mee als STRING en het bestand niet. Dus kwam een
nummer aan met een verwijzing naar een plaatje dat er niet was: precies de
halve waarheid die deze hele reeks moest opruimen, en ik liep er zelf opnieuw
in.

De playlists gingen wel via de zip en niet via de ophaalknop. Die liep de
tracks-collectie af en de playlist-collectie niet, en dan heb je alle muziek
en geen enkele plaat. De volgorde staat nergens anders.

Wat er nog meer boven kwam bij het naspelen: over AP komt de duur als
ISO-8601 ("PT212S") binnen en de database wil seconden, en de artiest zit in
summary, niet in een artist-veld. Zonder die twee kwamen nummers naamloos en
zonder duur aan.

En de bronkant moest ook hier open: playlistOpenTracks filtert op fedi_open,
dus de doel-actor kreeg een plaat met gaten. Nu geldt daar dezelfde regel als
bij de outbox en de tracks.

Bewezen op twee draaiende instanties, 3 nummers met hoes plus een plaat in de
volgorde c-a-b:

peer to peer mp3 2503b, hoes 74b, artiest, duur, plaat C -> A -> B
zip idem, plus kind=album

Changed files:
src/services/ArchiveExportService.js

  • hoesToevoegen(): de BYTES van een hoes in het archief, voor tracks en playlists, met shaer:coverFile ernaast

src/services/ArchiveImportService.js

  • hoesTerug(): de hoes op schijf en cover_url daarheen; geen bestand betekent geen cover_url, liever niets dan een img die 404't

src/services/MigrationService.js

  • de ingest haalt de hoes op uit icon/image, ONDERTEKEND
  • de playlists erbij, met hun volgorde en een kaart bron-URI -> nieuw id
  • duurSeconden(): PT212S naar 212; artiest uit summary

src/services/music/index.js

  • playlistOpenTracks({ alles }) voor de doel-actor van een Move

src/routes/activitypub.js

  • de playlist-route geeft die doel-actor de volledige plaat

remarks: over AP komt kind (album/playlist) niet mee, want de AP-collectie
draagt dat veld niet; via de zip wel. Klein verschil, apart te repareren als het
hindert.

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

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