Ignore:
File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/music/index.js

    r9026de5 r98acb97  
    1414
    1515import db from '../../config/database.js';
    16 import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection, isMbid } from '../ap-core.js';
     16import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection } from '../ap-core.js';
    1717import { 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';
    2118
    2219// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
     
    136133}
    137134
    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 
    167135export function buildTrackAudio(base, site, r, opts = {}) {
    168136  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
     
    200168    url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
    201169  };
    202   // De bak waar dit bestand in hangt (shaer-0nh). Voor Funkwhale is dit het
    203   // haakje waaraan een upload komt te zitten; zonder dit veld blijft een track
    204   // daar een naam zonder geluid.
    205   a.library = libraryId(base, site);
    206170  if (r.artist) a.summary = r.artist;              // artiest als summary: kaal AS2, geen eigen vocab
    207171  // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
     
    211175  // van te maken.
    212176  if (post) a.context = noteId(base, post.id);
    213   // Het NUMMER, los van dit bestand (shaer-3f8a, spoor B). Funkwhale en
    214   // Emissary lezen allebei `fw:track`, en petitminion noemde het ontbreken
    215   // ervan als eerste wat hem opviel aan onze objecten.
    216   //
    217   // EIGEN ID MET #track, en niet hetzelfde id als de Audio. Emissary hergebruikt
    218   // daar het object-id, maar dan zijn in JSON-LD de Audio en de Track EEN knoop
    219   // met twee typen -- en een bestand is geen werk. Dat verschil moeten we straks
    220   // toch maken, want een album verzamelt nummers en geen mp3's. Een fragment is
    221   // een geldige IRI en wijst naar hetzelfde document.
    222   //
    223   // GEEN `album`. Dat veld is bij hen een URI naar een Album-object en bij ons
    224   // een tekstkolom; er hier een adres van maken zou een ding beloven dat niet
    225   // bestaat. Zie shaer-k37k -- dat is de keuze die daarvoor eerst moet vallen.
    226   //
    227   // WIE IS DE ARTIEST. Hun Artist is een ENTITEIT met een id, en bij ons is een
    228   // artiest een tekstkolom op de track. Die twee verzoenen we zo: de entiteit
    229   // is de site-ACTOR -- een echt, opvraagbaar adres, het account dat dit
    230   // uitbrengt -- en de tekst uit de kolom gaat naar `credit`, want dat is
    231   // precies waar hun model de credittekst verwacht.
    232   //
    233   // Dat is eerlijk en het is niet nieuw: open.audio leidde op 13-8 al zelf een
    234   // artist_credit af uit onze attributedTo. We maken alleen expliciet wat daar
    235   // toch al gebeurde.
    236   //
    237   // DE GRENS ERVAN: brengt een site werk van iemand anders uit, dan zegt dit
    238   // dat de site de artiest is. Dat stond al in attributedTo, dus we maken het
    239   // niet erger -- maar het is wel de reden dat we hier geen id per artiestnaam
    240   // verzinnen. Identiteit uit een string is dezelfde fout als bij het album
    241   // (shaer-756s).
    242   const wanneer = r.created_at ? new Date(r.created_at).toISOString()
    243     : (site.created_at ? new Date(site.created_at).toISOString() : new Date(0).toISOString());
    244 
    245   a.track = {
    246     type: 'Track',
    247     id: `${a.id}#track`,
    248     name: a.name,
    249     published: wanneer,
    250     ...(Number(r.position) ? { position: Number(r.position) } : {}),
    251     artist_credit: artistCredit(base, site, r.artist, wanneer),
    252   };
    253   // De uitgave waar dit nummer op staat, INGESLOTEN (shaer-756s, stap 2).
    254   // `albums` mag expliciet null zijn: dan is er niets op te zoeken.
    255   const uitgave = opts.albums !== undefined
    256     ? (opts.albums && opts.albums.get(r.id)) || null
    257     : ((site.id && trackAlbums(site.id).get(r.id)) || null);
    258   if (uitgave) {
    259     a.track.album = buildAlbumObject(base, site, uitgave);
    260     // Ook op het Audio-object zelf, als URI. Funkwhale 2.0 en Emissary doen dat
    261     // allebei, en het scheelt een lezer het uitpakken van de track.
    262     a.album = a.track.album.id;
    263   }
    264177  if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
    265178  if (r.created_at) a.published = new Date(r.created_at).toISOString();
     
    300213}
    301214
    302 /** Het AS2-id van de bibliotheek van een site. */
    303 export function libraryId(base, site) {
    304   return `${actorId(base, site.slug)}/library`;
    305 }
    306 
    307 /**
    308  * De site als Funkwhale-LIBRARY (skelet).
    309  *
    310  * WAAROM DIT GEEN DIALECT IS ZOALS track EN ArtistCredit DAT WEL ZIJN. Die twee
    311  * vragen entiteiten waar wij tekst hebben; hiervoor hoeven we niets te
    312  * verzinnen. Een library is precies wat er al staat: onze open tracks, met een
    313  * echte telling en een echt id.
    314  *
    315  * WAAROM HET NODIG IS, gemeten op 13-8. open.audio heeft onze vier tracks
    316  * binnengehaald langs de AP-weg -- met ONZE track-id's, en met een artist_credit
    317  * dat Funkwhale zelf uit onze attributedTo afleidde. Maar `uploads` is leeg en
    318  * `is_playable` false. Bij hen hangt een upload aan een library; zonder library
    319  * is er geen bak om het bestand in te hangen. Het audiobestand zelf is wel
    320  * gewoon op te halen (200, audio/mpeg, ook anoniem) -- ze hebben het niet
    321  * geprobeerd.
    322  *
    323  * SKELET, en dat woord is letterlijk bedoeld. Dit is de vorm uit hun docs:
    324  * type, id, name, followers, totalItems, first, last, plus attributedTo en
    325  * summary. Wat er NIET is: de volg-afhandeling. Onze bibliotheek is openbaar --
    326  * elke track erin heeft fedi_open -- dus er valt niets goed te keuren. Komt er
    327  * ooit een besloten variant, dan hoort daar het Follow/Accept-werk bij.
    328  */
    329 export function buildLibrary(base, site, rows, { page = false } = {}) {
    330   const id = libraryId(base, site);
    331   const hostPosts = site.id ? trackHostPosts(site.id) : null;
    332   const albums = site.id ? trackAlbums(site.id) : null;
    333   const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts, albums }));
    334   return pagedCollection(id, items, {
    335     page,
    336     // Een platenkast is geen tijdlijn: `Collection`, niet `OrderedCollection`.
    337     // Funkwhale's LibrarySerializer accepteert ook alleen die twee typen
    338     // (as:Collection of fw:Library) en zijn CollectionPageSerializer alleen
    339     // `CollectionPage` met `items`.
    340     ongeordend: true,
    341     extra: {
    342       type: 'Library',
    343       name: site.title || site.slug,
    344       attributedTo: actorId(base, site.slug),
    345       // WAAROM DIT VELD ER MOET STAAN. Funkwhale's LibrarySerializer noemt
    346       // `audience` optioneel, maar zijn create() doet er meteen
    347       // `privacy[validated_data["audience"]]` mee -- zonder de sleutel is dat
    348       // een KeyError en geeft hun server een 500. Dat is wat open.audio op 15-8
    349       // teruggaf toen Robin onze library-URI daar opzocht.
    350       //
    351       // Het is bovendien gewoon waar: alles hierin is fedi_open, dus openbaar.
    352       // Bij hen is dit precies het verschil tussen privacy_level 'everyone' en
    353       // 'me' -- oftewel of onze nummers daar afspeelbaar zijn.
    354       audience: 'https://www.w3.org/ns/activitystreams#Public',
    355       // Vereist volgens hun docs. Openbaar, dus de telling is eerlijk en de
    356       // lijst blijft leeg -- wie ons volgt volgt de ACTOR, niet de bak.
    357       followers: `${id}/followers`,
    358       ...(site.description ? { summary: String(site.description).slice(0, 500) } : {}),
    359     },
    360   });
    361 }
    362 
    363215/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
    364 export function buildTrackCollection(base, site, rows, { page = false } = {}) {
     216export function buildTrackCollection(base, site, rows) {
    365217  // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
    366218  const posts = site.id ? trackHostPosts(site.id) : null;
    367   const albums = site.id ? trackAlbums(site.id) : null;
    368   const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts, albums }));
    369   return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
     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) } });
    370221}
    371222
     
    412263// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
    413264// poort staat.
    414 export function listPlaylistsAP(base, site, enriched, { page = false } = {}) {
     265export function listPlaylistsAP(base, site, enriched) {
    415266  const rows = db.prepare(
    416267    'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
     
    425276    return stub;
    426277  });
    427   return pagedCollection(colId, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
    428 }
    429 
    430 /**
    431  * Bij welke UITGAVE hoort een track? (shaer-756s, stap 2)
    432  *
    433  * Alleen playlists met kind='album' tellen: een mixtape is geen uitgave, en dat
    434  * onderscheid is precies wat de keuze album/playlist betekent. Zit een track in
    435  * twee albums, dan wint de oudste -- willekeurig maar STABIEL, en dat is wat
    436  * telt: een id dat per ophaalactie verspringt is erger dan een id dat niet de
    437  * mooiste keuze is.
    438  *
    439  * Eén zoekopdracht voor alle rijen samen, zoals trackHostPosts. Per track
    440  * vragen wordt bij tweehonderd nummers tweehonderd zoekopdrachten.
    441  */
    442 export function trackAlbums(siteId) {
    443   const rijen = db.prepare(`
    444     SELECT pt.track_id AS tid, p.id, p.title, p.artist, p.year, p.cover_url,
    445            p.release_date, p.mb_release_id, p.created_at
    446       FROM playlist_tracks pt
    447       JOIN playlists p ON p.id = pt.playlist_id
    448      WHERE p.site_id = ? AND p.kind = 'album'
    449      ORDER BY p.created_at, p.id
    450   `).all(siteId);
    451   const uit = new Map();
    452   for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, r);
    453   // De post die deze plaat uitbrengt, EEN keer per album opgezocht en niet per
    454   // track: uitgavePost() doet er echt werk voor (hij leest de typering van de
    455   // post) en een site heeft veel meer nummers dan platen.
    456   //
    457   // WAAROM DIT ERBIJ MOET: buildPlaylistCollection laat leenVanPost de naam van
    458   // de post overnemen -- de post IS de uitgave. Zonder dezelfde lening hier zou
    459   // het ingesloten Album "Cartoon Epic" heten en zijn eigen URI "Geen koffie,
    460   // wel thee!". Een id met twee namen, en dat is precies wat op 16-8 uit de
    461   // meting rolde.
    462   const perAlbum = new Map();
    463   for (const r of uit.values()) {
    464     if (perAlbum.has(r.id)) continue;
    465     perAlbum.set(r.id, uitgavePost(siteId, r.id));
    466   }
    467   for (const r of uit.values()) r._post = perAlbum.get(r.id) || null;
    468   return uit;
    469 }
    470 
    471 /**
    472  * Een uitgave als `fw:Album`.
    473  *
    474  * INGESLOTEN EN NIET ALS URI, en dat is het hele punt van deze stap. Funkwhale's
    475  * TrackSerializer heeft `album = AlbumSerializer()` -- een object met name,
    476  * published en een eigen artist_credit. Een kale URI expandeert naar een knoop
    477  * met alleen een @id en valt daar dus af. Emissary stuurt precies zo'n kale URI,
    478  * en dat is waarom hun tracks bij Funkwhale net zo goed stranden.
    479  *
    480  * Het `id` is de bestaande playlist-collectie: dereferenceerbaar, en het is
    481  * werkelijk hetzelfde ding. We verzinnen geen tweede adres voor iets dat er al
    482  * een heeft.
    483  */
    484 export function buildAlbumObject(base, site, pl) {
    485   if (!pl) return null;
    486   const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
    487   // WANNEER IS DEZE PLAAT GEPUBLICEERD. De post die hem uitbrengt gaat voor, en
    488   // niet als noodgreep maar omdat hij het beter weet: playlists.created_at is
    489   // het moment waarop de RIJ is aangemaakt, en dat kan weken eerder zijn terwijl
    490   // je nog aan het samenstellen was. AS2 `published` vraagt wanneer het object
    491   // openbaar werd, en dat is de post.
    492   //
    493   // GEEN epoch als laatste terugval. `published` is bij hen verplicht, maar 1970
    494   // is een ANTWOORD en geen ontbrekend veld -- en dat is erger: een lezer kan een
    495   // gat opmerken, een leugen niet. Zo kwam op 16-8 de route boven water die id,
    496   // title, artist, year, cover_url en kind selecteerde en de rest niet.
    497   //
    498   // OOK VOOR `released`, en daar had ik het eerst mis (Robin, 16-8). Mijn
    499   // bezwaar was: post je vandaag een plaat uit 2018, dan beweert dit dat hij
    500   // vandaag uitkwam. Dat gebeurt ook -- maar bij de meeste Klonkt-sites IS de
    501   // post het uitbrengen, en GEEN datum is slechter dan een datum die op het
    502   // gewone geval klopt. Het handmatige veld is precies het gereedschap voor de
    503   // uitzondering: bij een heruitgave vul je hem in en die wint.
    504   const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null;
    505   const wanneer = postDatum ? postDatum.toISOString()
    506     : (pl.created_at ? new Date(pl.created_at).toISOString() : null);
    507   // Dezelfde lening als in buildPlaylistCollection: de post die de plaat
    508   // uitbrengt geeft zijn titel, en de eigen titel blijft als alsoKnownAs staan.
    509   const titel = (pl._post && pl._post.title) || pl.title;
    510   const album = {
    511     type: 'Album',
    512     id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
    513     name: titel,
    514     ...(wanneer ? { published: wanneer } : {}),
    515     attributedTo: actorId(base, site.slug),
    516     artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()),
    517   };
    518   if (titel !== pl.title) album.alsoKnownAs = pl.title;
    519   // Het ingevulde veld wint altijd; anders de DAG waarop de post verscheen.
    520   // `year` vult hem nog steeds niet aan, en dat is geen inconsequentie: een
    521   // jaartal is geen dag, terwijl de postdatum een gebeurtenis is die werkelijk
    522   // heeft plaatsgevonden. Het verschil is verzinnen versus afleiden.
    523   if (pl.release_date) album.released = pl.release_date;
    524   else if (postDatum) album.released = postDatum.toISOString().slice(0, 10);
    525   if (pl.mb_release_id) album.musicbrainzId = pl.mb_release_id;
    526   const hoes = abs(pl.cover_url || null);
    527   if (hoes) album.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
    528   return album;
     278  return pagedCollection(colId, items, { extra: { attributedTo: actorId(base, site.slug) } });
    529279}
    530280
     
    537287  // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
    538288  const hostPosts = site.id ? trackHostPosts(site.id) : null;
    539   const albums = site.id ? trackAlbums(site.id) : null;
    540   // EEN keer opzoeken en twee keer gebruiken: het Album leent er zijn datums en
    541   // titel van, leenVanPost onderaan zijn tekst en tags. Twee losse aanroepen
    542   // zouden niet alleen dubbel werk zijn maar ook uiteen kunnen lopen -- en dan
    543   // staat er weer iets anders op het ingesloten object dan op zijn eigen URI.
    544   const post = site.id ? uitgavePost(site.id, playlist.id) : null;
    545   const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts, albums }));
     289  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
    546290  const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
    547291    extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
     
    554298  const cover = abs(playlist.cover_url || null);
    555299  if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
    556 
    557   // Is dit een UITGAVE, dan draagt deze collectie ook de albumvelden
    558   // (shaer-756s, stap 2): het is het adres waar track.album naar wijst, en dan
    559   // hoort hier hetzelfde te staan als in het ingesloten object.
    560   //
    561   // `type` blijft OrderedCollection, EN BLIJFT EEN STRING. Er stond hier even
    562   // ['OrderedCollection', 'Album'] -- geldig AS2, en het is ook werkelijk
    563   // allebei -- maar een bestaande test viel erover, en die test had gelijk: een
    564   // lezer die `type` als tekst uitpakt (Shaer doet dat) verliest dan in stilte
    565   // de hele playlist. Het kost ons niets, want hun AlbumSerializer declareert
    566   // geen type-veld en valideert het dus niet: haalt Funkwhale dit adres op als
    567   // album, dan leest hij deze velden gewoon. En het object dat hij echt gebruikt
    568   // staat toch al ingesloten op de track.
    569   if ((playlist.kind || 'album') === 'album') {
    570     const album = buildAlbumObject(base, site, { ...playlist, _post: post });
    571     for (const veld of ['published', 'released', 'musicbrainzId', 'artist_credit', 'image']) {
    572       if (album[veld] !== undefined) out[veld] = album[veld];
    573     }
    574   }
    575   return leenVanPost(base, site, out, post);
     300  return leenVanPost(base, site, out, uitgavePost(site.id, playlist.id));
    576301}
    577302
     
    598323  try {
    599324    const rijen = db.prepare(`
    600       SELECT id, slug, title, excerpt, content, cover_image_url, tags,
    601              COALESCE(published_at, created_at) AS uit_wanneer
     325      SELECT id, slug, title, excerpt, content, cover_image_url, tags
    602326      FROM posts
    603327      WHERE site_id = ? AND status = 'published'
Note: See TracChangeset for help on using the changeset viewer.