Changes in / [a5515b4:749605a] in Klonkt
- Files:
-
- 16 added
- 17 edited
-
scripts/admin-css-gelijk.mjs (added)
-
scripts/admin-css.mjs (added)
-
scripts/funkwhale-gat.mjs (added)
-
scripts/render-admin-views.mjs (added)
-
src/assets/js/mod/playlist-editor.js (modified) (21 diffs)
-
src/config/database.js (modified) (2 diffs)
-
src/routes/activitypub.js (modified) (5 diffs)
-
src/routes/admin-listeners.js (added)
-
src/routes/circle.js (modified) (1 diff)
-
src/server.js (modified) (2 diffs)
-
src/services/ActivityPubService.js (modified) (8 diffs)
-
src/services/PlaylistService.js (modified) (10 diffs)
-
src/services/ap-core.js (modified) (4 diffs)
-
src/services/i18n.js (modified) (3 diffs)
-
src/services/music/index.js (modified) (10 diffs)
-
src/services/music/luisteraars.js (added)
-
src/views/pages/admin-audio.ejs (modified) (4 diffs)
-
src/views/pages/admin-listeners.ejs (added)
-
src/views/pages/admin-media.ejs (modified) (1 diff)
-
src/views/pages/admin-playlists.ejs (modified) (2 diffs)
-
src/views/pages/admin-videos.ejs (modified) (1 diff)
-
src/views/partials/admin-back.ejs (added)
-
src/views/partials/admin-styles.ejs (added)
-
src/views/partials/media-tabs.ejs (modified) (1 diff)
-
src/views/partials/playlist-editor.ejs (modified) (2 diffs)
-
test/activitypub-as2.test.js (modified) (1 diff)
-
test/ap-collection-paging.test.js (added)
-
test/ap-fw-track.test.js (added)
-
test/ap-library.test.js (added)
-
test/cirkel-media-vorm.test.js (added)
-
test/library-luisteraars.test.js (added)
-
test/playlist-editor-i18n.test.js (added)
-
test/playlist-uitgave.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/js/mod/playlist-editor.js
ra5515b4 r749605a 6 6 7 7 import { pageData } from './lib.js'; 8 9 /** 10 * De teksten van deze modal, uit het gegevensblok van partials/playlist-editor.ejs. 11 * 12 * EIGEN BLOK en niet pageData(): die pakt met querySelector er EEN, en deze 13 * modal hangt onder pagina's die er zelf al een hebben. Een tweede blok zou 14 * daar genegeerd worden. 15 * 16 * De terugval is bewust de sleutelnaam en niet de Nederlandse tekst: ontbreekt 17 * er iets, dan zie je DAT er iets ontbreekt in plaats van een pagina die er 18 * half vertaald uitziet en waarvan niemand merkt welke helft. 19 */ 20 let _T = null; 21 function T(sleutel) { 22 if (_T === null) { 23 try { 24 const el = document.querySelector('script[type="application/json"][data-playlist-editor-i18n]'); 25 _T = el ? (JSON.parse(el.textContent) || {}) : {}; 26 } catch (e) { _T = {}; } 27 } 28 return _T[sleutel] != null ? _T[sleutel] : sleutel; 29 } 8 30 9 31 (function() { … … 54 76 if (Array.isArray(j.tracks)) tracks = j.tracks; 55 77 } catch (e) { 56 alert( 'Tracks ophalen mislukt');78 alert(T('e_tracks')); 57 79 return; 58 80 } 59 81 if (!tracks.length) { 60 alert( 'Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');82 alert(T('e_geen_tracks')); 61 83 return; 62 84 } 63 85 64 86 // Existing playlist data when editing 65 let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', track_ids: [] };87 let initial = { title: '', artist: '', year: '', cover: '', kind: 'album', release_date: '', mb_release_id: '', track_ids: [] }; 66 88 if (isEdit && opts.id) { 67 89 try { … … 75 97 backdrop.className = 'pl-modal-backdrop'; 76 98 backdrop.innerHTML = ` 77 <div class="pl-modal" role="dialog" aria-label=" Playlist editor">99 <div class="pl-modal" role="dialog" aria-label="${T('dialoog')}"> 78 100 <div class="pl-modal-header"> 79 <h3>${isEdit ? '✎ Playlist bewerken' : '+ Nieuwe playlist'}</h3>80 <button type="button" class="pl-modal-close" aria-label=" Sluiten">×</button>101 <h3>${isEdit ? '✎ ' + T('t_edit') : '+ ' + T('t_new')}</h3> 102 <button type="button" class="pl-modal-close" aria-label="${T('sluiten')}">×</button> 81 103 </div> 82 104 <div class="pl-modal-body"> … … 85 107 <div class="pl-meta-grid"> 86 108 <label class="pl-field pl-field-full"> 87 <span> Titel *</span>109 <span>${T('titel')}</span> 88 110 <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}"> 89 111 </label> 90 112 <label class="pl-field"> 91 <span> Artiest</span>113 <span>${T('artiest')}</span> 92 114 <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}"> 93 115 </label> 94 116 <label class="pl-field"> 95 <span> Jaar</span>117 <span>${T('jaar')}</span> 96 118 <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}"> 97 119 </label> 98 120 <label class="pl-field"> 99 <span> Type</span>121 <span>${T('type')}</span> 100 122 <select id="pli-kind"> 101 <option value="album" ${initial.kind === 'album' ? 'selected' : ''}>💿 Album (genummerd)</option>102 <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 Playlist (track-covers)</option>123 <option value="album" ${initial.kind === 'album' ? 'selected' : ''}>💿 ${T('k_album')}</option> 124 <option value="playlist" ${initial.kind === 'playlist' ? 'selected' : ''}>📃 ${T('k_playlist')}</option> 103 125 </select> 104 126 </label> 127 <label class="pl-field pl-uitgave"> 128 <span>${T('uitgave')}</span> 129 <input type="date" id="pli-release-date" value="${esc(initial.release_date || '')}"> 130 </label> 131 <label class="pl-field pl-uitgave"> 132 <span>${T('mb_release')}</span> 133 <input type="text" id="pli-mb-release" maxlength="36" spellcheck="false" 134 placeholder="00000000-0000-0000-0000-000000000000" 135 value="${esc(initial.mb_release_id || '')}"> 136 </label> 105 137 <div class="pl-field pl-field-full"> 106 <span> Cover</span>138 <span>${T('cover')}</span> 107 139 <div class="pl-cover-row"> 108 140 <span class="pl-cover-thumb" id="pli-cover-thumb"> … … 111 143 : `<span class="pl-cover-empty">🎨</span>`} 112 144 </span> 113 <input type="text" id="pli-cover" placeholder=" https://… of upload" value="${esc(initial.cover)}" style="flex:1">145 <input type="text" id="pli-cover" placeholder="${T('cover_url')}" value="${esc(initial.cover)}" style="flex:1"> 114 146 </div> 115 147 <div class="pl-cover-upload-row"> 116 148 <input type="file" id="pli-cover-file" accept="image/jpeg,image/png,image/webp,image/gif" hidden> 117 <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 Foto kiezen…</button>149 <button type="button" class="pl-btn-small" id="pli-cover-pick">📷 ${T('cover_kies')}</button> 118 150 <span class="pl-cover-status" id="pli-cover-status"></span> 119 151 </div> … … 121 153 </div> 122 154 <div class="pl-section-title"> 123 Tracks in playlist<span class="pl-track-count" id="pli-count">0</span>124 <small> (sleep ⠿ om te ordenen)</small>155 ${T('tracks_in')} <span class="pl-track-count" id="pli-count">0</span> 156 <small>${T('sleep_hint')}</small> 125 157 </div> 126 158 <div id="pli-selected" class="pl-selected-list"></div> 127 159 </div> 128 160 <div class="pl-editor-right"> 129 <div class="pl-section-title"> Beschikbare tracks</div>130 <input type="search" id="pli-search" placeholder=" Zoek..." class="pl-search-input">161 <div class="pl-section-title">${T('beschikbaar')}</div> 162 <input type="search" id="pli-search" placeholder="${T('zoek')}" class="pl-search-input"> 131 163 <div id="pli-available" class="pl-available-list"></div> 132 164 </div> … … 134 166 </div> 135 167 <div class="pl-modal-footer"> 136 <button type="button" class="btn" id="pli-cancel"> Annuleren</button>168 <button type="button" class="btn" id="pli-cancel">${T('annuleren')}</button> 137 169 <button type="button" class="btn btn-primary" id="pli-save" disabled> 138 ${isEdit ? 'Opslaan' : 'Aanmaken'}170 ${isEdit ? T('opslaan') : T('aanmaken')} 139 171 </button> 140 172 </div> … … 157 189 const yearEl = $('#pli-year'); 158 190 const kindEl = $('#pli-kind'); 191 const releaseEl = $('#pli-release-date'); 192 const mbReleaseEl = $('#pli-mb-release'); 159 193 const coverEl = $('#pli-cover'); 194 195 // Uitgavedatum en release-id horen bij een ALBUM, niet bij een 196 // afspeellijst -- dat is wat de keuze album/playlist betekent. Ze 197 // verdwijnen dus als je omschakelt, en de server maakt ze dan ook leeg; 198 // dit scherm is de uitleg, niet de bewaking. 199 const toonUitgave = () => { 200 const album = kindEl.value !== 'playlist'; 201 for (const el of backdrop.querySelectorAll('.pl-uitgave')) el.hidden = !album; 202 }; 203 kindEl.addEventListener('change', toonUitgave); 204 toonUitgave(); 160 205 const coverThumb = $('#pli-cover-thumb'); 161 206 const saveBtn = $('#pli-save'); … … 176 221 // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id 177 222 // nodig, dus uploaden kan ook al vóór het aanmaken. De URL belandt in het 178 // cover-veld en wordt bij 'Aanmaken'/'Opslaan'met de playlist meegestuurd.223 // cover-veld en wordt bij het opslaan met de playlist meegestuurd. 179 224 const coverFileInput = $('#pli-cover-file'); 180 225 const coverPickBtn = $('#pli-cover-pick'); … … 187 232 if (!file) return; 188 233 if (!/^image\//.test(file.type)) { 189 coverStatus.textContent = 'Alleen afbeeldingen';234 coverStatus.textContent = T('e_alleen_afb'); 190 235 coverStatus.className = 'pl-cover-status is-error'; 191 236 return; 192 237 } 193 coverStatus.textContent = 'Uploaden…';238 coverStatus.textContent = T('bezig'); 194 239 coverStatus.className = 'pl-cover-status'; 195 240 const fd = new FormData(); … … 200 245 ); 201 246 const j = await r.json(); 202 if (!r.ok || !j.url) throw new Error(j.error || 'Upload mislukt');247 if (!r.ok || !j.url) throw new Error(j.error || T('e_upload')); 203 248 const url = j.url || ''; 204 249 coverEl.value = url; … … 209 254 coverStatus.className = 'pl-cover-status is-ok'; 210 255 } catch (err) { 211 coverStatus.textContent = 'Mislukt: '+ err.message;256 coverStatus.textContent = T('e_mislukt') + err.message; 212 257 coverStatus.className = 'pl-cover-status is-error'; 213 258 } … … 222 267 countEl.textContent = selected.length; 223 268 if (selected.length === 0) { 224 selectedEl.innerHTML = '<div class="pl-empty"> Klik tracks rechts om toe te voegen.</div>';269 selectedEl.innerHTML = '<div class="pl-empty">' + T('leeg_sel') + '</div>'; 225 270 updateSaveBtn(); 226 271 return; … … 233 278 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`; 234 279 return `<div class="pl-row" data-id="${esc(id)}" data-pos="${i}"> 235 <span class="pl-row-handle" aria-label=" Verslepen">⠿</span>280 <span class="pl-row-handle" aria-label="${T('versleep')}">⠿</span> 236 281 <span class="pl-row-num">${i + 1}</span> 237 282 ${cover} … … 240 285 ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''} 241 286 </span> 242 <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label=" Verwijderen">×</button>287 <button type="button" class="pl-row-x" data-id="${esc(id)}" aria-label="${T('verwijder')}">×</button> 243 288 </div>`; 244 289 }).join(''); … … 263 308 }); 264 309 if (matches.length === 0) { 265 availEl.innerHTML = '<div class="pl-empty"> Geen resultaten.</div>';310 availEl.innerHTML = '<div class="pl-empty">' + T('geen_res') + '</div>'; 266 311 return; 267 312 } … … 274 319 ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>` 275 320 : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`; 276 return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : 'title="Track heeft geen audio-bestand"'}>321 return `<div class="${cls.join(' ')}" data-id="${esc(t.id)}" ${t.playable ? '' : `title="${T('geen_audio')}"`}> 277 322 ${cover} 278 323 <span class="pl-row-info"> … … 368 413 const orig = saveBtn.textContent; 369 414 saveBtn.disabled = true; 370 saveBtn.textContent = 'Opslaan...';415 saveBtn.textContent = T('bezig_opslaan'); 371 416 372 417 const payload = { … … 376 421 cover: coverEl.value.trim(), 377 422 kind: kindEl.value === 'playlist' ? 'playlist' : 'album', 423 release_date: releaseEl.value.trim(), 424 mb_release_id: mbReleaseEl.value.trim(), 378 425 tracks: selected.slice(), 379 426 }; … … 385 432 const j = await api('POST', url, payload); 386 433 if (!j.ok) { 387 alert( 'Opslaan mislukt: '+ (j.error || 'onbekend'));434 alert(T('e_opslaan') + (j.error || 'onbekend')); 388 435 saveBtn.disabled = false; 389 436 saveBtn.textContent = orig; … … 396 443 } 397 444 } catch (err) { 398 alert( 'Opslaan mislukt: '+ err.message);445 alert(T('e_opslaan') + err.message); 399 446 saveBtn.disabled = false; 400 447 saveBtn.textContent = orig; -
src/config/database.js
ra5515b4 r749605a 140 140 ensureColumn('sites', 'mb_artist_id', 'TEXT'); 141 141 ensureColumn('sites', 'mb_artist_name', 'TEXT'); 142 // Een UITGAVE heeft twee dingen die een afspeellijst niet heeft (shaer-756s). 143 // 144 // release_date en niet `year`: die kolom bestaat al en blijft, maar hun 145 // AlbumSerializer leest `released` als een DateField. Een jaartal als 146 // 2024-01-01 versturen is een dag verzinnen, en dat is precies wat we bij 147 // artiesten en albums niet doen. Volledige datum of niets. 148 // 149 // mb_release_id is de tegenhanger van sites.mb_artist_id: dezelfde soort 150 // verwijzing naar MusicBrainz, een niveau lager. 151 // 152 // Ze horen ALLEEN bij kind='album'. PlaylistService dwingt dat af bij het 153 // opslaan -- zie daar waarom dat niet alleen in het scherm mag zitten. 154 ensureColumn('playlists', 'release_date', 'TEXT'); 155 ensureColumn('playlists', 'mb_release_id', 'TEXT'); 142 156 ensureColumn('audio_tracks', 'cover_url', 'TEXT'); 143 157 ensureColumn('audio_tracks', 'album', 'TEXT'); … … 386 400 private_pem TEXT NOT NULL, 387 401 created_at DATETIME DEFAULT CURRENT_TIMESTAMP 388 ); 402 ); -- LUISTERAARS (shaer-0nh). Wie de BIBLIOTHEEK volgt, niet de actor. 403 -- 404 -- Een eigen tabel en niet een vlag op ap_followers, en dat is met opzet: 405 -- deze accounts horen onze gewone posts NIET te krijgen. Zolang ze in een 406 -- andere tabel staan kan een bezorging ze niet per ongeluk meenemen -- een 407 -- vlag die iemand vergeet te filteren zou dat wel doen, en dan komt de 408 -- Krant van een site bij mensen die alleen muziek wilden. 409 CREATE TABLE IF NOT EXISTS ap_library_followers ( 410 id INTEGER PRIMARY KEY AUTOINCREMENT, 411 slug TEXT NOT NULL, 412 actor_uri TEXT NOT NULL, 413 inbox TEXT, 414 shared_inbox TEXT, 415 name TEXT, 416 handle TEXT, 417 icon TEXT, 418 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 419 last_delivery_at DATETIME, 420 last_error_at DATETIME, 421 UNIQUE (slug, actor_uri) 422 ); 423 389 424 CREATE TABLE IF NOT EXISTS ap_followers ( 390 425 id INTEGER PRIMARY KEY AUTOINCREMENT, -
src/routes/activitypub.js
ra5515b4 r749605a 1115 1115 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20` 1116 1116 ).all(site.id); 1117 AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts ));1117 AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts, { page: paginaNr(req) })); 1118 1118 }); 1119 1119 … … 1130 1130 const site = publicSite(req.params.slug); 1131 1131 if (!site) return res.status(404).end(); 1132 AP.sendAP(res, AP.listPlaylistsAP(baseUrl(req), site, wantsEnriched(req, res) ));1132 AP.sendAP(res, AP.listPlaylistsAP(baseUrl(req), site, wantsEnriched(req, res), { page: paginaNr(req) })); 1133 1133 }); 1134 1134 … … 1139 1139 const site = publicSite(req.params.slug); 1140 1140 if (!site) return res.status(404).end(); 1141 AP.sendAP(res, AP.buildTrackCollection(baseUrl(req), site, AP.siteOpenTracks(site.id, { alles: await magAlles(req, site.slug) }))); 1141 AP.sendAP(res, AP.buildTrackCollection(baseUrl(req), site, AP.siteOpenTracks(site.id, { alles: await magAlles(req, site.slug) }), { page: paginaNr(req) })); 1142 }); 1143 1144 // De bibliotheek van deze site (shaer-0nh). Funkwhale's Audio draagt een 1145 // `library`, en dat is bij hen het haakje waar een UPLOAD aan komt te hangen -- 1146 // zonder die bak blijft een binnengehaalde track daar een naam zonder geluid. 1147 // Gemeten op 13-8: open.audio had onze vier tracks wel, met onze eigen AP-id's, 1148 // maar uploads leeg en is_playable false. 1149 // 1150 // Openbaar, want alles erin is fedi_open. Er valt dus niets goed te keuren en de 1151 // volgerslijst blijft leeg: wie ons volgt volgt de ACTOR, niet de bak. 1152 // 1153 // `?page=` MOET hier doorgegeven worden. Zonder dat adverteert de wortel een 1154 // `first` die op zichzelf uitkomt: de lezer volgt hem, krijgt weer een `Library` 1155 // in plaats van een pagina, en klapt eruit -- open.audio gaf op 15-8 een 500 op 1156 // precies deze URL. Dezelfde les als bij de outbox (shaer-sk4): een `first` 1157 // beloven is een pagina beloven. 1158 router.get('/ap/users/:slug/library', (req, res) => { 1159 const site = publicSite(req.params.slug); 1160 if (!site) return res.status(404).end(); 1161 AP.sendAP(res, AP.buildLibrary(baseUrl(req), site, AP.siteOpenTracks(site.id), { page: paginaNr(req) })); 1162 }); 1163 1164 // De volgerscollectie die hun docs als vereist noemen. Leeg en eerlijk: er is 1165 // geen goedkeuringspad omdat de bibliotheek openbaar is. 1166 router.get('/ap/users/:slug/library/followers', (req, res) => { 1167 const site = publicSite(req.params.slug); 1168 if (!site) return res.status(404).end(); 1169 AP.sendAP(res, AP.pagedCollection(`${AP.libraryId(baseUrl(req), site)}/followers`, [], { page: paginaNr(req) })); 1142 1170 }); 1143 1171 … … 1174 1202 const site = publicSite(req.params.slug); 1175 1203 if (!site) return res.status(404).end(); 1176 const pl = db.prepare('SELECT id, title, artist, year, cover_url, kind FROM playlists WHERE id = ? AND site_id = ?')1204 const pl = db.prepare('SELECT id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at FROM playlists WHERE id = ? AND site_id = ?') 1177 1205 .get(req.params.id, site.id); 1178 1206 if (!pl) return res.status(404).end(); … … 1361 1389 router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => { 1362 1390 try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); } 1363 catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); } 1391 // Met de STACK erbij. Hier stond alleen `e.message`, en op 15-8 leverde dat 1392 // zes keer "[AP inbox] error: slug is not defined" op zonder één aanwijzing 1393 // waar -- een ReferenceError in een handler van duizenden regels, met een 1394 // naam die overal voorkomt. Een fout die je niet kunt plaatsen is niet 1395 // gemeld. Het type en de activiteit erbij, want dat zegt welke tak liep. 1396 catch (e) { 1397 const soort = req.body && req.body.type; 1398 console.warn('[AP inbox] error:', e.message, '| type:', soort, '| slug:', req.params.slug || '(gedeeld)'); 1399 console.warn(e.stack); 1400 return res.status(202).end(); 1401 } 1364 1402 }); 1365 1403 -
src/routes/circle.js
ra5515b4 r749605a 16 16 return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null; 17 17 } 18 /** 19 * media_json als ARRAY, of leeg. 20 * 21 * De catch vangt KAPOTTE json; hij ving niet geldige json van het verkeerde 22 * TYPE. Een remote server stuurde media_json = "[]" -- een string MET daarin 23 * `[]` -- en JSON.parse geeft dan netjes een string terug. Een string heeft 24 * geen .map, en daarmee lag /cirkel op boiert.eu plat: een 500 op de hele 25 * pagina door een enkele post (rij 19 van 72). 26 * 27 * Precies dezelfde fout stond al beschreven en gerepareerd in 28 * views/partials/note-body.ejs, waar drie vreemde notes de Krant meenamen. 29 * Die reparatie is hier nooit gekomen -- zelfde data, andere route. Wat er 30 * binnenkomt is niet van ons, dus de vorm hoort afgedwongen en niet aangenomen. 31 */ 18 32 function safeJson(s) { 19 try { return s ? JSON.parse(s) : []; } catch { return []; } 33 try { 34 const v = s ? JSON.parse(s) : []; 35 return Array.isArray(v) ? v : []; 36 } catch { return []; } 20 37 } 21 38 // The cover image + (separately) a cover video from a remote note's media. NEVER use a video/audio -
src/server.js
ra5515b4 r749605a 30 30 import adminAudioRoutes from './routes/admin-audio.js'; 31 31 import adminPlaylistsRoutes from './routes/admin-playlists.js'; 32 import adminListenersRoutes from './routes/admin-listeners.js'; 32 33 import adminSitesRoutes from './routes/admin-sites.js'; 33 34 import adminUsersRoutes from './routes/admin-users.js'; … … 396 397 app.use('/admin/audio', adminAudioRoutes); 397 398 app.use('/admin/playlists', adminPlaylistsRoutes); 399 app.use('/admin/listeners', adminListenersRoutes); 398 400 } 399 401 app.use('/admin/media', adminMediaRoutes); // image library + cleanup (works in lite mode too) -
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' -
src/views/pages/admin-audio.ejs
ra5515b4 r749605a 1 <%# De gedeelde ax-*-primitieven. MOET boven het eigen style-blok van deze 2 pagina staan: wat hieronder blijft staan wijkt bewust af en hoort dus 3 later te komen, anders wint de partial ervan. %> 4 <%- include('../partials/admin-styles') %> 1 5 <div class="container ax-page"> 2 6 3 < p><a href="/admin" class="btn">← <%= t('nav.admin') %></a></p>7 <%- include('../partials/admin-back') %> 4 8 <%- include('../partials/media-tabs', { active: 'audio', audioOn: true }) %> 5 9 <header class="ax-header"> … … 150 154 padding: 0 1rem; 151 155 } 152 .ax-header {153 display: flex; align-items: flex-start; gap: 0.75rem;154 margin-bottom: 1.5rem;155 }156 .ax-header h1 {157 font-family: var(--font-display, serif);158 font-size: 1.75rem;159 margin: 0 0 0.25rem;160 }161 .ax-tagline {162 color: var(--ink-muted, var(--ink-soft));163 margin: 0;164 font-size: 0.9rem;165 line-height: 1.5;166 }167 .ax-tagline code {168 background: var(--paper-2);169 padding: 0.1em 0.4em;170 border-radius: 4px;171 font-size: 0.9em;172 }173 .ax-back {174 display: inline-flex; align-items: center; justify-content: center;175 width: 40px; height: 40px;176 border: 1px solid var(--rule);177 border-radius: 8px;178 background: var(--paper);179 color: var(--ink);180 text-decoration: none;181 font-size: 1.1rem;182 flex-shrink: 0;183 transition: border-color 120ms;184 }185 .ax-back:hover { border-color: var(--accent); }186 187 /* ─── Flash messages ───────────────────────────────────────────── */188 .ax-flash {189 padding: 0.75rem 1rem;190 border-radius: 8px;191 margin-bottom: 1rem;192 font-size: 0.9rem;193 }194 .ax-flash-ok { background: rgba(40,160,90,.15); color: #2a9d5e; border: 1px solid rgba(40,160,90,.3); }195 .ax-flash-err { background: rgba(200,60,60,.15); color: #c33; border: 1px solid rgba(200,60,60,.3); }196 197 /* ─── Card sections ────────────────────────────────────────────── */198 .ax-card {199 background: var(--paper);200 border: 1px solid var(--rule);201 border-radius: 12px;202 padding: 1.25rem;203 margin-bottom: 1rem;204 }205 156 .ax-card-title { 206 157 font-family: var(--font-display, serif); … … 303 254 .ax-form-actions[hidden] { display: none; } 304 255 305 /* ─── Buttons ──────────────────────────────────────────────────── */306 .ax-btn {307 display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;308 padding: 0.55rem 1rem;309 border: 1px solid var(--rule);310 background: var(--paper-2);311 color: var(--ink);312 font-family: var(--font-ui, system-ui), sans-serif;313 font-size: 0.9rem; font-weight: 500;314 text-decoration: none;315 border-radius: 6px;316 cursor: pointer;317 min-height: 40px;318 transition: background 120ms, border-color 120ms;319 -webkit-tap-highlight-color: transparent;320 }321 .ax-btn:hover { border-color: var(--accent); }322 .ax-btn-secondary { background: var(--paper-2); }323 .ax-btn-primary {324 background: var(--accent); color: white;325 border-color: var(--accent);326 }327 .ax-btn-primary:hover { opacity: 0.92; border-color: var(--accent); }328 329 /* Tiny icon-only button for row actions */330 .ax-icon-btn {331 display: inline-flex; align-items: center; justify-content: center;332 width: 40px; height: 40px;333 border: 1px solid var(--rule);334 background: var(--paper-2);335 color: var(--ink);336 border-radius: 8px;337 cursor: pointer;338 font-size: 1rem;339 transition: background 120ms, border-color 120ms, color 120ms;340 }341 .ax-icon-btn:hover { border-color: var(--accent); }342 .ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }343 344 256 .ax-track-play.is-playing { 345 257 border-color: var(--accent); … … 348 260 } 349 261 .ax-track-play-icon { font-size: 0.85rem; line-height: 1; } 350 351 /* ─── Empty state ──────────────────────────────────────────────── */352 .ax-empty {353 text-align: center;354 padding: 2rem 1rem;355 color: var(--ink-muted, var(--ink-soft));356 }357 .ax-empty-icon {358 font-size: 2.5rem;359 font-family: var(--font-display, serif);360 color: var(--ink-soft);361 opacity: 0.4;362 margin-bottom: 0.5rem;363 }364 .ax-empty p { margin: 0; }365 366 /* ─── Track list (card per row) ────────────────────────────────── */367 .ax-list {368 list-style: none; padding: 0; margin: 0;369 display: flex; flex-direction: column;370 gap: 0.6rem;371 }372 .ax-track {373 display: grid;374 grid-template-columns: 56px 1fr auto;375 gap: 0.85rem;376 align-items: center;377 padding: 0.75rem;378 background: var(--paper-2);379 border: 1px solid var(--rule);380 border-radius: 10px;381 transition: border-color 120ms;382 }383 .ax-track:hover { border-color: var(--accent); }384 .ax-track-cover {385 width: 56px; height: 56px;386 border-radius: 6px;387 object-fit: cover;388 background: var(--paper);389 display: flex; align-items: center; justify-content: center;390 font-family: var(--font-display, serif);391 font-size: 1.5rem;392 color: var(--accent);393 flex-shrink: 0;394 }395 262 .ax-track-cover-empty { /* span variant */ } 396 .ax-track-meta {397 min-width: 0; /* allow text-overflow on long titles */398 display: flex; flex-direction: column; gap: 0.2rem;399 }400 .ax-track-title {401 font-weight: 600;402 font-size: 0.95rem;403 color: var(--ink);404 overflow: hidden;405 text-overflow: ellipsis;406 white-space: nowrap;407 }408 .ax-track-sub {409 font-size: 0.8rem;410 color: var(--ink-muted, var(--ink-soft));411 display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;412 }413 .ax-track-sep { opacity: 0.5; }414 .ax-embed {415 display: inline-block;416 background: var(--paper);417 padding: 0.15rem 0.5rem;418 border-radius: 4px;419 font-family: var(--font-mono, ui-monospace, monospace);420 font-size: 0.75rem;421 color: var(--ink-soft);422 cursor: pointer;423 user-select: all;424 margin-top: 0.15rem;425 word-break: break-all;426 align-self: flex-start;427 transition: background 120ms;428 }429 .ax-embed:hover { background: var(--rule); }430 .ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }431 432 .ax-track-actions {433 display: flex; gap: 0.4rem;434 flex-shrink: 0;435 }436 263 .ax-track-delete { display: inline; } 437 264 438 265 /* ─── Mobile tweaks ───────────────────────────────────────────── */ 439 266 @media (max-width: 480px) { 440 .ax-track {441 grid-template-columns: 48px 1fr;442 grid-template-areas: "cover meta" "actions actions";443 gap: 0.6rem;444 }445 .ax-track-cover { grid-area: cover; width: 48px; height: 48px; }446 .ax-track-meta { grid-area: meta; }447 .ax-track-actions {448 grid-area: actions;449 justify-content: flex-end;450 border-top: 1px solid var(--rule);451 padding-top: 0.5rem;452 margin-top: 0.25rem;453 }454 267 } 455 268 -
src/views/pages/admin-media.ejs
ra5515b4 r749605a 1 <%# Beheer → Media: image library + cleanup (the Audio half links out to /admin/audio). %> 2 <div class="admin-wrap" style="max-width:980px;margin:0 auto;padding:1.5rem 1rem;"> 3 <a href="/admin" class="btn" style="margin-bottom:1rem;display:inline-block;">← <%= t('nav.admin') %></a> 4 <h1 style="font-family:var(--display,serif);margin:.2rem 0 1rem;"><%= t('admin.t_media') %></h1> 1 <%# Beheer -> Media: de afbeeldingenbibliotheek met opruiming. 2 De audio-helft zit onder /admin/audio, als eigen tab. 5 3 4 Deze pagina stond volledig op inline styles en viel daarmee buiten het 5 ax-*-systeem waar audio en playlists al op zaten. Nu op dezelfde romp: 6 zelfde breedte, zelfde kop, tabs op dezelfde plek. %> 7 <%- include('../partials/admin-styles') %> 8 <div class="container ax-page"> 9 10 <%- include('../partials/admin-back') %> 6 11 <%- include('../partials/media-tabs', { active: 'images', audioOn: audioOn }) %> 12 <header class="ax-header"> 13 <div> 14 <h1><%= t('admin.t_media') %></h1> 15 <p class="ax-tagline"> 16 <%= items.length %> <%= t('admin.media_count') %><% if (unusedCount) { %> · <strong><%= unusedCount %></strong> <%= t('admin.media_unused') %><% } %> 17 </p> 18 </div> 19 </header> 7 20 8 <% if (success) { %>< p style="background:rgba(60,160,90,.15);border:1px solid rgba(60,160,90,.4);padding:.5rem .8rem;border-radius:8px;"><%= success %></p><% } %>21 <% if (success) { %><div class="ax-flash ax-flash-ok"><%= success %></div><% } %> 9 22 10 <div style="display:flex;justify-content:space-between;align-items:center;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;"> 11 <p style="margin:0;opacity:.75;"> 12 <%= items.length %> <%= t('admin.media_count') %><% if (unusedCount) { %> · <strong><%= unusedCount %></strong> <%= t('admin.media_unused') %><% } %> 13 </p> 14 <% if (unusedCount) { %> 15 <button type="button" id="media-cleanup" class="btn" style="background:#b4452e;color:#fff;border-color:#b4452e;">🗑 <%= t('admin.media_cleanup') %> (<%= unusedCount %>)</button> 16 <% } %> 23 <% if (unusedCount) { %> 24 <div class="ax-actions-bar"> 25 <button type="button" id="media-cleanup" class="ax-btn ax-btn-danger">🗑 <%= t('admin.media_cleanup') %> (<%= unusedCount %>)</button> 17 26 </div> 27 <% } %> 18 28 19 29 <% if (!items.length) { %> 20 <p style="opacity:.7;"><%= t('admin.media_empty') %></p> 30 <div class="ax-empty"> 31 <div class="ax-empty-icon">📷</div> 32 <p><%= t('admin.media_empty') %></p> 33 </div> 21 34 <% } else { %> 22 <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:1rem;">35 <div class="ax-grid"> 23 36 <% items.forEach(function(it){ %> 24 <div class=" media-card" data-file="<%= it.file %>" style="border:1px solid var(--rule,rgba(128,128,128,.25));border-radius:10px;overflow:hidden;background:var(--paper-2,rgba(128,128,128,.06));">25 <div style="position:relative;aspect-ratio:1;background:#0a0e1a;">26 <img src="<%= thumb(it.url, 320) %>" alt="" loading="lazy" decoding="async" style=" width:100%;height:100%;object-fit:cover;display:block;">27 <% if (it.hasVideo) { %><span title="animated cover" style="position:absolute;top:6px;right:6px;background:rgba(0,0,0,.6);border-radius:6px;padding:0 6px;font-size:13px;">🎬</span><% } %>28 <% if (!it.usedCount) { %><span style="position:absolute;top:6px;left:6px;background:#b4452e;color:#fff;border-radius:6px;padding:1px 6px;font-size:11px;"><%= t('admin.media_unused') %></span><% } %>37 <div class="ax-tile media-card" data-file="<%= it.file %>"> 38 <div class="ax-tile-media" style="aspect-ratio:1;"> 39 <img src="<%= thumb(it.url, 320) %>" alt="" loading="lazy" decoding="async" style="object-fit:cover;height:100%;"> 40 <% if (it.hasVideo) { %><span class="ax-tile-badge ax-tile-badge-r" title="animated cover">🎬</span><% } %> 41 <% if (!it.usedCount) { %><span class="ax-tile-badge"><%= t('admin.media_unused') %></span><% } %> 29 42 </div> 30 <div style="padding:.5rem .6rem;font-size:13px;">31 <div style="opacity:.7;"><%= it.kb %> KB · <%= it.usedCount %>×</div>32 <div style="display:flex;gap:.4rem;margin-top:.45rem;">33 <button type="button" class=" btn" style="padding:3px 8px;font-size:12px;" data-copy="<%= it.url %>"><%= t('admin.media_copy') %></button>34 <button type="button" class=" btn" style="padding:3px 8px;font-size:12px;background:transparent;border-color:#b4452e;color:#b4452e;" data-del="<%= it.file %>">🗑</button>43 <div class="ax-tile-body"> 44 <div class="ax-tile-meta"><%= it.kb %> KB · <%= it.usedCount %>×</div> 45 <div class="ax-tile-actions"> 46 <button type="button" class="ax-btn ax-btn-sm" data-copy="<%= it.url %>"><%= t('admin.media_copy') %></button> 47 <button type="button" class="ax-btn ax-btn-sm ax-btn-danger" data-del="<%= it.file %>">🗑</button> 35 48 </div> 36 49 </div> -
src/views/pages/admin-playlists.ejs
ra5515b4 r749605a 5 5 const _csrf = (typeof csrfToken !== 'undefined' && csrfToken) || ''; 6 6 %> 7 <%# De gedeelde ax-*-primitieven. MOET boven het eigen style-blok van deze 8 pagina staan: wat hieronder blijft staan wijkt bewust af en hoort dus 9 later te komen, anders wint de partial ervan. %> 10 <%- include('../partials/admin-styles') %> 7 11 <div class="container ax-page"> 8 12 9 < p><a href="/admin" class="btn">← <%= t('nav.admin') %></a></p>13 <%- include('../partials/admin-back') %> 10 14 <%- include('../partials/media-tabs', { active: 'playlists', audioOn: true }) %> 11 15 <header class="ax-header"> … … 83 87 84 88 .ax-page { max-width: 880px; margin: 1.5rem auto 4rem; padding: 0 1rem; } 85 .ax-header { display: flex; align-items: flex-start; gap: 0.75rem; margin-bottom: 1.5rem; }86 .ax-header h1 { font-family: var(--font-display, serif); font-size: 1.75rem; margin: 0 0 0.25rem; }87 .ax-tagline { color: var(--ink-muted, var(--ink-soft)); margin: 0; font-size: 0.9rem; line-height: 1.5; }88 .ax-tagline code { background: var(--paper-2); padding: 0.1em 0.4em; border-radius: 4px; font-size: 0.9em; }89 .ax-back {90 display: inline-flex; align-items: center; justify-content: center;91 width: 40px; height: 40px;92 border: 1px solid var(--rule); border-radius: 8px;93 background: var(--paper); color: var(--ink); text-decoration: none;94 font-size: 1.1rem; flex-shrink: 0; transition: border-color 120ms;95 }96 .ax-back:hover { border-color: var(--accent); }97 98 .ax-actions-bar {99 display: flex;100 margin-bottom: 1rem;101 }102 103 .ax-card {104 background: var(--paper);105 border: 1px solid var(--rule);106 border-radius: 12px;107 padding: 1.25rem;108 margin-bottom: 1rem;109 }110 111 /* ─── Buttons (same primitives as admin-audio) ─────────────────── */112 .ax-btn {113 display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;114 padding: 0.6rem 1.1rem;115 border: 1px solid var(--rule); background: var(--paper-2);116 color: var(--ink);117 font-family: var(--font-ui, system-ui), sans-serif;118 font-size: 0.95rem; font-weight: 500;119 text-decoration: none; border-radius: 8px; cursor: pointer;120 min-height: 44px;121 transition: background 120ms, border-color 120ms;122 -webkit-tap-highlight-color: transparent;123 }124 .ax-btn:hover { border-color: var(--accent); }125 .ax-btn-primary { background: var(--accent); color: white; border-color: var(--accent); }126 .ax-btn-primary:hover { opacity: 0.92; }127 128 .ax-icon-btn {129 display: inline-flex; align-items: center; justify-content: center;130 width: 40px; height: 40px;131 border: 1px solid var(--rule); border-radius: 8px;132 background: var(--paper-2); color: var(--ink);133 cursor: pointer; font-size: 1rem;134 transition: background 120ms, border-color 120ms, color 120ms;135 }136 .ax-icon-btn:hover { border-color: var(--accent); }137 .ax-icon-btn-danger:hover { color: #dc2626; border-color: #dc2626; }138 139 /* ─── Empty state ──────────────────────────────────────────────── */140 .ax-empty { text-align: center; padding: 2rem 1rem; color: var(--ink-muted, var(--ink-soft)); }141 .ax-empty-icon { font-size: 2.5rem; margin-bottom: 0.5rem; opacity: 0.5; }142 .ax-empty p { margin: 0; }143 .ax-empty-sub { font-size: 0.85rem; margin-top: 0.5rem !important; }144 145 /* ─── List items (same as audio tracks) ───────────────────────── */146 .ax-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.6rem; }147 .ax-track {148 display: grid;149 grid-template-columns: 64px 1fr auto;150 gap: 0.85rem; align-items: center;151 padding: 0.85rem;152 background: var(--paper);153 border: 1px solid var(--rule);154 border-radius: 12px;155 transition: border-color 120ms;156 }157 .ax-track:hover { border-color: var(--accent); }158 .ax-track-cover {159 width: 64px; height: 64px; border-radius: 8px;160 background: var(--paper-2);161 display: flex; align-items: center; justify-content: center;162 font-family: var(--font-display, serif);163 font-size: 1.5rem; color: var(--accent);164 flex-shrink: 0;165 }166 .ax-track-meta { min-width: 0; display: flex; flex-direction: column; gap: 0.25rem; }167 .ax-track-title {168 font-family: var(--font-display, serif);169 font-weight: 600; font-size: 1.05rem; color: var(--ink);170 display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap;171 }172 .ax-pill {173 font-family: var(--font-ui);174 font-size: 0.7rem; font-weight: 500;175 color: var(--ink-soft);176 background: var(--paper-2);177 padding: 0.15em 0.55em; border-radius: 999px;178 letter-spacing: 0;179 }180 .ax-track-sub {181 font-size: 0.85rem;182 color: var(--ink-muted, var(--ink-soft));183 display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center;184 }185 .ax-track-sep { opacity: 0.5; }186 .ax-embed {187 display: inline-block;188 background: var(--paper-2);189 padding: 0.15rem 0.5rem;190 border-radius: 4px;191 font-family: var(--font-mono, ui-monospace, monospace);192 font-size: 0.75rem;193 color: var(--ink-soft);194 cursor: pointer; user-select: all;195 margin-top: 0.15rem;196 word-break: break-all;197 align-self: flex-start;198 transition: background 120ms;199 }200 .ax-embed:hover { background: var(--rule); }201 .ax-embed.is-copied { background: rgba(40,160,90,0.2); color: #2a9d5e; }202 203 .ax-track-actions { display: flex; gap: 0.4rem; flex-shrink: 0; }204 89 205 90 /* ─── Mobile ──────────────────────────────────────────────────── */ 206 91 @media (max-width: 480px) { 207 .ax-track {208 grid-template-columns: 56px 1fr;209 grid-template-areas: "cover meta" "actions actions";210 gap: 0.6rem;211 }212 .ax-track-cover { grid-area: cover; width: 56px; height: 56px; }213 .ax-track-meta { grid-area: meta; }214 .ax-track-actions {215 grid-area: actions;216 justify-content: flex-end;217 border-top: 1px solid var(--rule);218 padding-top: 0.5rem;219 margin-top: 0.25rem;220 }221 92 } 222 93 </style> -
src/views/pages/admin-videos.ejs
ra5515b4 r749605a 1 <%# Beheer → Media → Video's (Robins opdracht, 30-7): the videos C2S uploads 2 brought in (Shaer's composer, the help buoy), with usage and cleanup. 3 Same shape as the image page; the preview is the video itself with 4 preload="metadata", so the browser shows the first frame as its 5 thumbnail until real poster generation lands (bead: ffmpeg ontbreekt). %> 6 <div class="admin-wrap" style="max-width:980px;margin:0 auto;padding:1.5rem 1rem;"> 7 <a href="/admin" class="btn" style="margin-bottom:1rem;display:inline-block;">← <%= t('nav.admin') %></a> 8 <h1 style="font-family:var(--display,serif);margin:.2rem 0 1rem;"><%= t('admin.t_media') %></h1> 1 <%# Beheer -> Media -> Video's (Robins opdracht, 30-7): de video's die via C2S 2 binnenkwamen (Shaers composer, de reddingsboei), met gebruik en opruiming. 9 3 4 De voorvertoning is de video zelf met preload="metadata", zodat de browser 5 het eerste beeld als miniatuur toont tot echte poster-generatie landt 6 (bead: ffmpeg ontbreekt). 7 8 Zelfde romp als de andere mediapagina's; stond hiervoor op inline styles. %> 9 <%- include('../partials/admin-styles') %> 10 <div class="container ax-page"> 11 12 <%- include('../partials/admin-back') %> 10 13 <%- include('../partials/media-tabs', { active: 'videos', audioOn: audioOn }) %> 14 <header class="ax-header"> 15 <div> 16 <h1><%= t('admin.t_media') %></h1> 17 <p class="ax-tagline"><%= items.length %> <%= t('admin.videos_count') %></p> 18 </div> 19 </header> 11 20 12 <% if (success) { %><p style="background:rgba(60,160,90,.15);border:1px solid rgba(60,160,90,.4);padding:.5rem .8rem;border-radius:8px;"><%= success %></p><% } %> 13 14 <p style="margin:0 0 1rem;opacity:.75;"><%= items.length %> <%= t('admin.videos_count') %></p> 21 <% if (success) { %><div class="ax-flash ax-flash-ok"><%= success %></div><% } %> 15 22 16 23 <% if (!items.length) { %> 17 <p style="opacity:.7;"><%= t('admin.videos_empty') %></p> 24 <div class="ax-empty"> 25 <div class="ax-empty-icon">🎬</div> 26 <p><%= t('admin.videos_empty') %></p> 27 </div> 18 28 <% } else { %> 19 <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem;">29 <div class="ax-grid" style="--ax-tile:220px;"> 20 30 <% items.forEach(function(it){ %> 21 <div class=" media-card" data-file="<%= it.file %>" style="border:1px solid var(--rule,rgba(128,128,128,.25));border-radius:10px;overflow:hidden;background:var(--paper-2,rgba(128,128,128,.06));">22 <div style="position:relative;aspect-ratio:16/10;background:#000;">23 <video src="<%= it.url %>" preload="metadata" controls playsinline style=" width:100%;height:100%;object-fit:contain;display:block;"></video>24 <% if (!it.usedCount) { %><span style="position:absolute;top:6px;left:6px;background:#b4452e;color:#fff;border-radius:6px;padding:1px 6px;font-size:11px;z-index:1;"><%= t('admin.media_unused') %></span><% } %>31 <div class="ax-tile media-card" data-file="<%= it.file %>"> 32 <div class="ax-tile-media" style="aspect-ratio:16/10;background:#000;"> 33 <video src="<%= it.url %>" preload="metadata" controls playsinline style="height:100%;object-fit:contain;"></video> 34 <% if (!it.usedCount) { %><span class="ax-tile-badge"><%= t('admin.media_unused') %></span><% } %> 25 35 </div> 26 <div style="padding:.5rem .6rem;font-size:13px;">27 <div style="opacity:.7;word-break:break-all;"><%= it.file %></div>28 <div style="opacity:.7;"><%= it.kb %> KB · <%= it.usedCount %>×</div>29 <div style="display:flex;gap:.4rem;margin-top:.45rem;">30 <button type="button" class=" btn" style="padding:3px 8px;font-size:12px;" data-copy="<%= it.url %>"><%= t('admin.media_copy') %></button>36 <div class="ax-tile-body"> 37 <div class="ax-tile-meta" style="word-break:break-all;"><%= it.file %></div> 38 <div class="ax-tile-meta"><%= it.kb %> KB · <%= it.usedCount %>×</div> 39 <div class="ax-tile-actions"> 40 <button type="button" class="ax-btn ax-btn-sm" data-copy="<%= it.url %>"><%= t('admin.media_copy') %></button> 31 41 <% if (!it.usedCount) { %> 32 <button type="button" class=" btn" style="padding:3px 8px;font-size:12px;background:transparent;border-color:#b4452e;color:#b4452e;" data-del="<%= it.file %>">🗑</button>42 <button type="button" class="ax-btn ax-btn-sm ax-btn-danger" data-del="<%= it.file %>">🗑</button> 33 43 <% } %> 34 44 </div> -
src/views/partials/media-tabs.ejs
ra5515b4 r749605a 1 <%# Shared Media tab bar (Afbeeldingen / Audio / Playlists). Params: active = 'images'|'videos'|'audio'|'playlists'; audioOn (bool, default true). %> 1 <%# Gedeelde tabbalk voor Media (Afbeeldingen / Video's / Audio / Playlists / 2 Luisteraars). 3 4 Params: active = 'images'|'videos'|'audio'|'playlists'|'listeners'; 5 audioOn (bool, standaard true). 6 7 De opmaak stond hier volledig inline en zit nu in partials/admin-styles.ejs 8 bij de rest van de ax-*-primitieven. Een gedeeld element dat zijn eigen 9 opmaak meedroeg kon niet meebewegen met de pagina's eromheen -- en dat was 10 zichtbaar: de balk zag er hetzelfde uit op vijf pagina's die verder van 11 twee verschillende ontwerpen waren. 12 13 De actieve tab is `aria-current="page"` en niet een klasse: dat is wat een 14 schermlezer nodig heeft, en de opmaak kan er net zo goed op haken. %> 2 15 <% var _a = (typeof audioOn === 'undefined') ? true : audioOn; %> 3 <nav class=" media-tabs" aria-label="Media" style="display:flex;gap:.4rem;margin:0 0 1.25rem;border-bottom:1px solid var(--rule,rgba(128,128,128,.3));flex-wrap:wrap;">4 <a href="/admin/media" class=" btn" style="border-radius:8px 8px 0 0;<%= active === 'images' ? 'border-bottom:2px solid var(--accent,#e8b04b);font-weight:600;' : 'opacity:.8;' %>"><%= t('admin.media_images') %></a>5 <a href="/admin/media/videos" class=" btn" style="border-radius:8px 8px 0 0;<%= active === 'videos' ? 'border-bottom:2px solid var(--accent,#e8b04b);font-weight:600;' : 'opacity:.8;' %>"><%= t('admin.media_videos') %></a>16 <nav class="ax-tabs" aria-label="Media"> 17 <a href="/admin/media" class="ax-tab"<%- active === 'images' ? ' aria-current="page"' : '' %>><%= t('admin.media_images') %></a> 18 <a href="/admin/media/videos" class="ax-tab"<%- active === 'videos' ? ' aria-current="page"' : '' %>><%= t('admin.media_videos') %></a> 6 19 <% if (_a) { %> 7 <a href="/admin/audio" class="btn" style="border-radius:8px 8px 0 0;<%= active === 'audio' ? 'border-bottom:2px solid var(--accent,#e8b04b);font-weight:600;' : 'opacity:.8;' %>"><%= t('admin.b_audio') %></a> 8 <a href="/admin/playlists" class="btn" style="border-radius:8px 8px 0 0;<%= active === 'playlists' ? 'border-bottom:2px solid var(--accent,#e8b04b);font-weight:600;' : 'opacity:.8;' %>"><%= t('admin.b_playlists') %></a> 20 <a href="/admin/audio" class="ax-tab"<%- active === 'audio' ? ' aria-current="page"' : '' %>><%= t('admin.b_audio') %></a> 21 <a href="/admin/playlists" class="ax-tab"<%- active === 'playlists' ? ' aria-current="page"' : '' %>><%= t('admin.b_playlists') %></a> 22 <a href="/admin/listeners" class="ax-tab"<%- active === 'listeners' ? ' aria-current="page"' : '' %>><%= t('admin.b_listeners') %></a> 9 23 <% } %> 10 24 </nav> -
src/views/partials/playlist-editor.ejs
ra5515b4 r749605a 99 99 } 100 100 .pl-field { display: flex; flex-direction: column; gap: .25rem; } 101 /* `display: flex` wint van het hidden-attribuut, dus zonder deze regel doet 102 [hidden] op een .pl-field NIETS -- het veld blijft gewoon staan. De 103 uitgavevelden schakelen daarop (album versus afspeellijst). */ 104 .pl-field[hidden] { display: none; } 101 105 .pl-field-full { grid-column: 1 / -1; } 102 106 .pl-field > span { … … 280 284 </style> 281 285 282 <%# Het script staat in assets/js/mod/playlist-editor.js; de gegevens via partials/page-data.ejs (shaer-bqr). %> 286 <%# Het script staat in assets/js/mod/playlist-editor.js. 287 288 EIGEN GEGEVENSBLOK, niet dat van partials/page-data.ejs. pageData() pakt met 289 querySelector EEN blok, en deze modal wordt ingesloten op pagina's die er al 290 zelf een hebben (admin-playlists, post-edit) -- een tweede blok zou daar 291 stilzwijgend genegeerd worden en dan stond de hele modal weer in het 292 Nederlands. Eigen kenmerk, eigen lezer. 293 294 De teksten stonden hier tot 16-8 hard in het script. Dat viel niet op omdat 295 het toevallig de brontaal was: een Engelse of Duitse beheerder kreeg een 296 Nederlandse modal midden in een vertaalde pagina. %> 297 <script type="application/json" data-playlist-editor-i18n><%- JSON.stringify({ 298 titel: t('ple.titel'), artiest: t('ple.artiest'), jaar: t('ple.jaar'), type: t('ple.type'), 299 k_album: t('ple.k_album'), k_playlist: t('ple.k_playlist'), 300 uitgave: t('ple.uitgave'), mb_release: t('ple.mb_release'), 301 cover: t('ple.cover'), cover_kies: t('ple.cover_kies'), cover_url: t('ple.cover_url'), 302 tracks_in: t('ple.tracks_in'), sleep_hint: t('ple.sleep_hint'), 303 beschikbaar: t('ple.beschikbaar'), zoek: t('ple.zoek'), geen_res: t('ple.geen_res'), 304 leeg_sel: t('ple.leeg_sel'), 305 t_edit: t('ple.t_edit'), t_new: t('ple.t_new'), dialoog: t('ple.dialoog'), 306 sluiten: t('ple.sluiten'), annuleren: t('ple.annuleren'), 307 opslaan: t('ple.opslaan'), aanmaken: t('ple.aanmaken'), 308 bezig_opslaan: t('ple.bezig_opslaan'), 309 versleep: t('ple.versleep'), verwijder: t('ple.verwijder'), 310 geen_audio: t('ple.geen_audio'), e_geen_tracks: t('ple.e_geen_tracks'), 311 e_tracks: t('ple.e_tracks'), e_opslaan: t('ple.e_opslaan'), e_mislukt: t('ple.e_mislukt'), 312 e_alleen_afb: t('ple.e_alleen_afb'), bezig: t('ple.bezig'), e_upload: t('ple.e_upload'), 313 }).replace(/</g, '\\u003c') %></script> -
test/activitypub-as2.test.js
ra5515b4 r749605a 22 22 // adds a term we adopt). JSON-LD keywords included. 23 23 const AS2 = new Set([ 24 '@context', '@id', '@type', 24 // `@container` hoort in dit rijtje omdat de verzamelaar hieronder OOK door 25 // @context zelf loopt, en een termdefinitie mag naast @id en @type ook een 26 // container dragen (artist_credit is bij Funkwhale een @list). Het is een 27 // JSON-LD-sleutelwoord, geen vocabulaire -- die laatste is wat deze test moet 28 // vangen. Zonder deze regel faalde hij op een correcte declaratie. 29 '@context', '@id', '@type', '@container', 25 30 'id', 'type', 'actor', 'object', 'target', 'to', 'cc', 26 31 'content', 'name', 'summary', 'url', 'href', 'mediaType',
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)