Changeset 749605a in Klonkt for src/services
- Timestamp:
- 08/16/2026 03:06:18 PM (3 weeks ago)
- 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. - Location:
- src/services
- Files:
-
- 1 added
- 5 edited
-
ActivityPubService.js (modified) (8 diffs)
-
PlaylistService.js (modified) (10 diffs)
-
ap-core.js (modified) (4 diffs)
-
i18n.js (modified) (3 diffs)
-
music/index.js (modified) (10 diffs)
-
music/luisteraars.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
ra5515b4 r749605a 34 34 // De muziekkant woont in music/ (shaer-drc). Doorgeven wat hier altijd 35 35 // vandaan kwam, zodat elke bestaande aanroep blijft werken. 36 import { luisteraars } from './music/index.js'; 36 37 import { TRACK_KOLOMMEN, 37 38 playlistOpenTracks, siteOpenTracks, openTrack, trackHostPosts, … … 39 40 buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, 40 41 buildPostTrackCollection, uitgavePost, 42 buildLibrary, libraryId, 41 43 licentieUri, channelCategory, 42 44 } from './music/index.js'; … … 46 48 buildPlaylistCollection, listPlaylistsAP, playlistLinkTags, licentieUri, 47 49 buildPostTrackCollection, uitgavePost, 50 buildLibrary, libraryId, 48 51 }; 49 52 … … 354 357 const pagina = pickLink(o.url, () => true); 355 358 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 */ 366 function libraryOwnerSlug(uri) { 367 const u = String(uri || ''); 368 if (!u.endsWith('/library')) return null; 369 return localSlugOf(u.slice(0, -'/library'.length)); 356 370 } 357 371 … … 1186 1200 // these as the "Featured" tab (pinned to the profile). Posts come ordered by pin 1187 1201 // rank; embedded as full Notes so a remote server doesn't need extra fetches. 1188 export function buildFeatured(base, site, posts ) {1202 export function buildFeatured(base, site, posts, { page = false } = {}) { 1189 1203 const id = `${actorId(base, site.slug)}/featured`; 1190 1204 const items = (posts || []).map((p) => buildNote(base, site, p)); 1191 return pagedCollection(id, items );1205 return pagedCollection(id, items, { page }); 1192 1206 } 1193 1207 … … 2354 2368 if (type === 'Follow') { 2355 2369 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 } 2356 2400 // slugParam is de eigenaar van een per-actor inbox; op de GEDEELDE inbox is 2357 2401 // die er niet en werd de slug uit act.object geraden. Zonder hostcontrole … … 2437 2481 return 202; 2438 2482 } 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 2439 2494 if (type === 'Undo' && act.object) { 2440 2495 const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id); … … 6809 6864 buildPlaylistCollection, playlistOpenTracks, listPlaylistsAP, playlistLinkTags, 6810 6865 buildPostTrackCollection, uitgavePost, 6866 buildLibrary, libraryId, 6811 6867 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, deliverDelete, deliverUpdate, deliverActorUpdate, resyncFeaturedPins, 6812 6868 feedCursor, feedChangesSince, waitForFeedChange, -
src/services/PlaylistService.js
ra5515b4 r749605a 15 15 import { v4 as uuid } from 'uuid'; 16 16 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 */ 30 function 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. */ 39 function 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 17 44 class PlaylistService { 18 45 … … 52 79 const rows = db.prepare(` 53 80 SELECT p.id, p.title, p.artist, p.year, p.cover_url, p.kind, 81 p.release_date, p.mb_release_id, 54 82 p.created_at, p.updated_at, 55 83 (SELECT COUNT(*) FROM playlist_tracks WHERE playlist_id = p.id) AS track_count … … 65 93 cover: r.cover_url || '', 66 94 kind: r.kind || 'album', 95 release_date: r.release_date || '', 96 mb_release_id: r.mb_release_id || '', 67 97 track_count: r.track_count, 68 98 created_at: r.created_at, … … 86 116 if (!id) return null; 87 117 const p = db.prepare(` 88 SELECT id, title, artist, year, cover_url, kind, created_at, updated_at118 SELECT id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at, updated_at 89 119 FROM playlists WHERE site_id = ? AND id = ? 90 120 `).get(siteId, id); … … 125 155 cover: p.cover_url || fallbackCover, 126 156 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 || ''), 127 166 tracks: mappedTracks, 128 167 }; … … 141 180 const kind = data.kind === 'playlist' ? 'playlist' : 'album'; 142 181 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 143 192 const tx = db.transaction(() => { 144 193 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 147 196 `).run( 148 197 id, siteId, title, … … 150 199 Number.isFinite(+data.year) && +data.year > 0 ? +data.year : null, 151 200 String(data.cover || '').trim() || null, 152 kind, now, now,201 kind, releaseDate, mbRelease, now, now, 153 202 ); 154 203 this._writeTracks(id, siteId, data.tracks); … … 171 220 if (!id) return false; 172 221 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 = ?' 174 223 ).get(siteId, id); 175 224 if (!existing) return false; … … 177 226 const fields = []; 178 227 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'); 179 233 if (Object.prototype.hasOwnProperty.call(data, 'title')) { 180 234 const v = String(data.title || '').trim(); … … 193 247 } 194 248 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 } 196 266 } 197 267 fields.push('updated_at = ?'); values.push(new Date().toISOString()); -
src/services/ap-core.js
ra5515b4 r749605a 57 57 // zou een verhuizing kunnen laten mislukken. 58 58 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', 59 108 position: 'schema:position', 60 109 bitrate: 'schema:bitrate', … … 249 298 * en zonder `next`. Hem naar de laatste pagina terugbuigen zou opnieuw een 250 299 * 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 */ 313 export function pagedCollection(id, items, { totalItems, page = false, perPage = PAGINA_GROOTTE, alGesneden = false, ongeordend = false, extra = {} } = {}) { 253 314 const lijst = items || []; 254 315 const telling = totalItems === undefined ? lijst.length : totalItems; … … 267 328 '@context': AP_CONTEXT, 268 329 id: url(n), 269 type: 'OrderedCollectionPage',330 type: ongeordend ? 'CollectionPage' : 'OrderedCollectionPage', 270 331 partOf: id, 271 332 totalItems: telling, 333 ...(ongeordend ? { first: url(1), last: url(paginas) } : {}), 334 ...(extra.attributedTo ? { attributedTo: extra.attributedTo } : {}), 272 335 ...(n > 1 ? { prev: url(n - 1) } : {}), 273 336 ...(n < paginas ? { next: url(n + 1) } : {}), 274 orderedItems: deel,337 ...(ongeordend ? { items: deel } : { orderedItems: deel }), 275 338 }; 276 339 } … … 278 341 '@context': AP_CONTEXT, 279 342 id, 280 type: 'OrderedCollection',343 type: ongeordend ? 'Collection' : 'OrderedCollection', 281 344 ...extra, 282 345 totalItems: telling, 283 346 first: url(1), 284 347 last: url(paginas), 285 orderedItems: lijst,348 ...(ongeordend ? { items: lijst } : { orderedItems: lijst }), 286 349 }; 287 350 } -
src/services/i18n.js
ra5515b4 r749605a 76 76 '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?', 77 77 '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', 78 122 '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', 79 123 '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.', … … 1044 1088 '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?', 1045 1089 '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', 1046 1134 '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', 1047 1135 '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.', … … 2006 2094 '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?', 2007 2095 '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', 2008 2140 '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', 2009 2141 '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 14 14 15 15 import db from '../../config/database.js'; 16 import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection } from '../ap-core.js';16 import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection, isMbid } from '../ap-core.js'; 17 17 import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js'; 18 // De luisteraars horen bij de muziekkant; hier doorgegeven zodat 19 // ActivityPubService niet in een submap hoeft te grijpen. 20 export * as luisteraars from './luisteraars.js'; 18 21 19 22 // m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes. … … 133 136 } 134 137 138 /** 139 * De artiest-credit, gedeeld door track en album (shaer-3f8a / shaer-756s). 140 * 141 * De ENTITEIT is de site-actor: een echt, opvraagbaar adres. De credittekst -- 142 * de artiestkolom van de track of van de uitgave -- gaat naar `credit`, want 143 * daar verwacht hun model hem. Er een id per artiestnaam van maken zou 144 * identiteit uit een string zijn, en dat is de fout die we bij albums juist 145 * vermijden. 146 * 147 * Eén functie voor beide, zodat een track en het album waar hij op staat nooit 148 * een verschillende artiest kunnen krijgen door twee keer hetzelfde te bouwen. 149 */ 150 function artistCredit(base, site, creditTekst, wanneer) { 151 const artiest = { 152 type: 'Artist', 153 id: actorId(base, site.slug), 154 name: site.title || site.slug, 155 published: site.created_at ? new Date(site.created_at).toISOString() : wanneer, 156 }; 157 if (isMbid(site.mb_artist_id)) artiest.musicbrainzId = String(site.mb_artist_id).trim().toLowerCase(); 158 return [{ 159 type: 'ArtistCredit', 160 id: `${actorId(base, site.slug)}#artist-credit`, 161 published: artiest.published, 162 artist: artiest, 163 ...(creditTekst ? { credit: creditTekst } : {}), 164 }]; 165 } 166 135 167 export function buildTrackAudio(base, site, r, opts = {}) { 136 168 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`); … … 168 200 url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand], 169 201 }; 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); 170 206 if (r.artist) a.summary = r.artist; // artiest als summary: kaal AS2, geen eigen vocab 171 207 // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een … … 175 211 // van te maken. 176 212 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 } 177 264 if (r.duration) a.duration = `PT${Math.round(r.duration)}S`; 178 265 if (r.created_at) a.published = new Date(r.created_at).toISOString(); … … 213 300 } 214 301 302 /** Het AS2-id van de bibliotheek van een site. */ 303 export function libraryId(base, site) { 304 return `${actorId(base, site.slug)}/library`; 305 } 306 307 /** 308 * De site als Funkwhale-LIBRARY (skelet). 309 * 310 * WAAROM DIT GEEN DIALECT IS ZOALS track EN ArtistCredit DAT WEL ZIJN. Die twee 311 * vragen entiteiten waar wij tekst hebben; hiervoor hoeven we niets te 312 * verzinnen. Een library is precies wat er al staat: onze open tracks, met een 313 * echte telling en een echt id. 314 * 315 * WAAROM HET NODIG IS, gemeten op 13-8. open.audio heeft onze vier tracks 316 * binnengehaald langs de AP-weg -- met ONZE track-id's, en met een artist_credit 317 * dat Funkwhale zelf uit onze attributedTo afleidde. Maar `uploads` is leeg en 318 * `is_playable` false. Bij hen hangt een upload aan een library; zonder library 319 * is er geen bak om het bestand in te hangen. Het audiobestand zelf is wel 320 * gewoon op te halen (200, audio/mpeg, ook anoniem) -- ze hebben het niet 321 * geprobeerd. 322 * 323 * SKELET, en dat woord is letterlijk bedoeld. Dit is de vorm uit hun docs: 324 * type, id, name, followers, totalItems, first, last, plus attributedTo en 325 * summary. Wat er NIET is: de volg-afhandeling. Onze bibliotheek is openbaar -- 326 * elke track erin heeft fedi_open -- dus er valt niets goed te keuren. Komt er 327 * ooit een besloten variant, dan hoort daar het Follow/Accept-werk bij. 328 */ 329 export function buildLibrary(base, site, rows, { page = false } = {}) { 330 const id = libraryId(base, site); 331 const hostPosts = site.id ? trackHostPosts(site.id) : null; 332 const albums = site.id ? trackAlbums(site.id) : null; 333 const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts, albums })); 334 return pagedCollection(id, items, { 335 page, 336 // Een platenkast is geen tijdlijn: `Collection`, niet `OrderedCollection`. 337 // Funkwhale's LibrarySerializer accepteert ook alleen die twee typen 338 // (as:Collection of fw:Library) en zijn CollectionPageSerializer alleen 339 // `CollectionPage` met `items`. 340 ongeordend: true, 341 extra: { 342 type: 'Library', 343 name: site.title || site.slug, 344 attributedTo: actorId(base, site.slug), 345 // WAAROM DIT VELD ER MOET STAAN. Funkwhale's LibrarySerializer noemt 346 // `audience` optioneel, maar zijn create() doet er meteen 347 // `privacy[validated_data["audience"]]` mee -- zonder de sleutel is dat 348 // een KeyError en geeft hun server een 500. Dat is wat open.audio op 15-8 349 // teruggaf toen Robin onze library-URI daar opzocht. 350 // 351 // Het is bovendien gewoon waar: alles hierin is fedi_open, dus openbaar. 352 // Bij hen is dit precies het verschil tussen privacy_level 'everyone' en 353 // 'me' -- oftewel of onze nummers daar afspeelbaar zijn. 354 audience: 'https://www.w3.org/ns/activitystreams#Public', 355 // Vereist volgens hun docs. Openbaar, dus de telling is eerlijk en de 356 // lijst blijft leeg -- wie ons volgt volgt de ACTOR, niet de bak. 357 followers: `${id}/followers`, 358 ...(site.description ? { summary: String(site.description).slice(0, 500) } : {}), 359 }, 360 }); 361 } 362 215 363 /** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */ 216 export function buildTrackCollection(base, site, rows ) {364 export function buildTrackCollection(base, site, rows, { page = false } = {}) { 217 365 // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts. 218 366 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) } }); 221 370 } 222 371 … … 263 412 // collectie zelf, want ook een lijst mag niet verklappen wat er achter de 264 413 // poort staat. 265 export function listPlaylistsAP(base, site, enriched ) {414 export function listPlaylistsAP(base, site, enriched, { page = false } = {}) { 266 415 const rows = db.prepare( 267 416 'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id' … … 276 425 return stub; 277 426 }); 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 */ 442 export function trackAlbums(siteId) { 443 const rijen = db.prepare(` 444 SELECT pt.track_id AS tid, p.id, p.title, p.artist, p.year, p.cover_url, 445 p.release_date, p.mb_release_id, p.created_at 446 FROM playlist_tracks pt 447 JOIN playlists p ON p.id = pt.playlist_id 448 WHERE p.site_id = ? AND p.kind = 'album' 449 ORDER BY p.created_at, p.id 450 `).all(siteId); 451 const uit = new Map(); 452 for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, r); 453 // De post die deze plaat uitbrengt, EEN keer per album opgezocht en niet per 454 // track: uitgavePost() doet er echt werk voor (hij leest de typering van de 455 // post) en een site heeft veel meer nummers dan platen. 456 // 457 // WAAROM DIT ERBIJ MOET: buildPlaylistCollection laat leenVanPost de naam van 458 // de post overnemen -- de post IS de uitgave. Zonder dezelfde lening hier zou 459 // het ingesloten Album "Cartoon Epic" heten en zijn eigen URI "Geen koffie, 460 // wel thee!". Een id met twee namen, en dat is precies wat op 16-8 uit de 461 // meting rolde. 462 const perAlbum = new Map(); 463 for (const r of uit.values()) { 464 if (perAlbum.has(r.id)) continue; 465 perAlbum.set(r.id, uitgavePost(siteId, r.id)); 466 } 467 for (const r of uit.values()) r._post = perAlbum.get(r.id) || null; 468 return uit; 469 } 470 471 /** 472 * Een uitgave als `fw:Album`. 473 * 474 * INGESLOTEN EN NIET ALS URI, en dat is het hele punt van deze stap. Funkwhale's 475 * TrackSerializer heeft `album = AlbumSerializer()` -- een object met name, 476 * published en een eigen artist_credit. Een kale URI expandeert naar een knoop 477 * met alleen een @id en valt daar dus af. Emissary stuurt precies zo'n kale URI, 478 * en dat is waarom hun tracks bij Funkwhale net zo goed stranden. 479 * 480 * Het `id` is de bestaande playlist-collectie: dereferenceerbaar, en het is 481 * werkelijk hetzelfde ding. We verzinnen geen tweede adres voor iets dat er al 482 * een heeft. 483 */ 484 export function buildAlbumObject(base, site, pl) { 485 if (!pl) return null; 486 const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`); 487 // WANNEER IS DEZE PLAAT GEPUBLICEERD. De post die hem uitbrengt gaat voor, en 488 // niet als noodgreep maar omdat hij het beter weet: playlists.created_at is 489 // het moment waarop de RIJ is aangemaakt, en dat kan weken eerder zijn terwijl 490 // je nog aan het samenstellen was. AS2 `published` vraagt wanneer het object 491 // openbaar werd, en dat is de post. 492 // 493 // GEEN epoch als laatste terugval. `published` is bij hen verplicht, maar 1970 494 // is een ANTWOORD en geen ontbrekend veld -- en dat is erger: een lezer kan een 495 // gat opmerken, een leugen niet. Zo kwam op 16-8 de route boven water die id, 496 // title, artist, year, cover_url en kind selecteerde en de rest niet. 497 // 498 // OOK VOOR `released`, en daar had ik het eerst mis (Robin, 16-8). Mijn 499 // bezwaar was: post je vandaag een plaat uit 2018, dan beweert dit dat hij 500 // vandaag uitkwam. Dat gebeurt ook -- maar bij de meeste Klonkt-sites IS de 501 // post het uitbrengen, en GEEN datum is slechter dan een datum die op het 502 // gewone geval klopt. Het handmatige veld is precies het gereedschap voor de 503 // uitzondering: bij een heruitgave vul je hem in en die wint. 504 const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null; 505 const wanneer = postDatum ? postDatum.toISOString() 506 : (pl.created_at ? new Date(pl.created_at).toISOString() : null); 507 // Dezelfde lening als in buildPlaylistCollection: de post die de plaat 508 // uitbrengt geeft zijn titel, en de eigen titel blijft als alsoKnownAs staan. 509 const titel = (pl._post && pl._post.title) || pl.title; 510 const album = { 511 type: 'Album', 512 id: `${actorId(base, site.slug)}/playlists/${pl.id}`, 513 name: titel, 514 ...(wanneer ? { published: wanneer } : {}), 515 attributedTo: actorId(base, site.slug), 516 artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()), 517 }; 518 if (titel !== pl.title) album.alsoKnownAs = pl.title; 519 // Het ingevulde veld wint altijd; anders de DAG waarop de post verscheen. 520 // `year` vult hem nog steeds niet aan, en dat is geen inconsequentie: een 521 // jaartal is geen dag, terwijl de postdatum een gebeurtenis is die werkelijk 522 // heeft plaatsgevonden. Het verschil is verzinnen versus afleiden. 523 if (pl.release_date) album.released = pl.release_date; 524 else if (postDatum) album.released = postDatum.toISOString().slice(0, 10); 525 if (pl.mb_release_id) album.musicbrainzId = pl.mb_release_id; 526 const hoes = abs(pl.cover_url || null); 527 if (hoes) album.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes }; 528 return album; 279 529 } 280 530 … … 287 537 // De hoes van de playlist dient als terugval voor een track zonder eigen hoes. 288 538 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 })); 290 546 const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, { 291 547 extra: { name: playlist.title, attributedTo: actorId(base, site.slug) }, … … 298 554 const cover = abs(playlist.cover_url || null); 299 555 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); 301 576 } 302 577 … … 323 598 try { 324 599 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 326 602 FROM posts 327 603 WHERE site_id = ? AND status = 'published'
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)