Ignore:
File:
1 edited

Legend:

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

    r98acb97 r9026de5  
    1414
    1515import db from '../../config/database.js';
    16 import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection } from '../ap-core.js';
     16import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection, isMbid } 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.
     20export * as luisteraars from './luisteraars.js';
    1821
    1922// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
     
    133136}
    134137
     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 */
     150function 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
    135167export function buildTrackAudio(base, site, r, opts = {}) {
    136168  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
     
    168200    url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
    169201  };
     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);
    170206  if (r.artist) a.summary = r.artist;              // artiest als summary: kaal AS2, geen eigen vocab
    171207  // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
     
    175211  // van te maken.
    176212  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  }
    177264  if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
    178265  if (r.created_at) a.published = new Date(r.created_at).toISOString();
     
    213300}
    214301
     302/** Het AS2-id van de bibliotheek van een site. */
     303export 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 */
     329export 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
    215363/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
    216 export function buildTrackCollection(base, site, rows) {
     364export function buildTrackCollection(base, site, rows, { page = false } = {}) {
    217365  // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
    218366  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) } });
     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) } });
    221370}
    222371
     
    263412// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
    264413// poort staat.
    265 export function listPlaylistsAP(base, site, enriched) {
     414export function listPlaylistsAP(base, site, enriched, { page = false } = {}) {
    266415  const rows = db.prepare(
    267416    'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
     
    276425    return stub;
    277426  });
    278   return pagedCollection(colId, items, { extra: { attributedTo: actorId(base, site.slug) } });
     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 */
     442export 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 */
     484export 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;
    279529}
    280530
     
    287537  // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
    288538  const hostPosts = site.id ? trackHostPosts(site.id) : null;
    289   const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
     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 }));
    290546  const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
    291547    extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
     
    298554  const cover = abs(playlist.cover_url || null);
    299555  if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
    300   return leenVanPost(base, site, out, uitgavePost(site.id, playlist.id));
     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);
    301576}
    302577
     
    323598  try {
    324599    const rijen = db.prepare(`
    325       SELECT id, slug, title, excerpt, content, cover_image_url, tags
     600      SELECT id, slug, title, excerpt, content, cover_image_url, tags,
     601             COALESCE(published_at, created_at) AS uit_wanneer
    326602      FROM posts
    327603      WHERE site_id = ? AND status = 'published'
Note: See TracChangeset for help on using the changeset viewer.