Changeset 0d7acdf in Klonkt


Ignore:
Timestamp:
06/20/2026 05:04:13 AM (3 months ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
3ec691c
Parents:
fef781e
Message:

feat(audio): per-track owner/credit + license (+ written to mp3 ID3 tags)

New per-track metadata: credit (copyright holder) + license. Editable in the
track editor (license with datalist presets: All rights reserved, CC BY/…/CC0).

  • DB: audio_tracks.credit + .license.
  • ID3: on upload and on every metadata edit the tags are written into the mp3 itself — copyright=credit, comment=license (new retagMp3() in the transcoder, -c copy, no re-encode) → ownership travels with a download.
  • Visible: "credit · license" line below each track (post-audio-track).

busters audio.css?v=7.

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

Location:
src
Files:
8 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/audio.css

    rfef781e r0d7acdf  
    6969  font-size: 0.85rem;
    7070  color: var(--ink-muted, var(--ink-soft));
     71  white-space: nowrap;
     72  overflow: hidden;
     73  text-overflow: ellipsis;
     74}
     75/* Eigenaar/credit · licentie — subtiele regel onder de track. */
     76.post-audio-track .pat-credit {
     77  font-size: 0.72rem;
     78  color: var(--ink-faint, var(--ink-soft));
     79  margin-top: 0.1rem;
    7180  white-space: nowrap;
    7281  overflow: hidden;
  • src/config/database.js

    rfef781e r0d7acdf  
    9090  ensureColumn('audio_tracks', 'play_count', 'INTEGER DEFAULT 0');  // plays per track
    9191  ensureColumn('audio_tracks', 'downloadable', 'INTEGER DEFAULT 0'); // download-voor-email (premium #2)
     92  ensureColumn('audio_tracks', 'credit', 'TEXT');   // eigenaar/credit (copyright-houder)
     93  ensureColumn('audio_tracks', 'license', 'TEXT');  // licentie (bv. "CC BY 4.0", "Alle rechten voorbehouden")
    9294
    9395  // Playlists (v9 feature) — first-class entity. CREATE IF NOT EXISTS is
  • src/routes/admin-audio.js

    rfef781e r0d7acdf  
    2020import { toWebp } from '../services/ImageWebpService.js';
    2121import { requireGod } from '../middleware/auth.js';
    22 import { transcodeToMp3 } from '../services/AudioTranscoder.js';
     22import { transcodeToMp3, retagMp3 } from '../services/AudioTranscoder.js';
    2323import { audioUrl } from '../services/AudioStreamService.js';
    2424
     
    168168    const finalArtist = artist?.trim() || null;
    169169    const finalAlbum  = album?.trim() || null;
     170    // Eigenaarschap/licentie. credit valt terug op de artiest; deze gaan zowel de
     171    // DB in als de ID3-tags van de mp3 (copyright + comment).
     172    const finalCredit  = (req.body.credit  || '').trim() || finalArtist || null;
     173    const finalLicense = (req.body.license || '').trim() || null;
    170174
    171175    console.log('[admin-audio] upload received:', {
     
    186190          artist: finalArtist || undefined,
    187191          album: finalAlbum || undefined,
     192          copyright: finalCredit || undefined,
     193          comment: finalLicense || undefined,
    188194        },
    189195      });
     
    216222      console.log('[admin-audio] inserting audio_tracks row (duration=' + finalDuration + ')');
    217223      db.prepare(`
    218         INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, media_id, position)
    219         VALUES (?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
     224        INSERT INTO audio_tracks (id, site_id, title, artist, album, duration, cover_url, credit, license, media_id, position)
     225        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(
    220226          (SELECT MAX(position) + 1 FROM audio_tracks WHERE site_id = ?),
    221227          0
     
    226232        finalDuration,
    227233        coverUrl,
     234        finalCredit, finalLicense,
    228235        mediaId, site.id
    229236      );
     
    371378  const t = db.prepare(`
    372379    SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url,
    373            t.position, t.created_at, m.filename
     380           t.credit, t.license, t.position, t.created_at, m.filename
    374381    FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
    375382    WHERE t.id = ? AND t.site_id = ?
     
    388395 * fallback keeps working.
    389396 */
    390 router.post('/api/:id', requireGod, express.json(), (req, res) => {
     397router.post('/api/:id', requireGod, express.json(), async (req, res) => {
    391398  const site = res.locals.site;
    392399  if (!site) return res.status(404).json({ error: 'Site required' });
     
    433440    fields.push('downloadable = ?'); values.push(body.downloadable ? 1 : 0);
    434441  }
     442  if (Object.prototype.hasOwnProperty.call(body, 'credit')) {
     443    fields.push('credit = ?'); values.push(String(body.credit || '').trim() || null);
     444  }
     445  if (Object.prototype.hasOwnProperty.call(body, 'license')) {
     446    fields.push('license = ?'); values.push(String(body.license || '').trim() || null);
     447  }
    435448
    436449  if (fields.length === 0) {
     
    445458  }
    446459
    447   // Return fresh row so the caller can update its UI without reloading
     460  // Verse rij + (als tag-velden wijzigden) de mp3 her-taggen, zodat de eigenaar/
     461  // licentie ook IN het bestand staat (ID3) en meereist bij een download.
    448462  const fresh = db.prepare(`
    449     SELECT id, title, artist, album, duration, cover_url
    450     FROM audio_tracks WHERE id = ? AND site_id = ?
     463    SELECT t.id, t.title, t.artist, t.album, t.duration, t.cover_url, t.credit, t.license, m.storage_path
     464    FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
     465    WHERE t.id = ? AND t.site_id = ?
    451466  `).get(req.params.id, site.id);
    452   res.json({ ok: true, track: fresh });
     467
     468  const tagsChanged = ['title', 'artist', 'album', 'credit', 'license']
     469    .some((f) => Object.prototype.hasOwnProperty.call(body, f));
     470  if (fresh && fresh.storage_path && tagsChanged) {
     471    try {
     472      await retagMp3({ filePath: fresh.storage_path, tags: {
     473        title: fresh.title || undefined,
     474        artist: fresh.artist || undefined,
     475        album: fresh.album || undefined,
     476        copyright: fresh.credit || undefined,
     477        comment: fresh.license || undefined,
     478      } });
     479    } catch (e) {
     480      console.warn('[admin-audio] ID3 her-taggen mislukt (DB is wel bijgewerkt):', e.message);
     481    }
     482  }
     483  const { storage_path, ...trackOut } = fresh || {};
     484  res.json({ ok: true, track: trackOut });
    453485});
    454486
  • src/routes/posts.js

    rfef781e r0d7acdf  
    529529      const placeholders = trackIds.map(() => '?').join(',');
    530530      const rows = db.prepare(`
    531         SELECT t.id, t.title, t.artist, t.cover_url, m.filename
     531        SELECT t.id, t.title, t.artist, t.cover_url, t.credit, t.license, m.filename
    532532        FROM audio_tracks t LEFT JOIN media m ON m.id = t.media_id
    533533        WHERE t.site_id = ? AND t.id IN (${placeholders})
     
    542542          artist: r.artist,
    543543          cover: r.cover_url,
     544          credit: r.credit || '',
     545          license: r.license || '',
    544546          url: audioUrl(r.filename),
    545547        };
  • src/services/AudioEmbedService.js

    rfef781e r0d7acdf  
    253253        artist: t.artist || '',
    254254        cover: t.cover || '',
     255        credit: t.credit || '',
     256        license: t.license || '',
    255257      });
    256258      const titleH = this.escape(t.title || 'Untitled');
    257259      const artistH = this.escape(t.artist || '');
    258260      const urlH = this.escape(t.url);
     261      // Zichtbare eigenaar/licentie-regel onder de track.
     262      const creditBits = [this.escape(t.credit || ''), this.escape(t.license || '')].filter(Boolean).join(' · ');
    259263      const dataAttr = trackJson
    260264        .replace(/&/g, '&amp;').replace(/'/g, '&#39;').replace(/</g, '&lt;');
     
    267271    <div class="pat-title">${titleH}</div>
    268272    ${artistH ? `<div class="pat-artist">${artistH}</div>` : ''}
     273    ${creditBits ? `<div class="pat-credit">${creditBits}</div>` : ''}
    269274  </div>
    270275</div>`;
  • src/services/AudioTranscoder.js

    rfef781e r0d7acdf  
    138138
    139139/**
     140 * Herschrijf de ID3-tags van een BESTAANDE mp3 zonder her-encoden (`-c copy`).
     141 * Gebruikt bij het bewerken van track-metadata (titel/artiest/album/credit/licentie)
     142 * zodat de eigendomsinfo in het bestand zelf meereist bij een download.
     143 * ffmpeg kan niet in-place editen → schrijf naar tmp en hernoem atomisch terug.
     144 */
     145export async function retagMp3({ filePath, tags = {} }) {
     146  if (!filePath) throw new Error('retagMp3: filePath required');
     147  await stat(filePath); // throws als 't bestand mist
     148  const dir = path.dirname(filePath);
     149  const base = path.basename(filePath, path.extname(filePath));
     150  const tmpPath = path.join(dir, `${base}.retag-${process.pid}.mp3`);
     151  try {
     152    await new Promise((resolve, reject) => {
     153      const cmd = ffmpeg(filePath)
     154        .audioCodec('copy')        // geen her-encode → snel, geen kwaliteitsverlies
     155        .format('mp3')
     156        .outputOptions('-id3v2_version', '3')
     157        .outputOptions('-map_metadata', '-1')
     158        .outputOptions('-vn');
     159      if (tags.title)     cmd.outputOptions('-metadata', `title=${tags.title}`);
     160      if (tags.artist)    cmd.outputOptions('-metadata', `artist=${tags.artist}`);
     161      if (tags.album)     cmd.outputOptions('-metadata', `album=${tags.album}`);
     162      if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`);
     163      if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);
     164      cmd.on('error', (err, so, se) => reject(new Error(((err && err.message) || 'ffmpeg') + (se ? ' | ' + se : ''))))
     165         .on('end', () => resolve())
     166         .save(tmpPath);
     167    });
     168    const s = await stat(tmpPath);
     169    if (s.size === 0) throw new Error('retag output is leeg');
     170    await rename(tmpPath, filePath);
     171    return { filePath, size: s.size };
     172  } catch (err) {
     173    try { await unlink(tmpPath); } catch { /* tmp bestaat mogelijk niet */ }
     174    throw err;
     175  }
     176}
     177
     178/**
    140179 * Run a single ffmpeg pass: input -> tmp output.
    141180 * Returns a promise that resolves when ffmpeg exits cleanly, rejects otherwise.
     
    167206    // breaks any value containing a space (e.g. "Test Artist" gets parsed
    168207    // as a separate output filename).
    169     if (tags.title)  cmd.outputOptions('-metadata', `title=${tags.title}`);
    170     if (tags.artist) cmd.outputOptions('-metadata', `artist=${tags.artist}`);
    171     if (tags.album)  cmd.outputOptions('-metadata', `album=${tags.album}`);
     208    if (tags.title)     cmd.outputOptions('-metadata', `title=${tags.title}`);
     209    if (tags.artist)    cmd.outputOptions('-metadata', `artist=${tags.artist}`);
     210    if (tags.album)     cmd.outputOptions('-metadata', `album=${tags.album}`);
     211    if (tags.copyright) cmd.outputOptions('-metadata', `copyright=${tags.copyright}`); // ID3 TCOP — eigenaar/credit
     212    if (tags.comment)   cmd.outputOptions('-metadata', `comment=${tags.comment}`);     // ID3 COMM — licentie
    172213
    173214    cmd
  • src/views/partials/track-editor.ejs

    rfef781e r0d7acdf  
    385385                     value="${track.duration || ''}" placeholder="auto">
    386386            </label>
     387
     388            <div class="te-row te-row-2">
     389              <label class="te-field">
     390                <span>Eigenaar / credit <small>(copyright-houder)</small></span>
     391                <input type="text" id="te-credit" maxlength="200"
     392                       autocomplete="off" spellcheck="false"
     393                       placeholder="bv. © 2025 Robin Genis"
     394                       value="${esc(track.credit || '')}">
     395              </label>
     396              <label class="te-field">
     397                <span>Licentie</span>
     398                <input type="text" id="te-license" maxlength="120"
     399                       autocomplete="off" spellcheck="false" list="te-license-list"
     400                       placeholder="Alle rechten voorbehouden"
     401                       value="${esc(track.license || '')}">
     402                <datalist id="te-license-list">
     403                  <option value="Alle rechten voorbehouden"></option>
     404                  <option value="CC BY 4.0"></option>
     405                  <option value="CC BY-SA 4.0"></option>
     406                  <option value="CC BY-NC 4.0"></option>
     407                  <option value="CC BY-NC-SA 4.0"></option>
     408                  <option value="CC BY-ND 4.0"></option>
     409                  <option value="CC0 1.0 (publiek domein)"></option>
     410                </datalist>
     411              </label>
     412            </div>
    387413
    388414            <div class="te-field">
     
    603629          artist: $('#te-artist').value.trim() || null,
    604630          album:  $('#te-album').value.trim() || null,
     631          credit:  $('#te-credit').value.trim() || null,
     632          license: $('#te-license').value.trim() || null,
    605633          duration: $('#te-duration').value ? Number($('#te-duration').value) : null,
    606634          cover_url: urlInput.value.trim() || null,
  • src/views/shell.ejs

    rfef781e r0d7acdf  
    168168     anywhere (admin previews, post embeds, etc). The player itself is
    169169     a singleton — see the script tag near </body>. -->
    170 <link rel="stylesheet" href="/assets/css/audio.css?v=6">
     170<link rel="stylesheet" href="/assets/css/audio.css?v=7">
    171171<!-- Eigen custom media-embeds (YouTube/SoundCloud/Spotify) in huisstijl. -->
    172172<link rel="stylesheet" href="/assets/css/embed.css?v=8">
Note: See TracChangeset for help on using the changeset viewer.