Changeset 952baf3 in Klonkt for src/views/pages/admin-audio.ejs


Ignore:
Timestamp:
08/07/2026 05:15:52 PM (5 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
ba76bf5
Parents:
f85b2c3 (diff), 0d5bd2c (diff)
Note: this is a merge changeset, the changes displayed below correspond to the merge itself.
Use the (diff) links above to see all the changes relative to each parent.
Message:

Merge GitHub-main (1.7.0) met de VPS-lijn

De twee mains waren een dag gedivergeerd en bevatten elk echt werk. GitHub had 66
commits die nooit langs prutfolio.git zijn gekomen, omdat een parallelle sessie
rechtstreeks naar GitHub pushte vanaf een kloon in /tmp op de VPS. De VPS had twee
commits die GitHub niet had. Geen van beide bevatte de ander, en stable had geen van
de twee.

Bewust een merge en geen rebase: dan blijft beide historie intact en wordt er niets
herschreven waar iemand anders al op voortbouwt.

Drie bestanden raakten beide kanten. Alle drie zijn nagekeken, want dat een merge
automatisch slaagt zegt niets over of hij inhoudelijk klopt:

src/services/ActivityPubService.js

  • de sleutelbinding staat nu boven de nieuwe asSlug-aanroep van 1.7.0, dus de controle komt nog steeds voor de handtekeningcontrole

scripts/klonkt-refresh-updater.sh

  • alleen de opzij-aanpak overleefde; systemctl mask staat nergens meer als code

deploy/MULTI-INSTANCE.md

  • spreekt zichzelf niet tegen: beschrijft opzij zetten, met de reden waarom mask weigert

remarks: het gat dat in de review naar boven kwam staat hiermee ook op de 1.7.0-lijn.
De andere bevindingen uit die review staan nog open en zijn niet in deze merge
opgelost; die horen als beads. Ook nog te doen: dezelfde sleutelbinding op stable
als 1.6.1, want daar is het gat nog open bij self-hosters.

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/views/pages/admin-audio.ejs

    rf85b2c3 r952baf3  
    573573<%- include('../partials/track-editor', { csrfToken: (typeof csrfToken !== 'undefined' ? csrfToken : '') }) %>
    574574
    575 <script>
    576 (function() {
    577 
    578   // ── Live filename display for custom file inputs ──────────────
    579   document.querySelectorAll('.ax-file-control input[type="file"]').forEach(input => {
    580     const nameEl = input.parentElement.querySelector('.ax-file-name');
    581     if (!nameEl) return;
    582     input.addEventListener('change', () => {
    583       if (input.files && input.files[0]) {
    584         nameEl.textContent = input.files[0].name;
    585         nameEl.removeAttribute('data-empty');
    586       } else {
    587         nameEl.textContent = '<%= t('aaud.no_file') %>';
    588         nameEl.setAttribute('data-empty', '');
    589       }
    590     });
    591   });
    592 
    593   // ── Inline track preview (routed through the global mini-player) ──
    594   // Each .ax-track-play button is a thin wrapper around the global
    595   // window.pcmsAudioPlayer.setQueue([...]) call. Visual state (▶ / ⏸ /
    596   // .is-playing) is synced from the player's own audio element so it
    597   // stays accurate even when the user uses the mini-player's controls.
    598   (function setupTrackPreview() {
    599     const buttons = document.querySelectorAll('.ax-track-play');
    600     if (!buttons.length) return;
    601 
    602     function setIcon(btn, playing) {
    603       const icon = btn.querySelector('.ax-track-play-icon');
    604       if (icon) icon.textContent = playing ? '⏸' : '▶';
    605       btn.classList.toggle('is-playing', playing);
    606       btn.setAttribute('aria-label', playing ? '<%= t('aaud.pause') %>' : '<%= t('aaud.play') %>');
    607     }
    608 
    609     // Resync ALL preview buttons against the current audio element state.
    610     // Called on every play/pause/ended event so admins always see the right
    611     // icon — including the case where they hit pause on the mini-player
    612     // bar instead of the row's button.
    613     function resyncAll() {
    614       const audio = document.getElementById('audio-element');
    615       const player = window.pcmsAudioPlayer;
    616       const playing = audio && !audio.paused && !audio.ended;
    617       // audio.src is now a blob: URL (Spotify-style playback), so compare
    618       // against the player's logical track URL, not the element src.
    619       const cur = player && player.currentTrack();
    620       const curUrl = cur ? cur.url : '';
    621       buttons.forEach(b => {
    622         const isThisOne = playing && curUrl === b.dataset.streamUrl;
    623         setIcon(b, isThisOne);
    624       });
    625     }
    626 
    627     buttons.forEach(btn => {
    628       btn.addEventListener('click', e => {
    629         e.preventDefault();
    630         const player = window.pcmsAudioPlayer;
    631         if (!player) {
    632           console.warn('[admin-audio] miniplayer not available');
    633           return;
    634         }
    635         const url = btn.dataset.streamUrl;
    636         if (!url) return;
    637 
    638         // Build track metadata from the row's DOM so the mini-player shows
    639         // useful info (title/artist/album/cover) without an extra API call.
    640         const row = btn.closest('.ax-track');
    641         const titleEl  = row && row.querySelector('[data-cell="title"]');
    642         const artistEl = row && row.querySelector('[data-cell="artist"]');
    643         const albumEl  = row && row.querySelector('[data-cell="album"]');
    644         const coverImg = row && row.querySelector('img[data-cover-thumb]');
    645         const track = {
    646           url,
    647           title:  titleEl  ? titleEl.textContent.trim()  : 'Track',
    648           artist: artistEl ? artistEl.textContent.trim() : '',
    649           album:  albumEl  ? albumEl.textContent.trim()  : '',
    650           cover:  coverImg ? coverImg.src                : '',
    651         };
    652 
    653         // If this exact track is already current, toggle pause/play instead
    654         // of restarting from zero. Compare logical URLs (audio.src is a blob:).
    655         const cur = player.currentTrack();
    656         if (cur && cur.url === url) {
    657           if (player.isPlaying()) player.pause();
    658           else                    player.play();
    659           return;
    660         }
    661 
    662         player.setQueue([track], 0);
    663       });
    664     });
    665 
    666     // Hook the global audio element's events to keep the row buttons synced.
    667     // We attach lazily after the player has built its DOM. The script in
    668     // shell.ejs runs at page-load so #audio-element exists by the time
    669     // this IIFE fires (script tag is below the body content).
    670     const audio = document.getElementById('audio-element');
    671     if (audio) {
    672       ['play', 'pause', 'ended', 'loadstart', 'emptied'].forEach(ev => {
    673         audio.addEventListener(ev, resyncAll);
    674       });
    675       // Initial state on page load (e.g. user navigated back while a track
    676       // was already playing — buttons should reflect that).
    677       resyncAll();
    678     }
    679   })();
    680 
    681 
    682   // ── Bulk upload (drag-drop + sequential transcoding) ─────────
    683   // Files dropped or picked are queued (not uploaded immediately) so the
    684   // user can review the list, set shared metadata, then hit "Start upload".
    685   // The loop POSTs one file at a time to /admin/audio/upload with
    686   // Accept: application/json so the server returns structured per-file
    687   // results instead of redirecting.
    688   const dropzone   = document.getElementById('audio-dropzone');
    689   const fileInput  = document.getElementById('audio-files');
    690   const queueEl    = document.getElementById('upload-queue');
    691   const actionsEl  = document.getElementById('upload-actions');
    692   const startBtn   = document.getElementById('start-upload-btn');
    693   const clearBtn   = document.getElementById('clear-queue-btn');
    694   const artistInput= document.getElementById('batch-artist');
    695   const albumInput = document.getElementById('batch-album');
    696   const coverInput = document.getElementById('batch-cover');
    697 
    698   /** @type {Array<{file: File, el: HTMLElement, status: string}>} */
    699   const queue = [];
    700 
    701   function fmtBytes(n) {
    702     if (n < 1024) return n + ' B';
    703     if (n < 1024 * 1024) return (n / 1024).toFixed(0) + ' KB';
    704     return (n / 1024 / 1024).toFixed(1) + ' MB';
    705   }
    706 
    707   function setItemStatus(item, status, label) {
    708     const labels = {
    709       queued:      '<%= t('aaud.st_queued') %>',
    710       uploading:   '<%= t('aaud.st_uploading') %>',
    711       transcoding: '<%= t('aaud.st_transcoding') %>',
    712       done:        '✓ <%= t('aaud.st_done') %>',
    713       error:       '✗ <%= t('aaud.st_error') %>',
    714     };
    715     item.status = status;
    716     const badge = item.el.querySelector('.ax-queue-status');
    717     badge.className = 'ax-queue-status ax-queue-status--' + status;
    718     badge.textContent = label || labels[status] || status;
    719     // Restyle the row
    720     item.el.classList.remove(
    721       'ax-queue-item--active', 'ax-queue-item--done', 'ax-queue-item--error'
    722     );
    723     if (status === 'uploading' || status === 'transcoding') item.el.classList.add('ax-queue-item--active');
    724     else if (status === 'done')  item.el.classList.add('ax-queue-item--done');
    725     else if (status === 'error') item.el.classList.add('ax-queue-item--error');
    726   }
    727 
    728   function addFiles(files) {
    729     let added = 0;
    730     for (const file of files) {
    731       if (!file.type.startsWith('audio/') &&
    732           !/\.(mp3|m4a|ogg|opus|flac|wav|webm|aac|oga|mp4)$/i.test(file.name)) {
    733         // Silently skip non-audio drops; keeps the UX uncluttered.
    734         continue;
    735       }
    736       const li = document.createElement('li');
    737       li.className = 'ax-queue-item';
    738       li.innerHTML =
    739         '<div class="ax-queue-name"></div>' +
    740         '<span class="ax-queue-status ax-queue-status--queued"><%= t('aaud.st_queued') %></span>';
    741       // Use textContent to avoid HTML-injection if a filename contains markup.
    742       li.querySelector('.ax-queue-name').textContent = file.name;
    743       // Append size hint inline
    744       const size = document.createElement('span');
    745       size.className = 'ax-queue-size';
    746       size.textContent = fmtBytes(file.size);
    747       li.querySelector('.ax-queue-name').appendChild(size);
    748       queueEl.appendChild(li);
    749       queue.push({ file, el: li, status: 'queued' });
    750       added++;
    751     }
    752     if (added) {
    753       queueEl.hidden = false;
    754       actionsEl.hidden = false;
    755     }
    756   }
    757 
    758   // ── Drop-zone events ─────────────────────────────────────────
    759   // Page-level guard: a dropped file outside the zone would otherwise
    760   // make the browser navigate to it (e.g. opening the audio inline), which
    761   // discards typed metadata. We swallow drops anywhere unless the dropzone
    762   // explicitly handles them.
    763   ['dragover', 'drop'].forEach(ev => {
    764     window.addEventListener(ev, e => {
    765       // Allow drops INSIDE the dropzone — its own listener handles those.
    766       if (dropzone.contains(e.target)) return;
    767       e.preventDefault();
    768     });
    769   });
    770 
    771   ['dragenter', 'dragover'].forEach(ev => {
    772     dropzone.addEventListener(ev, e => {
    773       e.preventDefault();
    774       dropzone.classList.add('is-dragover');
    775     });
    776   });
    777   ['dragleave', 'drop'].forEach(ev => {
    778     dropzone.addEventListener(ev, e => {
    779       e.preventDefault();
    780       dropzone.classList.remove('is-dragover');
    781     });
    782   });
    783   dropzone.addEventListener('drop', e => {
    784     if (e.dataTransfer && e.dataTransfer.files) addFiles(e.dataTransfer.files);
    785   });
    786   fileInput.addEventListener('change', () => {
    787     addFiles(fileInput.files);
    788     // Reset so the same file can be picked again later if user wants
    789     fileInput.value = '';
    790   });
    791 
    792   // ── Clear queue button ───────────────────────────────────────
    793   clearBtn.addEventListener('click', () => {
    794     // Only remove items that aren't currently uploading (anything queued
    795     // or already finished). An in-progress upload finishes, then its row
    796     // would also disappear once we re-render — but we keep it simple and
    797     // just refuse to clear during an active run.
    798     if (startBtn.disabled) return;
    799     queue.length = 0;
    800     queueEl.innerHTML = '';
    801     queueEl.hidden = true;
    802     actionsEl.hidden = true;
    803   });
    804 
    805   // ── Sequential upload loop ───────────────────────────────────
    806   startBtn.addEventListener('click', async () => {
    807     if (startBtn.disabled) return;
    808     startBtn.disabled = true;
    809     clearBtn.disabled = true;
    810     dropzone.style.pointerEvents = 'none';
    811     dropzone.style.opacity = '0.5';
    812 
    813     const sharedArtist = artistInput.value.trim();
    814     const sharedAlbum  = albumInput.value.trim();
    815     const sharedCover  = coverInput.files && coverInput.files[0];
    816 
    817     // Process queued items one at a time. We iterate via index so that
    818     // if more files get dropped during the run they ALSO get processed
    819     // (queue.push above mutates the same array we're iterating).
    820     for (let i = 0; i < queue.length; i++) {
    821       const item = queue[i];
    822       if (item.status !== 'queued') continue;
    823       try {
    824         await uploadOne(item, sharedArtist, sharedAlbum, sharedCover);
    825       } catch (err) {
    826         console.error('upload failed for', item.file.name, err);
    827         setItemStatus(item, 'error', '✗ ' + (err.message || '<%= t('aaud.failed') %>'));
    828       }
    829     }
    830 
    831     startBtn.disabled = false;
    832     clearBtn.disabled = false;
    833     dropzone.style.pointerEvents = '';
    834     dropzone.style.opacity = '';
    835 
    836     // Reload the page so the new tracks appear in the list below.
    837     // Could also fetch them and inject, but a full reload is simpler and
    838     // ensures position indexes / album-grouping are correct.
    839     const anyDone = queue.some(q => q.status === 'done');
    840     if (anyDone) {
    841       setTimeout(() => location.reload(), 700);
    842     }
    843   });
    844 
    845   async function uploadOne(item, sharedArtist, sharedAlbum, sharedCover) {
    846     setItemStatus(item, 'uploading');
    847 
    848     const fd = new FormData();
    849     fd.append('audio', item.file);
    850     if (sharedArtist) fd.append('artist', sharedArtist);
    851     if (sharedAlbum)  fd.append('album',  sharedAlbum);
    852     if (sharedCover)  fd.append('cover',  sharedCover);
    853     // Title is intentionally omitted — server uses filename fallback.
    854 
    855     // We can't reliably distinguish "still uploading bytes" from
    856     // "uploading done, ffmpeg running" without progress events, but the
    857     // status flips to "Converteren…" once the request is past upload phase.
    858     // We approximate this by waiting until the response arrives — by then
    859     // both phases are complete on the server side. For a smoother feel we
    860     // briefly show "transcoding" near the end of the request lifecycle.
    861     const transcodeHint = setTimeout(() => {
    862       if (item.status === 'uploading') setItemStatus(item, 'transcoding');
    863     }, 1500);
    864 
    865     try {
    866       const res = await fetch('/admin/audio/upload', {
    867         method: 'POST',
    868         headers: { 'Accept': 'application/json' },
    869         body: fd,
    870         credentials: 'same-origin',
    871       });
    872       clearTimeout(transcodeHint);
    873 
    874       // Server responds with JSON for our Accept header. If it didn't
    875       // (e.g. session expired and got an HTML login page), surface that.
    876       let data;
    877       try { data = await res.json(); }
    878       catch (_) { throw new Error('<%= t('aaud.err_unexpected') %> (' + res.status + ')'); }
    879 
    880       if (!res.ok || !data.ok) {
    881         throw new Error(data.error || ('HTTP ' + res.status));
    882       }
    883 
    884       setItemStatus(item, 'done', '✓ ' + (data.title || '<%= t('aaud.st_done') %>'));
    885     } catch (err) {
    886       clearTimeout(transcodeHint);
    887       throw err;
    888     }
    889   }
    890 
    891   // ── Click-to-copy embed codes ─────────────────────────────────
    892   document.querySelectorAll('[data-copy]').forEach(el => {
    893     el.addEventListener('click', async () => {
    894       const text = el.dataset.copy;
    895       try {
    896         await navigator.clipboard.writeText(text);
    897         el.classList.add('is-copied');
    898         const original = el.textContent;
    899         el.textContent = '✓ <%= t('aaud.copied') %>';
    900         setTimeout(() => {
    901           el.classList.remove('is-copied');
    902           el.textContent = original;
    903         }, 1200);
    904       } catch (_) { /* fall through — selection still works */ }
    905     });
    906   });
    907 
    908   // ── "+ Track zonder audio": maak een link-only stub + open de editor ──
    909   const addLinkBtn = document.getElementById('add-link-track-btn');
    910   if (addLinkBtn) {
    911     addLinkBtn.addEventListener('click', async () => {
    912       addLinkBtn.disabled = true;
    913       try {
    914         const r = await fetch('/admin/audio/create-link', {
    915           method: 'POST', credentials: 'same-origin',
    916           headers: { 'Content-Type': 'application/json' },
    917           body: JSON.stringify({ title: '<%= t('aaud.new_track') %>' }),
    918         });
    919         const j = await r.json();
    920         if (!r.ok || !j.ok) throw new Error(j.error || '<%= t('aaud.create_failed') %>');
    921         if (typeof window.openTrackEditor !== 'function') { location.reload(); return; }
    922         window.openTrackEditor({ id: j.id, onSaved: () => location.reload() });
    923       } catch (err) {
    924         alert('<%= t('aaud.create_failed') %>: ' + err.message);
    925       } finally {
    926         addLinkBtn.disabled = false;
    927       }
    928     });
    929   }
    930 
    931   // ── Wire all "Edit" buttons to the track-editor modal ─────────
    932   // After save we patch the row in-place rather than reloading,
    933   // so the user keeps their scroll position on long lists.
    934   document.querySelectorAll('[data-track-edit]').forEach(btn => {
    935     btn.addEventListener('click', () => {
    936       if (typeof window.openTrackEditor !== 'function') {
    937         alert('<%= t('aaud.editor_not_loaded') %>');
    938         return;
    939       }
    940       const id = btn.dataset.id;
    941       window.openTrackEditor({
    942         id,
    943         onSaved: (track) => {
    944           const row = document.querySelector('li[data-track-id="' + id + '"]');
    945           if (!row) return;
    946           // Update visible cells
    947           const titleEl  = row.querySelector('[data-cell="title"]');
    948           const artistEl = row.querySelector('[data-cell="artist"]');
    949           const albumEl  = row.querySelector('[data-cell="album"]');
    950           if (titleEl)  titleEl.textContent  = track.title  || '<%= t('aaud.untitled') %>';
    951           if (artistEl) artistEl.textContent = track.artist || '—';
    952           if (albumEl)  {
    953             albumEl.textContent = track.album || '';
    954             if (track.album) albumEl.removeAttribute('hidden');
    955             else albumEl.setAttribute('hidden', '');
    956           }
    957           // Update cover thumb (replace element if type changed)
    958           const oldThumb = row.querySelector('[data-cover-thumb]');
    959           if (oldThumb) {
    960             const parent = oldThumb.parentElement;
    961             if (track.cover_url) {
    962               const img = document.createElement('img');
    963               img.className = 'ax-track-cover';
    964               img.src = track.cover_url;
    965               img.alt = '';
    966               img.dataset.coverThumb = '';
    967               parent.replaceChild(img, oldThumb);
    968             } else {
    969               const sp = document.createElement('span');
    970               sp.className = 'ax-track-cover ax-track-cover-empty';
    971               sp.textContent = '♫';
    972               sp.dataset.coverThumb = '';
    973               parent.replaceChild(sp, oldThumb);
    974             }
    975           }
    976         },
    977       });
    978     });
    979   });
    980 
    981   // Download-voor-email toggle — AJAX (geen pagina-reload meer).
    982   document.querySelectorAll('[data-track-dl]').forEach(btn => {
    983     btn.addEventListener('click', async () => {
    984       if (btn.disabled) return;
    985       const want = btn.dataset.on === '1' ? 0 : 1;
    986       btn.disabled = true;
    987       try {
    988         const res = await fetch('/admin/audio/api/' + btn.dataset.id, {
    989           method: 'POST',
    990           headers: { 'Content-Type': 'application/json' },
    991           body: JSON.stringify({ downloadable: !!want }),
    992         });
    993         if (!res.ok) throw new Error('HTTP ' + res.status);
    994         btn.dataset.on = String(want);
    995         btn.style.color = want ? 'var(--accent,#6b8f71)' : '';
    996         btn.style.opacity = want ? '1' : '.5';
    997         btn.title = want
    998           ? '<%= t('aaud.dl_on') %>'
    999           : '<%= t('aaud.dl_off') %>';
    1000       } catch (e) {
    1001         alert('<%= t('aaud.change_failed') %>: ' + (e.message || e));
    1002       } finally {
    1003         btn.disabled = false;
    1004       }
    1005     });
    1006   });
    1007 })();
    1008 </script>
     575<%# Het script staat in assets/js/mod/admin-audio.js; de gegevens via partials/page-data.ejs (shaer-bqr). %>
     576<%- include('../partials/page-data', { pageData: { change_failed: t('aaud.change_failed'), copied: t('aaud.copied'), create_failed: t('aaud.create_failed'), dl_off: t('aaud.dl_off'), dl_on: t('aaud.dl_on'), editor_not_loaded: t('aaud.editor_not_loaded'), err_unexpected: t('aaud.err_unexpected'), failed: t('aaud.failed'), new_track: t('aaud.new_track'), no_file: t('aaud.no_file'), pause: t('aaud.pause'), play: t('aaud.play'), st_done: t('aaud.st_done'), st_error: t('aaud.st_error'), st_queued: t('aaud.st_queued'), st_transcoding: t('aaud.st_transcoding'), st_uploading: t('aaud.st_uploading'), untitled: t('aaud.untitled') } }) %>
Note: See TracChangeset for help on using the changeset viewer.