Changes in / [a5515b4:749605a] in Klonkt


Ignore:
Files:
16 added
17 edited

Legend:

Unmodified
Added
Removed
  • src/assets/js/mod/playlist-editor.js

    ra5515b4 r749605a  
    66
    77import { 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 */
     20let _T = null;
     21function 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}
    830
    931(function() {
     
    5476      if (Array.isArray(j.tracks)) tracks = j.tracks;
    5577    } catch (e) {
    56       alert('Tracks ophalen mislukt');
     78      alert(T('e_tracks'));
    5779      return;
    5880    }
    5981    if (!tracks.length) {
    60       alert('Geen audio-tracks beschikbaar. Upload eerst tracks via Admin → Audio.');
     82      alert(T('e_geen_tracks'));
    6183      return;
    6284    }
    6385
    6486    // 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: [] };
    6688    if (isEdit && opts.id) {
    6789      try {
     
    7597    backdrop.className = 'pl-modal-backdrop';
    7698    backdrop.innerHTML = `
    77       <div class="pl-modal" role="dialog" aria-label="Playlist editor">
     99      <div class="pl-modal" role="dialog" aria-label="${T('dialoog')}">
    78100        <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>
    81103        </div>
    82104        <div class="pl-modal-body">
     
    85107              <div class="pl-meta-grid">
    86108                <label class="pl-field pl-field-full">
    87                   <span>Titel *</span>
     109                  <span>${T('titel')}</span>
    88110                  <input type="text" id="pli-title" maxlength="200" autofocus value="${esc(initial.title)}">
    89111                </label>
    90112                <label class="pl-field">
    91                   <span>Artiest</span>
     113                  <span>${T('artiest')}</span>
    92114                  <input type="text" id="pli-artist" maxlength="200" value="${esc(initial.artist)}">
    93115                </label>
    94116                <label class="pl-field">
    95                   <span>Jaar</span>
     117                  <span>${T('jaar')}</span>
    96118                  <input type="number" id="pli-year" min="1900" max="2099" value="${initial.year || ''}">
    97119                </label>
    98120                <label class="pl-field">
    99                   <span>Type</span>
     121                  <span>${T('type')}</span>
    100122                  <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>
    103125                  </select>
    104126                </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>
    105137                <div class="pl-field pl-field-full">
    106                   <span>Cover</span>
     138                  <span>${T('cover')}</span>
    107139                  <div class="pl-cover-row">
    108140                    <span class="pl-cover-thumb" id="pli-cover-thumb">
     
    111143                        : `<span class="pl-cover-empty">🎨</span>`}
    112144                    </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">
    114146                  </div>
    115147                  <div class="pl-cover-upload-row">
    116148                    <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>
    118150                    <span class="pl-cover-status" id="pli-cover-status"></span>
    119151                  </div>
     
    121153              </div>
    122154              <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>
    125157              </div>
    126158              <div id="pli-selected" class="pl-selected-list"></div>
    127159            </div>
    128160            <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">
    131163              <div id="pli-available" class="pl-available-list"></div>
    132164            </div>
     
    134166        </div>
    135167        <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>
    137169          <button type="button" class="btn btn-primary" id="pli-save" disabled>
    138             ${isEdit ? 'Opslaan' : 'Aanmaken'}
     170            ${isEdit ? T('opslaan') : T('aanmaken')}
    139171          </button>
    140172        </div>
     
    157189    const yearEl = $('#pli-year');
    158190    const kindEl = $('#pli-kind');
     191    const releaseEl = $('#pli-release-date');
     192    const mbReleaseEl = $('#pli-mb-release');
    159193    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();
    160205    const coverThumb = $('#pli-cover-thumb');
    161206    const saveBtn = $('#pli-save');
     
    176221    // requireAuth) dat een /media/-URL teruggeeft — dat heeft GEEN playlist-id
    177222    // 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.
    179224    const coverFileInput = $('#pli-cover-file');
    180225    const coverPickBtn   = $('#pli-cover-pick');
     
    187232        if (!file) return;
    188233        if (!/^image\//.test(file.type)) {
    189           coverStatus.textContent = 'Alleen afbeeldingen';
     234          coverStatus.textContent = T('e_alleen_afb');
    190235          coverStatus.className = 'pl-cover-status is-error';
    191236          return;
    192237        }
    193         coverStatus.textContent = 'Uploaden…';
     238        coverStatus.textContent = T('bezig');
    194239        coverStatus.className = 'pl-cover-status';
    195240        const fd = new FormData();
     
    200245          );
    201246          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'));
    203248          const url = j.url || '';
    204249          coverEl.value = url;
     
    209254          coverStatus.className = 'pl-cover-status is-ok';
    210255        } catch (err) {
    211           coverStatus.textContent = 'Mislukt: ' + err.message;
     256          coverStatus.textContent = T('e_mislukt') + err.message;
    212257          coverStatus.className = 'pl-cover-status is-error';
    213258        }
     
    222267      countEl.textContent = selected.length;
    223268      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>';
    225270        updateSaveBtn();
    226271        return;
     
    233278          : `<span class="pl-row-cover pl-row-cover-empty">♪</span>`;
    234279        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>
    236281          <span class="pl-row-num">${i + 1}</span>
    237282          ${cover}
     
    240285            ${t.artist ? `<span class="pl-row-artist">${esc(t.artist)}</span>` : ''}
    241286          </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>
    243288        </div>`;
    244289      }).join('');
     
    263308      });
    264309      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>';
    266311        return;
    267312      }
     
    274319          ? `<span class="pl-row-cover"><img src="${esc(t.cover)}" alt=""></span>`
    275320          : `<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')}"`}>
    277322          ${cover}
    278323          <span class="pl-row-info">
     
    368413      const orig = saveBtn.textContent;
    369414      saveBtn.disabled = true;
    370       saveBtn.textContent = 'Opslaan...';
     415      saveBtn.textContent = T('bezig_opslaan');
    371416
    372417      const payload = {
     
    376421        cover:  coverEl.value.trim(),
    377422        kind:   kindEl.value === 'playlist' ? 'playlist' : 'album',
     423        release_date:  releaseEl.value.trim(),
     424        mb_release_id: mbReleaseEl.value.trim(),
    378425        tracks: selected.slice(),
    379426      };
     
    385432        const j = await api('POST', url, payload);
    386433        if (!j.ok) {
    387           alert('Opslaan mislukt: ' + (j.error || 'onbekend'));
     434          alert(T('e_opslaan') + (j.error || 'onbekend'));
    388435          saveBtn.disabled = false;
    389436          saveBtn.textContent = orig;
     
    396443        }
    397444      } catch (err) {
    398         alert('Opslaan mislukt: ' + err.message);
     445        alert(T('e_opslaan') + err.message);
    399446        saveBtn.disabled = false;
    400447        saveBtn.textContent = orig;
  • src/config/database.js

    ra5515b4 r749605a  
    140140  ensureColumn('sites', 'mb_artist_id', 'TEXT');
    141141  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');
    142156  ensureColumn('audio_tracks', 'cover_url', 'TEXT');
    143157  ensureColumn('audio_tracks', 'album', 'TEXT');
     
    386400      private_pem TEXT NOT NULL,
    387401      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
    389424    CREATE TABLE IF NOT EXISTS ap_followers (
    390425      id INTEGER PRIMARY KEY AUTOINCREMENT,
  • src/routes/activitypub.js

    ra5515b4 r749605a  
    11151115     ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
    11161116  ).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) }));
    11181118});
    11191119
     
    11301130  const site = publicSite(req.params.slug);
    11311131  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) }));
    11331133});
    11341134
     
    11391139  const site = publicSite(req.params.slug);
    11401140  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.
     1158router.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.
     1166router.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) }));
    11421170});
    11431171
     
    11741202  const site = publicSite(req.params.slug);
    11751203  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 = ?')
    11771205    .get(req.params.id, site.id);
    11781206  if (!pl) return res.status(404).end();
     
    13611389router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
    13621390  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  }
    13641402});
    13651403
  • src/routes/circle.js

    ra5515b4 r749605a  
    1616  return typeof u === 'string' && /^https?:\/\//i.test(u) ? u : null;
    1717}
     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 */
    1832function 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 []; }
    2037}
    2138// The cover image + (separately) a cover video from a remote note's media. NEVER use a video/audio
  • src/server.js

    ra5515b4 r749605a  
    3030import adminAudioRoutes from './routes/admin-audio.js';
    3131import adminPlaylistsRoutes from './routes/admin-playlists.js';
     32import adminListenersRoutes from './routes/admin-listeners.js';
    3233import adminSitesRoutes from './routes/admin-sites.js';
    3334import adminUsersRoutes from './routes/admin-users.js';
     
    396397  app.use('/admin/audio', adminAudioRoutes);
    397398  app.use('/admin/playlists', adminPlaylistsRoutes);
     399  app.use('/admin/listeners', adminListenersRoutes);
    398400}
    399401app.use('/admin/media', adminMediaRoutes); // image library + cleanup (works in lite mode too)
  • src/services/ActivityPubService.js

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

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

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

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

    ra5515b4 r749605a  
    1414
    1515import db from '../../config/database.js';
    16 import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection } from '../ap-core.js';
     16import { AP_CONTEXT, PUBLIC, actorId, noteId, safeUrl, guessMediaType, buildHashtagList, pagedCollection, isMbid } from '../ap-core.js';
    1717import { afleidenUitInsluitingen, ingeslotenPlaylists } from '../../assets/js/shared/post-music-type.js';
     18// De luisteraars horen bij de muziekkant; hier doorgegeven zodat
     19// ActivityPubService niet in een submap hoeft te grijpen.
     20export * as luisteraars from './luisteraars.js';
    1821
    1922// m.size hoort erbij voor de RSS-enclosure: die eist een lengte in bytes.
     
    133136}
    134137
     138/**
     139 * De artiest-credit, gedeeld door track en album (shaer-3f8a / shaer-756s).
     140 *
     141 * De ENTITEIT is de site-actor: een echt, opvraagbaar adres. De credittekst --
     142 * de artiestkolom van de track of van de uitgave -- gaat naar `credit`, want
     143 * daar verwacht hun model hem. Er een id per artiestnaam van maken zou
     144 * identiteit uit een string zijn, en dat is de fout die we bij albums juist
     145 * vermijden.
     146 *
     147 * Eén functie voor beide, zodat een track en het album waar hij op staat nooit
     148 * een verschillende artiest kunnen krijgen door twee keer hetzelfde te bouwen.
     149 */
     150function artistCredit(base, site, creditTekst, wanneer) {
     151  const artiest = {
     152    type: 'Artist',
     153    id: actorId(base, site.slug),
     154    name: site.title || site.slug,
     155    published: site.created_at ? new Date(site.created_at).toISOString() : wanneer,
     156  };
     157  if (isMbid(site.mb_artist_id)) artiest.musicbrainzId = String(site.mb_artist_id).trim().toLowerCase();
     158  return [{
     159    type: 'ArtistCredit',
     160    id: `${actorId(base, site.slug)}#artist-credit`,
     161    published: artiest.published,
     162    artist: artiest,
     163    ...(creditTekst ? { credit: creditTekst } : {}),
     164  }];
     165}
     166
    135167export function buildTrackAudio(base, site, r, opts = {}) {
    136168  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
     
    168200    url: [...(post ? [{ type: 'Link', href: `${base}/${post.slug}`, mediaType: 'text/html' }] : []), bestand],
    169201  };
     202  // De bak waar dit bestand in hangt (shaer-0nh). Voor Funkwhale is dit het
     203  // haakje waaraan een upload komt te zitten; zonder dit veld blijft een track
     204  // daar een naam zonder geluid.
     205  a.library = libraryId(base, site);
    170206  if (r.artist) a.summary = r.artist;              // artiest als summary: kaal AS2, geen eigen vocab
    171207  // AS2-kern `context`: "de context waarbinnen dit object bestaat". Voor een
     
    175211  // van te maken.
    176212  if (post) a.context = noteId(base, post.id);
     213  // Het NUMMER, los van dit bestand (shaer-3f8a, spoor B). Funkwhale en
     214  // Emissary lezen allebei `fw:track`, en petitminion noemde het ontbreken
     215  // ervan als eerste wat hem opviel aan onze objecten.
     216  //
     217  // EIGEN ID MET #track, en niet hetzelfde id als de Audio. Emissary hergebruikt
     218  // daar het object-id, maar dan zijn in JSON-LD de Audio en de Track EEN knoop
     219  // met twee typen -- en een bestand is geen werk. Dat verschil moeten we straks
     220  // toch maken, want een album verzamelt nummers en geen mp3's. Een fragment is
     221  // een geldige IRI en wijst naar hetzelfde document.
     222  //
     223  // GEEN `album`. Dat veld is bij hen een URI naar een Album-object en bij ons
     224  // een tekstkolom; er hier een adres van maken zou een ding beloven dat niet
     225  // bestaat. Zie shaer-k37k -- dat is de keuze die daarvoor eerst moet vallen.
     226  //
     227  // WIE IS DE ARTIEST. Hun Artist is een ENTITEIT met een id, en bij ons is een
     228  // artiest een tekstkolom op de track. Die twee verzoenen we zo: de entiteit
     229  // is de site-ACTOR -- een echt, opvraagbaar adres, het account dat dit
     230  // uitbrengt -- en de tekst uit de kolom gaat naar `credit`, want dat is
     231  // precies waar hun model de credittekst verwacht.
     232  //
     233  // Dat is eerlijk en het is niet nieuw: open.audio leidde op 13-8 al zelf een
     234  // artist_credit af uit onze attributedTo. We maken alleen expliciet wat daar
     235  // toch al gebeurde.
     236  //
     237  // DE GRENS ERVAN: brengt een site werk van iemand anders uit, dan zegt dit
     238  // dat de site de artiest is. Dat stond al in attributedTo, dus we maken het
     239  // niet erger -- maar het is wel de reden dat we hier geen id per artiestnaam
     240  // verzinnen. Identiteit uit een string is dezelfde fout als bij het album
     241  // (shaer-756s).
     242  const wanneer = r.created_at ? new Date(r.created_at).toISOString()
     243    : (site.created_at ? new Date(site.created_at).toISOString() : new Date(0).toISOString());
     244
     245  a.track = {
     246    type: 'Track',
     247    id: `${a.id}#track`,
     248    name: a.name,
     249    published: wanneer,
     250    ...(Number(r.position) ? { position: Number(r.position) } : {}),
     251    artist_credit: artistCredit(base, site, r.artist, wanneer),
     252  };
     253  // De uitgave waar dit nummer op staat, INGESLOTEN (shaer-756s, stap 2).
     254  // `albums` mag expliciet null zijn: dan is er niets op te zoeken.
     255  const uitgave = opts.albums !== undefined
     256    ? (opts.albums && opts.albums.get(r.id)) || null
     257    : ((site.id && trackAlbums(site.id).get(r.id)) || null);
     258  if (uitgave) {
     259    a.track.album = buildAlbumObject(base, site, uitgave);
     260    // Ook op het Audio-object zelf, als URI. Funkwhale 2.0 en Emissary doen dat
     261    // allebei, en het scheelt een lezer het uitpakken van de track.
     262    a.album = a.track.album.id;
     263  }
    177264  if (r.duration) a.duration = `PT${Math.round(r.duration)}S`;
    178265  if (r.created_at) a.published = new Date(r.created_at).toISOString();
     
    213300}
    214301
     302/** Het AS2-id van de bibliotheek van een site. */
     303export function libraryId(base, site) {
     304  return `${actorId(base, site.slug)}/library`;
     305}
     306
     307/**
     308 * De site als Funkwhale-LIBRARY (skelet).
     309 *
     310 * WAAROM DIT GEEN DIALECT IS ZOALS track EN ArtistCredit DAT WEL ZIJN. Die twee
     311 * vragen entiteiten waar wij tekst hebben; hiervoor hoeven we niets te
     312 * verzinnen. Een library is precies wat er al staat: onze open tracks, met een
     313 * echte telling en een echt id.
     314 *
     315 * WAAROM HET NODIG IS, gemeten op 13-8. open.audio heeft onze vier tracks
     316 * binnengehaald langs de AP-weg -- met ONZE track-id's, en met een artist_credit
     317 * dat Funkwhale zelf uit onze attributedTo afleidde. Maar `uploads` is leeg en
     318 * `is_playable` false. Bij hen hangt een upload aan een library; zonder library
     319 * is er geen bak om het bestand in te hangen. Het audiobestand zelf is wel
     320 * gewoon op te halen (200, audio/mpeg, ook anoniem) -- ze hebben het niet
     321 * geprobeerd.
     322 *
     323 * SKELET, en dat woord is letterlijk bedoeld. Dit is de vorm uit hun docs:
     324 * type, id, name, followers, totalItems, first, last, plus attributedTo en
     325 * summary. Wat er NIET is: de volg-afhandeling. Onze bibliotheek is openbaar --
     326 * elke track erin heeft fedi_open -- dus er valt niets goed te keuren. Komt er
     327 * ooit een besloten variant, dan hoort daar het Follow/Accept-werk bij.
     328 */
     329export function buildLibrary(base, site, rows, { page = false } = {}) {
     330  const id = libraryId(base, site);
     331  const hostPosts = site.id ? trackHostPosts(site.id) : null;
     332  const albums = site.id ? trackAlbums(site.id) : null;
     333  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts, albums }));
     334  return pagedCollection(id, items, {
     335    page,
     336    // Een platenkast is geen tijdlijn: `Collection`, niet `OrderedCollection`.
     337    // Funkwhale's LibrarySerializer accepteert ook alleen die twee typen
     338    // (as:Collection of fw:Library) en zijn CollectionPageSerializer alleen
     339    // `CollectionPage` met `items`.
     340    ongeordend: true,
     341    extra: {
     342      type: 'Library',
     343      name: site.title || site.slug,
     344      attributedTo: actorId(base, site.slug),
     345      // WAAROM DIT VELD ER MOET STAAN. Funkwhale's LibrarySerializer noemt
     346      // `audience` optioneel, maar zijn create() doet er meteen
     347      // `privacy[validated_data["audience"]]` mee -- zonder de sleutel is dat
     348      // een KeyError en geeft hun server een 500. Dat is wat open.audio op 15-8
     349      // teruggaf toen Robin onze library-URI daar opzocht.
     350      //
     351      // Het is bovendien gewoon waar: alles hierin is fedi_open, dus openbaar.
     352      // Bij hen is dit precies het verschil tussen privacy_level 'everyone' en
     353      // 'me' -- oftewel of onze nummers daar afspeelbaar zijn.
     354      audience: 'https://www.w3.org/ns/activitystreams#Public',
     355      // Vereist volgens hun docs. Openbaar, dus de telling is eerlijk en de
     356      // lijst blijft leeg -- wie ons volgt volgt de ACTOR, niet de bak.
     357      followers: `${id}/followers`,
     358      ...(site.description ? { summary: String(site.description).slice(0, 500) } : {}),
     359    },
     360  });
     361}
     362
    215363/** De collectie van alle open tracks van een site (shaer-0nh, stap 3). */
    216 export function buildTrackCollection(base, site, rows) {
     364export function buildTrackCollection(base, site, rows, { page = false } = {}) {
    217365  // Eén zoekopdracht voor alle rijen samen; zie trackHostPosts.
    218366  const posts = site.id ? trackHostPosts(site.id) : null;
    219   const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts }));
    220   return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { extra: { attributedTo: actorId(base, site.slug) } });
     367  const albums = site.id ? trackAlbums(site.id) : null;
     368  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { hostPosts: posts, albums }));
     369  return pagedCollection(`${actorId(base, site.slug)}/tracks`, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
    221370}
    222371
     
    263412// collectie zelf, want ook een lijst mag niet verklappen wat er achter de
    264413// poort staat.
    265 export function listPlaylistsAP(base, site, enriched) {
     414export function listPlaylistsAP(base, site, enriched, { page = false } = {}) {
    266415  const rows = db.prepare(
    267416    'SELECT id, title, artist, year, cover_url FROM playlists WHERE site_id = ? ORDER BY created_at, id'
     
    276425    return stub;
    277426  });
    278   return pagedCollection(colId, items, { extra: { attributedTo: actorId(base, site.slug) } });
     427  return pagedCollection(colId, items, { page, extra: { attributedTo: actorId(base, site.slug) } });
     428}
     429
     430/**
     431 * Bij welke UITGAVE hoort een track? (shaer-756s, stap 2)
     432 *
     433 * Alleen playlists met kind='album' tellen: een mixtape is geen uitgave, en dat
     434 * onderscheid is precies wat de keuze album/playlist betekent. Zit een track in
     435 * twee albums, dan wint de oudste -- willekeurig maar STABIEL, en dat is wat
     436 * telt: een id dat per ophaalactie verspringt is erger dan een id dat niet de
     437 * mooiste keuze is.
     438 *
     439 * Eén zoekopdracht voor alle rijen samen, zoals trackHostPosts. Per track
     440 * vragen wordt bij tweehonderd nummers tweehonderd zoekopdrachten.
     441 */
     442export function trackAlbums(siteId) {
     443  const rijen = db.prepare(`
     444    SELECT pt.track_id AS tid, p.id, p.title, p.artist, p.year, p.cover_url,
     445           p.release_date, p.mb_release_id, p.created_at
     446      FROM playlist_tracks pt
     447      JOIN playlists p ON p.id = pt.playlist_id
     448     WHERE p.site_id = ? AND p.kind = 'album'
     449     ORDER BY p.created_at, p.id
     450  `).all(siteId);
     451  const uit = new Map();
     452  for (const r of rijen) if (!uit.has(r.tid)) uit.set(r.tid, r);
     453  // De post die deze plaat uitbrengt, EEN keer per album opgezocht en niet per
     454  // track: uitgavePost() doet er echt werk voor (hij leest de typering van de
     455  // post) en een site heeft veel meer nummers dan platen.
     456  //
     457  // WAAROM DIT ERBIJ MOET: buildPlaylistCollection laat leenVanPost de naam van
     458  // de post overnemen -- de post IS de uitgave. Zonder dezelfde lening hier zou
     459  // het ingesloten Album "Cartoon Epic" heten en zijn eigen URI "Geen koffie,
     460  // wel thee!". Een id met twee namen, en dat is precies wat op 16-8 uit de
     461  // meting rolde.
     462  const perAlbum = new Map();
     463  for (const r of uit.values()) {
     464    if (perAlbum.has(r.id)) continue;
     465    perAlbum.set(r.id, uitgavePost(siteId, r.id));
     466  }
     467  for (const r of uit.values()) r._post = perAlbum.get(r.id) || null;
     468  return uit;
     469}
     470
     471/**
     472 * Een uitgave als `fw:Album`.
     473 *
     474 * INGESLOTEN EN NIET ALS URI, en dat is het hele punt van deze stap. Funkwhale's
     475 * TrackSerializer heeft `album = AlbumSerializer()` -- een object met name,
     476 * published en een eigen artist_credit. Een kale URI expandeert naar een knoop
     477 * met alleen een @id en valt daar dus af. Emissary stuurt precies zo'n kale URI,
     478 * en dat is waarom hun tracks bij Funkwhale net zo goed stranden.
     479 *
     480 * Het `id` is de bestaande playlist-collectie: dereferenceerbaar, en het is
     481 * werkelijk hetzelfde ding. We verzinnen geen tweede adres voor iets dat er al
     482 * een heeft.
     483 */
     484export function buildAlbumObject(base, site, pl) {
     485  if (!pl) return null;
     486  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
     487  // WANNEER IS DEZE PLAAT GEPUBLICEERD. De post die hem uitbrengt gaat voor, en
     488  // niet als noodgreep maar omdat hij het beter weet: playlists.created_at is
     489  // het moment waarop de RIJ is aangemaakt, en dat kan weken eerder zijn terwijl
     490  // je nog aan het samenstellen was. AS2 `published` vraagt wanneer het object
     491  // openbaar werd, en dat is de post.
     492  //
     493  // GEEN epoch als laatste terugval. `published` is bij hen verplicht, maar 1970
     494  // is een ANTWOORD en geen ontbrekend veld -- en dat is erger: een lezer kan een
     495  // gat opmerken, een leugen niet. Zo kwam op 16-8 de route boven water die id,
     496  // title, artist, year, cover_url en kind selecteerde en de rest niet.
     497  //
     498  // OOK VOOR `released`, en daar had ik het eerst mis (Robin, 16-8). Mijn
     499  // bezwaar was: post je vandaag een plaat uit 2018, dan beweert dit dat hij
     500  // vandaag uitkwam. Dat gebeurt ook -- maar bij de meeste Klonkt-sites IS de
     501  // post het uitbrengen, en GEEN datum is slechter dan een datum die op het
     502  // gewone geval klopt. Het handmatige veld is precies het gereedschap voor de
     503  // uitzondering: bij een heruitgave vul je hem in en die wint.
     504  const postDatum = (pl._post && pl._post.uit_wanneer) ? new Date(pl._post.uit_wanneer) : null;
     505  const wanneer = postDatum ? postDatum.toISOString()
     506    : (pl.created_at ? new Date(pl.created_at).toISOString() : null);
     507  // Dezelfde lening als in buildPlaylistCollection: de post die de plaat
     508  // uitbrengt geeft zijn titel, en de eigen titel blijft als alsoKnownAs staan.
     509  const titel = (pl._post && pl._post.title) || pl.title;
     510  const album = {
     511    type: 'Album',
     512    id: `${actorId(base, site.slug)}/playlists/${pl.id}`,
     513    name: titel,
     514    ...(wanneer ? { published: wanneer } : {}),
     515    attributedTo: actorId(base, site.slug),
     516    artist_credit: artistCredit(base, site, pl.artist, wanneer || new Date().toISOString()),
     517  };
     518  if (titel !== pl.title) album.alsoKnownAs = pl.title;
     519  // Het ingevulde veld wint altijd; anders de DAG waarop de post verscheen.
     520  // `year` vult hem nog steeds niet aan, en dat is geen inconsequentie: een
     521  // jaartal is geen dag, terwijl de postdatum een gebeurtenis is die werkelijk
     522  // heeft plaatsgevonden. Het verschil is verzinnen versus afleiden.
     523  if (pl.release_date) album.released = pl.release_date;
     524  else if (postDatum) album.released = postDatum.toISOString().slice(0, 10);
     525  if (pl.mb_release_id) album.musicbrainzId = pl.mb_release_id;
     526  const hoes = abs(pl.cover_url || null);
     527  if (hoes) album.image = { type: 'Image', mediaType: guessMediaType(hoes), url: hoes };
     528  return album;
    279529}
    280530
     
    287537  // De hoes van de playlist dient als terugval voor een track zonder eigen hoes.
    288538  const hostPosts = site.id ? trackHostPosts(site.id) : null;
    289   const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts }));
     539  const albums = site.id ? trackAlbums(site.id) : null;
     540  // EEN keer opzoeken en twee keer gebruiken: het Album leent er zijn datums en
     541  // titel van, leenVanPost onderaan zijn tekst en tags. Twee losse aanroepen
     542  // zouden niet alleen dubbel werk zijn maar ook uiteen kunnen lopen -- en dan
     543  // staat er weer iets anders op het ingesloten object dan op zijn eigen URI.
     544  const post = site.id ? uitgavePost(site.id, playlist.id) : null;
     545  const items = (rows || []).map((r) => buildTrackAudio(base, site, r, { coverFallback: playlist.cover_url || null, hostPosts, albums }));
    290546  const out = pagedCollection(`${actorId(base, site.slug)}/playlists/${playlist.id}`, items, {
    291547    extra: { name: playlist.title, attributedTo: actorId(base, site.slug) },
     
    298554  const cover = abs(playlist.cover_url || null);
    299555  if (cover) out.icon = { type: 'Image', mediaType: guessMediaType(cover), url: cover };
    300   return leenVanPost(base, site, out, uitgavePost(site.id, playlist.id));
     556
     557  // Is dit een UITGAVE, dan draagt deze collectie ook de albumvelden
     558  // (shaer-756s, stap 2): het is het adres waar track.album naar wijst, en dan
     559  // hoort hier hetzelfde te staan als in het ingesloten object.
     560  //
     561  // `type` blijft OrderedCollection, EN BLIJFT EEN STRING. Er stond hier even
     562  // ['OrderedCollection', 'Album'] -- geldig AS2, en het is ook werkelijk
     563  // allebei -- maar een bestaande test viel erover, en die test had gelijk: een
     564  // lezer die `type` als tekst uitpakt (Shaer doet dat) verliest dan in stilte
     565  // de hele playlist. Het kost ons niets, want hun AlbumSerializer declareert
     566  // geen type-veld en valideert het dus niet: haalt Funkwhale dit adres op als
     567  // album, dan leest hij deze velden gewoon. En het object dat hij echt gebruikt
     568  // staat toch al ingesloten op de track.
     569  if ((playlist.kind || 'album') === 'album') {
     570    const album = buildAlbumObject(base, site, { ...playlist, _post: post });
     571    for (const veld of ['published', 'released', 'musicbrainzId', 'artist_credit', 'image']) {
     572      if (album[veld] !== undefined) out[veld] = album[veld];
     573    }
     574  }
     575  return leenVanPost(base, site, out, post);
    301576}
    302577
     
    323598  try {
    324599    const rijen = db.prepare(`
    325       SELECT id, slug, title, excerpt, content, cover_image_url, tags
     600      SELECT id, slug, title, excerpt, content, cover_image_url, tags,
     601             COALESCE(published_at, created_at) AS uit_wanneer
    326602      FROM posts
    327603      WHERE site_id = ? AND status = 'published'
  • 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') %>
    15<div class="container ax-page">
    26
    3   <p><a href="/admin" class="btn">&larr; <%= t('nav.admin') %></a></p>
     7  <%- include('../partials/admin-back') %>
    48  <%- include('../partials/media-tabs', { active: 'audio', audioOn: true }) %>
    59  <header class="ax-header">
     
    150154  padding: 0 1rem;
    151155}
    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 }
    205156.ax-card-title {
    206157  font-family: var(--font-display, serif);
     
    303254.ax-form-actions[hidden] { display: none; }
    304255
    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 
    344256.ax-track-play.is-playing {
    345257  border-color: var(--accent);
     
    348260}
    349261.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 }
    395262.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 }
    436263.ax-track-delete { display: inline; }
    437264
    438265/* ─── Mobile tweaks ───────────────────────────────────────────── */
    439266@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   }
    454267}
    455268
  • 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.
    53
     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') %>
    611  <%- 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) { %> &middot; <strong><%= unusedCount %></strong> <%= t('admin.media_unused') %><% } %>
     17      </p>
     18    </div>
     19  </header>
    720
    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><% } %>
    922
    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">&#128465; <%= t('admin.media_cleanup') %> (<%= unusedCount %>)</button>
    1726  </div>
     27  <% } %>
    1828
    1929  <% if (!items.length) { %>
    20     <p style="opacity:.7;"><%= t('admin.media_empty') %></p>
     30    <div class="ax-empty">
     31      <div class="ax-empty-icon">&#128247;</div>
     32      <p><%= t('admin.media_empty') %></p>
     33    </div>
    2134  <% } else { %>
    22   <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:1rem;">
     35  <div class="ax-grid">
    2336    <% 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">&#127916;</span><% } %>
     41          <% if (!it.usedCount) { %><span class="ax-tile-badge"><%= t('admin.media_unused') %></span><% } %>
    2942        </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 &middot; <%= it.usedCount %>&times;</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 %>">&#128465;</button>
    3548          </div>
    3649        </div>
  • src/views/pages/admin-playlists.ejs

    ra5515b4 r749605a  
    55const _csrf = (typeof csrfToken !== 'undefined' && csrfToken) || '';
    66%>
     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') %>
    711<div class="container ax-page">
    812
    9   <p><a href="/admin" class="btn">&larr; <%= t('nav.admin') %></a></p>
     13  <%- include('../partials/admin-back') %>
    1014  <%- include('../partials/media-tabs', { active: 'playlists', audioOn: true }) %>
    1115  <header class="ax-header">
     
    8387
    8488.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; }
    20489
    20590/* ─── Mobile ──────────────────────────────────────────────────── */
    20691@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   }
    22192}
    22293</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.
    93
     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') %>
    1013  <%- 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>
    1120
    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><% } %>
    1522
    1623  <% if (!items.length) { %>
    17     <p style="opacity:.7;"><%= t('admin.videos_empty') %></p>
     24    <div class="ax-empty">
     25      <div class="ax-empty-icon">&#127916;</div>
     26      <p><%= t('admin.videos_empty') %></p>
     27    </div>
    1828  <% } 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;">
    2030    <% 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><% } %>
    2535        </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 &middot; <%= it.usedCount %>&times;</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>
    3141            <% 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 %>">&#128465;</button>
    3343            <% } %>
    3444          </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. %>
    215<% 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>
    619  <% 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>
    923  <% } %>
    1024</nav>
  • src/views/partials/playlist-editor.ejs

    ra5515b4 r749605a  
    9999}
    100100.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; }
    101105.pl-field-full { grid-column: 1 / -1; }
    102106.pl-field > span {
     
    280284</style>
    281285
    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  
    2222// adds a term we adopt). JSON-LD keywords included.
    2323const 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',
    2530  'id', 'type', 'actor', 'object', 'target', 'to', 'cc',
    2631  'content', 'name', 'summary', 'url', 'href', 'mediaType',
Note: See TracChangeset for help on using the changeset viewer.