| 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 |
|
|---|
| 15 | import db from '../../config/database.js';
|
|---|
| 16 | import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection, isMbid } from '../ap-core.js';
|
|---|
| 17 | import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js';
|
|---|
| 18 | // De luisteraars horen bij de muziekkant; hier doorgegeven zodat
|
|---|
| 19 | // ActivityPubService niet in een submap hoeft te grijpen.
|
|---|
| 20 | export * as luisteraars from './luisteraars.js';
|
|---|
| 21 |
|
|---|
| 22 | // m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
|
|---|
| 23 | export const TRACK_KOLOMMEN = `t.id, t.title, t.artist, t.duration, t.cover_url, t.created_at,
|
|---|
| 24 | t.position, t.license,
|
|---|
| 25 | m.filename, m.storage_path, m.mime_type, m.size`;
|
|---|
| 26 |
|
|---|
| 27 | /**
|
|---|
| 28 | * `alles` net als bij siteOpenTracks (FEP-1580): zonder die tak krijgt de
|
|---|
| 29 | * instantie waar je naartoe verhuist een playlist met gaten erin, want alleen
|
|---|
| 30 | * de opengezette nummers zitten erin. Een halve plaat is geen plaat.
|
|---|
| 31 | */
|
|---|
| 32 | export function playlistOpenTracks(playlistId, { alles = false } = {}) {
|
|---|
| 33 | return db.prepare(
|
|---|
| 34 | `SELECT ${TRACK_KOLOMMEN}
|
|---|
| 35 | FROM playlist_tracks pt
|
|---|
| 36 | JOIN audio_tracks t ON t.id = pt.track_id
|
|---|
| 37 | JOIN media m ON m.id = t.media_id
|
|---|
| 38 | WHERE pt.playlist_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
|
|---|
| 39 | ORDER BY pt.position`
|
|---|
| 40 | ).all(playlistId);
|
|---|
| 41 | }
|
|---|
| 42 |
|
|---|
| 43 | /**
|
|---|
| 44 | * Alle tracks die deze site aan de federatie heeft opengezet (shaer-0nh, stap 3).
|
|---|
| 45 | *
|
|---|
| 46 | * Dit is de KANONIEKE plek, niet de playlist: een playlist is een keuze, dit is
|
|---|
| 47 | * wat de artiest heeft uitgebracht. Een track die in geen enkele playlist zit
|
|---|
| 48 | * was tot nu toe onzichtbaar voor de federatie -- die staat hier wel.
|
|---|
| 49 | */
|
|---|
| 50 | /**
|
|---|
| 51 | * `alles` bestaat voor FEP-1580. Bij een verhuizing behandelt de bron een
|
|---|
| 52 | * ondertekend verzoek van de DOEL-actor als zichzelf, en dat geldt hier net zo
|
|---|
| 53 | * goed als bij de outbox. Zonder deze tak neemt een verhuizing alleen je
|
|---|
| 54 | * opengezette nummers mee en blijft je hele gesloten bibliotheek achter op een
|
|---|
| 55 | * domein dat je gaat opzeggen. De poort blijft verder dicht: alleen die ene
|
|---|
| 56 | * actor, en alleen omdat moveAccount() een terugverwijzing eiste voordat
|
|---|
| 57 | * moved_to er kwam te staan.
|
|---|
| 58 | */
|
|---|
| 59 | export function siteOpenTracks(siteId, { alles = false } = {}) {
|
|---|
| 60 | return db.prepare(
|
|---|
| 61 | `SELECT ${TRACK_KOLOMMEN}
|
|---|
| 62 | FROM audio_tracks t JOIN media m ON m.id = t.media_id
|
|---|
| 63 | WHERE t.site_id = ?${alles ? '' : ' AND t.fedi_open = 1'}
|
|---|
| 64 | ORDER BY t.position, t.created_at, t.id`
|
|---|
| 65 | ).all(siteId);
|
|---|
| 66 | }
|
|---|
| 67 |
|
|---|
| 68 | export function openTrack(siteId, trackId) {
|
|---|
| 69 | return db.prepare(
|
|---|
| 70 | `SELECT ${TRACK_KOLOMMEN}
|
|---|
| 71 | FROM audio_tracks t JOIN media m ON m.id = t.media_id
|
|---|
| 72 | WHERE t.site_id = ? AND t.id = ? AND t.fedi_open = 1`
|
|---|
| 73 | ).get(siteId, trackId);
|
|---|
| 74 | }
|
|---|
| 75 |
|
|---|
| 76 | /**
|
|---|
| 77 | * Eén track als AS2 `Audio`, met een EIGEN id (shaer-0nh, stap 3).
|
|---|
| 78 | *
|
|---|
| 79 | * Waarom dat id het verschil maakt: zonder id is een track een naamloze bijlage
|
|---|
| 80 | * die alleen bestaat zolang je het omhullende object vasthoudt. Met id is het
|
|---|
| 81 | * een ding waar je naar kunt wijzen, dat je los kunt ophalen, en dat in twee
|
|---|
| 82 | * playlists hetzelfde ding is. Funkwhale adresseert zijn Audio-objecten
|
|---|
| 83 | * precies zo, per stuk, in Create en Delete.
|
|---|
| 84 | *
|
|---|
| 85 | * `url` is een Link-ARRAY, net als bij Funkwhale en net als wat onze eigen
|
|---|
| 86 | * inbox sinds bdcb3a3 verwacht: de mediaType hoort bij de link, niet bij het
|
|---|
| 87 | * object. Er zit GEEN text/html-link in: Klonkt heeft geen trackpagina -- een
|
|---|
| 88 | * track wordt getoond binnen een post, en een post over vijf nummers is niet de
|
|---|
| 89 | * pagina van dit ene nummer. Liever geen link dan een link die iets anders
|
|---|
| 90 | * belooft.
|
|---|
| 91 | */
|
|---|
| 92 | /**
|
|---|
| 93 | * Bij welke post hoort een track? (shaer-0nh)
|
|---|
| 94 | *
|
|---|
| 95 | * Een track staat nooit los in Klonkt: hij wordt getoond BINNEN een post, via
|
|---|
| 96 | * een van drie insluitingen in posts.content. Die relatie stond alleen in die
|
|---|
| 97 | * tekst en nergens op de draad -- waardoor Shaer, dat zijn feed uit de outbox
|
|---|
| 98 | * bouwt, sinds fb22f78 losse Audio-kaarten kreeg zonder inhoud.
|
|---|
| 99 | *
|
|---|
| 100 | * ALLES IN EEN ZOEKOPDRACHT, niet per track. De collectie loopt over elke open
|
|---|
| 101 | * track, en drie LIKE-scans per stuk wordt bij tweehonderd nummers zeshonderd
|
|---|
| 102 | * scans. Nu is het er een, en de map gaat mee als optie.
|
|---|
| 103 | *
|
|---|
| 104 | * De rang bepaalt welke post wint als er meerdere zijn: rechtstreeks ingesloten
|
|---|
| 105 | * is specifieker dan via een playlist, en die weer specifieker dan via een
|
|---|
| 106 | * albumnaam. Bij gelijke rang de nieuwste post -- dat is waar iemand hem het
|
|---|
| 107 | * laatst heeft uitgebracht.
|
|---|
| 108 | */
|
|---|
| 109 | export function trackHostPosts(siteId) {
|
|---|
| 110 | const rijen = db.prepare(`
|
|---|
| 111 | SELECT tid, post_id, post_slug, rang, wanneer FROM (
|
|---|
| 112 | SELECT t.id AS tid, p.id AS post_id, p.slug AS post_slug, 1 AS rang,
|
|---|
| 113 | COALESCE(p.published_at, p.created_at) AS wanneer
|
|---|
| 114 | FROM audio_tracks t
|
|---|
| 115 | JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
|
|---|
| 116 | AND p.content LIKE '%[[track:' || t.id || ']]%'
|
|---|
| 117 | WHERE t.site_id = ? AND t.fedi_open = 1
|
|---|
| 118 | UNION ALL
|
|---|
| 119 | SELECT t.id, p.id, p.slug, 2, COALESCE(p.published_at, p.created_at)
|
|---|
| 120 | FROM playlist_tracks pt
|
|---|
| 121 | JOIN audio_tracks t ON t.id = pt.track_id
|
|---|
| 122 | JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
|
|---|
| 123 | AND p.content LIKE '%[[playlist:' || pt.playlist_id || ']]%'
|
|---|
| 124 | WHERE t.site_id = ? AND t.fedi_open = 1
|
|---|
| 125 | UNION ALL
|
|---|
| 126 | SELECT t.id, p.id, p.slug, 3, COALESCE(p.published_at, p.created_at)
|
|---|
| 127 | FROM audio_tracks t
|
|---|
| 128 | JOIN posts p ON p.site_id = t.site_id AND p.status = 'published'
|
|---|
| 129 | AND p.content LIKE '%[[album:' || t.album || ']]%'
|
|---|
| 130 | WHERE t.site_id = ? AND t.fedi_open = 1 AND t.album IS NOT NULL AND t.album <> ''
|
|---|
| 131 | ) ORDER BY rang, wanneer DESC
|
|---|
| 132 | `).all(siteId, siteId, siteId);
|
|---|
| 133 | const uit = new Map();
|
|---|
| 134 | for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, { id: r.post_id, slug: r.post_slug });
|
|---|
| 135 | return uit;
|
|---|
| 136 | }
|
|---|
| 137 |
|
|---|
| 138 | /**
|
|---|
| 139 | * De artiest-credit, gedeeld door track en album (shaer-3f8a / shaer-756s).
|
|---|
| 140 | *
|
|---|
| 141 | * De ENTITEIT is de site-actor: een echt, opvraagbaar adres. De credittekst --
|
|---|
| 142 | * de artiestkolom van de track of van de uitgave -- gaat naar `credit`, want
|
|---|
| 143 | * daar verwacht hun model hem. Er een id per artiestnaam van maken zou
|
|---|
| 144 | * identiteit uit een string zijn, en dat is de fout die we bij albums juist
|
|---|
| 145 | * vermijden.
|
|---|
| 146 | *
|
|---|
| 147 | * Eén functie voor beide, zodat een track en het album waar hij op staat nooit
|
|---|
| 148 | * een verschillende artiest kunnen krijgen door twee keer hetzelfde te bouwen.
|
|---|
| 149 | */
|
|---|
| 150 | function artistCredit(base, site, creditTekst, wanneer) {
|
|---|
| 151 | const artiest = {
|
|---|
| 152 | type: 'Artist',
|
|---|
| 153 | id: actorId(base, site.slug),
|
|---|
| 154 | name: site.title || site.slug,
|
|---|
| 155 | published: site.created_at ? new Date(site.created_at).toISOString() : wanneer,
|
|---|
| 156 | };
|
|---|
| 157 | if (isMbid(site.mb_artist_id)) artiest.musicbrainzId = String(site.mb_artist_id).trim().toLowerCase();
|
|---|
| 158 | return [{
|
|---|
| 159 | type: 'ArtistCredit',
|
|---|
| 160 | id: `${actorId(base, site.slug)}#artist-credit`,
|
|---|
| 161 | published: artiest.published,
|
|---|
| 162 | artist: artiest,
|
|---|
| 163 | ...(creditTekst ? { credit: creditTekst } : {}),
|
|---|
| 164 | }];
|
|---|
| 165 | }
|
|---|
| 166 |
|
|---|
| 167 | /**
|
|---|
| 168 | * De identiteit van een track op de draad.
|
|---|
| 169 | *
|
|---|
| 170 | * Staat apart omdat hij op TWEE momenten nodig is die ver uit elkaar liggen:
|
|---|
| 171 | * bij het bouwen van het Audio-object, en bij het verwijderen ervan, wanneer de
|
|---|
| 172 | * rij al weg is en er dus niets meer te bouwen valt. Toen dit nog inline stond,
|
|---|
| 173 | * kende alleen de bouwkant de vorm en ging er bij verwijderen geen Delete uit --
|
|---|
| 174 | * elke server die de track had geindexeerd hield hem voor altijd (21-8).
|
|---|
| 175 | */
|
|---|
| 176 | export function trackUri(base, site, id) {
|
|---|
| 177 | return `${actorId(base, site.slug)}/tracks/${encodeURIComponent(id)}`;
|
|---|
| 178 | }
|
|---|
| 179 |
|
|---|
| 180 | export function buildTrackAudio(base, site, r, opts = {}) {
|
|---|
| 181 | const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
|
|---|
| 182 | const fn = r.filename || (r.storage_path || '').split('/').pop();
|
|---|
| 183 | // De bestandsgegevens horen bij de LINK, niet bij het object: het is die ene
|
|---|
| 184 | // representatie die zoveel bytes is en die bitrate heeft, niet het nummer.
|
|---|
| 185 | // Zo doet Funkwhale het ook.
|
|---|
| 186 | // De post waar dit nummer in staat. Meegegeven door de collectie (een
|
|---|
| 187 | // zoekopdracht voor alles), of hier opgezocht als deze track los wordt
|
|---|
| 188 | // opgehaald. `hostPosts` mag expliciet null zijn: dan is er niets te zoeken.
|
|---|
| 189 | const post = opts.hostPosts !== undefined
|
|---|
| 190 | ? (opts.hostPosts && opts.hostPosts.get(r.id)) || null
|
|---|
| 191 | : ((site.id && trackHostPosts(site.id).get(r.id)) || null);
|
|---|
| 192 |
|
|---|
| 193 | const bestand = { type: 'Link', href: `${base}/audio/stream/${encodeURIComponent(fn)}`, mediaType: r.mime_type || 'audio/mpeg' };
|
|---|
| 194 | if (Number(r.size)) bestand.size = Number(r.size);
|
|---|
| 195 | // Bitrate leiden we af uit bytes en seconden. Geen gok: voor een bestand IS
|
|---|
| 196 | // dat de gemiddelde bitrate, en bij CBR ook de echte. Alleen als we allebei
|
|---|
| 197 | // de getallen hebben -- liever geen veld dan een verzonnen getal.
|
|---|
| 198 | if (Number(r.size) && Number(r.duration)) bestand.bitrate = Math.round((Number(r.size) * 8) / Number(r.duration));
|
|---|
| 199 |
|
|---|
| 200 | const a = {
|
|---|
| 201 | ...(opts.standalone ? { '@context': AP_CONTEXT } : {}),
|
|---|
| 202 | id: trackUri(base, site, r.id),
|
|---|
| 203 | type: 'Audio',
|
|---|
| 204 | name: r.title || 'Audio',
|
|---|
| 205 | attributedTo: actorId(base, site.slug),
|
|---|
| 206 | // Op het OBJECT, niet alleen op de omhullende Create: een los opgehaalde
|
|---|
| 207 | // track moet zelf kunnen zeggen dat hij openbaar is.
|
|---|
| 208 | to: [PUBLIC],
|
|---|
| 209 | // De post die dit nummer uitbrengt staat VOORAAN als text/html, precies
|
|---|
| 210 | // zoals Funkwhale zijn trackpagina zet. Wij hadden dat veld leeg gelaten
|
|---|
| 211 | // omdat Klonkt geen trackpagina heeft -- maar de post IS waar je het kunt
|
|---|
| 212 | // horen, en dat is wat zo'n link betekent.
|
|---|
| 213 | url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
|
|---|
| 214 | };
|
|---|
| 215 | // De bak waar dit bestand in hangt (shaer-0nh). Voor Funkwhale is dit het
|
|---|
| 216 | // haakje waaraan een upload komt te zitten; zonder dit veld blijft een track
|
|---|
| 217 | // daar een naam zonder geluid.
|
|---|
| 218 | a.library = libraryId(base, site);
|
|---|
| 219 | if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab
|
|---|
| 220 | // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
|
|---|
| 221 | // track is dat de post die hem uitbrengt. Daarmee is de relatie die tot nu
|
|---|
| 222 | // toe alleen in posts.content stond, op de draad te zien -- en kan een lezer
|
|---|
| 223 | // die de post al heeft dit nummer overslaan in plaats van er een lege kaart
|
|---|
| 224 | // van te maken.
|
|---|
| 225 | if (post) a.context = noteId(base, post.id);
|
|---|
| 226 | // Het NUMMER, los van dit bestand (shaer-3f8a, spoor B). Funkwhale en
|
|---|
| 227 | // Emissary lezen allebei `fw:track`, en petitminion noemde het ontbreken
|
|---|
| 228 | // ervan als eerste wat hem opviel aan onze objecten.
|
|---|
| 229 | //
|
|---|
| 230 | // EIGEN ID MET #track, en niet hetzelfde id als de Audio. Emissary hergebruikt
|
|---|
| 231 | // daar het object-id, maar dan zijn in JSON-LD de Audio en de Track EEN knoop
|
|---|
| 232 | // met twee typen -- en een bestand is geen werk. Dat verschil moeten we straks
|
|---|
| 233 | // toch maken, want een album verzamelt nummers en geen mp3's. Een fragment is
|
|---|
| 234 | // een geldige IRI en wijst naar hetzelfde document.
|
|---|
| 235 | //
|
|---|
| 236 | // GEEN `album`. Dat veld is bij hen een URI naar een Album-object en bij ons
|
|---|
| 237 | // een tekstkolom; er hier een adres van maken zou een ding beloven dat niet
|
|---|
| 238 | // bestaat. Zie shaer-k37k -- dat is de keuze die daarvoor eerst moet vallen.
|
|---|
| 239 | //
|
|---|
| 240 | // WIE IS DE ARTIEST. Hun Artist is een ENTITEIT met een id, en bij ons is een
|
|---|
| 241 | // artiest een tekstkolom op de track. Die twee verzoenen we zo: de entiteit
|
|---|
| 242 | // is de site-ACTOR -- een echt, opvraagbaar adres, het account dat dit
|
|---|
| 243 | // uitbrengt -- en de tekst uit de kolom gaat naar `credit`, want dat is
|
|---|
| 244 | // precies waar hun model de credittekst verwacht.
|
|---|
| 245 | //
|
|---|
| 246 | // Dat is eerlijk en het is niet nieuw: open.audio leidde op 13-8 al zelf een
|
|---|
| 247 | // artist_credit af uit onze attributedTo. We maken alleen expliciet wat daar
|
|---|
| 248 | // toch al gebeurde.
|
|---|
| 249 | //
|
|---|
| 250 | // DE GRENS ERVAN: brengt een site werk van iemand anders uit, dan zegt dit
|
|---|
| 251 | // dat de site de artiest is. Dat stond al in attributedTo, dus we maken het
|
|---|
| 252 | // niet erger -- maar het is wel de reden dat we hier geen id per artiestnaam
|
|---|
| 253 | // verzinnen. Identiteit uit een string is dezelfde fout als bij het album
|
|---|
| 254 | // (shaer-756s).
|
|---|
| 255 | const wanneer = r.created_at ? new Date(r.created_at).toISOString()
|
|---|
| 256 | : (site.created_at ? new Date(site.created_at).toISOString() : new Date(0).toISOString());
|
|---|
| 257 |
|
|---|
| 258 | a.track = {
|
|---|
| 259 | type: 'Track',
|
|---|
| 260 | id: `${a.id}#track`,
|
|---|
| 261 | name: a.name,
|
|---|
| 262 | published: wanneer,
|
|---|
| 263 | ...(Number(r.position) ? { position: Number(r.position) } : {}),
|
|---|
| 264 | artist_credit: artistCredit(base, site, r.artist, wanneer),
|
|---|
| 265 | };
|
|---|
| 266 | // De uitgave waar dit nummer op staat, INGESLOTEN (shaer-756s, stap 2).
|
|---|
| 267 | // `albums` mag expliciet null zijn: dan is er niets op te zoeken.
|
|---|
| 268 | const uitgave = opts.albums !== undefined
|
|---|
| 269 | ? (opts.albums && opts.albums.get(r.id)) || null
|
|---|
| 270 | : ((site.id && trackAlbums(site.id).get(r.id)) || null);
|
|---|
| 271 | if (uitgave) {
|
|---|
| 272 | a.track.album = buildAlbumObject(base, site, uitgave);
|
|---|
| 273 | // Ook op het Audio-object zelf, als URI. Funkwhale 2.0 en Emissary doen dat
|
|---|
| 274 | // allebei, en het scheelt een lezer het uitpakken van de track.
|
|---|
| 275 | a.album = a.track.album.id;
|
|---|
| 276 | }
|
|---|
| 277 | if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
|
|---|
| 278 | if (r.created_at) a.published = new Date(r.created_at).toISOString();
|
|---|
| 279 | if (Number(r.position)) a.position = Number(r.position);
|
|---|
| 280 | const lic = licentieUri(r.license);
|
|---|
| 281 | if (lic) a.license = lic;
|
|---|
| 282 | const art = abs(r.cover_url || opts.coverFallback || null);
|
|---|
| 283 | // icon EN image: allebei AS2-kern. Wij gebruikten alleen icon; Funkwhale
|
|---|
| 284 | // leest image. Dezelfde hoes, twee namen, niemand die iets misloopt.
|
|---|
| 285 | if (art) {
|
|---|
| 286 | const plaat = { type: 'Image', mediaType: guessMediaType(art), url: art };
|
|---|
| 287 | a.icon = plaat;
|
|---|
| 288 | a.image = plaat;
|
|---|
| 289 | }
|
|---|
| 290 | return a;
|
|---|
| 291 | }
|
|---|
| 292 |
|
|---|
| 293 | /**
|
|---|
| 294 | * Onze licentie is VRIJE TEKST uit een keuzelijst ("CC BY 4.0", "Alle rechten
|
|---|
| 295 | * voorbehouden"); schema.org en Funkwhale willen een URI. Alleen de waarden die
|
|---|
| 296 | * onze eigen keuzelijst aanbiedt worden vertaald -- die kennen we exact. Al het
|
|---|
| 297 | * andere levert niets op: een zelfbedachte licentie-URI is erger dan geen, want
|
|---|
| 298 | * een lezer gelooft hem.
|
|---|
| 299 | */
|
|---|
| 300 | const LICENTIES = {
|
|---|
| 301 | 'cc0 1.0 (publiek domein)': 'http://creativecommons.org/publicdomain/zero/1.0/',
|
|---|
| 302 | 'cc by 4.0': 'http://creativecommons.org/licenses/by/4.0/',
|
|---|
| 303 | 'cc by-sa 4.0': 'http://creativecommons.org/licenses/by-sa/4.0/',
|
|---|
| 304 | 'cc by-nc 4.0': 'http://creativecommons.org/licenses/by-nc/4.0/',
|
|---|
| 305 | 'cc by-nc-sa 4.0': 'http://creativecommons.org/licenses/by-nc-sa/4.0/',
|
|---|
| 306 | 'cc by-nd 4.0': 'http://creativecommons.org/licenses/by-nd/4.0/',
|
|---|
| 307 | };
|
|---|
| 308 | export function licentieUri(waarde) {
|
|---|
| 309 | const s = String(waarde || '').trim();
|
|---|
| 310 | if (!s) return null;
|
|---|
| 311 | if (/^https?:\/\//i.test(s)) return safeUrl(s); // iemand vulde al een URI in
|
|---|
| 312 | return LICENTIES[s.toLowerCase()] || null; // "Alle rechten voorbehouden" heeft er geen
|
|---|
| 313 | }
|
|---|
| 314 |
|
|---|
| 315 | /** Het AS2-id van de bibliotheek van een site. */
|
|---|
| 316 | export function libraryId(base, site) {
|
|---|
| 317 | return `${actorId(base, site.slug)}/library`;
|
|---|
| 318 | }
|
|---|
| 319 |
|
|---|
| 320 | /**
|
|---|
| 321 | * De site als Funkwhale-LIBRARY (skelet).
|
|---|
| 322 | *
|
|---|
| 323 | * WAAROM DIT GEEN DIALECT IS ZOALS track EN ArtistCredit DAT WEL ZIJN. Die twee
|
|---|
| 324 | * vragen entiteiten waar wij tekst hebben; hiervoor hoeven we niets te
|
|---|
| 325 | * verzinnen. Een library is precies wat er al staat: onze open tracks, met een
|
|---|
| 326 | * echte telling en een echt id.
|
|---|
| 327 | *
|
|---|
| 328 | * WAAROM HET NODIG IS, gemeten op 13-8. open.audio heeft onze vier tracks
|
|---|
| 329 | * binnengehaald langs de AP-weg -- met ONZE track-id's, en met een artist_credit
|
|---|
| 330 | * dat Funkwhale zelf uit onze attributedTo afleidde. Maar `uploads` is leeg en
|
|---|
| 331 | * `is_playable` false. Bij hen hangt een upload aan een library; zonder library
|
|---|
| 332 | * is er geen bak om het bestand in te hangen. Het audiobestand zelf is wel
|
|---|
| 333 | * gewoon op te halen (200, audio/mpeg, ook anoniem) -- ze hebben het niet
|
|---|
| 334 | * geprobeerd.
|
|---|
| 335 | *
|
|---|
| 336 | * SKELET, en dat woord is letterlijk bedoeld. Dit is de vorm uit hun docs:
|
|---|
| 337 | * type, id, name, followers, totalItems, first, last, plus attributedTo en
|
|---|
| 338 | * summary. Wat er NIET is: de volg-afhandeling. Onze bibliotheek is openbaar --
|
|---|
| 339 | * elke track erin heeft fedi_open -- dus er valt niets goed te keuren. Komt er
|
|---|
| 340 | * ooit een besloten variant, dan hoort daar het Follow/Accept-werk bij.
|
|---|
| 341 | */
|
|---|
| 342 | export function buildLibrary(base, site, rows, { page = false } = {}) {
|
|---|
| 343 | const id = libraryId(base, site);
|
|---|
| 344 | const hostPosts = site.id ? trackHostPosts(site.id) : null;
|
|---|
| 345 | const albums = site.id ? trackAlbums(site.id) : null;
|
|---|
| 346 | const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts, albums }));
|
|---|
| 347 | return pagedCollection(id, items, {
|
|---|
| 348 | page,
|
|---|
| 349 | // Een platenkast is geen tijdlijn: `Collection`, niet `OrderedCollection`.
|
|---|
| 350 | // Funkwhale's LibrarySerializer accepteert ook alleen die twee typen
|
|---|
| 351 | // (as:Collection of fw:Library) en zijn CollectionPageSerializer alleen
|
|---|
| 352 | // `CollectionPage` met `items`.
|
|---|
| 353 | ongeordend: true,
|
|---|
| 354 | extra: {
|
|---|
| 355 | type: 'Library',
|
|---|
| 356 | name: site.title || site.slug,
|
|---|
| 357 | attributedTo: actorId(base, site.slug),
|
|---|
| 358 | // WAAROM DIT VELD ER MOET STAAN. Funkwhale's LibrarySerializer noemt
|
|---|
| 359 | // `audience` optioneel, maar zijn create() doet er meteen
|
|---|
| 360 | // `privacy[validated_data["audience"]]` mee -- zonder de sleutel is dat
|
|---|
| 361 | // een KeyError en geeft hun server een 500. Dat is wat open.audio op 15-8
|
|---|
| 362 | // teruggaf toen Robin onze library-URI daar opzocht.
|
|---|
| 363 | //
|
|---|
| 364 | // Het is bovendien gewoon waar: alles hierin is fedi_open, dus openbaar.
|
|---|
| 365 | // Bij hen is dit precies het verschil tussen privacy_level 'everyone' en
|
|---|
| 366 | // 'me' -- oftewel of onze nummers daar afspeelbaar zijn.
|
|---|
| 367 | audience: 'https://www.w3.org/ns/activitystreams#Public',
|
|---|
| 368 | // Vereist volgens hun docs. Openbaar, dus de telling is eerlijk en de
|
|---|
| 369 | // lijst blijft leeg -- wie ons volgt volgt de ACTOR, niet de bak.
|
|---|
| 370 | followers: `${id}/followers`,
|
|---|
| 371 | ...(site.description ? { summary: String(site.description).slice(0, 500) } : {}),
|
|---|
| 372 | },
|
|---|
| 373 | });
|
|---|
| 374 | }
|
|---|
| 375 |
|
|---|
| 376 | /** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
|
|---|
| 377 | export function buildTrackCollection(base, site, rows, { page = false } = {}) {
|
|---|
| 378 | // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
|
|---|
| 379 | const posts = site.id ? trackHostPosts(site.id) : null;
|
|---|
| 380 | const albums = site.id ? trackAlbums(site.id) : null;
|
|---|
| 381 | const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts, albums }));
|
|---|
| 382 | return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
|
|---|
| 383 | }
|
|---|
| 384 |
|
|---|
| 385 | // Een post die een playlist insluit wijst in zijn AS2 ook naar de collectie
|
|---|
| 386 | // (shaer-ayc, stap 2): een Link-tag per ingesloten playlist. Mastodon
|
|---|
| 387 | // parseert alleen Mention/Hashtag/Emoji en negeert een Link geruisloos; een
|
|---|
| 388 | // client die hem kent haalt de collectie op. Opgelost uit post.content en
|
|---|
| 389 | // ALLEEN binnen de eigen site: playlist-ids zijn een globale primary key, dus
|
|---|
| 390 | // zonder site-check zou een post van site A naar de collectie van site B
|
|---|
| 391 | // kunnen wijzen.
|
|---|
| 392 | export function playlistLinkTags(base, site, content, post = null) {
|
|---|
| 393 | const out = [];
|
|---|
| 394 | try {
|
|---|
| 395 | // Zelfde patroon als de renderer en als de afleiding: wat niet insluit,
|
|---|
| 396 | // krijgt ook geen link. Dit stond hier met een eigen patroon dat
|
|---|
| 397 | // underscores accepteerde die nergens anders meetellen.
|
|---|
| 398 | for (const id of ingeslotenPlaylists(content)) {
|
|---|
| 399 | const pl = db.prepare('SELECT id, title FROM playlists WHERE id = ? AND site_id = ?').get(id, site.id);
|
|---|
| 400 | if (!pl) continue;
|
|---|
| 401 | out.push({ type: 'Link', href: `${actorId(base, site.slug)}/playlists/${pl.id}`, mediaType: 'application/activity+json', name: pl.title });
|
|---|
| 402 | }
|
|---|
| 403 | // Losse tracks in een post zijn ook een uitgave (shaer-38y): ze krijgen een
|
|---|
| 404 | // eigen collectie, en de post wijst er langs dezelfde weg naar. Zonder deze
|
|---|
| 405 | // link zou die collectie bestaan maar door niemand te vinden zijn.
|
|---|
| 406 | if (post && post.id) {
|
|---|
| 407 | const eenheid = postMusicType(content, site.id);
|
|---|
| 408 | if (eenheid && !eenheid.collectie && eenheid.tracks?.length && losseTracksVanPost(site.id, eenheid.tracks).length) {
|
|---|
| 409 | out.push({
|
|---|
| 410 | type: 'Link',
|
|---|
| 411 | href: postTracksId(base, site, post.id),
|
|---|
| 412 | mediaType: 'application/activity+json',
|
|---|
| 413 | name: post.title || 'Tracks',
|
|---|
| 414 | });
|
|---|
| 415 | }
|
|---|
| 416 | }
|
|---|
| 417 | } catch { /* niet-fataal: een tag minder, geen kapotte Note */ }
|
|---|
| 418 | return out;
|
|---|
| 419 | }
|
|---|
| 420 |
|
|---|
| 421 | // De lijst van alle playlist-collecties van een site (shaer-ayc, stap 2).
|
|---|
| 422 | // Kaal standaard (URI's), verrijkt op verzoek (FEP-9876, zelfde conventie als
|
|---|
| 423 | // followers/following): een stub per playlist met naam, hoes en de EERLIJKE
|
|---|
| 424 | // telling -- totalItems van de stub telt het open deel, dezelfde regel als de
|
|---|
| 425 | // collectie zelf, want ook een lijst mag niet verklappen wat er achter de
|
|---|
| 426 | // poort staat.
|
|---|
| 427 | export function listPlaylistsAP(base, site, enriched, { page = false } = {}) {
|
|---|
| 428 | const rows = db.prepare(
|
|---|
| 429 | 'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
|
|---|
| 430 | ).all(site.id);
|
|---|
| 431 | const colId = `${actorId(base, site.slug)}/playlists`;
|
|---|
| 432 | const items = rows.map((p) => {
|
|---|
| 433 | const uri = `${actorId(base, site.slug)}/playlists/${p.id}`;
|
|---|
| 434 | if (!enriched) return uri;
|
|---|
| 435 | const stub = buildPlaylistCollection(base, site, p, playlistOpenTracks(p.id));
|
|---|
| 436 | delete stub['@context']; // genest object draagt de context van zijn omhulsel
|
|---|
| 437 | delete stub.orderedItems; // stub: wie de tracks wil, haalt de collectie op
|
|---|
| 438 | return stub;
|
|---|
| 439 | });
|
|---|
| 440 | return pagedCollection(colId, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
|
|---|
| 441 | }
|
|---|
| 442 |
|
|---|
| 443 | /**
|
|---|
| 444 | * Bij welke UITGAVE hoort een track? (shaer-756s, stap 2)
|
|---|
| 445 | *
|
|---|
| 446 | * Alleen playlists met kind='album' tellen: een mixtape is geen uitgave, en dat
|
|---|
| 447 | * onderscheid is precies wat de keuze album/playlist betekent. Zit een track in
|
|---|
| 448 | * twee albums, dan wint de oudste -- willekeurig maar STABIEL, en dat is wat
|
|---|
| 449 | * telt: een id dat per ophaalactie verspringt is erger dan een id dat niet de
|
|---|
| 450 | * mooiste keuze is.
|
|---|
| 451 | *
|
|---|
| 452 | * Eén zoekopdracht voor alle rijen samen, zoals trackHostPosts. Per track
|
|---|
| 453 | * vragen wordt bij tweehonderd nummers tweehonderd zoekopdrachten.
|
|---|
| 454 | */
|
|---|
| 455 | export function trackAlbums(siteId) {
|
|---|
| 456 | const rijen = db.prepare(`
|
|---|
| 457 | SELECT pt.track_id AS tid, p.id, p.title, p.artist, p.year, p.cover_url,
|
|---|
| 458 | p.release_date, p.mb_release_id, p.created_at
|
|---|
| 459 | FROM playlist_tracks pt
|
|---|
| 460 | JOIN playlists p ON p.id = pt.playlist_id
|
|---|
| 461 | WHERE p.site_id = ? AND p.kind = 'album'
|
|---|
| 462 | ORDER BY p.created_at, p.id
|
|---|
| 463 | `).all(siteId);
|
|---|
| 464 | const uit = new Map();
|
|---|
| 465 | for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, r);
|
|---|
| 466 | // De post die deze plaat uitbrengt, EEN keer per album opgezocht en niet per
|
|---|
| 467 | // track: uitgavePost() doet er echt werk voor (hij leest de typering van de
|
|---|
| 468 | // post) en een site heeft veel meer nummers dan platen.
|
|---|
| 469 | //
|
|---|
| 470 | // WAAROM DIT ERBIJ MOET: buildPlaylistCollection laat leenVanPost de naam van
|
|---|
| 471 | // de post overnemen -- de post IS de uitgave. Zonder dezelfde lening hier zou
|
|---|
| 472 | // het ingesloten Album "Cartoon Epic" heten en zijn eigen URI "Geen koffie,
|
|---|
| 473 | // wel thee!". Een id met twee namen, en dat is precies wat op 16-8 uit de
|
|---|
| 474 | // meting rolde.
|
|---|
| 475 | const perAlbum = new Map();
|
|---|
| 476 | for (const r of uit.values()) {
|
|---|
| 477 | if (perAlbum.has(r.id)) continue;
|
|---|
| 478 | perAlbum.set(r.id, uitgavePost(siteId, r.id));
|
|---|
| 479 | }
|
|---|
| 480 | for (const r of uit.values()) r._post = perAlbum.get(r.id) || null;
|
|---|
| 481 | return uit;
|
|---|
| 482 | }
|
|---|
| 483 |
|
|---|
| 484 | /**
|
|---|
| 485 | * Een uitgave als `fw:Album`.
|
|---|
| 486 | *
|
|---|
| 487 | * INGESLOTEN EN NIET ALS URI, en dat is het hele punt van deze stap. Funkwhale's
|
|---|
| 488 | * TrackSerializer heeft `album = AlbumSerializer()` -- een object met name,
|
|---|
| 489 | * published en een eigen artist_credit. Een kale URI expandeert naar een knoop
|
|---|
| 490 | * met alleen een @id en valt daar dus af. Emissary stuurt precies zo'n kale URI,
|
|---|
| 491 | * en dat is waarom hun tracks bij Funkwhale net zo goed stranden.
|
|---|
| 492 | *
|
|---|
| 493 | * Het `id` is de bestaande playlist-collectie: dereferenceerbaar, en het is
|
|---|
| 494 | * werkelijk hetzelfde ding. We verzinnen geen tweede adres voor iets dat er al
|
|---|
| 495 | * een heeft.
|
|---|
| 496 | */
|
|---|
| 497 | export function buildAlbumObject(base, site, pl) {
|
|---|
| 498 | if (!pl) return null;
|
|---|
| 499 | const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
|
|---|
| 500 | // WANNEER IS DEZE PLAAT GEPUBLICEERD. De post die hem uitbrengt gaat voor, en
|
|---|
| 501 | // niet als noodgreep maar omdat hij het beter weet: playlists.created_at is
|
|---|
| 502 | // het moment waarop de RIJ is aangemaakt, en dat kan weken eerder zijn terwijl
|
|---|
| 503 | // je nog aan het samenstellen was. AS2 `published` vraagt wanneer het object
|
|---|
| 504 | // openbaar werd, en dat is de post.
|
|---|
| 505 | //
|
|---|
| 506 | // GEEN epoch als laatste terugval. `published` is bij hen verplicht, maar 1970
|
|---|
| 507 | // is een ANTWOORD en geen ontbrekend veld -- en dat is erger: een lezer kan een
|
|---|
| 508 | // gat opmerken, een leugen niet. Zo kwam op 16-8 de route boven water die id,
|
|---|
| 509 | // title, artist, year, cover_url en kind selecteerde en de rest niet.
|
|---|
| 510 | //
|
|---|
| 511 | // OOK VOOR `released`, en daar had ik het eerst mis (Robin, 16-8). Mijn
|
|---|
| 512 | // bezwaar was: post je vandaag een plaat uit 2018, dan beweert dit dat hij
|
|---|
| 513 | // vandaag uitkwam. Dat gebeurt ook -- maar bij de meeste Klonkt-sites IS de
|
|---|
| 514 | // post het uitbrengen, en GEEN datum is slechter dan een datum die op het
|
|---|
| 515 | // gewone geval klopt. Het handmatige veld is precies het gereedschap voor de
|
|---|
| 516 | // uitzondering: bij een heruitgave vul je hem in en die wint.
|
|---|
| 517 | const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null;
|
|---|
| 518 | const wanneer = postDatum ? postDatum.toISOString()
|
|---|
| 519 | : (pl.created_at ? new Date(pl.created_at).toISOString() : null);
|
|---|
| 520 | // Dezelfde lening als in buildPlaylistCollection: de post die de plaat
|
|---|
| 521 | // uitbrengt geeft zijn titel, en de eigen titel blijft als alsoKnownAs staan.
|
|---|
| 522 | const titel = (pl._post && pl._post.title) || pl.title;
|
|---|
| 523 | const album = {
|
|---|
| 524 | type: 'Album',
|
|---|
| 525 | id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
|
|---|
| 526 | name: titel,
|
|---|
| 527 | ...(wanneer ? { published: wanneer } : {}),
|
|---|
| 528 | attributedTo: actorId(base, site.slug),
|
|---|
| 529 | artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()),
|
|---|
| 530 | };
|
|---|
| 531 | if (titel !== pl.title) album.alsoKnownAs = pl.title;
|
|---|
| 532 | // Het ingevulde veld wint altijd; anders de DAG waarop de post verscheen.
|
|---|
| 533 | // `year` vult hem nog steeds niet aan, en dat is geen inconsequentie: een
|
|---|
| 534 | // jaartal is geen dag, terwijl de postdatum een gebeurtenis is die werkelijk
|
|---|
| 535 | // heeft plaatsgevonden. Het verschil is verzinnen versus afleiden.
|
|---|
| 536 | if (pl.release_date) album.released = pl.release_date;
|
|---|
| 537 | else if (postDatum) album.released = postDatum.toISOString().slice(0, 10);
|
|---|
| 538 | if (pl.mb_release_id) album.musicbrainzId = pl.mb_release_id;
|
|---|
| 539 | const hoes = abs(pl.cover_url || null);
|
|---|
| 540 | if (hoes) album.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
|
|---|
| 541 | return album;
|
|---|
| 542 | }
|
|---|
| 543 |
|
|---|
| 544 | export function buildPlaylistCollection(base, site, playlist, rows) {
|
|---|
| 545 | const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
|
|---|
| 546 | // Dezelfde objecten als in de actor-collectie, met hetzelfde id (shaer-0nh,
|
|---|
| 547 | // stap 3). Een playlist is een KEUZE uit wat de artiest heeft uitgebracht,
|
|---|
| 548 | // geen tweede exemplaar ervan: staat een track in twee playlists, dan is het
|
|---|
| 549 | // twee keer hetzelfde ding en niet twee dingen die toevallig gelijk klinken.
|
|---|
| 550 | // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
|
|---|
| 551 | const hostPosts = site.id ? trackHostPosts(site.id) : null;
|
|---|
| 552 | const albums = site.id ? trackAlbums(site.id) : null;
|
|---|
| 553 | // EEN keer opzoeken en twee keer gebruiken: het Album leent er zijn datums en
|
|---|
| 554 | // titel van, leenVanPost onderaan zijn tekst en tags. Twee losse aanroepen
|
|---|
| 555 | // zouden niet alleen dubbel werk zijn maar ook uiteen kunnen lopen -- en dan
|
|---|
| 556 | // staat er weer iets anders op het ingesloten object dan op zijn eigen URI.
|
|---|
| 557 | const post = site.id ? uitgavePost(site.id, playlist.id) : null;
|
|---|
| 558 | const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts, albums }));
|
|---|
| 559 | const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
|
|---|
| 560 | extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
|
|---|
| 561 | });
|
|---|
| 562 | // Album of playlist is presentatie; op de draad is het één samenvattingsveld.
|
|---|
| 563 | const parts = [];
|
|---|
| 564 | if (playlist.artist) parts.push(playlist.artist);
|
|---|
| 565 | if (playlist.year) parts.push(String(playlist.year));
|
|---|
| 566 | if (parts.length) out.summary = parts.join(' · ');
|
|---|
| 567 | const cover = abs(playlist.cover_url || null);
|
|---|
| 568 | if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
|
|---|
| 569 |
|
|---|
| 570 | // Is dit een UITGAVE, dan draagt deze collectie ook de albumvelden
|
|---|
| 571 | // (shaer-756s, stap 2): het is het adres waar track.album naar wijst, en dan
|
|---|
| 572 | // hoort hier hetzelfde te staan als in het ingesloten object.
|
|---|
| 573 | //
|
|---|
| 574 | // `type` blijft OrderedCollection, EN BLIJFT EEN STRING. Er stond hier even
|
|---|
| 575 | // ['OrderedCollection', 'Album'] -- geldig AS2, en het is ook werkelijk
|
|---|
| 576 | // allebei -- maar een bestaande test viel erover, en die test had gelijk: een
|
|---|
| 577 | // lezer die `type` als tekst uitpakt (Shaer doet dat) verliest dan in stilte
|
|---|
| 578 | // de hele playlist. Het kost ons niets, want hun AlbumSerializer declareert
|
|---|
| 579 | // geen type-veld en valideert het dus niet: haalt Funkwhale dit adres op als
|
|---|
| 580 | // album, dan leest hij deze velden gewoon. En het object dat hij echt gebruikt
|
|---|
| 581 | // staat toch al ingesloten op de track.
|
|---|
| 582 | if ((playlist.kind || 'album') === 'album') {
|
|---|
| 583 | const album = buildAlbumObject(base, site, { ...playlist, _post: post });
|
|---|
| 584 | for (const veld of ['published', 'released', 'musicbrainzId', 'artist_credit', 'image']) {
|
|---|
| 585 | if (album[veld] !== undefined) out[veld] = album[veld];
|
|---|
| 586 | }
|
|---|
| 587 | }
|
|---|
| 588 | return leenVanPost(base, site, out, post);
|
|---|
| 589 | }
|
|---|
| 590 |
|
|---|
| 591 | // ── De post als uitgave (shaer-38y) ───────────────────────────────────────
|
|---|
| 592 |
|
|---|
| 593 | /** Het AS2-id van de collectie losse tracks van een post. */
|
|---|
| 594 | function postTracksId(base, site, postId) {
|
|---|
| 595 | return `${actorId(base, site.slug)}/posts/${encodeURIComponent(postId)}/tracks`;
|
|---|
| 596 | }
|
|---|
| 597 |
|
|---|
| 598 | /**
|
|---|
| 599 | * Welke post brengt deze playlist uit, en mag die zijn gegevens uitlenen?
|
|---|
| 600 | *
|
|---|
| 601 | * Niet zomaar de eerste post die de playlist noemt: alleen een post die er EEN
|
|---|
| 602 | * muzikale eenheid van maakt leent uit. Staan er twee collecties in, dan is de
|
|---|
| 603 | * post niet meer de drager van een identiteit en houdt de playlist de zijne --
|
|---|
| 604 | * dezelfde regel als in de afleiding, hier alleen toegepast.
|
|---|
| 605 | *
|
|---|
| 606 | * De nieuwste wint als er meerdere zijn: dat is waar hij het laatst is
|
|---|
| 607 | * uitgebracht.
|
|---|
| 608 | */
|
|---|
| 609 | export function uitgavePost(siteId, playlistId) {
|
|---|
| 610 | if (!siteId || !playlistId) return null;
|
|---|
| 611 | try {
|
|---|
| 612 | const rijen = db.prepare(`
|
|---|
| 613 | SELECT id, slug, title, excerpt, content, cover_image_url, tags,
|
|---|
| 614 | COALESCE(published_at, created_at) AS uit_wanneer
|
|---|
| 615 | FROM posts
|
|---|
| 616 | WHERE site_id = ? AND status = 'published'
|
|---|
| 617 | AND content LIKE '%[[playlist:' || ? || ']]%'
|
|---|
| 618 | ORDER BY COALESCE(published_at, created_at) DESC
|
|---|
| 619 | `).all(siteId, playlistId);
|
|---|
| 620 | for (const p of rijen) {
|
|---|
| 621 | const r = postMusicType(p.content, siteId);
|
|---|
| 622 | if (r && r.leentMetadata && r.collectie && r.collectie.id === playlistId) return p;
|
|---|
| 623 | }
|
|---|
| 624 | } catch { /* geen lening is geen fout */ }
|
|---|
| 625 | return null;
|
|---|
| 626 | }
|
|---|
| 627 |
|
|---|
| 628 | /**
|
|---|
| 629 | * De post leent zijn gegevens aan de uitgave (shaer-38y, punt 3).
|
|---|
| 630 | *
|
|---|
| 631 | * WAAROM DE POST WINT EN NIET DE PLAYLIST. Een playlist heeft een titel en soms
|
|---|
| 632 | * een hoes; een post heeft een titel, een tekst, een hoes, tags EN een datum.
|
|---|
| 633 | * Voor audio-gebaseerde inhoud is de post de uitgave -- dat is waar iemand hem
|
|---|
| 634 | * heeft uitgebracht en waar het verhaal erbij staat. Een Funkwhale-achtige
|
|---|
| 635 | * lezer vindt een collectie met alleen een naam te mager, en dat is precies wat
|
|---|
| 636 | * hij nu krijgt.
|
|---|
| 637 | *
|
|---|
| 638 | * De naam van de playlist gaat niet verloren: die blijft als `alsoKnownAs`
|
|---|
| 639 | * staan, zodat de eigen naam terug te vinden is als hij afwijkt.
|
|---|
| 640 | */
|
|---|
| 641 | function leenVanPost(base, site, obj, post) {
|
|---|
| 642 | if (!post) return obj;
|
|---|
| 643 | const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
|
|---|
| 644 |
|
|---|
| 645 | if (post.title) {
|
|---|
| 646 | if (obj.name && obj.name !== post.title) obj.alsoKnownAs = obj.name;
|
|---|
| 647 | obj.name = post.title;
|
|---|
| 648 | }
|
|---|
| 649 | // De tekst als `content`, niet als `summary`: in AS2 is summary de korte
|
|---|
| 650 | // samenvatting en content het lijf. Artiest en jaar blijven dus in summary
|
|---|
| 651 | // staan -- dat is een samenvatting, en de posttekst is dat niet.
|
|---|
| 652 | const tekst = tekstVanPost(post);
|
|---|
| 653 | if (tekst) obj.content = tekst;
|
|---|
| 654 |
|
|---|
| 655 | const cover = abs(post.cover_image_url || null);
|
|---|
| 656 | if (cover) {
|
|---|
| 657 | obj.image = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
|
|---|
| 658 | if (!obj.icon) obj.icon = obj.image; // geen eigen hoes? dan die van de post
|
|---|
| 659 | }
|
|---|
| 660 |
|
|---|
| 661 | // Dezelfde lijst als de Note: het tagveld EN de hashtags uit het lijf, waarbij
|
|---|
| 662 | // de geschreven vorm voorgaat. Een eigen lijst hier zou de tags uit de tekst
|
|---|
| 663 | // missen en de rest anders spellen dan dezelfde post elders doet.
|
|---|
| 664 | const tags = buildHashtagList(base, post.tags, post.content, { ruw: true });
|
|---|
| 665 | if (tags.length) obj.tag = tags;
|
|---|
| 666 |
|
|---|
| 667 | // Waar je hem kunt horen, en waar hij bij hoort. Zelfde paar als bij een
|
|---|
| 668 | // losse track: url wijst een mens naar de post, context zegt waar dit object
|
|---|
| 669 | // thuishoort.
|
|---|
| 670 | obj.url = `${base}/${post.slug}`;
|
|---|
| 671 | obj.context = noteId(base, post.id);
|
|---|
| 672 | return obj;
|
|---|
| 673 | }
|
|---|
| 674 |
|
|---|
| 675 | /**
|
|---|
| 676 | * De tekst van een post, als er een is. De excerpt heeft voorrang -- die is
|
|---|
| 677 | * geschreven om samen te vatten. Staat die leeg, dan het lijf zelf: zonder
|
|---|
| 678 | * shortcodes (die zijn de muziek, niet het verhaal erover) en zonder opmaak.
|
|---|
| 679 | * Levert null als er niets overblijft, want een leeg veld is slechter dan geen.
|
|---|
| 680 | */
|
|---|
| 681 | function tekstVanPost(post) {
|
|---|
| 682 | const excerpt = String(post.excerpt || '').trim();
|
|---|
| 683 | if (excerpt) return excerpt;
|
|---|
| 684 | const kaal = String(post.content || '')
|
|---|
| 685 | .replace(/\[\[[a-z]+:[^\]]*\]\]/gi, ' ')
|
|---|
| 686 | .replace(/<[^>]+>/g, ' ')
|
|---|
| 687 | .replace(/ /gi, ' ')
|
|---|
| 688 | .replace(/&[a-z#0-9]+;/gi, ' ')
|
|---|
| 689 | // Losse hashtags gaan eruit: die staan al in `tag`, en een description die
|
|---|
| 690 | // de tagwolk herhaalt is ruis. Live leverde dit "#DoenweNiet #DoenWeNiet
|
|---|
| 691 | // #devs" op als omschrijving van een post die verder geen tekst heeft.
|
|---|
| 692 | .replace(/(^|\s)#[\p{L}\p{M}\p{N}_]+/gu, ' ')
|
|---|
| 693 | .replace(/\s+/g, ' ')
|
|---|
| 694 | .trim();
|
|---|
| 695 | return kaal || null;
|
|---|
| 696 | }
|
|---|
| 697 |
|
|---|
| 698 | /** De open tracks uit een lijst ids, in de volgorde van die lijst. */
|
|---|
| 699 | function losseTracksVanPost(siteId, ids) {
|
|---|
| 700 | if (!siteId || !ids?.length) return [];
|
|---|
| 701 | const gaten = ids.map(() => '?').join(',');
|
|---|
| 702 | const rijen = db.prepare(
|
|---|
| 703 | `SELECT ${TRACK_KOLOMMEN}
|
|---|
| 704 | FROM audio_tracks t JOIN media m ON m.id = t.media_id
|
|---|
| 705 | WHERE t.site_id = ? AND t.fedi_open = 1 AND t.id IN (${gaten})`
|
|---|
| 706 | ).all(siteId, ...ids);
|
|---|
| 707 | // De volgorde van de POST, niet die van de tabel (shaer-38y, punt 1): zoals
|
|---|
| 708 | // iemand ze heeft neergezet is de volgorde waarin ze bedoeld zijn.
|
|---|
| 709 | const opId = new Map(rijen.map((r) => [r.id, r]));
|
|---|
| 710 | return ids.map((id) => opId.get(id)).filter(Boolean);
|
|---|
| 711 | }
|
|---|
| 712 |
|
|---|
| 713 | /**
|
|---|
| 714 | * De losse tracks van een post als EEN uitgave (shaer-38y).
|
|---|
| 715 | *
|
|---|
| 716 | * Tot nu toe gingen die los de deur uit: losse Audio-objecten die een lezer
|
|---|
| 717 | * nergens kon plaatsen. Ze horen bij elkaar omdat ze in dezelfde post staan, en
|
|---|
| 718 | * dat is wat deze collectie zegt -- met de gegevens van de post erbij, want die
|
|---|
| 719 | * heeft ze wel en de losse tracks niet.
|
|---|
| 720 | *
|
|---|
| 721 | * Geeft null als er niets te tonen is: geen post, geen losse tracks, of een
|
|---|
| 722 | * post die geen enkele muzikale eenheid IS.
|
|---|
| 723 | */
|
|---|
| 724 | export function buildPostTrackCollection(base, site, post) {
|
|---|
| 725 | if (!post || !post.id) return null;
|
|---|
| 726 | const eenheid = postMusicType(post.content, site.id);
|
|---|
| 727 | if (!eenheid || eenheid.collectie || !eenheid.tracks?.length) return null;
|
|---|
| 728 |
|
|---|
| 729 | const rows = losseTracksVanPost(site.id, eenheid.tracks);
|
|---|
| 730 | if (!rows.length) return null;
|
|---|
| 731 |
|
|---|
| 732 | const hostPosts = new Map(rows.map((r) => [r.id, { id: post.id, slug: post.slug }]));
|
|---|
| 733 | const out = pagedCollection(postTracksId(base, site, post.id),
|
|---|
| 734 | rows.map((r) => buildTrackAudio(base, site, r, { hostPosts })),
|
|---|
| 735 | { extra: { attributedTo: actorId(base, site.slug) } });
|
|---|
| 736 | return leenVanPost(base, site, out, post);
|
|---|
| 737 | }
|
|---|
| 738 |
|
|---|
| 739 | /**
|
|---|
| 740 | * Een track als publicatie: Create(Audio) (shaer-0nh, stap 4).
|
|---|
| 741 | *
|
|---|
| 742 | * Zelfde vorm als buildCreate voor een post, met een STABIEL id: dezelfde track
|
|---|
| 743 | * levert altijd dezelfde activiteit, zodat een lezer die de outbox twee keer
|
|---|
| 744 | * ophaalt niet denkt dat er iets nieuws is.
|
|---|
| 745 | */
|
|---|
| 746 | export function buildTrackCreate(base, site, r, opts = {}) {
|
|---|
| 747 | const audio = buildTrackAudio(base, site, r, opts);
|
|---|
| 748 | const me = actorId(base, site.slug);
|
|---|
| 749 | return {
|
|---|
| 750 | '@context': AP_CONTEXT,
|
|---|
| 751 | id: `${audio.id}#create`,
|
|---|
| 752 | type: 'Create',
|
|---|
| 753 | actor: me,
|
|---|
| 754 | published: audio.published,
|
|---|
| 755 | to: [PUBLIC],
|
|---|
| 756 | cc: [`${me}/followers`],
|
|---|
| 757 | object: audio,
|
|---|
| 758 | };
|
|---|
| 759 | }
|
|---|
| 760 |
|
|---|
| 761 | /**
|
|---|
| 762 | * `category` is kanaal-vocabulaire, en de waarde is 'music' (Robins keuze, 7-8).
|
|---|
| 763 | * Alleen gezet als de site ECHT audio publiceert: een blog zonder muziek als
|
|---|
| 764 | * muziekkanaal aankondigen is erger dan geen label. Het signaal is een track in
|
|---|
| 765 | * de kast, niet enable_audio_player -- die staat standaard aan en zegt niets.
|
|---|
| 766 | */
|
|---|
| 767 | export function channelCategory(site) {
|
|---|
| 768 | try {
|
|---|
| 769 | // ALLEEN opengezette tracks tellen. Eerst keek dit naar elke track, ook een
|
|---|
| 770 | // gated -- en dan roept een site met uitsluitend afgeschermde muziek toch
|
|---|
| 771 | // "hier is muziek" naar de hele fediverse. Dat botst met de regel die we
|
|---|
| 772 | // overal aanhouden: een gesloten track is AFWEZIG, niet stilletjes
|
|---|
| 773 | // aanwezig. Naar buiten toe is een kanaal zonder publieke muziek geen
|
|---|
| 774 | // muziekkanaal.
|
|---|
| 775 | return db.prepare('SELECT 1 FROM audio_tracks WHERE site_id = ? AND fedi_open = 1 LIMIT 1').get(site.id) ? 'music' : null;
|
|---|
| 776 | } catch { return null; }
|
|---|
| 777 | }
|
|---|
| 778 |
|
|---|
| 779 | // ── Welk soort muzikale uitgave is deze post? (shaer-cyg) ─────────────
|
|---|
| 780 |
|
|---|
| 781 | /**
|
|---|
| 782 | * Het type van een post afleiden uit de muziek die erin staat.
|
|---|
| 783 | *
|
|---|
| 784 | * DE REGEL ZELF staat in assets/js/shared/post-music-type.js, want de editor
|
|---|
| 785 | * gebruikt hem ook -- daar volgt het type live mee terwijl je schrijft. Twee
|
|---|
| 786 | * kopieen zouden stil uit elkaar lopen, dus is er er een. Hier komt alleen het
|
|---|
| 787 | * stuk bij dat de server kan en de browser niet: de gekozen soort van een
|
|---|
| 788 | * playlist opzoeken.
|
|---|
| 789 | *
|
|---|
| 790 | * WAARVOOR DIT WEL EN NIET IS (Robins afbakening, 9-8). Nieuwe posts krijgen
|
|---|
| 791 | * hun type uit de keuze: album of playlist wordt gekozen als de playlist wordt
|
|---|
| 792 | * gemaakt, en de post neemt dat over. Op de server is dit vooral voor wat er al
|
|---|
| 793 | * staat -- de posts met type=audio uit de tijd voor die keuze bestond.
|
|---|
| 794 | *
|
|---|
| 795 | * @param {string} content de HTML/tekst van de post
|
|---|
| 796 | * @param {string} siteId nodig om playlists.kind te kunnen opzoeken
|
|---|
| 797 | */
|
|---|
| 798 | export function postMusicType(content, siteId) {
|
|---|
| 799 | return afleidenUitInsluitingen(content, (id) => playlistKind(id, siteId));
|
|---|
| 800 | }
|
|---|
| 801 |
|
|---|
| 802 | /**
|
|---|
| 803 | * De gekozen soort van een playlist: 'album' | 'playlist', of null als hij niet
|
|---|
| 804 | * (op deze site) bestaat. Zelfde normalisatie als PlaylistService: alles wat
|
|---|
| 805 | * geen 'playlist' zegt is een album.
|
|---|
| 806 | */
|
|---|
| 807 | function playlistKind(id, siteId) {
|
|---|
| 808 | if (!siteId) return null;
|
|---|
| 809 | try {
|
|---|
| 810 | const r = db.prepare('SELECT kind FROM playlists WHERE id = ? AND site_id = ?').get(id, siteId);
|
|---|
| 811 | if (!r) return null;
|
|---|
| 812 | return r.kind === 'playlist' ? 'playlist' : 'album';
|
|---|
| 813 | } catch { return null; }
|
|---|
| 814 | }
|
|---|