Changeset 749605a in Klonkt for src/services


Ignore:
Timestamp:
08/16/2026 03:06:18 PM (3 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
a5caa73
Parents:
a5515b4 (diff), 9026de5 (diff)
Note: this is a merge changeset, the changes displayed below correspond to the merge itself.
Use the (diff) links above to see all the changes relative to each parent.
Message:

Bibliotheek en album-federatie terug op main (shaer-0nh, shaer-756s)

Achttien commits, in drie brokken.

DE BIBLIOTHEEK. Een Library-skelet met een eigen volgerssoort: wie de
bibliotheek volgt krijgt de muziek en met opzet niet de gewone posts, en
die staan daarom in een eigen tabel -- een vlag die iemand vergeet te
filteren bezorgt stilletjes de Krant aan mensen die er niet om vroegen.
Daarna wat er nodig bleek om werkelijk gelezen te worden: audience
(zonder dat gaf hun create() een KeyError en dus een 500 op onze URL),
de ongeordende collectievorm, en paginering op elke collectie die first
belooft -- die laatste vond meteen dat featured hetzelfde gat had.

HET VOCABULAIRE. fw:track in de kleine vorm die Emissary ook stuurt,
artist_credit met de site-actor als Artist (mogelijk geworden door de
MusicBrainz-koppeling), en het album als ingesloten fw:Album met
released, musicbrainzId en een eigen artist_credit. Daarvoor kwamen er
twee kolommen op playlists, alleen bij kind='album' en afgedwongen in
PlaylistService -- niet in het scherm, want de API ligt open.

HET BEHEER. De vijf mediapagina's stonden op drie verschillende
ontwerpen; nu op een gedeelde ax-romp, met de primitieven op een plek in
plaats van in acht kopieen. De playlist-modal ging alsnog door i18n: die
teksten stonden hard in het script, wat niet opviel omdat het toevallig
de brontaal was.

WAT ER NIET IN ZIT: bitrate en size top-level als fw:. Dat is bewust
(shaer-3f8a). Hun eigen outbox zet ze op de Link, net als wij, en het
wordt een gesprek in plaats van een aanpassing -- briefing in
~/bewaard/funkwhale-briefing-en.md.

Gereedschap dat meekomt: scripts/funkwhale-gat.mjs meet het gat naar hun
ingest, admin-css.mjs en admin-css-gelijk.mjs ontleden en vergelijken de
beheer-CSS, render-admin-views.mjs rendert de views gevuld en leeg. Die
laatste drie omdat de testsuite geen enkele view aanraakt.

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

Location:
src/services
Files:
1 added
5 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    ra5515b4 r749605a  
    3434// De muziekkant woont in music/ (shaer-drc). Doorgeven wat hier altijd
    3535// vandaan kwam, zodat elke bestaande aanroep blijft werken.
     36import { luisteraars } from './music/index.js';
    3637import { TRACK_KOLOMMEN,
    3738  playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts,
     
    3940  buildPlaylistCollection, listPlaylistsAP, playlistLinkTags,
    4041  buildPostTrackCollection, uitgavePost,
     42  buildLibrary, libraryId,
    4143  licentieUri, channelCategory,
    4244} from './music/index.js';
     
    4648  buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, licentieUri,
    4749  buildPostTrackCollection, uitgavePost,
     50  buildLibrary, libraryId,
    4851};
    4952
     
    354357  const pagina = pickLink(o.url, () => true);
    355358  return { html: HtmlSanitizerService.sanitize(o.content || ''), atts, url: pagina ? pagina.href : null };
     359}
     360
     361/**
     362 * De site achter een library-uri, of null. Zelfde strengheid als localSlugOf:
     363 * de uri moet met ONZE basis beginnen en de site moet bestaan -- anders levert
     364 * andermans /library met dezelfde padstaart hier een volger op onze naam op.
     365 */
     366function libraryOwnerSlug(uri) {
     367  const u = String(uri || '');
     368  if (!u.endsWith('/library')) return null;
     369  return localSlugOf(u.slice(0, -'/library'.length));
    356370}
    357371
     
    11861200// these as the "Featured" tab (pinned to the profile). Posts come ordered by pin
    11871201// rank; embedded as full Notes so a remote server doesn't need extra fetches.
    1188 export function buildFeatured(base, site, posts) {
     1202export function buildFeatured(base, site, posts, { page = false } = {}) {
    11891203  const id = `${actorId(base, site.slug)}/featured`;
    11901204  const items = (posts || []).map((p) => buildNote(base, site, p));
    1191   return pagedCollection(id, items);
     1205  return pagedCollection(id, items, { page });
    11921206}
    11931207
     
    23542368  if (type === 'Follow') {
    23552369    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
     2370    // EERST: volgt iemand onze BIBLIOTHEEK in plaats van onze actor? (shaer-0nh)
     2371    //
     2372    // Een luisteraar krijgt de muziek en NIET de gewone posts -- wie zich
     2373    // abonneert op een platenkast heeft niet om de Krant gevraagd. Vandaar een
     2374    // eigen tabel: zolang ze daar staan kan een postbezorging ze niet per
     2375    // ongeluk meenemen.
     2376    //
     2377    // De bibliotheek is openbaar (alles erin is fedi_open), dus dit accepteert
     2378    // meteen. Er valt niets goed te keuren, en dan is wachten oneerlijk.
     2379    const libSlug = libraryOwnerSlug(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
     2380    if (who && libSlug) {
     2381      const remote = await fetchActor(who);
     2382      if (!remote || !remote.inbox) return 202;
     2383      const fi = actorInfo(remote, who);
     2384      luisteraars.voegToe(libSlug, {
     2385        actorUri: who, inbox: remote.inbox,
     2386        sharedInbox: (remote.endpoints && remote.endpoints.sharedInbox) || null,
     2387        name: fi.name, handle: fi.handle, icon: fi.icon,
     2388      });
     2389      const keys = getOrCreateKeys(libSlug);
     2390      const accept = {
     2391        '@context': AP_CONTEXT,
     2392        id: `${actorId(base, libSlug)}#accept-library-${Date.now()}-${rid()}`,
     2393        type: 'Accept', actor: actorId(base, libSlug), object: act,
     2394      };
     2395      deliver(remote.inbox, accept, `${actorId(base, libSlug)}#main-key`, keys.privatePem)
     2396        .catch(() => { /* de volger staat er; een mislukte Accept mag dat niet omgooien */ });
     2397      console.log('[AP] library follow from', who, '->', libSlug);
     2398      return 202;
     2399    }
    23562400    // slugParam is de eigenaar van een per-actor inbox; op de GEDEELDE inbox is
    23572401    // die er niet en werd de slug uit act.object geraden. Zonder hostcontrole
     
    24372481    return 202;
    24382482  }
     2483  // Een luisteraar die weggaat, hoort meteen weg te zijn.
     2484  if (type === 'Undo' && act.object && act.object.type === 'Follow') {
     2485    const doel = typeof act.object.object === 'string' ? act.object.object : (act.object.object && act.object.object.id);
     2486    const libSlug = libraryOwnerSlug(doel);
     2487    const wie = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
     2488    if (libSlug && wie && luisteraars.verwijder(libSlug, wie)) {
     2489      console.log('[AP] library unfollow from', wie, '->', libSlug);
     2490      return 202;
     2491    }
     2492  }
     2493
    24392494  if (type === 'Undo' && act.object) {
    24402495    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
     
    68096864  buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags,
    68106865  buildPostTrackCollection, uitgavePost,
     6866  buildLibrary, libraryId,
    68116867  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins,
    68126868  feedCursor, feedChangesSince, waitForFeedChange,
  • src/services/PlaylistService.js

    ra5515b4 r749605a  
    1515import { v4 as uuid } from 'uuid';
    1616
     17/**
     18 * Een volledige datum of niets (shaer-756s).
     19 *
     20 * STRIKT, en dat is de hele functie. `year` bestaat al en blijft; dit veld
     21 * bestaat juist omdat een jaartal geen uitgavedatum is. Zou hij "2024"
     22 * doorlaten en er 2024-01-01 van maken, dan stond er straks een dag op de
     23 * federatie die niemand ooit heeft ingevoerd -- en dan hadden we het veld net
     24 * zo goed niet kunnen toevoegen.
     25 *
     26 * Ook 2024-02-31 valt af: dat is geen strengheid om de strengheid, Date rolt
     27 * hem stilletjes door naar 2 maart en dan slaan we iets anders op dan er
     28 * ingetypt is.
     29 */
     30function normDatum(v) {
     31  const s = String(v == null ? '' : v).trim();
     32  if (!s) return null;
     33  if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return null;
     34  const d = new Date(`${s}T00:00:00Z`);
     35  return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== s ? null : s;
     36}
     37
     38/** Een MusicBrainz-id of niets. Zelfde vorm als sites.mb_artist_id. */
     39function normMbid(v) {
     40  const s = String(v == null ? '' : v).trim().toLowerCase();
     41  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(s) ? s : null;
     42}
     43
    1744class PlaylistService {
    1845
     
    5279    const rows = db.prepare(`
    5380      SELECT p.id, p.title, p.artist, p.year, p.cover_url, p.kind,
     81             p.release_date, p.mb_release_id,
    5482             p.created_at, p.updated_at,
    5583             (SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = p.id) AS track_count
     
    6593      cover: r.cover_url || '',
    6694      kind: r.kind || 'album',
     95      release_date: r.release_date || '',
     96      mb_release_id: r.mb_release_id || '',
    6797      track_count: r.track_count,
    6898      created_at: r.created_at,
     
    86116    if (!id) return null;
    87117    const p = db.prepare(`
    88       SELECT id, title, artist, year, cover_url, kind, created_at, updated_at
     118      SELECT id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at, updated_at
    89119      FROM playlists WHERE site_id = ? AND id = ?
    90120    `).get(siteId, id);
     
    125155      cover: p.cover_url || fallbackCover,
    126156      kind: (p.kind === 'playlist') ? 'playlist' : 'album',
     157      // created_at hoort erbij omdat de AP-kant er `published` van maakt. Zonder
     158      // dit veld viel buildAlbumObject terug op 1970, en dat stond op 16-8
     159      // gewoon op de federatie.
     160      created_at: p.created_at,
     161      // Leeg als het een afspeellijst is -- de opslag houdt ze daar al leeg,
     162      // maar dit is de plek waar de editor leest en die mag niet afhangen van
     163      // wat er toevallig in de kolom stond.
     164      release_date: p.kind === 'playlist' ? '' : (p.release_date || ''),
     165      mb_release_id: p.kind === 'playlist' ? '' : (p.mb_release_id || ''),
    127166      tracks: mappedTracks,
    128167    };
     
    141180    const kind = data.kind === 'playlist' ? 'playlist' : 'album';
    142181
     182    // Alleen een UITGAVE draagt deze twee. Een afspeellijst heeft geen
     183    // uitgavedatum en geen release-id, en dat onderscheid is precies wat de
     184    // keuze album/playlist betekent (shaer-cyg). Het afdwingen gebeurt HIER en
     185    // niet alleen in het scherm: een scherm kun je omzeilen -- de API ligt open
     186    // voor de post-editor -- en dan staat er stille rommel op een mixtape die
     187    // later als Album de deur uit gaat.
     188    const uitgave = kind === 'album';
     189    const releaseDate = uitgave ? normDatum(data.release_date) : null;
     190    const mbRelease = uitgave ? normMbid(data.mb_release_id) : null;
     191
    143192    const tx = db.transaction(() => {
    144193      db.prepare(`
    145         INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, created_at, updated_at)
    146         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
     194        INSERT INTO playlists (id, site_id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at, updated_at)
     195        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    147196      `).run(
    148197        id, siteId, title,
     
    150199        Number.isFinite(+data.year) && +data.year > 0 ? +data.year : null,
    151200        String(data.cover || '').trim() || null,
    152         kind, now, now,
     201        kind, releaseDate, mbRelease, now, now,
    153202      );
    154203      this._writeTracks(id, siteId, data.tracks);
     
    171220    if (!id) return false;
    172221    const existing = db.prepare(
    173       'SELECT id FROM playlists WHERE site_id = ? AND id = ?'
     222      'SELECT id, kind FROM playlists WHERE site_id = ? AND id = ?'
    174223    ).get(siteId, id);
    175224    if (!existing) return false;
     
    177226    const fields = [];
    178227    const values = [];
     228    // Wat wordt het NA deze wijziging? `kind` hoeft niet in data te staan, dus
     229    // val terug op wat er ligt.
     230    const nieuwKind = Object.prototype.hasOwnProperty.call(data, 'kind')
     231      ? (data.kind === 'playlist' ? 'playlist' : 'album')
     232      : (existing.kind || 'album');
    179233    if (Object.prototype.hasOwnProperty.call(data, 'title')) {
    180234      const v = String(data.title || '').trim();
     
    193247    }
    194248    if (Object.prototype.hasOwnProperty.call(data, 'kind')) {
    195       fields.push('kind = ?'); values.push(data.kind === 'playlist' ? 'playlist' : 'album');
     249      fields.push('kind = ?'); values.push(nieuwKind);
     250    }
     251    // De uitgavevelden. Wordt dit een afspeellijst, dan gaan ze ALTIJD leeg --
     252    // ook als de aanroeper er niets over zei. Anders houdt een album dat je tot
     253    // mixtape ombouwt zijn uitgavedatum en zijn release-id, en die duiken dan
     254    // weer op zodra iemand hem terugzet. Een veld dat niet meer mag bestaan
     255    // hoort weg te zijn, niet te wachten.
     256    if (nieuwKind !== 'album') {
     257      fields.push('release_date = ?'); values.push(null);
     258      fields.push('mb_release_id = ?'); values.push(null);
     259    } else {
     260      if (Object.prototype.hasOwnProperty.call(data, 'release_date')) {
     261        fields.push('release_date = ?'); values.push(normDatum(data.release_date));
     262      }
     263      if (Object.prototype.hasOwnProperty.call(data, 'mb_release_id')) {
     264        fields.push('mb_release_id = ?'); values.push(normMbid(data.mb_release_id));
     265      }
    196266    }
    197267    fields.push('updated_at = ?'); values.push(new Date().toISOString());
  • src/services/ap-core.js

    ra5515b4 r749605a  
    5757    // zou een verhuizing kunnen laten mislukken.
    5858    sameAs: { '@id': 'schema:sameAs', '@type': '@id' },
     59    // ── Wat we uit Funkwhale's vocabulaire overnemen, en waarom ──
     60    //
     61    // LIBRARY. Gemeten op 13-8: open.audio had onze vier tracks binnengehaald
     62    // via hun AP-id, met een artist_credit dat het zelf uit onze attributedTo
     63    // afleidde -- maar uploads LEEG en is_playable false. Bij Funkwhale hangt
     64    // een upload aan een library; zonder die bak blijft een track een naam
     65    // zonder geluid.
     66    //
     67    // TRACK. Hier stond dat we deze NIET namen, met als reden: hij vraagt een
     68    // entiteit waar wij tekst hebben, en er een id voor verzinnen belooft wat
     69    // we niet waarmaken. Die redenering klopte half, en de Emissary-meting van
     70    // 16-8 (shaer-3f8a) laat zien welke helft. Een TRACK heeft bij ons wel
     71    // degelijk een eigen identiteit -- het is een rij met een titel en een
     72    // plaats in een uitgave. Wat wij niet hebben is een ALBUM als entiteit, en
     73    // dat is een andere vraag (shaer-k37k). Emissary stuurt precies die kleine
     74    // vorm: type, id, name, position. Wij ook, en album blijft eruit tot het
     75    // een echt object is -- een verzonnen album-URI is nu juist wel de belofte
     76    // die we niet kunnen waarmaken.
     77    //
     78    // Twee onafhankelijke implementaties zenden dit nu, en het is de kant waar
     79    // FEP-be68 heen beweegt. ArtistCredit blijft eruit: dat is nog steeds een
     80    // entiteit die wij niet hebben.
     81    //
     82    // De vorm van de termen is letterlijk die van hun contexts.py (regel
     83    // 293-306), zodat een lezer die hun context laadt en een lezer die de onze
     84    // leest op dezelfde IRI's uitkomen.
     85    fw: 'https://funkwhale.audio/ns#',
     86    Library: 'fw:Library',
     87    library: { '@id': 'fw:library', '@type': '@id' },
     88    Track: 'fw:Track',
     89    track: { '@id': 'fw:track', '@type': '@id' },
     90    // ARTIEST-CREDIT. Hun TrackSerializer eist minstens een artist_credit, en
     91    // dat leek lang onmogelijk: het vraagt een Artist met een eigen id, en bij
     92    // ons was een artiest tekst. Sinds de MusicBrainz-koppeling (shaer-mbz) is
     93    // dat niet meer waar -- de site-ACTOR is de artiest. Een echte, opvraagbare
     94    // URI, met de sitetitel als naam en een musicbrainzId als hij gekoppeld is.
     95    // Er valt hier niets te verzinnen; open.audio leidde dit zelfs al zelf af
     96    // uit onze attributedTo (gemeten 13-8).
     97    //
     98    // `@container: @list` is GEEN opsmuk. Ze lezen dit veld met
     99    // first_attr(FW.artist_credit, "@list"), en zonder die declaratie expandeert
     100    // onze array niet naar een @list -- dan staat er iets dat er goed uitziet en
     101    // door hun lezer niet gevonden wordt. Letterlijk hun contexts.py regel 311.
     102    Artist: 'fw:Artist',
     103    ArtistCredit: 'fw:ArtistCredit',
     104    artist: { '@id': 'fw:artist', '@type': '@id' },
     105    artist_credit: { '@id': 'fw:artist_credit', '@type': '@id', '@container': '@list' },
     106    credit: 'fw:credit',
     107    musicbrainzId: 'fw:musicbrainzId',
    59108    position: 'schema:position',
    60109    bitrate: 'schema:bitrate',
     
    249298 * en zonder `next`. Hem naar de laatste pagina terugbuigen zou opnieuw een
    250299 * antwoord zijn dat over zichzelf liegt.
    251  */
    252 export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, extra = {} } = {}) {
     300 *
     301 * `ongeordend` maakt er de NIET-geordende vorm van: `Collection` met
     302 * `CollectionPage` en `items`, in plaats van `OrderedCollection` met
     303 * `OrderedCollectionPage` en `orderedItems`. Dat is geen dialect maar de andere
     304 * helft van AS2 -- en de bibliotheek hoort daar: een platenkast heeft geen
     305 * volgorde die iets betekent, en `Library` is bij Funkwhale expliciet een
     306 * `Collection`. Onze outbox is wél geordend (chronologie is daar de inhoud) en
     307 * blijft dus zoals hij was.
     308 *
     309 * De pagina draagt in die vorm ook `first` en `last`. AS2 staat dat toe --
     310 * CollectionPage erft van Collection -- en een lezer die halverwege binnenkomt
     311 * kan zo terug naar het begin zonder eerst de wortel op te halen.
     312 */
     313export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, ongeordend = false, extra = {} } = {}) {
    253314  const lijst = items || [];
    254315  const telling = totalItems === undefined ? lijst.length : totalItems;
     
    267328      '@context': AP_CONTEXT,
    268329      id: url(n),
    269       type: 'OrderedCollectionPage',
     330      type: ongeordend ? 'CollectionPage' : 'OrderedCollectionPage',
    270331      partOf: id,
    271332      totalItems: telling,
     333      ...(ongeordend ? { first: url(1), last: url(paginas) } : {}),
     334      ...(extra.attributedTo ? { attributedTo: extra.attributedTo } : {}),
    272335      ...(n > 1 ? { prev: url(n - 1) } : {}),
    273336      ...(n < paginas ? { next: url(n + 1) } : {}),
    274       orderedItems: deel,
     337      ...(ongeordend ? { items: deel } : { orderedItems: deel }),
    275338    };
    276339  }
     
    278341    '@context': AP_CONTEXT,
    279342    id,
    280     type: 'OrderedCollection',
     343    type: ongeordend ? 'Collection' : 'OrderedCollection',
    281344    ...extra,
    282345    totalItems: telling,
    283346    first: url(1),
    284347    last: url(paginas),
    285     orderedItems: lijst,
     348    ...(ongeordend ? { items: lijst } : { orderedItems: lijst }),
    286349  };
    287350}
  • src/services/i18n.js

    ra5515b4 r749605a  
    7676    'admin.b_media': '🎬 Media', 'admin.t_media': 'Media', 'admin.media_images': 'Afbeeldingen', 'admin.media_videos': "Video's", 'admin.videos_count': "video's", 'admin.videos_empty': "Nog geen video's geupload.", 'admin.videos_del_confirm': 'Deze video verwijderen?', 'admin.media_count': 'afbeeldingen', 'admin.media_unused': 'ongebruikt', 'admin.media_cleanup': 'Ongebruikt opruimen', 'admin.media_cleanup_confirm': 'Alle ongebruikte afbeeldingen verwijderen?', 'admin.media_empty': 'Nog geen afbeeldingen geüpload.', 'admin.media_copy': 'Kopieer URL', 'admin.media_del_confirm': 'Deze afbeelding verwijderen?',
    7777    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Reacties', 'admin.b_seo': '🔎 SEO',
     78    'admin.b_listeners': 'Luisteraars',
     79    'alis.lis_title': 'Luisteraars',
     80    'alis.lis_intro': 'Accounts die je BIBLIOTHEEK volgen. Ze krijgen je muziek, en met opzet niet je gewone posts — wie zich op een platenkast abonneert heeft niet om de Krant gevraagd.',
     81    'alis.lis_empty': 'Nog niemand volgt de bibliotheek.',
     82    'alis.lis_since': 'Sinds',
     83    'alis.lis_last': 'Laatste bezorging',
     84    'alis.lis_never': 'nog niets bezorgd',
     85    'alis.lis_count': 'luisteraar(s)',
     86    'alis.lis_error': 'bezorging mislukt',
     87    'ple.titel': 'Titel *',
     88    'ple.artiest': 'Artiest',
     89    'ple.jaar': 'Jaar',
     90    'ple.type': 'Type',
     91    'ple.k_album': 'Album (genummerd)',
     92    'ple.k_playlist': 'Playlist (track-covers)',
     93    'ple.uitgave': 'Uitgavedatum',
     94    'ple.mb_release': 'MusicBrainz release-id',
     95    'ple.cover': 'Cover',
     96    'ple.cover_kies': 'Foto kiezen…',
     97    'ple.cover_url': 'https://… of upload',
     98    'ple.tracks_in': 'Tracks in playlist',
     99    'ple.sleep_hint': '(sleep ⠿ om te ordenen)',
     100    'ple.beschikbaar': 'Beschikbare tracks',
     101    'ple.zoek': 'Zoek…',
     102    'ple.geen_res': 'Geen resultaten.',
     103    'ple.leeg_sel': 'Klik tracks rechts om toe te voegen.',
     104    'ple.t_edit': 'Playlist bewerken',
     105    'ple.t_new': 'Nieuwe playlist',
     106    'ple.dialoog': 'Playlist-editor',
     107    'ple.sluiten': 'Sluiten',
     108    'ple.annuleren': 'Annuleren',
     109    'ple.opslaan': 'Opslaan',
     110    'ple.bezig_opslaan': 'Opslaan…',
     111    'ple.aanmaken': 'Aanmaken',
     112    'ple.versleep': 'Verslepen',
     113    'ple.verwijder': 'Verwijderen',
     114    'ple.geen_audio': 'Track heeft geen audio-bestand',
     115    'ple.e_geen_tracks': 'Geen audio-tracks beschikbaar. Upload eerst tracks via Beheer → Audio.',
     116    'ple.e_tracks': 'Tracks ophalen mislukt',
     117    'ple.e_opslaan': 'Opslaan mislukt: ',
     118    'ple.e_mislukt': 'Mislukt: ',
     119    'ple.e_alleen_afb': 'Alleen afbeeldingen',
     120    'ple.bezig': 'Uploaden…',
     121    'ple.e_upload': 'Upload mislukt',
    78122    'admin.b_settings': '⚙️ Instellingen', "mig.title": "Migreren", "mig.plan_new": "op je nieuwe Klonkt", "mig.plan_from_old": "Je kijkt naar de instantie die VERTREKT. Stap 1, 3 en 4 doe je op je nieuwe Klonkt.", "mig.plan_title": "Zo gaat een verhuizing", "mig.plan_hint": "De volgorde is niet vrijblijvend. Stap 2 moet voor stap 3, want je oude Klonkt geeft niets af aan een adres dat hij niet als zijn opvolger kent. Werk je met een zip, dan mag je 2 juist tot het laatst bewaren: een bestand vraagt niemand om toestemming.", "mig.plan_here": "hier", "mig.plan_old": "op je oude Klonkt", "mig.plan_1": "Je vorige account koppelen", "mig.plan_1_why": "Doe je dit niet, dan weigert je oude Klonkt de verhuizing.", "mig.plan_2": "De verhuizing aankondigen", "mig.plan_2_why": "Je volgers verhuizen mee. Daarna gaat je oude account op slot: posten, volgen en reageren kan daar niet meer.", "mig.plan_3": "Je berichten en muziek ophalen", "mig.plan_3_why": "Rechtstreeks van je oude Klonkt, of via een zip als die al offline is.", "mig.plan_4": "Je volglijst terugzetten", "mig.plan_5": "Later: als je het oude domein opzegt", "mig.plan_5_why": "Verwijder daar eerst je oude berichten. Dan verdwijnt een boost van iemand anders netjes, in plaats van te blijven staan als een kapotte kaart met een dode link. Haal daarna hierboven bij stap 1 je vorige account weg: dat adres beheer je dan niet meer. Hier zit nog geen knop voor.", "mig.follows_note": "Of plak de inhoud van je CSV hierboven. Het bestand wint als je allebei invult.", "asite.moved_to_migrate": "Aliassen en verhuizen staan nu bij Migreren, samen met exporteren, importeren en ophalen.", "mig.alias_title": "Stap 1: je vorige account koppelen", "mig.alias_hint": "Vertel welk account vroeger van jou was. Je oude Klonkt kijkt hiernaar voordat hij je volgers laat verhuizen, en de ophaalknop hieronder heeft het ook nodig.", "mig.alias_label": "Je vorige accounts, één per regel", "mig.alias_note": "Zoals je ze aan iemand zou geven: @jij@mastodon.social. Maximaal vijf.", "mig.alias_btn": "Opslaan", "mig.move_title": "Stap 2: de verhuizing aankondigen", "mig.move_hint": "Dit vertelt al je volgers dat je account voortaan ergens anders woont. Zij verhuizen mee, en dit account gaat daarna op slot: posten, volgen, liken en reageren kan niet meer.", "mig.move_label": "Je nieuwe adres", "mig.move_warn": "Dit is de enige knop op deze pagina die je niet kunt terugdraaien. Je oude account gaat er daarna van op slot. Werk je met de ophaalknop, dan MOET dit eerst: anders geeft je oude Klonkt niets af.", "mig.move_btn": "Verhuizing aankondigen", "mig.move_confirm": "Dit kondigt je verhuizing aan bij al je volgers en zet dit account op slot. Zeker weten?", "mig.move_done": "Dit account is al verhuisd naar", "mig.r_links_fixed": "berichten waarvan de links nu naar hier wijzen", "mig.r_posts_updated": "berichten gerepareerd (plaatjes lokaal gemaakt)", "mig.r_tracks_updated": "nummers aangevuld", "mig.r_tracks_missing": "{n} nummer(s) zijn NIET aangekomen, want het geluidsbestand ontbrak. Ze zijn met opzet niet aangemaakt: een nummer dat in de lijst staat en niet afspeelt is erger dan een nummer dat er niet is.", "mig.c_tracks": "nummers", "mig.c_playlists": "playlists", "mig.audio_missing": "Let op: van {n} nummer(s) is het geluidsbestand niet te vinden. Die gaan niet mee.", "mig.audio_none": "Let op: deze site heeft muziek, maar er gaat geen enkel nummer mee. Waarschijnlijk staan de bestanden ergens anders dan de database denkt.", "mig.pull_title": "Ophalen bij je oude Klonkt", "mig.pull_hint": "Ben je al verhuisd, dan haalt deze Klonkt je berichten rechtstreeks bij de oude op. Je hoeft niets over te typen: dat je verhuisd bent is het bewijs.", "mig.pull_source": "Je oude account", "mig.pull_source_hint": "Overgenomen uit stap 1. Klopt het niet, pas het hier aan.", "mig.pull_btn": "Berichten ophalen", "mig.pull_done": "Opgehaald", "mig.pull_failed": "Ophalen lukte niet", "mig.r_blocks": "blokkades overgenomen", "mig.state": "{n} berichten hebben een verwijzing van hun oude adres.", "mig.state_done": "De lijst is compleet.", "mig.state_busy": "De lijst is nog niet compleet.", "mig.e_no_source": "Er is geen oud account bekend. Vul het adres hierboven in.", "mig.e_unreachable": "De oude Klonkt is niet bereikbaar.", "mig.e_not_moved_here": "Dat account is niet naar hier verhuisd. Kondig de verhuizing eerst aan op je oude Klonkt.", "mig.e_no_backreference": "Dit account zegt nergens dat dat oude account van jou was. Vul het eerst in bij stap 1 hierboven, anders kan je oude Klonkt niet zien dat jij het bent.", "mig.e_no_outbox": "De oude Klonkt heeft geen berichtenlijst.", "mig.e_partial": "Halverwege gestopt. Wat binnen is blijft staan; probeer het nog eens.", "mig.e_config": "Deze Klonkt weet zijn eigen adres niet.", "mig.e_crash": "Er ging iets onverwachts mis.", "mig.e_points_at": "Dat account wijst naar:", "mig.lead": "Neem je berichten, muziek en foto’s mee naar een andere Klonkt, of haal ze hiernaartoe.", "mig.export_title": "Meenemen", "mig.export_hint": "Dit maakt een zip met je berichten, de reacties erop, en de media die erbij hoort.", "mig.c_posts": "berichten", "mig.c_replies": "reacties", "mig.c_media": "mediabestanden", "mig.c_following": "volgingen", "mig.c_size": "groot", "mig.missing": "Let op: van {n} mediaverwijzing(en) staat het bestand niet meer op schijf. Die gaan dus niet mee.", "mig.too_big": "Dit archief is te groot voor de webinterface. Gebruik scripts/export-archive.mjs op de server.", "mig.export_btn": "Archief downloaden (.zip)", "mig.export_none": "Er valt nog niets te exporteren.", "mig.import_title": "Hierheen halen", "mig.import_hint": "Kies je bestand. Je krijgt eerst te zien wat er zou gebeuren; er verandert nog niets.", "mig.file_label": "Je archiefbestand (.zip)", "mig.overwrite_label": "Berichten die er al staan vervangen", "mig.overwrite_hint": "Normaal blijft alles wat hier al staat met rust. Zet je dit aan, dan wordt een bericht met dezelfde naam vervangen, ook als het iets anders was. Dat komt niet terug.", "mig.check_btn": "Controleren", "mig.check_hint": "Dit verandert nog niets. Je krijgt eerst te zien wat er zou gebeuren.", "mig.r_dry": "Wat er zou gebeuren", "mig.r_done": "Ge\u00efmporteerd", "mig.r_would": "zouden erbij komen", "mig.r_imported": "erbij gekomen", "mig.r_skipped": "overgeslagen (staan er al)", "mig.r_overwritten": "overschreven", "mig.r_media": "mediabestanden", "mig.r_media_missing": "media ontbrak in het archief", "mig.r_new_ids": "Dit archief komt van een ander webadres. Je berichten staan hier dus op een nieuw adres. Andere servers weten dat nog niet, dus reacties en boosts die daar naar het oude adres wijzen blijven daar staan.", "mig.r_confirm_hint": "Ziet het er goed uit? Kies hetzelfde bestand nog een keer om het echt te doen.", "mig.r_confirm_btn": "Nu echt importeren", "mig.follows_title": "Wie je volgt", "mig.follows_hint": "Je volglijst zit ook in het archief, en je kunt hem los downloaden en terugzetten bij Connect.", "mig.follows_btn": "Naar Connect", "admin.b_migrate": "\ud83d\udce6 Migreren", 'admin.b_newpost': '✍️ Nieuwe post', 'admin.b_look': '🎨 Uiterlijk',
    79123    'admin.t_admin': 'Beheer', 'admin.t_settings': 'Instellingen', 'admin.t_audio': 'Audiotracks', 'admin.t_epk': 'Perskit bewerken', 'admin.t_newsletter': 'Nieuwsbrief', 'admin.t_playlists': 'Afspeellijsten', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Agenda', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'Nieuwe site', 'admin.t_editsite': 'Bewerken: {title}', 'admin.t_stats': 'Statistieken', 'admin.t_updates': 'Updates', 'admin.t_users': 'Gebruikers', 'admin.t_hub': 'Mijn Klonkt Hub', 'admin.t_manual': 'Handleiding', 'aset.premium_gate': '{feature} is premium — koppel Patreon in Beheer → Instellingen.',
     
    10441088    'admin.b_media': '🎬 Media', 'admin.t_media': 'Media', 'admin.media_images': 'Images', 'admin.media_videos': 'Videos', 'admin.videos_count': 'videos', 'admin.videos_empty': 'No videos uploaded yet.', 'admin.videos_del_confirm': 'Delete this video?', 'admin.media_count': 'images', 'admin.media_unused': 'unused', 'admin.media_cleanup': 'Delete unused', 'admin.media_cleanup_confirm': 'Delete all unused images?', 'admin.media_empty': 'No images uploaded yet.', 'admin.media_copy': 'Copy URL', 'admin.media_del_confirm': 'Delete this image?',
    10451089    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Comments', 'admin.b_seo': '🔎 SEO',
     1090    'admin.b_listeners': 'Listeners',
     1091    'alis.lis_title': 'Listeners',
     1092    'alis.lis_intro': 'Accounts following your LIBRARY. They get your music, and deliberately not your ordinary posts — subscribing to a record shelf is not asking for the newspaper.',
     1093    'alis.lis_empty': 'Nobody follows the library yet.',
     1094    'alis.lis_since': 'Since',
     1095    'alis.lis_last': 'Last delivery',
     1096    'alis.lis_never': 'nothing delivered yet',
     1097    'alis.lis_count': 'listener(s)',
     1098    'alis.lis_error': 'delivery failed',
     1099    'ple.titel': 'Title *',
     1100    'ple.artiest': 'Artist',
     1101    'ple.jaar': 'Year',
     1102    'ple.type': 'Type',
     1103    'ple.k_album': 'Album (numbered)',
     1104    'ple.k_playlist': 'Playlist (track covers)',
     1105    'ple.uitgave': 'Release date',
     1106    'ple.mb_release': 'MusicBrainz release ID',
     1107    'ple.cover': 'Cover',
     1108    'ple.cover_kies': 'Choose image…',
     1109    'ple.cover_url': 'https://… or upload',
     1110    'ple.tracks_in': 'Tracks in playlist',
     1111    'ple.sleep_hint': '(drag ⠿ to reorder)',
     1112    'ple.beschikbaar': 'Available tracks',
     1113    'ple.zoek': 'Search…',
     1114    'ple.geen_res': 'No results.',
     1115    'ple.leeg_sel': 'Click tracks on the right to add them.',
     1116    'ple.t_edit': 'Edit playlist',
     1117    'ple.t_new': 'New playlist',
     1118    'ple.dialoog': 'Playlist editor',
     1119    'ple.sluiten': 'Close',
     1120    'ple.annuleren': 'Cancel',
     1121    'ple.opslaan': 'Save',
     1122    'ple.bezig_opslaan': 'Saving…',
     1123    'ple.aanmaken': 'Create',
     1124    'ple.versleep': 'Drag',
     1125    'ple.verwijder': 'Remove',
     1126    'ple.geen_audio': 'Track has no audio file',
     1127    'ple.e_geen_tracks': 'No audio tracks available. Upload some first via Admin → Audio.',
     1128    'ple.e_tracks': 'Could not load tracks',
     1129    'ple.e_opslaan': 'Saving failed: ',
     1130    'ple.e_mislukt': 'Failed: ',
     1131    'ple.e_alleen_afb': 'Images only',
     1132    'ple.bezig': 'Uploading…',
     1133    'ple.e_upload': 'Upload failed',
    10461134    'admin.b_settings': '⚙️ Settings', "mig.title": "Migrate", "mig.plan_new": "on your new Klonkt", "mig.plan_from_old": "You are looking at the instance that is LEAVING. Steps 1, 3 and 4 happen on your new Klonkt.", "mig.plan_title": "How a move works", "mig.plan_hint": "The order matters. Step 2 must come before step 3, because your old Klonkt gives nothing to an address it does not know as its successor. Working from a zip? Then step 2 can wait until last: a file asks nobody for permission.", "mig.plan_here": "here", "mig.plan_old": "on your old Klonkt", "mig.plan_1": "Link your previous account", "mig.plan_1_why": "Skip this and your old Klonkt refuses the move.", "mig.plan_2": "Announce the move", "mig.plan_2_why": "Your followers come along. Your old account locks afterwards: no more posting, following or replying there.", "mig.plan_3": "Fetch your posts and music", "mig.plan_3_why": "Straight from your old Klonkt, or from a zip if it is already offline.", "mig.plan_4": "Restore your following list", "mig.plan_5": "Later: when you give up the old domain", "mig.plan_5_why": "Delete your old posts there first. Then someone else’s boost disappears cleanly instead of sitting there as a broken card with a dead link. After that, remove your previous account at step 1 above: you no longer control that address. There is no button for this yet.", "mig.follows_note": "Or paste the contents of your CSV above. The file wins if you fill in both.", "asite.moved_to_migrate": "Aliases and moving now live under Migrate, together with export, import and fetch.", "mig.alias_title": "Step 1: link your previous account", "mig.alias_hint": "Tell us which account used to be yours. Your old Klonkt checks this before it moves your followers, and the fetch button below needs it too.", "mig.alias_label": "Your previous accounts, one per line", "mig.alias_note": "The way you would give them to someone: @you@mastodon.social. Five at most.", "mig.alias_btn": "Save", "mig.move_title": "Step 2: announce the move", "mig.move_hint": "This tells all your followers that your account now lives elsewhere. They move with you, and this account locks afterwards: no more posting, following, liking or replying.", "mig.move_label": "Your new address", "mig.move_warn": "This is the only button on this page you cannot undo. Your old account locks afterwards. Using the fetch button? Then this MUST come first: otherwise your old Klonkt hands over nothing.", "mig.move_btn": "Announce the move", "mig.move_confirm": "This announces your move to all your followers and locks this account. Are you sure?", "mig.move_done": "This account already moved to", "mig.r_links_fixed": "posts whose links now point here", "mig.r_posts_updated": "posts repaired (images made local)", "mig.r_tracks_updated": "tracks completed", "mig.r_tracks_missing": "{n} track(s) did NOT arrive, because the audio file was absent. They were deliberately not created: a track that sits in the list and will not play is worse than one that is missing.", "mig.c_tracks": "tracks", "mig.c_playlists": "playlists", "mig.audio_missing": "Note: for {n} track(s) the audio file cannot be found. Those will not travel.", "mig.audio_none": "Note: this site has music, but not a single track is travelling. The files are probably somewhere other than the database thinks.", "mig.pull_title": "Fetch from your old Klonkt", "mig.pull_hint": "If you already moved, this Klonkt fetches your posts straight from the old one. Nothing to type over: having moved is the proof.", "mig.pull_source": "Your old account", "mig.pull_source_hint": "Taken from step 1. Change it here if it is wrong.", "mig.pull_btn": "Fetch posts", "mig.pull_done": "Fetched", "mig.pull_failed": "Fetching did not work", "mig.r_blocks": "blocks carried over", "mig.state": "{n} posts have a pointer from their old address.", "mig.state_done": "The list is complete.", "mig.state_busy": "The list is not complete yet.", "mig.e_no_source": "No old account is known. Fill in the address above.", "mig.e_unreachable": "The old Klonkt cannot be reached.", "mig.e_not_moved_here": "That account has not moved here. Announce the move on your old Klonkt first.", "mig.e_no_backreference": "This account does not say anywhere that the old account was yours. Fill it in at step 1 above, otherwise your old Klonkt cannot tell it is you.", "mig.e_no_outbox": "The old Klonkt has no post list.", "mig.e_partial": "Stopped halfway. What arrived stays; try again.", "mig.e_config": "This Klonkt does not know its own address.", "mig.e_crash": "Something unexpected went wrong.", "mig.e_points_at": "That account points at:", "mig.lead": "Take your posts, music and photos to another Klonkt, or bring them here.", "mig.export_title": "Take with you", "mig.export_hint": "This builds a zip with your posts, their replies, and the media that belongs to them.", "mig.c_posts": "posts", "mig.c_replies": "replies", "mig.c_media": "media files", "mig.c_following": "follows", "mig.c_size": "in size", "mig.missing": "Note: for {n} media reference(s) the file is no longer on disk. Those will not travel.", "mig.too_big": "This archive is too large for the web interface. Use scripts/export-archive.mjs on the server.", "mig.export_btn": "Download archive (.zip)", "mig.export_none": "Nothing to export yet.", "mig.import_title": "Bring here", "mig.import_hint": "Pick your file. You will see what would happen first; nothing changes yet.", "mig.file_label": "Your archive file (.zip)", "mig.overwrite_label": "Replace posts that are already here", "mig.overwrite_hint": "Normally everything already here is left alone. Turn this on and a post with the same name gets replaced, even if it was something else. That does not come back.", "mig.check_btn": "Check", "mig.check_hint": "This changes nothing yet. You will see what would happen first.", "mig.r_dry": "What would happen", "mig.r_done": "Imported", "mig.r_would": "would be added", "mig.r_imported": "added", "mig.r_skipped": "skipped (already here)", "mig.r_overwritten": "overwritten", "mig.r_media": "media files", "mig.r_media_missing": "media missing from the archive", "mig.r_new_ids": "This archive comes from a different web address, so your posts live at a new address here. Other servers do not know that yet, so replies and boosts pointing at the old address stay where they are.", "mig.r_confirm_hint": "Looks right? Pick the same file once more to actually do it.", "mig.r_confirm_btn": "Import for real", "mig.follows_title": "Who you follow", "mig.follows_hint": "Your following list is in the archive too, and you can download and restore it separately under Connect.", "mig.follows_btn": "Go to Connect", "admin.b_migrate": "\ud83d\udce6 Migrate", 'admin.b_newpost': '✍️ New post', 'admin.b_look': '🎨 Appearance',
    10471135    'admin.t_admin': 'Admin', 'admin.t_settings': 'Settings', 'admin.t_audio': 'Audio tracks', 'admin.t_epk': 'Edit press kit', 'admin.t_newsletter': 'Newsletter', 'admin.t_playlists': 'Playlists', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Agenda', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'New site', 'admin.t_editsite': 'Edit: {title}', 'admin.t_stats': 'Statistics', 'admin.t_updates': 'Updates', 'admin.t_users': 'Users', 'admin.t_hub': 'My Klonkt Hub', 'admin.t_manual': 'Manual', 'aset.premium_gate': '{feature} is a premium feature — connect Patreon in Admin → Settings.',
     
    20062094    'admin.b_media': '🎬 Medien', 'admin.t_media': 'Medien', 'admin.media_images': 'Bilder', 'admin.media_videos': 'Videos', 'admin.videos_count': 'Videos', 'admin.videos_empty': 'Noch keine Videos hochgeladen.', 'admin.videos_del_confirm': 'Dieses Video löschen?', 'admin.media_count': 'Bilder', 'admin.media_unused': 'ungenutzt', 'admin.media_cleanup': 'Ungenutzte löschen', 'admin.media_cleanup_confirm': 'Alle ungenutzten Bilder löschen?', 'admin.media_empty': 'Noch keine Bilder hochgeladen.', 'admin.media_copy': 'URL kopieren', 'admin.media_del_confirm': 'Dieses Bild löschen?',
    20072095    'admin.b_playlists': '📃 Playlists', 'admin.b_comments': '💬 Kommentare', 'admin.b_seo': '🔎 SEO',
     2096    'admin.b_listeners': 'Hörer',
     2097    'alis.lis_title': 'Hörer',
     2098    'alis.lis_intro': 'Konten, die deiner BIBLIOTHEK folgen. Sie bekommen deine Musik, und bewusst nicht deine normalen Beiträge — wer ein Plattenregal abonniert, hat nicht um die Zeitung gebeten.',
     2099    'alis.lis_empty': 'Noch folgt niemand der Bibliothek.',
     2100    'alis.lis_since': 'Seit',
     2101    'alis.lis_last': 'Letzte Zustellung',
     2102    'alis.lis_never': 'noch nichts zugestellt',
     2103    'alis.lis_count': 'Hörer',
     2104    'alis.lis_error': 'Zustellung fehlgeschlagen',
     2105    'ple.titel': 'Titel *',
     2106    'ple.artiest': 'Künstler',
     2107    'ple.jaar': 'Jahr',
     2108    'ple.type': 'Typ',
     2109    'ple.k_album': 'Album (nummeriert)',
     2110    'ple.k_playlist': 'Playlist (Titelbilder)',
     2111    'ple.uitgave': 'Veröffentlichungsdatum',
     2112    'ple.mb_release': 'MusicBrainz-Release-ID',
     2113    'ple.cover': 'Cover',
     2114    'ple.cover_kies': 'Bild wählen…',
     2115    'ple.cover_url': 'https://… oder hochladen',
     2116    'ple.tracks_in': 'Titel in der Playlist',
     2117    'ple.sleep_hint': '(⠿ ziehen zum Sortieren)',
     2118    'ple.beschikbaar': 'Verfügbare Titel',
     2119    'ple.zoek': 'Suchen…',
     2120    'ple.geen_res': 'Keine Ergebnisse.',
     2121    'ple.leeg_sel': 'Klicke rechts auf Titel, um sie hinzuzufügen.',
     2122    'ple.t_edit': 'Playlist bearbeiten',
     2123    'ple.t_new': 'Neue Playlist',
     2124    'ple.dialoog': 'Playlist-Editor',
     2125    'ple.sluiten': 'Schließen',
     2126    'ple.annuleren': 'Abbrechen',
     2127    'ple.opslaan': 'Speichern',
     2128    'ple.bezig_opslaan': 'Speichern…',
     2129    'ple.aanmaken': 'Anlegen',
     2130    'ple.versleep': 'Ziehen',
     2131    'ple.verwijder': 'Entfernen',
     2132    'ple.geen_audio': 'Titel hat keine Audiodatei',
     2133    'ple.e_geen_tracks': 'Keine Audiotitel vorhanden. Lade zuerst welche über Verwaltung → Audio hoch.',
     2134    'ple.e_tracks': 'Titel konnten nicht geladen werden',
     2135    'ple.e_opslaan': 'Speichern fehlgeschlagen: ',
     2136    'ple.e_mislukt': 'Fehlgeschlagen: ',
     2137    'ple.e_alleen_afb': 'Nur Bilder',
     2138    'ple.bezig': 'Wird hochgeladen…',
     2139    'ple.e_upload': 'Upload fehlgeschlagen',
    20082140    'admin.b_settings': '⚙️ Einstellungen', "mig.title": "Migrieren", "mig.plan_new": "auf deinem neuen Klonkt", "mig.plan_from_old": "Du siehst die Instanz, die WEGGEHT. Schritt 1, 3 und 4 machst du auf deinem neuen Klonkt.", "mig.plan_title": "So l\u00e4uft ein Umzug", "mig.plan_hint": "Die Reihenfolge ist nicht beliebig. Schritt 2 muss vor Schritt 3, denn dein altes Klonkt gibt nichts an eine Adresse heraus, die es nicht als Nachfolger kennt. Mit einem Zip darf Schritt 2 dagegen zuletzt: eine Datei fragt niemanden.", "mig.plan_here": "hier", "mig.plan_old": "auf deinem alten Klonkt", "mig.plan_1": "Dein früheres Konto verknüpfen", "mig.plan_1_why": "Ohne das verweigert dein altes Klonkt den Umzug.", "mig.plan_2": "Den Umzug ank\u00fcndigen", "mig.plan_2_why": "Deine Follower ziehen mit. Danach wird das alte Konto gesperrt: kein Posten, Folgen oder Antworten mehr.", "mig.plan_3": "Beitr\u00e4ge und Musik holen", "mig.plan_3_why": "Direkt vom alten Klonkt, oder aus einem Zip, wenn es schon offline ist.", "mig.plan_4": "Deine Folgeliste zur\u00fcckspielen", "mig.plan_5": "Sp\u00e4ter: wenn du die alte Domain aufgibst", "mig.plan_5_why": "Lösche dort zuerst deine alten Beiträge. Dann verschwindet ein fremder Boost sauber, statt als kaputte Karte mit totem Link stehen zu bleiben. Entferne danach oben bei Schritt 1 dein früheres Konto: diese Adresse kontrollierst du dann nicht mehr. Dafür gibt es noch keinen Knopf.", "mig.follows_note": "Oder f\u00fcge den Inhalt deiner CSV oben ein. Die Datei gewinnt, wenn du beides ausf\u00fcllst.", "asite.moved_to_migrate": "Aliasse und Umzug stehen jetzt unter Migrieren, zusammen mit Export, Import und Holen.", "mig.alias_title": "Schritt 1: dein früheres Konto verknüpfen", "mig.alias_hint": "Sag, welches Konto früher deins war. Dein altes Klonkt prüft das, bevor es deine Follower umzieht, und der Hol-Knopf unten braucht es auch.", "mig.alias_label": "Deine früheren Konten, eines pro Zeile", "mig.alias_note": "So, wie du sie jemandem geben würdest: @du@mastodon.social. Höchstens fünf.", "mig.alias_btn": "Speichern", "mig.move_title": "Schritt 2: den Umzug ank\u00fcndigen", "mig.move_hint": "Das sagt allen Followern, dass dein Konto jetzt woanders wohnt. Sie ziehen mit um, und dieses Konto wird danach gesperrt: kein Posten, Folgen, Liken oder Antworten mehr.", "mig.move_label": "Deine neue Adresse", "mig.move_warn": "Das ist der einzige Knopf auf dieser Seite, den du nicht r\u00fcckg\u00e4ngig machen kannst. Dein altes Konto wird danach gesperrt. Nutzt du den Hol-Knopf, MUSS das zuerst: sonst gibt dein altes Klonkt nichts heraus.", "mig.move_btn": "Umzug ank\u00fcndigen", "mig.move_confirm": "Das k\u00fcndigt deinen Umzug allen Followern an und sperrt dieses Konto. Sicher?", "mig.move_done": "Dieses Konto ist bereits umgezogen nach", "mig.r_links_fixed": "Beitr\u00e4ge, deren Links jetzt hierher zeigen", "mig.r_posts_updated": "Beitr\u00e4ge repariert (Bilder lokal geholt)", "mig.r_tracks_updated": "Titel erg\u00e4nzt", "mig.r_tracks_missing": "{n} Titel sind NICHT angekommen, weil die Audiodatei fehlte. Sie wurden bewusst nicht angelegt: ein Titel, der in der Liste steht und nicht spielt, ist schlimmer als ein fehlender.", "mig.c_tracks": "Titel", "mig.c_playlists": "Playlists", "mig.audio_missing": "Achtung: Bei {n} Titel(n) ist die Audiodatei nicht auffindbar. Die kommen nicht mit.", "mig.audio_none": "Achtung: Diese Seite hat Musik, aber es kommt kein einziger Titel mit. Die Dateien liegen vermutlich woanders als die Datenbank denkt.", "mig.pull_title": "Von deinem alten Klonkt holen", "mig.pull_hint": "Bist du schon umgezogen, holt dieses Klonkt deine Beiträge direkt beim alten. Nichts abzutippen: dass du umgezogen bist, ist der Beweis.", "mig.pull_source": "Dein altes Konto", "mig.pull_source_hint": "Aus Schritt 1 übernommen. Stimmt es nicht, ändere es hier.", "mig.pull_btn": "Beitr\u00e4ge holen", "mig.pull_done": "Geholt", "mig.pull_failed": "Holen hat nicht geklappt", "mig.r_blocks": "Blockierungen \u00fcbernommen", "mig.state": "{n} Beitr\u00e4ge haben einen Verweis von ihrer alten Adresse.", "mig.state_done": "Die Liste ist vollst\u00e4ndig.", "mig.state_busy": "Die Liste ist noch nicht vollst\u00e4ndig.", "mig.e_no_source": "Es ist kein altes Konto bekannt. Trage die Adresse oben ein.", "mig.e_unreachable": "Das alte Klonkt ist nicht erreichbar.", "mig.e_not_moved_here": "Dieses Konto ist nicht hierher umgezogen. Kündige den Umzug zuerst auf deinem alten Klonkt an.", "mig.e_no_backreference": "Dieses Konto sagt nirgends, dass das alte Konto deins war. Trage es oben bei Schritt 1 ein, sonst kann dein altes Klonkt nicht erkennen, dass du es bist.", "mig.e_no_outbox": "Das alte Klonkt hat keine Beitragsliste.", "mig.e_partial": "Auf halbem Weg gestoppt. Was da ist, bleibt; versuche es noch einmal.", "mig.e_config": "Dieses Klonkt kennt seine eigene Adresse nicht.", "mig.e_crash": "Etwas Unerwartetes ist schiefgegangen.", "mig.e_points_at": "Dieses Konto verweist auf:", "mig.lead": "Nimm deine Beiträge, Musik und Fotos zu einem anderen Klonkt mit, oder hole sie hierher.", "mig.export_title": "Mitnehmen", "mig.export_hint": "Das erstellt ein Zip mit deinen Beitr\u00e4gen, den Antworten darauf und den zugeh\u00f6rigen Medien.", "mig.c_posts": "Beitr\u00e4ge", "mig.c_replies": "Antworten", "mig.c_media": "Mediendateien", "mig.c_following": "Folge ich", "mig.c_size": "gro\u00df", "mig.missing": "Achtung: Bei {n} Medienverweis(en) liegt die Datei nicht mehr auf der Platte. Die kommen nicht mit.", "mig.too_big": "Dieses Archiv ist zu gro\u00df f\u00fcr die Weboberfl\u00e4che. Nutze scripts/export-archive.mjs auf dem Server.", "mig.export_btn": "Archiv herunterladen (.zip)", "mig.export_none": "Noch nichts zu exportieren.", "mig.import_title": "Hierher holen", "mig.import_hint": "Wähle deine Datei. Du siehst zuerst, was passieren würde; es ändert sich noch nichts.", "mig.file_label": "Deine Archivdatei (.zip)", "mig.overwrite_label": "Beiträge ersetzen, die schon hier sind", "mig.overwrite_hint": "Normalerweise bleibt alles, was hier steht, unangetastet. Schaltest du das ein, wird ein Beitrag mit demselben Namen ersetzt, auch wenn es etwas anderes war. Das kommt nicht zurück.", "mig.check_btn": "Pr\u00fcfen", "mig.check_hint": "Das \u00e4ndert noch nichts. Du siehst zuerst, was passieren w\u00fcrde.", "mig.r_dry": "Was passieren w\u00fcrde", "mig.r_done": "Importiert", "mig.r_would": "k\u00e4men dazu", "mig.r_imported": "dazugekommen", "mig.r_skipped": "\u00fcbersprungen (schon vorhanden)", "mig.r_overwritten": "\u00fcberschrieben", "mig.r_media": "Mediendateien", "mig.r_media_missing": "Medien fehlten im Archiv", "mig.r_new_ids": "Dieses Archiv kommt von einer anderen Webadresse, deine Beiträge liegen hier also unter einer neuen Adresse. Andere Server wissen das noch nicht, also bleiben Antworten und Boosts auf die alte Adresse dort stehen.", "mig.r_confirm_hint": "Sieht gut aus? W\u00e4hle dieselbe Datei noch einmal, um es wirklich zu tun.", "mig.r_confirm_btn": "Jetzt wirklich importieren", "mig.follows_title": "Wem du folgst", "mig.follows_hint": "Deine Folgeliste ist auch im Archiv, und du kannst sie unter Connect separat herunterladen und zur\u00fcckspielen.", "mig.follows_btn": "Zu Connect", "admin.b_migrate": "\ud83d\udce6 Migrieren", 'admin.b_newpost': '✍️ Neuer Beitrag', 'admin.b_look': '🎨 Aussehen',
    20092141    'admin.t_admin': 'Verwaltung', 'admin.t_settings': 'Einstellungen', 'admin.t_audio': 'Audiotracks', 'admin.t_epk': 'Pressemappe bearbeiten', 'admin.t_newsletter': 'Newsletter', 'admin.t_playlists': 'Playlists', 'admin.t_seo': 'SEO', 'admin.t_shows': 'Termine', 'admin.t_sites': 'Sites', 'admin.t_newsite': 'Neue Site', 'admin.t_editsite': 'Bearbeiten: {title}', 'admin.t_stats': 'Statistiken', 'admin.t_updates': 'Updates', 'admin.t_users': 'Benutzer', 'admin.t_hub': 'Mein Klonkt Hub', 'admin.t_manual': 'Handbuch', 'aset.premium_gate': '{feature} ist eine Premium-Funktion — verbinde Patreon in Verwaltung → Einstellungen.',
  • src/services/music/index.js

    ra5515b4 r749605a  
    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.