No description
  • PHP 46.2%
  • TypeScript 31.5%
  • Twig 15%
  • CSS 6.8%
  • JavaScript 0.4%
Find a file
2026-08-24 07:59:06 +02:00
.ddev Add initial project structure with configuration files and core components 2026-08-21 09:55:11 +02:00
assets Add chapter navigation buttons and manual chapter editor for tracks 2026-08-23 22:29:37 +02:00
bin Add initial project structure with configuration files and core components 2026-08-21 09:55:11 +02:00
config Add cover upload functionality for genres, albums, and artists with UI enhancements 2026-08-23 08:44:25 +02:00
media Add initial project structure with configuration files and core components 2026-08-21 09:55:11 +02:00
migrations Add genre cover upload functionality and enhance genre management with cover URL support 2026-08-22 20:02:18 +02:00
public Add PWA support with manifest and service worker for app installation 2026-08-23 13:03:07 +02:00
src Add statistics page for play counts and recently played tracks with new repository methods 2026-08-24 07:59:06 +02:00
storage/audio Add initial project structure with configuration files and core components 2026-08-21 09:55:11 +02:00
templates Add statistics page for play counts and recently played tracks with new repository methods 2026-08-24 07:59:06 +02:00
uploads Add initial project structure with configuration files and core components 2026-08-21 09:55:11 +02:00
.editorconfig Add configuration files for Symfony framework and initial project setup 2026-08-21 10:13:04 +02:00
.env Add configuration files for Symfony framework and initial project setup 2026-08-21 10:13:04 +02:00
.env.dev Add configuration files for Symfony framework and initial project setup 2026-08-21 10:13:04 +02:00
.gitignore Add genre cover upload functionality and enhance genre management with cover URL support 2026-08-22 20:02:18 +02:00
.npmrc Add initial project structure with configuration files and core components 2026-08-21 09:55:11 +02:00
composer.json Add lyrics fetching functionality and clear playback button to player 2026-08-21 17:03:48 +02:00
composer.lock Add lyrics fetching functionality and clear playback button to player 2026-08-21 17:03:48 +02:00
package-lock.json Add lyrics fetching functionality and clear playback button to player 2026-08-21 17:03:48 +02:00
package.json Add initial project structure with configuration files and core components 2026-08-21 09:55:11 +02:00
README.md Add statistics page for play counts and recently played tracks with new repository methods 2026-08-24 07:59:06 +02:00
symfony.lock Add download functionality for tracks and albums, including ZIP packaging for albums 2026-08-21 14:50:27 +02:00
TODO.md Add genre cover upload functionality and enhance genre management with cover URL support 2026-08-22 20:02:18 +02:00
tsconfig.json Add initial project structure with configuration files and core components 2026-08-21 09:55:11 +02:00

MusicDock

Ein schlankes, selbstgehostetes Musik-Streaming-Grundgerüst im Stil von Spotify/Amazon Music/Soundcloud Symfony 7+ Backend, minimal-dependencies Frontend (Bootstrap 5.3 nur per CDN + HTMX + Vanilla JS, kein Build-Schritt).

Die Grundidee

Ein Künstler ist ein Ordner. Ein Album ist ein Unterordner. Ein Song ist eine Datei. Deshalb gibt es genau eine Tabelle (nodes), die einen Baum aus Ordnern und Dateien abbildet. App\Entity\Node unterscheidet nur zwischen type (folder/file) und einem freien metadata-JSON-Feld, in dem u. a. kind (artist, album, category) steckt.

Alle "Ansichten" Künstler, Kategorie, Dateimanager, Suche sind reine Interpretationen desselben Baums, keine eigenen Datenmodelle:

Ansicht Controller Interpretation
Künstler ArtistController Wurzel-Ordner mit kind=artist, deren Kinder = Alben
Kategorie CategoryController Wurzel-Ordner mit kind=category, rekursiv über path LIKE
Dateimanager FileManagerController roher Baum, keine Interpretation
Suche SearchController name LIKE + metadata.artist_name LIKE

Setup mit DDEV

Vollständige Reihenfolge für einen frischen git clone (jeder Schritt baut auf dem vorherigen auf, daher wichtig in dieser Reihenfolge):

# 1. Container starten - baut dabei auch das Web-Image inkl. ffmpeg
#    (.ddev/web-build/Dockerfile) und die Upload-Limits (.ddev/php/upload.ini)
ddev start

# 2. Font Awesome Pro braucht einen Token, BEVOR npm install funktioniert
#    (die @fortawesome-Registry in .npmrc ist authentifiziert) - Token unter
#    https://fontawesome.com/account -> "Account Details" -> "Package Token"
cp .ddev/.env.example .ddev/.env
# .ddev/.env öffnen, FONTAWESOME_NPM_AUTH_TOKEN=<dein Token> eintragen
ddev restart   # nötig, damit die neue .ddev/.env tatsächlich geladen wird

# 3. PHP-Abhängigkeiten (inkl. symfony/lock für die Cron-Befehle)
ddev composer install

# 4. Datenbankschema - alle Migrationen in einem Rutsch (nodes, playlists,
#    uploads-Tabelle, ...)
ddev exec bin/console doctrine:migrations:migrate --no-interaction

# 5. Frontend-Abhängigkeiten + Build - ohne das fehlt public/build/app.js
#    und app.css komplett, die Seite wäre unstyled/nicht interaktiv
ddev npm install
ddev npm run build

Danach ist die Seite unter ddev launch erreichbar (leer, bis importiert wurde - siehe "Musik importieren" unten). media/, storage/audio/, uploads/ und public/media/covers/ existieren bereits als leere Ordner (nur .gitkeep versioniert, siehe .gitignore) - kein manuelles Anlegen nötig.

Optional, nicht für den Betrieb erforderlich:

  • Direkter DB-Zugriff (PhpStorm/phpMyAdmin) - siehe eigener Abschnitt unten.
  • Cronjobs für app:generate-pending-waveforms und app:process-uploads - ohne die funktioniert alles synchron beim Import, nur eben ohne asynchrones Nachholen für --no-waveform-Importe bzw. neue Web-Uploads (letztere werden dann gar nicht automatisch verarbeitet - der Cron dafür ist also nur bei tatsächlicher Upload-Nutzung nötig, siehe "Web-Upload").

Direkter DB-Zugriff (phpMyAdmin, PhpStorm/DBeaver)

.ddev/config.yaml legt einen festen host_db_port (13087) fest - damit ist die Datenbank auch direkt vom Host aus erreichbar, ohne über ddev mysql zu gehen:

  • PhpStorm/DBeaver/TablePlus o.Ä.: neue MySQL-Datenquelle, Host 127.0.0.1, Port 13087, Nutzer db, Passwort db, Datenbank db.
  • phpMyAdmin ist seit einiger Zeit kein DDEV-Core-Feature mehr, sondern ein Add-on - einmalig installieren:
    ddev add-on get ddev/ddev-phpmyadmin
    ddev restart
    ddev phpmyadmin
    

host_db_port: "13087" ist projektspezifisch - läuft parallel ein anderes DDEV-Projekt mit demselben Port, in .ddev/config.yaml einfach auf einen freien Port ändern und ddev restart.

Musik importieren

Lege deine Bibliothek nach diesem Muster in media/ ab:

media/
  Pink Floyd/
    The Dark Side of the Moon/
      cover.jpg              <- optional, wird als Album-Cover übernommen
      01 Speak to Me.mp3
      02 Breathe.mp3
    01 Money.mp3          <- Track ohne Album-Unterordner, landet direkt beim Künstler
  Daft Punk/
    Discovery/
      01 One More Time.mp3

Dann:

ddev exec bin/console app:import-media

Der Import liest ID3-Tags (Titel, Album, Tracknummer, Dauer) über james-heinrich/getid3 und legt für jede Ebene einen Node an. Künstler- Ordner werden automatisch mit kind=artist getaggt, Alben mit kind=album.

Zeigt dabei eine Fortschrittsleiste (Symfony Console ProgressBar, SymfonyStyle::createProgressBar()): Balken, Zähler, Prozent und der gerade verarbeitete Dateiname. MediaImporter::countPendingFiles() zählt vorab die Gesamtzahl (rein lesend, dieselbe Traversierung wie importAll() selbst, nur ohne Seiteneffekte) für die Prozentanzeige; importAll() ruft nach jedem einzelnen Track einen optionalen $onTrackImported-Callback mit dem Dateinamen auf, den der Befehl nutzt, um den Balken weiterzuschieben und die Nachricht zu aktualisieren - MediaImporter selbst weiß dabei nichts von Symfony Console, reiner callable-Parameter.

Referenz: Alle Shell-Befehle & Cronjobs

Kurzübersicht, damit man sich das nicht aus den einzelnen Abschnitten zusammensuchen muss. Details/Optionen jeweils beim verlinkten Abschnitt.

Einmalig nach Checkout/Klon (siehe "Setup mit DDEV" oben für die vollständige Reihenfolge inkl. .env/Font-Awesome-Token):

ddev start
ddev composer install
ddev exec bin/console doctrine:migrations:migrate --no-interaction
ddev npm install
ddev npm run build

Musik importieren (siehe "Musik importieren" unten):

ddev exec bin/console app:import-media                          # kopiert, media/ bleibt erhalten (Standard)
ddev exec bin/console app:import-media --move                    # verschiebt, media/ danach leer
ddev exec bin/console app:import-media --no-waveform              # überspringt ffmpeg-Wellenformberechnung (schneller)
ddev exec bin/console app:import-media --flush                    # löscht + importiert komplett neu (fragt nochmal nach)

Wellenformen nachträglich berechnen (siehe "Wellenformen asynchron nachholen"), z.B. nach --no-waveform oder für Alt-Tracks:

ddev exec bin/console app:generate-pending-waveforms
ddev exec bin/console app:generate-pending-waveforms --time-budget=120 --batch-size=50   # Optionen anpassen

Lyrics nachträglich abrufen (siehe "Lyrics") - lädt Song-Lyrics von lrclib.net für Tracks nach, für die das noch nie versucht wurde:

ddev exec bin/console app:fetch-pending-lyrics
ddev exec bin/console app:fetch-pending-lyrics --time-budget=120 --batch-size=30 --max-duration=1200   # Optionen anpassen

Web-Uploads verarbeiten (siehe "Web-Upload") - nötig, sonst bleiben hochgeladene Dateien dauerhaft im Status "wartend" hängen:

ddev exec bin/console app:process-uploads

Frontend-Build (siehe "Frontend-Build"):

ddev npm run build      # einmaliger Produktions-Build (minifiziert)
ddev npm run watch      # während der Entwicklung, baut bei jeder Änderung neu
ddev npm run typecheck  # nur Typprüfung, kein Build

Cronjobs (Host-Crontab, nicht im Container - ddev exec startet den Container bei Bedarf mit):

# Wellenformen: alle 5 Minuten während der 4-Uhr-Stunde (ruhige Nachtstunde)
*/5 4 * * * cd /pfad/zu/musicdock && ddev exec bin/console app:generate-pending-waveforms >> var/log/waveform-cron.log 2>&1

# Lyrics: alle 10 Minuten, unkritisch wann - reine externe API-Abfrage ohne CPU-Last
*/10 * * * * cd /pfad/zu/musicdock && ddev exec bin/console app:fetch-pending-lyrics >> var/log/lyrics-cron.log 2>&1

# Uploads: minütlich, damit neue Uploads zeitnah in der Bibliothek landen
* * * * * cd /pfad/zu/musicdock && ddev exec bin/console app:process-uploads >> var/log/upload-cron.log 2>&1

Alle drei Cron-Befehle sind Lock-geschützt (Symfony Lock, FlockStore) und zeitbudgetiert - ein noch laufender Vorgänger blockt den nächsten Tick einfach ab, kein Tick läuft je über sein eigenes Zeitbudget hinaus. Wellenformen/Uploads nutzen dafür die gemeinsame App\Service\RuntimeStorage-Klasse, app:fetch-pending-lyrics eine simple lokale time()-Deadline direkt im Befehl selbst - funktional gleichwertig, nur ohne die (hier nicht gebrauchte) geteilte Utility-Klasse, die für Deadline-Weitergabe ÜBER Klassengrenzen hinweg gedacht ist (RuntimeStorage wird z.B. sowohl vom Cron-Befehl selbst als auch von WaveformGenerator gelesen).

Datenbank direkt erreichen (siehe "Direkter DB-Zugriff"):

ddev add-on get ddev/ddev-phpmyadmin   # einmalig
ddev restart
ddev phpmyadmin

Speicher-Architektur: Inbox vs. verwalteter Speicher

media/ ist bewusst nur eine Inbox für neue Musik, kein dauerhafter Speicherort - und liegt deshalb auch nicht mehr unter public/ (nie über den Webserver erreichbar, war vorher unnötig offen). Beim Import wird jede Audiodatei zusätzlich nach storage/audio/ übernommen - genau wie Cover in 256 Hex-Bucket-Ordner (<uuid>.<ext>) einsortiert, obfuskiert und einheitlich benannt, unabhängig vom ursprünglichen Dateinamen (MediaImporter::storeAudioFile()). StreamController liefert Audio immer aus storage/audio/, nie aus media/ direkt.

Kopieren (Standard) vs. Verschieben (--move):

ddev exec bin/console app:import-media          # kopiert - media/ bleibt erhalten
ddev exec bin/console app:import-media --move   # verschiebt - media/ ist danach leer

Standardmäßig wird kopiert, das Original bleibt in media/ liegen. Das ist wichtig für --flush: der leert die komplette DB und baut aus media/ neu auf - wären die Originale bereits verschoben, hätte ein Flush nichts mehr zum Reimportieren. Mit --move werden Dateien tatsächlich verschoben (spart Plattenplatz, hält media/ als reine Inbox für "aktuell noch unverarbeitete" Musik sauber) - dafür kann ein späteres --flush diese Dateien nicht mehr reimportieren, weil die Quelle fehlt. storage/audio/ ist dann die einzige verbleibende Kopie - entsprechend wichtig, das separat zu sichern/backupen. Der Befehl warnt davor explizit und fragt nochmal nach.

Warum liegen Cover trotzdem weiter unter public/media/covers/, Audio aber nicht mehr unter public/? Cover-Bilder sind unkritisch - kein Grund, den Overhead eines eigenen Controllers dafür zu tragen. Audio dagegen soll ausschließlich über StreamController laufen (Range-Requests fürs Spulen, und später der natürliche Ansatzpunkt für Zugriffskontrolle, sobald Login existiert) - das funktioniert unabhängig davon, ob die Datei unter public/ liegt oder nicht, BinaryFileResponse braucht dafür keinen Webserver-Zugriff auf den Pfad. Es liegt einfach konsequenterweise dort, wo es hingehört: nicht-öffentlicher Anwendungsspeicher.

public/media/covers/ ist NICHT dasselbe wie das Top-Level media/ (die Inbox für neue Musik) - nur zufällig derselbe Name. media/ ist vom Nutzer kuratierter Quellinhalt, public/media/covers/ sind rein generierte, jederzeit per Reimport reproduzierbare Artefakte. Der Name kam auf ausdrücklichen Wunsch zustande (Ablage weiterhin unter public/ für Zero-Overhead-Auslieferung, aber mit "media" im Pfad statt dem generischen public/covers/).

--flush räumt jetzt auch storage/audio/ leer (clearAudioStorage()), nicht mehr nur Cover - sonst blieben bei geänderten/entfernten Quelldateien alte Kopien liegen.

--flush räumt auch verwaiste Upload-Tracking-Einträge auf (UploadRepository::deleteImported()) - nur die mit Status "importiert", deren Node gerade mitgelöscht wurde und deren Datei (dank --move- Semantik in importUploadedFile()) ohnehin längst nicht mehr in uploads/ liegt. Bewusst NICHT "wartend"/"in Arbeit"/"fehlgeschlagen" mitgelöscht - deren Dateien liegen noch echt in uploads/ und warten auf Verarbeitung, das hat mit einem Bibliotheks-Flush nichts zu tun.

file_path ist relativ, nicht absolut

metadata.file_path auf einem Track-Node speichert seit Kurzem einen relativen Pfad wie audio/ec/1234-....mp3 - relativ zum Storage-Root (dem Elternordner von storage/audio/), nicht mehr den vollen absoluten Pfad inklusive Container-Präfix (früher z.B. /var/www/html/storage/audio/...). Grund: ein absoluter Pfad ist fest an genau ein Deployment gebunden - verschiebt sich der Präfix (anderer Server, anderer Docroot, ein zweiter Container für einen DB-Export/-Import), stimmen alle gespeicherten Pfade nicht mehr.

src/Service/AudioPathResolver.php übersetzt in beide Richtungen: toRelative() beim Schreiben (MediaImporter), toAbsolute() beim Lesen für tatsächliche Dateizugriffe (StreamController, GenerateWaveformsCommand). Der Präfix selbst kommt zur Laufzeit aus %kernel.project_dir% - Symfony löst das in jeder Umgebung automatisch korrekt auf, keine manuell gepflegte Umgebungsvariable dafür nötig.

Kein erzwungener Reimport nötig: toAbsolute() erkennt alte, bereits gespeicherte absolute Pfade am führenden / und gibt sie unverändert zurück - bestehende Tracks funktionieren also weiter, auch ohne --flush. Neu importierte/hochgeladene Tracks bekommen automatisch den neuen relativen Pfad. Für volle Konsistenz (z.B. bevor man die DB auf einen anderen Server umzieht) trotzdem empfehlenswert, einmal app:import-media --flush laufen zu lassen.

Kategorien/Playlists folgen nicht zwangsläufig der Ordnerstruktur einer Musikbibliothek (ein Song kann in mehreren Playlists sein) und werden daher separat gepflegt entweder über die Doctrine-Konsole/eine kleine Fixture, oder indem du dir später einen app:create-category-Command o. Ä. baust, der bestehende Track-Nodes per kind=category-Wurzel-Ordner gruppiert und als zusätzlichen Node-Eintrag mit denselben file_path-Metadaten referenziert.

Frontend-Build (TypeScript, lokale Dependencies)

Kein CDN mehr - htmx.org und bootstrap liegen als npm-Pakete vor und werden zusammen mit dem TypeScript-Player per esbuild zu public/build/app.js + public/build/app.css gebündelt.

ddev npm install
ddev npm run build      # einmaliger Produktions-Build (minifiziert)
ddev npm run watch      # während der Entwicklung, baut bei jeder Änderung neu
ddev npm run typecheck  # nur Typprüfung, kein Build

public/build/ wird nicht versioniert (siehe .gitignore) - nach jedem Checkout einmal ddev npm install && ddev npm run build laufen lassen, sonst fehlt base.html.twig die referenzierte app.js/app.css.

Quelltext liegt unter assets/ts/ (app.ts = Einstiegspunkt, player.ts = der eigentliche Player) und assets/css/ (theme.css = Sonora-Theme, app.css = projekteigene Overrides darüber).

Design: Sonora-Theme

Das komplette Frontend basiert auf einem übernommenen Theme-Entwurf ("Sonora Music Library") - Sidebar-Layout, Dark/Light-Modus + 4 Akzentfarben (Violet/Ocean/Emerald/Rose), Queue-/Lyrics-Seitenpanels, Kontextmenü pro Track, alles über CSS Custom Properties + Container Queries. assets/css/theme.css ist die (leicht angepasste) Theme-Quelldatei, assets/css/app.css enthält nur unsere eigenen Ergänzungen/Overrides darüber (Importreihenfolge in app.ts: Bootstrap → Font Awesome → theme.css → app.css - bei gleicher Spezifität gewinnt also immer die eigene Anpassung).

Font Awesome Pro kommt lokal per npm statt CDN, passend zu Bootstrap/htmx - @fortawesome/fontawesome-pro, nicht die Free-Variante: das Theme nutzt u.a. list-music (Warteschlange/Playlist-Icon), das in der komplett kostenlosen Font-Awesome-Stufe in der Kategorie "Music + Audio" gar nicht existiert (dort gibt es in Classic Solid keine einzige Free-Version). package.jsons Build-Skripte haben --loader:.woff=file (und .woff2) bekommen, damit esbuild die Font-Dateien mit ins Bundle kopiert.

Nur woff/woff2, nicht ttf/eot/svg: Font Awesomes CSS listet pro Icon-Stil mehrere Font-Formate als Fallback-Kette (src: url(...woff2), url(...woff), url(...ttf), ...) - jeder halbwegs aktuelle Browser lädt davon aber ausschließlich woff2 (die erste, bevorzugte Quelle). ttf/eot/svg sind reine Altlasten für IE11 & uraltes Android/Safari, aber jeweils 1+ MB groß. Da esbuild beim Bundeln blind jede url()-Referenz einer CSS kopiert, ohne zu wissen, dass der Browser davon eh nur eine lädt, würden sie sonst mitgebündelt werden, ohne je gebraucht zu werden. Deshalb --external:*.ttf --external:*.eot --external:*.svg statt eines file-Loaders dafür - esbuild lässt die url()-Referenz dann einfach unangetastet stehen, kopiert aber nichts.

Font Awesome Pro liegt in einer authentifizierten npm-Registry - dafür brauchst du einen Node-Auth-Token aus deinem Font-Awesome-Pro-Account (https://fontawesome.com/account → "Account Details" → "Package Token"). Der Token landet nicht im Repo:

cp .ddev/.env.example .ddev/.env
# .ddev/.env öffnen, FONTAWESOME_NPM_AUTH_TOKEN=<dein Token> eintragen
ddev restart

.ddev/.env wird von DDEV automatisch an alle Container durchgereicht und ist per .gitignore ausgeschlossen. .npmrc im Projekt-Root leitet das @fortawesome-Scope an npm.fontawesome.com um und liest den Token per ${FONTAWESOME_NPM_AUTH_TOKEN} aus dieser Umgebungsvariable - dort steht also ebenfalls kein Klartext-Secret. Solltest du (oder jemand anders, der das Projekt später aufsetzt) keinen Pro-Zugang haben: einfach zurück auf @fortawesome/fontawesome-free wechseln (in package.json und dem Import in assets/ts/app.ts) und fa-list-music durch ein Free-Icon ersetzen, z.B. fa-list-ol.

Theme-Umschaltung (assets/ts/theme.ts): data-mode/data-theme auf <html> + localStorage, keine Server-Abhängigkeit. Ein kleines Inline- <script> im <head> von base.html.twig setzt beide synchron VOR dem ersten Rendern (verhindert einen kurzen Flacker im falschen Theme beim Laden) - theme.ts übernimmt danach normal weiter.

Deterministische Farbverlauf-Platzhalter statt grauer Boxen: Nodes ohne echtes Cover zeigen einen der 8 (bzw. 5 für Künstler) Farbverläufe aus dem Theme, ausgewählt über die UUID (GradientExtension/gradient_class- Twig-Filter, src/Twig/GradientExtension.php) - deterministisch, damit dieselbe Kachel bei jedem Aufruf denselben Verlauf zeigt statt bei jedem Reload zufällig zu wechseln.

Bewusste Anpassungen gegenüber der Theme-Vorlage (die war als Standalone-Demo mit festem JS-Datenarray gebaut, nicht für einen Server-gerenderten Multi-Page-HTMX-Stack):

  • Album-/Künstlerdetails sind echte Seiten, kein Bootstrap-Modal. Die Vorlage öffnet Album-/Künstler-Details in einem JS-befüllten Modal (detailAlbum()/detailArtist() in der Original-music-app.js). Bei uns sind das eigene URLs (/album/{id}, /kuenstler/{id}) mit HTMX-Boost-Navigation - konsistent mit dem Rest der App, Browser-History- fähig, kein Duplizieren von Serverdaten in JS. Optisch wiederverwendet (.detail-modal/.detail-hero/.artist-detail als "abgerundete Karte", nicht als tatsächliches Modal).
  • Kontextmenü nur an echten Track-Zeilen, nicht an Album-/ Künstler-Kacheln. Im Original verwies der "..."-Button einer Album-Kachel auf einen einzelnen Beispiel-Track (data-track="0") - in echten Daten ist unklar, "welcher" Track eines Albums gemeint wäre. Album- /Künstler-Kacheln sind bei uns reine Navigation zur Detailseite.
  • Der frühere separate ""-Playlist-Button ist im Kontextmenü aufgegangen (Play now/next/Queue/Playlist/Favorit gebündelt) - der Klick auf das Cover selbst startet jetzt die Wiedergabe (wie im Original), mit einem kleinen Hover-Overlay als Play-Affordanz (.track-cover::after in app.css, in der Vorlage nicht vorhanden).
  • Progress-/Volume-Leiste: klickbare <div>s wie im Original, aber echte Klick-Logik für die Lautstärke ergänzt (im Original-JS nur für die Fortschrittsanzeige verdrahtet, Lautstärke war rein optisch).
  • "Recently played" → "Kürzlich hinzugefügt": es gibt keine Wiedergabe-Historie (dafür bräuchte es ein eigenes Tracking-Feature) - stattdessen zeigt die Startseite die zuletzt importierten Alben (Node::$createdAt, kostenlos vorhanden).
  • "Favoriten" sind keine eigene Tabelle, sondern eine Playlist mit festem Slug favorites (PlaylistRepository::findOrCreateFavorites()) - reine Konvention statt Schema-Änderung, siehe Abschnitt "Playlists".
  • Lyrics-Panel bleibt UI-Hülle ohne Daten - es gibt keine Lyrics-Quelle, das Original hatte einen fest eingebauten Beispieltext. Zeigt stattdessen ehrlich "Keine Lyrics verfügbar."
  • Kein "Downloads"-Menüpunkt - keine Offline-/Download-Funktion vorhanden, wurde nicht als Fake-Feature nachgebaut.
  • Genres sind exakt unsere bestehenden Kategorien (kind=category), nur umbenannt in der Navigation - kein neues Konzept.

Genres

Eigenständige, granulare Entity (App\Entity\Genre) statt eines reinen Metadata-Strings auf dem Node - bewusste Design-Entscheidung, drei Gründe:

  1. Deduplizierung "for free" über den UNIQUE-Constraint auf name
    • die case-insensitive Tabellen-Collation (utf8mb4_unicode_ci, wie im Rest des Projekts) - "Rock" und "rock" landen automatisch auf demselben Datensatz, keine eigene Normalisierungslogik nötig.
  2. Viele-zu-viele-Zuordnung (ein Track/Album kann mehrere Genres haben, ein Genre gehört zu vielen Tracks/Alben) ist mit Doctrine sauber und ohne Umwege abbildbar - Node::$genres, ManyToMany gegen Genre, Join-Tabelle node_genres.
  3. "Alle Tracks/Alben zu Genre X" ist darüber eine simple, schnelle Abfrage über die Join-Tabelle statt einer teuren JSON-Feld-Suche über alle Nodes hinweg - Grundlage für eine spätere Genre-Übersichtsseite (noch nicht gebaut, aber die Architektur trägt das direkt).

Zuweisung bewusst nur auf Track- und Album-Nodes (UI-seitig durchgesetzt - _genre_editor.html.twig wird nur in track/show.html.twig und album/show.html.twig eingebunden) - die Doctrine-Relation selbst schränkt das nicht ein, ein Genre könnte technisch an jeden Node hängen.

Vererbung vom Album: Node::getEffectiveGenres() - liefert die eigenen Genres, falls der Track welche hat, sonst (nur relevant für Tracks unter einem Album) die des Elternalbums. Reine Lesefunktion, keine Duplizierung der Zuordnung in der Datenbank - ändert sich das Album-Genre später, zieht jeder Track ohne eigenes Genre das automatisch nach. Auf der Track-Detailseite wird das geerbte Genre informativ angezeigt ("Vom Album geerbt: ..."), editierbar ist dort aber immer nur die eigene Zuordnung (node.genres, nicht die effektive).

GenreController, generisch auf Node-Ebene (nicht im TrackController/AlbumController) - dieselbe Aktion (Genre hinzufügen/entfernen, POST/DELETE /node/{id}/genres) gilt für beide Node-Typen. GenreRepository::findOrCreateByName() sucht zuerst nach einem bestehenden Genre mit diesem Namen (case-insensitive dank Collation), legt es nur an, falls es das noch nicht gibt - ein UniqueConstraintViolationException-Fangnetz deckt den seltenen Fall ab, dass zwei parallele Anfragen gleichzeitig dasselbe neue Genre anlegen wollen (die DB lässt nur eine durch, die andere liest den inzwischen angelegten Datensatz).

_genre_editor.html.twig, wiederverwendbar in Track- UND Album-Detailseite - Chips mit Entfernen-Button + Eingabefeld, komplett HTMX-getrieben (hx-post/hx-delete, hx-target="#genre-editor-{id}" hx-swap="outerHTML" ersetzt die ganze Komponente mit der Server-Antwort)

  • kein zusätzliches JS nötig, auch das Leeren des Eingabefelds nach dem Hinzufügen passiert automatisch (die Server-Antwort ist ein frisches, leeres Formular).

"Method Not Allowed" bei Reload nach jeder Genre-Interaktion - auch beim Entfernen-Button, der nie in einem <form> war. Zwei Zwischen-Fixe (native method/action-Absicherung, hx-boost="false", später ein komplett formularloser Umbau) halfen alle nicht - der gemeinsame blinde Fleck: hx-boost="false" schaltet NUR das Boost-spezifische Link-/Formular-Verhalten ab, nicht die SEPARAT vererbten hx-select/hx-push-url-Attribute. Die tatsächliche Ursache (siehe base.html.twig, dort ausführlich dokumentiert - genau dieses Muster steckt bereits im Playlist-Picker als Präzedenzfall): hx-target/hx-select/hx-push-url sitzen am <body> (fürs App-weite #content-Boosting) und werden von JEDEM HTMX-Request geerbt, sofern nicht EXPLIZIT überschrieben - auch von kleinen Fragmenten wie diesem hier, unabhängig von hx-boost. Ohne Override suchte HTMX im (viel kleineren) Fragment-Response ein #content- Element, fand nichts ("Zeile leert sich"), UND änderte trotzdem die Browser-URL (hx-push-url="true" vom body geerbt) - ein Reload dieser URL scheiterte dann als GET an einer POST/DELETE-only-Route. Behoben, indem jeder hx-post/hx-delete explizit hx-select (auf sich selbst) und hx-push-url="false" setzt - exakt wie im Playlist-Picker (playlist/_picker.html.twig).

Bleibt trotzdem formularlos (aus dem vorherigen Zwischenschritt) - Chips + Eingabefeld + Hinzufügen-Button sind eigenständige Elemente mit direktem hx-post/hx-delete, hx-trigger="keyup[key=='Enter']" auf dem Eingabefeld selbst für die Enter-Taste, hx-include für den Hinzufügen-Button, um den Eingabefeld-Wert mitzuschicken - unabhängig vom eigentlichen Fund oben eine zusätzliche, saubere Absicherung gegen jedes denkbare native Formular-Submit-Verhalten.

Noch keine Zugriffsbeschränkung - laut Absprache soll das später an ein User-ACL-System gebunden werden, sobald es Nutzer im Projekt gibt. Bis dahin kann jeder, der die Seite erreicht, Genres pflegen.

Migration Version20260824000000 - erzeugt genres (id, name mit UNIQUE-Index) und node_genres (Join-Tabelle, node_id+genre_id als zusammengesetzter Primärschlüssel, beide Fremdschlüssel mit ON DELETE CASCADE - ein gelöschter Track/Album oder ein gelöschtes Genre räumt seine Zuordnungen automatisch mit auf).

Genre-Autocomplete

GenreExtension (Twig-Funktion all_genre_names()) liefert alle bestehenden Genre-Namen für ein natives <datalist> im Genre-Editor (_genre_editor.html.twig) - vermeidet doppelte/unterschiedlich geschriebene Einträge ("Hip-Hop" vs "HipHop", die Collation dedupliziert nur reine Groß-/Kleinschreibung wie "Rock"/"rock", nicht das). Als Twig-Funktion statt vom jeweiligen Controller durchgereicht, damit nicht JEDE der vier Einbindungsstellen (Track-/Album-Detailseite, GenreController::add()/remove()) das explizit mitliefern muss - für eine private Musikbibliothek unproblematisch (kleine, günstige Abfrage), sonst wäre ein direkter DB-Zugriff aus Twig heraus ein Anti-Pattern. Browser übernehmen Filterung/Tastatur-Navigation selbst, kostenlos - kein eigenes JS-Autocomplete nötig.

Genre-Chips in der Trackliste

_track_row.html.twig zeigt die EFFEKTIVEN Genres (Node::getEffectiveGenres()

  • eigene, sonst vom Album geerbt) als kleine, farbige Chips unter Künstler/Album, verlinkt zur jeweiligen Genre-Seite. Genre::$color (falls gesetzt) färbt den Chip GitHub-Label-artig: gedämpfter Hintergrund (Hex-Farbe + 26-Alpha-Suffix, ~15% Deckkraft) plus volle Farbe für Text/Rand. Ohne gesetzte Farbe fällt der Chip auf die neutrale CSS-Standardoptik zurück.

Performance-Hinweis: löst pro Zeile eine (kleine, lazy-geladene) Doctrine-Abfrage aus - bei sehr langen Listen (Tracks-Übersicht, 60 Zeilen/Seite) potenziell spürbar, für eine private Musikbibliothek aber unproblematisch. Bei Bedarf später mit Eager-Loading (JOIN in NodeRepository) nachbessern.

Genre-Cover-Upload

Genre::$coverUrl (Migration Version20260826000000) - "zweite visuelle Aufwertung zur Farbe" (beide können gleichzeitig gesetzt sein, das Cover ersetzt die Farbe nicht), wird ausschließlich auf der Genre-Detailseite groß gezeigt (genre/show.html.twig), nicht in Listen. Anzeige-Priorität dort: Cover > Farbe > deterministischer Gradient, genau wie bei Album-Covern das gleiche Muster.

GenreCoverUploader - deutlich einfacher als der Album-/Track- Cover-Pfad in MediaImporter (dort thumb+mini, mehrere Quellformate über ID3-Extraktion): nur EIN direkter Upload, EINE Zielgröße (GD- Skalierung auf maximal 512×512, Seitenverhältnis erhalten, kein Zuschnitt/keine Verzerrung), gespeichert als JPEG unter genre_covers_path (public/media/genre-covers/, eigener Ordner statt covers_path/ - Genre-Cover sind konzeptuell unabhängig von Album-/ Track-Covern, kein zugehöriger Node). Ein Bild pro Genre, Dateiname = Genre-ID, ein erneuter Upload überschreibt einfach die bestehende Datei

  • Cache-Busting per ?v=timestamp-Suffix in der gespeicherten URL, sonst würde der Browser nach einem erneuten Upload weiterhin das alte Bild aus dem Cache zeigen.

Upload-Feld im "..."-Popup neben der Farbauswahl (components/_genre_card.html.twig) - Datei-Uploads über HTMX brauchen hx-encoding="multipart/form-data" auf dem sendenden Element selbst (baut intern ein temporäres Formular fürs Multipart-Encoding auf), kein separates <form>-Element nötig, genau wie beim Farbfeld daneben - inklusive derselben hx-select/hx-push-url="false"-Absicherung.

Track-Cover in MP3 einbetten

Das riskanteste der vier Features - schreibt tatsächlich in die Audiodatei auf der Festplatte, nicht nur in eine separate Anzeige-Datei. TrackCoverEmbedder bettet ein hochgeladenes Bild als APIC-Frame (ID3v2 "attached picture") direkt in die MP3 ein, über james-heinrich/getid3s Schreib-Modul (getid3_writetags, liegt NICHT im Composer-PSR-4-Autoloading - getID3s Struktur ist historisch dateibasiert, kein Namespace - der Pfad zu write.php wird deshalb per Reflection relativ zur bereits injizierten getID3-Hauptklasse ermittelt, statt einen Vendor-Pfad hart zu codieren).

Entsprechend defensiv:

  1. Nur MP3 (mime_type audio/mpeg) - andere Formate haben andere Tag-Systeme (FLAC: Vorbis-Kommentare, OGG etc.), getID3s ID3v2- Schreib-Modul ist spezifisch für MP3/ID3. Der Upload-Bereich wird auf der Track-Detailseite nur bei MP3-Tracks überhaupt angezeigt.
  2. Bestehende Tags werden vor dem Schreiben vollständig ausgelesen (getID3->analyze()) und beim Schreiben explizit mitgegeben - nicht nur auf getid3_writetags' eigenes Merge-Verhalten verlassen, sonst könnten Titel/Künstler/Album/Tracknummer etc. beim Cover-Update verloren gehen.
  3. Backup vor dem Schreiben (Kopie der Original-Datei, .cover-backup-Suffix) - getid3_writetags bietet kein natives atomares Schreiben (kein Temp-Datei+rename()-Muster). Schlägt das Schreiben fehl oder wirft eine Exception, wird die Original-Datei aus dem Backup wiederhergestellt, bevor der Fehler nach oben gereicht wird - eine kaputte Mediendatei ist inakzeptabel. Backup wird in jedem Fall (Erfolg wie Fehler) am Ende wieder gelöscht.

Generiert danach dieselben Anzeige-Varianten (thumb/mini) wie beim Import, über die wiederverwendeten MediaImporter::finalizeCover()/ bucketFor() (dafür von private auf public umgestellt) - kein duplizierter Code für die Thumbnail-Erzeugung. Aktualisiert cover_url/cover_thumb_url/cover_mini_url auf dem Track-Node.

Vorhandenes Cover wird VOR dem Schreiben gelöscht (Dateisystem) - das ORIGINALE, beim Import extrahierte Cover kann jede Extension haben (MediaImporter::storeCoverFile() übernimmt die Extension der Quelldatei, z.B. .png/.webp), während dieser Upload IMMER als .jpg schreibt. Ohne Bereinigung würde bei abweichender Original- Extension die alte Datei als Leiche liegen bleiben - cover_url zeigt zwar korrekt auf die neue .jpg, aber die alte Datei verbraucht dauerhaft unnötig Speicherplatz. glob($bucket/$uuid.'*') (kein Punkt im Muster) erfasst {uuid}.EXT UND {uuid}_thumb.jpg/{uuid}_mini.jpg gleichermaßen, unabhängig von der genauen Extension - alle werden vor dem Schreiben der neuen Dateien entfernt.

XMLHttpRequest statt natives Formular/HTMX (assets/ts/track-detail.ts, initTrackCoverUpload()) - dasselbe Muster wie assets/ts/upload.ts für den Drag&Drop-Upload: kein <form>, kein hx-post. Ein erster Entwurf nutzte ein natives Formular mit vollständigem Redirect, aus Sorge vor den hx-select/hx-push-url-Fallstricken, die beim Genre-Editor mehrfach Probleme gemacht hatten - das war zu vorsichtig gedacht: das eigentliche Kollisionsrisiko ist nie "HTMX generell", sondern spezifisch, dass hx-select/hx-push-url vom <body> an JEDES Element mit hx-*- Attributen vererbt werden. Ein reiner XHR-Aufruf trägt gar keine hx-*-Attribute, betrifft dieses Problem also überhaupt nicht - der Drag&Drop-Upload ist der Beweis, dass das schon die ganze Zeit problemlos funktioniert. Aktualisiert das Cover-Bild nach Erfolg direkt im DOM (#trackCoverImg), kein Reload nötig - deutlich bessere UX als der ursprüngliche Redirect-Ansatz. Antwort ist JSON ({status: 'ok', cover_url: ..., ...} oder {error: '...'}), genau wie UploadController::upload()s Antwortformat.

Cache-Busting (?v=timestamp-Suffix an den gespeicherten URLs, analog zum Genre-Cover-Upload) - derselbe Dateiname (Track-UUID) wird bei jedem Upload überschrieben, ohne das Suffix würde der Browser nach einem erneuten Upload weiterhin das alte Bild aus dem Cache zeigen (auch bei einem künftigen Seitenaufruf, nicht nur der aktuellen Session).

Erster Live-Test deckte einen echten Bug auf: getID3 als Konstruktor- Argument autowiren lassen funktioniert nicht. getID3 hat keine registrierte Symfony-Service-Definition (Autowiring schlug mit "Cannot autowire service ... references class getID3 but no such service exists" fehl) - MediaImporter selbst injiziert getID3 deshalb NIE per Konstruktor-Typisierung, sondern instanziiert es manuell im Konstruktor- Body ($this->getId3 = new getID3();). TrackCoverEmbedder macht das jetzt genauso.

Fehlermeldungen jetzt spezifisch statt generisch - embed() warf vorher bei JEDEM Fehler (falsches Audioformat, fehlende Datei, ungültiges Bild, Schreibfehler) null zurück, die UI zeigte immer dieselbe Meldung ("ungültiges Bild") - beim ersten echten Testfall irreführend, wenn die Ursache in Wahrheit ganz woanders lag. Jede Fehlerquelle wirft jetzt eine \RuntimeException mit der KONKRETEN Ursache (falsches Audioformat, GD kann das Bildformat nicht dekodieren, getid3_writetagss eigene Fehlermeldungen, etc.) - der Controller reicht die Nachricht direkt an die UI durch, Details landen zusätzlich im Log.

Weiterhin ohne vollständigen End-to-End-Test ausgeliefert - der Autowiring-Fehler verhinderte bisher JEDEN Aufruf von getid3_writetags, das eigentliche Schreiben in eine echte MP3-Datei ist also noch nicht verifiziert. Vor dem produktiven Einsatz an wichtigen Dateien: bitte zuerst an einem unwichtigen Test-Track ausprobieren. Das Backup-Verfahren (Punkt 3 oben) sollte auch bei einem unerwarteten Fehler die Originaldatei schützen, aber echtes Testen mit einer echten MP3-Datei ersetzt das nicht.

Genre-Übersichtsseite

/genres (genre_index) - für jedes Genre die EFFEKTIVE Track-Anzahl (inkl. vom Album geerbter, nicht nur direkt zugewiesener) plus bis zu 5 Beispiel-Tracks als Vorschau (GenreController::PREVIEW_TRACK_COUNT). Klick auf ein Genre führt zu /genre/{id} (genre_show) mit der vollständigen Track-Liste (normale _track_row.html.twig-Zeilen, ganz normal abspielbar wie überall sonst).

Ersetzt ein ALTES, nie genutztes category-Node-System - der "Genres"-Sidebar-Link zeigte vorher auf category_index (kind:'category'-Nodes, ein generischer Ordner-Typ aus einer früheren Session, für den nie tatsächlich Daten angelegt wurden) - deshalb zeigte die Seite immer "Noch keine Genres angelegt", unabhängig davon, ob Genres über die neue Entity längst existierten. Sidebar-Link UND die Genre-Vorschau-Sektion auf der Startseite (home/index.html.twig, vorher ebenfalls auf categories aufgebaut) zeigen jetzt beide auf die echte Genre-Entity. Das alte CategoryController/category-System bleibt unangetastet bestehen (wird noch als Fallback in der Suche referenziert, search/_results.html.twig/_suggestions.html.twig, für generische Node-Kinds, falls solche Nodes je entstehen sollten) - kein aktiver Code-Pfad führt aber mehr dorthin.

GenreRepository::findEffectiveTracksForGenre() - die Umkehrung von Node::getEffectiveGenres(): sammelt direkt zugewiesene Tracks PLUS Tracks, deren Album dieses Genre hat und die selbst KEIN eigenes Genre gesetzt haben (Vererbung). Ein Track mit einem ANDEREN eigenen Genre taucht bewusst NICHT auf, selbst wenn sein Album dieses Genre hat - das eigene Genre hat Vorrang, konsistent mit der Anzeige auf der Track-Detailseite. Reine PHP-Objektgraph-Traversierung statt einer komplexen DQL-Abfrage - bei der für eine private Musikbibliothek üblichen Größenordnung unproblematisch, Doctrine lädt die Kind-Collections betroffener Alben ohnehin lazy nach.

Genre-Farben

Manuell wählbare Kartenfarbe pro Genre (Genre::$color, Hex, z.B. "#7c3aed") - bewusst kein eigenes Farbpaletten-System, ein freies Hex-Feld reicht und lässt sich direkt in einem nativen <input type="color"> bearbeiten. Ohne gewählte Farbe fällt die Anzeige weiterhin auf den deterministischen Gradient zurück (aus der Genre-ID abgeleitet), wie bei Alben/Künstlern ohne Cover.

"..."-Menü auf jeder Übersichtskarte (components/_genre_card.html.twig, eigenständige, wiederverwendbare Komponente - GenreController::index() UND ::setColor() rendern beide dasselbe Fragment, damit nach einem Farbwechsel per HTMX NUR diese eine Karte ersetzt wird, nicht die ganze Übersichtsseite). assets/ts/genre-color-menu.ts regelt das Öffnen/ Schließen des Popups rein per Event-Delegation auf document - läuft dadurch automatisch auch für Karten, die erst nach einem HTMX-Swap neu ins DOM kommen, ohne erneuten Init-Aufruf.

Bewusst KEIN verschachteltes <a> um "..."-Trigger + Popup - der klickbare Kartenbereich (Name + Vorschau) ist ein eigener <a> (.genre-overview-link), der Farbwähler-Menü-Bereich (.genre-color-menu) liegt daneben als Geschwister-Element, nicht darin verschachtelt - interaktive Elemente ineinander verschachtelt sind HTML-technisch fragwürdig (Screenreader-/Tastatur-Navigation).

<input type="color"> sendet seinen Wert automatisch mit (eigenes name="color"-Attribut reicht, kein hx-vals nötig - HTMX nimmt jedes Element mit name-Attribut innerhalb seiner Request-Parameter mit, ganz ohne <form>, genau wie beim formularlosen Genre-Editor). Ein separater "Zurücksetzen"-Button (nur sichtbar, wenn aktuell eine Farbe gesetzt ist) schickt stattdessen reset=1 - <input type="color"> selbst kann nie "leer" sein (liefert immer einen gültigen 6-stelligen Hex-Wert), deshalb dieser explizite, separate Weg zurück zum deterministischen Gradient.

hx-select/hx-push-url wieder explizit überschrieben - dieselbe Lektion wie beim Genre-Editor (siehe unten): ohne Override sucht HTMX im Fragment-Response ein #content-Element (findet nichts) und ändert trotzdem die Browser-URL.

Migration Version20260825000000 - fügt genres.color hinzu (nullable, VARCHAR(7)).

Noch keine Admin-Seite zum genrelosen Massenpflegen ("Genre setzen, ohne in jeden Track/jedes Album einzeln reinzugehen") - laut Absprache für später, sobald es sich lohnt. Die aktuelle Pflege läuft ausschließlich über _genre_editor.html.twig direkt auf Track-/Album-Detailseiten.

Track-Detailseite

/track/{id} (track_show, TrackController::show(), templates/track/show.html.twig) - "eine Etage tiefer" als die Album- Seite: großes Cover, alle Metadaten (die wir tatsächlich gespeichert haben - kein rohes ID3-Tag-Dump, MediaImporter::importTrack() extrahiert nur ein festes Feld-Set, keine vollständige Tag-Sammlung), Songtext. Der Tracktitel in jeder Track-Zeile (components/_track_row.html.twig) führt jetzt hierher statt zur Album-/Künstlerseite.

Play-Button ohne neues JS - das sichtbare Hero-Markup trägt dieselben data-track-*-Attribute wie eine normale .track-row in der Liste, verpackt in zwei display:contents-Wrapper ([data-track-list] außen, .track-row innen). .track-row bringt ihr eigenes Grid-Layout (6 enge Spalten für die Listenansicht) mit, das mit dem gewünschten .detail-hero-Layout (260px Cover + Rest) kollidieren würde - display:contents lässt diese Wrapper selbst KEINE eigene Box rendern, ihre Kinder erscheinen direkt im umgebenden .detail-hero-Grid, unbeeinflusst vom .track-row-eigenen Layout. Die DOM-STRUKTUR bleibt für player.ts aber unverändert intakt - wireTrackDelegation()/ playFromRow() finden die Zeile ganz normal über closest('.track-row'), der [data-track-list]-Wrapper stellt zusätzlich sicher, dass NUR dieser eine Track in die Wiedergabe-Warteschlange kommt (ohne einen solchen Vorfahren würde playFromRow() auf document.body zurückfallen und ALLE .track-row-Elemente der Seite einsammeln).

Songtext per JS nachgeladen (assets/ts/track-detail.ts, initTrackDetailLyrics()) - dieselbe loadLyrics()/renderLyrics()- Infrastruktur wie das Lyrics-Panel des Players, aber OHNE Player-Queue- Bezug: der hier gezeigte Track ist nicht zwangsläufig der gerade abgespielte, ein onUpdated-Callback, der den Player-State/localStorage nachzieht, wäre hier fehl am Platz - der Callback rendert deshalb nur lokal im selben Container neu (z.B. nach einem erfolgreichen "Lyrics suchen"-Klick).

Synchronisierte Zeilen sprangen im FALSCHEN (dem gerade laufenden) Track - renderLyrics() hängte für Klick-zum-Springen bisher UNBEDINGT einen Handler an, der das globale #player-audio-Element ansteuert - im Player-Panel/Karaoke-Overlay korrekt (dort ist der gezeigte Text immer der des laufenden Tracks), auf der Track-Detailseite aber nicht: ein Klick sprang witzig, aber falsch im aktuell laufenden, meist komplett ANDEREN Track. renderLyrics() hat dafür jetzt einen seekable-Parameter (Standard true, bewahrt das bisherige Verhalten für Panel/Karaoke) - track-detail.ts übergibt explizit false, kein Klick-Handler wird angehängt, .lyrics-static (CSS-Klasse auf dem Container) nimmt zusätzlich den Zeiger-Cursor/Hover-Effekt weg, damit es auch optisch klar nicht-interaktiv wirkt.

Klick auf Künstler-/Album-Namen im Untertitel navigiert weiterhin gezielt zu Künstler/Album (unverändert) - nur der HAUPTTITEL selbst wechselt jetzt zur Track-Detailseite. Die Album-/Künstler-Sprungmarken- Navigation der Player-Leiste (data-track-link, "wo ist dieser Track in meiner Bibliothek") bleibt davon unberührt, eine bewusst separate Funktion von "zeig mir Details zu DIESEM Track".

Player-Leiste-Titelklick tat nichts mehr, wenn ein Track von seiner eigenen Detailseite aus abgespielt wurde - data-track-link war dort zunächst bewusst leer gesetzt (da man ja schon auf der Detailseite ist), das führt aber zu titleLink.href = '#' in player.ts - ein Klick darauf tut buchstäblich nichts. Jetzt trägt auch die Detailseite dieselbe title_target-Sprungmarke (Album-/Künstlerseite) wie jede normale Track-Zeile - der Player-Leiste-Klick verhält sich dadurch konsistent, egal von wo der Track gestartet wurde.

Wie auf jeder bekannten Plattform: Klick auf einen Tracktitel/Künstler/ Album navigiert dorthin (mit Sprungmarke + Hervorhebung zum Track auf der Zielseite), Klick auf Cover/Play-Button spielt ab - zwei getrennte Aktionen. components/_track_row.html.twig löst dafür pro Track die strukturellen Eltern auf (track.parent -> Album -> dessen Parent -> Künstler) und legt sowohl fertige Links im Markup selbst an (Untertitel: Künstler- und Album-Name je eigener Link) als auch data-track-*-Attribute (data-track-artist-link, data-track-album-link, data-track-link mit #track-row-ID-Anker) für JS-Konsumenten.

Sprungmarke landet nicht hinter der sticky Topbar (--topbar, 76px Desktop/64px Mobile, .topbar{position:sticky;top:0}) - scroll-margin-top: var(--topbar) sitzt auf .track-row selbst (nicht nur .track-row:target, siehe unten warum), CSS respektiert das bei scrollIntoView() unabhängig vom block-Parameter. Eine einzige CSS-Variable statt zweier unabhängig gepflegter Zahlen (app.css, ganz oben): treibt sowohl die TATSÄCHLICHE Topbar-Höhe (Override der theme.css-Vorgabe) als auch jede scroll-margin-top, die die Topbar berücksichtigen muss - bleibt automatisch synchron, egal welcher künftige Code zu einem .track-row scrollt (scrollIntoView(), Browser-eigene #anchor-Navigation, ...), ohne dass man an mehreren Stellen nachziehen müsste. Zwei Werte, weil die Topbar selbst schon responsiv ist (theme.css, @media(max-width:700px)) - dieselbe Bedingung in app.css gespiegelt, damit --topbar in jedem Breakpoint zur tatsächlichen Höhe passt. scrollToHashTarget() (app.ts) nutzt dafür bewusst block: 'start' statt 'center' - bei 'center' kann der Browser, wenn die Seite/Liste zu kurz für echtes Zentrieren ist (wenig Scroll- Spielraum, z.B. ein kleines Album mit nur 2-3 Tracks), das Ziel trotzdem ganz oben landen lassen, wo die Marge unzuverlässiger greift. 'start'

  • Marge ist das vorhersehbarere Paar. Betrifft NUR diesen Hauptseiten- Scroll - die Lyrics-/Karaoke-Sync-Zeile (lyrics.ts, updateActiveLine()) scrollt innerhalb eines eigenen Panel-/Overlay-Containers, dort gibt's keine Topbar-Überlagerung, block: 'center' bleibt dort unverändert richtig.

Der eigentliche Grund, warum das trotz der Marge weiterhin zu Position 0 sprang: scrollToHashTarget() verließ sich komplett auf location.hash NACH dem HTMX-Swap - ein #fragment wird nie an den Server gesendet (reine Client-Angelegenheit), und ob HTMX es beim eigenen history.pushState() nach einem Boosted-Request überhaupt zuverlässig überträgt, ist nicht garantiert. War das nicht der Fall, war location.hash leer, scrollToHashTarget() brach sofort ab (if (!target) return), und übrig blieb nur HTMX' eigenes Standardverhalten nach einer Boosted-Navigation: zum Seitenanfang scrollen - exakt das gemeldete Symptom, unabhängig von scroll-margin-top/block, weil der eigene Scroll-Code gar nicht erst lief. Behoben, indem das #fragment schon BEIM KLICK selbst gemerkt wird (pendingScrollTarget, aus dem originalen href, per click-Listener im CAPTURE-Modus registriert - garantiert VOR HTMX' eigenem Klick-Handler, unabhängig von der Registrierungsreihenfolge) statt sich erst nach dem Swap auf location.hash zu verlassen. Fällt nur noch auf location.hash zurück, wenn es gar keinen vorherigen Klick gab (erstes Laden mit Hash in der URL - Lesezeichen, geteilter Link).

Player-Leiste und Warteschlangen-Panel sind kein Server-Template, sondern werden von assets/ts/player.ts per JS befüllt - deshalb spiegelt buildArtistAlbumHtml() dort exakt dieselbe "Künstler · Album, je eigener Link"-Logik wie der Twig-Untertitel, nur clientseitig.

htmx.process() nach jedem Trackwechsel, sonst bricht die Wiedergabe beim Klicken ab. HTMX erkennt "boost-fähige" <a>-Tags beim initialen Scan der Seite - ein zur Ladezeit hrefloser Link (der Titel-Link hat anfangs kein Ziel, bis der erste Track lädt) oder per innerHTML neu eingefügte Links (Künstler/Album in #playerArtist) werden dabei nicht zuverlässig automatisch erkannt. Ohne explizites Nachscannen bliebe ein Klick eine normale, nicht von HTMX abgefangene Browser-Navigation - ein kompletter Seiten-Reload, der die laufende Wiedergabe beendet (genau das gemeldete Symptom). loadTrack() ruft deshalb nach dem Setzen von titleLink.href und dem Neuaufbau von #playerArtist einmal window.htmx.process(document.querySelector('.player-track')) auf - sagt HTMX explizit "scanne diesen Bereich (neu)", deckt damit automatisch sowohl den Titel-Link als auch die Künstler-/Album-Links ab, ohne für jeden einzeln gesondert etwas tun zu müssen. Kein eigener Klick-Handler/history.pushState() nötig - sobald HTMX den Link korrekt erkennt, übernimmt es Navigation und URL-Pushen ganz normal selbst.

Bewusst STRUKTURELLE Namen (artist.name/album.name) statt der reinen ID3-Tag-Strings für Linktext UND -Ziel zusammen - sonst könnte der angezeigte Name (Tag) vom tatsächlichen Link-Ziel (Struktur) abweichen, v.a. bei Compilation-Tracks unter "Various Artists" (siehe "Compilations / Various Artists" weiter unten).

Wiedergabe komplett leeren

Papierkorb-Symbol ganz rechts in der Player-Toolleiste (MusicDockPlayer::clearPlayback()) - anders als playPause()/ onTrackEnded(), die den Player sichtbar und "bereit für den nächsten Track" lassen, entfernt das JEDE Wiedergabe-Information komplett UND blendet die Player-Leiste selbst aus (display:none über .player-bar--hidden), statt sie nur leer stehen zu lassen:

  • Audio wird pausiert UND die Quelle wirklich entladen (removeAttribute('src') + load()) - nicht nur pausiert, sonst bliebe die zuletzt geladene Datei im Hintergrund geladen.
  • Warteschlange + currentIndex zurückgesetzt, localStorage-Einträge für Queue/Index/Position gelöscht. Lautstärke bleibt bewusst erhalten (eigener Key, wird nicht gelöscht) - das ist eine Geräte-/ Nutzereinstellung, keine Wiedergabe-Information.
  • Alle .track-row--active-Markierungen verschwinden automatisch (syncActiveRowHighlight() mit leerer Queue), Warteschlangen-Panel zeigt den leeren Zustand.

Kommt automatisch wieder, sobald erneut ein Track gestartet wird - loadTrack() entfernt .player-bar--hidden als erstes, bevor es irgendetwas anderes tut. Gilt auch für restoreFromStorage() beim nächsten Seitenaufruf (falls dann wieder eine gespeicherte Queue existiert - nach clearPlayback() ist sie das nicht, die Leiste bleibt dann im normalen, nicht versteckten Leerzustand "Kein Track ausgewählt").

Sidebar reichte nicht bis ganz unten, solange kein Player sichtbar war - .app-sidebar reserviert per theme.css (inset:0 auto var(--player) 0) IMMER Platz für die Player-Leiste am unteren Rand, unabhängig davon, ob sie gerade sichtbar ist. Fehlt sie (.player-bar--hidden), blieb eine unschöne leere Lücke stehen. Behoben mit body:has(.player-bar--hidden) .app-sidebar { bottom: 0 !important; }

  • :has() nutzen wir schon bei den Suchvorschlägen, hier derselbe Ansatz statt einer JS-Lösung.

Durchgehende Wiedergabe bei Navigation

Der <audio>-Tag liegt in templates/player/_bar.html.twig, außerhalb des Bereichs, den HTMX bei Klicks austauscht:

<body hx-boost="true" hx-target="#content" hx-select="#content" hx-swap="outerHTML">
  ...
  <main id="content" hx-history-elt>{% block content %}{% endblock %}</main>
  {% include 'player/_bar.html.twig' %}   {# bleibt bei Navigation unberührt #}

hx-boost fängt Klicks auf interne Links ab, lädt die Zielseite per AJAX und ersetzt nur #content - die Player-Leiste (und damit das laufende <audio>-Element) wird nie aus dem DOM entfernt.

hx-swap="outerHTML", nicht "innerHTML" - wichtige Lektion, die auch für jede künftige HTMX-Erweiterung gilt. hx-select wählt zwar ein Element aus der Server-Antwort aus, aber HTMX swappt dabei dessen KOMPLETTES outerHTML (inklusive des Elements selbst) gemäß hx-swap in den Target ein - nicht nur seinen Inhalt. Bei hx-select="#content" + hx-target="#content" + hx-swap="innerHTML" landet also das GANZE <div id="content"> aus der Antwort als KIND im bereits bestehenden #content - zwei verschachtelte #content-Divs, doppeltes CSS-Padding (genau der Bug, der das mal ausgelöst hat, sichtbar nur beim Vergleich zwischen einem hart geladenen und einem per HTMX-Boost nachgeladenen Dokument). outerHTML ersetzt das Ziel-Element stattdessen komplett durch das ausgewählte - keine Verschachtelung. Gilt für jeden Fall, wo Ziel- und Auswahl-Selektor dieselbe ID treffen - betraf auch die Suchvorschau (siehe unten).

Ausnahme, wo innerHTML weiterhin richtig ist: wenn JS eine feste Referenz auf das Ziel-Element hält (z.B. navigation.ts für die Suchvorschau, zum Schließen bei Klick außerhalb). Ein outerHTML-Swap würde diese DOM-Node bei jedem Request ersetzen und die JS-Referenz veralten lassen. Lösung dort: Ziel- und Auswahl-ID bewusst UNTERSCHIEDLICH halten (#search-suggestions bleibt stabil, nur sein Kind #search-suggestions-inner wird per innerHTML ausgetauscht) - dann ist innerHTML sicher, weil keine Verschachtelung entstehen kann.

Browser Zurück/Vor: HTMX snapshotet für die History standardmäßig den kompletten <body> und würde bei einem Zurück-Klick alles - inklusive Player-Leiste - durch die alte Version ersetzen. Das hx-history-elt auf #content sorgt dafür, dass nur dieser Bereich gesnapshotet und bei Zurück/Vor wiederhergestellt wird. Der Player ist damit bei jeder Art von Navigation (Klick, Boost, History) safe.

assets/ts/player.ts bindet außerdem nur einen delegierten Click-Listener auf document für alle .track-play-btn - der funktioniert automatisch auch für Zeilen, die HTMX erst nach einem Swap oder einer History-Restore ins DOM einfügt, ohne dass irgendwo neu gebunden werden müsste. Nach jedem htmx:afterSettle (Swap und History-Restore) wird zusätzlich die Markierung der aktuell spielenden Zeile neu gesetzt, damit die Optik nie hinter dem tatsächlichen Player-State zurückbleibt.

Einzige verbleibende Grenze: ein vollständiger Seiten-Reload (F5) lädt zwangsläufig auch dieses Skript neu und unterbricht die Wiedergabe - das ist technisch nicht vermeidbar (jede Web-App verliert dabei ihren State). Die Warteschlange wird aber in localStorage gespiegelt, sodass nach einem Reload zumindest Titel/Position/Warteschlange wiederhergestellt werden (Autoplay bleibt aus, weil Browser automatisches Abspielen ohne Nutzerinteraktion blockieren).

Streaming & Spulen

StreamController::stream() nutzt Symfonys BinaryFileResponse mit ->prepare($request) das wertet einen Range-Header automatisch aus und liefert bei Bedarf 206 Partial Content. Genau das braucht der Browser, um im <audio>-Element vor- und zurückspulen zu können, ohne die komplette Datei neu zu laden.

Track-Download

GET /download/{id} (StreamController::download()) - eigener Endpunkt, nicht einfach ein Query-Parameter an /stream: der <audio>-Tag erwartet beim Streamen KEIN Content-Disposition: attachment (manche Browser laden dann gar nicht erst ab, statt abzuspielen), Download und Streaming brauchen also unterschiedliche Header auf demselben zugrundeliegenden File-Response. Dateiname wird aus Künstler + Titel gebaut (nicht der intern genutzte UUID-Dateiname in storage/audio/), Sonderzeichen entfernt, die auf gängigen Dateisystemen (v.a. Windows) Probleme machen könnten. Ausgelöst über "Herunterladen" im Kontextmenü (echte Navigation, window.location.href, kein fetch()/Blob-Handling nötig - der Browser übernimmt den Download-Dialog selbst korrekt).

Album-Download (ZIP)

GET /album/{id}/download (StreamController::downloadAlbum()) - packt alle Tracks eines Albums als ZIP, ohne Kompression (ZipArchive::CM_STORE statt des Standard-Deflate): Audiodateien (MP3/FLAC/etc.) sind bereits komprimiert, ein erneutes Deflate würde nur CPU-Zeit kosten, ohne den Platzbedarf nennenswert zu senken. addFile() statt addFromString(file_get_contents()) liest beim Packen direkt von der Platte, läuft nicht komplett durch den PHP-Speicher - wichtig bei großen Alben. Das ZIP wird in eine temporäre Datei geschrieben (nicht komplett im Speicher aufgebaut) und nach vollständigem Versand automatisch gelöscht (BinaryFileResponse::deleteFileAfterSend(true)). Fehlt bei einem einzelnen Track die Datei (z.B. extern gelöscht), wird er übersprungen statt den ganzen Download abzubrechen. Tracknummer wird dem Dateinamen im ZIP vorangestellt, damit die Reihenfolge auch außerhalb von MusicDock erkennbar bleibt.

Braucht ext-zip (in composer.json deklariert) - in den meisten PHP-Images (auch DDEVs Standard-Web-Image) bereits enthalten, falls nicht: .ddev/config.yaml -> webimage_extra_packages bzw. die passende PHP-Extension nachinstallieren.

hx-boost="false" auf dem Download-Button ist Pflicht, kein Stilfehler. Ein normaler <a href> würde sonst von HTMX' globalem hx-boost (siehe <body> in base.html.twig) abgefangen und als AJAX-Request statt einer echten Browser-Navigation behandelt - der Browser zeigt den nativen Download-Dialog nur bei echter Navigation, nicht bei einem XHR/fetch-Response. Jeder künftige Download-Link (egal welche Route) braucht dasselbe Attribut, sonst passiert beim Klick sichtbar nichts (der Download lädt unsichtbar per AJAX und versucht dann, seinen Binärinhalt sinnlos in #content zu swappen). Der Track-Download im Kontextmenü ist davon NICHT betroffen - der läuft über window.location.href in JS, nicht über ein echtes <a>-Tag im DOM, und umgeht HTMX' Klick-Interception dadurch automatisch.

"Läuft gerade"-Anzeige in der Track-Zeile

Die aktuell spielende Zeile bekam schon vorher .track-row--active (Hintergrund-Tönung + Akzentfarbe im Titel) - auf den ersten Blick war das aber nicht unbedingt als "läuft gerade" statt nur "das ist der aktuelle Track" erkennbar. Deshalb ersetzt die Tracknummer-Spalte bei der aktiven Zeile ihre Zahl durch drei kleine animierte Balken (.playing-bars, CSS-Keyframes, kein JS-getriebenes Neuzeichnen nötig) - dasselbe visuelle Muster wie z.B. bei Spotify.

Animiert NUR, wenn tatsächlich Ton läuft (body.is-playing, per JS bei den play/pause-Events des <audio>-Elements gesetzt) - pausiert zeigt sich derselbe Balkensatz ruhig stehend statt fälschlich "läuft gerade" zu signalisieren. Der CSS-Selektor kombiniert beide Zustände (body.is-playing .track-row--active .playing-bars i), kein zusätzliches Nachziehen pro Zeile nötig - funktioniert automatisch auch für Zeilen, die erst nach einem HTMX-Swap ins DOM kommen, weil .track-row--active von syncActiveRowHighlight() ohnehin schon nach jedem Swap neu gesetzt wird.

Überarbeitet, weil kaum sichtbar (erste Version: 13px hoch, 2px breit, alle drei Balken exakt dieselbe Basis-Höhe UND dieselben Animations-Keyframes) - dadurch sahen sie eher wie drei winzige, gleich hohe Punkte aus statt wie ein Equalizer, selbst mit laufender Animation kaum als solche wahrnehmbar. Jetzt: 20px hoch, 3px breit (.track-number ist laut theme.css 38px breit, die Zeile mindestens 52px hoch - reichlich Platz), unterschiedliche Basis-Höhen pro Balken (45/90/65%, sieht auch im pausierten/statischen Zustand nach einem "eingefrorenen Equalizer" aus, nicht nach drei gleichen Strichen).

Animierte nur einmal kurz, dann Stillstand - reine CSS-Lösung letztlich aufgegeben. Erster Versuch: eine gemeinsame @keyframes- Animation für alle drei Balken. Zweiter Versuch: drei getrennte @keyframes mit konsolidierten animation-Shorthand-Deklarationen (statt separater animation/animation-delay-Properties, die theoretisch dieselbe Override-Reihenfolge-Problematik hätten haben können). Beides zeigte in der Praxis dasselbe Symptom - die genaue Ursache ließ sich trotz mehrfacher Korrektur nicht zweifelsfrei isoliert reproduzieren. Statt weiter an reinem CSS zu suchen: komplett auf JS-gesteuerte Animation umgestellt (MusicDockPlayer::animatePlayingBars(), requestAnimationFrame) - dieselbe Grundidee wie beim WebGL-Visualizer, hier aber einfach nur drei <i>-Höhen, die per style.height gesetzt werden (transition: height 0.12s linear in CSS glättet zwischen den Frames). Drei leicht unterschiedliche Sinus-Frequenzen/Phasen statt CSS-Keyframes - eliminiert jede Abhängigkeit von CSS-Animation-Zuverlässigkeit komplett, läuft dauerhaft im Hintergrund (wie der Visualizer), tut aber nichts (kein nennenswerter Overhead), solange weder body.is-playing gesetzt ist noch eine .track-row--active .playing-bars im DOM existiert.

Struktur der Warteschlange (Queue)

Jede Track-Zeile (templates/components/_track_row.html.twig) trägt ihre Daten als data-*-Attribute und einen Play-Button. Klick auf ▶ sammelt player.js alle .track-row-Geschwister im selben [data-track-list]-Container ein und baut daraus die Warteschlange die Album-, Kategorie-, Dateimanager- und Suchansicht müssen dafür nichts Player-spezifisches wissen, sie liefern nur Markup mit den richtigen Attributen.

Cover

MediaImporter löst pro Album in dieser Reihenfolge auf:

  1. cover.jpg/cover.png/folder.jpg/folder.png direkt im Album-Ordner
  2. eingebettetes ID3-Bild des ersten Tracks im Album
  3. kein Cover -> Frontend zeigt einen Platzhalter (components/_cover.html.twig)

Jeder Track bekommt zusätzlich sein EIGENES eingebettetes Cover (importTrack()), nicht nur Album/Künstler. Ursprünglich extrahierte NUR der allererste Track eines Parents ohne Cover ein Bild, und zwar ausschließlich für den PARENT - alle Tracks eines Albums zeigten dadurch zwangsläufig dasselbe (Parent-)Bild, selbst wenn einzelne Tracks (Live- Mitschnitte, Compilations, Podcast-artige Sammlungen) ein eigenes, abweichendes Cover eingebettet hatten. Jetzt versucht jeder einzelne Track sein eigenes Bild zu extrahieren und speichert es auf sich selbst; der Parent bekommt weiterhin (wie bisher) ein Fallback-Cover vom ersten Track mit Bild, falls er noch keins hat. components/_track_row.html.twig prüft dafür ohnehin schon zuerst das Track-eigene Cover, bevor es auf den Parent zurückfällt (track.getMetadataValue('cover_thumb_url') ?: ...)

  • die Anzeigenlogik war also schon vorbereitet, nur die Datenerzeugung fehlte. Kostet bei "normalen" Alben (identisches Cover in jeder Datei eingebettet) etwas mehr Speicherplatz/Rechenzeit (pro Track eigene Thumb+Mini-Dateien statt nur einmal auf Album-Ebene) - keine Deduplizierung über den Bildinhalt, bewusst einfach gehalten.

Künstler-Kacheln zeigen eine Collage aus bis zu 4 Album-Covern statt nur eines einzelnen Bilds: MediaImporter::addAlbumCoverToArtistCollage() sammelt beim Import die Cover der ersten (bis zu) 4 Alben mit einem Cover in metadata.cover_urls auf dem Künstler-Node, dedupliziert dabei über die URL. Das Frontend (components/_cover.html.twig + .cover-collage-* in assets/css/app.css) rendert je nach Anzahl ein passendes CSS-Grid-Layout: 2 nebeneinander, 3 (eines groß links, zwei gestapelt rechts), 4 im klassischen 2×2-Raster. Bei nur einem Album-Cover oder keinem zeigt die Kachel wie bisher ein einzelnes Bild bzw. den Platzhalter. Tracks ohne Album-Unterordner (direkt beim Künstler) versuchen nur ihr eigenes eingebettetes Bild.

War zwischenzeitlich komplett unsichtbar, reiner Frontend-Bug, kein Backend-Problem. MediaImporter::addAlbumCoverToArtistCollage() hat metadata.cover_urls die ganze Zeit über korrekt befüllt - der Twig-Code in _cover.html.twig, der diese Daten tatsächlich ANZEIGT, ist bei einem der Lazy-Loading-/Mini-Thumbnail-Refactors versehentlich verloren gegangen (das Template wurde mehrfach komplett neu geschrieben, dabei blieb nur der beschreibende Kommentar übrig, nicht der Code selbst) - Künstler-Kacheln zeigten seitdem nur noch den Gradient-Platzhalter, auch wenn Album-Cover längst vorhanden waren. Wiederhergestellt, mit Lazy-Loading-Unterstützung (jedes Collage-Bild einzeln per data-src).

Zweiter, unabhängiger Grund, warum sie trotzdem nicht auftauchte: JEDE Stelle im Frontend, die einen Künstler-Node rendert (Home-Seite, Künstler-Übersicht), übergibt shape: 'round' an _cover.html.twig - die erste Wiederherstellung hatte (dem alten Kommentar folgend "eine 2x2-Collage sieht in einem Kreis nicht gut aus") Collagen für shape == 'round' bewusst ausgeschlossen. Da es aktuell KEINE eckigen Künstler-Kacheln im UI gibt, war die Collage dadurch technisch tot, unabhängig vom ersten Fix. Diese Einschränkung ist jetzt aufgehoben - eine runde Collage funktioniert optisch problemlos: overflow:hidden auf der äußeren Kachel rundet die 2x2-Aufteilung automatisch mit ab, keine gesonderte CSS-Änderung nötig.

Dritter, ebenfalls unabhängiger Grund: die Künstler-DETAILSEITE (artist/show.html.twig, der große runde Avatar oben auf der eigenen Künstlerseite) band _cover.html.twig gar nicht erst ein - eigenes, separates Cover-Handling, das nur cover_url (das einzelne, vom ersten Album übernommene Cover) kannte, cover_urls (die Collage-Daten) nie berücksichtigte. Jetzt durch dasselbe gemeinsame Include ersetzt wie auf den Grid-Seiten ({% include 'components/_cover.html.twig' with {node: artist, shape: 'round', extra_class: 'artist-large'} %}) - kein duplizierter Code mehr für dieselbe Anzeigenlogik an drei Stellen. extra_class ist dafür neu in _cover.html.twig: die Detailseite braucht eine feste 220px-Größe (.artist-large), die shape: 'round' allein nicht mitbringt (.artist-photo hat KEINE feste Größe, die kommt normalerweise vom umgebenden Grid) - wird einfach zusätzlich zur shape_class gesetzt.

Vierter Grund, diesmal ein ECHTES Datenproblem statt nur Anzeige: addAlbumCoverToArtistCollage() lief bisher AUSSCHLIESSLICH im media/-Hauptimport (importAll()), nie im Upload-Workflow (importUploadedFile()). Künstler, die ausschließlich über den Uploader importiert wurden, bekamen dadurch nie eine Künstler-Collage UND nicht mal das einfache Einzelbild-Fallback (metadata.cover_url/ cover_thumb_url auf dem Künstler-Node selbst) - selbst wenn ihre Alben längst eigene Cover hatten. importUploadedFile() ruft addAlbumCoverToArtistCollage() jetzt ebenfalls auf (nach importTrack(), nur wenn der Track tatsächlich in einem Album landet - lose Tracks direkt beim Künstler haben kein Album, dessen Cover in die Collage aufgenommen werden könnte, die bekommen ihr Cover bereits über importTrack()s eigenen generischen Fallback). Hier ist ein Reimport (--flush oder erneutes Hochladen über den Uploader) tatsächlich nötig - anders als bei den ersten drei Gründen waren hier wirklich Daten nie geschrieben worden, nicht nur die Anzeige kaputt.

Dateinamen sind UUIDs, keine Datenbank-IDs. Jeder Node bekommt eine UUID (Node::$uuid), die deterministisch (UUID v5) aus dem relativen Datei-/Ordnerpfad berechnet wird (MediaImporter::uuidFor()) - nicht zufällig. Cover landen entsprechend als <uuid>.<ext> (Vollbild, Format wie importiert - JPEG/PNG/WebP), <uuid>_thumb.jpg (max. 300px) und <uuid>_mini.jpg (max. 80px, siehe unten) - Thumb/Mini sind IMMER JPEG, unabhängig vom Format des Vollbilds (siehe unten). Alle per GD erzeugt (ext-gd muss aktiv sein, in DDEVs PHP-Images standardmäßig der Fall) und werden direkt vom Webserver ausgeliefert, kein eigener Controller nötig. Grid-Kacheln, die Player-Leiste und die Warteschlange verwenden bevorzugt die Thumbnail-Variante (metadata.cover_thumb_url), Track-Zeilen die Mini-Variante (siehe unten) - beide fallen automatisch aufs jeweils nächstgrößere Bild zurück, falls aus irgendeinem Grund keine erzeugt werden konnte.

Thumb/Mini sind grundsätzlich JPEG, auch bei einer PNG- oder WebP-Quelle (generateThumbnail()) - deutlich kleinere Dateien als PNG bei praktisch identischer wahrgenommener Qualität für fotografisches Cover-Material (PNG lohnt sich nur bei Flächen/Text/Transparenz, nicht bei Albumcovern, die so gut wie nie transparent sind). Weißer Hintergrund vor dem Kopieren, für den in der Praxis kaum vorkommenden, aber defensiv behandelten Fall von Transparenz in der Quelle - JPEG kennt keinen Alphakanal. Das Vollbild (cover_url) bleibt davon unberührt - nur die generierten Ableitungen (Thumb/Mini) werden komprimiert, nie die als Master gedachte Originaldatei; wer die auch komprimiert haben möchte, müsste das gesondert einrichten.

Mini-Thumbnails für Track-Zeilen (metadata.cover_mini_url, MediaImporter::MINI_THUMBNAIL_MAX_DIMENSION = 80, MINI_THUMBNAIL_JPEG_QUALITY = 55) - eine dritte, deutlich kleinere und stärker komprimierte Variante NUR für components/_track_row.html.twig. Track-Zeilen zeigen das Cover typischerweise bei ~38-42px CSS-Größe, selbst bei High-DPI-Displays (2x) reichen dafür ~80px tatsächliche Pixel völlig - bei langen Tracklisten (z.B. 60 Tracks/Seite) summiert sich der Unterschied zur 300px-Variante spürbar. generateThumbnail() ist dafür parametrisiert (Zielgröße + Qualität als Parameter) statt die GD-Skalierlogik zu duplizieren - finalizeCover() ruft sie einmal für jede der beiden Größen auf (THUMBNAIL_JPEG_QUALITY = 85 für die "normale" Größe, 55 für die Mini-Variante - bei der winzigen Anzeigegröße fallen Kompressionsartefakte praktisch nicht auf). Player-Leiste/Warteschlange bleiben bewusst bei der 300px-Variante (data-track-cover in _track_row.html.twig nutzt weiterhin cover_thumb_url) - die zeigen das Cover größer, würden mit der Mini-Variante sichtbar unscharf.

Sharding in 256 Bucket-Ordner (00-ff). Cover liegen nicht flach in public/media/covers/, sondern in public/media/covers/<bucket>/<uuid>.jpg - <bucket> sind die ersten zwei Hex-Zeichen der UUID (MediaImporter::coverBucket()). Bei einer großen Bibliothek (mehrere Tausend Cover + Thumbnails) verteilt das die Dateien gleichmäßig über 256 Ordner, statt alle in einem einzigen Verzeichnis zu sammeln - viele Dateisysteme werden bei zehntausenden Einträgen in einem Ordner spürbar langsamer (Verzeichnis-Listing, Lookups). Da UUIDs (v5-Hashes) bereits gleichverteilt aussehen, reicht "erste zwei Hex-Ziffern nehmen" für eine gleichmäßige Verteilung, ganz ohne eine separate Hash-Funktion. Alle 256 Ordner werden einmal zu Beginn von importAll() angelegt (ensureCoverBuckets()), damit nicht bei jeder einzelnen Datei ein is_dir()/mkdir()-Check nötig ist.

Zwei Gründe für UUIDs statt nodes.id im Dateinamen:

  • Keine fortlaufenden, erratbaren IDs in öffentlichen URLs.
  • Stabil über --flush-Neuaufbauten hinweg: weil die UUID aus dem Pfad berechnet wird, ergibt derselbe Ordner/dieselbe Datei nach einem app:import-media --flush wieder exakt dieselbe UUID und denselben Cover-Dateinamen - Browser-Caches bleiben gültig, keine verwaisten Cover-Dateien.

Lazy-Loading für Cover-Bilder

assets/ts/lazy-images.ts, IntersectionObserver-basiert - Bilder in Grids/Listen (Alben, Künstler, Track-Zeilen) laden erst kurz BEVOR sie in den sichtbaren Bereich scrollen (rootMargin: '200px' als Vorlauf, meist schon fertig geladen, bevor man tatsächlich hinscrollt), nicht alle sofort beim Seitenaufbau - bei vielen Einträgen auf einer Seite (z.B. 60 Tracks/Seite) spart das spürbar Bandbreite und Ladezeit.

Arbeitet mit dem bestehenden Gradient-Platzhalter-Muster zusammen (gradient_class-Filter) statt einer separaten Lösung: der Container trägt jetzt IMMER die deterministische Verlaufsfarbe als Hintergrund - vorher nur im "kein Cover"-Fall, jetzt zusätzlich als Ladezustand, bis das per data-src verzögert geladene <img> tatsächlich da ist. Das <img> liegt mit opacity:0 darüber (.lazy-cover) und blendet erst sanft ein (.lazy-loaded, nach dem load-Event per JS gesetzt) - bis dahin bleibt der Verlauf sichtbar statt eines leeren/kaputten Bild-Icons, kein zusätzlicher Zwischenzustand nötig.

initLazyImages() ist sicher mehrfach aufrufbar (nach jedem HTMX-Swap, in app.ts sowohl initial als auch bei htmx:afterSettle) - bereits verarbeitete <img>s (Klasse .lazy-observed) werden übersprungen, nur neu ins DOM gekommene beobachtet. Kein IntersectionObserver im Browser (sehr alt)? Bilder laden dann einfach sofort, kein Lazy-Loading, aber auch kein Fehlerzustand.

Bewusst NICHT für die Warteschlange (Queue-Panel) oder die großen Detail-Hero-Bilder (Album-/Künstlerseite) - die Queue ist typischerweise kurz und wird per JS-innerHTML gerendert (zusätzlicher Aufwand, erneut initLazyImages() nach jedem renderQueue()-Aufruf zu rufen, für wenig Nutzen bei wenigen Einträgen), Hero-Bilder sind ohnehin fast immer "above the fold" beim Seitenaufruf - Lazy-Loading brächte dort keinen nennenswerten Vorteil.

Mehrfach-CDs (Disc-Nummer)

MediaImporter liest zusätzlich zur Tracknummer (ID3v2 TRCK) auch die Disc-Nummer (ID3v2 TPOS, bei getID3 als part_of_a_set verfügbar) und speichert sie als metadata.disc_number. Beide werden zu einer einzigen Sortierzahl kombiniert (Node::sortOrder): Disc 1 Track 3 → 1003, Disc 2 Track 1 → 2001 - eine reine Tracknummern-Sortierung hätte sonst "Disc 2, Track 1" vor "Disc 1, Track 12" einsortiert. Fehlt der Disc-Tag, wird Disc 1 angenommen (Normalfall Einzel-CD).

In der Track-Zeile wird die Disc-Nummer nur als Präfix angezeigt, wenn sie tatsächlich > 1 ist (z.B. 2-05) - bei normalen Einzel-CDs bleibt es beim schlichten 05, um nicht unnötig zu drängen.

(Cover nicht versioniert, siehe .gitignore - reproduzierbar durch erneuten app:import-media-Lauf.)

Import zurücksetzen (--flush)

ddev exec bin/console app:import-media --flush

Löscht vor dem eigentlichen Import alle bestehenden Nodes (Künstler, Alben, Tracks), alle PlaylistItems (per FK-Cascade automatisch, weil sie auf gelöschte Track-Nodes zeigen würden - die Playlists selbst bleiben als leere Container erhalten), alle generierten Cover-Dateien (MediaImporter::clearCovers()) und alle Dateien in storage/audio/ (MediaImporter::clearAudioStorage()). Fragt interaktiv nach Bestätigung, außer --no-interaction ist gesetzt. Ohne --flush legt ein erneuter Lauf schlicht doppelte Nodes an - es gibt (noch) keinen Abgleich "nur neue/ geänderte Dateien importieren".

Nur sicher, solange bisher ohne --move importiert wurde - siehe "Speicher-Architektur" weiter oben. Nach einem --move-Import ist media/ leer, und --flush hätte nichts mehr zum Reimportieren.

Sinnvoll immer dann, wenn sich an der Bibliotheksstruktur grundlegend was geändert hat, oder direkt nach einer Migration wie Version20260822000000 (führt selbst schon ein TRUNCATE durch, siehe deren Beschreibung) - danach ist die DB leer und ein --flush-Import baut alles sauber neu auf.

Tag-basierter Import (_dump-Ordner)

Für Dateien, bei denen sich eine saubere Ordnerstruktur nicht lohnt oder nicht vorhanden ist - einfach in media/_dump/ werfen, beliebig flach oder verschachtelt:

media/
  Pink Floyd/                 <- normal ordnerbasiert, wie gehabt
    The Dark Side of the Moon/
      01 Speak to Me.mp3
  _dump/                      <- Sonderordner, wird NICHT als Künstler interpretiert
    irgendwas/x/y/song.mp3    <- Ordnerlage hier komplett egal
    noch_ein_song.mp3

Alles unterhalb von _dump/ wird rekursiv gescannt und ausschließlich anhand der ID3-Tags (Artist, Album, Title) einsortiert - MediaImporter::importDumpFolder(). Künstler/Album werden dabei per Namensabgleich wiederverwendet statt dupliziert (NodeRepository::findChildByNameAndKind()): trifft der Tag-Künstlername "Pink Floyd" auf den bereits ordnerbasiert vorhandenen Künstler "Pink Floyd", landet der Track dort - unabhängig davon, ob der Zielknoten ursprünglich aus einem echten Ordner oder aus Tags entstanden ist. Fehlen Artist-/Album-Tags komplett, landen die Tracks unter "Unbekannter Künstler"/"Unbekanntes Album".

Weil ein tag-erzeugter Künstler/Album-Node ein ganz normaler Node mit metadata.kind ist wie jeder ordnerbasierte auch, braucht der Rest der App (Künstler-/Kategorie-/Dateimanager-Ansicht, Suche, Player, Playlists) dafür keine einzige Sonderfall-Zeile - das ist genau der Punkt der Ordner-als-Baum-Abstraktion.

Künstler-Erkennung bei losen Tracks ("Künstler - Album" als ein Ordner)

Nicht jede Bibliothek ist sauber Künstler/Album/Track.mp3 verschachtelt - ein gängiges reales Muster (v.a. bei Einzel-Downloads/EPs) ist ein einziger Ordner wie The Koi Boys - Meant to be/ direkt unter media/, ohne eigenen Album-Unterordner. Ohne Gegenmaßnahme interpretiert der Import so einen Ordner als KÜNSTLER (Name = kompletter Ordnername inklusive Album-Teil) und die Tracks darin als Singles ohne Album - sichtbar falsch, sowohl beim Künstlernamen als auch bei der fehlenden Album-Gruppierung.

Deshalb: für Tracks, die direkt in einem "Künstlerordner" liegen (kein Album-Unterordner), liest MediaImporter::importAll() pro Track zuerst die ID3-Tags. Findet sich ein Album-Tag, werden Künstler UND Album daraus abgeleitet - genau dieselbe findOrCreateTaggedFolder()-Logik wie beim _dump-Import, hier aber als Fallback für "eigentlich verschachtelt gedachte" Ordner, deren Struktur es nicht ganz ist. Nur Tracks GANZ OHNE Album-Tag bleiben echte Singles direkt beim Ordner-Künstler.

Kann einen jetzt leeren Künstlerordner hinterlassen (der ursprüngliche "The Koi Boys - Meant to be"-Ordner-Node bleibt bestehen, aber ohne Kinder, wenn alle seine Tracks zum tag-basierten "The Koi Boys"-Künstler umgezogen sind) - NodeRepository::deleteChildlessFolders() räumt das am Ende von importAll() automatisch auf, in mehreren Durchgängen (falls das Löschen eines leeren Albums seinen Elternkünstler dadurch ebenfalls leer werden lässt).

Derselbe Bug steckte separat auch im Web-Upload-Pfad (MediaImporter::importUploadedFile(), siehe "Web-Upload" weiter unten) - eine ANDERE Methode als importAll(), die beim ersten Fix übersehen wurde, und beim zweiten Anlauf noch nicht vollständig: zuerst wurde der Album-Tag bei einem einzelnen Ordner-Level (oder gar keinem Ordnerkontext) komplett ignoriert - nur der Artist-Tag zählte, nie das Album. Der nächste Fix ergänzte die Album-Gruppierung, korrigierte dabei aber nur das Album, NICHT den Künstlernamen selbst - Ordner "The Koi Boys - Meant to Be" blieb als Künstlername stehen, bekam nur zusätzlich ein (korrektes) Album "Meant to Be" drin. Jetzt wird bei EINEM Ordner-Level auch der Artist-Tag geprüft und bei Abweichung vom Ordnernamen bevorzugt - genau wie bei importAll() schon der Fall. ProcessUploadsCommand räumt danach ebenfalls per NodeRepository::deleteChildlessFolders() auf, falls der ursprüngliche Ordner-Künstler dadurch leer zurückbleibt.

Dritter Anlauf, diesmal auch bei ZWEI Ordner-Ebenen: selbst Künstler/Album/Track.mp3 ist nicht zwingend sicher - der äußere Ordner kann trotzdem selbst "Künstler - Album" heißen (z.B. eine versehentlich doppelt verschachtelte Struktur wie Gregorian - Pure Chants/Pure Chants/track.mp3). folderNameCombinesArtistTag() prüft deshalb IMMER den ersten Ordner-Level, unabhängig von der Verschachtelungstiefe - aber bewusst NICHT "weicht der Artist-Tag irgendwie vom Ordnernamen ab" (das würde bei Compilation-Ordnern wie "Various Artists" falsch auslösen, wo einzelne Track-Artists ABSICHTLICH vom Ordnernamen abweichen), sondern gezielt nur das Muster "Ordnername beginnt mit <Artist-Tag> gefolgt von einem Trenner" (Bindestrich, Halbgeviertstrich, Doppelpunkt). Derselbe Helfer wird sowohl in importAll() (lose Tracks direkt im Künstlerordner) als auch in importUploadedFile() (Upload-Workflow) genutzt - eine Prüfung, ein Ort.

Im Upload-Workflow entsteht dabei kein "Hilfsknoten" mehr. folderNameCombinesArtistTag() arbeitet rein mit dem Ordnername-STRING, nicht mit einem bereits angelegten Node - importUploadedFile() prüft deshalb VOR dem ersten findOrCreateTaggedFolder()-Aufruf, welcher Name der richtige ist, statt erst mit dem (evtl. falschen) Ordnernamen einen echten Node anzulegen und ihn bei Bedarf hinterher zu korrigieren. Kein Zwischenschritt, der später wieder verworfen werden müsste, keine "verbrauchte" Auto-Increment-ID.

importAll() (der ordner-basierte media/-Import) lässt sich das dagegen nicht ganz so einfach ersparen: der äußere $artist-Node wird dort auch für echte Album-Unterordner gebraucht (die vertrauen dem Ordnernamen direkt, keine Tag-Prüfung nötig) und muss deshalb so oder so früh angelegt werden, bevor klar ist, ob lose Tracks ihn am Ende umleiten. deleteChildlessFolders() bleibt dort als Sicherheitsnetz bestehen.

Grundsatz seitdem konsistent an allen Stellen: erst ab ZWEI Ordner-Ebenen (Künstler/Album/Track.mp3) gilt die Ordnerstruktur als eindeutig genug, um ohne Tag-Rückfrage übernommen zu werden - darunter entscheiden immer die Tags mit (Künstler UND Album), und selbst bei zwei Ebenen wird der äußere Ordnername gegen das "Künstler - X"-Muster geprüft.

Compilations / Various Artists

Ein Album, an dem mehrere Künstler beteiligt sind, braucht besondere Behandlung - sonst würde jeder beitragende Künstler sein eigenes Album-Fragment mit nur seinen eigenen Tracks bekommen, und man könnte die Compilation nirgends als Ganzes browsen.

Ordnerbasierter Import: leg die Compilation unter einem eigenen Künstler-Ordner ab, z.B. media/Various Artists/Jetzt! 2024/. Die Ordnerlage entscheidet über den Baum, ID3-Tags sind hier nur Anzeige-Metadata - jede Track-Zeile zeigt trotzdem den tatsächlichen Interpreten aus dem Artist-Tag an, auch wenn der Track unter "Various Artists" hängt. Das ist die präzisere Variante, weil sie sich nicht auf Tag-Heuristik verlassen muss.

Tag-basierter Dump-Import: MediaImporter::importDumpFolder() erkennt Compilations automatisch - taucht unter demselben Albumnamen mehr als ein Artist-Tag auf, landet das komplette Album unter einem gemeinsam genutzten "Various Artists"-Künstler statt in einzelne Artist-Fragmente zerrissen zu werden. Bewusster Kompromiss: die Erkennung hängt einzig am "mehrere Artists, gleicher Albumname"-Muster, nicht an einem expliziten Compilation-Tag (der ist zu uneinheitlich gesetzt, um sich darauf zu verlassen). Zwei echte, unabhängige Alben mit zufällig identischem Titel von unterschiedlichen Künstlern würden dadurch fälschlich zusammengelegt - selten, aber möglich. Wo das wichtig ist: ordnerbasiert importieren statt über _dump/.

Album-Übersicht (/alben, AlbumController::index() + NodeRepository::findAllFoldersByKind()): zeigt alle Alben aus der gesamten Bibliothek auf einen Blick, unabhängig davon unter welchem Künstler sie hängen - genau dafür gedacht, eine Compilation zu finden, ohne vorher wissen zu müssen, dass sie unter "Various Artists" liegt. Jede Kachel zeigt zusätzlich den zugehörigen Künstler-Namen darunter.

Playlists

Bewusst außerhalb des Node-Baums modelliert (Playlist + PlaylistItem), weil ein Track in mehreren Playlists gleichzeitig stecken kann, im Baum aber nur einen einzigen tatsächlichen Ort hat (sein Album/Künstler-Ordner). PlaylistItem ist die Zuordnungstabelle mit expliziter position fürs Sortieren.

Aktuell sind Playlists global (kein Login vorhanden). Sobald die Login- Funktion kommt, braucht Playlist ein owner-Feld (ManyToOne auf User, nullable für die bis dahin angelegten globalen Playlists) und PlaylistRepository::findAllOrdered() einen Owner-Filter - der Rest der Struktur bleibt unverändert.

Jede Track-Zeile hat einen ""-Button, der per HTMX ein kleines Dropdown (playlist/_picker.html.twig) direkt an die Zeile hängt - Playlist auswählen oder per Schnellformular gleich eine neue anlegen. Entfernen aus einer Playlist läuft über hx-delete auf der Playlist-Detailseite.

In-App-Navigation (eigene Zurück/Vor-Buttons)

Wie bei Spotify/Amazon Music/iTunes gibt es in der Navbar eigene /-Buttons (#nav-back/#nav-forward, verdrahtet in assets/ts/navigation.ts), die einfach history.back()/history.forward() aufrufen. Technisch ist das kein eigener History-Stack - HTMX pflegt durch hx-push-url ohnehin schon die normale Browser-Session-History, die Buttons machen sie nur Teil der eigenen App-Oberfläche, ohne dass Nutzer auf den Browser-eigenen Button angewiesen sind. Der hx-history-elt-Fix (siehe unten) sorgt dafür, dass auch das den Player nie unterbricht.

WebGL-Ambient-Hintergrund

assets/ts/ambient-fx.ts, #ambientFxCanvas (templates/base.html.twig, ganz vorne im Body - liegt dadurch von Natur aus hinter allem später Deklarierten, kein z-index-Wettrüsten nötig). Ein Partikelfeld im 3D-Tiefenraum ("Demoscene-Starfield"-Prinzip) mit Verbindungslinien zwischen nahen Punkten ("Konstellations"-Look) - bewusst KEINE zusätzliche Hintergrundebene (Nebel/Aurora) mehr, siehe unten warum.

Drei Anläufe für eine zusätzliche Hintergrundebene, keiner hat überzeugt - erst eine fraktale Rauschtextur (Domain-Warping, Zoom-Tiefenschichten für einen "durch den Nebel fliegen"-Eindruck), dann Aurora-Borealis-Lichtvorhänge (Sinuswellen, später 1D-Rauschen für organischere Bahnen). Trotz mehrfacher Kalibrierung überzeugte keine Version durchgehend (mal "wabernder Matschsumpf", mal zu flächig - 80-90% der Fläche eingefärbt trotz mehrfacher Nachjustierung des Falloffs, mal die Balance zwischen Light-/Dark-Theme-Sichtbarkeit nicht getroffen). "Weniger ist mehr": das Sternenfeld allein, mit spürbar mehr Punkten als zuvor (POINT_COUNT: 55 → 110, jetzt wo es den kompletten visuellen Effekt allein trägt), ersetzt die separate Hintergrundebene komplett.

Starfield-Bewegung: jeder Punkt hat eine Tiefe (z), die pro Frame abnimmt (p.z -= speed) - bei der Projektion (screenX = x/z, screenY = y/z) wandern Punkte dadurch von der Bildmitte (weit weg, klein, unauffällig) nach außen (nah, groß, hell), bis sie die Nah-Ebene passieren und mit neuer zufälliger Position zurück auf die Fern-Ebene gesetzt werden (randomizePoint()) - der klassische "durch Sterne fliegen"-Effekt, bewusst LANGSAM (BASE_SPEED, kalibriert für eine komplette Durchquerung in ~17s bei Normal-Tempo).

Verbindungslinien: pro Frame prüft JS für jedes Punktpaar den Abstand in der projizierten 2D-Ebene (CONNECT_DISTANCE = 0.22) - liegen zwei Punkte nah genug beieinander, wird ein Liniensegment mit Deckkraft proportional zur Nähe gezeichnet. MAX_CONNECTIONS_PER_POINT = 4 begrenzt die Verbindungen pro Punkt - sonst würden dichte Cluster schnell unübersichtlich vernetzt aussehen.

Kompletter Ansatzwechsel gegenüber der allerersten Version (reiner Vollbild-Fragment-Shader mit Rauschen bzw. Distanzfeldern): JS übernimmt die eigentliche Partikelsimulation (Positionen, Tiefe, welche Punkte gerade verbunden sind) und baut daraus PRO FRAME echte WebGL-Vertex- Buffer (Punkte via gl.POINTS, Linien via gl.LINES) - der GPU-Rasterizer zeichnet nur die tatsächlich sichtbaren Formen, statt dass ein Fragment-Shader für jeden Bildschirm-Pixel alle Punkte/Paare durchrechnen müsste.

Canvas wird jeden Frame per glClear() auf --bg zurückgesetzt - übernahm vorher die (jetzt entfallene) Nebel-/Aurora-Hintergrundebene implizit mit, da sie als undurchsichtiges Vollbild-Quad jeden Pixel überschrieb. body{background:transparent} (app.css) bleibt trotzdem nötig, damit das Canvas (das den kompletten Bildschirm mit --bg füllt, Punkte/Linien werden darüber gezeichnet) überhaupt sichtbar wird.

Interne Auflösung deutlich höher als bei reinen Rausch-Shadern nötig wäre (Math.min(1800, innerWidth * min(2, dpr))) - Punkte/Linien sind geometrisch scharfe Formen (kein Rauschen), für die GPU sehr billig zu rasterisieren, eine niedrige Auflösung ließ sie nur unnötig "blurry" wirken. Punktgrößen sind mit einem Skalierungsfaktor (resScale = canvas.width / 640) nachgezogen.

Audio-reaktives Wabbeln - jeder Punkt bekommt eine winzige, phasenversetzte Sinus-Oszillation auf seine projizierte Position, deren Amplitude mit der geglätteten Lautstärke wächst - bei Stille praktisch unsichtbar, bei Musik ein spürbares Wackeln/Wabern der Punkte.

Echte Beat-Erkennung statt reiner (träger) RMS-Glättung - wertet nur den BASS-Frequenzbereich aus (unterste ~8% der Frequenzbänder), Beats sind dort am deutlichsten erkennbar. Vergleicht den aktuellen Bass-Pegel mit einem gleitenden Durchschnitt der letzten ~0.65s - liegt er deutlich darüber (und über einer Mindestlautstärke, gegen falsche Treffer bei generell leiser Musik), gilt das als Beat: beatPulse springt auf 1.0 und klingt danach schnell wieder ab - wirkt wie ein kurzer "Blitz" im Rhythmus der Musik, moduliert Fluggeschwindigkeit, Punktgröße und -Helligkeit.

Zwei getrennte WebGL-Programme (pointsProgram/linesProgram) - Punkte nutzen gl_PointSize im Vertex-Shader (pro Punkt unterschiedlich groß, je nach Tiefe) und einen weichen kreisförmigen Alpha-Falloff im Fragment-Shader (gl_PointCoord-basiert), Linien sind einfache gl.LINES-Segmente mit interpolierter Deckkraft pro Vertex.

Im Light-Theme spürbar zurückhaltender (alphaScale: 0.5 Light / 0.9 Dark) - ein bewegter Hintergrund wirkt auf hellem Grund schneller unruhig/unleserlich als auf dunklem. Respektiert die gewählte Akzentfarbe (--accent-rgb, alle 4 Themes) automatisch, da bei jedem Frame neu ausgelesen.

Kein WebGL im Browser? Der Canvas selbst trägt background:var(--bg) als CSS-Fallback (identisch zur bisherigen Optik) - initAmbientFx() bricht sauber ab, kein Fehlerzustand, sieht einfach aus wie vorher, nur ohne Bewegung.

Dekorativer Verlaufshintergrund (body::before)

Reines CSS, eigenständig von #ambientFxCanvas (das WebGL-Sternenfeld, siehe assets/ts/ambient-fx.ts) - liegt als zusätzliche, statische atmosphärische Ebene DAVOR: ein sanfter linearer Verlauf von oben (Akzentfarbe, abnehmende Deckkraft) plus ein radialer Verlauf (Akzentfarbe im Zentrum, zu --bg ausblendend). Getrennte Werte pro Theme-Modus (html[data-mode=light]), analog zu .detail-modal.

Explizites z-index: 0 ergänzt (im ursprünglichen Entwurf nicht gesetzt) - der Ambient-Canvas hat z-index: -1, das Canvas selbst rendert trotz alpha: false im WebGL-Context transparent (lokal verifiziert), verdeckt diesen Verlauf also ohnehin nicht. Die explizite Stacking-Reihenfolge (VOR dem Canvas, aber weiterhin HINTER dem eigentlichen Seiteninhalt - Topbar/Sidebar/Content liegen normal im Fluss, kein eigenes niedriges z-index) ist trotzdem sauberer als sich auf implizite Regeln zu verlassen. pointer-events: none, rein dekorativ, soll nie Klicks abfangen.

Genre-Karten: glasiger Look + Hover-Verfeinerung

.detail-modal und .genre-overview-card teilen sich jetzt EINE konsolidierte Hintergrund-Regel (vorher zwei separate, identische Werte-Paare) - ein Wert für beide vermeidet künftiges Auseinanderlaufen, falls die Deckkraft/Blur nochmal angepasst wird.

Neuer, deutlicherer Hover-Zustand - die Karte wird beim Hover DURCHSICHTIGER (rgb(from var(--surface) r g b / 25%)) statt nur die Randfarbe zu wechseln, passend zum glasigen Grundlook. Der innere Link (.genre-overview-link) bleibt dabei IMMER transparent (vorher bekam er beim Hover einen eigenen var(--surface-2)-Hintergrund) - sonst würden sich zwei unterschiedlich deckende Ebenen übereinanderlegen und die Karte fleckig wirken.

Verzögertes Positions-Restore (weniger Requests bei Reload)

Ein Reload/Seitenaufruf ruft restoreFromStorage() auf, die den gespeicherten Track lädt UND (bei resumeAt > 0) an die zuletzt gehörte Position springen will. Eine frühere Version tat das SOFORT (per loadedmetadata-Listener direkt in loadTrack()), UNABHÄNGIG davon, ob der Nutzer die Wiedergabe überhaupt fortsetzen wollte.

Bei einer MP3 ohne exakten Zeit-zu-Byte-Index (kein Xing/VBRI- Header - bei vielen "einfach" gerippten Dateien der Fall) muss der Browser für einen Sprung an eine bestimmte Zeitposition SELBST durch mehrere Range-Requests "sondieren", um den passenden Byte-Offset zu finden - reines, dokumentiertes Browser-Verhalten beim HTML5-<audio>- Seeking, kein Fehler in unserer eigenen Range-Behandlung (StreamController::serve() nutzt Symfonys BinaryFileResponse::prepare(), Standard-konform). Bei langen Tracks (Podcasts) mit weit fortgeschrittener gespeicherter Position waren das teils ~20 Requests - DIREKT bei jedem Seitenaufruf, auch wenn der Nutzer die Seite nur kurz öffnete, ohne die Wiedergabe fortzusetzen. "Je nach Streaming- Fortschritt" (die Beobachtung, die zur Diagnose führte) passt genau ins Bild: je weiter/ungenauer die Zielposition geschätzt werden muss, desto mehr Sondierungs-Requests.

Jetzt nur gemerkt (MusicDockPlayer::pendingResumeTime), nicht sofort angewendet - applyPendingResumeTime() wird erst von JEDER Stelle aufgerufen, die tatsächlich audio.play() auslöst (playPause(), sowie loadTrack()s eigener autoplay-Zweig, dort zur Konsistenz, auch wenn pendingResumeTime dort praktisch immer 0 ist - nur beim Storage-Restore gesetzt, wo autoplay immer false ist). Die teuren Seek-Requests passieren dadurch erst beim tatsächlichen Play-Wunsch, nicht mehr blind bei jedem bloßen Laden der Seite.

UX-Kompromiss: die Zeit-/Fortschrittsanzeige zeigt bis zum ersten Play-Klick "0:00" statt der gespeicherten Position, springt aber sofort korrekt, sobald tatsächlich abgespielt wird - vertretbarer Trade-off gegen die vielen unnötigen Requests bei jedem bloßen Seitenaufruf.

Nachtrag: die verzögerte Restore-Position allein reichte nicht - der Netzwerk-Tab zeigte weiterhin 11 Requests bei einem reinen Reload, ALLE mit Initiator: Other statt eines unserer eigenen Skripte. Das war der entscheidende Hinweis: Other bedeutet, der BROWSER selbst löst das aus, nicht unser JS. Ursache: <audio preload="metadata"> - der Browser beginnt SOFORT, sobald src gesetzt wird, mit dem Laden (mindestens die ersten Bytes für Header/Metadaten). Bei VBR-MP3s ohne exakten Zeit-Index (Xing/VBRI-Header) muss der Browser dafür teils MEHRERE Range-Requests machen, nur um die genaue Dauer zu ermitteln - komplett unabhängig vom verzögerten Resume, direkt bei jedem Trackwechsel/Seitenaufruf.

Jetzt preload="none" (templates/player/_bar.html.twig) - wir brauchen den Browser dafür gar nicht: die Dauer steht bereits aus unseren eigenen Metadaten bereit (track.duration, aus der Datenbank, ursprünglich per getID3 beim Import ermittelt). loadTrack() setzt die Zeit-/Dauer-Anzeige jetzt SOFORT aus track.duration, statt auf das Browser-loadedmetadata-Event zu warten - der loadedmetadata-Handler bleibt als Fallback bestehen (aktualisiert den Wert, falls audio.duration nach dem tatsächlichen Laden abweicht), ist aber nicht mehr der alleinige Auslöser. preload="none" verhindert JEDES automatische Laden, bis der Nutzer tatsächlich auf Play klickt.

Play-Button in der Trackliste startete den laufenden Track neu

Klick auf das Track-Cover (.track-play-btn) in einer Liste löste immer playFromRow() aus, egal ob der Track schon lief - playFromRow() ruft loadTrack(), das audio.src neu setzt und die Wiedergabe damit IMMER von vorne startet, auch bei einem bereits laufenden/pausierten Track. Der kleine Equalizer (.playing-bars) neben der Track-Nummer zeigte zwar korrekt an, dass der Track lief, aber ein Klick auf das Cover daneben pausierte nicht, sondern setzte zurück.

Klick auf den BEREITS aktiven Track togglet jetzt nur noch Play/Pause (this.playPause()), ohne loadTrack() erneut aufzurufen - ein Klick auf einen ANDEREN Track lädt/startet ihn weiterhin wie bisher. Dieselbe Anpassung auch beim Mikrofon-Button (Lyrics öffnen) - läuft der Track schon, wird er beim Klick nicht mehr neu gestartet, nur das Panel geöffnet.

Play-Icon wechselt jetzt zu Pause, solange der aktive Track spielt (body.is-playing .track-row--active .track-cover::after, dasselbe Selektor-Muster, das bereits für den Equalizer genutzt wird) - zeigt korrekt an, was ein Klick als Nächstes tut, weiterhin nur bei :hover sichtbar (unverändertes Grundverhalten).

Cover-Upload optisch über das Track-Cover statt separatem Dateifeld

Das sichtbare <input type="file"> unterhalb des "Abspielen"-Buttons wirkte deplatziert. Jetzt ein Hover-Overlay direkt auf dem großen Cover (.track-cover-upload-zone/.track-cover-upload-overlay) - bekanntes Muster (Profilbild-Upload bei Spotify/Discord u.ä.). Das eigentliche <input type="file"> bleibt im DOM, aber visuell versteckt (Standard- "visually-hidden"-Technik: 1×1px, clip, nicht display:none - bleibt dadurch für Tastatur-/Screenreader-Fokus technisch erreichbar) - track-detail.ts öffnet es per input.click(), sobald auf das Overlay geklickt wird, ein programmatischer Klick auf ein <input type="file"> öffnet denselben nativen Datei-Dialog wie ein direkter Klick.

Statistiken: Auswertung (Meistgehört, zuletzt gehört)

Zweite Stufe der Statistik-Funktion, vorgezogen - die Datenerhebung (play_count/last_played_at, TrackController::playEvent()) lief schon länger, jetzt gibt es endlich eine Ansicht dafür (/statistiken, neuer Sidebar-Link unter "Deine Musik"). Rein lesend, keine neue Datenerhebung.

Drei neue NodeRepository-Methoden, native SQL statt DQL für die Sortierung - JSON_VALUE()/JSON_EXTRACT() liefern einen JSON- Skalarwert zurück, dessen STRING-Repräsentation für numerischen Vergleich ungeeignet wäre (z.B. "9" > "10" alphabetisch) - CAST(... AS UNSIGNED) erzwingt echten numerischen Vergleich. Andere Repository-Methoden nutzen JSON_EXTRACT() zwar bereits in WHERE- Bedingungen per DQL (findRecentlyAddedAlbums() etc.), aber ein Cast speziell in ORDER BY wäre darüber unzuverlässiger gewesen als eine direkte, gezielt geschriebene SQL-Formulierung:

  • findMostPlayedTracks() - absteigend nach play_count.
  • findRecentlyPlayedTracks() - absteigend nach last_played_at (ISO- 8601-Strings sortieren korrekt lexikografisch, kein Cast nötig).
  • getPlayStatsSummary() - Gesamtsumme aller Plays + Anzahl verschiedener gehörter Tracks, eine gemeinsame Abfrage statt zwei getrennter JSON-Extraktionen.

findOrderedByIds()-Hilfsmethode - ein SQL WHERE id IN (...) garantiert die Reihenfolge NICHT, wird aber gebraucht, um die bereits korrekt sortierten IDs (aus dem nativen SQL) in vollständige Node- Entities über die normale Doctrine-Hydration umzuwandeln, ohne die mühsam erarbeitete Sortierung zu verlieren.

_track_row.html.twig um optionalen extra_label-Parameter erweitert - zeigt einen Zusatztext unter der Dauer (Play-Count/ Zeitstempel), Standard bleibt unverändert (keine Auswirkung auf bestehende Includes, die den Parameter nicht setzen).

Kapitel-Buttons umpositioniert: immer sichtbar, neben dem Play-Button

Zwei Nachbesserungen an den gerade eingeführten Kapitel-Sprung-Buttons.

Neue Position - jetzt direkt in der Hauptsteuerungsreihe, neben dem Play-Button, statt separat an der Progress-Leiste: [Track zurück] [Kapitel zurück] [PLAY] [Kapitel vor] [Track vor]. Teilen sich die .player-icon-Basisoptik (38px, gleicher Hover-Effekt wie die übrigen Controls), mit kleinerem Icon als die übrigen Symbole - "nächstes Kapitel" ist konzeptuell nebensächlicher als "nächster Track", die kleinere Größe spiegelt das.

Immer sichtbar statt hidden - ein Button, der je nach Track auf- und wieder verschwindet, hätte die Nachbar-Buttons (allen voran den Play-Button selbst) bei jedem Trackwechsel leicht verschoben. Jetzt per .chapter-nav-btn--disabled-Klasse nur noch halbtransparent + pointer-events: none, wenn der aktuelle Track keine Kapitel hat - renderChapterMarkers() togglet diese Klasse statt vorher das hidden-Attribut. Startet im Template bereits als --disabled (statt erst per JS beim ersten loadTrack() gesetzt zu werden) - sonst wären die Buttons für einen kurzen Moment beim allerersten Laden der Seite voll aktiv sichtbar gewesen, bevor überhaupt ein Track geladen ist.

Sichtbare Vor-/Zurück-Buttons für Kapitel-Sprünge

Bisher nur über die Tasten ]/[ erreichbar (jumpToChapter()) - jetzt zusätzlich zwei kleine, sichtbare Buttons direkt neben der Progress- Leiste (#chapterPrevBtn/#chapterNextBtn), für alle, die die Tastenkürzel nicht kennen. Rufen dieselbe jumpToChapter()-Logik auf.

Eigene Chevron-Icons statt der Step-Symbole der großen Track-Skip- Buttons weiter oben in der Player-Leiste - vermeidet Verwechslung zwischen "nächster Track" und "nächstes Kapitel", die sich sonst optisch zu ähnlich gewesen wären, direkt übereinander sitzend.

Nur sichtbar, wenn der aktuelle Track tatsächlich Kapitel hat - renderChapterMarkers() blendet beide Buttons per hidden-Attribut ein/aus, zusammen mit den Wellenform-Markern selbst (dieselbe Prüfung: Kapitel vorhanden UND Laufzeit bekannt). Einmalig in wireControls() gebunden (die Buttons sind Teil der statischen Player-Leiste, werden nie per HTMX ausgetauscht).

Kapitel-Marker blieben komplett unsichtbar, auch nach Reload

Der vorherige Fix (updateTrackChapters() nach dem Speichern) deckte NICHT den Fall ab, dass der Track schon lief, BEVOR seine Kapitel zum allerersten Mal angelegt wurden - genau das war beim Testen der Fall.

Ursache: War der Track schon vor dem Bearbeiten in der Warteschlange (mit chapters: [] zum Ladezeitpunkt), blieb diese in localStorage gespeicherte Kopie dauerhaft auf dem alten (leeren) Stand eingefroren - restoreFromStorage() lädt beim nächsten Seitenaufruf ja explizit AUS localStorage, nicht vom Server. Selbst nach einem Reload zeigte der Player deshalb weiterhin die veralteten, leeren Kapitel, obwohl track/show.html.twig auf der Detailseite selbst längst die frisch gespeicherten Kapitel korrekt anzeigte - zwei unterschiedliche Datenquellen (Server-gerendertes DOM vs. eingefrorener localStorage-Snapshot), die auseinanderliefen.

syncChaptersFromCurrentPage() (player.ts, läuft in syncActiveRowHighlight() mit - dadurch automatisch bei jedem Trackwechsel UND jeder HTMX-Navigation, nicht nur bei einem vollen Reload) - prüft, ob die Zeile/Detailseite des AKTUELL SPIELENDEN Tracks gerade sichtbar ist (data-track-chapters steht dort immer aktuell, frisch aus der DB gerendert) und gleicht die Warteschlangen-Kopie bei Bedarf über updateTrackChapters() ab. Durchsucht bewusst alle .track-row-Elemente statt eines interpolierten Attribut-Selektors - Track-IDs landen so nie ungeprüft in einem CSS-Selector-String.

Kapitel-Marker aktualisierten sich nicht sofort nach dem Speichern

Nach dem Speichern neuer Kapitel blieb die Wellenform-Anzeige auf dem alten Stand, falls genau dieser Track gerade lief - die Marker werden nur bei loadTrack() neu gerendert, nicht bei jeder Änderung am zugrundeliegenden Queue-Objekt. Der Nutzer hätte den Track pausieren/ neu starten müssen, nur um seine gerade gespeicherten Kapitel überhaupt zu sehen.

Neue updateTrackChapters()-Methode (player.ts) - dasselbe State-Sync-Muster wie updateTrackCover() (für den Cover-Upload): aktualisiert alle passenden Queue-Einträge, rendert die Marker sofort neu, falls es der aktuell laufende Track ist, und persistiert. Rein clientseitig, kein eigener Request - der Editor hat den Server bereits aktualisiert.

chapters.ts bekommt die Player-Instanz als Parameter (dasselbe Muster wie initTrackCoverUpload(player) in track-detail.ts) und ruft player.updateTrackChapters() nach erfolgreichem Speichern auf - importiert Chapter direkt aus player.ts statt einer eigenen, duplizierten Interface-Definition, um Typ-Drift zwischen beiden Modulen zu vermeiden.

Wellenform verschwand komplett nach dem Kapitel-Feature

Echter Bug, direkt im Anschluss ans Kapitel-Feature aufgefallen: die komplette Wellenform-Anzeige war weg, nicht nur die neuen Kapitel- Marker.

Ursache: restoreFromStorage() lädt die Warteschlange aus localStorage mit JSON.parse(...) as Track[] - dieses as Track[] ist nur eine TypeScript-Typannahme, KEINE Laufzeitprüfung. Eine bereits gespeicherte Warteschlange von VOR Einführung des chapters-Felds enthält dieses Feld schlicht nicht - zur Laufzeit ist track.chapters dann undefined, obwohl TypeScript es als garantiertes Array behandelt. renderChapterMarkers() griff direkt auf track.chapters.length zu, das warf bei undefined eine Exception - und die brach den GESAMTEN Rest von loadTrack() ab, BEVOR loadWaveform() überhaupt lief (renderChapterMarkers() steht davor in der Aufrufreihenfolge). Nicht nur die Kapitel-Marker blieben dadurch aus, sondern die komplette Wellenform-Anzeige.

Fix an der Quelle: restoreFromStorage() normalisiert jetzt jeden aus localStorage geladenen Track (chapters: t.chapters ?? []) - zusätzlich renderChapterMarkers()/jumpToChapter() selbst ebenfalls defensiv (track.chapters ?? [] statt direktem Zugriff) als zweites Sicherheitsnetz, falls chapters aus einem anderen Weg als localStorage einmal fehlen sollte.

Nebenbei entdeckt und ebenfalls behoben: .progress-track verließ sich bisher auf flex: 1 aus der Theme-Basisklasse, das nur wirkt, wenn der DIREKTE Elternteil display: flex hat - durch den neuen .progress-track-wrapper (siehe oben) ist das nicht mehr der Fall, der Wrapper selbst hat nur flex: 1 (als Empfänger vom äußeren Flex-Container), aber kein eigenes display: flex. Jetzt mit explizitem width: 100% auf .progress-track selbst abgesichert, unabhängig vom Flex-Kontext.

Tastenkürzel, Wiedergabegeschwindigkeit, Kapitel/Sprungmarken

Drei neue Player-Funktionen in einem Rutsch.

Tastenkürzel

wireKeyboardShortcuts() (player.ts) - Leertaste (Play/Pause), Pfeiltasten (±10s spulen, ±5% Lautstärke), Umschalt+Pfeil (nächster/ vorheriger Track), ]/[ (nächstes/vorheriges Kapitel, siehe unten). Feuert NICHT, während der Fokus in einem Eingabefeld liegt (Input/ Textarea/Select/contenteditable) - sonst würde z.B. die Leertaste in einem Suchfeld die Wiedergabe togglen statt ein Leerzeichen einzufügen. Ohne geladenen Track passiert nichts, Standardverhalten der Tasten (z.B. Scrollen bei Pfeiltasten) bleibt dann unangetastet.

Wiedergabegeschwindigkeit

Neuer #speedButton in der Player-Leiste (Text statt Icon, zeigt die aktuelle Rate wie "1.5x") - zykelt bei Klick durch feste Werte (0.75x-2x). Gilt GLOBAL, nicht pro Track, und wird in localStorage persistiert - wie bei Podcast-Playern üblich, eine gewählte Geschwindigkeit soll für den nächsten Track/die nächste Folge erhalten bleiben, nicht bei jedem Wechsel auf 1x zurückspringen. this.playbackRate wird bei JEDEM loadTrack() erneut explizit gesetzt (nicht nur einmal) - manche Browser setzen playbackRate bei einem neuen audio.src nicht zuverlässig zurück, andere schon (uneinheitliches Verhalten je nach Engine), die erneute Zuweisung ist ein günstiges Sicherheitsnetz.

Kapitel/Sprungmarken

Für lange Tracks (Podcasts etc.) - Sprungmarken mit Zeit + Titel, erscheinen als kleine, klickbare Striche mit Tooltip auf der Wellenform/Progress-Leiste in der Player-Leiste.

Anders als Lyrics (lrclib.net) gibt es KEINEN externen Kapitel- Datendienst - Kapitel werden ausschließlich manuell gepflegt, über einen neuen Editor auf der Track-Detailseite (assets/ts/chapters.ts): Zeit im MM:SS-Format (oder reine Sekundenzahl) + Titel, mit einem "aktuelle Position übernehmen"-Button (liest #player-audio direkt aus, kein Umweg über eine öffentliche Player-API nötig). Hinzufügen/ Entfernen passiert rein clientseitig in einer lokalen Liste, "Speichern" schickt die VOLLSTÄNDIGE Liste an den neuen Endpunkt POST /track/{id}/chapters (TrackController::setChapters()) - ersetzt den bisherigen Stand komplett, kein Anhängen einzelner Einträge. Serverseitig zusätzlich validiert und nach Zeit sortiert (Client-seitige Prüfung ist nur UX, kein Sicherheitsnetz).

Speicherung: chapters-Node-Metadaten als JSON-Array ({time, title}), dasselbe etablierte Muster wie bio/play_count etc. Landet als data-track-chapters-Attribut im DOM (_track_row.html.twig, track/show.html.twig) UND im Album-JSON- Endpunkt (AlbumController::tracksJson(), für "Album abspielen" von einer Kachel aus) - bewusst DIREKT im DOM statt bei Bedarf nachgeladen (anders als Lyrics-Text, der absichtlich NICHT im DOM steht): Kapitel- Listen sind typischerweise winzig, ein Nachladen wäre unnötiger Aufwand für so wenig Daten.

Wellenform-Marker brauchten einen zusätzlichen Wrapper um .progress-track (templates/player/_bar.html.twig) - der hat selbst overflow: hidden und keine eigene Positionierung (theme.css), Marker DARIN wären am 4px hohen Balken abgeschnitten worden. Der neue .progress-track-wrapper trägt position: relative, die Marker liegen als Geschwister-Ebene NEBEN .progress-track (#chapterMarkers) statt darin - können dadurch über dessen schmale Höhe hinausragen. Container selbst hat pointer-events: none (normales Seeken durch die restliche Leiste bleibt unangetastet), nur die einzelnen .chapter-marker-Striche sind klickbar und holen sich das per pointer-events: auto zurück.

]/[-Tasten springen zum nächsten/vorherigen Kapitel (jumpToChapter(), player.ts) - sucht das nächste Kapitel VOR bzw. NACH der aktuellen Position, nicht einfach "Index ± 1" (sonst würde ein Sprung MITTEN in einem Kapitel beim "zurück" das eigentlich aktuelle Kapitel überspringen). 2-Sekunden-Puffer beim Rückwärtssprung - "zurück" innerhalb der ersten 2s eines Kapitels springt zum VORHERIGEN, nicht zum eigenen Anfang (dasselbe Verhalten wie der "vorheriger Track"-Button bei den meisten Playern: erst zurück zum Anfang, ein zweites Drücken erst zum vorherigen).

Statistiken: Datenerhebung (Play-Count)

Erste Stufe der geplanten Statistik-Funktion - reine Datenerhebung, noch KEINE eigene Auswertungsseite/-ansicht. Sammelt jetzt schon Play-Zahlen, damit eine spätere Auswertung nicht bei null anfängt, sondern auf bereits vorhandenen Daten aufbauen kann.

play_count/last_played_at als Node-Metadaten auf dem Track - dasselbe etablierte Muster wie bio/cover_url etc., keine neue Tabelle nötig für zwei einfache Zähler-/Zeitstempel-Felder. Neuer Endpunkt POST /track/{id}/play-event (TrackController::playEvent()).

Nur "gültige" Plays zählen - der Client meldet sich erst, sobald der aktuelle Track entweder 30 Sekunden erreicht hat ODER 50% seiner Laufzeit (was zuerst eintritt, maybeReportPlayEvent() in player.ts)

  • derselbe Schwellenwert, den auch große Streaming-Dienste nutzen, um echtes Hören von einem bloßen Antippen/sofortigen Überspringen zu unterscheiden. Nur EINMAL pro Track-Ladevorgang (playEventReported- Flag, in loadTrack() bei jedem neuen Track zurückgesetzt) - ein erneutes Abspielen desselben Tracks (z.B. nach Ende/Neustart) zählt wieder neu.

Positions-basiert, nicht Hörzeit-akkumuliert - prüft audio.currentTime gegen die Schwellenwerte, akkumuliert NICHT die tatsächlich gehörte Zeit über Pausen/Sprünge hinweg. Bewusst einfach für eine erste Datenerhebung, keine hochpräzise Hörzeit-Messung. Der Duration-Check (50%) greift nur bei einer gültigen, positiven Laufzeit

  • bei unbekannter/0 Laufzeit (Metadaten noch nicht geladen) würde "50% von 0" sonst sofort bei Sekunde 0 auslösen.

Minimale Verifikations-Anzeige auf der Track-Detailseite ("Abspielungen: N" in den Detail-Metadaten, nur sichtbar sobald play_count > 0) - kein Ersatz für eine richtige Auswertung, nur ein einfacher Nachweis, dass die Zählung tatsächlich ankommt.

Kachel-Play-Icon: dezenter statt Akzentfarbe + Dropshadow

Der runde Icon-Kreis auf den Album-Kachel-Covern (.album-card-play-icon i) nutzte bisher var(--accent) mit Dropshadow, wie die anderen Hero- Play-Buttons. Jetzt halbtransparentes Schwarz mit weißem Icon, kein Schatten - fügt sich in jede Cover-Farbe ein, statt sie zu überstrahlen. Das umschließende Abdunkel-Overlay (deckt die gesamte Cover-Fläche leicht ab, damit der Kreis besser sichtbar ist) blieb unverändert - betroffen war nur der Icon-Kreis selbst.

Album-Detailseite: Cover-Play-Icon entfernt, Buttons enger zusammen

Zwei Nachbesserungen am gerade zurückgebrachten Layout.

Play/Pause-Icon direkt auf dem Cover (#albumCoverPlayBtn) ist wieder komplett entfallen - mit dem großen, eigenständigen #albumHeroPlayBtn direkt daneben war es tatsächlich redundant, zwei Auslöser für dieselbe Aktion nebeneinander. "Cover ändern" ist damit wieder die einzige Interaktion auf dem Cover selbst - auf der Kachel- Übersicht bleibt das Cover dagegen weiterhin der Play/Pause-Auslöser (dort gibt es kein "Cover ändern", das um denselben Klickbereich konkurrieren könnte).

Play/Bearbeiten/ZIP-Download stehen jetzt direkt nebeneinander statt mit .col/.col-auto auseinandergezogen zu sein - eine schlichte d-flex gap-2-Reihe, alle drei Buttons dicht beieinander statt Bearbeiten/ZIP an den rechten Rand geschoben.

syncAlbumCoverPlayButton() und der delegierte Klick-Handler behandeln jetzt wieder nur zwei Stellen (#albumHeroPlayBtn, .album-card-cover-btn) statt drei.

Separater Play-Button auf der Album-Detailseite zurückgebracht

Der große, eigenständige Play-Button war beim Umbau auf reines Cover- Play/Pause versehentlich als "jetzt redundant" komplett entfernt worden

  • sollte aber weiterhin da sein, nur zusammen mit Bearbeiten/ZIP- Download in einer Reihe (Amazon-Music-artiges Layout: alle Aktionen nebeneinander statt verteilt).

Neuer #albumHeroPlayBtn - der große Button ist zurück (.col in der Bootstrap-Grid-Reihe, Bearbeiten/ZIP-Download als .col-auto daneben), läuft aber jetzt SYNCHRON mit dem kleinen Cover-Icon (#albumCoverPlayBtn) statt unabhängig immer stur von vorne zu starten - beide zeigen zu jedem Zeitpunkt denselben Play-/Pause- Zustand. syncAlbumCoverPlayButton() und der delegierte Klick-Handler behandeln jetzt DREI mögliche Stellen (Cover-Icon, Hero-Button, Kachel-Buttons) in derselben Schleife bzw. demselben Fall - alle tragen data-album-id, kein separater Code-Pfad pro Stelle nötig. Der "gehört das zur Album-Detailseite oder zu einer Kachel"-Unterschied (wichtig für den [data-track-list]-Shortcut, siehe oben) wird jetzt über !classList.contains('album-card-cover-btn') geprüft statt einer festen ID - deckt beide Detailseiten-Buttons gleichermaßen ab.

Kachel-Klick spielte immer denselben, falschen Container ab

Echter Bug: Klick auf IRGENDEINE Album-Kachel spielte immer denselben, falschen Inhalt - unabhängig davon, welches Album tatsächlich angeklickt wurde.

Ursache: Der Klick-Handler prüfte zuerst, ob bereits eine [data-track-list] im DOM existiert (als Abkürzung, um bei bereits vorhandener Trackliste keinen unnötigen Extra-Request zu machen) - diese Prüfung war aber viel zu grob: document.querySelector('[data-track-list]') findet einfach die ERSTE passende Stelle auf der GESAMTEN Seite, ganz unabhängig davon, ob sie überhaupt zum angeklickten Album gehört. Auf der Künstlerseite gibt es z.B. eine [data-track-list] für lose Singles (Tracks ohne Album-Zwischenebene, artist/show.html.twig) - die hat mit keinem der angezeigten Alben etwas zu tun, wurde aber trotzdem gefunden und blind abgespielt, komplett unabhängig davon, welche Kachel tatsächlich angeklickt wurde.

Fix: Die Abkürzung greift jetzt NUR noch, wenn der Klick vom einzelnen Button auf der Album-DETAILSEITE selbst kam (#albumCoverPlayBtn, erkennbar an der ID) - dort ist garantiert, dass die einzige vorhandene [data-track-list] tatsächlich zum angezeigten Album gehört. Ein Klick auf eine Kachel (.album-card-cover-btn, auf Künstlerseite/Album-Übersicht/Startseite) läuft jetzt IMMER über playAlbumById() (den JSON-Endpunkt) - kein blinder DOM-Shortcut mehr, der versehentlich einen unabhängigen Container erwischen könnte.

Album-Kachel: Klick aufs Cover ist jetzt IMMER Play/Pause

Zwei Nachbesserungen aus dem Praxistest der Kachel-Play-Buttons.

Klick auf Play navigierte trotzdem ins Album

Der Play-Button lag bisher innerhalb des Kachel-<a>-Links, player.ts rief event.preventDefault() auf, um die Navigation zu unterdrücken - funktionierte aber nicht zuverlässig. Ursache: HTMX' eigener Klick- Handler für den geboosteten Link feuerte VOR dem eigenen Handler und leitete die Navigation bereits ein, ganz unabhängig vom später aufgerufenen preventDefault().

Der <a>-Wrapper ums Cover ist komplett entfallen - die ganze Cover-Fläche ist jetzt selbst ein <button class="album-card-cover-btn">, kein Navigations-Ziel mehr, an dem HTMX überhaupt ansetzen könnte. Ein Klick IRGENDWO auf dem Cover löst jetzt zuverlässig Play/Pause aus, wie bei Spotify/Apple Music - ins Album kommt man nur noch über den Titel- Link darunter.

Play-Icon zentriert statt in der Ecke

Auf der Kachel-Übersicht (nicht auf der Album-Detailseite - dort bleibt die kleine Ecken-Variante bestehen, da dort zusätzlich das konkurrierende "Cover ändern"-Hover-Overlay sitzt, ein zentriertes vollflächiges Icon wäre dort in die Quere gekommen) zeigt ein halbtransparentes Overlay jetzt das Play-/Pause-Icon mittig auf dem Cover, statt eines kleinen Kreises in der Ecke - erscheint beim Hover oder permanent, wenn gerade ein Track dieses Albums spielt.

Nachtrag: direkter Album-ID-Abgleich statt URL-String-Vergleich

Die "gehört dieser Track zu Album X"-Prüfung verglich bisher track.albumLink (eine URL-String, z.B. /album/123) gegen window.location.pathname (Detailseite) bzw. eine selbst zusammengebaute /album/{id}-URL (Kachel) - funktioniert, aber indirekter als nötig und an die konkrete URL-Struktur gebunden.

Track-Interface um albumId erweitert (die rohe Node-ID, nicht die URL) - befüllt aus einem neuen data-track-album-id-Attribut (_track_row.html.twig, track/show.html.twig) bzw. direkt im JSON-Endpunkt (AlbumController::tracksJson()). Der Vergleich läuft jetzt einfach currentTrack.albumId === albumId - robuster (unabhängig von URL-Formatierung) und leichter zu lesen.

Beide Fälle (Detailseite + Kacheln) laufen jetzt über dieselbe Schleife in syncAlbumCoverPlayButton() sowie denselben Klick-Fall in der delegierten Event-Behandlung - da beide Buttons jetzt data-album-id tragen, gibt es keinen Grund mehr für zwei fast identische Code-Pfade. Der Klick-Handler probiert zuerst, ob bereits eine [data-track-list] im DOM existiert (Detailseite - Tracks stehen dort schon bereit, kein Extra-Request nötig) und fällt erst sonst (Kachel-Übersicht) auf playAlbumById() zurück.

Album abspielen direkt aus der Kachel-Übersicht

Album-Kacheln (Album-Übersicht, Künstlerseite, Startseite) bekamen dasselbe Play/Pause-Icon wie die Album-Detailseite - man muss nicht mehr erst ins Album hinein, um es zu starten.

Neuer JSON-Endpunkt GET /album/{id}/tracks.json - liefert die Tracks eines Albums in EXAKT derselben Struktur wie player.ts' Track-Interface (dieselben Felder, die trackFromElement() normalerweise aus den data-track-*-Attributen einer .track-row liest). Nötig, weil eine Kachel-Übersicht - anders als die Album-Detailseite, wo #albumCoverPlayBtn/.play-container-btn einfach die nächste [data-track-list] im DOM finden - KEINE Tracks im DOM stehen hat, nur Cover + Name pro Album.

player.ts bekam playAlbumById() - ruft den JSON-Endpunkt ab, setzt die Warteschlange direkt aus der Antwort und startet den ersten Track. Der neue Klick-Fall .album-card-play-btn (mehrere pro Seite möglich, data-album-id für die Zuordnung) läuft über dieselbe delegierte Event-Behandlung wie alle anderen Play-Buttons - funktioniert dadurch auch nach HTMX-Navigation ohne erneutes Binden. preventDefault() verhindert dabei die Navigation zur Albumseite (der Button liegt innerhalb des Kachel-<a>-Links, ein Klick würde sonst zusätzlich navigieren).

syncAlbumCoverPlayButton() erweitert - synchronisiert jetzt sowohl den einzelnen Button auf der Detailseite als auch beliebig viele Kachel-Buttons auf einer Übersicht. Kachel-Variante baut sich die erwartete albumLink-URL aus data-album-id zusammen (/album/{id}), da mehrere Kacheln auf derselben Seite stehen können - anders als die Detailseite, die einfach window.location.pathname vergleichen kann.

Der separate .hero-play-btn/.play-container-btn auf der Album- Detailseite selbst wurde entfernt - jetzt, wo das Cover direkt Play/ Pause übernimmt, wäre ein zweiter Play-Button daneben redundant gewesen. Bearbeiten/ZIP-Download bleiben als einfache Button-Reihe stehen (kein .col/.col-auto-Split mehr nötig, ohne Play-Button gibt es nichts mehr, wovon sie getrennt werden müssten) - dieselbe schlichte Anordnung wie Bearbeiten + "Metadaten abrufen" auf der Künstlerseite.

Edit-Button umpositioniert + Album-Cover-Play/Pause

Edit-Button saß deplatziert direkt neben dem Titel

Wanderte in dieselbe Zeile wie der ZIP-Download-Button (Album/Track) bzw. neben "Metadaten abrufen" (Künstler, wo es keinen Play-Button gibt, der ihn davon trennen müsste).

Bootstrap-Grid (row/col/col-auto) statt eigener Spacer-Klasse für Album/Track (beide brauchen einen echten Links-/Rechts-Split: Play- Button links, Bearbeiten/ZIP-Download rechts) - margin-left: auto direkt in .hero-download-btn verankert hätte sich bei MEHREREN solchen Buttons in einer Reihe falsch verhalten (der freie Platz hätte sich auf jeden Auto-Margin einzeln aufgeteilt, statt alle rechten Buttons bündig nebeneinander zu halten) - eine erste Zwischenlösung mit einem eigenen .hero-actions-spacer-Element funktionierte zwar, war aber unnötig, da Bootstrap (ohnehin Projektabhängigkeit) genau dafür gebaute Utilities mitbringt: .col (Play-Button) nimmt den gesamten verfügbaren Platz, .col-auto (Bearbeiten/ZIP-Download) bleiben auf ihre Inhaltsbreite begrenzt und rutschen dadurch automatisch an den rechten Rand. Fehlt die .col (z.B. leeres Album ohne Tracks), sitzen die .col-auto-Spalten einfach am Zeilenanfang - kein Sonderfall nötig. Genre/Künstler (kein Split nötig, nur eine einfache Button-Reihe) bleiben bei der schlichten .hero-actions-Flex-Klasse.

Album-Hero-Actions-Zeile ist jetzt IMMER sichtbar, nicht nur wenn Tracks vorhanden sind - der Edit-Button muss auch bei einem leeren Album erreichbar bleiben, nur Play/ZIP-Download brauchen echte Tracks und bleiben bedingt.

displayContainerId korrigiert - zeigte vorher auf einen Wrapper, der Name UND Stift-Button (bzw. bei Album/Track sogar die ganze Play/ZIP-Zeile) umfasste. Fürs Bearbeiten muss aber wirklich nur der NAME-Text selbst versteckt werden - sitzt jetzt direkt auf dem <h2>.

Album-Cover: Play/Pause-Icon

Klick auf das Album-Cover spielt jetzt das Album ab (kleiner, separater Kreis in der unteren rechten Ecke - bewusst NICHT vollflächig wie das "Cover ändern"-Overlay, sonst würden beide beim Hover denselben Bereich beanspruchen wollen). Läuft bereits ein Track dieses Albums, zeigt der Kreis ein Pause- statt Play-Icon und bleibt PERMANENT sichtbar (nicht nur beim Hover) - dasselbe Play/Pause-Toggle-Prinzip wie .track-play-btn in Tracklisten, nur auf Albumebene übertragen.

syncAlbumCoverPlayButton() (player.ts) - vergleicht track.albumLink direkt gegen window.location.pathname (beide sind identisch, wenn man sich gerade auf der Albumseite befindet, deren Track spielt) statt eine zusätzliche data-album-id-Zuordnung zu brauchen. Läuft an drei Stellen mit: syncActiveRowHighlight() (jeder Trackwechsel), den play/pause-Audio-Events (reines Play/Pause- Toggle desselben Tracks, OHNE Trackwechsel - würde sonst nicht mitgezogen), und darüber auch bei jeder HTMX-Navigation sowie beim initialen Laden (restoreFromStorage()loadTrack()syncActiveRowHighlight()).

Eigener, per closest() erkannter Klick-Fall in der bestehenden delegierten wireTrackDelegation() (nicht .play-container-btn wiederverwendet) - der Button braucht ein echtes Toggle (läuft der Track schon, nur pausieren/fortsetzen), .play-container-btn selbst startet immer stur von vorne, ohne Toggle-Möglichkeit. Als eigenes click-Handling delegiert statt separat gebunden - ein direkt am Element gebundener Listener hätte nach jeder HTMX-Navigation zur Albumseite ins Leere gezeigt (Element existierte beim einmaligen Konstruktor-Aufruf noch nicht).

Metadaten editierbar: Künstler, Album, Track

Größer gedacht als nur die Bio - Stift-Button auf allen drei Detailseiten (Künstler/Album/Track), öffnet ein Inline-Formular (ersetzt die normale Anzeige temporär), speichert per XHR.

Was editierbar ist:

  • Künstler: Name + Bio, in einem gemeinsamen Formular.
  • Album: nur der Name.
  • Track: Titel + Track-/Disc-Nummer.

Reine DB-Metadaten, KEIN Rückschreiben in Mediendateien - bewusste Entscheidung, mit unterschiedlicher Begründung je Ebene:

  • Künstler/Album haben ohnehin keine eigene Mediendatei (Künstler nie, ein Album ist nur ein Ordner mit mehreren Tracks darin) - ein Album-Name-Edit MÜSSTE, um ihn zurückzuspiegeln, JEDEN einzelnen Track-MP3 im Album neu schreiben - für eine reine Namensänderung unverhältnismäßig.
  • Bei Track wäre es technisch machbar (EIN Track = EINE Datei, dieselbe getid3_writetags-Infrastruktur wie TrackCoverEmbedder fürs Cover- Einbetten) - bewusst als mögliche SPÄTERE Erweiterung offengelassen, nicht Teil dieser ersten Version (ID3-Schreibzugriff bräuchte eigene Sorgfalt: Backup/Restore bei Fehlschlag, welche Tag-Version schreiben etc. - der DB-Edit an sich ist schon der wertvolle Teil).

Name-Edits sind gefahrlos - Node::setName() ändert nur den Anzeigenamen in der DB, rührt den tatsächlichen Ordner/die Dateien auf der Festplatte nicht an. Verifiziert: Streaming (StreamController) nutzt an keiner Stelle den Namen zur Pfadberechnung, nur die beim Import gespeicherte metadata.file_path bzw. eine feste, deterministische UUID (Cover-Speicherorte) - eine Umbenennung hat darauf keinen Einfluss.

Ein gemeinsames, parametrisierbares JS-Modul (assets/ts/node-edit.ts, initNodeEdit()) statt drei fast identischer Kopien - die drei Seiten unterscheiden sich nur in der Feldmenge und dem Ziel-Endpunkt. initArtistEdit()/initAlbumEdit()/initTrackEdit() sind dünne, seitenspezifische Wrapper darum, lesen die jeweilige Node-ID selbst aus einem data-*-Attribut am Stift-Button (konsistent mit initArtistMetadataFetch()), kein Parameter-Durchreichen in app.ts nötig.

Künstler-Sonderfall: onSuccess-Callback blendet die Bio-Sektion ein, falls sie vorher versteckt war (noch keine Bio vorhanden), und setzt die frische Bio wieder auf "eingeklappt" (.artist-bio-clamped) - analog zu applyResult() in artist-metadata.ts nach einem MusicBrainz-Abruf, sonst bliebe ein zuvor ausgeklappter Zustand nach dem Speichern stehen.

Biografie: begrenzt auf 3 Zeilen mit Klick-Toggle

#artistBioText bekam bisher unbegrenzt viel Platz - ein langer Wikipedia-Extract sprengte die Hero-Karte spürbar. Jetzt standardmäßig auf 3 Zeilen begrenzt (.artist-bio-clamped, CSS -webkit-line-clamp - trotz des Namens de-facto-Standard für mehrzeilige Text-Trunkierung, von allen gängigen Browsern unterstützt), mit "..."-Trunkierung. Klick auf den Text selbst togglet die Begrenzung (initBioToggle(), assets/ts/artist-metadata.ts) - kein separater Auf-/Zuklappen-Button nötig, der ausgeklappte Zustand braucht kein eigenes CSS (die Klasse wird einfach entfernt, dann gilt normaler, unbegrenzter Fließtext).

Nach einem erneuten "Metadaten abrufen" startet die Anzeige immer wieder eingeklappt (applyResult() fügt die Klasse explizit wieder hinzu) - unabhängig vom vorherigen Auf-/Zu-Zustand, konsistent mit dem Anfangszustand beim ersten Laden der Seite.

Drei Nachträge aus dem Praxistest

Genre-Gruppierung zeigt jetzt Album-Kacheln statt sofortiger Track-Listen

Die "nach Album gruppieren"-Ansicht dumpte bisher unter jeder Album- Überschrift sofort die komplette Track-Liste. Jetzt eine Kachel-Übersicht (wiederverwendet components/_album_card.html.twig, inkl. Cover) - Klick auf ein Album führt zur normalen Albumseite (alle Tracks des Albums, nicht nur die mit diesem Genre - einfacher und konsistenter mit dem Rest der App als eine eigene gefilterte Zwischenansicht extra dafür zu bauen). Singles (Tracks ohne Album-Zwischenebene) haben kein natürliches Cover - bleiben als flache Liste unter einer Künstler-Überschrift, wie bisher.

GenreController::show() trennt die Gruppen jetzt bereits selbst in albumGroups/singleGroups (statt im Twig zu filtern) - das Template rendert beide unterschiedlich.

"Metadaten aktualisiert", aber kein Bild - Wikidata-Fallback nachgerüstet

Bei Gregorian (und vermutlich vielen anderen Künstlern) hat MusicBrainz NUR eine "wikidata"-Relation, keine direkte "wikipedia"-Relation - findWikipediaTitle() suchte bisher AUSSCHLIESSLICH nach der direkten Variante. Das war eine bewusste, aber falsche Einschätzung beim ursprünglichen Bau ("zusätzlicher Schritt für einen bei bekannten Künstlern ohnehin seltenen Fall") - Tags/Genres kamen trotzdem korrekt (die stammen direkt aus MusicBrainz, unabhängig von Wikipedia), aber Bio/Bild blieben leer, ohne dass ein Fehler sichtbar wurde.

Wikidata-Fallback jetzt nachgerüstet - findet findWikipediaTitle() keine direkte Wikipedia-Relation, aber eine Wikidata-Relation, wird die Wikidata-Entität abgefragt (Special:EntityData/{id}.json) und daraus der passende Sitelink für die konfigurierte Sprache (dewiki) gezogen. Eigener try/catch - ein fehlgeschlagener Wikidata-Abruf bricht nicht die gesamte Metadaten-Abfrage ab, nur Bio/Bild bleiben dann eben leer.

Durchgängiges Logging ergänzt (findWikipediaTitle(), fetchWikipediaSummary(), ArtistController::tryDownloadAndSetCover())

  • vorher blieben mehrere Fehlerpfade (keine Relation gefunden, Wikipedia- Abruf schlug fehl, kein Vorschaubild im Artikel, Bild-Download/- Verarbeitung schlug fehl) komplett spurlos, "Metadaten aktualisiert" erschien trotzdem als Erfolg (Bio/Genres kamen ja tatsächlich an), ohne dass irgendwo sichtbar wurde, warum das Bild fehlte.

Genre-Vorschläge blieben dauerhaft (und veraltet) bestehen

Vorher wurden Vorschläge in metadata.suggested_genres auf dem Künstler gespeichert und erschienen dadurch bei JEDEM künftigen Seitenaufruf erneut - auch für Genres, die längst übernommen waren, oder Wochen nach dem eigentlichen Abruf. Verwirrend statt hilfreich.

Nicht mehr persistiert - Vorschläge existieren jetzt ausschließlich als Teil der fetch-metadata-Antwort, rein im Browser-Speicher dieser Sitzung. Verlässt man die Seite oder lädt neu, sind sie weg; artist/show.html.twig rendert den Vorschläge-Bereich beim normalen Laden nicht mehr serverseitig, nur noch als leerer, versteckter Platzhalter, den assets/ts/artist-metadata.ts nach einem frischen Abruf befüllt.

Bereits vorhandene Genres werden zusätzlich rausgefiltert - bevor die Vorschläge in der Antwort landen, vergleicht ArtistController::fetchMetadata() sie (case-insensitive) gegen $artist->getGenres() - ein Vorschlag für ein Genre, das der Künstler schon hat, wäre ohnehin nutzlos und wird gar nicht erst gezeigt.

MusicBrainz-Fehler nach anfänglichem Erfolg + Genre-Seite ohne Struktur

Zwei weitere Nachträge aus dem praktischen Testen.

"Keine externen Daten gefunden" für JEDEN Künstler

Trat erst nach anfänglich erfolgreichen Abrufen auf - ein starkes Indiz für eine TECHNISCHE Ursache (MusicBrainz-Rate-Limit erreicht, abgelehnter User-Agent, Netzwerkproblem), nicht für "kein Treffer" bei jedem einzelnen Künstler. Das Problem: jeder HTTP-Fehlschlag (429 Rate-Limit, 503 überlastet, 403 abgelehnt, echtes "kein Treffer") führte bisher zu genau derselben generischen null-Rückgabe und Meldung - technischer Fehler und "nichts gefunden" waren dadurch nicht zu unterscheiden.

ArtistMetadataFetcher wirft jetzt eine \RuntimeException mit der konkreten Ursache (analog zu TrackCoverEmbedder/NodeCoverUploader) statt still null zurückzugeben - der tatsächliche HTTP-Status-Code UND der Response-Body werden zusätzlich geloggt (vorher: gar nicht). Bei einem 503 zeigt die Meldung jetzt explizit den Hinweis auf Rate- Limit/Überlastung. ArtistController::fetchMetadata() reicht die konkrete Meldung direkt an die UI durch.

User-Agent MusicBrainz-konformer gemacht - MusicBrainz verlangt laut eigenen Richtlinien eine ECHTE Kontakt-URL/E-Mail im Format AppName/Version ( contact ), ein rein beschreibender String ohne Kontakt (vorherige Version) wird teils strenger gedrosselt oder ganz abgelehnt. Der aktuelle Platzhalter (https://example.invalid/contact) ist NICHT echt - bitte durch eine eigene Kontakt-URL/E-Mail in ArtistMetadataFetcher::requestHeaders() ersetzen.

Bitte nach dem Update die Logs prüfen (var/log), falls der Fehler weiterhin auftritt - die neue Meldung sollte jetzt zeigen, ob es tatsächlich ein Rate-Limit ist (dann hilft nur warten) oder etwas anderes.

Genre-Seite ohne Struktur bei vielen Tracks

Nachdem die Genre-Vererbung bis zum Künstler jetzt korrekt funktioniert, zeigte sich: bei einem breiten Genre kamen schnell hunderte Tracks in einer einzigen, unpaginierten, ungruppierten Liste zusammen.

Zwei Ansichten jetzt (Umschalter oben in .detail-body):

  1. Flache Liste MIT Paginierung (Standard, ?page=N) - dieselbe Seitengröße (60) und dasselbe Muster wie TrackController::index().
  2. Nach Album gruppiert (?group_by_album=1) - KEINE Paginierung dort, die Gruppierung selbst schafft schon genug Struktur, auch bei vielen Tracks. Tracks ohne Album (lose Singles direkt unter einem Künstler) landen in einer eigenen Gruppe PRO Künstler, nicht in einer gemeinsamen "ohne Album"-Sammelgruppe - sonst würden Singles komplett unterschiedlicher Künstler vermischt.

Paginierung/Gruppierung passiert in PHP, nicht per DB-Query - GenreRepository::findEffectiveTracksForGenre() bleibt reine Objektgraph-Traversierung (die "effektive" Zugehörigkeit inkl. Album-/ Künstler-Vererbung steht erst danach fest, kein einfaches SQL-LIMIT/ OFFSET möglich) - für eine private Musikbibliothek unproblematisch.

"Alle abspielen" nur in der flachen Ansicht - bei der Gruppierung gibt es mehrere [data-track-list]-Container (einer pro Album), player.tss play-container-btn nimmt aber immer nur den ERSTEN auf der Seite - hätte dort nur die erste Gruppe abgespielt, nicht das gesamte Genre. Lieber ausgeblendet als ein verwirrend halbes Verhalten anbieten.

Genre-Vererbung bis zum Künstler + Bild-Konflikt-Auflösung

Zwei Nachträge, nachdem die MusicBrainz-Genre-Vorschläge in der Praxis getestet wurden.

Genre-Vererbung endete vorher beim Album

Node::getEffectiveGenres() kannte bisher nur Track→Album - ein Genre AUF DEM KÜNSTLER selbst (z.B. gerade über die neuen MusicBrainz-Vorschläge gesetzt) hatte dadurch praktisch KEINE Wirkung: kein Track/Album darunter zeigte es an, die Genre-Übersicht zählte 0 effektive Tracks - "leer", bis man es manuell auf jeden Track/jedes Album einzeln wiederholt hätte. Genau das widersprach dem Sinn der neu vorgeschlagenen Künstler-Genres.

Jetzt die vollständige Kette: Track → eigenes Genre, sonst Album → eigenes Genre, sonst Künstler. Zusätzlich: Tracks OHNE Album- Zwischenebene (lose Singles direkt unter einem Künstler) erben jetzt auch direkt vom Künstler. Rekursiv gelöst (Album::getEffectiveGenres() climbt bei Bedarf selbst zum Künstler, ein Track darüber übernimmt einfach das Ergebnis) statt die "zum Künstler erben"-Logik doppelt zu schreiben.

GenreRepository::findEffectiveTracksForGenre() (für Genre-Übersicht/ -Detailseite, arbeitet unabhängig auf der Genre→Node-Zuordnung, nicht über getEffectiveGenres()) bekam denselben Nachtrag: ein Genre direkt auf einem Künstler-Node zählt jetzt dessen Alben ohne eigenes Genre (deren Tracks ohne eigenes Genre erben mit) und Tracks direkt unter dem Künstler ohne eigenes Genre.

Gefundenes Bild konnte ein bestehendes Cover nicht ersetzen

Vorher: hatte der Künstler bereits ein Cover, wurde ein bei "Metadaten abrufen" gefundenes Bild einfach stillschweigend verworfen (die "nur wenn leer"-Regel) - der Nutzer bekam nie die Wahl, es trotzdem zu übernehmen.

Jetzt eine echte Entscheidung: Hat der Künstler noch KEIN Cover, wird das gefundene Bild weiterhin sofort übernommen (nichts zu verlieren, keine Rückfrage nötig). Hat er bereits eins, liefert die Antwort nur die externe Bild-URL als Vorschau (suggestedImageUrl) - der Browser zeigt sie direkt vom Wikipedia-Server, kein eigener Download für die reine Vorschau. Zwei neue Buttons ("Übernehmen"/"Verwerfen"): "Verwerfen" blendet die Vorschau einfach aus, kein Server-Aufruf nötig. "Übernehmen" ruft den neuen Endpunkt POST /kuenstler/{id}/apply-image auf, der das Bild jetzt TATSÄCHLICH herunterlädt und das bestehende Cover ERSETZT - der Nutzer hat das durch den expliziten Klick bereits bestätigt.

Domain-Whitelist beim Übernehmen - der neue Endpunkt nimmt die Bild-URL als Parameter entgegen (kein erneuter MusicBrainz/Wikipedia- Roundtrip nötig, die URL wurde schon beim ursprünglichen Abruf ermittelt), akzeptiert aber nur wikipedia.org/wikimedia.org-Domains

  • die URL kommt zwar vom eigenen Frontend, aber ein manipulierter Request könnte theoretisch eine beliebige URL einschleusen; der Server soll nicht als offener Bild-Downloader für irgendeine URL missbraucht werden können.

Externe Metadaten: MusicBrainz + Wikipedia

Neuer "Metadaten abrufen"-Button auf der Künstlerseite - reichert einen Künstler mit Bio-Text, Genre-Vorschlägen und optional einem Bild an. Bewusst NUR für Künstler gebaut (nicht Alben) - Bio/Bild/Tags machen auf Künstler-Ebene am meisten Sinn, album-spezifische Metadaten (Release- Datum etc.) wären ein separates, größeres Thema für sich.

Zwei kostenlose Dienste ohne API-Key - MusicBrainz (strukturierte Daten: eindeutige MBID, Tags/Genres) und Wikipedia (Prosa-Bio-Text, Vorschaubild). Bewusst NICHT Last.fm, obwohl ursprünglich mit vorgeschlagen - würde einen persönlichen API-Key verlangen, den wir hier nicht fest hinterlegen könnten, und Last.fm hat seine eigene Bildunterstützung ohnehin weitgehend abgebaut. MusicBrainz selbst hostet weder Prosa-Biografien noch Bilder - ArtistMetadataFetcher folgt dafür EINER direkten Wikipedia-Relation aus den MusicBrainz-Daten, sofern vorhanden (bewusst kein Umweg über Wikidata, falls nur eine Wikidata- statt einer direkten Wikipedia-Relation existiert - zusätzlicher, fehleranfälligerer Schritt für einen bei bekannten Künstlern seltenen Fall).

MusicBrainz-Rate-Limit beachtet (1 Anfrage/Sekunde pro IP, sonst temporäre Sperre) - der Service macht pro Aufruf zwei MusicBrainz- Anfragen (Suche, dann Lookup mit Tags+URL-Relationen), dazwischen liegt eine kurze Pause. Für einen manuellen, vom Nutzer ausgelösten Klick (kein Bulk-Job über die ganze Bibliothek) ist die daraus resultierende Wartezeit von 1-2 Sekunden akzeptabel.

Genres werden NIE automatisch übernommen - nur als Vorschläge gespeichert (metadata.suggested_genres), jeder Chip fügt sein Genre erst bei Klick hinzu (bestehender node_genre_add-Endpunkt). Automatisches Anwenden ungeprüfter externer Tags wäre riskant (falsche Zuordnungen bei Namensgleichheit verschiedener Künstler, unpassende MusicBrainz-Tags). Ein erneuter Klick auf einen bereits hinzugefügten Vorschlag ist harmlos/idempotent - der Chip bleibt deshalb bewusst einfach stehen, statt sich selbst zu entfernen.

Bild nur als Cover gesetzt, wenn der Künstler noch KEIN eigenes hat

  • dieselbe "nur wenn leer"-Regel wie bei der automatischen Track→Album→ Künstler-Vererbung (TrackCoverEmbedder::propagateToAlbumAndArtist()), ein extern gefundenes Bild soll ein bereits gesetztes/hochgeladenes Cover nie stillschweigend überschreiben. Download läuft über den bestehenden HttpClientInterface in eine Temp-Datei, dann dieselbe Skalierungs-/Speicherlogik wie beim manuellen Upload (NodeCoverUploader) - ein fehlgeschlagener Bild-Download bringt die restlichen, bereits erfolgreich geholten Metadaten (Bio, Genre- Vorschläge) nicht mit zu Fall.

XHR statt HTMX (assets/ts/artist-metadata.ts) - ein einzelnes HTML-Fragment hätte für drei unabhängig ein-/auszublendende Bereiche (Bio, Genre-Vorschläge, Cover) nicht gereicht. Für den Künstler-Cover- Fall (Collage-Rendering, kein einfaches <img id="nodeCoverImg">) dieselbe Update-Logik wie initCoverUploadOverlay() (assets/ts/cover-upload.ts) - der Inhalt des äußeren .cover/ .artist-photo-Divs wird durch ein frisches <img> ersetzt. Neu eingefügte Genre-Vorschlags-Chips brauchen ein explizites window.htmx.process() - anders als bei <a>-Tags (die HTMX per MutationObserver automatisch erkennt) gilt das nicht automatisch für neu eingefügte Elemente mit hx-post u.ä.

Warteschlange per Drag&Drop umsortierbar

Dieselbe Sortable.js-Technik wie bei Playlists (assets/ts/playlist-reorder.ts), aber deutlich einfacher: kein Server-Sync, kein Debounce, keine Offline- Queue nötig - die Warteschlange ist rein lokaler localStorage-Zustand dieses Browsers (persist()), kein Server-Roundtrip erforderlich.

Drag-Handle (.drag-handle, fa-grip-vertical) - dasselbe inline gestylte Markup wie bei Playlists, kein neues CSS für den Handle selbst nötig. Sortable.create() mit handle: '.drag-handle' sorgt dafür, dass nur ein Ziehen AM Handle einen Drag startet, nicht die ganze Zeile - ein Klick zum Springen zu einem Track bleibt dadurch unverändert möglich.

data-queue-index bleibt fest am jeweiligen DOM-Element (Index zum Zeitpunkt des Renderns) - Sortable.js ändert beim Ziehen NUR die DOM- Reihenfolge, nie die Attribute selbst. Nach einem Drop (handleQueueReorder()) lässt sich daraus die neue Anordnung als Permutation der ursprünglichen Indizes auslesen - robuster als über Track-IDs, die bei doppelt vorkommenden Tracks in der Warteschlange nicht eindeutig wären. this.currentIndex wird passend mitgeführt (der zuvor aktuelle Track landet dort, wo die Permutation seinen alten Index enthält) - die tatsächliche Audio-Wiedergabe bleibt dabei komplett unberührt (kein loadTrack()-Aufruf), nur seine Position in der Warteschlangen-Datenstruktur ändert sich.

.queue-item brauchte eine vierte Grid-Spalte (theme.css hatte nur drei: 42px 1fr auto für Cover/Text/Zeit) - der neue Drag-Handle als zusätzliches erstes Kind-Element hätte das bestehende Grid sonst durcheinandergebracht.

Einmalig im Konstruktor gebunden, nicht bei jedem renderQueue()- Aufruf - queueList wird nie durch einen HTMX-Swap ersetzt, nur sein innerHTML per renderQueue(). Sortable.js überwacht den Container selbst, nicht die ursprünglichen Kind-Elemente, bleibt also auch nach wiederholten renderQueue()-Aufrufen gültig.

PWA: "Als App installieren"

MusicDock lässt sich jetzt als eigenständige App installieren (Chrome/ Edge/Safari - bekannt von Netflix, Amazon Music). Drei Bestandteile:

public/manifest.json - App-Metadaten (Name, Icons, Farben, display: standalone für ein fensterloses App-Erscheinungsbild ohne Browser-Chrome). theme_color/background_color (#0b0c10) passen zum Standard-Dunkelmodus - bleiben statisch, auch wenn der Nutzer später zu Hell wechselt (ein Meta-Tag/Manifest kann nicht dynamisch je nach gewähltem Theme reagieren, ohne deutlich mehr Aufwand für einen kleinen kosmetischen Vorteil).

public/sw.js (Service Worker) - eine der beiden technischen Voraussetzungen für den "Installieren"-Button (Chrome verlangt Manifest + einen Service Worker mit Fetch-Handler). Bewusst KEIN vollständiges Offline-Erlebnis: gecacht wird ausschließlich die statische App-Shell (/build/app.js, /build/app.css, die beiden großen Icons) - Audio-Streams, /stream/*, JSON-Endpunkte, HTMX- Navigationen etc. gehen immer direkt ans Netzwerk, nie in den Cache (würde sonst schnell riesige/veraltete Daten anhäufen). Stale-while-revalidate statt reinem Cache-First - esbuild erzeugt keinen Content-Hash im Dateinamen (app.js/app.css bleiben nach einem Rebuild unter demselben Pfad), eine reine Cache-First-Strategie hätte einen neuen Build sonst dauerhaft verdeckt. Stattdessen: die gecachte Version sofort liefern (falls vorhanden), gleichzeitig im Hintergrund neu laden und den Cache für den nächsten Aufruf aktualisieren.

assets/ts/pwa.ts - registriert den Service Worker (initPwa(), einmalig in app.ts, wie initEqualizer()/initFx()). Reiner Feature-Check ('serviceWorker' in navigator) statt blind draufzuschreiben - ältere/exotische Browser bleiben dadurch funktionsfähig, nur ohne Installierbarkeit.

Eigenes Icon generiert (public/img/icons/, Quelle icon.svg) - eine selbst gezeichnete Doppel-Achtelnote auf dem bestehenden Marken-Farbverlauf (--accent--accent-2, dieselben Farben wie .brand-mark in der Sidebar), bewusst kein kopiertes Font-Awesome-Glyph (Lizenzfrage). Drei Varianten: icon-192.png/ icon-512.png (normale App-Icons), icon-512-maskable.png (eigene zweite SVG-Quelle mit ~20% Sicherheitsabstand zum Rand - Android beschneidet maskable Icons kreisförmig/eckig je nach Launcher, ein Icon ohne diese Reserve würde dabei Teile verlieren), sowie apple-touch-icon.png (180×180, undurchsichtiger Hintergrund - iOS kommt mit transparenten Home-Screen-Icons nicht gut zurecht).

Track-Play-Button und Startseiten-Genre-Kacheln nachgezogen

Track-Detailseite hatte noch den alten, rechteckigen Play-Button - beim Vereinheitlichen von Album/Genre auf runde Icon-Buttons (.hero-play-btn) übersehen. Jetzt gleich, funktionale .track-play-btn-Klasse (für player.tss Klick-Handler) bleibt unverändert erhalten, nur die visuellen btn btn-primary-Klassen weichen hero-play-btn.

Genre-Kacheln auf der Startseite ignorierten genre.coverUrl komplett - zeigten nur Farbe/Gradient, selbst wenn ein Cover hochgeladen war (im Gegensatz zur Genre-Übersichts- und -Detailseite, die die Priorität Cover > Farbe > Gradient bereits korrekt hatten - die Startseite hat ein eigenes, einfacheres Markup statt components/_genre_card.html.twig wiederzuverwenden, wurde beim ursprünglichen Bauen des Cover-Features übersehen). Jetzt dieselbe Priorität wie überall sonst - .genre-card span hatte bereits position: relative; z-index: 1 (theme.css), das neue <img> braucht kein eigenes z-index, es steht im DOM vor dem ::after-Overlay- Gradient und dem <span>, landet dadurch automatisch dahinter.

ZIP-Download-Button transparent bei Hover + Hero-Buttons vereinheitlicht

.btn-soft (theme.css) hatte keine eigene :hover-Regel - Bootstrap (im Projekt eingebunden) definiert für die generische .btn- Basisklasse ohne eigene Farbvariante einen transparenten Hover-Fallback über CSS-Variablen, der ohne Override durchschlug. Betraf ALLE .btn-soft-Stellen im Projekt (Playlist, Startseite, Track-Seiten- Pagination), nicht nur den ZIP-Download-Button, der den Bug ursprünglich auffällig machte - global mit einer expliziten .btn-soft:hover-Regel behoben.

Album-/Genre-Hero-Buttons vereinheitlicht: "Album abspielen"/"Alle abspielen" sind jetzt runde, reine Icon-Buttons (.hero-play-btn, 52px) statt rechteckiger Text-Buttons - dieselbe Optik wie der Play- Button unten im Player (.play-button, 40px), nur etwas größer für die Hero-Prominenz. ZIP-Download (nur Album) ist jetzt ein kleinerer, weniger prominenter Icon-Button (.hero-download-btn, 38px, fa-file-zipper statt fa-download) daneben, per margin-left: auto an den rechten Rand der Zeile geschoben - die Zeile selbst nimmt die volle Breite der zweiten .detail-hero-Grid-Spalte ein (1fr), der Button landet dadurch am rechten Rand des gesamten Hero-Bereichs, ohne dass position: absolute nötig wäre. Beide Button-Farben nutzen halbtransparentes Weiß statt der Theme-Variablen (var(--surface-3) etc.) - der Hero-Hintergrund ist immer ein dunkler, farbiger Gradient, unabhängig vom Dark-/Light-Modus der restlichen App, die Theme-Variablen wären dort nicht zuverlässig sichtbar gewesen.

Große Cover-Bilder wirkten verzerrt statt beschnitten

.detail-cover (theme.css) hat feste width/height (260px bzw. 190px im kleineren Breakpoint), aber KEIN object-fit gesetzt - Browser- Standard dafür ist object-fit: fill, das Bild wird proportionslos gestreckt statt beschnitten, sobald sein Seitenverhältnis nicht exakt quadratisch ist (der Normalfall - kaum ein hochgeladenes Bild ist exakt 1:1). Betraf alle vier Upload-Detailseiten (Track/Album/Künstler/Genre), die .detail-cover für ihr großes Hero-Cover nutzen. Andere Cover- Stellen im Projekt (.cover, .track-cover, .genre-overview-cover) hatten object-fit: cover bereits korrekt gesetzt - nur diese eine Klasse nicht.

object-fit: cover ergänzt - füllt den Bereich vollständig aus und beschneidet dafür bei Bedarf die längere Achse, statt zu verzerren, konsistent mit den anderen Cover-Stellen im Projekt. Wirkungslos auf <div>-Platzhalter (Gradient-Fall ohne echtes Bild) - object-fit betrifft nur ersetzte Elemente wie <img>.

Künstlername erschien doppelt bei Tracks ohne Album

Tracks, die direkt unter einem Künstler-Ordner liegen (keine Album- Zwischenebene, z.B. lose Singles), zeigten den Künstlernamen zweimal - einmal korrekt verlinkt, ein zweites Mal unverlinkt genau dort, wo normalerweise das Album stünde.

Ursache im Import: MediaImporter::importTrack() setzte album_name bisher mit $tags['album'][0] ?? $parent->getName() - ohne echten Album-ID3-Tag fiel das auf $parent->getName() zurück. $parent ist bei einem Track OHNE Album-Zwischenebene aber der KÜNSTLER-Ordner selbst - album_name bekam dadurch fälschlich den Künstlernamen. _track_row.html.twig/track/show.html.twig zeigten diesen (falschen) Fallback-Wert dann als vermeintliches Album an, sobald keine strukturelle Album-Beziehung vorlag.

Fix an zwei Stellen: Der Import setzt album_name jetzt nur noch, wenn entweder ein echter Album-Tag vorhanden ist ODER $parent TATSÄCHLICH ein Album-Ordner ist ($parent->getKind() === 'album') - betrifft nur künftige Importe. Für bereits bestehende, VOR dem Fix importierte Tracks (ohne Reimport) wurde zusätzlich der Template- Fallback eingeschränkt: album_name wird nur noch angezeigt, wenn wir NICHT bereits strukturell wissen, dass der Track direkt unter einem Künstler liegt (artist gesetzt, album null) - in diesem Fall zeigt die Zeile jetzt einfach nichts an der Album-Stelle, wie gewünscht. Dieselbe Einschränkung auch für data-track-album (Player-Leiste/ Warteschlange, player.tss buildArtistAlbumHtml() überspringt einen leeren Wert ohnehin automatisch, kein Fehlerzustand).

Künstler-Cover-Upload unterbrach die laufende Wiedergabe

Der Upload-Erfolgspfad für Künstler fiel bisher bewusst auf einen vollständigen window.location.reload() zurück - die Collage- Darstellung (Grid aus bis zu 4 Album-Covern) wurde als "zu komplex, um sie 1:1 in JS nachzubilden" eingestuft. Ein voller Reload unterbricht aber IMMER die laufende Wiedergabe (verzögertes Positions-Restore verhindert zwar unnötige Requests, siehe oben, aber die Musik startet nach einem Reload nie automatisch neu, das ist so gewollt) - bei Album/ Genre/Track fiel das nie auf, da dort das direkte DOM-Update ohne Reload lief.

Reload durch echtes DOM-Update ersetzt, ohne die Collage-Logik nachzubilden. Der Schlüssel: NodeCoverUploader::upload() leert bei JEDEM manuellen Upload die Collage-Array (cover_urls, siehe der Cache-Busting-Fix weiter unten in dieser Historie) - der Zielzustand nach einem erfolgreichen Upload ist deshalb IMMER "Einzelbild", nie Collage, unabhängig vom Ausgangszustand. components/_cover.html.twig bekam dafür einen stabilen Wrapper id="{zoneId}Display" um alle drei Fälle (Collage/Einzelbild/Gradient) - deren äußeres .cover/ .artist-photo-Div ist in allen drei Fällen strukturell identisch (class="{{ shape_class }} {{ gradient_class }}"), nur der INHALT unterscheidet sich. initCoverUploadOverlay() muss bei Erfolg deshalb nur diesen Inhalt durch ein frisches <img> ersetzen (outerCoverDiv.innerHTML = ...)

  • shape_class/gradient_class bleiben unverändert auf dem bereits vorhandenen äußeren Div, kein erneutes Berechnen in JS nötig. Ein Reload bleibt nur noch als reines Sicherheitsnetz bestehen, falls {zoneId}Display aus irgendeinem Grund fehlen sollte (sollte praktisch nie eintreten).

Album-Hero zeigte das Cover doppelt (gestreckt als Hintergrund + sauber als Thumbnail)

Album war die einzige der vier Detailseiten, die das Cover-Bild SELBST gestreckt (background-size: cover) als Hero-Hintergrund zeigte - Track, Genre und Künstler nutzen alle nur einen Gradient. Zwei Probleme damit: der Hintergrund aktualisierte sich nach einem Cover-Upload nie (initCoverUploadOverlay(), assets/ts/cover-upload.ts, kennt nur das saubere Vordergrund-Thumbnail, nicht diesen separaten Hintergrund-Style

  • blieb also auf dem alten Bild stehen, während das Thumbnail davor korrekt aktualisiert wurde), UND das gestreckte Cover direkt neben dem sauberen Cover-Thumbnail wirkte redundant und optisch unschön.

Jetzt immer nur der Gradient, unabhängig davon, ob ein Cover vorhanden ist - konsistent mit den anderen drei Detailseiten. Behebt beides gleichzeitig: kein Sync-Bug mehr möglich (der Gradient hängt nie vom Cover ab), und "nicht gestreckt" ist der Gradient von Natur aus, kein Bild, das verzerrt werden könnte.

Cover-Upload jetzt auch für Album, Künstler und Genre

Dasselbe Hover-Overlay-Muster wie beim Track-Cover-Upload, jetzt auch auf Album-, Künstler- und Genre-Detailseite - ohne MP3-Bezug (Album/ Künstler/Genre haben keine eigene Mediendatei, in die man ein Cover einbetten könnte), rein eine eigene Anzeige-Datei.

Bug gefunden und behoben, bevor er relevant werden konnte: MediaImporter::addAlbumCoverToArtistCollage() prüfte bisher nur count($collage) === 1 (das allererste Element in der Collage-Array), um zu entscheiden, ob es das singuläre cover_url auf dem Künstler setzen soll - ein MANUELL hochgeladenes Künstler-Cover rührt diese Collage-Array aber gar nicht an. Ohne Fix hätte das nächste Album-Cover- Ereignis (Import oder nachträglicher Track-/Album-Cover-Upload) ein manuell gesetztes Künstler-Cover stillschweigend überschrieben. Prüft jetzt zusätzlich !$artist->getMetadataValue('cover_url').

NodeCoverUploader (neuer Service, für Album UND Künstler - beide Node-Entities) - skaliert auf maximal 512×512, dieselbe Speicher- struktur wie beim automatischen Import (public/media/covers/{bucket}/{uuid}.*, über die wiederverwendeten MediaImporter::finalizeCover()/bucketFor()). Anders als die automatische "nur wenn leer"-Vererbung (Track → Album → Künstler, siehe TrackCoverEmbedder::propagateToAlbumAndArtist()) ERSETZT ein manueller Upload ein bestehendes Cover bewusst - expliziter Nutzerwunsch, kein automatisches Ableiten. POST /album/{id}/cover zieht bei Erfolg trotzdem denselben Künstler-Collage-Mechanismus nach (addAlbumCoverToArtistCollage(), "nur wenn der Künstler noch keins hat") - konsistent mit dem Track-Pfad. POST /kuenstler/{id}/cover für den Künstler direkt.

Genre nutzt einen ZWEITEN, separaten Endpunkt (POST /genre/{id}/cover-json, GenreController::setCoverJson()) statt des bestehenden genre_set_cover - der bestehende Endpunkt liefert ein HTML-Fragment für das "..."-Popup der Genre-Übersichtsseite (hx-swap="outerHTML"), der neue liefert JSON im selben Format wie Track/Album/Künstler. Ein einheitlicher Endpunkt hätte eines der beiden Frontends gebrochen - setCover() (HTML-Fragment) bleibt für die Übersichtsseite unverändert, setCoverJson() ist neu für die Detailseite.

_cover.html.twig um optionalen upload_zone_id/upload_url-Wrapper erweitert - NUR gesetzt auf der Künstler-Detailseite (Grid-Kacheln bleiben unverändert), legt einen zusätzlichen Wrapper MIT Hover-Upload- Overlay um das bestehende Collage-/Einzelbild-/Gradient-Markup, ohne diese Fälle selbst zu verändern oder zu duplizieren. album/show.html.twig bekam dasselbe Overlay-Markup direkt eingebaut (bindet _cover.html.twig gar nicht ein, hatte schon vorher eigenes, einfacheres Markup).

Neues, gemeinsames JS-Modul assets/ts/cover-upload.ts - wieder- verwendet für Album/Künstler/Genre. Track bleibt bewusst bei seiner eigenen, bereits getesteten Logik in track-detail.ts (kein Umbau- Risiko für den funktionierenden Track-Pfad). Die Ziel-URL kommt über ein data-upload-url-Attribut am Zone-Element (Twig kennt die URL bereits über path(), kein JS-seitiges URL-Bauen nötig).

Künstler fällt bei Erfolg auf einen vollständigen Reload zurück - die Collage-Darstellung (Grid aus bis zu 4 Album-Covern) ist zu komplex, um sie 1:1 in JS nachzubilden; ein einfaches <img id="nodeCoverImg"> existiert dort deshalb bewusst nicht. initCoverUploadOverlay() prüft das automatisch (fehlt das Element, wird stattdessen neu geladen) - für Album/Genre (genau ein Bild) läuft es weiterhin ohne Reload direkt im DOM.

CSS konsolidiert statt dupliziert - .track-cover-upload-* (Track) und die neuen .cover-upload-* (Album/Künstler/Genre) teilen sich dieselben Regeln (.track-cover-upload-zone, .cover-upload-zone { ... } usw.). Künstler-Cover ist rund (.artist-photo/.artist-large, border-radius: 50%) - #artistCoverUploadZone bekommt dieselbe Rundung + overflow: hidden, sonst hätte das eckige Overlay über die runde Form hinausgeragt oder deren Ecken freigelassen.

Nachtrag: manuell hochgeladenes Künstler-Cover erschien in Tracks/ Alben ohne eigenes Cover, aber NICHT auf der Künstlerseite selbst - dort blieb weiterhin die alte Collage sichtbar. components/_cover.html.twig prüft cover_urls|length > 1 (die Collage-Array, bis zu 4 Album-Cover) VOR dem einzelnen cover_url - NodeCoverUploader::upload() hatte diese Collage-Array beim manuellen Upload nie angerührt, eine bestehende Collage gewann deshalb weiterhin gegen das gerade hochgeladene, neue Cover. Jetzt wird cover_urls bei jedem manuellen Upload explizit geleert (bei Album ist die Property ohnehin immer leer, dort ein No-Op).

Tracks, die das Künstler-Cover erben, sind kein Bug - Tracks ohne eigenes/Album-Cover erben vom Eltern-Ordner (components/_track_row.html.twig, track.parent); bei Singles ohne Album-Zwischenebene ist der Eltern-Ordner der Künstler selbst. Bereits bestehendes Verhalten, nicht neu durch diese Funktion eingeführt.

Nachtrag 2: ein erneuter Cover-Upload für Album/Künstler zeigte weiterhin das alte Bild. NodeCoverUploader hatte - anders als Track- und Genre-Cover-Upload - schlicht KEIN Cache-Busting. Da der Dateiname (Node-UUID) bei jedem Upload gleich bleibt, blieb auch die gespeicherte URL bei einem erneuten Upload BUCHSTÄBLICH identisch - der Browser hatte also gar keinen Grund, das Bild neu vom Server zu laden, und zeigte weiterhin das alte aus dem eigenen Cache, obwohl die Datei auf der Festplatte längst ersetzt war. Jetzt mit demselben ?v=timestamp-Suffix wie bei Track/Genre.

Sound-Effekte: Reverb, Echo, Kompressor

Neues, eigenständiges FX-Panel (Trigger #fxButton in der Player- Leiste, neben dem Equalizer-Button) - inspiriert von einem Community- Skript, das der Nutzer gefunden hat. Nutzt denselben geteilten Audio- Graphen wie der Equalizer (assets/ts/audio-graph.ts, createMediaElementSource() darf pro <audio>-Element nur einmal im Seitenleben aufgerufen werden - beide Module teilen sich deshalb das bestehende Singleton-Muster, kein zweiter, konkurrierender Graph-Aufbau).

Erweiterte Signalkette:

audio -> [5 EQ-Filter] -> Compressor -> Delay(dry+wet) -> Reverb(dry+wet) -> Analyser -> destination

Der Analyser sitzt weiterhin ganz am Ende (nach EQ UND FX) - der Visualizer zeigt dadurch weiterhin das tatsächlich hörbare Signal, inklusive aller eigenen Effekte, nicht das unveränderte Original.

Delay/Echo und Reverb haben je einen eigenen Dry/Wet-Split - Signal läuft parallel unverändert UND durch den Effekt, beide werden an einem gemeinsamen Ausgangs-GainNode wieder gemischt (Web Audio summiert mehrere eingehende connect()-Aufrufe an einem Node automatisch, kein eigener "Merge"-Node-Typ nötig). Erlaubt sauberes, klickfreies Ein-/ Ausblenden über setTargetAtTime() (statt den Signalpfad komplett umzuschalten) - RAMP_SECONDS = 0.05 glättet jede Wertänderung.

Reverb nutzt einen synthetischen Impulse-Response-Buffer (Zufalls- rauschen mit exponentiell abklingender Lautstärke pro Kanal, 2 Sekunden lang) statt eine echte Impulse-Response-Audiodatei laden zu müssen - derselbe Trick wie im Ausgangsskript. Ein ConvolverNode faltet jedes Eingangssample mit dieser "Antwort" - zufällige, abklingende Reflexionen sind genau das, was einen plausiblen Raum-/Plattenhall ausmacht.

Delay/Echo mit Feedback-Loop (delayNode.connect(feedback); feedback.connect(delayNode);) für wiederholte, leiser werdende Echos - klassisches Tape-Delay-Muster, drei Regler (Zeit, Wiederholungsstärke, Mix).

Kompressor ohne eigenen Dry/Wet-Split - ein DynamicsCompressorNode mit threshold: 0dB komprimiert praktisch nichts (kein Signal geht über 0dB), das entspricht funktional "aus"; der Threshold-Regler (-60 bis 0dB) steuert direkt, ab wann komprimiert wird.

Zwei Effekte aus dem Ausgangsskript bewusst NICHT übernommen:

  • Pitch-Shift über ScriptProcessorNode - die Web-Audio-API stuft ScriptProcessorNode als deprecated ein (blockiert den Hauptthread, ersetzt durch AudioWorkletNode), UND der verwendete Algorithmus ist eigentlich nur ein Resampling-Trick, der Tonhöhe UND Wiedergabegeschwindigkeit gleichzeitig ändert (kein echter, geschwindigkeitsneutraler Pitch-Shift - der bräuchte einen Phase-Vocoder o.ä., deutlich aufwändiger).
  • "Phone"-Bandpassfilter - würde sich mit dem bestehenden 5-Band-EQ überschneiden (deckt denselben Frequenzbereich bereits ab).

Eigene Persistierung (localStorage, Key musicdock.fx, analog zu musicdock.eq) - alle Regler-/Toggle-Zustände bleiben über einen Reload hinweg erhalten, unabhängig vom Equalizer.

Genre-Cover-Upload vereinheitlicht (nur noch über die Detailseite)

Genre war die einzige Ausnahme - Cover-Upload lief dort über ein sichtbares Datei-Feld im "..."-Popup der Übersichtsseite, nicht wie bei Track/Album/Künstler über ein Hover-Overlay auf der großen Grafik. Jetzt einheitlich: das Popup pflegt nur noch die Farbe, Cover-Upload läuft ausschließlich über genre/show.html.twigs .cover-upload-zone (genau wie bei den anderen drei).

GenreController::setCover() (der frühere HTML-Fragment-Endpunkt für das Popup) entfernt - setCoverJson() (JSON-Antwort, ursprünglich nur für die Detailseite gebaut) übernimmt jetzt allein diese Rolle und wurde zurück auf den saubereren Namen setCover()/Route /genre/{id}/cover umbenannt, da er jetzt der einzige Cover-Endpunkt ist. Tote CSS-Klassen (.genre-cover-upload-label/-input) mit entfernt.

Übersichtskarten zeigen jetzt ein Cover-Thumbnail (.genre-overview-cover, 48×48px, border-radius: 10px) - dasselbe Muster wie eine Track-Zeile (.track-cover), Priorität Cover > Farbe > Gradient, konsistent mit der Detailseite. Der frühere Farbakzent-Rand (border-left auf .genre-overview-link) ist entfallen - das Thumbnail übernimmt jetzt die Farb-/Cover-Anzeige, ein zusätzlicher Rand wäre redundant gewesen. Beim Umbau fiel eine versehentlich doppelte .genre-overview-link-Regel auf (display: block vs. das neue display: flex) - zu einer konsolidiert.

Player-Leiste zeigte nach Cover-Upload weiterhin das alte Bild

Der Cover-Upload auf der Track-Detailseite (initTrackCoverUpload()) aktualisierte bisher nur das große Bild AUF der Detailseite selbst - nicht die Player-Leiste, falls genau dieser Track gerade lief, nicht das Queue-Panel (falls der Track dort auftaucht), und nicht den in localStorage persistierten Zustand (ein Reload hätte danach wieder das alte Cover gezeigt, bis zum nächsten Trackwechsel). Kein Browser-Cache- Problem (Cache-Busting per ?v=timestamp war bereits vorhanden) - die betroffenen Anzeigen wurden schlicht nie angefasst.

Neue Methode MusicDockPlayer.updateTrackCover(trackId, coverUrl) (public) - aktualisiert den passenden Queue-Eintrag, die Player-Leiste (falls der Track aktuell läuft) und ruft persist() auf. Rein clientseitiger State-Sync, kein eigener Request - der Upload selbst hat den Server bereits aktualisiert.

player musste dafür in den Modul-Scope von app.ts gehoben werden

  • war bisher nur lokal innerhalb der DOMContentLoaded-Callback deklariert, der htmx:afterSettle-Handler (der initTrackCoverUpload() nach jeder Navigation erneut aufruft) hatte darauf keinen Zugriff. Die Instanz selbst wird weiterhin nur EINMAL erzeugt, nur der Verweis wird jetzt außerhalb beider Handler gehalten.

Album-/Künstler-Cover erbten ein nachträgliches Track-Cover nicht

Album- und Künstler-Cover werden ausschließlich beim Import gesetzt - NUR wenn zu diesem Zeitpunkt noch keins vorhanden war (MediaImporter::importAll(), if (!$album->getMetadataValue('cover_url'))). Hatte ein Album beim Import keinen einzigen Track/Ordner-Cover (z.B. ein Album mit nur einem, damals coverlosen Track), blieb es dauerhaft beim deterministischen Gradient - auch wenn dieser eine Track später per TrackCoverEmbedder doch ein Cover bekam. Es gab schlicht keinen Code-Pfad, der "Track hat jetzt ein Cover" mit "Album/Künstler nachziehen" verband.

Neue Methode TrackCoverEmbedder::propagateToAlbumAndArtist() - läuft nach jedem erfolgreichen Embed, prüft dieselbe "nur wenn leer"- Regel wie beim Import fürs Album, und ruft danach MediaImporter::addAlbumCoverToArtistCollage() auf (dafür von private auf public umgestellt - kein duplizierter Code, dieselbe Dedupe-/ Collage-Logik wie beim Import). Wird IMMER aufgerufen, nicht nur bei gerade neu gesetztem Album-Cover - addAlbumCoverToArtistCollage() prüft selbst, ob die URL schon in der Künstler-Collage steckt, ein wiederholter Aufruf mit demselben Cover ist harmlos.

Progress ging nach zwei Reloads ohne Play verloren

Ein weiterer Nachtrag zum verzögerten Positions-Restore oben: loadTrack() ruft an seinem Ende immer persist() auf - persist() speicherte bisher unbedingt audio.currentTime, das beim Restore aber BEWUSST noch 0 ist (Seek verzögert bis zum tatsächlichen Play-Klick, siehe oben). Das überschrieb die echte gespeicherte Position in localStorage schon beim ERSTEN Reload mit 0 - sichtbar wurde das erst beim ZWEITEN Reload, weil pendingResumeTime als reine JS-Variable für die erste Seitenansicht noch den korrekten Wert im Speicher hielt, aber der zweite restoreFromStorage()-Aufruf bereits die überschriebene 0 aus localStorage las.

persist() bevorzugt jetzt pendingResumeTime, falls eine Restore-Position noch aussteht, statt bedingungslos audio.currentTime zu speichern - alle anderen Persistierungen (Queue, Index, Shuffle/ Repeat) waren nie betroffen, nur die Position.

Wellenform zeigte nach dem Reload-Fix keinen Fortschritt mehr

Nebenwirkung des preload="none"-Fixes (oben): audio.currentTime bleibt jetzt bis zum ersten Play-Klick bei 0, aber loadWaveform()s initialer drawWaveform()-Aufruf (ohne Argument) berechnete den Fortschritt weiterhin aus genau diesem Wert - die Wellenform selbst war sichtbar, zeigte aber immer "0% gehört", egal wie weit man vorher tatsächlich war. Dieselbe Ursache betraf auch die einfache Balken-Progressbar (Fallback ohne Wellenform, hängt über timeupdate ebenfalls an audio.currentTime).

Neue Methode previewPendingResumePosition() - zeichnet den visuellen Fortschritt direkt aus pendingResumeTime (der gemerkten, noch nicht angewendeten Restore-Position), OHNE audio.currentTime anzufassen - kein Request, rein zeichnerisch. Aufgerufen an ZWEI Stellen (deckt beide Reihenfolgen ab): einmal direkt in loadTrack() (Fall ohne Wellenform, reagiert sofort), einmal in loadWaveform()s Promise-Callback (Fall MIT Wellenform, die erst asynchron nachgeladen wird - trifft später ein als loadTrack()s eigener Aufruf, der zu dem Zeitpunkt noch keine Wellenform hatte).

Progressbar-Drag löste kontinuierlich Requests aus

Ein zweiter, unabhängiger Fund: Ziehen an der Progressbar (nicht nur ein einzelner Klick) feuerte WÄHREND des gesamten Drags laufend Requests ab, statt erst beim Loslassen - wireSeekScrubbing()s document.mousemove- Handler setzte bei JEDER Mausbewegung audio.currentTime direkt, und jede solche Zuweisung löst beim <audio>-Element einen eigenen Seek/ Range-Request aus.

Getrennt in "visuelle Vorschau während des Drags" und "tatsächlicher Seek bei Drop" - previewPosition(ratio) aktualisiert während des Ziehens NUR das Sichtbare (Tooltip, Wellenform-Vorschau über einen neuen optionalen previewRatio-Parameter auf drawWaveform(), oder die einfache Balken-Progressbar als Fallback), OHNE audio.currentTime anzufassen - kein Request. Der eigentliche Seek (seekTo(), setzt audio.currentTime) passiert nur noch: einmalig bei einem reinen Klick (sofort, wie gewünscht), und einmalig bei mouseup/touchend (dem Drop) nach einem Drag - nicht mehr bei jedem Zwischenschritt. Gilt gleichermaßen für Maus- und Touch-Bedienung.

Nachtrag 1: Wellenform flackerte während des Drags zwischen echter und Drag-Position. Der timeupdate-Handler (läuft weiter, solange die Wiedergabe im Hintergrund tatsächlich läuft) zeichnete unabhängig davon weiterhin basierend auf audio.currentTime - konkurrierte damit direkt mit previewPosition()s Zeichnen der Drag-Position ums selbe Canvas. Neue Klassen-Eigenschaft isScrubbing (der timeupdate-Handler sitzt in einer anderen Methode als wireSeekScrubbing(), eine lokale Variable hätte dort nicht gereicht) - der visuelle Teil des timeupdate-Handlers (Wellenform/Progressbar, Zeit-Anzeige, aktive Lyrics-Zeile) wird komplett unterdrückt, solange isScrubbing true ist. Das periodische persist() läuft unabhängig davon weiter, unbetroffen.

Nachtrag 2: neues Debounce-Verhalten beim Drag - "Verharren ohne Drop" löst nach STOP_WITHOUT_DROP_MS (600ms) trotzdem den echten Seek aus. Bleibt die Maus/der Finger beim Ziehen für diese Zeitspanne an derselben Stelle stehen, ohne dass losgelassen wird, fühlt es sich für den Nutzer an wie ein kurzes Drop - das Dragging selbst endet dabei NICHT (Weiterziehen bleibt jederzeit möglich, klassisches Debounce: der Timer wird bei jeder Mausbewegung neu gestartet und läuft nur ab, wenn wirklich nichts mehr passiert). Nach einem ausgelösten Stop-Seek wird KEIN neuer Timer gestartet, bis sich wieder etwas bewegt - kein Wiederholungs-Risiko bei stillstehender Maus.

Shuffle/Repeat-Persistenz

persist()/restoreFromStorage() speicherten bisher NUR Warteschlange, Index und Wiedergabeposition - Shuffle/Repeat wurden nie mitgesichert, standen nach jedem Reload also immer wieder aus, unabhängig vom Zustand davor. Jetzt zwei zusätzliche localStorage-Keys (musicdock.shuffle/musicdock.repeat), gesetzt bei jedem Klick auf den jeweiligen Button (nicht erst beim nächsten Trackwechsel) und beim Start VOR dem eigentlichen Queue-Restore gelesen - wichtig in dieser Reihenfolge, da playPrev()/playNext() (z.B. per Tastenkürzel direkt nach dem Laden) this.shuffle/this.repeat sofort auswerten, nicht erst nach einem Klick.

Lautstärke-Regler

MusicDockPlayer::wireVolumeScrubbing() - Klick UND Ziehen, dasselbe Grundmuster wie wireSeekScrubbing() bei der Progress-/Wellenform-Leiste (mousedown startet, mousemove/mouseup laufen auf document, nicht nur auf der oft nur wenige Pixel hohen Leiste selbst - sonst reißt das Ziehen ab, sobald die Maus die Leiste verlässt). Vorher war die Leiste nur klickbar, kein Ziehen möglich.

Lautstärke-Icon schaltet stumm/laut um (volumeIconBtn) - vorher hatte der Button gar keine id, ein Klick tat also buchstäblich nichts. lastNonZeroVolume merkt sich den Pegel VOR dem Stummschalten, ein erneuter Klick springt dorthin zurück statt immer auf einen festen Standardwert. Das Icon selbst wechselt je nach Pegel (fa-volume-xmark/fa-volume-low/fa-volume-high).

Wellenform-Progressbar

Vorab berechnete Peak-Daten statt Audio-Decoding im Browser - genau der Grund, warum das nicht anders sinnvoll geht: würde der Browser die Wellenform selbst berechnen, müsste er dafür jede MP3 komplett laden und per Web Audio API decodieren, nur um eine Kurve zu zeichnen. Für eine reine Progressbar-Optik unnötig teuer, gerade bei vielen Tracks in einer Liste.

Backend, läuft nur beim Import: src/Service/WaveformGenerator.php dekodiert die Audiodatei per ffmpeg zu rohen PCM-Samples (mono, 4 kHz - für eine reine Amplitudenkurve über 300 Balken reicht das, spart ffmpeg-Rechenzeit ggü. der Originalqualität), fasst sie zu 300 Balken zusammen (lautester Sample-Wert pro Zeitabschnitt, normiert auf 0..1) und speichert das Ergebnis in metadata.waveform_peaks auf dem Track-Node - passt ohne Schema-Änderung ins bestehende JSON-Metadata-Feld. MediaImporter::importTrack() ruft das nach dem Speichern der Audiodatei auf; app:import-media --no-waveform überspringt es (schneller Import, z.B. bei sehr großen Bibliotheken oder wenn ffmpeg fehlt).

Performance-Falle, auf die man leicht reinfällt: die Samples dürfen NIE komplett in ein PHP-Array entpackt werden. Ein 4-Minuten-Track hat bei 4 kHz ~960.000 Samples - PHP-Arrays haben pro Element erheblichen Overhead, das Entpacken aller Samples auf einmal (frühe Version dieser Klasse) hat den Import massiv ausgebremst, spürbar bei jedem einzelnen Track. WaveformGenerator arbeitet deshalb direkt auf dem rohen Byte-String: pro Balken wird nur der jeweils betroffene Ausschnitt per substr() entpackt (ein paar Tausend statt hunderttausende Samples auf einmal). Der ffmpeg-Aufruf läuft außerdem unter timeout 30 (GNU coreutils, in DDEVs Debian-basiertem Web-Image standardmäßig vorhanden) - falls ffmpeg an einer kaputten/exotischen Datei hängen bleibt, bricht das nach 30s ab statt den ganzen Import zu blockieren.

-nostdin ist Pflicht, kein optionaler Zusatz. Ohne dieses Flag erwartet ffmpeg in bestimmten Situationen Eingaben von stdin und blockiert dann komplett, wenn keine kommen - ein bekannter Stolperstein beim Aufruf aus PHP/Skripten/Cron heraus (nicht-interaktiver Kontext), sichtbar als kompletter Stillstand ohne jede Fehlermeldung. Zusätzlich < /dev/null als zweite Absicherung, damit ffmpeg stdin gar nicht erst offen erreichen kann.

-vn (no video) spart unnötige Arbeit bei Cover-tragenden Dateien. Ein eingebettetes Cover in einer MP3 (siehe MediaImporter::extractEmbeddedCover()) wird von ffmpeg intern als eigener Video-Stream behandelt (ein einzelnes MJPEG-Bild) - ohne -vn verarbeitet ffmpeg diesen ggf. mit, obwohl nur der Audio-Teil gebraucht wird. Spürbar bei einer Bibliothek, in der die meisten Dateien ein eingebettetes Cover haben.

Neue Systemvoraussetzung: ffmpeg muss im DDEV-Webcontainer verfügbar sein - .ddev/web-build/Dockerfile installiert es automatisch als zusätzlicher Layer auf dem Standard-Web-Image. Nach dem Auspacken/Ändern einmal ddev restart, damit das Image neu gebaut wird. Ohne ffmpeg liefert WaveformGenerator::generate() einfach null (kein Hard-Fail des Imports) - betroffene Tracks fallen dann auf die schlichte Balken-Progressbar zurück, genau wie Tracks, die vor Einführung dieses Features importiert wurden.

Frontend: StreamController::waveform() liefert die Peak-Werte als winzige JSON-Antwort (GET /stream/{id}/waveform) - der Player lädt sie beim Trackwechsel, nie die Audiodatei selbst zu diesem Zweck. assets/ts/player.ts zeichnet sie per <canvas> (#waveformCanvas, liegt über der bestehenden #progressValue-Füllung): gespielter Teil in Akzentfarbe, Rest in gedämpfter Oberflächenfarbe, bei jedem timeupdate und Klick-Seek neu gezeichnet. Ein Zähler (waveformRequestId) verwirft verspätet eintreffende Antworten, falls zwischenzeitlich schon zum nächsten Track gesprungen wurde - sonst könnte kurzzeitig die Wellenform des falschen Tracks aufblitzen. Tracks ohne Peak-Daten zeigen unverändert die schlichte Balken-Progressbar von vorher (#progressValue) - reiner Fallback, kein Fehlerzustand.

Wellenformen asynchron nachholen (Cronjob)

Für große Bibliotheken (viele Tracks, oder lange Dateien wie 1-Stunden- Podcasts) läuft app:generate-pending-waveforms als eigener, Cron-tauglicher Befehl - holt die ffmpeg-Arbeit nach, statt den eigentlichen app:import-media zu blockieren.

Kein eigenes "pending"-Tag nötig: NodeRepository::findTracksWithoutWaveform() nutzt einfach die Abwesenheit von metadata.waveform_peaks als Kriterium - deckt automatisch alle drei Fälle ab (--no-waveform beim Import, ffmpeg-Fehler, oder ein Track von vor Einführung des Features), ganz ohne dass beim Import zusätzlich etwas markiert werden müsste.

ddev exec bin/console app:generate-pending-waveforms

Zwei Sicherungen für den Cron-Betrieb:

  • Lock (Symfony Lock, FlockStore - reine Dateisystem-Sperre, braucht keine zusätzliche Konfiguration): läuft ein Aufruf länger als der Cron-Takt, überspringt sich der nächste Tick selbst statt parallel zum noch laufenden Vorgänger zu arbeiten.
  • Zeitbudget (--time-budget, Default 240s): bricht NACH dem gerade laufenden Track sauber ab, sobald das Budget aufgebraucht ist, statt über den nächsten Cron-Tick hinweg zu laufen. Ein einzelner sehr langer Track kann das Budget überziehen, aber nie mehr als um die Dauer dieses einen Tracks.

Beispiel-Crontab (auf dem Host, nicht im Container - ddev exec startet den Container bei Bedarf mit):

# Alle 5 Minuten während der 4-Uhr-Stunde - eine ruhige Nachtstunde mit
# wenig Konkurrenz um CPU-Zeit. Zeitfenster/Takt frei anpassbar.
*/5 4 * * * cd /pfad/zu/musicdock && ddev exec bin/console app:generate-pending-waveforms >> var/log/waveform-cron.log 2>&1

Bis ein Track dran war, zeigt der Player einfach weiter die schlichte Balken-Progressbar - kein Fehlerzustand.

Lyrics

Song-Lyrics von lrclib.net, einer kostenlosen, öffentlichen API ohne Auth-Zwang. Zwei Wege, wie sie ins System kommen - bewusst beide, nicht nur einer:

  1. Automatisch im Hintergrund - app:fetch-pending-lyrics (Cronjob, siehe "Referenz: Alle Shell-Befehle & Cronjobs" oben) fragt für Tracks, bei denen das noch nie versucht wurde, bei lrclib.net an.
  2. Manuell auf Zuruf - "Lyrics suchen"-Button im Lyrics-Panel, wenn für einen Track (noch) nichts gefunden wurde. Anders als der automatische Cron-Nachlauf fragt der Button IMMER neu an, auch wenn schon einmal (erfolglos) versucht wurde - ein gezielter Klick ist ein bewusster Neuversuch, keine automatische Wiederholung.

Lyrics-Text steht NICHT im initialen HTML jeder Track-Zeile - nur ein schlankes data-track-has-lyrics="1" (Boolean, für das Mikrofon-Icon in der Zeile + die sofortige Hervorhebung des Lyrics-Buttons in der Player-Toolleiste, ganz ohne den eigentlichen Text zu kennen). Bei z.B. 100 Tracks in einer Liste würden sonst 100 vollständige Songtexte im initialen HTML landen, obwohl zu jedem Zeitpunkt nur der gerade laufende Track überhaupt gebraucht wird - unnötig aufgeblähte Seitengröße für praktisch nie genutzte Daten. Stattdessen holt player.ts (loadLyrics() in lyrics.ts) den Text erst bei tatsächlichem Bedarf nach: bei jedem Trackwechsel GET /track/{id}/lyrics (TrackController::getLyrics(), reiner DB-Read, KEIN neuer lrclib.net-Aufruf - dafür ist der separate POST .../lyrics/fetch-Endpunkt da, siehe unten). Kein Fetch, wenn track.hasLyrics bereits false ist (aus dem schlanken Attribut bekannt) oder der Text für diesen Track in der laufenden Session schon einmal geladen wurde (im Queue-Eintrag gemerkt, onLyricsUpdated() zieht localStorage nach). Panel und Karaoke-Overlay teilen sich EINEN gemeinsamen Ladevorgang statt je einem eigenen - sonst würden zwei unabhängige Aufrufe denselben Fetch unnötig doppelt auslösen. Ein kurzer "Lyrics werden geladen…"-Hinweis überbrückt die (in der Praxis sehr kurze) Wartezeit.

Warum LRC (zeitsynchronisiert) statt reinem Klartext als bevorzugtes Format: MusicDock hat mit der Wellenform-Progressbar bereits exakte audio.currentTime-Verfolgung - synchronisierte Lyrics (mitlaufend hervorgehoben, Klick auf eine Zeile springt zu deren Zeitstempel, wie bei Spotify/Apple Music) sind damit fast geschenkt. lrclib.net liefert syncedLyrics im LRC-Format ([mm:ss.xx]Zeile...) UND plainLyrics als Klartext-Fallback zurück - MusicDock speichert beides (metadata.lyrics_synced/metadata.lyrics_plain) und zeigt bevorzugt die synchronisierte Variante, fällt auf Klartext zurück, falls für einen Track nur der vorliegt.

Architektur, bewusst analog zu Wellenformen: LyricsFetcher (src/Service/LyricsFetcher.php) macht den eigentlichen HTTP-Request an GET https://lrclib.net/api/search?track_name=...&artist_name=...&album_name=...

  • /api/search, nicht /api/get. Beobachtung beim Testen: /api/get matcht offenbar recht strikt/nah am exakten Titel-String - "Miserere Mei, Deus" (mit Komma) lieferte über /get nur unsynced Lyrics, während /search mit denselben Parametern mehrere Kandidaten fand, darunter einen mit vollständigen synced+plain Lyrics. /search ist damit robuster gegenüber Schreibweise-Abweichungen (Kommas, Klammern, ID3-Tag-Eigenheiten) - dafür liefert es eine LISTE von Kandidaten statt eines einzelnen Treffers, LyricsFetcher::pickBestCandidate() wählt daraus: bevorzugt Kandidaten mit synced Lyrics, darunter (falls die Zieldauer bekannt ist) den mit der geringsten Abweichung davon (unterscheidet z.B. Album- von Live-Versionen desselben Songtitels), sonst dieselbe Logik für plain Lyrics, sonst einfach der erste Treffer. /api/search unterstützt duration nicht als Filter-Parameter (anders als /api/get) - die Dauer-Abwägung passiert deshalb clientseitig in der Kandidatenauswahl statt als Suchparameter. Netzwerkfehler/Timeouts (8s) werfen NIE eine Exception nach außen - werden geloggt, liefern null zurück, brechen nie einen Cron-Lauf oder Button-Klick ab.

BEWUSST NICHT synchron während app:import-media abgefragt - das würde bei einer großen Bibliothek hunderte blockierende HTTP-Requests bedeuten, den Import massiv verlangsamen und von einer stabilen Internetverbindung abhängig machen. Stattdessen exakt dasselbe Muster wie bei Wellenformen: NodeRepository::findTracksWithoutLyricsAttempt() nutzt die Abwesenheit von metadata.lyrics_fetched_at als Kriterium - wichtig, das ist "wurde versucht", nicht "hat Lyrics gefunden": ein Track ohne Treffer bei lrclib.net wird trotzdem als "erledigt" markiert (lyrics_fetched_at wird auch bei einem Fehltreffer gesetzt), sonst würde jeder Cron-Lauf erneut erfolglos anfragen.

Frontend (assets/ts/lyrics.ts): parst LRC clientseitig (Regex pro Zeile, [mm:ss.xx] extrahieren, Rest ist der Text), rendert bei synchronisierten Lyrics eine Liste von <p data-time="...">-Zeilen - updateActiveLine() läuft bei jedem audio-timeupdate (in player.ts verdrahtet) und hebt die aktuell passende Zeile hervor (die im Theme bereits vorbereitete .lyrics p.active-Klasse) sowie scrollt sie ins Bild. Klick auf eine Zeile springt zu deren Zeitstempel im Track. Kein Treffer + noch nie versucht -> "Lyrics suchen"-Button, kein Treffer + schon versucht -> "Erneut versuchen" (derselbe Endpunkt, nur anderer Button-Text).

Manueller Retry (POST /track/{id}/lyrics/fetch, TrackController::fetchLyrics()) antwortet mit JSON statt einem HTML-Fragment - lyrics.ts baut die Anzeige (inkl. LRC-Parsing) ohnehin schon clientseitig auf, kein zweiter serverseitiger Render-Pfad für denselben Anzeigezustand nötig. Nach einem erfolgreichen manuellen Abruf aktualisiert player.ts auch seinen eigenen Queue-Eintrag (und damit localStorage) auf denselben Stand - sonst wären neu gefundene Lyrics beim nächsten Trackwechsel oder Seiten-Reload wieder weg.

Braucht symfony/http-client (composer.json) - kein eigenes Bundle wie bei Monolog nötig, HttpClientInterface steht automatisch über Symfonys Standard-Autowiring bereit, sobald das Paket installiert ist.

Lange Tracks (Podcasts, Hörbuch-Kapitel) werden automatisch übersprungen - --max-duration (Standard 900s = 15min) auf app:fetch-pending-lyrics, blendet sie direkt aus der Datenbank-Abfrage aus (NodeRepository::findTracksWithoutLyricsAttempt()). Dafür gibt es bei lrclib.net ohnehin nie Treffer, unnötige Anfragen. Tracks ohne bekannte Dauer werden trotzdem einbezogen (können nicht sinnvoll gefiltert werden). Gilt NUR für den automatischen Cron - der manuelle "Lyrics suchen"-Button fragt immer an, unabhängig von der Dauer, das ist eine bewusste Einzelentscheidung. 0 deaktiviert den Filter komplett.

Visuelle Hinweise, welche Tracks Lyrics haben:

  • Mikrofon-BUTTON in der Track-Zeile (.track-lyrics-btn, components/_track_row.html.twig), sichtbar sobald lyrics_synced oder lyrics_plain vorliegt - auf einen Blick erkennbar, ohne erst abspielen zu müssen. NORMALES Grid-Kind in einer eigenen Spalte (nutzt die im Grid schon vorhandene, bisher ungenutzte 55px-Spalte: grid-template-columns: 38px 42px minmax(0,1fr) 90px 55px 38px, siehe theme.css), direkt VOR .more-btn - keine absolute Positionierung mehr. Ein früherer Versuch (ein gemeinsamer, absolut positionierter Flex-Wrapper um Mikrofon- + More-Button) brach dabei etwas auf, das schon lange funktionierte: .more-btn hat seit einer viel früheren Session bereits eine App-css-Override-Regel (.more-btn{position:static;width/height:38px}), extra damit es sauber als normales Grid-Kind in die letzte Spalte passt - der absolut positionierte Wrapper riss BEIDE Buttons aus dem Grid-Fluss, unabhängig davon, was die Kinder selbst für position hatten (Ursache für das Überstehen über die schmale Spalte hinaus UND die falsche vertikale Position selbst ohne Lyrics-Icon). Jetzt zentriert .track-row{align-items:center} (theme.css) den Button ganz normal, wie jeden anderen Grid-Inhalt auch. Der Slot wird IMMER gerendert (auch leer ohne Lyrics) - sonst würde die Spaltenbreite je nach Zeile springen. Auf Mobile blendet .track-lyrics-btn-slot genau wie .more-btn komplett aus (eigene @media(max-width:700px)-Regel in app.css, da theme.css nicht direkt angefasst wird). Echter klickbarer Button, keine reine Deko-Anzeige - Klick spielt den Track ab UND öffnet gleich das Lyrics-Panel (wireTrackDelegation() in player.ts, per Event-Delegation wie .track-play-btn). Nutzt dafür openPanel(), jetzt aus panels.ts exportiert - bewusst nicht togglePanel(): ein Toggle könnte das Panel versehentlich SCHLIESSEN, falls gerade schon ein anderes offen war.
  • Lyrics-Button in der Player-Toolleiste hervorgehoben (#lyricsButton.has-lyrics, dieselbe Akzentfarbe wie .player-icon.selected bei Shuffle/Repeat, aber eigene Klasse - ist ein reiner Info-Zustand, keine aktivierbare Einstellung), sobald der AKTUELLE Track Lyrics hat. MusicDockPlayer::updateLyricsButtonHighlight() läuft bei jedem Trackwechsel UND nach einem erfolgreichen manuellen Abruf (derselbe onLyricsUpdated-Callback, der auch Panel und Karaoke-Overlay synchron hält).

Lyrics werden nur abgefragt, wenn sowohl Titel als auch Künstler bekannt sind - ohne Künstlername ist eine lrclib.net-Anfrage praktisch nutzlos (matcht dort nur über den Titel, viel zu unpräzise, bei häufigen Songtiteln quasi garantiert der falsche Treffer). Dreifach abgesichert:

  1. NodeRepository::findTracksWithoutLyricsAttempt() schließt Tracks ohne metadata.artist_name (oder - defensiv, praktisch nie der Fall - ohne Titel) direkt aus der Datenbank-Abfrage aus, der automatische Cron sieht sie also nie.
  2. TrackController::fetchLyrics() (der manuelle Endpunkt) lehnt eine Anfrage ohne Titel/Künstler ebenfalls ab - serverseitige Absicherung, falls die Route direkt aufgerufen wird, unabhängig vom Frontend.
  3. lyrics.ts (appendRetryButton()) hängt den "Lyrics suchen"/"Erneut versuchen"-Button gar nicht erst an, wenn track.title/track.artist fehlen - würde ohnehin serverseitig abgelehnt, also gar nicht erst anbieten.

lrclib.net-Eigenheit: lyricsFile statt plainLyrics/syncedLyrics. Manche Antworten enthalten ein zusätzliches Feld lyricsFile (rohes YAML) - laut Beobachtung werden plainLyrics/syncedLyrics dann IGNORIERT, die eigentlichen Lyrics stecken im YAML. Da das genaue Schema nicht zuverlässig dokumentiert ist, wird es bewusst nicht blind geparst - falsch geparste Lyrics wären schlimmer als gar keine. LyricsFetcher erkennt den Fall (hasLyricsFile) und liefert dann kein synced/plain, das Frontend zeigt stattdessen einen ehrlichen Hinweis ("Lyrics gefunden, aber in einem noch nicht unterstützten Format.") statt fälschlich "Keine Lyrics verfügbar" zu behaupten. Für spätere Analyse/ einen echten Parser, sobald ein reales lyricsFile-Beispiel vorliegt, wird zusätzlich Folgendes mitgespeichert:

  • metadata.lyrics_id - die numerische lrclib.net-ID des Treffers.
  • metadata.lyrics_has_lyrics_file - true, wenn dieser Fall vorlag.
  • metadata.lyrics_raw_response - die KOMPLETTE Rohantwort von lrclib.net als JSON-String, unverändert. Reines Debug-/Analyse-Feld, wird vom Frontend nirgends angezeigt oder ausgewertet.

Karaoke-Ansicht (#karaokeOverlay, Trigger: Vollbild-Icon im Lyrics-Panel-Header) - vollflächiges, halbtransparentes Overlay mit Backdrop-Blur, wie bei Amazon Music: aktuelle Zeile groß und hell hervorgehoben, Nachbarzeilen kleiner und gedämpft. Zeigt exakt dieselben Daten wie das normale Panel, in einem zweiten Container - renderLyrics()/updateActiveLine() arbeiten generisch auf einem beliebigen HTMLElement, kein separates Render-System für das Overlay nötig. Wird bei jedem Trackwechsel automatisch mitgepflegt (nicht nur wenn offen) - beim Öffnen sofort aktuell statt erst beim nächsten Trackwechsel; updateActiveLine() für das Overlay läuft aber nur, wenn es tatsächlich offen ist (unnötige scrollIntoView()-Aufrufe auf einem unsichtbaren Container vermeiden). Schließbar per X-Button, Klick auf den Hintergrund (event.target === currentTarget-Prüfung, nicht auf die Zeilen selbst) oder Esc. Bewusst NICHT über das generische Panel-System (panels.ts, openPanel()/closeAllPanels()) gelöst - strukturell anders (vollflächig statt rechts andockend) und soll parallel zum normalen Lyrics-Panel bedienbar bleiben, nicht mit ihm um "nur eins gleichzeitig" konkurrieren.

Klick auf einen manuellen "Lyrics suchen"-Erfolg aktualisiert BEIDE Container, nicht nur den, in dem geklickt wurde - der zentrale onLyricsUpdated-Callback in loadTrack() rendert nach einem erfolgreichen Abruf sowohl Panel als auch Karaoke-Overlay neu, sonst bliebe der jeweils andere bis zum nächsten Trackwechsel im alten "keine Lyrics"-Zustand stehen.

Der Neuversuch-Button fehlte, sobald schon (irgendwelche) Lyrics vorlagen. Ursprünglich erschien "Lyrics suchen"/"Erneut versuchen" NUR im komplett leeren Zustand - lagen bereits unsynced Lyrics vor, gab es gar keinen Weg mehr, gezielt auf eine synced Version upzugraden (obwohl /api/search, siehe oben, durchaus einen ANDEREN Kandidaten mit synced Lyrics finden kann, selbst wenn der zuerst gespeicherte nur plain hatte). Jetzt bietet JEDER Zustand außer dem bestmöglichen (synced bereits vorhanden) einen Neuversuch-Button an - appendRetryButton() in lyrics.ts ist dafür in eine wiederverwendbare Funktion ausgelagert, mit passendem Beschriftungstext je Zustand ("Synchronisierte Lyrics suchen" bei bereits vorhandenem Klartext, "Erneut versuchen"/"Lyrics suchen" sonst). Ein erneuter Abruf kann dabei nur VERBESSERN, nie etwas bereits Vorhandenes verlieren - data.synced || track.lyricsSynced statt ??, falls ein neuer Versuch aus irgendeinem Grund weniger liefert als vorher schon gespeichert war.

Web-Upload (Drag & Drop, ganze Ordner)

Wichtige, mit Datenverlust verbundene Lektion, behoben in MediaImporter::storeAudioFile()/ensureParentDirectory(): Alle schreibenden Stellen (Audio, Cover, Thumbnails) legen ihren Ziel-Bucket- Ordner jetzt selbst an, statt sich darauf zu verlassen, dass vorher ensureBucketDirectories() gelaufen ist - das ruft NUR importAll() auf. importUploadedFile() (der komplette Upload-Workflow über ProcessUploadsCommand) durchläuft importAll() nie und schrieb dadurch auf einen nicht existierenden Ordner. Schlimmer als nur ein Fehler: beim --move-Verhalten (das importUploadedFile() immer nutzt) lief unlink($sourcePath) vorher UNBEDINGT direkt nach copy(), ohne dessen Rückgabewert zu prüfen - schlug copy() fehl (weil der Zielordner fehlte), wurde die Quelldatei trotzdem gelöscht: keine Kopie am Ziel, keine Quelle mehr, Datei komplett weg. unlink() läuft jetzt ausschließlich nach nachweislich erfolgreichem copy(), sonst wird eine Exception geworfen (die ProcessUploadsCommand bereits pro Datei abfängt und als failed markiert, statt den ganzen Cron-Lauf abzubrechen).

Musik lässt sich direkt im Browser hochladen (/upload, Nav-Link "Upload") - einzelne Dateien oder komplette Ordner per Drag & Drop, wie man es von Dropzone.js kennt. Läuft in drei Schritten, bewusst asynchron statt alles in einem Request zu erledigen:

  1. Frontend sammelt rekursiv (assets/ts/upload.ts): beim Drop wird DataTransferItem.webkitGetAsEntry() genutzt, um bei einem Ordner rekursiv durch FileSystemDirectoryReader zu laufen und alle enthaltenen Audiodateien mit ihrem vollen relativen Pfad einzusammeln (z.B. Pink Floyd/The Wall/01 In The Flesh.mp3 - die Ordnerstruktur bleibt erhalten). Fallback: Klick öffnet den normalen Datei-/Ordner- Dialog (<input type="file" webkitdirectory>) für Browser/Systeme, bei denen Ordner-Drag&Drop nicht rund läuft.
  2. Jede Datei wird EINZELN hochgeladen, nicht der ganze Ordner in einem Request. Dadurch gibt es kein "Upload-Größen"-Problem im Sinne eines Gesamtlimits - es greift nur upload_max_filesize/post_max_size pro Datei (siehe .ddev/php/upload.ini, auf 200M/210M gesetzt - großzügig genug für FLACs oder lange Podcast-Aufnahmen). UploadController speichert jede Datei unter uploads/<relativePath> und legt eine Upload-Entity mit Status pending an.
  3. app:process-uploads importiert asynchron per Cron (genau dasselbe Lock+Zeitbudget-Muster wie app:generate-pending-waveforms, siehe unten) - holt wartende Uploads, ruft MediaImporter::importUploadedFile() auf (Ordnerstruktur des Uploads wird 1:1 als Künstler/Album interpretiert - dieselbe findOrCreateTaggedFolder()-Logik wie beim _dump-Import, hier aber aus den Ordnernamen statt aus ID3-Tags; eine lose Datei ganz ohne Ordnerkontext fällt auf Tag-basierte Platzierung zurück) und räumt uploads/ danach auf (Datei wandert nach storage/audio/, --move- Verhalten fest einprogrammiert, nicht konfigurierbar - ein Upload ist "verbraucht", sobald er verarbeitet wurde).

Sichtbarer Status statt stiller Blackbox: Upload ist bewusst eine eigene Entity, unabhängig vom Node-Baum (heißt Upload, nicht UploadedFile - kollidiert sonst mit Symfony\Component\HttpFoundation\File\UploadedFile). Solange eine Datei nur hochgeladen, aber noch nicht verarbeitet ist, taucht sie NICHT in der Bibliothek auf (kein Node existiert dafür) - stattdessen zeigt /upload den Status (wartend/in Arbeit/importiert/fehlgeschlagen) mit Fehlermeldung bei fehlgeschlagenen Imports.

Cronjob (analoges Muster zu den Wellenformen, hier aber minütlich statt alle 5 Minuten - Uploads sollen zeitnah verarbeitet werden, nicht erst nachts):

* * * * * cd /pfad/zu/musicdock && ddev exec bin/console app:process-uploads >> var/log/upload-cron.log 2>&1

App\Service\RuntimeStorage ist das wiederverwendbare Zeitbudget- Muster dahinter (und hinter app:generate-pending-waveforms, das beim Bauen dieses Features gleich mit umgestellt wurde): ein Command setzt einmal RuntimeStorage::setDeadline($sekunden), jede Verarbeitungsschleife - auch in ganz anderen Klassen, ohne dass ein $deadline-Parameter durchgereicht werden müsste - prüft einfach RuntimeStorage::deadlineReached() und bricht sauber ab. Kombiniert mit dem Lock (Symfony Lock, FlockStore) verhindert das zuverlässig, dass ein nachfolgender Cron-Tick in einen noch gehaltenen Lock läuft: kein Tick läuft je länger als sein eigenes Zeitbudget, und ein bereits laufender Vorgänger blockt den nächsten Tick einfach per Lock ab, statt parallel zu arbeiten.

Equalizer (5-Band, Web Audio API) & WebGL-Visualizer

Beide clientseitig, keine Server-Beteiligung - und beide hängen am selben gemeinsamen Web-Audio-Graphen (assets/ts/audio-graph.ts), aus genau einem Grund: AudioContext.createMediaElementSource() darf pro <audio>-Element nur EINMAL im gesamten Seitenleben aufgerufen werden (der Browser wirft sonst eine Exception). Equalizer und Visualizer bräuchten beide diesen Aufruf - getAudioGraph() baut ihn deshalb lazy und garantiert nur beim allerersten Aufruf tatsächlich auf, jeder weitere Aufruf (aus egal welchem Modul) bekommt denselben bereits bestehenden Graphen zurück:

audio -> Lowshelf(60Hz) -> Peak(250Hz) -> Peak(1kHz) -> Peak(4kHz) -> Highshelf(12kHz) -> Analyser -> Lautsprecher
                                    (Equalizer, 5 Bänder)                        (Visualizer liest hier)

Der AnalyserNode sitzt bewusst NACH den EQ-Filtern, nicht direkt an der Quelle - der Visualizer zeigt so das tatsächlich hörbare Signal inklusive eigener EQ-Einstellungen, nicht das unveränderte Original.

initEqualizer()/initVisualizer() werden beide bewusst nur einmal beim App-Start aufgerufen, nicht nach jedem HTMX-Swap (anders als z.B. initPlaylistReordering()) - unproblematisch, weil das <audio>-Element über die ganze Session hinweg dasselbe bleibt, derselbe Grund, aus dem auch die Wiedergabe selbst nie unterbrochen wird.

AudioContext startet in den meisten Browsern "suspended" bis zu einer Nutzer-Geste - ohne resume() bliebe die Wiedergabe stumm. Zwei Gelegenheiten zum Aufwecken: der erste Klick irgendwo im Dokument, und (als Netz) jedes Mal, wenn die Wiedergabe tatsächlich startet.

Equalizer

Fünf BiquadFilterNodes (60 Hz Lowshelf, 250 Hz/1 kHz/4 kHz Peaking, 12 kHz Highshelf), Werte bei ±12 dB pro Band, persistiert in localStorage (musicdock.eq) wie die Lautstärke. Vertikale Regler über writing-mode: vertical-lr (moderne, browserübergreifend einheitliche Alternative zum alten -webkit-appearance: slider-vertical).

Genre-Presets (Flach/Rock/Pop/Dance/Jazz/Klassik/Bass-Boost/ Höhen-Boost) sitzen als Record<string, number[]> in PRESETS (assets/ts/equalizer.ts) - Gains in derselben Reihenfolge wie die Bänder (60/250/1k/4k/12k Hz). Werte orientieren sich an den Kurven klassischer Consumer-Equalizer (Winamp, foobar2000, Android-System-EQ), kein Anspruch auf akustische Exaktheit. Das <select id="eqPreset"> im Panel wird bewusst NICHT im Twig-Template befüllt, sondern aus PRESETS generiert - ein Preset existiert nur an einer Stelle im Code. Manuelles Verstellen eines Reglers springt automatisch zurück auf "Eigene Einstellung", damit die Auswahl nie fälschlich einen Preset-Namen zeigt, dessen Werte gar nicht mehr aktiv sind.

WebGL-Visualizer

assets/ts/visualizer.ts, EINE Instanz in der linken Sidebar (kein separates rechtes Panel - das machte in der Praxis eh niemand auf, siehe Entscheidung unten). Klick auf den Streifen schaltet die drei Ansichten durch (Balken -> Linie -> Nadel -> Balken -> ...), Auswahl in localStorage (musicdock.vizmode) gemerkt, Tastatur-bedienbar (role="button", Enter/Leertaste):

  • Balken - klassischer Frequenz-Equalizer, 20 Balken (bewusst weniger als in einer früheren Panel-Version mit 48 - bei der schmalen Sidebar-Breite wären mehr optisch kaum unterscheidbar) aus analyser.getByteFrequencyData(). Logarithmische statt linearer Frequenzband-Zuordnung - bei linearer Aufteilung (fester data.length/BAR_COUNT-Schritt) bekam jeder Balken einen gleich GROSSEN Frequenzbereich; da sich die akustische Energie bei praktisch jeder Musik auf die unteren ~10-20% des Spektrums konzentriert (Bass/Mitten), waren die meisten (oberen) Balken dadurch fast immer bei Null - wirkte wie ein fehlendes Frequenzspektrum, war aber schlicht die Natur der linearen Aufteilung. Jetzt exponentiell verteilt: die unteren Bins (wo die meiste Energie/musikalische Information sitzt) bekommen mehr, schmalere Balken, die oberen (Höhen, wenig Energie) weniger, dafür breiter zusammengefasste - entspricht auch der menschlichen Wahrnehmung (Oktaven sind Frequenz-VERDOPPLUNGEN, kein fester Hz-Abstand). Oberer Cutoff bewusst NICHT bis zur vollen Nyquist-Frequenz (sampleRate/2, bei üblichen 44100Hz ~22kHz) - bei fftSize=2048 deckte der GANZ RECHTE Balken sonst grob den Bereich ~15.5-22kHz ab, wo bei praktisch jeder Musik (besonders komprimierten Formaten wie MP3, die oft schon bei 16-19kHz abschneiden) kaum noch Energie vorhanden ist - akustisch korrekt, der Balken bewegte sich dadurch aber faktisch nie. MAX_FREQ_HZ = 14000 kappt den Bereich bei einem musikalisch noch aktiven Wert (Becken, Hi-Hats, Zischlaute liegen typischerweise im 5-14kHz-Bereich) - verschiebt die gesamte logarithmische Verteilung nach unten, auch der letzte Balken bleibt dadurch in einem Bereich, in dem üblicherweise noch etwas passiert.
  • Linie - "Rubber-Line"-Oszilloskop aus getByteTimeDomainData() (Zeitsignal, nicht Frequenzspektrum).
  • Nadel - klassisches VU-Meter, schwenkt basierend auf dem RMS (quadratisches Mittel, nicht Peak - zeigt wahrgenommene Lautstärke statt nervös auf jede kurze Spitze zu reagieren) zwischen zwei Anschlägen hin und her, mit Zifferblatt-Bogen als Hintergrund.

Ein einziges, sehr schlankes Shader-Programm für alle drei Modi (reine Flächenfarbe per Uniform, aus --accent-rgb gelesen - liegt in theme.css schon als "155,123,255" vor, kein Hex-Parsing nötig) - jeder Modus erzeugt nur unterschiedliche Dreiecks-Geometrie in JS, gezeichnet wird immer gleich (gl.TRIANGLES). Balken und Linie sind als echte Flächen gezeichnet, nicht gl.LINE_STRIP: WebGLs Linienbreite steht auf vielen Plattformen (u.a. den meisten Desktop-GPU-Treibern) fest auf 1px, unabhängig von lineWidth() - eine sichtbar dicke, glatte Linie braucht ein selbst gezeichnetes Rechteck-Band entlang des Pfads (buildRibbon(), mit fester Breite senkrecht zur Pfadrichtung pro Segment).

Läuft bewusst IMMER, kein "nur wenn geöffnet"-Vorbehalt - das ist ja der Sinn: ein ambienter Indikator direkt in der Sidebar, ohne irgendwas öffnen zu müssen.

Design-Entscheidung, ein Umbau während der Entwicklung: ursprünglich gab es zwei gleichzeitige Instanzen - ein großes, umschaltbares Panel (Player-Leiste -> eigener Button) UND eine kleine Sidebar-Variante parallel dazu. In der Praxis wurde das Panel nie geöffnet, die Sidebar- Variante war der eigentlich genutzte Teil - deshalb radikal vereinfacht auf EINE Instanz, Klick-zum-Durchschalten statt separater Umschalt-Buttons in einem eigenen Panel. Weniger Code, ein Rendering-Ziel statt zwei, kein VizInstance-Parameter mehr nötig (die frühere Zwei-Instanzen-Version kapselte GL-Kontext/Programm/Buffer pro Canvas in einer solchen Struktur - mit nur noch einem Canvas unnötige Komplexität).

Kein WebGL im Browser? canvas.getContext('webgl') liefert dann null, initVisualizer() bricht sauber ab - der Streifen bleibt einfach leer (Equalizer, Player etc. bleiben komplett unbeeinflusst), kein Absturz.

Playlist-Reordering (Drag & Drop, offline-tolerant)

Der einzige Ort in der App, an dem bewusst "richtiges" Client-JS statt HTMX zum Einsatz kommt (assets/ts/playlist-reorder.ts) - für sehr interaktives Drag&Drop ist das der pragmatischere Weg als der Umweg über Server-Requests pro Drop.

  • Sortable.js übernimmt das reine Drag&Drop im DOM, rein optimistisch, kein Server-Roundtrip für die Optik selbst.
  • Nach jedem Drop läuft ein Debounce (800ms) - erst wenn kurz nichts mehr passiert, wird synchronisiert. Mehrere schnelle Drags hintereinander erzeugen also einen Request, nicht einen pro Drop.
  • Der Sync schickt die komplette aktuelle Reihenfolge (Liste von PlaylistItem-IDs) per PUT /playlist/{id}/reorder - der Server nummeriert position daraus neu durch (PlaylistController::reorder()).
  • Schlägt der Request fehl (offline, Server nicht erreichbar), landet die Reihenfolge in IndexedDB (assets/ts/offline-queue.ts, ein Object-Store pending-reorders, ein Eintrag pro Playlist - ein neuerer Reorder überschreibt einfach den alten, kein Merge-Konflikt möglich) und wird beim nächsten online-Browser-Event oder beim nächsten App-Start automatisch nachgeholt (retryPendingReorders() in app.ts).
  • Bewusst kein Service-Worker/Background-Sync - deutlich mehr Umfang (und in Safari nur lückenhaft unterstützt) für einen Anwendungsfall, bei dem "beim nächsten Start nachholen" völlig ausreicht. Falls die App mal komplett offline nutzbar sein soll, ist das ein eigenes, größeres Thema.

Nach jedem HTMX-Swap (htmx:afterSettle) wird initPlaylistReordering() neu aufgerufen, weil #content inkl. der DOM-Referenzen komplett ersetzt wurde - alte Sortable-Instanzen werden dabei sauber zerstört, bevor eine neue entsteht.

Nächste sinnvolle Schritte

  • Login/User: Playlists (Owner-Feld, siehe oben), evtl. private Kategorien, "Zuletzt gehört" - alles baut auf einer User-Entity auf, die noch fehlt.
  • Warteschlangen-Anzeige im Player: aktuell ist die Queue nur intern in player.ts - eine sichtbare "Als Nächstes"-Liste (z.B. als Slide-in-Panel) ist der naheliegende nächste Schritt, sobald Playlists als Datenquelle dafür stehen.
  • Waveform statt schlichtem Seek-Balken (Idee, noch nicht umgesetzt - am ehesten zusammen mit dem visuellen Durchgang sinnvoll, weil es sowieso eine Canvas/SVG-Zeichnung braucht): Peaks beim Import berechnen (z.B. via ffmpeg/audiowaveform-CLI-Tool aus einem Subprocess in MediaImporter, oder einer PHP-Audio-Decoding-Lib) und als Array in metadata['waveform_peaks'] auf dem Track-Node speichern - passt ohne Schema-Änderung ins bestehende JSON-Metadata-Feld. Frontend zeichnet die Kurve dann per <canvas> statt des aktuellen <input type="range"> und färbt den bereits gehörten Teil beim timeupdate ein.
  • Admin-Oberfläche zum Verschieben/Umbenennen von Nodes per Drag & Drop wäre mit EasyAdminBundle oder einer schlanken eigenen HTMX-Oberfläche machbar der Dateimanager-Controller liefert dafür schon die Baum-Basis.
  • gedmo/doctrine-extensions (Tree-Behavior) als Alternative zum handgepflegten path-Feld, falls der Baum später komplexer wird (Verschieben ganzer Teilbäume, Nested-Set-Abfragen).