Compare commits

..
87 Commits
Author SHA1 Message Date
marcandClaude Opus 5 b6e98463ee feat(web+android): Lyrics-Mitlaufen umschaltbar, echtes Mischen, Wiederholung am Queue-Ende
Lyrics: Schalter "Mitlaufen" im Lyrics-Reiter, Vorgabe AUS. Hervorgehoben wird die
laufende Zeile immer, gescrollt nur bei eingeschaltetem Mitlaufen. Nur bei synchronen
Texten sichtbar. Android speichert den Schluessel einzeln, damit das Speichern im
Einstellungsbildschirm ihn nicht auf die Vorgabe zuruecksetzt.

Mischen (Web): sort(() => Math.random() - 0.5) ist keine Mischung. Gemessen blieb der
erste Titel drei- bis fuenfmal zu oft vorn (18,8 % statt 10 % bei n=10, 0,23 % statt
0,05 % bei n=2000). Ersetzt durch Fisher-Yates in queueMath.mischeListe.

Queue-Ende (Web): ein beendetes <audio> meldet sich als pausiert, die Wiederaufnahme
nach unfreiwilligem Hintergrund-Stop rief play() und spielte den letzten Titel erneut.
Jetzt Abbruch bei audio.ended.

Shuffle (Android): das Mischen macht jetzt der Player (playSongs(liste, -1)), damit die
ungemischte Liste erhalten bleibt — vorher leuchtete das Symbol nicht und "Shuffle aus"
konnte die Albumreihenfolge nicht wiederherstellen.

Radio-Modus (beide): der laufende Titel ist aus der letzten Rueckfallebene ausgenommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-01 17:13:38 +02:00
marcandClaude Opus 5 ae357b0eb9 change: Konzerte ohne Zeitfenster — Chips nur noch bei den Veroeffentlichungen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 10:46:05 +02:00
marcandClaude Opus 5 17a677a5a5 feat(web+android+backend): Konzerte gefolgter Kuenstler als zweiter Reiter
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 19:23:00 +02:00
marcandClaude Opus 5 1366ec3832 docs: Entwurf fuer die Konzert-Meldungen im Backlog festhalten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 17:56:17 +02:00
marcandClaude Opus 5 7879e37a7b test(backend): Filterprobe fragt auch YT Music ab — erste Fassung meldete erfundene Fehler
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 17:03:14 +02:00
marcandClaude Opus 5 2f87a12f08 fix(backend): Kuenstler vor Dauer in der Suchkaskade, mit Regressionsprobe
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 09:48:35 +02:00
marcandClaude Opus 5 748b1446a2 fix(backend): Umschrift-Muster toleranter, Kanji-Kuenstler ueber Romaji erkennbar
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 00:09:01 +02:00
marcandClaude Opus 5 06b13caea9 fix(backend): Download-Filter konnte ohne Titel- oder Kuenstlermerkmal zugreifen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 23:31:29 +02:00
marcandClaude Opus 5 1432a960fb style: Honigtopf auf Feldbreite aufgezogen, wirkt jetzt so gross wie das Zahnrad
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 23:11:07 +02:00
marcandClaude Opus 5 37f8f8acac feat(web+android): Gesamtdauer mit Stunden, in Android neu
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 22:38:40 +02:00
marcandClaude Opus 5 c7fed82736 style(web): Honigtopf in der Kopfzeile groesser
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 18:25:22 +02:00
marcandClaude Opus 5 e8582381ec feat(web+android): Honigtopf mit Brief statt Glocke fuer Benachrichtigungen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 16:53:06 +02:00
marcandClaude Opus 5 899c98977a feat: Wischgeste wechselt Titel, Designs Violet und Crimson, Mischen-Anzeige gefixt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:04:37 +02:00
marcandClaude Opus 5 549bec85ce feat(web): Rollbalken nur ueber dem Inhalt, X-Knopf in der Warteschlange
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:50:23 +02:00
marcandClaude Opus 5 2258e5465f fix(android): Songzeilen halten rechts Platz fuer den Rollbalken
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:47:37 +02:00
marcandClaude Opus 5 2641a6df44 fix(android): Rollbalken verschluckte das Kontextmenue der Songzeilen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:45:20 +02:00
marcandClaude Opus 5 d5b5c8cf84 feat(web+android): Warteschlange bearbeiten — ziehen und entfernen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:31:08 +02:00
marcandClaude Opus 5 77a3bb33f0 fix(android): Rollbalken-Geste brach ab, weil pointerInput auf die Geometrie geschluesselt war
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:24:37 +02:00
marcandClaude Opus 5 d420123a44 fix(web): Rollbalken lief ueber die Player-Leiste
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:21:37 +02:00
marcandClaude Opus 5 f083fad986 fix(android): Rollbalken liess sich nicht greifen — Greifflaeche und Gestenvorrang
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:20:08 +02:00
marcandClaude Opus 5 363eb2b1aa docs: Rollbalken in Android im Changelog (3.1, APK 1.16)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:13:37 +02:00
marcandClaude Opus 5 1aa218f3e3 feat(android): Rollbalken zum Ziehen in den langen Listen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:13:10 +02:00
marcandClaude Opus 5 6652fb4d2b feat(web): greifbarer Rollbalken, Zeitleiste in die Favoriten verlegt (3.1)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 19:41:09 +02:00
marcandClaude Opus 5 073593d963 change(backend): nur noch der erste Kuenstler im Dateinamen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 22:57:04 +02:00
marcandClaude Opus 5 8d25bf0404 feat(web+backend): Dubletten im Editor aufloesen, Favoriten ueberleben das Umbenennen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 21:41:42 +02:00
marcandClaude Opus 5 24558497da fix(backend): Schraegstrich im Namen und zu lockere Titelsuche fuehrten auf die falsche Datei
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:35:36 +02:00
marcandClaude Opus 5 eb72e5c27d feat(android): Album-Knopfleiste wie im Playlist-Detail; Changelog-Backlog aufgeraeumt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 20:06:30 +02:00
marcandClaude Opus 5 66fcdbba05 docs: Release als Version 3.0 festhalten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 12:39:22 +02:00
marcandClaude Opus 5 86810b6a51 feat(web+android): gefolgte Künstler begrenzt anzeigen, mit "Alle anzeigen"
Vorher landeten alle gefolgten Künstler in der waagerechten Reihe der
Favoriten — bei 200 Gefolgten 200 Karten samt Bildern, von denen man vier
sieht. Am Ende steht jetzt "Alle anzeigen (+n weitere)" und springt in die
Künstlerliste mit aktivem Gefolgt-Filter.

Im Web richtet sich die Anzahl nach der Fensterbreite statt nach einer
festen Zahl (passende Karten minus eine, damit der Knopf nicht
angeschnitten klebt; Untergrenze zwei), und bei Größenänderung wird neu
aufgeteilt. Android zeigt zehn; die LazyRow baut ohnehin nur Sichtbares,
dort ging es allein um Durchsehbarkeit.

LibraryIntent reicht die Absicht einmalig durch, weil das
LibraryViewModel am Navigationseintrag hängt und einen Zielwechsel nicht
überlebt.

Dazu: die Zeitfenster-Chips der Android-Zeitleiste beginnen mit 90 Tagen,
der Vorgabe — Web war schon so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:37:47 +02:00
marcandClaude Opus 5 cb43c3375e feat(android): Gefolgt-Filter im Künstler-Reiter und Zeitleiste "Neu von deinen Künstlern"
Filter statt eigenem Reiter: die Bibliotheksleiste trägt schon Playlists,
Alben, Künstler und Offline. Die markierten IDs kommen bei jedem
Einschalten frisch, damit ein eben entfolgter Künstler nicht dasteht.

Die Zeitleiste ist über "✨ Neu" in den Favoriten erreichbar — dort stehen
die gefolgten Künstler, also liegt die Frage nach Neuem dort nahe.
Gleiche Darstellung wie im Web: nach Monat gruppiert, neueste zuerst,
Vorhandenes mit Haken statt Download-Knopf, Künstlername anklickbar.
Download läuft über den bekannten Weg samt Fortschrittsmeldung.

Neu: api/ReleasesClient.kt, api/model/ReleaseModels.kt,
viewmodel/ArtistNewsViewModel.kt, ui/screen/ArtistNewsScreen.kt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:10:37 +02:00
marcandClaude Opus 5 ea7167f851 perf(web): Künstlerbilder ohne einen API-Aufruf pro Künstler
Das Künstlerraster feuerte pro Künstler ein getArtistInfo2 nur für die
Bildadresse: bei 200 Künstlern 200 gleichzeitige Aufrufe, die der Browser
zu sechst abarbeitet, und jeder konnte Navidrome zu einer externen
Last.fm-Abfrage zwingen. Fehlende Bilder kamen aus derselben Quelle —
Last.fm liefert für viele Künstler nur noch einen Platzhalter.

getArtists liefert das Bild längst mit (toArtistID3 setzt coverArt, und
Navidrome löst Künstlerbilder selbst über ArtistArtPriority auf und cacht
sie). Jetzt geht nur noch das Bild über die Leitung; ohne coverArt sparen
wir uns den Versuch ganz. Im Test 0 statt 120 Zusatzaufrufe.

Gleiches für die Künstlerkarten in der lokalen Suche und die gefolgten
Künstler in den Favoriten. Android war nie betroffen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:05:11 +02:00
marcandClaude Opus 5 f3d54158d3 feat(web): Filter "❤ Gefolgt" plus Suche auf der Künstlerseite
Die Liste, wem man folgt, gehört dorthin, wo man Künstler ohnehin sucht —
bisher gab es sie nur als waagerechte Reihe unter den Favoriten.
getArtists liefert kein starred mit, die Markierungen kommen deshalb aus
getStarred2 und werden 30 s gehalten, damit das Umschalten flott bleibt.

Ein Herz-Klick verwirft diesen Zwischenspeicher sofort — sonst zeigte der
Filter nach dem Entfolgen noch eine halbe Minute den alten Stand. Beim
Testen aufgefallen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:58:09 +02:00
marcandClaude Opus 5 908488a337 feat: Zeitleiste "Neu von deinen Künstlern" (Web) + Backend-Endpunkt
Eigener Punkt in der Seitenleiste: jüngste Veröffentlichungen aller
gefolgten Künstler, nach Monat gruppiert, neueste zuerst, umschaltbar auf
30/90/365 Tage. Was schon in der Bibliothek liegt, ist markiert (Vergleich
über Künstler + Albumname, akzent- und zeichenunabhängig); alles andere
bekommt einen Laden-Knopf zum vertrauten Download-Dialog.

Neuer Endpunkt POST /api/artist-releases (backend/lib/releases.js): die
Deezer-Zuordnung passiert serverseitig, weil die Künstler-Pins dort
ohnehin liegen. Gepinnte nutzen die feste ID, ungepinnte werden über den
Namen gesucht und nach Fan-Zahl sortiert; ohne Treffer kommt der Name als
ohneZuordnung zurück und die Oberfläche rät zum Pinnen.

Cache im Backend statt im Client — bei 30 gefolgten Künstlern wären das
sonst 30 Deezer-Aufrufe pro Gerät und App-Start. 6 h für Alben, 24 h für
die Namenszuordnung, max. 4 Anfragen parallel, bei Deezer-Fehlern wird
der abgelaufene Stand ausgeliefert statt einer leeren Liste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:50:33 +02:00
marcandClaude Opus 5 19f241e493 feat(web+android): Folgen-Herz ins Künstler-Banner, Favoriten zeigen Künstler und Alben
Das Herz sitzt jetzt im Banner neben dem Pin-Zahnrad statt in der
Aktionsleiste: die Leiste ist "was ich jetzt tue" (abspielen, mischen,
Konzerte), Folgen ist ein Zustand und gehört an den Künstler. Auf beiden
Plattformen gleich. Beim Album bleibt das Herz in der Aktionsleiste, dort
gibt es kein Banner.

Dazu die Übersicht, wem man folgt: die Favoritenseite bekommt Abschnitte
Künstler / Alben / Songs. Ohne zusätzlichen Aufruf — getStarred2 liefert
Künstler und Alben im selben Paket wie die Songs, wir haben sie bisher
verworfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:11:18 +02:00
marcandClaude Opus 5 5f043beb5d feat(web+android): Künstlern folgen, Alben merken, Top Songs per Künstler-ID
Folgen ist kein eigener Speicher, sondern Navidromes Stern auf dem
Künstler (star&id=<ndArtistId>) — Navidrome wirft id, albumId und
artistId beim Markieren in eine Liste und behandelt sie gleich, der
vorhandene Aufruf trug also ohne Änderung. Zustand aus `starred` in
getArtist/getAlbum, beide Plattformen schalten optimistisch um.

Android nutzt den vorhandenen StarState, der ohnehin über IDs arbeitet.

Dazu: getTopSongs bekommt seit Navidrome 0.64 zusätzlich `id`. Der
Namensweg sucht mit LIKE und Max:1, bei gleichnamigen Künstlern gewann
der erste Treffer. Jetzt id + artist zusammen; in Erkunden bleibt es beim
Namen, weil die artistId dort Deezers ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:51:53 +02:00
marcandClaude Opus 5 de674dbe3f fix(android): Offline-Downloads vollständig löschen, Fortschritt selbst begradigen
Löschen ließ Dateien zurück: Cover wurden nie entfernt (nur die
Audiodatei), unfertige Titel behielten ihre Datei, weil der Pfad aus der
Karte der FERTIGEN Titel kam, und laufende Downloadarbeit lief nach dem
Löschen noch minutenlang ins Leere. Jetzt kommt der Pfad aus der
Datenbankzeile, .tmp wird mitgenommen, Cover gehen nur weg wenn kein
anderer Titel sie noch benutzt, und die Arbeit der Gruppe wird
abgebrochen.

Dazu: ein zu hoch gezählter Fortschritt aus der Zeit vor 1.10 konnte sich
nie mehr korrigieren — die Gruppe galt als vollständig, der Knopf war
unantastbar und das Fortsetzen sucht nur Gruppen unter dem Sollwert. Beim
App-Start wird der Fortschritt aller Gruppen einmal aus dem echten
Zustand neu berechnet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:00:08 +02:00
marcandClaude Opus 5 ffedc7532b feat(android): Schalter "Nur über WLAN herunterladen" (Standard an)
Die Downloadarbeit lief mit NetworkType.CONNECTED, also über jede
Verbindung. Das war selbstbegrenzend, solange nach zehn Minuten Schluss
war — mit der automatischen Wiederholung aus 1.9 hätte sich eine große
Playlist unterwegs ununterbrochen weitergeladen (1900 Titel ≈ 9-10 GB).

Neuer Schalter unter "Offline-Downloads", standardmäßig an: dann gilt
UNMETERED, der Download pausiert ohne WLAN und läuft von selbst weiter.
Abschalten nur über eine Rückfrage, die das Datenvolumen benennt.

Beim Speichern werden laufende Gruppen mit REPLACE neu eingereiht —
WorkManager ändert die Bedingungen eingereihter Arbeit nicht mehr, der
Schalter hätte sonst erst beim nächsten App-Start gewirkt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:34:44 +02:00
marcandClaude Opus 5 e2e7b2490f fix(android): Zählfehler im Downloader — halb geladene Gruppen galten als vollständig
Der Worker startete done bei group.downloadedTracks und zählte dieselben
fertigen Titel beim Überspringen nochmal: 412 fertige wurden zu 824.
Übersteigt der Wert totalTracks, gilt die Gruppe als vollständig, der
Fortschrittsbalken verschwindet und der Herunterladen-Knopf wird
unantastbar, obwohl Titel fehlen.

Der Fehler ist älter, fiel aber kaum auf, solange Fortsetzen nur von Hand
passierte — mit der automatischen Wiederholung aus 1.9 hätte er bei jedem
Lauf zugeschlagen. Zähler beginnt jetzt bei 0.

Dazu: beim Überspringen wurde der unveränderte Fortschritt geschrieben,
1900 überflüssige DB-Writes je Lauf. Jetzt nur nach echtem Versuch und
einmal beim Abbruch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 20:22:18 +02:00
marcandClaude Opus 5 8bc03da88e fix(android): große Offline-Downloads laufen jetzt bis zum Ende
WorkManager stoppt einen Hintergrund-Worker nach ~10 Minuten (ein
Vordergrunddienst scheidet aus, FOREGROUND_SERVICE_TYPE_DATA_SYNC stürzt
unter Android 15 ab). Der Worker prüfte nie isStopped, lief blind weiter
und meldete Result.failure() ohne Backoff — WorkManager startete nichts
neu, bei 1900 Titeln musste man wiederholt von Hand antippen.

Jetzt: sauberer Ausstieg bei isStopped mit Result.retry() und linearem
Backoff, Selbst-Fortsetzung solange Titel offen sind (max. 20 Versuche,
damit dauerhaft kaputte Titel keine Endlosschleife auslösen), Retry statt
Failure im äußeren catch, und beim App-Start werden unvollständige
Gruppen weitergeführt.

Dazu: die Zahl offener Titel kam aus einer Abfrage pro Titel am Ende
jedes Laufs — jetzt ein einzelnes COUNT mit JOIN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:17:50 +02:00
marcandClaude Opus 5 35e36067af fix(android): große Offline-Playlists ließen sich nicht abspielen
Die Warteschlange wurde aus allen Titeln einer Gruppe gebaut, ohne den
Download-Zustand zu prüfen. Die Map für lokale Dateien kennt aber nur
state = 'DONE' — für wartende oder fehlgeschlagene Titel lieferte
streamUrl() die Netz-Adresse, die offline nichts tut. Bei 100 Titeln ist
der Download durch, bei 1900 nicht; steht so ein Titel vorn, scheitert
die Wiedergabe sofort.

Play und Shuffle nehmen jetzt nur fertige Titel, ein Toast nennt die Zahl
der übersprungenen. Die Liste zeigt weiter alle, damit sichtbar bleibt,
was fehlt; Tippen auf einen unfertigen sagt es ebenfalls.

Nicht die Ursache: Media3 reicht große Listen als BundleListRetriever
durch, die 1-MB-Binder-Grenze greift dort nicht.

Dazu Backlog-Notiz: Künstler/Alben liken als Basis für "Künstler folgen".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 22:50:27 +02:00
marcandClaude Opus 5 cb5ea26c77 fix: Maus-Zurück im PWA-Fenster, sprechende Player-Namen, Pfad-Etikett
Im installierten Fenster ("display": standalone) gibt es keine Zurück-/
Vorwärts-Knöpfe, und die Maus-Seitentasten werden nicht auf den Verlauf
umgelenkt — im Tab ging es, im Fenster nicht. Die App übernimmt X1/X2
jetzt selbst, aber nur im Standalone-Fenster (im Tab würde das doppelt
navigieren; beide Fälle im Test abgedeckt). Zurück läuft über _navGoBack,
schließt also erst ein offenes Now-Playing.

Backend meldet sich jetzt als NovaSonic-Backend, der Scrobble-Dienst als
NovaSonic-Scrobble. Vorher novasonic vs. NovaSonic — in Navidromes
Player-Liste (Name = "client [User-Agent]") zwei Zeilen "[node]", die
sich nur an einem Großbuchstaben unterschieden.

Pfad-Etikett wieder ohne "(Navidrome)": der Wert kommt ohnehin aus der
Subsonic-API, die Erklärung gehört in den Code und nicht ins Fenster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:34:17 +02:00
marcandClaude Opus 5 568bb5002c fix(web): Zurück-Knopf tat oft nichts + Besitzer geteilter Playlists (2.6)
navigate() ließ den Stack-Eintrag weg, wenn sich die Ansicht nicht
geändert hatte, rief history.pushState aber trotzdem. Zweimal auf
denselben Tab geklickt = zwei Browser-Einträge auf dieselbe Ansicht,
jeder davon ein Zurück-Druck ohne sichtbare Wirkung. Im Test: 3x "Alben"
-> 3 Browser-Einträge bei 1 Stack-Eintrag, danach bewegten 4 Zurück-
Drücke die Ansicht einmal. Jetzt pusht nur eine wirklich neue Ansicht,
sonst replaceState. Vorwärts mitgetestet, unverändert.

Playlists: Navidrome liefert owner mit, das Web nutzte es nie. Übersicht
und Detail zeigen "· von <Name>", nur bei fremden Listen. Android zeigte
ihn bisher bei jeder Liste und unbeschriftet; beide Plattformen jetzt
gleich, Bibliotheks-Übersicht ebenfalls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 14:12:07 +02:00
marcandClaude Opus 5 ea448e109f docs(changelog): Android-Versionen je Release in einer Zeile bündeln
Die APK-Versionen standen als eigene Einträge zwischen den Features und
wiederholten deren Inhalt: "Android 1.6: Warteschlange ist sofort da"
neben "Zuletzt Gehörtes überlebt den Neustart" — die beiden
widersprachen sich sogar (nur Song-Ids vs. volles JSON), weil 1.6 den
Ansatz aus 1.4 abgelöst hatte.

Jede Release-Überschrift trägt jetzt eine "Android:"-Zeile mit Spanne und
je einem Halbsatz; die Einträge darunter beschreiben nur noch die
Änderung. Die Warteschlangen-Einträge sind zu einem zusammengezogen, mit
dem Weg 1.4 -> 1.6 als Unterpunkt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:58:46 +02:00
marcandClaude Opus 5 4ee20de359 fix(docs+web): "NN-NN im Dateinamen" war kein Bug — Navidromes fakePath
Der Pfad Rising Insane/DEATHRACE/01-02 - DEATHRACE.mp3 existiert so nicht
auf der Platte. Navidrome gibt über Subsonic standardmäßig nicht den
echten Pfad heraus, sondern baut ihn per fakePath() aus den Tags:
{AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{ext} — 01 ist die Disc, 02 die
Tracknummer. An der Künstlerstelle steht also kein kaputter Wert, dort
steht gar kein Künstler.

Known-Bug-Eintrag entsprechend widerlegt statt gelöscht, samt Hinweis auf
"Report Real Path" am Player novasonic — das ist die Wurzel der
Datei-Ratearbeit im Backend und damit auch des Fehlers "Metadaten springen
zurück". Das Backend nimmt einen absoluten Pfad bereits ohne Änderung.

Info-Dialog beschriftet das Feld jetzt als "Pfad (Navidrome)".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:56:15 +02:00
marcandClaude Opus 5 ac69d259d5 fix(web): Künstler in den Albumansichten sichtbar machen
Der Name stand in --text2 (#8888aa) und 14px unter einem 28px starken
Albumtitel — er ging darin unter, und dass er anklickbar ist, war nicht
erkennbar. Jetzt --text, 15px, halbfett; wo ein Ziel dahintersteht,
zusätzlich dezent unterstrichen (Hover in Akzentfarbe).

Gilt für lokale Albumseite, Deezer-Albumseite und die Inline-Ansicht in
der globalen Suche. Die Teil-Album-Seite bleibt ohne Link, dorthin kommt
man nur von der Künstlerseite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 13:43:17 +02:00
marcandClaude Opus 5 ec75fdef7b feat(web+android): vom Album in der globalen Suche zum Künstler springen (2.5 / Android 1.8)
Der Künstlername unter einem Deezer-Album war reiner Text — von dort zum
Künstler ging nur über Zurück und neu suchen. Er ist jetzt anklickbar und
öffnet dieselbe Deezer-Künstlerseite wie eine Künstler-Kachel.

Kostet keinen zusätzlichen API-Aufruf: Deezer legt /search/album und
/album/{id} das artist-Objekt samt id bei (120/120 Treffer über zwölf
Suchen geprüft), beide Mappings übernahmen die id längst.

/artist/{id}/albums lässt das artist-Objekt weg — Alben von dort bleiben
bewusst reiner Text, statt ins Leere zu zeigen.

Android zeigte in der Album-Detailansicht bisher gar keinen Künstler und
lädt Bild + Fanzahl nach, wenn der Künstler aus einem Album stammt.

.album-artist-name trug cursor:pointer auch ohne Ziel — jetzt nur noch,
wo wirklich etwas dahintersteht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 22:22:37 +02:00
marcandClaude Opus 5 fca7cc79c1 feat(android): Cache nach einem Update einmalig leeren (1.7)
Im cacheDir liegen Coils Bildspeicher und heruntergeladene APK-Updates, beides
jederzeit neu beschaffbar. Blieb dort nach einer neuen Version etwas Altes
liegen, war "Cache leeren" von Hand in den Systemeinstellungen die einzige
Abhilfe — nichts, das sich einem normalen Nutzer erklaeren laesst.

Geleert wird jetzt beim ersten Start nach einem Versionswechsel, verglichen wird
der gespeicherte versionCode. Im laufenden Betrieb passiert nichts, sonst waeren
die Cover bei jedem Start neu zu laden. Beim allerersten Start nach der
Installation ebenfalls nicht, da gibt es nichts aufzuraeumen.

Offline-Downloads bleiben unberuehrt: die liegen unter filesDir/offline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 08:42:27 +02:00
marcandClaude Opus 5 651f979401 perf(android): Warteschlange sofort wiederherstellen, SW-Cache automatisch versionieren
Android speicherte nur die Song-Ids und lud beim Start jeden Titel EINZELN und
nacheinander ueber das Netz nach. Das dauerte Sekunden, war unzuverlaessig und
ohne Verbindung unmoeglich. Gespeichert werden jetzt die vollstaendigen Songs als
JSON — Wiederherstellen ist ein lokaler Lesevorgang, wie im Web. Der Preis ist
ein veralteter Titel oder Cover bis zum naechsten Abspielen, was die deutlich
kleinere Unschoenheit ist.

Dazu sichert MusicService.onTaskRemoved die Warteschlange beim Wegwischen der
App: dort feuert onIsPlayingChanged nicht zuverlaessig, genau dieser Fall ging
vorher verloren.

Service Worker: die Cache-Version stand handgepflegt als ns-v78 in sw.js und
wurde beim Ausliefern vergessen — die Aktivierung loescht nur Caches mit
abweichendem Namen, also blieb der alte bestehen und Nutzer mussten den
Browsercache von Hand leeren. Ein Vite-Plugin setzt sie jetzt aus der Bauzeit,
die Quelldatei bleibt unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:01:30 +02:00
marcandClaude Opus 5 0b23a07039 fix(android): Warteschlangen-Wiederherstellung robuster und nachvollziehbar (1.5)
Das Wiederherstellen schlug still fehl. Mehrere runCatching verschluckten jeden
Grund, sodass von aussen nur "passiert nichts" zu sehen war.

Zwei wahrscheinliche Ursachen adressiert:
- connect() laeuft in Application.onCreate(), wo der DataStore mit den
  Navidrome-Zugangsdaten unter Umstaenden noch nicht gelesen ist. Dann scheitert
  das Aufloesen ALLER Song-Ids und die Warteschlange bleibt leer. Es wird jetzt
  bis zu fuenfmal mit wachsendem Abstand versucht.
- Wischt man die App aus den letzten Apps, wurde nichts gesichert:
  onIsPlayingChanged feuert dabei nicht zuverlaessig. MusicService.onTaskRemoved
  sichert jetzt vor dem Beenden.

Dazu Protokollausgaben unter dem Tag NsQueue an jeder Stufe (gesichert,
gefunden, Id nicht aufloesbar, wiederhergestellt, aufgegeben), damit ein
weiterer Fehlschlag per logcat zu klaeren ist statt per Vermutung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 23:26:17 +02:00
marcandClaude Opus 5 8da67e0975 chore(android): versionCode 7, versionName 1.4
Erste Android-Version mit Warteschlangen-Wiederherstellung und Streitpunkten.
Das zuvor hochgeladene 1.3 enthielt restoreQueue noch nicht — daher blieb die
Now-Playing-Leiste nach dem Oeffnen leer. Release-Beschreibung mit angehoben,
sonst meldet der In-App-Updater kein Update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:44:25 +02:00
marcandClaude Opus 5 e8108b0a7a docs(changelog): Dateinamen-Fehler richtig beschrieben (NN-NN im Kuenstlerfeld)
Die erste Fassung beschrieb einen anderen Mechanismus (doppelte Tracknummer aus
dem Titel-Tag). Marcs Beispiel zeigt etwas anderes: 01-02 steht an der Stelle des
Kuenstlers, die Tracknummer fehlt. trackPrefix kann diese Form nicht erzeugen,
die Quelle muss also das Kuenstlerfeld so geliefert haben — dieselbe Form ist in
metadata.test.js bereits als "garbage artist prefix" festgehalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:46:47 +02:00
marcandClaude Opus 5 871ecdd72e docs(changelog): doppelte Tracknummern im Dateinamen als bekannten Fehler notiert
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:44:59 +02:00
marcandClaude Opus 5 a2211a62d2 refactor(web+android): nur Warteschlange und Titel sichern, keine Sekunden
Das Mitschreiben der Abspielposition war zu viel des Guten: es bedeutete
staendiges Schreiben waehrend der Wiedergabe fuer einen Gewinn, den man kaum
bemerkt. Gesichert werden jetzt nur Warteschlange und aktueller Titel, der dann
von vorn beginnt.

Der eigentliche Gewinn bleibt und war der Ausloeser: unter Android war die
Now-Playing-Leiste nach dem Oeffnen leer, bis man einmal auf Play drueckte —
gesichert war nur der letzte Song, nicht die Warteschlange. Das Wiederherstellen
fuellt den Zustand jetzt ueber syncState(), also ohne Abspielen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:25:06 +02:00
marcandClaude Opus 5 65fa950043 feat(web+android): Wiedergabe fortsetzen und Streitpunkte im Duell (2.4)
Wiedergabe fortsetzen:
Im Web ueberlebten Warteschlange und letzter Titel bereits, die POSITION im
Titel aber nicht — beim Neuladen begann der Song von vorn. Android sicherte nur
den letzten Song ohne Warteschlange. Beide merken sich jetzt Warteschlange,
Listenposition und Sekunde.

Web schreibt gedrosselt (alle 5 s) waehrend der Wiedergabe und sicher beim
Pausieren, Wegwischen und Tabwechsel — pagehide/visibilitychange statt
beforeunload, das am Handy unzuverlaessig feuert. Android sichert bei jedem
Titelwechsel und beim Pausieren und stellt nach dem Verbinden wieder her:
vorbereitet, aber NICHT abgespielt, wer die App oeffnet will nicht ueberfallen
werden. Laeuft schon etwas (Android Auto), passiert nichts.

Zwei Feinheiten: lag die Position im letzten Rest des Titels, faengt er von vorn
an, statt zwei Sekunden Abspann zu spielen; und gespeichert werden nur Song-Ids,
weil Titel und Cover sich durch Metadaten-Bearbeitung aendern koennen —
verschwundene Titel werden beim Wiederherstellen uebersprungen.

Streitpunkte im Duell:
Gemeinsame Kuenstler nach groesstem Ungleichgewicht statt nach Summe, mit
Kraefteverhaeltnis-Balken. Mindestens vier gemeinsame Plays und 50 % Unwucht,
sonst gilt ein einzelner Play gegen null schon als Streit. Web und Android mit
identischen Schwellen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:20:40 +02:00
marcandClaude Opus 5 92c3d96936 docs(changelog): Dublettenprüfung nachgezogen, Unterstrich-Lücke im Variantenfilter notiert
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 23:11:42 +02:00
marcandClaude Opus 5 dfae34c706 feat(android): Navidrome-Adresse als Vorgabe, Login vorbelegt (1.3)
Ergaenzt Backend und Scrobble um die Navidrome-Adresse: alle drei kommen als
buildConfigField aus dem Build und greifen, wenn im Geraetespeicher nichts steht.
Der Anmeldebildschirm ist damit vorbelegt — beim ersten Start bleiben nur noch
Benutzer und Passwort. Die Felder in den Einstellungen bleiben sichtbar und
ueberschreibbar, ein Umzug ist also ohne neues APK zu korrigieren.

Zugangsdaten bleiben aussen vor. Das Publish-Skript reicht die Werte aus
scripts/.env.release durch, damit kuenftige Builds sie nicht verlieren.
Am erzeugten BuildConfig geprueft, alle vier Werte stehen darin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:37:13 +02:00
marcandClaude Opus 5 334aa51a09 feat(android): Backend- und Scrobble-Adresse als Vorgabe aus dem Build
Beide Adressen stehen ohnehin in der docker-compose.yml — sie zusaetzlich auf
jedem Geraet einzutippen ist Arbeit ohne Nutzen. Sie kommen jetzt als
buildConfigField aus -PnovasonicBackend= / -PnovasonicScrobble= und greifen,
wenn im Geraetespeicher nichts steht.

Die Felder in den Einstellungen bleiben sichtbar und ueberschreibbar: zieht ein
Dienst um oder aendert sich ein Port, laesst sich das am Geraet korrigieren,
statt ein APK bauen, hochladen und installieren zu muessen. Ohne gesetzte
Eigenschaften verhaelt sich die App exakt wie bisher.

Zugangsdaten bleiben bewusst aussen vor — die gehoeren pro Person, nicht in ein
APK, das im Release liegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:35:37 +02:00
marcandClaude Opus 5 83570b22b5 refactor(android): Web-Adresse aus BuildConfig statt als vierte Einstellung
Der Host stand ohnehin schon beim Bauen fest, weil die App-Links ihn im Manifest
brauchen. Ihn zusaetzlich als Einstellung zu fuehren hiess: eine vierte Adresse
pflegen, die vom Manifest abweichen kann, ohne dass es jemand merkt — und der
Teilen-Eintrag versteckte sich, solange sie leer war, was beim Suchen schlicht
wie ein fehlendes Feature aussah.

Jetzt kommt beides aus -PnovasonicHost=…: das Manifest fuer eingehende Links,
BuildConfig.SHARE_HOST fuer ausgehende. Die Einstellung samt Speicherfeld ist
entfernt, Teilen ist immer sichtbar. versionCode 5 / 1.2, APK hochgeladen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:27:27 +02:00
marcandClaude Opus 5 dea0485a54 docs(changelog): Android 1.1 und die Sichtbarkeit des Teilen-Eintrags
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:03:57 +02:00
marcandClaude Opus 5 4323d68edc chore(android): versionCode 4, versionName 1.1
Der In-App-Updater vergleicht versionCode aus der Gitea-Release-Beschreibung mit
BuildConfig.VERSION_CODE. Beide standen auf 3, das hochgeladene APK meldete sich
also korrekt als "kein Update" — trotz Teilen-per-Link, Songloeschen,
Playlist-aus-Import und Duell-Anzeigetafel. Version angehoben und die
Release-Beschreibung auf versionCode=4 gesetzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 19:03:17 +02:00
marcandClaude Opus 5 91d0d5fd0b feat(web+android): Musik-Match wird zum Duell mit Anzeigetafel
Statt eines Prozentrings ("wie aehnlich seid ihr") zeigt die Ansicht jetzt, wer
worin gewinnt: fuenf Kategorien mit je einem Sieger und ein Gesamtstand.
Hoerzeit, Vielfalt, Tiefe (Plays je Kuenstler), Nachteule (Anteil vor 5 Uhr) und
Entdeckerdrang (Anteil exklusiver Kuenstler) — bewusst Kennzahlen zur Hoerweise
statt zur Menge, sonst gewinnt immer, wer laenger dabei ist.

Am laufenden Dienst mit zwei gegensaetzlichen Nutzern geprueft: 60 Plays a 300 s
ergeben 5,0 h und Tiefe 12,0, jeder dritte Play nachts ergibt 33 %, 60 von 62
exklusiven Kuenstlern ergeben 97 % — Endstand 3:2. Das JSON-Feldschema wurde
gegen das Kotlin-Modell abgeglichen.

Dazu drei Fixes im selben Bereich:
- Die Web-Ansicht lebte nur von Inline-Stilen mit festen Flex-Verhaeltnissen und
  zerfiel am Desktop. Jetzt ein Raster mit eigenen Klassen, eine Spalte am Handy,
  zwei ab 780 px; das Fenster musste dafuer von 720 auf 940 px, sonst haette die
  zweite Spalte nie ausgeloest.
- Gemeinsame Songs wurden ueber song_id abgeglichen und fielen auseinander,
  sobald eine Datei umbenannt wurde. Jetzt normalisierter Titel+Kuenstler.
- Media-Session: die Metadaten wurden feldweise mutiert (gegen Kachel-Flackern
  unter Windows), ueber MPRIS kam davon nichts an — Plasma-Widgets zeigten
  dauerhaft den zuerst gespielten Titel. Bei echtem Titelwechsel jetzt ein neues
  MediaMetadata, sonst weiter in-place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:55:16 +02:00
marcandClaude Opus 5 5a6cff8105 chore(share): echten Signatur-Fingerabdruck in assetlinks.json
Die veroeffentlichten APKs sind Debug-signiert (kein signingConfig im
Release-Buildtype), der Fingerabdruck ist deshalb der des Debug-Keys:
1039A00E… — geprueft mit apksigner am gebauten APK und identisch mit dem
Schluessel des bisher veroeffentlichten Release, Updates laufen also in-place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 13:42:37 +02:00
marcandClaude Opus 5 5f1650b82a build(apk): Publish-Skript reicht den App-Link-Host an Gradle durch
Der Host steht fest im Manifest und muss beim Bauen mitgegeben werden. Das
Publish-Skript rief Gradle bisher ohne ihn auf, ein so veroeffentlichtes APK
haette also den Platzhalter aus build.gradle.kts getragen und geteilte Links
waeren im Browser gelandet statt in der App. NOVASONIC_HOST (und optional
NOVASONIC_HOST2) kommen aus scripts/.env.release; fehlt der Wert, warnt das
Skript deutlich, statt still das Falsche zu bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:25:14 +02:00
marcandClaude Opus 5 94fdc3274e docs(share): Ablauf eines Domainwechsels als Reihenfolge
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:23:05 +02:00
marcandClaude Opus 5 a89e2974bc feat(android): zweiter App-Link-Host fuer einen Domainwechsel
Der Host eingehender Links steht fest im Manifest, ein Domainwechsel brauchte
also zwingend ein neues APK und liess alte Links sofort ins Leere laufen. Mit
-PnovasonicHost2= laesst sich waehrend des Umzugs eine zweite Adresse angeben;
ohne Angabe wird der erste Host wiederholt. Bewusst kein Platzhalter-Host als
Vorgabe: auf Android 11 und aelter verifiziert das System nur, wenn ALLE Hosts
eine gueltige assetlinks.json liefern — ein toter Eintrag wuerde alle blockieren.
Am zusammengefuehrten Manifest geprueft, beide Hosts landen darin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 03:47:35 +02:00
marcandClaude Opus 5 430ecdc737 feat(web+android): Teilen per Link
Ein Song oder Album laesst sich per https://<host>/?song=<id> an jemanden auf
derselben Instanz schicken. Der Link traegt nur die Navidrome-Id, keine
Zugangsdaten — der Empfaenger sieht den Inhalt mit seinem eigenen Konto.

Web: Teilen ueber navigator.share() mit Rueckfall auf die Zwischenablage. Beim
Start wird ?song= / ?album= aufgeloest (nach der Anmeldung, vorher fehlen die
Zugangsdaten), das Album geoeffnet, der Song gestartet und der Parameter aus der
Adresszeile entfernt, damit ein Neuladen nicht erneut losspielt. Das Manifest
bekommt launch_handler=navigate-existing, damit der Link in einem bereits
offenen PWA-Fenster landet statt in einem neuen.

Android: Teilen ueber ACTION_SEND, Empfang ueber einen App-Link-Intent-Filter.
Der Host ist eine Build-Eigenschaft (-PnovasonicHost=…, verifiziert am
zusammengefuehrten Manifest), die Web-Adresse fuer ausgehende Links eine neue
Einstellung. Weil der NavController in der Compose-Hierarchie lebt und die
Activity nicht selbst navigieren kann, reicht DeepLinkState den Link durch;
onNewIntent ist mitbedacht, sonst passierte beim zweiten Link nichts.

assetlinks.json liegt als Vorlage in public/ und wird vom Frontend-Image
ausgeliefert (im Build verifiziert); ohne den Release-Fingerabdruck oeffnet der
Link die Web-App im Browser statt die App. Anleitung: docs/share-links.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 03:30:32 +02:00
marcandClaude Opus 5 3c99778567 fix(backend): Dublettenpruefung erkennt Akzente und Soundtrack-Ordner
Drei Luecken derselben Familie, die dazu fuehrten, dass dieselben Songs ein
zweites Mal geladen wurden:

- Kuenstlernamen wurden zeichengenau verglichen: "Cécile Corbel" und "Cecile
  Corbel" galten als zwei Personen. Welche Schreibweise ankommt, haengt an der
  Quelle (Deezer vs. Spotify). Der Vergleich normalisiert jetzt Akzente weg —
  nur der Vergleich, Tags und Dateinamen behalten das Original.
- Typografische Apostrophe: "Arrietty’s Song" (U+2019) und "Arrietty's Song"
  fielen auseinander. Fiel beim Testen auf, betrifft viele Soundtracks.
- findExistingTrack durchsucht nur den Ordner des uebergebenen Kuenstlers. Bei
  Soundtracks weicht der Album- vom Titel-Kuenstler ab, der Song landet je nach
  Ladeweg unter dem einen oder anderen — geprueft wurde jeweils der falsche.
  Weicht der Titel-Kuenstler ab, wird nun auch dessen Ordner geprueft (~0,6 ms
  bei 28.801 Dateien, gegenueber 3-9 s pro Download).

Sieben neue Tests zielen gezielt auf Fehlalarme: aehnliche Namen (Cecil Taylor
vs. Cécile Corbel), gleicher Titel bei verschiedenen Kuenstlern, Remix gegen
Basistitel, fremder Titel im Soundtrack-Ordner — alle bleiben getrennt.
Gesamte Suite: 42 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 08:40:02 +02:00
marcandClaude Opus 5 f62b248bb6 fix(backend): Metadaten-Edit trifft nicht mehr die falsche Datei
Findet das Backend die von Navidrome gemeldete Datei nicht am angegebenen Pfad
(Subsonic liefert oft einen aus Tags gebauten virtuellen Pfad), sucht es ueber
Dateiname bzw. Kuenstler+Titel weiter. Liegt derselbe Song mehrfach auf der
Platte, konnte das die falsche Kopie treffen: die wurde getaggt, der bearbeitete
Song blieb unveraendert — die Aenderung "sprang zurueck". Das erklaert, warum es
bei Songs ohne Dublette immer ging und bei anderen nie.

Der ueber Aehnlichkeit gefundene Kandidat wird jetzt per ffprobe gegen die Tags
geprueft, die Navidrome fuer diesen Song meldet, und bei Widerspruch mit 409 und
erklaerender Meldung abgebrochen statt geraten. Zusaetzlich melden uebersprungene
Umbenennungen und Verschiebungen sich als Warnung, statt stumm zu bleiben; die
Antwort nennt den tatsaechlich bearbeiteten Pfad, der Editor zeigt beides an.

Ausserdem: -dontwarn org.slf4j.** — der Release-APK-Build brach seit laengerem an
R8 ab (Ktors Logging referenziert den slf4j-1.x-Binder, den es auf Android nicht
gibt). Vorbestehend, mit HEAD~1 verifiziert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:46:44 +02:00
marcandClaude Opus 5 aae358c4c7 feat(android): Songs loeschen und Playlist aus dem Import, plus Web-Scrollfix
Android holt zwei Sachen nach, die es im Web laengst gibt:

- Song von der Festplatte loeschen, im Kontextmenue unten abgesetzt und rot, mit
  derselben Sicherheitsabfrage wie im Web. Laeuft ueber /api/delete-song und
  erscheint nur mit konfiguriertem Backend — Navidrome allein loescht nichts.
- Playlist direkt aus dem Import anlegen: Haekchen plus Namensfeld ab zwei
  Titeln, danach Scan anstossen, Titel suchen, Playlist aus den gefundenen.
  Nicht gefundene werden gezaehlt statt die Erstellung scheitern zu lassen.

Dazu der Web-Fix zur Album-Position: die gespeicherte Scrollposition wurde in
genau einem requestAnimationFrame gesetzt. Solange Cover und Eintraege noch
nachluden, war scrollHeight kleiner als das Ziel und der Browser kappte den Wert
stillschweigend — man landete wieder oben. Jetzt wird er ueber bis zu ~0,5 s
erneut gesetzt, bis er sitzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:40:57 +02:00
marcandClaude Opus 5 a05c15df93 docs(changelog): YTM-Wächter, Album-Zurück und Scrobble-Gruppierung
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:52:03 +02:00
marcandClaude Opus 5 da32d2911a fix(scrobble): Metadaten-Bearbeitung zerlegt die Statistik nicht mehr
Top-Titel gruppierte nach song_id. Wird eine Datei beim Bearbeiten umbenannt,
vergibt Navidrome eine neue id — der Titel erschien danach zweimal mit geteilten
Zaehlern. Kuenstler/Alben/Genres gruppierten nach lower(), sodass schon ein
doppeltes Leerzeichen zwei Eintraege ergab.

Schluessel ist jetzt durchgehend normalisiert (lower + trim + Leerzeichen
gefaltet), Top-Titel gruppiert nach Titel+Kuenstler statt nach song_id. Die
Anzeigefelder kommen aus dem JUENGSTEN Play statt aus MAX(), sonst zeigte die
Statistik nach einer Korrektur weiter die alte Schreibweise. Am laufenden Dienst
geprueft: 40 Plays vor und 10 nach einer Umbenennung erscheinen als ein Eintrag
mit 50 Plays, neuer Schreibweise und neuer songId.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:47 +02:00
marcandClaude Opus 5 39d47b78d4 fix(web): Zurueck im Album fuehrt dorthin zurueck, wo man geklickt hat
album-back-btn war die einzige Detailseite, die per navigate() VORWAERTS auf die
Albenliste sprang statt einen Schritt im Verlauf zurueck. Dadurch landete man
nicht dort, wo man das Album angeklickt hatte (z. B. Home), und es kam ein
zusaetzlicher History-Eintrag auf den Stapel, durch den sich der Browser-Zurueck-
Knopf erst arbeiten musste. Jetzt _navGoBack() wie bei Artist, Genre, Deezer- und
Partial-Album; die Albenliste bleibt nur das Ziel, wenn es keinen Verlauf gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:47 +02:00
marcandClaude Opus 5 eee98d193f fix(backend): Kanaltreffer in der YTM-Suche verwirft nicht mehr die Trefferliste
getYtmVideoUrls wertete jedes /browse/UC in der Ausgabe als "yt-dlp hat einen
Artist-Channel aufgeklappt" und gab [] zurueck — samt der bereits gesammelten
Song-URLs. Bei "DAT ADAM UFO" stand der Kanal auf Platz 9 und riss sieben gute
Treffer mit, darunter den offiziellen Art Track auf Platz 1; uebrig blieb der
YouTube-Fallback mit einem Live-Mitschnitt. An 8 echten Suchen gemessen feuerte
das in 7 Faellen, Schritt 1 der Kaskade war also praktisch tot.

Mit --flat-playlist kann gar nichts aufgeklappt werden, ein Kanal ist EINE Zeile.
pickYtmVideoUrls() behaelt daher alle watch?v=-Zeilen und ueberspringt browse/UC
(Kanal) wie browse/MPREb_ (Album). Ueber 20k Zufallsausgaben geprueft: die neue
Funktion ist eine Obermenge der alten, kann also nur Kandidaten hinzufuegen.
Drei echte Downloads liefen end-to-end sauber durch (Dauer sekundengenau auf
Deezer, korrekte Tags), alle aus Schritt 1 statt aus dem Fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:32 +02:00
marcandClaude Opus 5 33b3df609a fix(backend): Live-Mitschnitte aus der Suche ausschliessen
is_live meint nur "laeuft gerade als Stream" — eine Konzert-AUFNAHME kam
ungehindert durch und landete als Albumtrack in der Bibliothek ("Dat Adam - Ufo
Video Days 2015 Live" auf dem echten Kuenstlerkanal, Dauer im Fenster). Der
Variantenfilter bekommt daher eine live-Regel. Sie schaltet sich ab, wenn der
gesuchte Titel selbst live enthaelt; die Pruefung nutzt \blive\b statt includes,
sonst wuerde "deliver" die Regel aushebeln. Drei Tests dazu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:35:44 +02:00
marcandClaude Opus 5 6d8a8a1d5e fix(backend): Neuauflage-Jahreszahl nicht als Pflicht-Keyword fordern
Folgekorrektur zu 02b5905: "… - 2020 Remaster" steht auf der Veroeffentlichung,
aber nie im YouTube-Titel — als Pflicht-Keyword haette die Zahl den richtigen
Upload abgelehnt. Jahreszahlen neben einem Qualifier (remaster/version/edition/
anniversary/mono/stereo) fallen daher wieder raus; ein Titel, der selbst eine
Jahreszahl ist ("1999", "Berlin 2020"), behaelt sie. Gegen 29 Beispieltitel
gemessen verhalten sich 24 exakt wie vorher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:20:44 +02:00
marcandClaude Opus 5 02b590505a fix(backend): Zahlen im Titel sind Pflicht-Keywords, Abkuerzungen erlaubt
"700 Main Street" lud "777 Main Street" eines fremden Artists: buildTitleKeywords
behielt nur die zwei laengsten Woerter (Street, Main), die unterscheidende 700
fiel als zu kurz raus — und Schritt 3 der Kaskade prueft den Kuenstler-Channel
nicht mehr. Zahlen werden jetzt immer als \b<n>\b gefordert. Dazu eine kleine
Abkuerzungstabelle (street/st, saint/st, mount/mt, doctor/dr, versus/vs,
number/no), denn der korrekte Art Track heisst "700 Main st" und scheiterte
bisher an jedem Schritt. Tests in backend/test/ytdlp.test.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:04:00 +02:00
marcandClaude Opus 5 9f0d4c3153 docs(changelog): 2.3 — Pull-Stack, APK-Download-Fix, Alben-Tab-Scrollfix
Fasst die Aenderungen seit 2.2 als Release zusammen: image-basiertes
docker-compose.yml (Bauen wandert nach .dev.yml), der auf GITEA_URL
umgeschriebene APK-Asset-Download und der Alben-Tab-Fix, dazu das
veroeffentlichte versionCode 3. Am Handy bestaetigt: Update-Dialog,
In-Place-Install und die Scrollpositionen funktionieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:36:59 +02:00
marcandClaude Opus 5 e91708bc97 chore(android): versionCode 3 veroeffentlicht (Scroll-Fixes im Release)
APK mit dem Debug-Key vom alten Rechner gebaut (Signatur 1039a00e…, passend zur
installierten App) und als Asset im Release apk-latest ersetzt, Body auf
versionCode=3. Dazu das ausfuehrbare Bit auf gradlew, das im Clone fehlte, und
.idea/ ignoriert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:25:25 +02:00
marcandClaude Opus 5 0029b89de4 fix(android): Alben-Tab behaelt die Scrollposition beim Zurueck
LaunchedEffect(state.albumSort) { scrollToItem(0) } feuerte nicht nur beim
Sortierwechsel, sondern auch beim ersten Compose nach der Rueckkehr aus einem
Album — die im ViewModel gehaltene Position wurde also sofort ueberschrieben.
Das Hochscrollen sitzt jetzt in setAlbumSort() und laeuft nur bei echtem
Wechsel; dazu zwei ungenutzte remember*State-Importe entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:38 +02:00
marcandClaude Opus 5 3e8897b551 fix(backend): APK-Download nutzt den erreichbaren Gitea-Host statt der ROOT_URL
Gitea baut browser_download_url aus seiner eigenen ROOT_URL (hier
192.168.178.153:3000), die vom Backend-Container nicht erreichbar ist. Der
API-Call gelang, der Asset-Download nicht — der stille Fallback auf die nicht
vorhandene lokale Datei meldete dann "Keine APK hinterlegt". Asset- und
Redirect-URLs werden jetzt auf GITEA_URL umgeschrieben; scheitert der Weg
trotzdem, sagen Log und Fehlermeldung welcher der beiden Wege fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:21:31 +02:00
marcandClaude Opus 5 043ec3a108 refactor(docker): docker-compose.yml ist der Pull-Weg, Bauen wandert nach .dev.yml
Namen getauscht: das Standard-Compose zieht jetzt die fertigen Registry-Images
(vorher docker-compose.prod.yml), die Build-Variante heisst docker-compose.dev.yml.
Weniger verwirrend, weil der Produktivfall ohne -f auskommt. Kopfkommentare,
README, CLAUDE.md, .env.example und build-push-images.mjs entsprechend angepasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:15:46 +02:00
marcandClaude Opus 5 62391e4aae feat(docker): pull-based prod stack (docker-compose.prod.yml + Registry-Images)
Produktivsystem baut nicht mehr selbst, sondern zieht die drei fertigen Images
(novasonic-frontend/-backend/-scrobble) aus der Gitea-Registry. Das bestehende
docker-compose.yml bleibt der Build-auf-dem-Host-Weg. Dazu .dockerignore fuers
Frontend-Image (android/, backend/, .git raus) und README/CLAUDE.md aktualisiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:13:46 +02:00
HoneyandClaude Opus 4.8 767d088665 refactor(docker): default compose builds from Dockerfiles (baked deps, cached)
Chosen flow: build on the host from backend/scrobble Dockerfiles instead of
pulling from a registry (no registry / no Docker-on-dev needed). Heavy deps are
baked, so restarts are instant and rebuilds after git pull are cache-fast.
Removed the now-obsolete docker-compose.dev.yml (runtime-install variant); the
optional push-to-registry script stays for a future CI flow. Updated README,
CLAUDE.md, .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 13:34:00 +02:00
HoneyandClaude Opus 4.8 1d8d8143f2 refactor(docker): make image-based the default compose, build variant is .dev
Less confusion: docker-compose.yml is now the default (pulls pre-built images)
and the build-on-host variant is docker-compose.dev.yml. Updated all references
(CLAUDE.md, README, .env.example, build-push-images.mjs, compose header).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 08:03:14 +02:00
HoneyandClaude Opus 4.8 149443e4f7 feat(docker): image-based prod stack (Dockerfiles + prod compose + push script)
Adds a pull-based production option so the host no longer builds/installs deps
on every restart:
- backend/Dockerfile + scrobble/Dockerfile bake the heavy deps (ffmpeg, Deno,
  yt-dlp, node libs). Backend still refreshes yt-dlp on start (non-fatal).
- docker-compose.prod.yml pulls ${REGISTRY}/novasonic-*:${TAG}.
- scripts/build-push-images.mjs (npm run push-images) builds + pushes all three.
- .env.example documents every var incl. REGISTRY/TAG; .env gitignored.

The build-on-host docker-compose.yml stays as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 07:45:17 +02:00
HoneyandClaude Opus 4.8 93ee958434 feat: artist-page scroll/search memory (android) + move APK button to bottom (web)
- Android: the artist page's main list and the "Alle anzeigen" album overlay
  (grid scroll + search query) are hoisted into the Artist/Explore ViewModels,
  so drilling into an album/artist and back keeps position + query. Covers the
  library artist and Deezer (Explore) artist screens.
- Web: the "Android App" download section now sits at the very bottom of
  Settings, below the Navidrome-Server (account) section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 07:42:24 +02:00
HoneyandClaude Opus 4.8 a780f6622b chore(android): versionCode 2 (first apk-latest release upload)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 07:27:06 +02:00
105 changed files with 8138 additions and 959 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# Frontend build context (root Dockerfile) — keep it to what `vite build` needs.
.git
node_modules
dist
android
docs
scripts
backend
scrobble
.env
*.log
+31 -15
View File
@@ -1,20 +1,36 @@
# Copy to .env and fill in your values
# Copy to .env next to your docker-compose file and fill in. cp .env.example .env
# Used by docker-compose.yml (zieht fertige Images) und docker-compose.dev.yml (baut lokal).
# REGISTRY/TAG unten braucht nur der Pull-Weg (docker-compose.yml).
# Pre-fills the URLs on the setup screen
NAVIDROME_URL=https://music.yourdomain.com
BACKEND_URL=https://novasonic-api.yourdomain.com
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # play-log / stats service (port 3710)
# ── URLs pre-filled on the frontend setup screen ─────────────────────────────
NAVIDROME_URL=https://music.example.com
BACKEND_URL=https://backend.example.com
SCROBBLE_URL=https://scrobble.example.com
# Absolute path to your Navidrome music library on the host
MUSIC_PATH=/srv/navidrome/music
AUDIO_FMT=mp3 # mp3 | flac | opus | best (used by yt-dlp)
AUDIO_QUALITY=0 # 0 = best quality (used by yt-dlp)
# ── Library / downloads (backend) ────────────────────────────────────────────
# Absolute music path — mounted 1:1 into the backend (no remapping), same value inside the container.
MUSIC_PATH=/opt/navidrome/music
AUDIO_FMT=mp3
AUDIO_QUALITY=0
MAX_PARALLEL=2
DOWNLOAD_BACKEND=ytdlp
# Ticketmaster Events (optional — free API key)
# 1. Sign up at https://developer.ticketmaster.com/
# 2. Create an app — copy the Consumer Key
TICKETMASTER_API_KEY=
# ── Android APK download button + in-app updater ─────────────────────────────
# The backend proxies the fixed Gitea release ("apk-latest") over the LAN, so Gitea can stay internal.
GITEA_URL=http://192.168.2.51:3000 # Gitea-Basis, die der BACKEND-CONTAINER erreicht.
# Muss nicht Giteas eigene ROOT_URL sein — das Backend baut
# die Asset-URL auf diesen Host um (Test aus dem Container:
# curl -sI $GITEA_URL/api/v1/version).
GITEA_REPO=Docker/NovaSonic
GITEA_TOKEN= # read token (only needed for a private repo)
GITEA_APK_TAG=apk-latest
# ── Fertige Images (nur docker-compose.yml) ──────────────────────────────────
# REGISTRY = where `npm run push-images` pushed to and where the host pulls from.
# Gitea container registry (lowercase owner): gitea.honeyy.duckdns.org/docker
# Docker Hub: docker.io/youruser
# Plain-HTTP internal registries need the host's Docker daemon to allow it
# (/etc/docker/daemon.json → {"insecure-registries":["192.168.2.51:3000"]}).
REGISTRY=gitea.honeyy.duckdns.org/docker
TAG=latest
+2
View File
@@ -3,3 +3,5 @@
node_modules/
dist/
scripts/.env.release
.env
.idea/
+863 -10
View File
@@ -3,6 +3,9 @@
All notable changes to NovaSonic are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features and fixes together).
Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche das waren, steht in einer
**Android:**-Zeile direkt unter der Release-Überschrift — die Einträge darunter beschreiben nur noch,
*was* sich geändert hat, nicht in welchem APK.
---
@@ -13,13 +16,14 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| Music playback (Navidrome) | ✅ | ✅ |
| Seek bar / scrubbing | ✅ | ✅ |
| Queue management | ✅ | ✅ |
| Warteschlange bearbeiten (ziehen/löschen) | ✅ | ✅ |
| Shuffle / Repeat | ✅ | ✅ |
| Radio mode (similar songs) | ✅ | ✅ |
| Now Playing overlay | ✅ | ✅ |
| Dominant color background | ✅ | ✅ |
| Cover art scale animation | ❌ | ✅ |
| Synced lyrics (LRCLIB) | ✅ | ✅ |
| Lyrics autoscroll | ✅ | ✅ |
| Lyrics-Mitlaufen (umschaltbar) | ✅ | ✅ |
| Star / favorite song | ✅ | ✅ |
| Multi-artist navigation | ✅ | ✅ |
| Album detail page | ✅ | ✅ |
@@ -35,7 +39,17 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| Playlist browsing | ✅ | ✅ |
| Playlist create/edit | ✅ | ✅ |
| Playlist cover upload | ✅ | ✅ |
| Besitzer geteilter Playlists | ✅ | ✅ |
| Künstler folgen / Album merken | ✅ | ✅ |
| Neu von deinen Künstlern | ✅ | ✅ |
| Konzerte deiner Künstler | ✅ | ✅ |
| Spotify / YouTube importer | ✅ | ✅ |
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
| Wiedergabe fortsetzen | ✅ | ✅ |
| Teilen per Link | ✅ | ✅ |
| Album → Künstler (Deezer-Suche) | ✅ | ✅ |
| Playlist aus Import anlegen | ✅ | ✅ |
| Song von Festplatte löschen | ✅ | ✅ |
| Metadata editor (tags + cover) | ✅ | ❌ |
| Song delete (from disk) | ✅ | ❌ |
| Import notification | ✅ | ✅ |
@@ -50,6 +64,7 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| ABS notification (modern style) | ❌ | ✅ |
| Android Auto | — | ✅ |
| Offline download (albums/playlists) | ❌ | ✅ |
| Downloads nur über WLAN | — | ✅ |
| Offline download (single songs) | ❌ | ✅ |
| Offline playback | ❌ | ✅ |
| Offline detail / manage downloads | ❌ | ✅ |
@@ -64,6 +79,799 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
---
## [3.2] — 2026-10-01
**Android:** 1.25 (versionCode 28) → 1.26 (versionCode 29).
### Added
- **Lyrics: Mitlaufen ein- und ausschalten (Web + Android)** — im Lyrics-Reiter sitzt jetzt ein
kleiner Schalter *Mitlaufen / Folgt*. Aus heißt: der Text bleibt stehen, man scrollt selbst.
An heißt: der Text springt mit und hält die laufende Zeile bei 40 % Höhe.
- **Vorgabe ist AUS.** Wer mitliest, blättert auch mal zurück oder vor — und genau dann sprang
der Text bisher bei jeder neuen Zeile unter dem Finger weg.
- **Nur bei synchronen Texten sichtbar.** Ohne Zeitstempel gibt es nichts mitzuführen; bei
reinem Text erscheint der Schalter nicht.
- Hervorgehoben wird die laufende Zeile **immer**, auch bei ausgeschaltetem Mitlaufen — man will
ja sehen, wo man ist. Einschalten richtet sofort aus, statt bis zur nächsten Zeile zu warten.
- Gespeichert wird pro Gerät (`ns_lyrics_folgen` im Web, `lyrics_autoscroll` in den Android-
Einstellungen). Der Android-Schlüssel wird **einzeln** geschrieben: würde ihn das Speichern im
Einstellungsbildschirm mitschreiben, setzte es ihn jedes Mal auf die Vorgabe zurück, weil
dieser Bildschirm das Feld gar nicht führt.
### Fixed
- **Mischen war nicht zufällig (Web)** — `liste.sort(() => Math.random() - 0.5)` ist keine
Mischung: ein Vergleicher, der bei derselben Paarung mal so und mal anders antwortet, ist für
eine Sortierung ungültig, und das Ergebnis ist messbar verzerrt. Gemessen (V8, 3000 Durchläufe)
blieb der erste Titel drei- bis fünfmal zu oft vorn — bei 10 Titeln in 18,8 % der Fälle statt
10 %, bei 100 in 3,1 % statt 1 %, bei 2000 in 0,23 % statt 0,05 %. Die Grobordnung sah dabei
gemischt aus, weshalb es lange nicht auffiel; bevorzugt wurden trotzdem immer dieselben Titel.
Ersetzt durch Fisher-Yates in `src/player/queueMath.js` (`mischeListe`), verwendet an allen vier
Stellen: Warteschlange starten, Mischen umschalten und die beiden Genre-Knöpfe.
- **Der letzte Titel lief mehrfach wieder los (Web/PWA)** — am Ende der Warteschlange ohne Repeat
steht nichts mehr an, trotzdem spielte der letzte Titel bei jedem Zurückholen der Seite erneut.
Ursache: ein beendetes `<audio>` meldet sich als *pausiert*, und die Wiederaufnahme nach einem
unfreiwilligen Hintergrund-Stop rief `play()` — das spult dann auf Anfang zurück. Mit Mischen
fiel es besonders auf, weil der letzte Titel dort ein beliebiger ist. Jetzt bricht die
Wiederaufnahme bei `audio.ended` ab.
- **Shuffle-Knopf und tatsächliche Reihenfolge gingen auseinander (Android)** — jeder
Shuffle-Knopf mischte selbst (`liste.shuffled()`) und übergab die fertige Reihenfolge. Damit war
die „ursprüngliche" Liste schon die gemischte: das Shuffle-Symbol leuchtete nicht, obwohl
gemischt gespielt wurde, und *Shuffle aus* konnte die Albumreihenfolge nie wiederherstellen.
Das Mischen macht jetzt der Player (`playSongs(liste, -1)`), der die ungemischte Liste behält.
- **Radio-Modus konnte den laufenden Titel direkt hinter sich selbst hängen (Web + Android)** — in
der letzten Rückfallebene sind Wiederholungen erlaubt, damit Musik weiterläuft; der gerade
laufende Titel war davon nicht ausgenommen und spielte dann zweimal in Folge. Jetzt ist er es.
## [3.1] — 2026-09-22
**Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf und die Gesamtdauer, 1.24 die Konzerte, 1.25 sie ohne Zeitfenster.
### Added
- **Konzerte deiner Künstler (Web + Android)** — die Zeitleiste hat einen zweiten Reiter: neben
*Neu erschienen* steht jetzt *Konzerte* und zeigt, wann die Künstler spielen, denen du folgst.
Neuer Endpunkt `POST /api/artist-concerts`, Gegenstück zu `/api/artist-releases`.
- **Zwei Reiter statt zweier Knöpfe** in den Favoriten. Beides ist dasselbe Thema; zwei Knöpfe
drängeln sich, und man müsste sich merken, welcher was zeigt.
- **Bewusst nicht in eine gemeinsame Liste gemischt.** Veröffentlichungen liegen hinter dir,
Konzerte vor dir. Chronologisch gemischt stünde „heute" mittendrin und man wüsste nie, ob man
vorwärts oder rückwärts liest. Deshalb läuft die Konzertliste **nach vorn**: das nächste
Konzert zuerst, nach Monat gruppiert wie bei den Veröffentlichungen.
- **Kein Zeitfenster bei den Konzerten.** Die Chips 90/30/365 waren dort zuerst mit dabei und
ergaben keinen Sinn: Ticketmaster liefert ohnehin nur Anstehendes, und bei Terminen will man
alles Angekündigte sehen — eine Tagesgrenze schneidet nur willkürlich ab. Die Chip-Reihe
erscheint jetzt nur noch beim Reiter *Neu erschienen*; der Endpunkt beachtet ein
mitgeschicktes Fenster weiterhin, verlangt es aber nicht mehr.
- **Gegenprobe gegen die Stichwortsuche.** Ticketmaster wird über den Namen abgefragt
(`keyword=`), nicht über `attractionId` — dieselbe Unschärfe wie bei der Deezer-Zuordnung.
Jeder Treffer wird deshalb gegengeprüft: der Künstlername muss im Veranstaltungs- **oder**
Attraktionsnamen vorkommen. Ohne das liefert „MORE" oder „TPR" fremde Veranstaltungen.
- **Ortszeit, nicht UTC.** Ein Konzert um 20:00 stünde sonst als 08:00 da. Die Zeit wird als
zonenlose Ortszeit durchgereicht und unverändert angezeigt.
- **Kein Ortsfilter** — die Suche läuft weltweit. Eine Einschränkung per `.env` ist im Backlog
notiert; fürs Erste reicht, was die API liefert.
- Der Cache liegt im Backend (6 h, höchstens vier Anfragen gleichzeitig), nicht im Client: bei
50 gefolgten Künstlern wären das sonst 50 Abfragen pro Gerät und App-Start. Bei einem Fehler
kommt der abgelaufene Stand zurück statt einer leeren Liste.
- **Neun Prüfungen** in `backend/test/konzerte.test.js` — mit eingeschobener
Ticketmaster-Antwort, also ohne Schlüssel und ohne Netz: Ortszeit, Gegenprobe, Fenster,
Sortierung nach vorn, Cache-Treffer, abgesagte Termine.
- **Zwei neue Designs: Violet und Crimson (Web + Android)** — ein helles Lila auf tiefem
Violettschwarz und ein gedämpftes Rot auf Weinrot-Schwarz. Beide mit demselben Variablensatz wie
die vorhandenen, geprüft: kein Design vermisst eine Variable.
- Crimson trägt bewusst **kein reines Rot** als Akzent — das läge zu nah an der Warnfarbe, und
Löschknöpfe sähen dann aus wie gewöhnliche Akzente. Stattdessen ein gedämpftes Rosé-Rot, und
die Warnfarbe wurde dort heller gesetzt, damit sie weiterhin absticht.
- Beide Plattformen lesen ihre Designliste durch, die Auswahl in den Einstellungen füllt sich
also von selbst.
- **Wischen wechselt den Titel (Android)** — seitwärts wischen blätterte im Now-Playing zwischen
Player, Warteschlange und Text; den Titel wechselte nur ein Wisch **genau über dem Cover**. Das
ist verdreht: der Titelwechsel ist die Geste, die man dort ständig braucht. Jetzt nimmt die ganze
Player-Seite den Wisch entgegen und springt zum nächsten oder vorherigen Titel.
- **Umstellbar** in den Einstellungen unter *Wiedergabe* → „Wischen wechselt den Titel". Aus
heißt: wie bisher. Die Reiter oben schalten in beiden Fällen weiter, es geht also nie eine
Ansicht verloren.
- Auf Warteschlange und Text liegt die Geste **nicht** — dort würde sie mit dem Ziehen in der
Liste streiten.
- **Die Warteschlange lässt sich bearbeiten (Web + Android)** — bisher war sie eine reine Anzeige:
reinlegen ging, umsortieren oder wieder herausnehmen nicht. Jetzt trägt jede Zeile einen Griff
zum Ziehen und ein ✕ zum Entfernen, auf beiden Plattformen und in beiden Web-Listen (Seitenleiste
und Now-Playing).
- **Der Zeiger auf den laufenden Titel wandert mit.** Das ist der Teil, bei dem ein Fehler nicht
auffällt, sondern später den falschen Song spielt: fällt ein Titel **vor** dem laufenden weg,
rutscht dieser eine Stelle vor; trifft es den laufenden selbst, rückt der nächste nach (am
Ende der Liste der davor); beim Verschieben wird der Zeiger über die **Position** des laufenden
Titels neu bestimmt statt gerechnet — das stimmt auch, wenn er selbst gezogen wurde.
- Diese Rechnung liegt im Web als reines Modul ohne Importe in `src/player/queueMath.js`, damit
sie ohne DOM und ohne Wiedergabe prüfbar ist: **12 Fälle, alle grün.**
- **Web: Zeiger-Ereignisse statt HTML5-Drag-and-Drop.** Dessen `dragstart` gibt es auf
Touch-Geräten gar nicht — die Warteschlange wäre am Handy unbearbeitbar geblieben.
`setPointerCapture` bedient Maus und Finger über denselben Weg. Die Zeilen zwischen Start und
Ziel weichen aus, damit die Lücke dorthin wandert, wo man loslässt.
- **Android: getauscht wird laufend**, sobald der Finger eine Zeilenhöhe zurückgelegt hat — so
braucht es kein Geisterbild, die Liste zeigt die neue Reihenfolge sofort. Gezogen wird am
Griff, nicht an der Zeile, sonst ließen sich Ziehen und Scrollen nicht auseinanderhalten; und
der erste Fingerkontakt wird verbraucht, sonst beansprucht die `LazyColumn` die Geste (dieselbe
Falle wie beim Rollbalken).
- Im Player laufen die lokale Liste und Media3 im Gleichschritt (`moveMediaItem`,
`removeMediaItem`). Liefen sie auseinander, zeigte die Oberfläche den falschen Song an, weil
der laufende Index vom Controller kommt, der Titel dazu aber aus der lokalen Liste.
### Changed
- **Gesamtdauer mit Stunden (Web + Android)** — im Web stand bei Playlists und Alben nur eine
Minutenzahl; „134 Min." muss man selbst umrechnen, und bei einer Playlist mit 2000 Titeln steht
dort eine vierstellige Zahl. Ab einer Stunde heißt es jetzt „2 Std. 14 Min.", volle Stunden ohne
Anhängsel („2 Std."). **In Android fehlte die Dauer ganz** — Playlist und Album zeigen sie jetzt
ebenfalls.
- Kostet keinen zusätzlichen Aufruf: Subsonic liefert `duration` beim Playlist- und Album-Objekt
mit, in Android wird genau die genommen; im Web wird weiterhin über die geladenen Titel
summiert, weil die dort ohnehin schon vorliegen.
- Dieselbe Regel und dieselbe Schreibweise auf beiden Plattformen, damit dieselbe Playlist nicht
zwei verschiedene Angaben trägt. Neun Fälle geprüft (0, 59 Min., genau 1 Std., 100 Std., leer).
- **Benachrichtigungen tragen einen Honigtopf mit Brief statt der Glocke (Web + Android)** —
reine Strichzeichnung wie die übrigen Symbole, damit sie die Akzentfarbe annimmt, wenn etwas
Ungelesenes anliegt. Im Web als Inline-SVG, in Android als Vektor-Drawable mit denselben
Konturen, an beiden Stellen (Kopfzeile und Kopf des Benachrichtigungsfensters).
- Bei kleiner Größe zählt jeder Strich: die erste Fassung hatte einen zu großen Brief und einen
geraden Topf, das verlief zu einer Fläche. Der Topf hat jetzt einen gewölbten Bauch und der
Brief sitzt kleiner in der Mitte — gerendert und in Originalgröße gegengesehen.
- **Nennmaß ist nicht gleich Wirkung.** Neben dem Zahnrad wirkte der Topf kleiner, obwohl er
mit 23 px nominal *größer* war. Nachgemessen: das Zahnrad füllt sein 24er-Feld vollständig aus,
der Topf nur 18,4 × 15,9 — also etwa zwei Drittel. Die Zeichnung wird jetzt auf Feldbreite
aufgezogen (Faktor 1,38) und steht bei 19 px; damit stimmen die sichtbaren Höhen überein
(17,3 gegen 18,0). Die Strichstärke ist entsprechend zurückgenommen, weil das Aufziehen sie
mitvergrößert. Dieselbe Korrektur im Android-Vektor, dort war die Schieflage dieselbe.
- **Der Rollbalken läuft nur noch über den Inhalt, nicht über die Suchleiste (Web)** — die
Kopfzeile mit Suche, Glocke und Zahnrad lag **im** Rollbereich und blieb per `sticky` oben
kleben. Der Balken gehört zum Rollbereich und reichte damit bis ganz nach oben, neben eine
Leiste, in der gar nichts rollt. Die Kopfzeile sitzt jetzt außerhalb: eine Spalte rechts der
Seitenleiste trägt oben die feste Kopfzeile und darunter den Rollbereich. Das Suchfeld samt
Ergebnisfenster ist dabei unverändert geblieben — mit jsdom gegengeprüft, **14 Fälle grün**.
- **Die Warteschlange hat einen Schließen-Knopf (Web)** — sie ließ sich nur über denselben Knopf
im Player wieder zumachen, mit dem man sie geöffnet hatte. Jetzt steht ein ✕ in ihrer Kopfzeile.
- **Der Rollbalken ist greifbar (Web)** — er war 4 px breit. Sichtbar reichte das, mit der Maus war
er kaum zu treffen: in einer langen Playlist kam man nicht ans Ende, ohne zu scrollen. Jetzt 12 px
**Greiffläche** mit einem schmalen sichtbaren Balken darin (durchsichtiger Rand plus
`background-clip:content-box`), heller beim Überfahren und beim Ziehen — wie in Spotify.
- `min-height:44px` hält ihn auch bei sehr langen Listen greifbar; bei 1900 Titeln schrumpfte er
sonst auf wenige Pixel, also genau dort, wo man ihn am nötigsten hat.
- **Firefox bekommt nur die Farbe.** Dessen Standardbalken ist ohnehin breit genug; ein
`scrollbar-width:thin` hätte ihn wieder verschmälert. Die Ausnahmen, die bewusst keinen Balken
zeigen (waagerechte Kartenreihen, Lyrics, Chips), bleiben unangetastet.
- **Rollbalken zum Ziehen (Android)** — Compose bringt keinen mit: eine `LazyColumn` zeigt nichts
an, egal wie lang sie ist. Bei einer Playlist mit 1900 Titeln bleibt nur Wischen. Jetzt steht am
rechten Rand ein Balken, der sich greifen und ziehen lässt — in Playlist, Album, Favoriten,
Genre-Titeln und der Offline-Ansicht.
- **Gerechnet wird in Einträgen, nicht in Pixeln.** Die Gesamthöhe einer `LazyColumn` ist
unbekannt, solange nicht alles gebaut wurde, und bei unterschiedlich hohen Zeilen auch nicht
ausrechenbar. Über den Anteil sichtbarer an allen Einträgen stimmt der Balken bei gleich hohen
Zeilen — und genau die haben Song- und Albumlisten.
- **Er bleibt dauerhaft sichtbar,** nur gedämpft (0,28), und wird beim Scrollen und Ziehen
kräftiger. Ein Balken, der erst beim Scrollen erscheint, lässt sich nicht greifen — und Greifen
ist hier der ganze Zweck. Wie im Web gibt es eine Mindesthöhe von 44 dp.
- Beim Ziehen läuft die Position als Kommazahl mit. Würde jeder Zeigerschritt aus
`firstVisibleItemIndex` neu rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal
verloren und der Balken bliebe bei langsamem Ziehen stehen.
- **Nachtrag 1.17: greifen ging zuerst gar nicht.** Zwei Fehler auf einmal. Die Greiffläche war
so schmal wie der sichtbare Balken (8 dp) — am Bildschirmrand trifft das mit dem Finger niemand,
Androids eigene Vorgabe für Bedienelemente liegt bei 48 dp. Und `detectVerticalDragGestures`
wartet die Wischschwelle ab, bevor es die Geste beansprucht; die `LazyColumn` startet ihr
eigenes Scrollen in genau diesem Moment und gewinnt jedes Mal. Jetzt sind sichtbar 6 dp und
anfassbar 28 dp, und der erste Fingerkontakt wird sofort verbraucht, bevor die Liste ihn sieht.
- Verbraucht wird nur, wenn der Finger **auf dem Daumen** aufsetzt (16 dp Spiel). Daneben bleibt
das Wischen am rechten Rand normales Scrollen — sonst käme dort plötzlich die umgekehrte
Richtung heraus, weil Balken ziehen und Inhalt wischen entgegengesetzt laufen.
- **Nachtrag 1.18: das Ziehen brach sofort wieder ab.** `pointerInput` war auf die Geometrie
geschlüsselt — unter anderem auf die Daumenposition, und die steht in jedem Bild anders da. Ein
geänderter Schlüssel setzt die Gestenerkennung neu auf: der Finger lag noch auf dem Schirm, die
Erkennung fing wieder bei null an. Je länger die Liste, desto öfter, deshalb war es bei 2000
Titeln unbrauchbar. Der Schlüssel ist jetzt `Unit`; die wechselnden Werte liest die Geste über
`rememberUpdatedState` zur Laufzeit nach.
- Gescrollt wird außerdem nicht mehr im Zeigerereignis, sondern in **einem** Effekt, der die
Zielposition verfolgt. Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte
konkurrierende Scroll-Aufrufe, die sich gegenseitig überholten.
- **Nachtrag 1.21: die Songzeilen halten rechts 24 dp frei.** Dort steht der Rollbalken, und wo
sein Daumen gerade liegt, wäre ein ⋮ darunter weiterhin nicht zu treffen gewesen — der Daumen
wandert ja mit. Die Zeilen rücken deshalb überall ein, auch dort, wo es gar keinen Balken gibt,
damit sie gleich aussehen.
- **Nachtrag 1.20: der Balken verschluckte das ⋮ der Songzeilen.** Compose reicht eine Berührung
nur an das **oberste** getroffene Element weiter — der Greifstreifen lag über die volle Höhe
am rechten Rand und nahm damit jeden Tipper auf das Kontextmenü entgegen, obwohl er ihn gar
nicht wollte. Jetzt hört nur noch der **Daumen** zu (mit 10 dp Luft darüber und darunter); der
Rahmen darum hat kein `pointerInput` und ist dadurch durchlässig. Der Streifen ist außerdem von
28 auf 24 dp geschrumpft.
- **„Neu von deinen Künstlern" sitzt jetzt in den Favoriten (Web)** — es stand links unter
*Entdecken*, in Android dagegen als Knopf „✨ Neu" in den Favoriten. Zwei Plattformen, zwei Orte
für dieselbe Sache. Das Web folgt jetzt Android: der Eintrag in der Seitenleiste ist weg, in der
Kopfzeile der Favoriten steht der Knopf — dort, wo auch die gefolgten Künstler stehen.
- Der Knopf erscheint nur, wenn man überhaupt jemandem folgt, genau wie in Android.
- Die Seite hat dafür einen Zurück-Knopf bekommen (vorher unnötig, weil sie über die Seitenleiste
erreichbar war) und zählt für die Seitenleiste jetzt zur Bibliothek statt zu Entdecken.
### Fixed
- **Künstler schlägt Dauer: die Kaskade sucht in anderer Reihenfolge (Backend)** — Nachtrag zum
Fehlgriff bei „Dearly Beloved" (TPR). Die Kette war vollständig nachvollziehbar:
- Deezer kennt **keinen** Künstler „TPR"; das Album läuft dort unter *Project Destati*, der Titel
heißt *Dearly Beloved I* und dauert **230 s**. Von dort kam die erwartete Dauer.
- Der richtige Upload dauert **136 s** und fiel damit aus dem Fenster 207–253 s. Eine Rockband,
die tatsächlich **DEARLY BELOVED** heißt, liegt mit ihrem Stück „LSD" bei **250 s** mitten
drin — und ihr **Bandname** erfüllte die Titelprüfung.
- Entschieden hat die Reihenfolge: die Stufe *Dauer behalten, Künstler fallen lassen* lief vor
der Stufe *Künstler behalten, Dauer lockern*. Die erste griff zu, die zweite kam nie dran.
- **Die beiden sind jetzt vertauscht.** Der Künstler ist das stärkere Merkmal: die erwartete
Dauer stammt aus Metadaten und gehört oft zu einer anderen Fassung, während Kanalname und
`artist`-Feld den Song selbst benennen. Es fällt **kein** Schritt weg — was vorher gefunden
wurde, wird weiter gefunden, gegebenenfalls eine Stufe später.
- **Belegt statt behauptet:** `backend/scripts/filter-probe.mjs` holt für bekannte Fälle die
echten Suchtreffer — **aus beiden Quellen, YT Music zuerst** — und spielt beide Reihenfolgen
durch. Fünf Fälle, **ein** Unterschied (der gemeldete), **null** Regressionen: BTS auf dem
Label-Kanal BANGTANTV, der japanische Soundtrack-Titel, Rising Insane und Daft Punk kommen
unverändert heraus.
- Die erste Fassung der Probe sah **nur** die gewöhnliche YouTube-Suche und meldete deshalb für
„Get Lucky" einen Grammy-Mitschnitt und für TPR ein Tutorial-Video. Beides war erfunden: über
YT Music steht dort an erster Stelle die offizielle Tonspur („Get Lucky (feat. Pharrell
Williams and Nile Rodgers)", 370 s) beziehungsweise TPRs echtes „Dearly Beloved" (138 s) — und
genau die lädt die Kaskade. Eine Probe, die eine Stufe auslässt, erfindet Fehler; deshalb fragt
sie jetzt beide Quellen ab.
- **Der Download-Filter konnte den falschen Titel speichern — zwei Löcher, ein Grund (Backend)** —
gemeldet mit „銀髪の少年" (Yoshihisa Hirano, HUNTER×HUNTER) und „Dearly Beloved" (TPR): beide Male
kam etwas anderes an.
- **Die Kaskadenschritte 3 und 4 lassen die Künstlerprüfung fallen** — bewusst, damit
Label-Kanäle durchkommen. Übrig bleibt dann Titel + Dauer. Bei „Dearly Beloved" reicht das
nicht: es gibt Rocksongs, die genau so heißen, und einer davon lag im Dauerfenster.
- **Für japanische Titel gab es überhaupt keine Titelprüfung.** `buildTitleKeywords` lieferte
dafür eine leere Liste (japanische Titel haben keine Wortgrenzen, und Art Tracks sind oft
romanisiert). Zusammen mit Schritt 3/4 blieb damit **allein die Dauer** übrig — und auf einem
Soundtrack-Album passt fast jedes andere Stück desselben Komponisten in dasselbe Fenster.
Genau das ist bei „銀髪の少年" passiert.
- **Behoben in drei Schritten:** japanische Titel bekommen ein Muster aus *Originaltitel oder
Romaji-Fassung* (Art Tracks tragen üblicherweise das eine oder das andere; Leerzeichen und
Bindestriche bleiben frei, weil Uploader sie unterschiedlich setzen). Ein Schritt **ohne**
Titelprüfung läuft jetzt immer **mit** Künstlerprüfung, egal was die Kaskade lockern wollte.
Und japanische Künstlernamen werden gegen das `artist`-Feld geprüft — der Kanalname steht kaum
je in Kanji, das Feld der Art Tracks dagegen schon; vorher hatten sie gar keine Prüfung.
- Bleibt am Ende weder ein Titel- noch ein Künstlermerkmal übrig, wird **gar nicht gesucht**:
ohne Merkmal landet sonst der erste Treffer passender Länge auf der Platte. Der Direktlink im
Importer ist dafür der vorgesehene Weg.
- **Die Umschrift ist nur *eine* Schreibweise.** Der Tokenizer liefert „Ginpatsu No Shounen",
Uploader schreiben „Ginpatsu no Shonen", setzen Bindestriche oder lassen den
Katakana-Längungsstrich weg (der bei „テーゼ" sogar unübersetzt stehenbleibt: „TeーZe"). Das
Muster lässt diese Abweichungen zu — langes o/u optional, Längungsstrich weg, Trennzeichen
frei. Sonst wäre die Alternative so streng, dass sie praktisch nie greift.
- Dieselbe Hilfe für **Kanji-Künstlernamen**: Kanäle und Videotitel schreiben sie meist
lateinisch, also zählt die Umschrift jetzt auch als Künstlermerkmal. Vorher blieb dort nur das
`artist`-Feld in Originalschrift.
- Sieben neue Prüfungen in `backend/test/ytdlp.test.js` (61 grün), darunter „anderes Stück
desselben Albums darf nicht passen" und die Schreibvarianten der Umschrift.
- **Das Mischen-Symbol zeigte nie an, dass es an ist (Android)** — und der Grund lag nicht in der
Anzeige. `toggleShuffle` baute den Rest der Warteschlange als Schleife aus `removeMediaItem` +
`addMediaItem` um; dazwischen hatte der Controller vorübergehend weniger Titel als unsere Liste.
Genau darauf reagiert `onTimelineChanged` mit „die Titel kamen von außen", baute die
Warteschlange neu auf — und setzte dabei `isManuallyShuffled` **zurück**. Gemischt wurde also,
aber der Zustand war eine Zehntelsekunde später wieder weg. Jetzt wird der Rest in **einem** Zug
ersetzt (`replaceMediaItems`), der Zwischenzustand entfällt.
- **Mischen und Wiederholen sind jetzt auch zu sehen (Android)** — ein eingefärbtes Symbol allein
reicht auf einem dunklen Cover nicht, das liest sich wie eine Schattierung. Ist der Schalter an,
liegt ein getönter Kreis darunter und ein Punkt darunter.
- **Der Rollbalken lief über die Player-Leiste (Web)** — sofort nach dem Verbreitern gemeldet. Der
Balken gehört zum Scrollbereich und läuft über dessen ganze Höhe, und `.main` reichte per
`padding-bottom` bis an den Fensterboden, also bis **unter** die Leiste. Bei 4 px fiel das nie
auf, bei 12 px sofort. Der Scrollbereich endet jetzt per `margin-bottom` über der Leiste — auf
dem Handy über Leiste **und** unterer Navigation. Die Platzreserve für den Inhalt, die das
`padding-bottom` war, ist damit überflüssig geworden und entfällt.
---
## [3.0] — 2026-09-19
**Android:** 1.13 (versionCode 16) → 1.15 (versionCode 18). 1.13 brachte Folgen, Zeitleiste und
den Gefolgt-Filter, 1.14 die begrenzte Reihe in den Favoriten, 1.15 die Knopfleiste im Album.
Erster Sprung auf eine neue Hauptversion seit 2.0: „Künstlern folgen" ist kein weiteres Fenster,
sondern ein neuer Umgang mit der Bibliothek — die App erzählt jetzt, was **draußen** neu ist, statt
nur zu zeigen, was drinsteht.
### Added
- **Künstlern folgen und Alben merken (Web + Android)** — Grundlage für die geplanten Meldungen zu
neuen Veröffentlichungen und Konzerten. „Folgen" ist **kein eigener Speicher**, sondern Navidromes
Stern auf dem Künstler: `star&id=<ndArtistId>`. Damit ist der Zustand sofort auf allen Geräten da,
überlebt Neuinstallationen und taucht sogar in Navidromes eigener Oberfläche auf.
- Möglich ist das, weil Navidrome `id`, `albumId` und `artistId` beim Markieren in **eine** Liste
wirft und gleich behandelt (`media_annotation.go`) — unser vorhandener Aufruf trug also ohne
Änderung. Den Zustand liefert `starred` aus `getArtist`/`getAlbum`.
- Das Herz sitzt **im Banner** des Künstlers, direkt neben dem ⚙ für den Pin — auf beiden
Plattformen gleich. Die Aktionsleiste darunter ist „was ich jetzt tue" (abspielen, mischen,
Konzerte); Folgen ist dagegen ein Zustand und gehört an den Künstler selbst. Beim Album bleibt
das Herz in der Aktionsleiste, dort gibt es kein Banner.
- **Übersicht, wem man folgt:** die Favoritenseite zeigt jetzt Abschnitte *Künstler, denen du
folgst*, *Gemerkte Alben* und *Songs*. Das kostet **keinen zusätzlichen Aufruf** — `getStarred2`
lieferte Künstler und Alben die ganze Zeit im selben Paket mit, wir haben sie nur weggeworfen.
- Beide Plattformen schalten **optimistisch** um und fallen bei einem Fehlschlag zurück — Android
über den vorhandenen `StarState`, der ohnehin über IDs arbeitet und deshalb unverändert taugt.
- Wer ohne Künstler-Pin folgt, bekommt einen Hinweis: ohne Pinnung läuft die Deezer-Zuordnung über
den Namen, und bei gleichnamigen Künstlern kämen dauerhaft die Neuheiten des Falschen.
- **Filter „❤ Gefolgt" auf der Künstlerseite (Web)** — die Künstlerseite hat jetzt zwei Chips
(*Alle* / *❤ Gefolgt (n)*) und ein Suchfeld. Damit ist die Liste, wem man folgt, dort, wo man
Künstler ohnehin sucht — statt nur als waagerechte Reihe unter den Favoriten. `getArtists` liefert
kein `starred` mit, die Markierungen kommen deshalb aus `getStarred2` und werden 30 s gehalten,
damit das Umschalten der Chips nicht jedes Mal nachfragt. Ein Herz-Klick verwirft diesen Zwischen-
speicher sofort — sonst zeigte der Filter nach dem Entfolgen noch eine halbe Minute den alten Stand.
- **Die Reihe der gefolgten Künstler ist begrenzt, mit „Alle anzeigen" (Web + Android)** — vorher
landeten **alle** gefolgten Künstler in der waagerechten Reihe der Favoriten. Bei 200 Gefolgten
sind das 200 Karten samt Bildern, von denen man vier sieht — und zum Durchsehen taugt eine endlose
Reihe ohnehin nicht. Am Ende steht jetzt eine Karte „Alle anzeigen (+n weitere)", die in die
Künstlerliste mit aktivem Gefolgt-Filter springt.
- **Im Web richtet sich die Anzahl nach der Fensterbreite** statt nach einer festen Zahl: es wird
gerechnet, wie viele Karten nebeneinander passen (Kartenbreite plus Abstand), minus eine, damit
„Alle anzeigen" nicht angeschnitten am Rand klebt. Untergrenze zwei. Bei einer Größenänderung
wird neu aufgeteilt — sonst bliebe die Zahl auf dem Stand des ersten Aufbaus.
- Android zeigt zehn und dann den Knopf; die `LazyRow` baut ohnehin nur Sichtbares, dort ging es
allein um die Durchsehbarkeit. Weil das `LibraryViewModel` am Navigationseintrag hängt und einen
Zielwechsel nicht überlebt, reicht `LibraryIntent` die Absicht einmalig durch.
- **Die Knopfleiste im Album sieht aus wie die der Playlist (Android)** — im Album trugen *Play*
und *Shuffle* noch Beschriftungen und teilten sich die Breite, während Herz und Download auf feste
46 dp gesetzt waren. Vier Knöpfe in einer Reihe lassen dafür keinen Platz: die Schrift wurde
abgeschnitten, und die Reihe war ungleich geteilt. Jetzt vier gleich breite reine Symbolknöpfe wie
im Playlist-Detail — Play gefüllt als Hauptaktion, der Rest umrandet und erst accent gefärbt, wenn
er aktiv ist (gemerkt, vollständig geladen). Die Symbole tragen jetzt Beschriftungen für die
Vorlesefunktion, vorher stand dort bei Play und Shuffle `null`.
- **Zeitfenster-Chips der Zeitleiste beginnen mit 90 Tagen (Android)** — das ist die Vorgabe, also
steht der aktive Chip jetzt vorn statt in der Mitte. Web war schon so.
- **Filter „❤ Gefolgt" im Künstler-Reiter (Android)** — zwei Chips über der Liste, gleiche Stelle
wie im Web. Bewusst ein Filter und kein eigener Reiter: die Bibliotheksleiste trägt schon
Playlists, Alben, Künstler und Offline. Die markierten IDs werden bei jedem Einschalten frisch
geholt, damit ein eben entfolgter Künstler nicht noch dasteht.
- **Zeitleiste „Neu von deinen Künstlern" (Android)** — erreichbar über „✨ Neu" in den Favoriten,
dort, wo auch die gefolgten Künstler stehen. Gleiche Darstellung wie im Web: nach Monat gruppiert,
neueste zuerst, Vorhandenes mit Haken statt Download-Knopf, Künstlername anklickbar. Der
Download-Knopf hängt am bekannten Weg samt Benachrichtigung über den Fortschritt.
- **Zeitleiste „Neu von deinen Künstlern" (Web)** — eigener Punkt in der Seitenleiste. Zeigt die
jüngsten Veröffentlichungen aller Künstler, denen du folgst: nach Monat gruppiert, neueste zuerst,
pro Eintrag Cover, Künstler, Art (Album/Single/EP), Titelzahl und Datum. Umschaltbar auf 30 Tage,
90 Tage oder ein Jahr.
- **Was du schon hast, ist markiert.** Verglichen wird über Künstler + Albumname (akzent- und
zeichenunabhängig) — Deezers Album-Id hat mit Navidromes nichts zu tun. Alles andere bekommt
direkt einen Laden-Knopf, der den vertrauten Download-Dialog öffnet.
- **Neuer Backend-Endpunkt `POST /api/artist-releases`** (`backend/lib/releases.js`). Die
Deezer-Zuordnung passiert dort, weil die Künstler-Pins ohnehin serverseitig liegen: der Client
schickt nur seine Navidrome-Künstler und muss über Deezer nichts wissen. Gepinnte Künstler nutzen
die feste ID, ungepinnte werden über den Namen gesucht — sortiert nach Fan-Zahl, weil Deezers
Suche nicht nach Bekanntheit ordnet. Künstler ohne Treffer kommen als `ohneZuordnung` zurück und
die Oberfläche rät zum Pinnen, statt still die Neuheiten des Falschen anzuzeigen.
- **Der Cache liegt im Backend, nicht im Client.** Bei 30 gefolgten Künstlern wären das sonst 30
Deezer-Aufrufe pro Gerät und App-Start. 6 h für Alben, 24 h für die Namenszuordnung, höchstens
vier Anfragen gleichzeitig; bei einem Deezer-Fehler wird der abgelaufene Stand ausgeliefert
statt einer leeren Liste.
### Fixed
- **Favoriten überleben ein Umbenennen (Web)** — Umbenennen und Verschieben geben dem Song beim
nächsten Scan eine **neue** Navidrome-ID. Für Playlists wurde die Zugehörigkeit längst umgehängt,
für den Stern nicht: wer einen markierten Song bearbeitete, verlor die Markierung stillschweigend.
Der Zustand wird jetzt vor dem Schreiben gelesen (`starred` aus `getSong`) und nach dem Wiederfinden
auf die neue ID gesetzt. Schlägt das fehl, steht es als Warnung im Fenster statt unbemerkt zu
verschwinden.
**Grenze:** der Stern liegt in Navidrome **pro Nutzer**. Übertragen lässt sich nur der des
bearbeitenden Nutzers — fremde Markierungen sind über die Subsonic-API weder lesbar noch setzbar.
- **Dubletten lassen sich im Editor selbst auflösen (Web)** — wollte das Speichern die Datei in den
Ordner der neuen Tags verschieben und lag dort schon eine gleichnamige, blieb sie liegen und der
Hinweis schickte einen auf die Konsole. Derselbe Song lag danach doppelt in der Bibliothek — genau
das, was die ganze Dateizuordnung durcheinanderbringt.
- Das Fenster stellt jetzt **beide Kopien gegenüber** (Pfad, Künstler, Titel, Länge, Größe) und
sagt, ob es dieselbe Aufnahme ist: gleiche Länge (± 2 s) **und** gleicher Titel-Tag. Darunter
steht ein Knopf, der die überzählige löscht — mit derselben Rückfrage wie beim Song-Löschen.
- **Neuer Endpunkt `POST /api/resolve-duplicate`.** Er nimmt Pfade entgegen und prüft deshalb
strenger als das Löschen per ID: beide müssen im Musikordner liegen, Audiodateien sein und
existieren, und dieselbe Länge und denselben Titel tragen. Sonst 409 und es wird nichts gelöscht.
Geprüft mit echten Dateien: sechs Fälle, von „fehlende Felder" über einen Pfad außerhalb des
Musikordners bis zum anderen Song mit ähnlichem Namen — nur die echte Dublette fiel.
- **Ein „/" im Künstler- oder Albumnamen führte den Metadaten-Editor auf die falsche Datei** —
beim Bearbeiten von „K/DA – VILLAIN" meldete das Backend, die gefundene Datei enthalte
„Bad Omens – Like A Villain". Zwei Fehler trafen aufeinander:
- **Der gemeldete Pfad kann es gar nicht geben.** Navidrome baut ihn aus den Tags
(`fakePath()`): `{AlbumArtist}/{Album}/{DD}-{TT} - {Titel}`. Steht ein Schrägstrich **im**
Namen, wird daraus eine zusätzliche Verzeichnisebene — „K/DA/ALL OUT/…" statt des Ordners
`K_DA`, den unser `sanitize()` beim Herunterladen angelegt hat. Dasselbe bei „POP/STARS" →
`POP_STARS`. Der Pfad zeigt also ins Leere, und die Suche fällt auf Ähnlichkeit zurück.
- **Die Ähnlichkeitssuche war zu großzügig.** Sie nahm jede Datei, deren Stamm auf den Titel
**endet** — und „bad omens - like a villain" endet nun einmal auf „villain". Getroffen wird
jetzt nur noch ein vollständiges Feld des Dateinamens: der ganze Stamm, oder der Teil hinter
einem „ - ". Die Endsilben-Regel ist ersatzlos weg.
- Neu davor: der Ordner wird **aus den Tags nachgebaut**, genau so wie der Downloader ihn
angelegt hätte (Schrägstrich → `_`, erster Künstler), und zuerst dort gesucht. Das ist gezielt
statt geraten und macht die Ähnlichkeitssuche für diesen Fall überflüssig.
- Dass der Fehler überhaupt sichtbar wurde, lag am Tag-Abgleich aus 2.3: er hat den Fehlgriff
erkannt und abgebrochen, statt die falsche Datei zu bearbeiten.
- **Das Löschen hatte diesen Abgleich nicht** — es nahm den Treffer der Ähnlichkeitssuche ohne
Gegenprobe und löschte. Bei „VILLAIN" hätte es die Datei von Bad Omens erwischt, unwiderruflich
und ohne Hinweis. Jetzt prüft auch `/api/delete-song` die Tags der gefundenen Datei gegen das,
was Navidrome für diesen Song meldet, und bricht im Zweifel mit 409 ab. Als letzten Suchschritt
bekommt es zusätzlich `findExistingTrack` — der Weg, der bei „K/DA" von sich aus richtig liegt,
weil er `/` und `_` beim Vergleich gleich behandelt.
- Die Suchhelfer liegen jetzt in [metadata.js](backend/lib/metadata.js) statt im Server, damit sie
geprüft werden können: sieben neue Fälle in `backend/test/metadata.test.js` (49 Prüfungen grün).
### Changed
- **Nur noch ein Künstler im Dateinamen (Backend + Editor)** — bisher stand dort die vollständige
Gästeliste: `01 - K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine,League of Legends - MORE.mp3`.
Üblich ist das nirgends — Picard, beets und Lidarr schreiben höchstens **einen** Künstler in den
Namen —, und es hatte zwei handfeste Nachteile: `sanitize()` schneidet bei 100 Zeichen hart ab,
also mitten im letzten Namen, und mit dem Dateisystemlimit von 255 Byte wird es bei mehrbytigen
Namen eng, was Umbenennen und Verschieben scheitern lässt. Jetzt `01 - K_DA - MORE.mp3`.
- Genommen wird der **erste Künstler des Titels**, nicht der des Albums — so trägt ein
Soundtrack-Titel im Namen weiterhin seinen eigenen Interpreten, auch wenn der Ordner am
Album-Künstler hängt. Die vollständige Liste steht unverändert im `artist`-Tag, und nur den
liest Navidrome.
- Gilt für **neue** Downloads und für Dateien, die bearbeitet werden; Bestehendes bleibt liegen.
Beide Namensformen stehen also eine Weile nebeneinander. Die Dublettenprüfung erkennt beide —
eigener Testfall, damit die alten Dateien nicht ein zweites Mal geladen werden.
- **Künstlerbilder laden endlich zügig (Web)** — im Künstlerraster lief **pro Künstler ein eigener
`getArtistInfo2`-Aufruf**, nur um die Bildadresse zu erfahren. Bei 200 Künstlern sind das 200
API-Aufrufe auf einen Schlag, die der Browser zu sechst abarbeitet (mehr Verbindungen erlaubt er
pro Host nicht) — und jeder davon konnte Navidrome zu einer **externen** Abfrage bei Last.fm
zwingen. Dass manche Bilder gar nicht kamen, lag an derselben Quelle: Last.fm liefert für viele
Künstler nur noch einen Platzhalter, den der Code als „kein Bild" erkennt.
Dabei liefert `getArtists` das Bild längst mit: `toArtistID3` setzt `coverArt`, und Navidrome löst
Künstlerbilder selbst auf (`ArtistArtPriority` = `artist.*, album/artist.*, external`) und legt sie
im eigenen Cache ab. Jetzt geht pro Künstler nur noch das Bild selbst über die Leitung, kein
API-Aufruf davor; ist `coverArt` leer, hat der Künstler kein Bild und wir sparen uns den Versuch.
Gemessen im Test: **0 statt 120 Zusatzaufrufe** bei 120 Künstlern. Gleiches gilt jetzt für die
Künstlerkarten in der lokalen Suche und die gefolgten Künstler in den Favoriten.
**Android war nie betroffen** — dort lief es immer schon über `coverArt`.
- **Top Songs fragen jetzt über die Künstler-ID (Web + Android)** — Navidrome 0.64 nimmt bei
`getTopSongs` zusätzlich `id`. Bisher lief die Abfrage nur über den **Namen**, und der Namensweg
sucht mit `LIKE` und `Max: 1` (`core/external/provider.go`): bei zwei gleichnamigen Künstlern in
der Bibliothek gewann schlicht der erste Treffer. Wir schicken jetzt `id` **und** `artist` — ältere
Navidrome-Versionen ignorieren `id`, neuere fallen bei unbekannter ID von selbst auf den Namen
zurück. In Erkunden bleibt es bewusst beim Namen: die dortige `artistId` ist **Deezers**, nicht
Navidromes.
---
## [2.6] — 2026-09-12
**Android:** 1.9 (versionCode 12) → 1.12 (versionCode 15). 1.9 brachte den Besitzer geteilter
Playlists und die Offline-Korrekturen, 1.10 den Zählfehler im Downloader, 1.11 den WLAN-Schalter,
1.12 das vollständige Löschen.
### Fixed
- **Zurück und Vorwärts gingen im installierten Fenster am PC gar nicht (Web)** — im Browser-Tab
funktionierten sie, im PWA-Fenster nicht. Das Manifest setzt `"display": "standalone"`: dort gibt
es keine Zurück-/Vorwärts-Knöpfe, und die Seitentasten der Maus werden nicht auf den Verlauf
umgelenkt. Die App übernimmt die Maustasten X1/X2 jetzt selbst — **nur** im Standalone-Fenster,
denn im Tab macht das der Browser und wir würden doppelt navigieren (beides mitgetestet).
Zurück geht über denselben Weg wie der In-App-Knopf, schließt also erst ein offenes
Now-Playing-Fenster. `Alt`+`←`/`→` bleibt dem Browser überlassen, das kann er auch im Fenster.
- **Zwei Node-Dienste hießen in Navidromes Player-Liste fast gleich** — das Backend meldete sich als
`novasonic`, der Scrobble-Dienst als `NovaSonic`; da Navidrome den Namen als `client [User-Agent]`
bildet, standen dort zwei Zeilen `novasonic [node]` und `NovaSonic [node]`, unterscheidbar nur an
einem Großbuchstaben. Genau die Zeile, an der „Report Real Path" hängt, war so nicht auffindbar.
Jetzt `NovaSonic-Backend` und `NovaSonic-Scrobble`. Navidrome legt dafür **neue** Player-Zeilen an;
die alten können weg. Eine Player-Zeile hält nur Client, User-Agent, IP, Transcoding, Bitratengrenze
und die beiden Schalter — keine Wiedergabezahlen, keine Historie. Löschen ist also gefahrlos, und
die neuen Zeilen erben `ND_SUBSONIC_DEFAULTREPORTREALPATH`.
- **„Pfad (Navidrome)" im Info-Fenster wieder zu „Pfad"** — der Zusatz war doppelt gemoppelt: der
Wert kommt ohnehin aus der Subsonic-API. Was er bedeutet, steht als Kommentar im Code und in den
Known Bugs, nicht als Etikett im Fenster.
- **Der Zurück-Knopf tat oft nichts (Web)** — `navigate()` prüfte zwar, ob die Ansicht sich
überhaupt geändert hat, und ließ den Verlaufseintrag dann weg — **rief `history.pushState` aber
trotzdem**. Zweimal auf denselben Tab geklickt hieß also zwei Browser-Einträge auf dieselbe
Ansicht, und jeder davon kostete einen Zurück-Druck, bei dem sichtbar nichts passierte. Gemessen
im Test: dreimal „Alben" erzeugte **drei** Browser-Einträge bei **einem** Stack-Eintrag, danach
bewegten vier Zurück-Drücke die Ansicht genau einmal. Jetzt bekommt nur eine wirklich neue Ansicht
einen Verlaufseintrag, sonst wird der bestehende ersetzt. Der allererste Aufruf ersetzt ebenfalls,
weil `main.js` die schon offene Seite bereits auf `nsIdx 0` gesetzt hatte — auch das war ein
toter Zurück-Druck gleich zu Beginn. Vorwärts funktioniert unverändert (mitgetestet).
### Fixed
- **Offline-Downloads löschen ließ Dateien zurück (Android)** — „komplett löschen und neu laden" ist
der naheliegende Ausweg, wenn eine Gruppe durcheinandergeraten ist; dafür muss Löschen aber wirklich
alles erwischen. Es fehlte an drei Stellen:
- **Cover blieben liegen.** Entfernt wurde nur die Audiodatei. Bei einer Playlist über viele Alben
sammeln sich so hunderte JPEGs an. Jetzt werden sie mitgelöscht — aber nur, wenn kein anderer
Titel dasselbe Cover mehr benutzt, denn Cover werden geteilt gespeichert.
- **Unfertige Titel behielten ihre Datei.** Der Pfad kam aus der Karte der *fertigen* Titel; ein
abgebrochener oder fehlgeschlagener Download tauchte darin nicht auf und blieb unberührt.
Der Pfad kommt jetzt aus der Datenbankzeile selbst, dazu die angefangene `.tmp`.
- **Laufende Downloadarbeit lief weiter.** Nach dem Löschen lud der Worker noch minutenlang ins
Leere; jetzt wird die Arbeit der Gruppe abgebrochen.
- **Ein zu hoch gezählter Fortschritt konnte sich nie mehr korrigieren (Android)** — Gruppen aus der
Zeit vor 1.10 tragen womöglich einen Wert über `totalTracks` und gelten damit als „Vollständig":
der Herunterladen-Knopf ist unantastbar, und das Fortsetzen beim Start sucht nur Gruppen *unter*
dem Sollwert — die Gruppe fiel also durch jedes Raster. Beim App-Start wird der Fortschritt aller
Gruppen jetzt einmal aus dem tatsächlichen Zustand der Titel neu berechnet (eine SQL-Anweisung),
danach greifen Knopf und Fortsetzen wieder.
- **Halb geladene Gruppen meldeten sich als „Vollständig" (Android)** — der Worker startete seinen
Zähler bei `group.downloadedTracks` und zählte dieselben fertigen Titel beim Überspringen **gleich
noch einmal**: nach einem Fortsetzen mit 412 fertigen Titeln meldete die Gruppe 824. Überschreitet
der Wert `totalTracks`, gilt sie als vollständig — der Fortschrittsbalken verschwindet und der
Herunterladen-Knopf wird unantastbar, obwohl Titel fehlen. Der Fehler steckte schon länger drin,
fiel aber kaum auf, solange Fortsetzen nur von Hand passierte; mit der automatischen Wiederholung
hätte er bei jedem Lauf zugeschlagen. Der Zähler beginnt jetzt bei 0 und zählt jeden fertigen Titel
genau einmal.
- Nebenbei: beim Überspringen wurde der Fortschritt in die Datenbank geschrieben, obwohl er sich
nicht ändert — 1900 überflüssige Schreibzugriffe je Lauf. Geschrieben wird jetzt nur nach einem
tatsächlichen Versuch und einmal beim Abbruch.
- **Große Offline-Downloads liefen nie zu Ende (Android)** — WorkManager räumt einem
Hintergrund-Worker rund **10 Minuten** ein und stoppt ihn dann. Ein Vordergrunddienst ist keine
Option, `FOREGROUND_SERVICE_TYPE_DATA_SYNC` bringt die App unter Android 15 zum Absturz. Der
Downloader prüfte aber nie `isStopped`, lief nach dem Stoppsignal blind weiter bis zum harten
Abbruch, und meldete am Ende `Result.failure()` statt `Result.retry()` — ohne Backoff. WorkManager
startete deshalb **nichts** von allein neu: bei 1900 Titeln musste man immer wieder von Hand auf
Herunterladen tippen. Jetzt:
- die Schleife steigt bei `isStopped` sauber aus und fordert den nächsten Lauf an (`Result.retry()`,
linearer Backoff 30 s). Fertige Titel überspringt sie ohnehin, es wird also fortgesetzt statt neu
geladen.
- bleiben danach noch Titel offen, fordert der Worker selbst weitere Läufe an — begrenzt auf 20
Versuche, damit dauerhaft kaputte Titel (auf dem Server gelöscht, immer 404) keine Endlosschleife
auslösen. Danach steht in der Gruppenzeile weiter `x/y Titel`, und ein Tipp startet die Kette neu.
- ein einzelner Netzfehler beendete bisher die **ganze** Kette endgültig (`Result.failure()` im
äußeren `catch`); jetzt wird auch das wiederholt.
- beim App-Start werden unvollständige Gruppen weitergeführt (`setzeUnvollstaendigeFort()`), falls
die Kette doch einmal reißt — Geräteneustart, aufgebrauchte Versuche.
- Nebenbei: die Zahl der offenen Titel kam aus einer Abfrage **pro Titel** am Ende jedes Laufs
(1900 Stück); jetzt eine einzige `COUNT`-Abfrage mit JOIN.
- **Große Offline-Playlists ließen sich nicht abspielen (Android)** — bei ~100 Titeln ging alles, bei
1900 nicht. Ursache war nicht die Größe an sich: die Warteschlange wurde aus **allen** Titeln der
Gruppe gebaut, ohne Rücksicht auf den Download-Zustand. Die Karte, aus der die lokale Datei kommt,
kennt dagegen nur fertige (`WHERE state = 'DONE'`) — für wartende oder fehlgeschlagene Titel
lieferte `streamUrl()` deshalb die **Netz-Adresse**, und die tut offline nichts. Bei 100 Titeln ist
der Download längst durch, bei 1900 praktisch nie; liegt so ein Titel vorn, scheitert die
Wiedergabe sofort und sieht aus, als ginge die ganze Playlist nicht. Play und Shuffle nehmen jetzt
nur fertige Titel, ein Hinweis nennt die Zahl der übersprungenen. Die Liste zeigt weiterhin alle,
damit man sieht, was noch fehlt; ein Tipp auf einen unfertigen Titel sagt das auch.
- Verworfen: die naheliegende Vermutung, Media3 stoße bei 1900 Einträgen an die 1-MB-Grenze einer
Binder-Transaktion. Tut es nicht — `MediaControllerImplBase` reicht die Liste als
`BundleListRetriever` durch, den die Session seitenweise abholt. Die Größe ist unschuldig.
### Added
- **„Nur über WLAN herunterladen" (Android)** — die Downloadarbeit lief bisher mit
`NetworkType.CONNECTED`, also über **jede** Verbindung. Das war selbstbegrenzend, solange nach zehn
Minuten Schluss war; mit der automatischen Wiederholung hätte sich eine Playlist unterwegs
ununterbrochen weitergeladen — bei 1900 Titeln grob 9–10 GB über Mobilfunk. Neuer Schalter in den
Einstellungen unter **📥 Offline-Downloads**, **standardmäßig an**: dann gilt `UNMETERED`, der
Download pausiert ohne WLAN und läuft von selbst weiter, sobald es zurück ist. Abschalten geht nur
über eine Rückfrage, die das Datenvolumen ausdrücklich benennt.
- Beim Speichern werden laufende Gruppen neu eingereiht (`REPLACE`): WorkManager ändert die
Bedingungen einer bereits eingereihten Arbeit nicht mehr, der Schalter hätte sonst erst beim
nächsten App-Start gewirkt. Unbedenklich, weil der Worker fortsetzt statt neu zu laden.
- **Bei geteilten Playlists steht jetzt dabei, von wem sie sind (Web + Android)** — Navidrome
liefert `owner` in `getPlaylists`/`getPlaylist` mit, im Web wurde das Feld nie benutzt. Übersicht
und Detailseite zeigen es als `· von <Name>`, und zwar **nur bei fremden** Listen — bei den
eigenen wäre „von mir" nur Rauschen. Android zeigte den Besitzer auf der Detailseite bisher bei
**jeder** Liste und unbeschriftet, sodass der eigene Name wie ein Teil des Titels aussah; jetzt
verhalten sich beide Plattformen gleich, und die Playlist-Übersicht in der Bibliothek zeigt es
ebenfalls.
---
## [2.5] — 2026-09-10
**Android:** 1.8 (versionCode 11) — Sprung vom Album zum Künstler.
### Added
- **Vom Album zum Künstler springen (Web + Android)** — in der globalen Suche führte ein Album
bisher in eine Sackgasse: der Künstlername darunter war reiner Text. Wer von dort zum Künstler
wollte, musste zurück und neu suchen. Der Name ist jetzt anklickbar und öffnet dieselbe
Deezer-Künstlerseite wie eine Künstler-Kachel. Betrifft im Web die Inline-Albumansicht in der
globalen Suche und die Vollbild-Albumseite, in Android die Album-Detailansicht in Erkunden —
dort stand vorher **gar kein** Künstler, nur der Albumtitel.
- **Kostet keinen zusätzlichen API-Aufruf.** Deezer legt `/search/album` und `/album/{id}` das
vollständige `artist`-Objekt samt `id` bei (in 120 von 120 Treffern über zwölf Suchen geprüft);
beide Mappings (`_dzAlbum` im Web, `DzAlbum.toItunesItem()` in Android) übernahmen die id schon,
sie wurde nur nirgends benutzt.
- **Ausnahme mit Absicht:** `/artist/{id}/albums` lässt das `artist`-Objekt weg. Alben aus dieser
Liste haben keine id, dort bleibt der Name deshalb reiner Text statt ins Leere zu zeigen — man
kommt ja gerade von der Künstlerseite. Ein Ersatz-Lookup wäre ein Aufruf für einen Sprung, den
niemand macht.
- Deezer nennt nur den **Haupt**künstler: bei „A feat. B" landet man auf A.
- Android lädt Bild und Fanzahl nach, wenn der Künstler aus einem Album konstruiert wurde — sonst
sähe die Kopfzeile anders aus als beim Weg über die Künstlersuche.
- **Der Künstler in den Albumansichten ist überhaupt erst zu sehen (Web)** — er stand in `--text2`
(`#8888aa`) und 14px unter einem 28px starken Albumtitel und ging darin schlicht unter; dass man
ihn anklicken kann, war nirgends erkennbar. Jetzt hell (`--text`), 15px und halbfett, und wo
wirklich ein Ziel dahintersteht, zusätzlich dezent unterstrichen (beim Überfahren in Akzentfarbe).
Gilt für die lokale Albumseite, die Deezer-Albumseite und die Inline-Albumansicht in der globalen
Suche. Die Teil-Album-Seite bleibt bewusst ohne Link — dorthin kommt man nur von der Künstlerseite.
- Nebenbei behoben: `.album-artist-name` trug `cursor:pointer` unabhängig davon, ob der Name
irgendwohin führte — der Zeiger versprach auf der Deezer- und der Teil-Album-Seite einen Klick,
den es nicht gab. Jetzt nur noch dort, wo wirklich ein Ziel dahintersteht.
---
## [2.4] — 2026-09-06
**Android:** 1.4 (versionCode 7) → 1.7 (versionCode 10). 1.4 brachte Warteschlangen-Wiederherstellung
und die Streitpunkte im Duell, 1.6 machte das Wiederherstellen rein lokal und damit sofort, 1.7 leert
den Cache nach einem Update selbst.
### Added
- **Cache räumt sich nach einem Update selbst auf (Android)** — im `cacheDir`
liegen Coils Bildspeicher (Cover) und heruntergeladene APK-Updates. Blieb dort nach einer neuen
Version etwas Altes liegen, half nur „Cache leeren" von Hand in den Systemeinstellungen — nichts,
das man einem normalen Nutzer erklären möchte. Beim ersten Start nach einem Versionswechsel wird
der Cache jetzt einmalig geleert; im laufenden Betrieb passiert nichts, sonst wären die Cover bei
jedem Start neu zu laden. **Offline-Downloads sind nicht betroffen**, die liegen unter
`filesDir/offline` und damit außerhalb des Caches.
- **Service-Worker-Cache versioniert sich beim Bauen selbst** — die Cache-Version stand handgepflegt
als `ns-v78` in `sw.js` und wurde beim Ausliefern zuverlässig vergessen. Da die Aktivierung nur
Caches mit *abweichendem* Namen löscht, blieb der alte bestehen und Nutzer mussten den Browsercache
von Hand leeren. Ein Vite-Plugin setzt die Version jetzt aus der Bauzeit (`ns-JJJJMMTThhmmss`), der
alte Cache räumt sich beim ersten Start selbst ab. Die Quelldatei bleibt unverändert.
- **Zuletzt Gehörtes und Warteschlange überleben den Neustart (Web + Android)** — Android sicherte
bisher nur den letzten Song, ohne Warteschlange: die Now-Playing-Leiste blieb nach dem Öffnen
**leer, bis man einmal auf Play drückte**. Jetzt merken sich beide Plattformen Warteschlange und
aktuellen Titel und stellen sie beim Start wieder her — Android **vorbereitet, aber nicht
abgespielt**, damit einen die App beim Öffnen nicht überfällt; läuft schon etwas, passiert nichts.
Der Titel beginnt von vorn: die Sekunde im Titel wird bewusst **nicht** gespeichert, das spart
dauerndes Schreiben während der Wiedergabe.
- Der erste Anlauf (1.4) sicherte nur Song-Ids und lud sie beim Start **einzeln nacheinander über
das Netz** nach: das dauerte Sekunden, klappte mal und mal nicht und ohne Verbindung gar nicht.
Seit 1.6 liegen die vollständigen Songs als JSON im Gerätespeicher, das Wiederherstellen ist ein
rein lokaler Lesevorgang wie im Web. Preis: zwischenzeitlich geänderte Titel oder Cover zeigen
bis zum nächsten Abspielen den alten Stand.
- `MusicService.onTaskRemoved` sichert die Warteschlange auch, wenn die App aus den letzten Apps
gewischt wird — dabei feuert `onIsPlayingChanged` nicht zuverlässig, genau dieser Fall ging
vorher verloren. Protokoll unter dem Tag `NsQueue` (`adb logcat -s NsQueue`).
- Hinweis fürs Testen: der gespeicherte Zustand entsteht erst ab dieser Version, direkt nach der
Installation ist die Leiste also noch leer — einmal abspielen, App schließen, wieder öffnen.
- **Streitpunkte im Musik-Duell (Web + Android)** — gemeinsame Künstler, sortiert nach dem größten
**Ungleichgewicht** statt nach der Summe: wo einer 120 Plays hat und der andere 3. Ein Balken
zeigt das Kräfteverhältnis. Mindestens vier gemeinsame Plays und 50 % Unwucht, damit ein einzelner
Play gegen null nicht als Streit gilt. Beide Plattformen nutzen dieselben Schwellen.
---
## [2.3] — 2026-09-04
**Android:** versionCode 3 → 1.3 (versionCode 6). versionCode 3 lieferte den Alben-Tab-Fix aus und
erstmals das Scroll- und Suchgedächtnis der Künstlerseite (2.2 enthielt den Code, das damalige APK
war älter); 1.1 brachte Teilen-Link, Songlöschen, Playlist aus dem Import und die Duell-Anzeigetafel;
1.2 und 1.3 nahmen die Adressen aus den Einstellungen in den Build. Alle mit demselben Debug-Schlüssel
signiert, Updates laufen also in-place und App-Daten bleiben.
### Fixed
- **YT-Music-Suche war praktisch tot** — `getYtmVideoUrls` verwarf die komplette Trefferliste, sobald
irgendwo ein Kanal-Eintrag (`browse/UC…`) auftauchte, samt der bereits gesammelten Song-URLs. An
acht echten Suchen gemessen passierte das in **sieben** Fällen: Schritt 1 der Kaskade — die Stufe
mit den offiziellen Art Tracks — lieferte nie etwas, faktisch lief jeder Download über den
YouTube-Fallback. Das erklärt eine ganze Serie falscher Treffer (u. a. „Easy" als Mixtape- statt
Albumversion). Kanal- und Album-Einträge werden jetzt übersprungen statt abzubrechen; über 20 000
Zufallsausgaben geprüft ist die neue Auswahl eine Obermenge der alten, kann also nur Kandidaten
hinzufügen.
- **Zurück im Album führte in die Albenliste** — statt dorthin, wo man das Album angeklickt hatte.
Der Knopf navigierte vorwärts, legte dabei einen History-Eintrag an und brachte so auch den
Browser-Zurück-Knopf durcheinander. Jetzt geht er einen Schritt im Verlauf zurück wie jede andere
Detailseite.
- **Metadaten bearbeiten zerlegte die Hörstatistik** — Top-Titel gruppierten nach `song_id`, und beim
Bearbeiten umbenannte Dateien bekommen von Navidrome eine neue ID: der Titel erschien doppelt mit
geteilten Zählern, Künstler ebenso bei geänderter Schreibweise. Gruppiert wird jetzt über einen
normalisierten Schlüssel (Titel + Künstler), Anzeigenamen kommen aus dem jüngsten Play. Playlists
waren nie betroffen, die werden beim Bearbeiten ohnehin auf die neue ID übertragen.
- **Downloader grabbed a same-shaped title with a different number** — searching DAT ADAM's
"700 Main Street" saved **"777 Main Street" by an unrelated artist**. `buildTitleKeywords` required
only the two longest words (Street, Main); "700" was dropped for being short, and the cascade step
that widens to the top 10 no longer checks the artist channel, so nothing else caught it. Numbers
are now always required keywords. In the same pass, a small abbreviation map (street/st, saint/st,
mount/mt, doctor/dr, versus/vs, number/no) lets a candidate spelled "700 Main St" match a release
titled "700 Main Street" — that mismatch had rejected the correct official Art Track in every step,
which is why the search fell through to the wrong video in the first place. A re-release year next
to a qualifier ("… - 2020 Remaster") is exempt from the number rule: it appears on the release but
never in the YouTube title. Measured against a 29-title sample, 24 titles keep their previous
behaviour exactly.
- **Downloader saved a concert recording as the album track** — searching DAT ADAM's "UFO" stored
"Dat Adam - Ufo Video Days 2015 Live". It passed every gate legitimately: right channel, 209 s
inside the duration window, "Ufo" in the title, and `!is_live` rejects only *running streams*, not
live recordings. The variant blocklist (remix/cover/acoustic/…) now includes `live`. As with the
other rules it switches itself off when the searched title says live, so "Live Forever" and
deliberately requested live versions are unaffected.
- **Album-Position im Web wurde doch nicht wiederhergestellt** — die gespeicherte Scrollposition wurde
in genau einem `requestAnimationFrame` gesetzt. Solange Cover und Listeneinträge noch nachluden, war
`scrollHeight` kleiner als das Ziel, und der Browser kappte den Wert stillschweigend: man landete
wieder oben. Der Wert wird jetzt über bis zu ~0,5 s erneut gesetzt, bis er sitzt.
- **Metadaten-Änderungen „sprangen zurück" — bei manchen Songs immer, bei anderen nie.** Findet das
Backend die von Navidrome gemeldete Datei nicht am angegebenen Pfad (Navidrome liefert oft einen
aus Tags gebauten *virtuellen* Pfad), sucht es über Dateiname bzw. Künstler+Titel weiter. Liegt
derselbe Song mehrfach auf der Platte — etwa ein Fehlgriff des Downloaders neben der richtigen
Datei —, konnte das die **falsche Kopie** treffen: die wurde getaggt, der bearbeitete Song blieb
unverändert. Genau deshalb ging es bei Songs ohne Dublette immer und bei anderen nie. Der über
Ähnlichkeit gefundene Kandidat wird jetzt gegen die Tags geprüft, die Navidrome für den Song
meldet, und bei Widerspruch mit einer erklärenden Meldung abgebrochen, statt zu raten.
- **Dieselbe Playlist zweimal geladen ⇒ Songs doppelt auf der Platte.** Die Dublettenprüfung
verglich Künstlernamen zeichengenau, sodass „Cécile Corbel" und „Cecile Corbel" als zwei
verschiedene Personen galten — welche Schreibweise ankommt, hängt davon ab, ob die Quelle Deezer
oder Spotify war. Der Vergleich ignoriert jetzt Akzente (nur der **Vergleich** — Tags und
Dateinamen behalten die Originalschreibweise) und ebenso typografische Apostrophe, an denen
`Arrietty’s Song` und `Arrietty's Song` bisher auseinanderfielen.
- **Soundtracks landeten doppelt** — die Prüfung durchsucht nur den Ordner des Album-Künstlers.
Bei Soundtracks und Compilations weicht der vom Titel-Künstler ab: als Album geladen landet der
Song unter `Arcane/`, einzeln geladen unter `Bea Miller/` — die Prüfung sah jeweils im falschen
Ordner nach. Weicht der Titel-Künstler ab, wird jetzt auch dessen Ordner geprüft. Gemessen an
28.801 Dateien kostet der zweite Durchlauf ~0,6 ms, gegenüber 3–9 s pro Download.
- **Stumm übersprungene Umbenennungen** — lag am Zielnamen schon eine Datei, blieb der Song unter
altem Namen bzw. im alten Ordner liegen, ohne Hinweis. Solche Fälle kommen jetzt als Warnung
zurück; der Editor zeigt sie samt bearbeitetem Dateipfad an und bleibt offen.
### Added
- **Musik-Match wird zum Duell (Web + Android)** — statt eines Prozentrings zeigt die Ansicht jetzt
eine Anzeigetafel mit fünf Kategorien, die je einen Sieger haben: Hörzeit, Vielfalt, Tiefe
(Plays je Künstler), Nachteule (Anteil vor 5 Uhr) und Entdeckerdrang (Anteil exklusiver Künstler).
Bewusst Kennzahlen zur *Hörweise* statt zur schieren Menge, sonst gewinnt immer, wer länger dabei
ist. Unentschieden zählt für niemanden.
- **Vergleichsansicht im Web zerfiel am Desktop** — sie lebte ausschließlich von Inline-Stilen mit
festen Flex-Verhältnissen. Jetzt ein echtes Raster (eine Spalte am Handy, zwei ab 780 px) mit
eigenen CSS-Klassen; das Fenster ist von 720 auf 940 px verbreitert, sonst hätte die zweite Spalte
nie ausgelöst.
- **Gemeinsame Songs wurden über `song_id` abgeglichen** — hatten beide denselben Song aus
verschiedenen Dateien gehört oder wurde eine Datei zwischendurch umbenannt, galt er als nicht
gemeinsam. Jetzt derselbe normalisierte Schlüssel wie in der Statistik.
- **Titelwechsel erreichte den Linux-Desktop nicht** — die Media-Session-Metadaten wurden feldweise
mutiert, um unter Windows das Neuaufbauen der Medienkachel zu vermeiden. Über MPRIS kam davon
nichts an: Plasma-Widgets zeigten dauerhaft den zuerst gespielten Titel. Bei echtem Titelwechsel
wird jetzt ein neues `MediaMetadata` zugewiesen, sonst weiter in-place (Cover-Nachladen).
- **Keine Adressen mehr eintippen (Android)** — Navidrome-, Backend- und
Scrobble-Adresse kommen als Vorgabe aus dem Build (`-PnovasonicNavidrome=` / `-PnovasonicBackend=`
/ `-PnovasonicScrobble=`, gepflegt in `scripts/.env.release`). Sie greifen, wenn im Gerätespeicher
nichts steht; die Felder in den Einstellungen bleiben sichtbar und überschreibbar, damit ein Umzug
am Gerät korrigierbar ist statt per neuem APK. Beim ersten Start ist nur noch **Benutzer und
Passwort** einzugeben — Zugangsdaten werden bewusst *nicht* eingebacken, die gehören pro Person.
- **Die Web-Adresse ist keine Einstellung mehr (Android)** — sie kommt aus
`BuildConfig.SHARE_HOST`, also aus **derselben** Angabe (`-PnovasonicHost=…`), die ohnehin für die
App-Links ins Manifest wandert. Damit gibt es keine vierte Adresse zu pflegen, „Teilen" ist immer
sichtbar, und ein- und ausgehende Links können nicht mehr auseinanderlaufen.
- **Falle beim Veröffentlichen** — der In-App-Updater vergleicht den `versionCode` aus
der Gitea-Release-Beschreibung mit dem der installierten App. Wird die Version nicht angehoben
**oder** die Beschreibung nicht mitgepflegt (etwa beim Hochladen von Hand statt über
`npm run publish-apk`), meldet die App korrekt „kein Update" — obwohl ein neues APK bereitliegt.
- **Teilen per Link (Web + Android)** — ein Song oder Album lässt sich per `https://…?song=<id>`
an jemanden auf derselben Instanz schicken. Der Link trägt **nur die Id, keine Zugangsdaten**:
der Empfänger sieht den Inhalt mit seinem eigenen Navidrome-Konto. Web teilt über
`navigator.share()` und fällt sonst auf die Zwischenablage zurück; beim Öffnen wird das Album
angezeigt, der Song gestartet und der Parameter aus der Adresszeile entfernt, damit ein Neuladen
nicht erneut losspielt. Als installierte PWA landet der Link dank `launch_handler` im bereits
offenen Fenster. Android teilt über den Systemdialog (`ACTION_SEND`) und empfängt Links per
App-Link-Intent-Filter; der Host kommt beim Bauen aus `-PnovasonicHost=…`, die Web-Adresse für
ausgehende Links aus derselben Angabe (seit 1.2, vorher eine eigene Einstellung). Für das Öffnen ohne Nachfrage muss der SHA-256 des
Release-Schlüssels in `public/.well-known/assetlinks.json` stehen — Anleitung in
`docs/share-links.md`. Ohne diesen Schritt öffnet der Link die Web-App im Browser.
- **Android: Songs von der Festplatte löschen** — im Song-Kontextmenü, unten abgesetzt und rot, mit
derselben Sicherheitsabfrage wie im Web. Nutzt denselben Backend-Endpunkt (`/api/delete-song`) und
erscheint nur, wenn ein Backend konfiguriert ist — Navidrome allein kann keine Dateien entfernen.
- **Android: Playlist direkt aus dem Import anlegen** — der Importer hat jetzt wie das Web ein
Häkchen „Playlist daraus anlegen" samt Namensfeld (ab zwei Titeln). Nach dem Download stößt er
Navidromes Scan an, sucht jeden Titel und legt die Playlist aus den gefundenen an; nicht
gefundene werden gezählt statt die Erstellung scheitern zu lassen.
Zusätzlich zum Auslieferungsteil dieser Version: production **pulls pre-built images** instead of
compiling on the server, the **APK download works again** (it had been broken by Gitea's own link
host), and the scroll memory from 2.2 finally behaves on the Alben tab.
### Added (Auslieferung)
- **Pull-based production stack** — `docker-compose.yml` now references
`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}` and builds nothing on the host:
`docker compose pull && docker compose up -d`. Building locally from the Dockerfiles moved to
`docker-compose.dev.yml` (no registry needed there). Images are pushed from the dev machine with
`npm run push-images`. A `.dockerignore` keeps the frontend build context to what `vite build`
needs, so `android/`, `backend/` and `.git` no longer travel into the image.
### Fixed
- **APK download failed with "Keine APK hinterlegt"** — Gitea builds `browser_download_url` from its
own `ROOT_URL`, which pointed at a host the backend cannot reach (a leftover from an earlier
network). The API call succeeded, so the download button appeared, but fetching the asset ran into
the dead address and fell back silently to a local file that does not exist. Asset and redirect
URLs are now rewritten onto `GITEA_URL`; if the Gitea path still fails, a warning log and the 404
body name both sources that were tried instead of claiming no APK exists.
- **Alben tab jumped back to the top on Back (Android)** — `LaunchedEffect(state.albumSort)` scrolled
to item 0. That effect runs on first composition too, not only on a sort change, so returning from
an album discarded the position the ViewModel had correctly kept. Scrolling to the top now happens
in `LibraryViewModel.setAlbumSort()`, on a real sort change only.
---
## [2.2] — 2026-08-22
A cleanup-and-parity release: the **Song Importer moves into the Global Search field** (one field,
@@ -567,7 +1375,6 @@ Polish release: Android navigation and offline resilience, download accuracy for
## Planned / Backlog
### Features
- **Web + Android** — **Share by link** — share a song/album to another user of the same instance via one `https://…` link that deep-links into the **PWA on desktop** and the **Android app on phone** (like Spotify/YouTube links). Android **App Links** (`intent-filter` + hosted `/.well-known/assetlinks.json` with the *release* signing SHA-256) open the app directly; PWA `launch_handler`/`handle_links` capture links on desktop. Link format leaning query param `?song=<id>` (no server SPA-rewrite; PWA reads it on load). Carries only the song id — no secrets; playback uses the recipient's own Navidrome creds. Songs first → albums/artists/playlists. Web: `navigator.share()`; Android: `ACTION_SEND`.
- **Web + Android** — **Yearly Wrapped** — a yearly story (`/scrobble/wrapped` + web story page + Android pager), built on the existing stats aggregations.
- **Web + Android** — **Household leaderboard (>2 users)** — "who listened most this month" + top artists across all users; the broader multi-user version of the already-built 2-user Musik-Match. Needs wider cross-user reads (admin/household mode, or an aggregate endpoint exposing per-user totals only, never raw histories).
- **Backend (optional)** — **ListenBrainz relay** — mirror counted plays to ListenBrainz.
@@ -577,23 +1384,69 @@ Polish release: Android navigation and offline resilience, download accuracy for
- **Keep what you like:** per-track (or whole-mix) option to **promote into the permanent library** instead of letting it expire.
- Goal: completely random/unfamiliar music you don't already know — like Spotify's curated/discovery playlists, but self-hosted.
- Open questions: where downloaded-but-temporary tracks live vs. the main library (separate folder + retention job), how Navidrome should/shouldn't index them, and dedupe against tracks you already own.
- **Web + Android** — **Artist following + new-release & concert notifications** — follow artists and get notified when they drop something new or play nearby. Idea:
- A **follow list** of artists (stored per user, e.g. in app settings / a small backend table).
- A **"Following" view** listing all artists you follow (grid of artist photos), as an entry point to their pages — a dedicated overview so you can see and manage who you follow, plus a follow/unfollow toggle on the artist page.
- **New-release alerts:** periodic check for new albums/singles by followed artists (sources: iTunes lookup / MusicBrainz / Last.fm), surfaced in the existing in-app notification bell (and optionally push).
- **Concert alerts:** reuse the existing **Ticketmaster** events integration to notify when a followed artist has nearby/upcoming shows.
- Ties into the importer: a "download this new release" action straight from the notification.
- **Web + Android** — **Meldungen zu gefolgten Künstlern** — der Rest des Künstler-folgen-Vorhabens.
Folgen selbst, die Übersicht „wem folge ich" und die Zeitleiste neuer Veröffentlichungen sind
**seit 3.0 gebaut** (siehe dort); offen sind nur noch die beiden Meldewege:
- **Glocke für neue Veröffentlichungen:** die Zeitleiste muss man heute selbst aufschlagen. Die
Daten liegen schon vor (`POST /api/artist-releases`), es fehlt der Vergleich „was ist seit
deinem letzten Besuch dazugekommen" und der Eintrag in der vorhandenen Benachrichtigungsglocke
(wahlweise Push).
- **Konzert-Meldungen:** die vorhandene **Ticketmaster**-Anbindung wiederverwenden und zeigen,
wann gefolgte Künstler spielen. Bewusst zurückgestellt — erst die Neuheiten. Der Entwurf steht
aber schon (25.9.2026):
- **Ein Einstieg, zwei Reiter**, kein zweiter Knopf in der Favoriten-Kopfzeile. Neuheiten und
Konzerte sind dasselbe Thema; zwei Knöpfe drängeln sich, und man muss sich merken, welcher
was zeigt. Die Zeitleiste bekommt oben *Neu erschienen* / *Konzerte*, die Tages-Chips
(90/30/365) gelten nur für die Neuheiten.
- **Nicht in eine gemeinsame Liste mischen.** Veröffentlichungen liegen in der Vergangenheit,
Konzerte in der Zukunft. Chronologisch gemischt stünde „heute" mittendrin und man wüsste nie,
ob man gerade vorwärts oder rückwärts liest.
- **Ort erst später, dann per `.env` im Backend** (Land, optional Stadt und Umkreis). Fürs
Erste reicht, was die API von sich aus liefert — die Abfrage sucht heute weltweit
(`/api/events/:artist`, `keyword=`, `size=20`, nach Datum sortiert).
- **Offene Schwachstelle: die Abfrage läuft über den Namen, nicht über eine ID.** Dieselbe
Falle wie bei der Deezer-Zuordnung — „TPR" oder „MORE" treffen als Stichwort alles Mögliche.
Ticketmaster hat dafür `attractionId`; die einmal pro Künstler auflösen und zwischenspeichern,
so wie die Deezer-IDs.
- Aufwandsseite unkritisch: eine Anfrage pro Künstler, im Backend gecacht wie die Releases. Bei
50 Künstlern und sechs Stunden Haltbarkeit bleibt das weit unter Ticketmasters Tagesgrenze.
- **Android** — Playlist drag-and-drop / sort order
- **Android** — Artist page iTunes fallback (for artists not in Navidrome)
- **Android** — Romaji / Japanese sort support
- **Web + Android** — Backend settings UI rework (move, reorganize)
- **Web + Android** — yt-dlp backend update / maintenance
- **Web + Andoird** - Safe last played Queue, where it stopped and show last played song loaded (rn only on web kinda with the last song played loaded)
- **Android** - Andorid Auto Sync last played and queue too with Phone then, rn its not really syncing or safin what last played on Android Auto, also check if selecting a song to play on Android Auto works that song is getting played and in a playlist it will play the queue a this point then like it works already in Android and Web
### Ideen / Backlog
- (Der Punkt „Künstler und Alben liken, daraus ‚folgen' bauen" ist mit 3.0 umgesetzt und dort
beschrieben.)
### Known Bugs
- **Android (lyrics)** — Synced-lyrics auto-scroll is **not smooth**: it follows playback and highlights the right line, but the list jumps to it rather than gliding. Web autoscroll is smooth. Not yet fixed.
- **Web (PWA, desktop)** — The Windows media tile (SMTC) briefly closes and reopens when skipping tracks. Swapping the `<audio>` element's source leaves a momentary gap with no active media output, so Windows tears down and rebuilds the tile. A real fix needs one continuous source (MSE), which requires streaming an MSE-compatible format. The native Android app is unaffected. (The two-element gapless handoff added in 1.1 makes skips effectively instant but does **not** fix this — switching elements still ends and restarts the OS session.)
- **Backend (download)** — The duplicate check requires at least a `firstArtist` folder match. If the same song was previously stored under a very different artist name (e.g. before an iTunes-name fix), it will not be detected as a duplicate and will be re-downloaded.
- **Backend (download)** — Die Dublettenprüfung sucht in den Ordnern von Album- **und** Titel-Künstler
und ignoriert Akzente sowie Apostrophe (2.3). Ein Rest bleibt: steht derselbe Song unter einem
**deutlich anderen** Künstlernamen (etwa vor einer Namenskorrektur), wird er weiterhin nicht
erkannt und erneut geladen.
- ~~**Backend (Dateinamen)** — `NN-NN - {Titel}` statt `{Tracknummer} - {Künstler} - {Titel}`~~
**Kein Bug — am 11.9.2026 widerlegt.** Der Pfad `Rising Insane/DEATHRACE/01-02 - DEATHRACE.mp3`
steht so **nirgends auf der Platte**. Navidrome gibt über die Subsonic-API standardmäßig gar nicht
den echten Pfad heraus, sondern baut einen aus den Tags zusammen
([`fakePath()` in `server/subsonic/helpers.go`](https://github.com/navidrome/navidrome/blob/master/server/subsonic/helpers.go)):
`{AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{Endung}` — `01` ist die **Disc**, `02` die
**Tracknummer**. An der Stelle des Künstlers steht also nichts Kaputtes, dort steht überhaupt kein
Künstler. Der Info-Dialog zeigte diesen Pfad nur unbeschriftet als „Pfad" an (`s.path` aus
`getSong`), was wie ein falscher Dateiname aussah.
**Folge fürs Backend:** dass Navidrome den echten Pfad verschweigt, ist die Wurzel der ganzen
Datei-Ratearbeit in `metadata.js` — und damit auch der „Metadaten springen zurück"-Fehler (2.3),
bei dem die Suche nach Ähnlichkeit die falsche Kopie traf. Navidrome hat dafür einen Schalter:
**Einstellungen → Players → Zeile `novasonic` → „Report Real Path"**. Ist er an, liefert `getSong`
den echten absoluten Pfad; das Backend nimmt den bereits ohne Codeänderung direkt
([novasonic-server.js:1638](../backend/novasonic-server.js)), und das Raten entfällt. Es gibt zwei
Player-Zeilen, weil Web/Android als `NovaSonic` und das Backend als `novasonic` anfragen —
entscheidend ist die des **Backends**.
- **Backend (download)** — Der Variantenfilter nutzt `\b`-Wortgrenzen, und die zählen den Unterstrich
als Wortzeichen. Titel wie `song_instrumental_cover` oder `Easy_live` rutschen deshalb durch,
während `Easy (Instrumental)` korrekt abgewiesen wird.
- **Backend (metadata/sorting)** — MusicBrainz `release['artist-credit']` is often absent in the standard recording search response (requires `inc=artist-credits` which can slow the endpoint). The albumArtist field from MusicBrainz autofill may fall back to the recording artist rather than the release artist.
- **Backend (metadata/sorting)** — MBIDs are not embedded in downloaded tracks; Navidrome cannot use them for authoritative album grouping (low priority — album splitting is mostly solved via consistent `album_artist`).
+41 -3
View File
@@ -41,9 +41,22 @@ npm run build # production build → dist/
npm run preview # preview the production build
```
Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives at
`/opt/navidrome/music`. Restart after backend changes: `docker compose restart novasonic-backend`
(this also re-pulls the latest yt-dlp).
Der Stack läuft in Docker. **`docker-compose.yml` ist der Normalfall und zieht fertige Images**
(`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}`) — der Server baut nichts:
`docker compose pull && docker compose up -d`. Ein einfacher Restart ist `docker compose restart
novasonic-backend` (das Backend zieht bei jedem Start das neueste yt-dlp nach).
Gepusht wird von der Dev-Maschine mit `npm run push-images` (`scripts/build-push-images.mjs`, liest
`REGISTRY`/`TAG` aus `.env`); ohne Node tut es direkt `docker build -t $REGISTRY/<name>:latest
<context>` + `docker push` mit den Kontexten `.`, `backend/`, `scrobble/`. Registry ist die
Gitea-Registry (`…/docker`, Owner klein geschrieben).
**`docker-compose.dev.yml`** ist die Variante, die stattdessen lokal aus den Dockerfiles baut
(`backend/Dockerfile` + `scrobble/Dockerfile`), ohne Registry: `docker compose -f
docker-compose.dev.yml up -d --build`. Die schweren Deps (ffmpeg, Deno, yt-dlp, node libs) sind in
den Images gebacken, deshalb sind **Restarts sofort da** und Rebuilds nach `git pull` cache-schnell.
On the deploy host the music lives at `/opt/navidrome/music`.
## Download pipeline (hard-won knowledge)
@@ -80,6 +93,31 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
matches, download **nothing** and fail cleanly with "Nicht eindeutig gefunden — bitte
Direktlink im Importer nutzen". Never fall back to a blind title-only/no-filter grab — it
pulled same-titled covers by other uploaders, and wrong Piano Echoes for Japanese tracks.
- **Numbers in a title are always required keywords.** `buildTitleKeywords` kept only the two
LONGEST words, and numbers are short — so "700 Main Street" was filtered on Street+Main only and
the cascade happily saved **"777 Main Street" by a different artist** (step 3 drops the channel
check, so nothing else caught it). Numeric tokens are now emitted as `\b<n>\b` in addition to the
two longest words. Same fix carries a small abbreviation map (street/st, saint/st, mount/mt,
doctor/dr, versus/vs, number/no): uploaders write "700 Main St" where the release says "Street",
which had rejected the correct official Art Track in every step. One exception: a **re-release year
next to a qualifier** ("… - 2020 Remaster") is dropped again — it sits on the release but never in
the YouTube title, so requiring it would reject the correct upload. A title that IS a year ("1999",
"Berlin 2020") keeps it. Unit-tested in `backend/test/ytdlp.test.js`.
- **Live recordings are excluded by title, not by `is_live`.** `!is_live` only means "not a running
stream" — a concert RECORDING passes it. On the artist's own channel nothing else distinguishes it
from the studio track, which is how "Dat Adam - Ufo Video Days 2015 Live" (channel `Dat Adam`,
209 s, title contains UFO) got saved as the album track. `buildVariantFilter` therefore carries a
`live` rule like remix/cover/…. It self-disables when the searched title itself says live, so
"Live Forever" or a deliberately requested live version still works — that check uses a `\blive\b`
regex (`skipRe`), because a plain substring test would let "deliver"/"delivery" switch the rule off.
- **A channel hit in the YTM results is a line to skip, not a reason to abort.** `getYtmVideoUrls`
used to treat any `/browse/UC…` in the output as "yt-dlp expanded an artist channel" and returned
`[]` — discarding the song URLs it had already collected. Measured on 8 real searches, that fired
in **7 of 8**, so step 1 (YTM, where the official Art Tracks live) was effectively dead and almost
every download came from the YouTube fallback. With `--flat-playlist` a channel is printed as ONE
line, so expansion cannot happen; `pickYtmVideoUrls()` now keeps every `watch?v=` line and skips
`browse/UC…` (channel) and `browse/MPREb_…` (album) alike. It is a strict superset of the old
behaviour — fuzz-checked over 20k random outputs — so it can only add candidates, never remove any.
- **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2×
expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through.
- **Leading aesthetic characters:** artists often style their name with leading punctuation
+18 -1
View File
@@ -87,7 +87,8 @@ NovaSonic/
├── styles.css
├── vite.config.js
├── Dockerfile
├── docker-compose.yml
├── docker-compose.yml # Standard: zieht die fertigen Images aus der Registry
├── docker-compose.dev.yml # Variante: baut lokal aus den Dockerfiles
├── nginx.conf
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL / SCROBBLE_URL at runtime
```
@@ -123,9 +124,25 @@ TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
### 2. Start
```bash
docker compose pull
docker compose up -d
```
Zieht die drei fertigen Images aus der Registry — nichts wird auf dem Server gebaut. Dafür in der
`.env` `REGISTRY` (und optional `TAG`) setzen; die Images heißen
`$REGISTRY/novasonic-frontend|-backend|-scrobble`. Neue Stände lädt man von der Dev-Maschine mit
`npm run push-images` hoch (bzw. `docker build -t $REGISTRY/<name>:latest <context> && docker push …`
— Kontexte: `.` (frontend), `backend/`, `scrobble/`).
**Selbst bauen statt ziehen** (keine Registry nötig):
```bash
docker compose -f docker-compose.dev.yml up -d --build
```
Baut lokal aus den Dockerfiles; die Deps stecken in den Images, also sind Restarts sofort da und
Rebuilds nach einem `git pull` dank Layer-Cache schnell.
Frontend on port 8080, backend on port 3700, scrobble service on port 3710. Route each through your reverse proxy. **Important:** the scrobble subdomain must *not* sit behind an SSO / forward-auth gateway — it authenticates itself via Navidrome (`ping.view`), and a gateway in front redirects the app's API calls to a login page.
### 3. First launch
+29 -2
View File
@@ -14,8 +14,35 @@ android {
applicationId = "com.novasonic.app"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionCode = 29
versionName = "1.26"
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
// Platzhalter stehen und der App-Link greift schlicht nicht.
val host1 = (project.findProperty("novasonicHost") as String?) ?: "music.example.com"
// Zweiter Host fuer einen Domainwechsel: solange beide Adressen erreichbar sind, oeffnen
// Links von der alten UND der neuen in der App. Ohne Angabe wird host1 wiederholt — ein
// doppelter Eintrag ist harmlos, ein Platzhalter-Host waere es NICHT: auf Android 11 und
// aelter verifiziert das System nur, wenn ALLE angegebenen Hosts eine gueltige
// assetlinks.json ausliefern, ein toter Host wuerde also alle blockieren.
val host2 = (project.findProperty("novasonicHost2") as String?) ?: host1
manifestPlaceholders["shareHost"] = host1
manifestPlaceholders["shareHost2"] = host2
// Derselbe Host auch fuer AUSGEHENDE Links. Er steht ohnehin schon fest im Manifest, eine
// zusaetzliche Einstellung in der App waere also nur eine weitere Adresse zum Pflegen —
// und eine, die vom Manifest abweichen koennte, ohne dass es jemand merkt.
buildConfigField("String", "SHARE_HOST", "\"$host1\"")
// Vorgabe-Adressen fuer Backend und Scrobble-Dienst. Sie stehen ohnehin in der
// docker-compose.yml — sie zusaetzlich auf jedem Geraet einzutippen ist Arbeit ohne Nutzen.
// Leer gelassen verhaelt sich die App wie bisher (Felder sichtbar, nichts vorbelegt).
// Zugangsdaten bleiben bewusst aussen vor: die gehoeren pro Person, nicht ins APK.
val backendUrl = (project.findProperty("novasonicBackend") as String?) ?: ""
val scrobbleUrl = (project.findProperty("novasonicScrobble") as String?) ?: ""
val navidromeUrl= (project.findProperty("novasonicNavidrome") as String?) ?: ""
buildConfigField("String", "DEFAULT_BACKEND_URL", "\"$backendUrl\"")
buildConfigField("String", "DEFAULT_SCROBBLE_URL", "\"$scrobbleUrl\"")
buildConfigField("String", "DEFAULT_ND_URL", "\"$navidromeUrl\"")
}
buildFeatures {
+6
View File
@@ -6,3 +6,9 @@
# Keep Media3 session / exoplayer classes used by reflection / service binding.
-keep class androidx.media3.** { *; }
-keep class com.google.android.exoplayer2.** { *; }
# Ktors Logging referenziert ueber slf4j den Binder aus slf4j 1.x. Auf Android existiert der nicht
# (slf4j 2.x sucht Implementierungen per ServiceLoader, und wir binden ohnehin keine), deshalb bricht
# R8 den Release-Build mit "Missing class org.slf4j.impl.StaticLoggerBinder" ab. Die Referenz ist zur
# Laufzeit unerreichbar — slf4j faengt das Fehlen selbst ab und schaltet auf einen No-op-Logger.
-dontwarn org.slf4j.**
+13
View File
@@ -33,6 +33,19 @@
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Geteilte Links (https://<host>/?song=…) direkt in der App oeffnen.
autoVerify greift erst, wenn unter https://<host>/.well-known/assetlinks.json der
SHA-256 des RELEASE-Signaturschluessels hinterlegt ist. Ohne das bleibt der Filter
wirkungslos, bis der Nutzer die App in den Systemeinstellungen unter
"Standardmaessig oeffnen" fuer diese Domain zulaesst. -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="${shareHost}"/>
<data android:scheme="https" android:host="${shareHost2}"/>
</intent-filter>
</activity>
<service
@@ -24,13 +24,41 @@ class NovaSonicApp : Application() {
// 30s preview player — app-level so its control bar survives navigation (see AppNavigation).
val previewController: PreviewController = PreviewController()
/**
* Raeumt den Cache nach einem App-Update einmalig auf.
*
* Im cacheDir liegen Coils Bildspeicher (Cover) und heruntergeladene APK-Updates — beides
* jederzeit neu beschaffbar. Nach einem Update konnten dort Reste liegen, die zu alten Anzeigen
* fuehrten; die einzige Abhilfe war bisher "Cache leeren" von Hand in den Systemeinstellungen,
* was man einem normalen Nutzer nicht erklaeren moechte. Das passiert jetzt automatisch und
* ausschliesslich beim Versionswechsel, nicht im laufenden Betrieb — sonst waeren die Cover bei
* jedem Start neu zu laden.
*
* Offline-Downloads sind NICHT betroffen: die liegen unter filesDir/offline, nicht im cacheDir.
*/
private fun cacheNachUpdateLeeren() {
val prefs = getSharedPreferences("app_state", MODE_PRIVATE)
val zuletzt = prefs.getInt("last_version_code", -1)
val jetzt = BuildConfig.VERSION_CODE
if (zuletzt == jetzt) return
if (zuletzt != -1) {
runCatching { cacheDir.deleteRecursively() }
.onSuccess { android.util.Log.i("NsCache", "Cache nach Update $zuletzt -> $jetzt geleert") }
.onFailure { android.util.Log.w("NsCache", "Cache-Leeren fehlgeschlagen", it) }
}
prefs.edit().putInt("last_version_code", jetzt).apply()
}
override fun onCreate() {
super.onCreate()
cacheNachUpdateLeeren()
offlineRepository = OfflineRepository(applicationContext) // must be first
repository = MusicRepository(applicationContext)
playerController = PlayerController(applicationContext, repository)
playerController.connect()
absPlayerController = AbsPlayerController(applicationContext)
// Angefangene Downloads weiterlaufen lassen (grosse Playlists brauchen viele Laeufe).
offlineRepository.setzeUnvollstaendigeFort()
// Flush any plays queued while offline to the scrobble service.
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
runCatching { repository.flushPlayLog() }
@@ -8,6 +8,7 @@ import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
@@ -54,5 +55,18 @@ class ImporterClient(private val backendUrl: String) {
suspend fun status(jobId: String): DownloadStatus =
http.get("${base()}/api/status/$jobId").body()
/**
* Loescht einen Song endgueltig von der Platte — derselbe Endpunkt, den der Web-Client nutzt.
* Das Backend braucht die Navidrome-Zugangsdaten, weil es den Dateipfad ueber getSong aufloest.
* Wirft bei HTTP != 2xx (Ktor `expectSuccess` ist hier aus, deshalb der explizite Check).
*/
suspend fun deleteSong(ndUrl: String, user: String, pass: String, songId: String) {
val res = http.post("${base()}/api/delete-song") {
contentType(ContentType.Application.Json)
setBody(DeleteSongRequest(ndUrl.trimEnd('/'), user, pass, songId))
}
if (!res.status.isSuccess()) error("HTTP ${res.status.value}")
}
fun close() = http.close()
}
@@ -0,0 +1,51 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.ArtistRef
import com.novasonic.app.api.model.ArtistReleasesRequest
import com.novasonic.app.api.model.ArtistKonzerteRequest
import com.novasonic.app.api.model.ArtistKonzerteResponse
import com.novasonic.app.api.model.ArtistReleasesResponse
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
/**
* Neue Veröffentlichungen der gefolgten Künstler. Die Deezer-Auflösung macht das Backend — dort
* liegen auch die Künstler-Pins, und der Cache gilt dann für alle Geräte gemeinsam statt pro App.
*/
object ReleasesClient {
private val http = HttpClient(Android) {
expectSuccess = true
// Der erste Aufruf kann dauern: das Backend holt pro Künstler die Deezer-Diskografie.
// Danach liegt es dort im Cache.
install(HttpTimeout) { requestTimeoutMillis = 60_000; connectTimeoutMillis = 10_000 }
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
}
}
suspend fun fetch(backendUrl: String, artists: List<ArtistRef>, days: Int): ArtistReleasesResponse {
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistReleasesResponse()
return http.post("${backendUrl.trimEnd('/')}/api/artist-releases") {
contentType(ContentType.Application.Json)
setBody(ArtistReleasesRequest(artists, days))
}.body()
}
/** Anstehende Konzerte derselben Kuenstler — gleicher Weg, anderer Endpunkt. */
suspend fun konzerte(backendUrl: String, artists: List<ArtistRef>): ArtistKonzerteResponse {
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistKonzerteResponse()
// Ohne `days`: das Backend liefert alles Angekuendigte.
return http.post("${backendUrl.trimEnd('/')}/api/artist-concerts") {
contentType(ContentType.Application.Json)
setBody(ArtistKonzerteRequest(artists))
}.body()
}
}
@@ -149,9 +149,16 @@ class SubsonicClient(
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
?: com.novasonic.app.api.model.ArtistInfo()
suspend fun getTopSongs(artist: String, count: Int = 10): List<com.novasonic.app.api.model.Song> =
get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song
?: emptyList()
/**
* id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
* Namen zurueck, wenn es sie nicht kennt; aeltere Versionen ignorieren id. Der Namensweg sucht
* mit LIKE und Max:1 — bei gleichnamigen Kuenstlern gewann bisher der erste Treffer.
*/
suspend fun getTopSongs(artist: String, count: Int = 10, artistId: String? = null): List<com.novasonic.app.api.model.Song> =
get("getTopSongs.view", buildMap {
put("artist", artist); put("count", count)
artistId?.takeIf { it.isNotBlank() }?.let { put("id", it) }
}).topSongs?.song ?: emptyList()
suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
get("getGenres.view").genres?.genre ?: emptyList()
@@ -163,6 +170,9 @@ class SubsonicClient(
suspend fun addSongToPlaylist(playlistId: String, songId: String) =
getList("updatePlaylist.view", listOf("playlistId" to playlistId, "songIdToAdd" to songId))
/** Stoesst Navidromes Bibliotheks-Scan an — nach einem Import, damit frische Dateien auffindbar sind. */
suspend fun startScan() = get("startScan.view")
suspend fun createPlaylist(name: String, songIds: List<String> = emptyList()): com.novasonic.app.api.model.PlaylistWithSongs {
val params = mutableListOf("name" to name)
songIds.forEach { params += "songId" to it }
@@ -42,6 +42,15 @@ data class DownloadRequest(
@Serializable
data class ResolveRequest(val url: String)
/** Body fuer POST {backend}/api/delete-song — loescht die Datei von der Platte (wie im Web). */
@Serializable
data class DeleteSongRequest(
val ndUrl: String,
val user: String,
val pass: String,
val songId: String,
)
/** Response from GET {backend}/api/status/{jobId} */
@Serializable
data class DownloadStatus(
@@ -0,0 +1,59 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
/** Eine Veröffentlichung eines gefolgten Künstlers, wie sie das Backend liefert. */
@Serializable
data class ArtistRelease(
val artistId: String = "",
val artistName: String = "",
val deezerArtistId: String? = null,
val albumId: String = "",
val title: String = "",
val cover: String = "",
val releaseDate: String = "",
val recordType: String = "album",
val trackCount: Int? = null,
val explicit: Boolean = false,
)
@Serializable
data class ArtistReleasesResponse(
val releases: List<ArtistRelease> = emptyList(),
/** Künstler, zu denen kein Deezer-Profil gefunden wurde — dort hilft nur ein Pin. */
val ohneZuordnung: List<String> = emptyList(),
)
@Serializable
data class ArtistRef(val id: String, val name: String)
@Serializable
data class ArtistReleasesRequest(val artists: List<ArtistRef>, val days: Int)
/** Ein anstehendes Konzert eines gefolgten Künstlers. */
@Serializable
data class ArtistKonzert(
val artistId: String = "",
val artistName: String = "",
val eventId: String = "",
val titel: String = "",
/** Ortszeit ohne Zone, z. B. "2026-11-04T20:00:00" — bewusst nicht UTC. */
val datum: String = "",
val ort: String = "",
val stadt: String = "",
val land: String = "",
val url: String = "",
val abgesagt: Boolean = false,
)
@Serializable
data class ArtistKonzerteResponse(
val konzerte: List<ArtistKonzert> = emptyList(),
/** Künstler ohne anstehende Termine — nur zur Erklärung einer leeren Liste. */
val ohneTermine: List<String> = emptyList(),
/** Im Backend ist kein Ticketmaster-Schlüssel hinterlegt. */
val keinSchluessel: Boolean = false,
)
@Serializable
data class ArtistKonzerteRequest(val artists: List<ArtistRef>)
@@ -85,6 +85,7 @@ data class CompareResult(
val userA: String = "",
val userB: String = "",
val matchScore: Int = 0,
val battle: Battle? = null,
val totalsA: CompareTotals = CompareTotals(),
val totalsB: CompareTotals = CompareTotals(),
val sharedArtists: List<SharedArtist> = emptyList(),
@@ -93,6 +94,23 @@ data class CompareResult(
val onlyB: List<UniqueArtist> = emptyList(),
)
/** Anzeigetafel: je Kategorie ein Sieger ("A"/"B"/null bei Gleichstand) plus Gesamtstand. */
@Serializable
data class Battle(
val kategorien: List<BattleKategorie> = emptyList(),
val punkteA: Int = 0,
val punkteB: Int = 0,
)
@Serializable
data class BattleKategorie(
val key: String = "",
val label: String = "",
val textA: String = "",
val textB: String = "",
val sieger: String? = null,
)
@Serializable
data class CompareTotals(
val plays: Int = 0,
@@ -0,0 +1,26 @@
package com.novasonic.app.data
import android.net.Uri
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Uebergabepunkt fuer geteilte Links (https://<host>/?song=… bzw. ?album=…).
*
* Die Activity kann nicht selbst navigieren — der NavController lebt in der Compose-Hierarchie.
* Deshalb legt MainActivity den eingehenden Link hier ab, und AppNavigation holt ihn sich, sobald
* es bereit ist. Ein einzelner Slot genuegt: der letzte geoeffnete Link gewinnt.
*/
object DeepLinkState {
val pending = MutableStateFlow<Uri?>(null)
fun offer(uri: Uri?) {
// Nur Links mit einer fuer uns verwertbaren Kennung annehmen — ein blosser Start ueber das
// App-Symbol darf keine Navigation ausloesen.
if (uri == null) return
if (uri.getQueryParameter("song") == null && uri.getQueryParameter("album") == null) return
pending.value = uri
}
/** Holt den Link und leert den Slot, damit er bei Drehung o. ae. nicht erneut greift. */
fun consume(): Uri? = pending.value.also { pending.value = null }
}
@@ -0,0 +1,11 @@
package com.novasonic.app.data
/**
* Einmalige Absicht beim Öffnen der Bibliothek. Das LibraryViewModel hängt am Navigationseintrag,
* überlebt also keinen Wechsel des Ziels — ein Zustand, den ein anderer Screen setzen will, muss
* deshalb hier durch. Wird beim Verarbeiten sofort zurückgesetzt, damit er nicht nachwirkt.
*/
object LibraryIntent {
/** Direkt in den Künstler-Reiter mit aktivem "Gefolgt"-Filter springen. */
var oeffneGefolgte: Boolean = false
}
@@ -101,11 +101,15 @@ class MusicRepository(private val context: Context) {
}
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
/** artistId ist die NAVIDROME-Kuenstler-ID (nicht Deezers) — ohne sie sucht Navidrome
* ueber den Namen und nimmt bei Gleichnamigen den ersten Treffer. */
suspend fun getTopSongs(artistName: String, artistId: String? = null) =
api().getTopSongs(artistName, artistId = artistId)
suspend fun getGenres() = api().getGenres()
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
suspend fun createPlaylist(name: String, songIds: List<String>) = api().createPlaylist(name, songIds)
suspend fun startScan() = api().startScan()
suspend fun updatePlaylistMeta(playlistId: String, name: String, comment: String? = null) = api().updatePlaylistMeta(playlistId, name, comment)
suspend fun removeSongsFromPlaylist(playlistId: String, indices: List<Int>) = api().removeSongsFromPlaylist(playlistId, indices)
suspend fun deletePlaylist(playlistId: String) = api().deletePlaylist(playlistId)
@@ -168,6 +172,9 @@ class MusicRepository(private val context: Context) {
suspend fun saveTheme(themeId: String) = prefs.saveTheme(themeId)
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) =
prefs.saveQueueState(songs, index)
suspend fun readQueueState() = prefs.readQueueState()
suspend fun saveLastSong(song: com.novasonic.app.api.model.Song) =
prefs.saveLastSong(song.id, song.title, song.artist ?: "", song.coverArt)
@@ -3,9 +3,11 @@ package com.novasonic.app.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore("novasonic_prefs")
@@ -25,10 +27,19 @@ class PrefsStore(private val context: Context) {
val KEY_ABS_URL = stringPreferencesKey("abs_url")
val KEY_ABS_API_KEY = stringPreferencesKey("abs_api_key")
val KEY_ABS_CHAPTER_TRACK = booleanPreferencesKey("abs_chapter_track")
val KEY_DL_WIFI_ONLY = booleanPreferencesKey("download_wifi_only")
val KEY_WISCH_TITEL = booleanPreferencesKey("swipe_changes_track")
val KEY_LYRICS_FOLGEN = booleanPreferencesKey("lyrics_autoscroll")
val KEY_LAST_SONG_ID = stringPreferencesKey("last_song_id")
val KEY_LAST_SONG_TITLE = stringPreferencesKey("last_song_title")
val KEY_LAST_SONG_ARTIST = stringPreferencesKey("last_song_artist")
val KEY_LAST_SONG_COVER = stringPreferencesKey("last_song_cover")
// Warteschlange fortsetzen. Gespeichert werden die VOLLSTAENDIGEN Songs als JSON, nicht nur
// Ids: mit Ids musste beim Start jeder Titel einzeln ueber das Netz nachgeladen werden, was
// Sekunden dauerte und ohne Verbindung gar nicht ging. So ist es ein lokaler Lesevorgang.
// Ohne Sekunde im Titel — der zuletzt gehoerte Titel beginnt von vorn.
val KEY_QUEUE_JSON = stringPreferencesKey("queue_json")
val KEY_QUEUE_INDEX = intPreferencesKey("queue_index")
}
data class Credentials(val serverUrl: String, val username: String, val password: String) {
@@ -48,6 +59,16 @@ class PrefsStore(private val context: Context) {
val absUrl: String = "",
val absApiKey: String = "",
val absChapterTrackMode: Boolean = false,
/** Offline-Downloads nur ueber WLAN. Standard an: eine grosse Playlist sind schnell
* mehrere Gigabyte, und die Wiederholkette laeuft ohne Zutun weiter. */
val downloadWifiOnly: Boolean = true,
/** Waagerechtes Wischen im Now-Playing wechselt den Titel statt der Ansicht.
* Standard an — Titelwechsel ist die Geste, die man dort staendig braucht. */
val wischenWechseltTitel: Boolean = true,
/** Im Lyrics-Tab automatisch zur laufenden Zeile scrollen. Standard AUS: wer selbst
* blaettert, will nicht, dass der Text unter dem Finger wegspringt. Umschalten direkt
* im Lyrics-Tab, wirkt nur bei synchronen Texten. */
val lyricsFolgenAutomatisch: Boolean = false,
)
data class LastPlayedSong(
@@ -59,7 +80,9 @@ class PrefsStore(private val context: Context) {
val credentials: Flow<Credentials> = context.dataStore.data.map { prefs ->
Credentials(
serverUrl = prefs[KEY_SERVER] ?: "",
// Adresse vorbelegt, Zugangsdaten bewusst nicht — die gehoeren pro Person.
serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
username = prefs[KEY_USERNAME] ?: "",
password = prefs[KEY_PASSWORD] ?: "",
)
@@ -67,18 +90,26 @@ class PrefsStore(private val context: Context) {
val appSettings: Flow<AppSettings> = context.dataStore.data.map { prefs ->
AppSettings(
serverUrl = prefs[KEY_SERVER] ?: "",
serverUrl = prefs[KEY_SERVER]?.ifBlank { null }
?: com.novasonic.app.BuildConfig.DEFAULT_ND_URL,
username = prefs[KEY_USERNAME] ?: "",
password = prefs[KEY_PASSWORD] ?: "",
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
backendUrl = prefs[KEY_BACKEND_URL] ?: "",
scrobbleUrl = prefs[KEY_SCROBBLE_URL] ?: "",
backendUrl = prefs[KEY_BACKEND_URL]?.ifBlank { null }
?: com.novasonic.app.BuildConfig.DEFAULT_BACKEND_URL,
// Leerer Speicherwert => eingebaute Vorgabe. So muss auf einem frischen Geraet nichts
// eingetippt werden, ein eigener Eintrag hat aber weiterhin Vorrang.
scrobbleUrl = prefs[KEY_SCROBBLE_URL]?.ifBlank { null }
?: com.novasonic.app.BuildConfig.DEFAULT_SCROBBLE_URL,
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
theme = prefs[KEY_THEME] ?: "dark-ocean",
absUrl = prefs[KEY_ABS_URL] ?: "",
absApiKey = prefs[KEY_ABS_API_KEY] ?: "",
absChapterTrackMode = prefs[KEY_ABS_CHAPTER_TRACK] ?: false,
downloadWifiOnly = prefs[KEY_DL_WIFI_ONLY] ?: true,
wischenWechseltTitel = prefs[KEY_WISCH_TITEL] ?: true,
lyricsFolgenAutomatisch = prefs[KEY_LYRICS_FOLGEN] ?: false,
)
}
@@ -114,9 +145,21 @@ class PrefsStore(private val context: Context) {
prefs[KEY_ABS_URL] = settings.absUrl
prefs[KEY_ABS_API_KEY] = settings.absApiKey
prefs[KEY_ABS_CHAPTER_TRACK] = settings.absChapterTrackMode
prefs[KEY_DL_WIFI_ONLY] = settings.downloadWifiOnly
prefs[KEY_WISCH_TITEL] = settings.wischenWechseltTitel
// KEY_LYRICS_FOLGEN bewusst NICHT: der Schalter sitzt im Lyrics-Tab und wird mit
// saveLyricsFolgen() einzeln geschrieben. Wuerde er hier mitgeschrieben, setzte jedes
// Speichern in den Einstellungen ihn auf die Vorgabe zurueck — der Bildschirm fuehrt
// dieses Feld gar nicht mit.
}
}
/** Einzeln speicherbar: der Schalter sitzt im Lyrics-Tab, nicht in den Einstellungen, und
* darf dort nicht den ganzen Einstellungssatz ueberschreiben. */
suspend fun saveLyricsFolgen(an: Boolean) {
context.dataStore.edit { prefs -> prefs[KEY_LYRICS_FOLGEN] = an }
}
suspend fun saveTheme(themeId: String) {
context.dataStore.edit { prefs -> prefs[KEY_THEME] = themeId }
}
@@ -130,6 +173,39 @@ class PrefsStore(private val context: Context) {
}
}
private val queueJson = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
/**
* Sichert die Warteschlange als VOLLSTAENDIGE Songs, nicht nur als Ids. Mit Ids musste beim
* Start jeder Titel einzeln ueber das Netz nachgeladen werden — das dauerte Sekunden und ging
* ohne Verbindung gar nicht. So ist das Wiederherstellen ein rein lokaler Lesevorgang.
* Der Preis: zwischenzeitlich geaenderte Titel oder Cover zeigen bis zum naechsten Abspielen
* den alten Stand. Das ist die deutlich kleinere Unschoenheit.
*/
suspend fun saveQueueState(songs: List<com.novasonic.app.api.model.Song>, index: Int) {
val kodiert = runCatching {
queueJson.encodeToString(kotlinx.serialization.builtins.ListSerializer(
com.novasonic.app.api.model.Song.serializer()), songs)
}.getOrNull() ?: return
context.dataStore.edit { prefs ->
prefs[KEY_QUEUE_JSON] = kodiert
prefs[KEY_QUEUE_INDEX] = index.coerceAtLeast(0)
}
}
data class QueueState(val songs: List<com.novasonic.app.api.model.Song>, val index: Int)
suspend fun readQueueState(): QueueState? {
val prefs = context.dataStore.data.first()
val roh = prefs[KEY_QUEUE_JSON]?.takeIf { it.isNotBlank() } ?: return null
val songs = runCatching {
queueJson.decodeFromString(kotlinx.serialization.builtins.ListSerializer(
com.novasonic.app.api.model.Song.serializer()), roh)
}.getOrNull().orEmpty()
if (songs.isEmpty()) return null
return QueueState(songs, (prefs[KEY_QUEUE_INDEX] ?: 0).coerceIn(0, songs.lastIndex))
}
suspend fun clear() {
context.dataStore.edit { it.clear() }
}
@@ -21,13 +21,18 @@ class DownloadWorker(
companion object {
const val EXTRA_GROUP_ID = "group_id"
private const val TAG = "DownloadWorker"
/** Obergrenze fuer selbst angeforderte Wiederholungen, damit dauerhaft kaputte Titel
* (auf dem Server geloescht, immer 404) die Kette nicht endlos weiterlaufen lassen. */
private const val MAX_VERSUCHE = 20
}
private val offlineRepo = applicationContext.app.offlineRepository
override suspend fun doWork(): Result {
val groupId = inputData.getString(EXTRA_GROUP_ID) ?: return Result.failure()
val group = offlineRepo.getGroup(groupId) ?: return Result.failure()
// Nur die Existenz zaehlt — der Fortschritt wird unten neu gezaehlt, nicht fortgeschrieben.
offlineRepo.getGroup(groupId) ?: return Result.failure()
val songIds = offlineRepo.songIdsForGroup(groupId)
if (songIds.isEmpty()) return Result.success()
@@ -35,14 +40,26 @@ class DownloadWorker(
if (!creds.isValid) return Result.failure()
val subsonicClient = SubsonicClient(creds.serverUrl, creds.username, creds.password)
var done = group.downloadedTracks
// Bei 0 anfangen und JEDEN fertigen Titel zaehlen — auch die aus frueheren Laeufen, die
// gleich uebersprungen werden. Frueher stand hier group.downloadedTracks, und die Schleife
// zaehlte dieselben Titel gleich nochmal: nach einem Fortsetzen mit 412 fertigen Titeln
// meldete die Gruppe 824. Ueberschreitet der Wert totalTracks, gilt die Gruppe als
// "Vollstaendig" und der Herunterladen-Knopf wird unantastbar, obwohl Titel fehlen.
var done = 0
val dao = OfflineDatabase.get(applicationContext).offlineDao()
return try {
var gestoppt = false
for (songId in songIds) {
// WorkManager raeumt Hintergrundarbeit rund 10 Minuten ein und stoppt sie dann.
// Ohne diese Pruefung laeuft die Schleife blind weiter, bis sie hart abgebrochen
// wird — der angefangene Titel geht verloren und niemand startet neu. So steigen
// wir sauber aus und fordern den naechsten Lauf an; Fertiges wird uebersprungen.
if (isStopped) { gestoppt = true; break }
if (offlineRepo.getTrackState(songId) == DownloadState.DONE) {
// Kein Schreibvorgang beim Ueberspringen: der Fortschritt aendert sich dabei
// nicht, und bei 1900 Titeln waeren das 1900 unnoetige Schreibzugriffe je Lauf.
done++
dao.updateGroupProgress(groupId, done)
continue
}
@@ -69,17 +86,41 @@ class DownloadWorker(
offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
done++
} catch (e: Exception) {
Log.e("DownloadWorker", "Failed to download song $songId", e)
Log.e(TAG, "Titel $songId fehlgeschlagen", e)
audioFile.delete()
offlineRepo.markFailed(songId)
}
dao.updateGroupProgress(groupId, done)
}
Result.success()
// Beim Abbruch ist der letzte Stand sonst nicht gesichert.
if (gestoppt) dao.updateGroupProgress(groupId, done)
val offen = dao.offeneTitel(groupId)
when {
gestoppt -> {
Log.i(TAG, "Gestoppt bei $done/${songIds.size} — fordere naechsten Lauf an")
Result.retry()
}
offen == 0 -> {
Log.i(TAG, "Gruppe $groupId vollstaendig (${songIds.size} Titel)")
Result.success()
}
runAttemptCount < MAX_VERSUCHE -> {
Log.i(TAG, "$offen Titel offen (Versuch $runAttemptCount) — noch ein Lauf")
Result.retry()
}
else -> {
// Aufgeben, aber nicht stillschweigend: die Gruppenzeile zeigt weiterhin
// "x/y Titel", und ein Tipp auf Herunterladen startet die Kette neu.
Log.w(TAG, "$offen Titel bleiben offen — nach $runAttemptCount Versuchen aufgegeben")
Result.success()
}
}
} catch (e: Exception) {
Log.e("DownloadWorker", "Worker failed for group $groupId", e)
Result.failure()
Log.e(TAG, "Lauf fuer Gruppe $groupId abgebrochen", e)
// Frueher Result.failure(): ein einzelner Netzfehler beendete die ganze Kette endgueltig.
if (runAttemptCount < MAX_VERSUCHE) Result.retry() else Result.failure()
}
}
@@ -53,6 +53,26 @@ interface OfflineDao {
@Query("SELECT * FROM download_groups WHERE groupId = :groupId")
suspend fun getGroup(groupId: String): DownloadGroupEntity?
/**
* Rechnet den gespeicherten Fortschritt ALLER Gruppen aus dem tatsaechlichen Zustand der Titel
* neu. Noetig, weil der Worker bis 1.10 doppelt zaehlte: der Wert konnte totalTracks
* uebersteigen, die Gruppe galt als "Vollstaendig", der Herunterladen-Knopf wurde unantastbar
* und das Fortsetzen uebersprang sie — der falsche Wert konnte sich also nie mehr korrigieren.
* Eine Anweisung fuer alle Gruppen, laeuft beim App-Start.
*/
@Query("""
UPDATE download_groups SET downloadedTracks = (
SELECT COUNT(*) FROM group_track_cross_refs r
INNER JOIN downloaded_tracks t ON t.songId = r.songId
WHERE r.groupId = download_groups.groupId AND t.state = 'DONE'
)
""")
suspend fun fortschrittNeuBerechnen()
/** Gruppen, bei denen noch Titel fehlen — Grundlage fuers Fortsetzen beim App-Start. */
@Query("SELECT * FROM download_groups WHERE totalTracks > 0 AND downloadedTracks < totalTracks")
suspend fun incompleteGroups(): List<DownloadGroupEntity>
@Query("DELETE FROM download_groups WHERE groupId = :groupId")
suspend fun deleteGroup(groupId: String)
@@ -84,9 +104,21 @@ interface OfflineDao {
""")
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>>
/** Wie viele Titel der Gruppe noch nicht fertig sind — eine Abfrage statt einer pro Titel. */
@Query("""
SELECT COUNT(*) FROM downloaded_tracks t
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
WHERE r.groupId = :groupId AND t.state != 'DONE'
""")
suspend fun offeneTitel(groupId: String): Int
@Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId")
suspend fun removeCrossRef(groupId: String, songId: String)
/** Wie viele Titel dieses Cover noch benutzen — Cover werden geteilt gespeichert. */
@Query("SELECT COUNT(*) FROM downloaded_tracks WHERE coverArt = :coverArtId")
suspend fun titelMitCover(coverArtId: String): Int
/** Find a locally-stored cover path by Navidrome coverArt ID (deduped storage). */
@Query("SELECT coverPath FROM downloaded_tracks WHERE coverArt = :coverArtId AND state = 'DONE' AND coverPath IS NOT NULL LIMIT 1")
suspend fun localCoverPathByCoverArt(coverArtId: String): String?
@@ -154,18 +154,98 @@ class OfflineRepository(context: Context) {
}
dao.updateGroupProgress(groupId, alreadyDone)
starteWorker(groupId)
}
/**
* Startet (oder setzt fort) die Downloadarbeit einer Gruppe. KEEP heisst: laeuft schon etwas
* fuer diese Gruppe, passiert nichts — sonst beginnt ein Lauf, der alles Fertige ueberspringt.
*/
private suspend fun starteWorker(groupId: String, ersetzen: Boolean = false) {
// UNMETERED = WLAN (bzw. jede nicht getaktete Verbindung). WorkManager pausiert den Download
// dann von selbst, sobald das WLAN weg ist, und setzt fort, sobald es zurueck ist. Mit
// CONNECTED liefe die Wiederholkette auch ueber Mobilfunk durch — bei einer grossen Playlist
// sind das schnell mehrere Gigabyte ohne Zutun des Nutzers.
val nurWlan = runCatching {
com.novasonic.app.data.PrefsStore(appContext).appSettings.first().downloadWifiOnly
}.getOrDefault(true)
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiredNetworkType(if (nurWlan) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresStorageNotLow(true)
.build()
)
// Ohne Backoff wuerde ein Result.retry() sofort erneut anlaufen; 30 s linear geben dem
// Geraet Luft und halten die Kette trotzdem in Gang.
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, java.util.concurrent.TimeUnit.SECONDS)
.build()
WorkManager.getInstance(appContext)
.enqueueUniqueWork(groupId, ExistingWorkPolicy.KEEP, request)
WorkManager.getInstance(appContext).enqueueUniqueWork(
groupId,
if (ersetzen) ExistingWorkPolicy.REPLACE else ExistingWorkPolicy.KEEP,
request,
)
}
/**
* Nach einer Aenderung an "nur ueber WLAN" die laufende Arbeit neu einreihen. WorkManager
* aendert die Bedingungen einer bereits eingereihten Arbeit nicht mehr — ohne das bliebe der
* Schalter bis zum naechsten App-Start wirkungslos. REPLACE ist unbedenklich, weil der Worker
* ohnehin fortsetzt statt von vorn zu laden.
*/
fun netzbedingungAktualisieren() {
scope.launch {
runCatching { dao.incompleteGroups() }.getOrDefault(emptyList())
.forEach { starteWorker(it.groupId, ersetzen = true) }
}
}
/**
* Beim App-Start unvollstaendige Gruppen weiterlaufen lassen. WorkManager stoppt einen
* Hintergrund-Worker nach rund 10 Minuten; eine Playlist mit 1900 Titeln braucht daher viele
* Laeufe. Der Worker fordert selbst einen neuen an (Result.retry()), aber wenn die Kette doch
* einmal reisst — Neustart des Geraets, aufgebrauchte Versuche — faengt das hier es auf, statt
* darauf zu warten, dass jemand von Hand erneut auf Herunterladen tippt.
*/
fun setzeUnvollstaendigeFort() {
scope.launch {
// Erst den gespeicherten Fortschritt begradigen: Gruppen mit einem zu hohen Wert aus
// der Zeit vor 1.10 sehen sonst vollstaendig aus und werden hier gar nicht gefunden.
runCatching { dao.fortschrittNeuBerechnen() }
.onFailure { Log.w("OfflineRepository", "Fortschritt neu berechnen fehlgeschlagen", it) }
val offen = runCatching { dao.incompleteGroups() }.getOrDefault(emptyList())
offen.forEach { g ->
Log.i("OfflineRepository",
"Setze fort: ${g.groupId} (${g.downloadedTracks}/${g.totalTracks})")
starteWorker(g.groupId)
}
}
}
/**
* Entfernt alle Dateien eines Titels und dann die Datenbankzeile.
*
* Frueher lief das ueber _audioMap — die kennt aber nur FERTIGE Titel. Ein abgebrochener oder
* fehlgeschlagener Download hinterliess seine Datei also unberuehrt. Jetzt kommt der Pfad aus
* der Zeile selbst, dazu die angefangene .tmp und das Cover, sofern es kein anderer Titel mehr
* benutzt (Cover werden geteilt gespeichert).
*/
private suspend fun loescheTitelVollstaendig(songId: String) {
val track = dao.getTrackById(songId)
val pfade = listOfNotNull(track?.localPath, _audioMap.value[songId])
pfade.distinct().forEach { p ->
runCatching { File(p).delete(); File("$p.tmp").delete() }
}
track?.coverArt?.let { coverArtId ->
// Diese Zeile zaehlt noch mit, deshalb ist 1 der letzte Benutzer.
if (dao.titelMitCover(coverArtId) <= 1) {
runCatching { File(coversDir(), "$coverArtId.jpg").delete() }
track.coverPath?.let { runCatching { File(it).delete() } }
}
}
dao.deleteTrackById(songId)
}
// ── Delete with reference-count file cleanup ──────────────────────────────
@@ -173,22 +253,18 @@ class OfflineRepository(context: Context) {
/** Removes a group and any tracks that are no longer referenced by another group. */
suspend fun deleteGroup(groupId: String) {
val songIds = dao.songIdsForGroup(groupId)
// Laufende Downloadarbeit stoppen, sonst laedt sie noch Minuten weiter ins Leere.
WorkManager.getInstance(appContext).cancelUniqueWork(groupId)
dao.deleteGroup(groupId) // cascades GroupTrackCrossRef rows
for (songId in songIds) {
if (dao.groupCountForSong(songId) == 0) {
_audioMap.value[songId]?.let { File(it).delete() }
dao.deleteTrackById(songId)
}
if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
}
}
/** Removes a single track from a group, deleting the file if no other group still needs it. */
suspend fun removeSongFromGroup(groupId: String, songId: String) {
dao.removeCrossRef(groupId, songId)
if (dao.groupCountForSong(songId) == 0) {
_audioMap.value[songId]?.let { File(it).delete() }
dao.deleteTrackById(songId)
}
if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId)
recountGroup(groupId)
}
@@ -238,7 +238,14 @@ class MusicService : MediaLibraryService() {
override fun onGetSession(info: MediaSession.ControllerInfo) = session
override fun onTaskRemoved(rootIntent: Intent?) { player.stop(); stopSelf() }
override fun onTaskRemoved(rootIntent: Intent?) {
// Wischt jemand die App aus den letzten Apps, wird der Prozess gleich beendet — hier ist die
// letzte Gelegenheit, die Warteschlange festzuhalten. Ohne das ginge sie genau in dem Fall
// verloren, in dem man sie beim naechsten Oeffnen am ehesten erwartet.
runCatching { (application as? com.novasonic.app.NovaSonicApp)?.playerController?.saveQueueNow() }
player.stop()
stopSelf()
}
override fun onDestroy() {
scope.cancel()
@@ -37,6 +37,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
private val _state = MutableStateFlow(PlayerState())
val state: StateFlow<PlayerState> = _state.asStateFlow()
private val TAG_QUEUE = "NsQueue"
private val scope = CoroutineScope(
Dispatchers.Main + SupervisorJob() +
CoroutineExceptionHandler { _, t -> android.util.Log.e("PlayerController", "Uncaught coroutine exception", t) }
@@ -56,24 +58,95 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
controller = runCatching { future.get() }.getOrNull()
controller?.addListener(PlayerListener())
syncState()
restoreQueue()
}, context.mainExecutor)
}
}
/**
* Haelt Warteschlange und Listenposition fest — ohne die Sekunde im Titel: der zuletzt gehoerte
* Titel beginnt von vorn. ABS-Hoerbuecher bleiben aussen vor, die haben ihren eigenen
* Fortschritt auf dem Server.
*/
private fun persistQueueState() {
val ctrl = controller ?: return
val songs = queue
if (songs.isEmpty() || songs.any { it.id.startsWith("abs:") }) return
val index = ctrl.currentMediaItemIndex
scope.launch {
runCatching { repo.saveQueueState(songs, index) }
.onSuccess { android.util.Log.i(TAG_QUEUE, "gesichert: ${songs.size} Titel, Index $index") }
.onFailure { android.util.Log.w(TAG_QUEUE, "Sichern fehlgeschlagen", it) }
}
}
/** Von aussen aufrufbar (Dienst-Ende beim Wegwischen der App). */
fun saveQueueNow() = persistQueueState()
/**
* Stellt die zuletzt gehoerte Warteschlange wieder her — vorbereitet, aber NICHT abgespielt.
* Wer die App oeffnet, will nicht ueberfallen werden; ein Druck auf Play macht dort weiter.
* Laeuft schon etwas (etwa ueber Android Auto gestartet), passiert nichts.
*
* Die Aufloesung der Song-Ids braucht die Navidrome-Zugangsdaten. connect() laeuft aber schon in
* Application.onCreate(), wo der DataStore unter Umstaenden noch nicht gelesen ist — dann
* scheitern alle Abfragen und die Warteschlange bliebe still leer. Deshalb wird es mehrfach mit
* wachsendem Abstand versucht, statt beim ersten Fehlschlag aufzugeben.
*/
fun restoreQueue() {
scope.launch {
val zustand = runCatching { repo.readQueueState() }.getOrNull()
if (zustand == null) {
android.util.Log.i(TAG_QUEUE, "nichts gesichert — nichts wiederherzustellen")
return@launch
}
val ctrl = controller
if (ctrl == null) {
android.util.Log.w(TAG_QUEUE, "kein Controller — Wiederherstellen verworfen")
return@launch
}
if (ctrl.mediaItemCount > 0) {
android.util.Log.i(TAG_QUEUE, "Player hat bereits Titel — nicht angefasst")
return@launch
}
val songs = zustand.songs
val index = zustand.index.coerceIn(0, songs.lastIndex)
queue = songs
originalQueue = songs
runCatching {
ctrl.setMediaItems(songs.map { it.toMediaItem(repo) }, index, 0L)
ctrl.prepare()
}.onFailure { android.util.Log.w(TAG_QUEUE, "setMediaItems fehlgeschlagen", it) }
syncState()
android.util.Log.i(TAG_QUEUE, "wiederhergestellt: ${songs.size} Titel, Index $index")
}
}
// ── Playback commands ─────────────────────────────────────────────────────
/**
* Startet eine Liste. Ein `startIndex` KLEINER 0 bedeutet "gemischt starten".
*
* Warum das Mischen hier passiert und nicht beim Aufrufer: vorher mischte jeder
* Shuffle-Knopf selbst (`liste.shuffled()`) und uebergab die fertige Reihenfolge. Damit war
* `originalQueue` schon die gemischte Liste, `isManuallyShuffled` blieb false — das
* Shuffle-Symbol leuchtete nicht, obwohl gemischt gespielt wurde, und ein Druck auf
* "Shuffle aus" stellte die Albumreihenfolge nie wieder her, weil es keine mehr gab.
*/
fun playSongs(songs: List<Song>, startIndex: Int = 0) {
// If ABS is active, close it — user explicitly switched to music
(context.applicationContext as? NovaSonicApp)?.absPlayerController?.let { abs ->
if (abs.isActive) abs.close()
}
val ctrl = controller ?: return
queue = songs
originalQueue = songs
isManuallyShuffled = false
val gemischt = startIndex < 0
val reihenfolge = if (gemischt) songs.shuffled() else songs
queue = reihenfolge
originalQueue = songs // immer die UNGEMISCHTE Liste — Rueckweg fuer "Shuffle aus"
isManuallyShuffled = gemischt
ctrl.shuffleModeEnabled = false
val items = songs.map { it.toMediaItem(repo) }
ctrl.setMediaItems(items, startIndex, 0L)
val items = reihenfolge.map { it.toMediaItem(repo) }
ctrl.setMediaItems(items, if (gemischt) 0 else startIndex.coerceIn(0, maxOf(0, songs.lastIndex)), 0L)
ctrl.prepare()
ctrl.play()
syncState()
@@ -142,6 +215,40 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
ctrl.addMediaItem(song.toMediaItem(repo))
}
// ── Warteschlange bearbeiten ────────────────────────────────────────────
// queue (lokal) und die MediaItems des Controllers muessen im Gleichschritt bleiben:
// syncState() liest den laufenden Index vom Controller, den Titel dazu aber aus queue.
// Liefen die beiden auseinander, zeigte die Oberflaeche den falschen Song an.
/** Entfernt Position [index]. Traf es den laufenden Titel, rueckt Media3 selbst weiter. */
fun entferneAusQueue(index: Int) {
val ctrl = controller ?: return
if (index !in queue.indices) return
val raus = queue[index]
queue = queue.toMutableList().apply { removeAt(index) }
// originalQueue haelt die Reihenfolge fuer das Zuruecknehmen des Mischens — dort denselben
// Titel EINMAL entfernen, sonst taucht er beim Entmischen wieder auf.
originalQueue = originalQueue.toMutableList().apply {
val i = indexOfFirst { it.id == raus.id }
if (i >= 0) removeAt(i)
}
ctrl.removeMediaItem(index)
if (queue.isEmpty()) {
ctrl.stop()
ctrl.clearMediaItems()
}
syncState()
}
/** Verschiebt Position [von] auf [nach]. */
fun verschiebeInQueue(von: Int, nach: Int) {
val ctrl = controller ?: return
if (von == nach || von !in queue.indices || nach !in queue.indices) return
queue = queue.toMutableList().apply { add(nach, removeAt(von)) }
ctrl.moveMediaItem(von, nach)
syncState()
}
fun toggleRepeat() {
val ctrl = controller ?: return
ctrl.repeatMode = when (ctrl.repeatMode) {
@@ -155,22 +262,22 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val ctrl = controller ?: return
val currentIdx = ctrl.currentMediaItemIndex
if (!isManuallyShuffled) {
// Der Rest hinter dem laufenden Titel wird in EINEM Zug ersetzt. Vorher lief das als
// Schleife aus removeMediaItem + addMediaItem, und dazwischen hatte der Controller
// voruebergehend weniger Titel als unsere Liste. Genau darauf reagiert onTimelineChanged
// mit "die Titel kamen von aussen" — es baute die Warteschlange neu auf und setzte dabei
// isManuallyShuffled ZURUECK. Das Mischen lief, aber das Symbol leuchtete nie.
val neuerRest = if (!isManuallyShuffled) {
isManuallyShuffled = true
val played = queue.take(currentIdx + 1)
val remaining = queue.drop(currentIdx + 1).shuffled()
queue = played + remaining
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
remaining.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
queue.drop(currentIdx + 1).shuffled()
} else {
isManuallyShuffled = false
val currentSong = queue.getOrNull(currentIdx)
val origIdx = originalQueue.indexOfFirst { it.id == currentSong?.id }.coerceAtLeast(0)
val restOfOriginal = originalQueue.drop(origIdx + 1)
queue = originalQueue.take(origIdx + 1) + restOfOriginal
for (i in ctrl.mediaItemCount - 1 downTo currentIdx + 1) ctrl.removeMediaItem(i)
restOfOriginal.forEach { s -> ctrl.addMediaItem(s.toMediaItem(repo)) }
originalQueue.drop(origIdx + 1)
}
queue = queue.take(currentIdx + 1) + neuerRest
ctrl.replaceMediaItems(currentIdx + 1, ctrl.mediaItemCount, neuerRest.map { it.toMediaItem(repo) })
syncState()
}
@@ -207,7 +314,10 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
}
if (candidates.isEmpty()) {
// Letzte Rueckfallebene: Wiederholungen sind erlaubt, damit Musik weiterlaeuft — aber
// nicht der gerade laufende Titel. Der haengte sich sonst direkt hinter sich selbst.
candidates = runCatching { repo.getRandomSongs() }.getOrDefault(emptyList())
.filter { it.id != currentSong.id }
}
val ctrl = controller ?: return
@@ -317,6 +427,8 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) startTicker() else stopTicker()
syncState()
// Beim Pausieren die Position festhalten: von da soll es beim naechsten Start weitergehen.
if (!isPlaying) persistQueueState()
}
override fun onPlaybackStateChanged(state: Int) = syncState()
@@ -336,6 +448,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0)
scheduleScrobble(song, dur)
scope.launch { runCatching { repo.saveLastSong(song) } }
persistQueueState()
prefetchLyrics(song)
}
@@ -123,11 +123,37 @@ fun AppNavigation() {
}
val navController = rememberNavController()
// Geteilten Link aufloesen, sobald die Navigation steht: Song abspielen und sein Album oeffnen,
// bzw. direkt zum Album. Laeuft ueber denselben Weg wie im Web, damit sich beide gleich
// verhalten. Fehlschlaege (geloeschter Song, fremde Instanz) enden in einer Meldung statt still.
LaunchedEffect(Unit) {
com.novasonic.app.data.DeepLinkState.pending.collect { uri ->
if (uri == null) return@collect
com.novasonic.app.data.DeepLinkState.consume()
val songId = uri.getQueryParameter("song")
val albumId = uri.getQueryParameter("album")
runCatching {
when {
songId != null -> {
val song = repo.getSong(songId)
song.albumId?.let { navController.navigate("album/$it") }
player.playSongs(listOf(song), 0)
}
albumId != null -> navController.navigate("album/$albumId")
}
}.onFailure {
Toast.makeText(context, "Link konnte nicht geöffnet werden: ${it.message}",
Toast.LENGTH_LONG).show()
}
}
}
var showNowPlaying by remember { mutableStateOf(false) }
var contextMenuSong by remember { mutableStateOf<Song?>(null) }
var playlistPickerSong by remember { mutableStateOf<Song?>(null) }
var infoSheetSong by remember { mutableStateOf<Song?>(null) }
var songToDelete by remember { mutableStateOf<Song?>(null) }
var showNotifications by remember { mutableStateOf(false) }
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
var showAbsPlayer by remember { mutableStateOf(false) }
@@ -350,7 +376,7 @@ fun AppNavigation() {
onGenrePlay = { genre ->
scope.launch { runCatching {
val songs = repo.getSongsByGenre(genre.value)
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
}}
},
onShowAllGenres = { navController.navigate("genres") },
@@ -389,6 +415,19 @@ fun AppNavigation() {
onPlay = { songs, idx -> player.playSongs(songs, idx) },
onStarToggle = { song -> starToggle(song.id, song.starred != null) },
onShowContextMenu = ::showContextMenu,
onArtistClick = { id -> navController.navigate("artist/$id") },
onAlbumClick = { id -> navController.navigate("album/$id") },
onOpenNews = { navController.navigate("artist-news") },
onOpenFollowed = {
com.novasonic.app.data.LibraryIntent.oeffneGefolgte = true
navController.navigate(TopLevel.LIBRARY.route)
},
)
}
composable("artist-news") {
ArtistNewsScreen(
onBack = { navController.popBackStack() },
onArtistClick = { id -> navController.navigate("artist/$id") },
)
}
composable(TopLevel.ABS.route) {
@@ -508,7 +547,7 @@ fun AppNavigation() {
onGenrePlay = { genre ->
scope.launch { runCatching {
val songs = repo.getSongsByGenre(genre.value)
if (songs.isNotEmpty()) player.playSongs(songs.shuffled(), 0)
if (songs.isNotEmpty()) player.playSongs(songs, -1) // -1 = gemischt
}}
},
onBack = { navController.popBackStack() },
@@ -582,6 +621,58 @@ fun AppNavigation() {
Toast.makeText(context, "Download gestartet: ${song.title}", android.widget.Toast.LENGTH_SHORT).show()
contextMenuSong = null
},
// Link auf die eigene Web-App, den der Empfaenger mit SEINEN Zugangsdaten oeffnet —
// uebertragen wird nur die Song-Id, kein Geheimnis. Der Host kommt aus BuildConfig
// (dieselbe Angabe wie fuer die App-Links im Manifest), damit es keine vierte
// Adresse zum Pflegen gibt und beide Richtungen nie auseinanderlaufen koennen.
onShare = {
val link = "https://${com.novasonic.app.BuildConfig.SHARE_HOST}/?song=${song.id}"
val text = listOfNotNull(song.title, song.artist).joinToString(" — ")
context.startActivity(android.content.Intent.createChooser(
android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(android.content.Intent.EXTRA_SUBJECT, text)
putExtra(android.content.Intent.EXTRA_TEXT, "$text\n$link")
}, "Song teilen"))
},
// Nur mit Backend: das Loeschen laeuft ueber /api/delete-song, Navidrome allein
// kann keine Dateien entfernen.
onDelete = if (appSettings.backendUrl.isNotBlank()) {
{ songToDelete = song; contextMenuSong = null }
} else null,
)
}
// Sicherheitsabfrage vor dem endgueltigen Loeschen — Wortlaut wie im Web.
songToDelete?.let { song ->
AlertDialog(
onDismissRequest = { songToDelete = null },
title = { Text("Song löschen") },
text = { Text("\"${song.title}\" wird unwiderruflich von der Festplatte gelöscht. " +
"Leere Album- und Künstlerordner werden ebenfalls entfernt.") },
confirmButton = {
TextButton(onClick = {
songToDelete = null
scope.launch {
val creds = repo.lastCredentials
val client = com.novasonic.app.api.ImporterClient(appSettings.backendUrl)
val res = runCatching {
client.deleteSong(creds?.serverUrl ?: "", creds?.username ?: "",
creds?.password ?: "", song.id)
}
client.close()
Toast.makeText(
context,
res.fold({ "🗑 \"${song.title}\" gelöscht" },
{ "Löschen fehlgeschlagen: ${it.message}" }),
Toast.LENGTH_LONG,
).show()
// Navidrome braucht einen Moment zum Nachscannen, dann die Ansicht neu laden.
if (res.isSuccess) { kotlinx.coroutines.delay(3000); homeRefreshKey++ }
}
}) { Text("Löschen", color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = { songToDelete = null }) { Text("Abbrechen") } },
)
}
@@ -0,0 +1,16 @@
package com.novasonic.app.ui
/**
* Gesamtdauer einer Playlist oder eines Albums, in Sekunden hereingereicht.
*
* Ab einer Stunde werden Stunden ausgewiesen: "134 Min." muss man sonst selbst umrechnen, und bei
* einer Playlist mit 2000 Titeln staende dort eine vierstellige Minutenzahl. Gleiche Regel und
* gleiche Schreibweise wie `formatGesamtdauer` im Web (src/utils.js).
*/
fun formatGesamtdauer(sekunden: Int): String {
val min = Math.round((sekunden.coerceAtLeast(0)) / 60.0).toInt()
if (min < 60) return "$min Min."
val std = min / 60
val rest = min % 60
return if (rest > 0) "$std Std. $rest Min." else "$std Std."
}
@@ -19,6 +19,8 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Geteilter Link, mit dem die App gestartet wurde.
com.novasonic.app.data.DeepLinkState.offer(intent?.data)
enableEdgeToEdge()
setContent {
NovaSonicTheme {
@@ -28,6 +30,14 @@ class MainActivity : ComponentActivity() {
}
}
}
// launchMode=singleTask: laeuft die App bereits, kommt ein weiterer Link hier an statt in
// onCreate. Ohne das passierte beim zweiten geteilten Link schlicht nichts.
override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
com.novasonic.app.data.DeepLinkState.offer(intent.data)
}
}
@Composable
@@ -9,7 +9,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -26,6 +25,8 @@ import com.novasonic.app.ui.theme.*
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import androidx.compose.ui.res.painterResource
import com.novasonic.app.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -49,7 +50,7 @@ fun NotificationSheet(
.fillMaxWidth()
.padding(start = 20.dp, end = 8.dp, top = 18.dp, bottom = 12.dp),
) {
Icon(Icons.Default.Notifications, null,
Icon(painterResource(R.drawable.ic_honigtopf_brief), null,
tint = NsAccent, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(8.dp))
Text(
@@ -0,0 +1,174 @@
package com.novasonic.app.ui.component
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/**
* Rollbalken zum Ziehen fuer eine [LazyListState] — das Gegenstueck zum verbreiterten Balken im Web.
*
* Compose bringt keinen mit: eine LazyColumn zeigt gar nichts an, egal wie lang sie ist. Bei einer
* Playlist mit 2000 Titeln bleibt so nur Wischen, bis der Daumen glueht.
*
* Gerechnet wird in ITEMS, nicht in Pixeln: die tatsaechliche Gesamthoehe einer LazyColumn ist
* unbekannt, solange nicht alles gebaut wurde, und bei unterschiedlich hohen Zeilen laesst sie sich
* auch nicht ausrechnen. Ueber den Anteil sichtbarer an allen Eintraegen stimmt der Balken bei
* gleich hohen Zeilen — und genau die haben Song- und Albumlisten.
*
* Drei Fallen steckten hier drin, alle beim Ziehen:
*
* 1. **Die Greifflaeche muss breiter sein als der sichtbare Balken.** Sichtbar 6 dp, anfassbar
* [griffBreite]. 6 dp am Bildschirmrand trifft mit dem Finger niemand.
* 2. **Der erste Fingerkontakt muss verbraucht werden**, sonst startet die LazyColumn ihre eigene
* Scrollgeste, sobald die Wischschwelle faellt, und der Balken sieht die Bewegung nie.
* 3. **`pointerInput` darf NICHT auf die Geometrie geschluesselt sein.** Genau das war der Grund,
* warum das Ziehen unbrauchbar blieb: die Daumenposition steht in jedem Bild anders da, ein
* Schluessel darauf setzt die Gestenerkennung also mitten in der Geste neu auf — der Finger
* liegt noch auf dem Schirm, die Erkennung faengt aber wieder bei null an. Der Schluessel ist
* deshalb `Unit`; die wechselnden Werte liest die Geste ueber [rememberUpdatedState] zur
* Laufzeit nach.
*
* Gescrollt wird nicht im Zeigerereignis, sondern in einem eigenen Effekt, der [zielPos] verfolgt.
* Ein `launch` pro Fingerbewegung erzeugte bei 2000 Titeln hunderte konkurrierende Scroll-Aufrufe.
*
* Und eine vierte, die erst nach dem Ausliefern auffiel: **nur der Daumen nimmt Beruehrungen an,
* nicht die ganze Spur.** Compose reicht eine Beruehrung nur an das oberste getroffene Element
* weiter — ein Streifen ueber die volle Hoehe verschluckte damit das ⋮ jeder Songzeile, obwohl er
* die Beruehrung gar nicht wollte. Der aeussere Rahmen hat deshalb KEIN `pointerInput` und ist
* dadurch durchlaessig; nur der Daumen selbst hoert zu, mit etwas Luft nach oben und unten.
*/
@Composable
fun Rollbalken(
state: LazyListState,
modifier: Modifier = Modifier,
breite: Dp = 6.dp,
griffBreite: Dp = 24.dp,
minHoehe: Dp = 44.dp,
farbe: Color = Color.White,
) {
val dichte = LocalDensity.current
var spurPx by remember { mutableFloatStateOf(0f) }
var ziehen by remember { mutableStateOf(false) }
// Beim Ziehen fuehrt diese Kommazahl. Wuerde jeder Zeigerschritt aus firstVisibleItemIndex neu
// rechnen, ginge der Rest unterhalb eines ganzen Eintrags jedes Mal verloren — der Balken
// bliebe bei langsamem Ziehen stehen.
var zielPos by remember { mutableFloatStateOf(0f) }
val info = state.layoutInfo
val gesamt = info.totalItemsCount
val zuSehen = info.visibleItemsInfo.size
if (gesamt == 0 || zuSehen == 0 || gesamt <= zuSehen) return
val aktiv = state.isScrollInProgress || ziehen
val alpha by animateFloatAsState(
targetValue = if (aktiv) 0.9f else 0.3f,
animationSpec = tween(if (aktiv) 120 else 450),
label = "rollbalken-alpha",
)
val maxIndex = (gesamt - zuSehen).coerceAtLeast(1)
val minHoehePx = with(dichte) { minHoehe.toPx() }
val daumenPx = (spurPx * zuSehen / gesamt).coerceIn(minHoehePx.coerceAtMost(spurPx), spurPx)
val frei = (spurPx - daumenPx).coerceAtLeast(0f)
val anteil = if (ziehen) zielPos / maxIndex
else state.firstVisibleItemIndex.toFloat() / maxIndex
val daumenY = frei * anteil.coerceIn(0f, 1f)
val spielPx = with(dichte) { 10.dp.toPx() } // Luft ober- und unterhalb des Daumens
// Was die Geste zur Laufzeit braucht — nicht als pointerInput-Schluessel, siehe oben.
val geo = rememberUpdatedState(Geometrie(maxIndex.toFloat(), frei, daumenY, daumenPx, spielPx))
// Die Liste folgt zielPos in EINER Koroutine statt in einer je Fingerbewegung.
LaunchedEffect(ziehen) {
if (!ziehen) return@LaunchedEffect
snapshotFlow { zielPos.roundToInt() }.collect { state.scrollToItem(it) }
}
Box(
modifier
.width(griffBreite)
.fillMaxHeight()
.onSizeChanged { spurPx = it.height.toFloat() }
) {
val luftDp = with(dichte) { spielPx.toDp() }
Box(
Modifier
.align(Alignment.TopEnd)
// Der Greifbereich beginnt eine Spielbreite ueber dem Daumen und ist entsprechend
// hoeher; der sichtbare Balken sitzt durch das Innenmass wieder genau auf daumenY.
.offset { IntOffset(0, (daumenY - spielPx).roundToInt()) }
.width(griffBreite)
.height(with(dichte) { (daumenPx + 2 * spielPx).toDp() })
.pointerInput(Unit) {
awaitEachGesture {
val start = awaitFirstDown(requireUnconsumed = false)
if (geo.value.frei <= 0f) return@awaitEachGesture
start.consume()
zielPos = state.firstVisibleItemIndex.toFloat()
ziehen = true
try {
while (true) {
val ereignis = awaitPointerEvent()
val punkt = ereignis.changes.firstOrNull { it.id == start.id } ?: break
if (!punkt.pressed) break
val delta = punkt.positionChange().y
punkt.consume()
if (delta != 0f) {
val jetzt = geo.value
if (jetzt.frei > 0f) {
zielPos = (zielPos + delta * jetzt.maxIndex / jetzt.frei)
.coerceIn(0f, jetzt.maxIndex)
}
}
}
} finally {
ziehen = false
}
}
}
.padding(vertical = luftDp, horizontal = 0.dp),
contentAlignment = Alignment.CenterEnd,
) {
Box(
Modifier
.padding(end = 2.dp)
.width(breite)
.fillMaxHeight()
.clip(RoundedCornerShape(50))
.background(farbe.copy(alpha = alpha))
)
}
}
}
/** Nur ein Buendel der Werte, die die Geste zur Laufzeit nachliest. */
private data class Geometrie(
val maxIndex: Float,
val frei: Float,
val daumenY: Float,
val daumenPx: Float,
val spiel: Float,
)
@@ -33,6 +33,8 @@ fun SongContextSheet(
onGoToAlbum: (() -> Unit)? = null,
onShowInfo: (() -> Unit)? = null,
onDownload: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
onShare: (() -> Unit)? = null,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
@@ -77,6 +79,16 @@ fun SongContextSheet(
onGoToAlbum?.let { CtxItem(Icons.Default.Album, "Zum Album", onClick = { onDismiss(); it() }) }
onShowInfo?.let { CtxItem(Icons.Default.Info, "Song-Info", onClick = { onDismiss(); it() }) }
onDownload?.let { CtxItem(Icons.Default.Download, "Herunterladen", onClick = { onDismiss(); it() }) }
onShare?.let { CtxItem(Icons.Default.Share, "Teilen", onClick = { onDismiss(); it() }) }
}
// Loeschen steht bewusst unten, abgesetzt und rot: es entfernt die Datei unwiderruflich von
// der Platte. Die Sicherheitsabfrage sitzt beim Aufrufer, wie im Web auch.
onDelete?.let {
HorizontalDivider(color = NsBorder, modifier = Modifier.padding(vertical = 4.dp))
CtxItem(Icons.Default.DeleteForever, "Von Festplatte löschen",
tint = MaterialTheme.colorScheme.error,
onClick = { onDismiss(); it() })
}
Spacer(Modifier.height(24.dp))
@@ -45,7 +45,10 @@ fun SongRow(
.fillMaxWidth()
.clickable(onClick = onPlay)
.background(if (isPlaying) NsAccent.copy(alpha = 0.08f) else Color.Transparent)
.padding(start = if (isPlaying) 0.dp else 4.dp),
// Rechts 24 dp frei halten: genau dort liegt der Rollbalken, und ein ⋮ darunter
// waere nicht mehr zu treffen — Compose gibt eine Beruehrung nur an das oberste
// getroffene Element. Gilt ueberall, damit die Zeilen gleich aussehen.
.padding(start = if (isPlaying) 0.dp else 4.dp, end = 24.dp),
) {
// Accent left bar when playing
if (isPlaying) {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -11,6 +12,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import com.novasonic.app.data.StarState
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.*
@@ -34,6 +38,8 @@ import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.AlbumViewModel
import com.novasonic.app.ui.component.Rollbalken
import com.novasonic.app.ui.formatGesamtdauer
@Composable
fun AlbumScreen(
@@ -72,152 +78,178 @@ fun AlbumScreen(
return
}
LazyColumn(Modifier.fillMaxSize()) {
item {
// ── Hero ─────────────────────────────────────────────────────────
Box(Modifier.fillMaxWidth()) {
AsyncImage(
model = coverUrl(album.coverArt),
contentDescription = album.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
// gradient overlay
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(
Brush.verticalGradient(
0.0f to Color.Transparent,
0.40f to NsBackground.copy(alpha = 0.35f),
1.0f to NsBackground,
val listState = rememberLazyListState()
Box(Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), state = listState) {
item {
// ── Hero ─────────────────────────────────────────────────────────
Box(Modifier.fillMaxWidth()) {
AsyncImage(
model = coverUrl(album.coverArt),
contentDescription = album.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
// gradient overlay
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(
Brush.verticalGradient(
0.0f to Color.Transparent,
0.40f to NsBackground.copy(alpha = 0.35f),
1.0f to NsBackground,
)
)
)
// back button — frosted circle
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp),
)
}
// album info overlaid at bottom
Column(
Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
album.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
album.artist!!, fontFamily = Outfit,
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
Modifier.clickable { onArtistClick(album.artistId!!) }
else Modifier,
)
}
val meta = listOfNotNull(
album.year?.toString(),
album.genre?.takeIf { it.isNotBlank() },
if (album.songCount > 0) "${album.songCount} Songs" else null,
if (album.duration > 0) formatGesamtdauer(album.duration) else null,
).joinToString(" · ")
if (meta.isNotBlank()) {
Spacer(Modifier.height(3.dp))
Text(
meta, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.55f),
)
}
}
}
// ── Play / Shuffle / Merken / Download ────────────────────────────
// Gleiche Leiste wie beim Playlist-Detail: vier gleich breite, reine Symbolknoepfe.
// Play und Shuffle trugen hier noch Beschriftungen, waehrend Herz und Download fest
// 46 dp breit waren — die Reihe war dadurch ungleich geteilt und die Schrift wurde auf
// schmalen Geraeten abgeschnitten. Play bleibt gefuellt als Hauptaktion; die uebrigen
// sind umrandet und faerben sich erst accent, wenn sie aktiv sind.
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { onPlay(album.song, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
}
OutlinedButton(
onClick = { onPlay(album.song, -1) }, // -1 = gemischt, der Player mischt
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
}
// Album merken — derselbe Weg wie beim Song: Navidrome behandelt Song-, Album- und
// Kuenstler-IDs beim Markieren gleich.
val overrides by StarState.overrides.collectAsStateWithLifecycle()
val gemerkt = overrides[album.id] ?: (album.starred != null)
OutlinedButton(
onClick = { vm.merkenUmschalten(album.id, album.starred != null) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (gemerkt) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(
if (gemerkt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
if (gemerkt) "Gemerkt" else "Album merken",
Modifier.size(20.dp), tint = if (gemerkt) NsAccent else NsOnSurface2,
)
}
val isFullyDownloaded = downloadGroup != null &&
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
downloadGroup!!.totalTracks > 0
OutlinedButton(
// Tappable unless fully done — resumes / re-downloads any missing songs.
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (isFullyDownloaded) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
when {
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", Modifier.size(20.dp), tint = NsAccent)
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsOnSurface2)
}
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(album.song, key = { _, s -> s.id }) { index, song ->
SongRow(
song = song,
coverUrl = null,
isPlaying = playerState.currentSong?.id == song.id,
trackNumber = song.track ?: (index + 1),
starred = song.starred != null,
onPlay = { onPlay(album.song, index) },
onStarToggle = onStarToggle?.let { cb -> { cb(song) } },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
// back button — frosted circle
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp),
)
}
// album info overlaid at bottom
Column(
Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
album.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
if (!album.artist.isNullOrBlank()) {
Spacer(Modifier.height(4.dp))
Text(
album.artist!!, fontFamily = Outfit,
fontWeight = FontWeight.Medium, fontSize = 15.sp, color = NsAccent,
modifier = if (onArtistClick != null && !album.artistId.isNullOrBlank())
Modifier.clickable { onArtistClick(album.artistId!!) }
else Modifier,
)
}
val meta = listOfNotNull(
album.year?.toString(),
album.genre?.takeIf { it.isNotBlank() },
if (album.songCount > 0) "${album.songCount} Songs" else null,
).joinToString(" · ")
if (meta.isNotBlank()) {
Spacer(Modifier.height(3.dp))
Text(
meta, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.55f),
)
}
}
}
// ── Play / Shuffle row ────────────────────────────────────────────
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { onPlay(album.song, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
OutlinedButton(
onClick = { onPlay(album.song.shuffled(), 0) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
}
val isFullyDownloaded = downloadGroup != null &&
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
downloadGroup!!.totalTracks > 0
OutlinedButton(
// Tappable unless fully done — resumes / re-downloads any missing songs.
onClick = { if (!isFullyDownloaded) vm.downloadAlbum() },
modifier = Modifier.height(46.dp).width(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(
when {
isFullyDownloaded -> MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
else -> NsAccent.copy(alpha = 0.55f)
}
)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
when {
isFullyDownloaded -> Icon(Icons.Default.CheckCircle, "Downloaded", Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary)
isDownloading -> CircularProgressIndicator(Modifier.size(18.dp), color = NsAccent, strokeWidth = 2.dp)
else -> Icon(Icons.Default.Download, "Herunterladen", Modifier.size(20.dp), tint = NsAccent)
}
}
}
HorizontalDivider(color = NsBorder)
item { Spacer(Modifier.height(16.dp)) }
}
itemsIndexed(album.song, key = { _, s -> s.id }) { index, song ->
SongRow(
song = song,
coverUrl = null,
isPlaying = playerState.currentSong?.id == song.id,
trackNumber = song.track ?: (index + 1),
starred = song.starred != null,
onPlay = { onPlay(album.song, index) },
onStarToggle = onStarToggle?.let { cb -> { cb(song) } },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(16.dp)) }
Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
)
}
}
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Event
import com.novasonic.app.data.StarState
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
@@ -82,7 +85,7 @@ fun ArtistDetailScreen(
LaunchedEffect(state.pendingShufflePlay) {
state.pendingShufflePlay?.let { songs ->
onPlay(songs, 0)
onPlay(songs, -1) // -1 = gemischt starten, der Player mischt
vm.consumeShufflePlay()
}
}
@@ -215,11 +218,14 @@ fun ArtistDetailScreen(
title = openAllAlbums ?: "",
entries = allAlbumEntries,
onBack = { openAllAlbums = null },
gridState = vm.allAlbumsGrid,
query = vm.allAlbumsQuery.value,
onQueryChange = { vm.allAlbumsQuery.value = it },
)
return
}
LazyColumn(Modifier.fillMaxSize()) {
LazyColumn(state = vm.pageListState, modifier = Modifier.fillMaxSize()) {
// Back link
item {
Row(
@@ -257,6 +263,24 @@ fun ArtistDetailScreen(
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
// Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste darunter: die Leiste
// ist "was ich jetzt tue", Folgen ist ein Zustand. Deshalb als Herz ins Banner,
// direkt neben das Zahnrad — genau wie im Web.
val overrides by StarState.overrides.collectAsStateWithLifecycle()
val folgt = overrides[artist.id] ?: (artist.starred != null)
Surface(
onClick = { vm.folgenUmschalten(artist.id, artist.starred != null) },
shape = RoundedCornerShape(999.dp),
color = Color(0x59000000),
modifier = Modifier.align(Alignment.TopEnd).padding(top = 10.dp, end = 52.dp),
) {
Icon(
if (folgt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
if (folgt) "Du folgst ${artist.name}" else "${artist.name} folgen",
tint = if (folgt) NsAccent else Color.White.copy(0.72f),
modifier = Modifier.padding(7.dp).size(17.dp),
)
}
// "Wrong artist?" pin — minimal icon-only chip, top-right over the hero.
Surface(
onClick = { vm.openPinPicker() },
@@ -624,8 +648,10 @@ internal fun AllAlbumsOverlay(
title: String,
entries: List<AllAlbumEntry>,
onBack: () -> Unit,
gridState: androidx.compose.foundation.lazy.grid.LazyGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState(),
query: String = "",
onQueryChange: (String) -> Unit = {},
) {
var query by remember { mutableStateOf("") }
val filtered = remember(query, entries) {
if (query.isBlank()) entries
else entries.filter { it.name.contains(query, ignoreCase = true) }
@@ -660,11 +686,11 @@ internal fun AllAlbumsOverlay(
// Search bar
OutlinedTextField(
value = query,
onValueChange = { query = it },
onValueChange = onQueryChange,
placeholder = { Text("Suchen…", color = NsOnSurface2) },
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
trailingIcon = if (query.isNotEmpty()) { {
IconButton(onClick = { query = "" }) {
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Default.Clear, null, tint = NsOnSurface2)
}
} } else null,
@@ -684,6 +710,7 @@ internal fun AllAlbumsOverlay(
)
LazyVerticalGrid(
state = gridState,
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -0,0 +1,352 @@
package com.novasonic.app.ui.screen
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.ArtistRelease
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ArtistNewsViewModel
import com.novasonic.app.viewmodel.NewsReiter
import com.novasonic.app.api.model.ArtistKonzert
import androidx.compose.ui.platform.LocalUriHandler
import java.text.SimpleDateFormat
import java.util.Locale
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.style.TextDecoration
/**
* Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. Nach Monat gruppiert,
* neueste zuerst. Was schon in der Bibliothek liegt, ist markiert statt anklickbar.
*/
@Composable
fun ArtistNewsScreen(
onBack: () -> Unit,
onArtistClick: (String) -> Unit,
) {
val vm: ArtistNewsViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val ctx = LocalContext.current
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.statusBarsPadding().padding(start = 4.dp, top = 4.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = MaterialTheme.colorScheme.onSurface)
}
val konzerte = state.reiter == NewsReiter.KONZERTE
Text(
if (konzerte) "Konzerte deiner Künstler" else "Neu von deinen Künstlern",
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface,
)
}
// Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind dasselbe
// Thema. Bewusst NICHT in eine gemeinsame Liste gemischt — Veroeffentlichungen liegen
// hinter, Konzerte vor dem heutigen Tag.
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
) {
listOf(
NewsReiter.RELEASES to "Neu erschienen",
NewsReiter.KONZERTE to "Konzerte",
).forEach { (r, label) ->
FilterChip(
selected = state.reiter == r,
onClick = { vm.zeigeReiter(r) },
label = { Text(label, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent,
),
)
}
}
// Das Zeitfenster gilt nur fuer die Veroeffentlichungen. Konzerte sind ohnehin Zukunft,
// dort will man alles Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
if (state.reiter == NewsReiter.RELEASES) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 10.dp),
) {
// 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn.
listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) ->
FilterChip(
selected = state.tage == tage,
onClick = { vm.setzeTage(tage) },
label = { Text(label, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent,
),
)
}
}
} else {
Spacer(Modifier.height(10.dp))
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
when {
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
state.keinBackend -> Hinweis("🔌", "Kein Backend konfiguriert",
"Neue Veröffentlichungen kommen über das Backend von Deezer.")
state.keineGefolgten -> Hinweis("❤", "Du folgst noch niemandem",
"Auf einer Künstlerseite oben rechts aufs Herz tippen.")
state.error != null -> Hinweis("⚠", "Laden fehlgeschlagen", state.error!!)
state.reiter == NewsReiter.KONZERTE && state.keinSchluessel -> Hinweis("🎟",
"Kein Ticketmaster-Schlüssel hinterlegt", "Im Backend TICKETMASTER_API_KEY setzen.")
state.reiter == NewsReiter.KONZERTE && state.konzerte.isEmpty() -> Hinweis("🎪",
"Keine anstehenden Termine",
"Gesucht wird weltweit über den Künstlernamen.")
state.reiter == NewsReiter.KONZERTE -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
var letzterMonat = ""
state.konzerte.forEach { k ->
val monat = monatVon(k.datum)
if (monat != letzterMonat) {
letzterMonat = monat
item(key = "km_$monat") {
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
}
}
item(key = "k_${k.eventId}_${k.artistId}") {
KonzertZeile(k) { onArtistClick(k.artistId) }
}
}
}
state.releases.isEmpty() -> Hinweis("🌙",
"Nichts Neues in den letzten ${state.tage} Tagen", "")
else -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
if (state.ohneZuordnung.isNotEmpty()) {
item { ZuordnungsHinweis(state.ohneZuordnung) }
}
var letzterMonat = ""
state.releases.forEach { rel ->
val monat = monatVon(rel.releaseDate)
if (monat != letzterMonat) {
letzterMonat = monat
item(key = "m_$monat") {
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
}
}
item(key = "${rel.artistId}_${rel.albumId}") {
ReleaseZeile(
rel = rel,
schonDa = vm.hatSchon(rel),
onArtist = { onArtistClick(rel.artistId) },
onDownload = {
vm.herunterladen(rel) { _, msg ->
Toast.makeText(ctx, msg, Toast.LENGTH_SHORT).show()
}
},
)
}
}
}
}
}
}
@Composable
private fun Hinweis(symbol: String, titel: String, text: String) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(symbol, fontSize = 34.sp)
Spacer(Modifier.height(10.dp))
Text(titel, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
color = MaterialTheme.colorScheme.onSurface)
if (text.isNotBlank()) {
Spacer(Modifier.height(6.dp))
Text(text, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
}
}
}
@Composable
private fun ZuordnungsHinweis(namen: List<String>) {
Box(
Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
.clip(RoundedCornerShape(10.dp))
.background(NsAccent.copy(alpha = 0.08f))
.border(1.dp, NsAccent.copy(alpha = 0.28f), RoundedCornerShape(10.dp))
.padding(12.dp),
) {
Text(
"⚠ Für ${namen.size} Künstler wurde kein Deezer-Profil gefunden: " +
namen.take(4).joinToString(", ") + (if (namen.size > 4) " …" else "") +
". Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, lineHeight = 17.sp,
)
}
}
private val TYP = mapOf("album" to "Album", "single" to "Single", "ep" to "EP",
"compile" to "Compilation")
@Composable
private fun ReleaseZeile(
rel: ArtistRelease,
schonDa: Boolean,
onArtist: () -> Unit,
onDownload: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 7.dp),
) {
AsyncImage(
model = rel.cover.takeIf { it.isNotBlank() },
contentDescription = rel.title,
contentScale = ContentScale.Crop,
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(rel.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(rel.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(onClick = onArtist))
Text(
" · " + (TYP[rel.recordType] ?: rel.recordType) +
(rel.trackCount?.let { " · $it Titel" } ?: ""),
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
Text(datumVon(rel.releaseDate), fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface3, modifier = Modifier.padding(top = 2.dp))
}
Spacer(Modifier.width(8.dp))
if (schonDa) {
Icon(Icons.Default.CheckCircle, "In deiner Bibliothek",
tint = NsOnSurface3, modifier = Modifier.size(20.dp))
} else {
IconButton(onClick = onDownload) {
Icon(Icons.Default.Download, "Herunterladen", tint = NsAccent,
modifier = Modifier.size(20.dp))
}
}
}
}
// Deezer liefert "JJJJ-MM-TT".
private val EIN = SimpleDateFormat("yyyy-MM-dd", Locale.GERMAN)
private val MONAT = SimpleDateFormat("LLLL yyyy", Locale.GERMAN)
private val DATUM = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN)
private fun monatVon(s: String) = runCatching { MONAT.format(EIN.parse(s)!!) }.getOrDefault(s)
private fun datumVon(s: String) = runCatching { DATUM.format(EIN.parse(s)!!) }.getOrDefault(s)
// Ticketmaster liefert die ORTSZEIT ohne Zone: "2026-11-04T20:00:00".
private val EIN_ZEIT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMAN)
private val TAG_KURZ = SimpleDateFormat("d", Locale.GERMAN)
private val MON_KURZ = SimpleDateFormat("MMM", Locale.GERMAN)
private val UHR = SimpleDateFormat("HH:mm", Locale.GERMAN)
private fun zeitVon(s: String) = runCatching { EIN_ZEIT.parse(s) }.getOrNull()
/**
* Eine Konzertzeile. Statt eines Covers steht links ein Kalenderblatt — ein Konzert hat kein Bild,
* aber ein Datum, und danach sucht das Auge in einer Liste, die nach vorn läuft.
*/
@Composable
private fun KonzertZeile(k: ArtistKonzert, onArtist: () -> Unit) {
val uri = LocalUriHandler.current
val d = zeitVon(k.datum)
val ort = listOf(k.ort, k.stadt, k.land).filter { it.isNotBlank() }.joinToString(" · ")
val uhr = d?.let { UHR.format(it) }?.takeIf { it != "00:00" }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.size(50.dp)
.clip(RoundedCornerShape(10.dp))
.background(NsSurface)
.border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(10.dp))
.alpha(if (k.abgesagt) 0.45f else 1f),
) {
Text(d?.let { TAG_KURZ.format(it) } ?: "?", fontFamily = Outfit,
fontWeight = FontWeight.Bold, fontSize = 17.sp,
color = MaterialTheme.colorScheme.onSurface)
Text(d?.let { MON_KURZ.format(it) }?.uppercase() ?: "", fontFamily = Outfit,
fontSize = 10.sp, color = NsOnSurface2)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
k.titel.ifBlank { k.artistName },
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = if (k.abgesagt) NsOnSurface2 else MaterialTheme.colorScheme.onSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis,
textDecoration = if (k.abgesagt) TextDecoration.LineThrough else null,
)
Spacer(Modifier.height(2.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(k.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(onClick = onArtist))
val rest = listOfNotNull(ort.ifBlank { null }, uhr?.let { "$it Uhr" })
if (rest.isNotEmpty()) {
Text(" · " + rest.joinToString(" · "), fontFamily = Outfit, fontSize = 12.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
if (k.abgesagt) {
Text("abgesagt", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
} else if (k.url.isNotBlank()) {
TextButton(onClick = { uri.openUri(k.url) }) {
Text("Tickets", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp, color = NsAccent)
}
}
}
}
@@ -148,7 +148,7 @@ private fun CompareContent(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
) {
ScoreRing(d)
Scoreboard(d)
Spacer(Modifier.height(20.dp))
Totals(d)
Spacer(Modifier.height(22.dp))
@@ -173,6 +173,8 @@ private fun CompareContent(
Spacer(Modifier.height(22.dp))
}
Streitpunkte(d, onArtistClick)
SectionHeader("Nur bei…")
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f))
@@ -182,46 +184,102 @@ private fun CompareContent(
}
@Composable
private fun ScoreRing(d: CompareResult) {
val score = d.matchScore.coerceIn(0, 100)
val verdict = when {
score >= 80 -> "Seelenverwandte 🎯"
score >= 60 -> "Starkes Match 🔥"
score >= 40 -> "Solide Überschneidung 👍"
score >= 20 -> "Ein paar Gemeinsamkeiten 🙂"
else -> "Ziemlich unterschiedlich 🌗"
private fun Streitpunkte(d: CompareResult, onArtistClick: (String) -> Unit) {
// Gemeinsame Kuenstler mit dem groessten Ungleichgewicht — das Gegenstueck zu "Gemeinsame
// Kuenstler", das nach Summe sortiert. Mindestmasse, damit ein einzelner Play gegen null nicht
// als Streit gilt. Identische Schwellen wie im Web, damit beide dasselbe zeigen.
val items = d.sharedArtists
.map { a ->
val summe = a.playsA + a.playsB
Triple(a, summe, if (summe > 0) kotlin.math.abs(a.playsA - a.playsB) / summe.toFloat() else 0f)
}
.filter { (_, summe, unwucht) -> summe >= 4 && unwucht >= 0.5f }
.sortedWith(compareByDescending<Triple<SharedArtist, Int, Float>> { it.third }.thenByDescending { it.second })
.take(8)
SectionHeader("Streitpunkte")
if (items.isEmpty()) {
Text("Ihr hört gemeinsame Künstler erstaunlich ausgewogen.",
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
return
}
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.size(132.dp), contentAlignment = Alignment.Center) {
val accent = NsAccent
val track = NsSurface2
Canvas(Modifier.fillMaxSize()) {
val stroke = 14.dp.toPx()
val inset = stroke / 2
val arcSize = Size(size.width - stroke, size.height - stroke)
drawArc(
color = track, startAngle = 0f, sweepAngle = 360f, useCenter = false,
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize,
style = Stroke(width = stroke),
)
drawArc(
color = accent, startAngle = -90f, sweepAngle = score * 3.6f, useCenter = false,
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize,
style = Stroke(width = stroke),
)
items.forEach { (a, summe, _) ->
val anteilA = a.playsA / summe.toFloat()
Column(
Modifier.fillMaxWidth()
.clickable { a.artistId?.let(onArtistClick) }
.padding(vertical = 6.dp)
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("${a.playsA}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
fontWeight = FontWeight.Bold)
Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f).padding(horizontal = 8.dp))
Text("${a.playsB}", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
fontWeight = FontWeight.Bold)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("$score%", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 30.sp, color = NsOnSurface)
Text("Match", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
Spacer(Modifier.height(4.dp))
Row(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp))) {
Box(Modifier.weight(anteilA.coerceAtLeast(0.001f)).fillMaxHeight().background(NsAccent))
Box(Modifier.weight((1f - anteilA).coerceAtLeast(0.001f)).fillMaxHeight()
.background(NsAccent.copy(alpha = .35f)))
}
}
Spacer(Modifier.height(10.dp))
Text(verdict, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsOnSurface)
Spacer(Modifier.height(4.dp))
Text("${d.userA} ⟷ ${d.userB}", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
}
Spacer(Modifier.height(10.dp))
}
@Composable
private fun Scoreboard(d: CompareResult) {
val b = d.battle
if (b == null || b.kategorien.isEmpty()) return
val urteil = when {
b.punkteA == b.punkteB -> "Kopf-an-Kopf — kein Sieger."
b.punkteA > b.punkteB -> "${d.userA} gewinnt ${b.punkteA}:${b.punkteB}."
else -> "${d.userB} gewinnt ${b.punkteB}:${b.punkteA}."
}
Column(Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Text(d.userA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
Text(" ${b.punkteA} : ${b.punkteB} ", fontFamily = Outfit, fontWeight = FontWeight.Black,
fontSize = 30.sp, color = NsOnSurface)
Text(d.userB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = NsOnSurface, maxLines = 1, modifier = Modifier.weight(1f, false))
}
Text(urteil, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp, bottom = 12.dp),
textAlign = TextAlign.Center)
// Eine Zeile je Kategorie: Wert · Bezeichnung · Wert. Der Sieger wird farblich
// hervorgehoben, der Verlierer zurueckgenommen — identisch zum Web.
b.kategorien.forEach { k ->
val gewinnA = k.sieger == "A"
val gewinnB = k.sieger == "B"
Row(
Modifier.fillMaxWidth().padding(vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(k.textA, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = if (gewinnA) NsAccent else if (gewinnB) NsOnSurface3 else NsOnSurface,
textAlign = TextAlign.End, modifier = Modifier.weight(1f))
Text(k.label.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 11.sp, color = NsOnSurface2, textAlign = TextAlign.Center,
maxLines = 1, modifier = Modifier.weight(1.3f))
Text(k.textB, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = if (gewinnB) NsAccent else if (gewinnA) NsOnSurface3 else NsOnSurface,
modifier = Modifier.weight(1f))
}
}
Spacer(Modifier.height(6.dp))
}
}
@Composable
private fun Totals(d: CompareResult) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@@ -126,6 +126,7 @@ fun ExploreScreen(
previewingId = state.previewingId,
onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet,
onArtistClick = vm::openArtistOfAlbum,
onBack = vm::dismissAlbum,
)
}
@@ -145,6 +146,9 @@ fun ExploreScreen(
allAlbumsOpen = state.artistAllAlbums,
onOpenAllAlbums = vm::openArtistAllAlbums,
onCloseAllAlbums = vm::closeArtistAllAlbums,
allAlbumsGrid = vm.allAlbumsGrid,
allAlbumsQuery = vm.allAlbumsQuery.value,
onAllAlbumsQueryChange = { vm.allAlbumsQuery.value = it },
)
}
}
@@ -404,6 +408,7 @@ private fun GlobalExploreContent(
previewingId = state.previewingId,
onPreview = vm::togglePreview,
onDownload = onDownload,
onArtistClick = vm::openArtistOfAlbum,
onBack = vm::dismissAlbum,
)
return
@@ -521,6 +526,7 @@ internal fun ItunesAlbumDetail(
previewingId: Long?,
onPreview: (ItunesItem) -> Unit,
onDownload: (ItunesItem) -> Unit = {},
onArtistClick: ((ItunesItem) -> Unit)? = null,
onBack: () -> Unit,
) {
Column(Modifier.fillMaxSize()) {
@@ -532,9 +538,25 @@ internal fun ItunesAlbumDetail(
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = MaterialTheme.colorScheme.onSurface)
}
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f))
// Anklickbar nur, wenn Deezer eine Künstler-id mitgeliefert hat — /artist/{id}/albums
// lässt das artist-Objekt weg, dort bleibt der Name einfach Text.
val artistName = albumItem?.artistName
val artistTippbar = onArtistClick != null && albumItem?.artistId != null &&
!artistName.isNullOrBlank()
Column(
Modifier.weight(1f).then(
if (artistTippbar) Modifier.clickable { onArtistClick!!(albumItem!!) } else Modifier
)
) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface)
if (!artistName.isNullOrBlank()) {
Text(artistName, fontFamily = Outfit, fontSize = 12.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (artistTippbar) NsAccent else NsOnSurface2)
}
}
if (albumItem != null) {
IconButton(onClick = { onDownload(albumItem) }) {
Icon(Icons.Default.Download, "Album herunterladen",
@@ -2,6 +2,7 @@
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -18,6 +19,7 @@ import com.novasonic.app.app
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.ui.component.Rollbalken
@Composable
fun GenreSongsScreen(
@@ -77,19 +79,29 @@ fun GenreSongsScreen(
songs.isEmpty() -> Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text("Keine Songs gefunden", color = NsOnSurface2)
}
else -> LazyColumn {
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(songs, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
else -> {
val listState = rememberLazyListState()
Box(Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), state = listState) {
itemsIndexed(songs, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(songs, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(8.dp)) }
}
Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
)
}
item { Spacer(Modifier.height(8.dp)) }
}
}
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
@@ -33,6 +32,8 @@ import com.novasonic.app.data.NotificationStore
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.HomeViewModel
import androidx.compose.ui.res.painterResource
import com.novasonic.app.R
private val greetingsWithName = listOf(
"Ready for some beats,",
@@ -120,7 +121,7 @@ fun HomeScreen(
}
) {
IconButton(onClick = onBellClick) {
Icon(Icons.Default.Notifications, "Benachrichtigungen",
Icon(painterResource(R.drawable.ic_honigtopf_brief), "Benachrichtigungen",
tint = if (unreadCount > 0) NsAccent else NsOnSurface2)
}
}
@@ -182,6 +182,38 @@ fun ImporterScreen(onBack: () -> Unit) {
}
}
// Playlist aus dem Import anlegen — wie im Web-Importer. Nur ab zwei Titeln sinnvoll;
// ein einzelner Track ergibt keine Playlist.
if (r.tracks.size > 1) {
item {
Column(Modifier.fillMaxWidth().padding(bottom = 10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(
checked = state.createPlaylist,
onCheckedChange = vm::setCreatePlaylist,
colors = CheckboxDefaults.colors(checkedColor = brand,
checkmarkColor = Color.Black),
)
Text("Playlist daraus anlegen", fontFamily = Outfit, fontSize = 14.sp,
color = NsOnSurface)
}
if (state.createPlaylist) {
OutlinedTextField(
value = state.playlistName.ifBlank { r.name ?: "" },
onValueChange = vm::setPlaylistName,
label = { Text("Playlist-Name", fontFamily = Outfit) },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
}
state.playlistStatus?.let {
Text(it, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 6.dp))
}
}
}
}
// Download button / job progress
item {
if (state.isDownloading || state.jobStatus == "done" || state.jobStatus == "error") {
@@ -109,6 +109,9 @@ fun ItunesArtistScreen(
allAlbumsOpen: String?,
onOpenAllAlbums: (String) -> Unit,
onCloseAllAlbums: () -> Unit,
allAlbumsGrid: androidx.compose.foundation.lazy.grid.LazyGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState(),
allAlbumsQuery: String = "",
onAllAlbumsQueryChange: (String) -> Unit = {},
) {
val artist = artistState.artist
val cats = if (!artistState.isLoading)
@@ -144,6 +147,9 @@ fun ItunesArtistScreen(
title = allAlbumsOpen,
entries = allAlbumEntries,
onBack = onCloseAllAlbums,
gridState = allAlbumsGrid,
query = allAlbumsQuery,
onQueryChange = onAllAlbumsQueryChange,
)
return
}
@@ -219,7 +225,7 @@ fun ItunesArtistScreen(
Text("Top Songs", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp)
}
OutlinedButton(
onClick = { onSongPlay(artistState.topSongs.shuffled(), 0) },
onClick = { onSongPlay(artistState.topSongs, -1) },
modifier = Modifier.weight(1f).height(42.dp),
shape = RoundedCornerShape(12.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true)
@@ -7,9 +7,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
@@ -66,6 +64,15 @@ fun LibraryScreen(
val vm: LibraryViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val offlineVm: OfflineViewModel = viewModel()
// Absicht aus den Favoriten ("Alle anzeigen" bei den gefolgten Künstlern) einmalig einlösen.
LaunchedEffect(Unit) {
if (com.novasonic.app.data.LibraryIntent.oeffneGefolgte) {
com.novasonic.app.data.LibraryIntent.oeffneGefolgte = false
vm.selectTab(LibraryTab.ARTISTS)
vm.nurGefolgteUmschalten(true)
}
}
val offlineGroups by offlineVm.groups.collectAsStateWithLifecycle()
// Fully-downloaded source IDs per type — drives the "downloaded" badge on cards.
@@ -167,7 +174,9 @@ fun LibraryScreen(
}
}
val albumGridState = vm.albumsGridState // hoisted → survives album drill-in/back
LaunchedEffect(state.albumSort) { albumGridState.scrollToItem(0) }
// Kein LaunchedEffect(state.albumSort) zum Hochscrollen: der liefe auch beim ersten
// Compose nach dem Zurückkommen und würde die gemerkte Position sofort verwerfen.
// Das Hochscrollen passiert jetzt in vm.setAlbumSort(), also nur bei echtem Wechsel.
LazyVerticalGrid(
state = albumGridState,
columns = GridCells.Adaptive(minSize = 150.dp),
@@ -191,12 +200,20 @@ fun LibraryScreen(
LibraryTab.ARTISTS -> {
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
val scope = rememberCoroutineScope()
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
// "Gefolgt" filtert die vorhandene Liste — ein eigener Reiter wäre in einer Leiste,
// die schon vier Kacheln trägt, nur Gedränge.
val indizes = remember(state.artistIndexes, state.nurGefolgte, state.gefolgteIds) {
if (!state.nurGefolgte) state.artistIndexes
else state.artistIndexes
.map { idx -> idx.copy(artist = idx.artist.filter { it.id in state.gefolgteIds }) }
.filter { it.artist.isNotEmpty() }
}
val letters = remember(indizes) { indizes.map { it.name } }
// Map letter → flat LazyColumn index of its header item
val letterToIndex = remember(state.artistIndexes) {
val letterToIndex = remember(indizes) {
buildMap<String, Int> {
var flat = 0
state.artistIndexes.forEach { idx ->
indizes.forEach { idx ->
put(idx.name, flat)
flat += 1 + idx.artist.size
}
@@ -204,13 +221,54 @@ fun LibraryScreen(
}
var activeLetter by remember { mutableStateOf<String?>(null) }
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, top = 6.dp, bottom = 6.dp),
) {
FilterChip(
selected = !state.nurGefolgte,
onClick = { vm.nurGefolgteUmschalten(false) },
label = { Text("Alle", fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent),
)
FilterChip(
selected = state.nurGefolgte,
onClick = { vm.nurGefolgteUmschalten(true) },
label = {
val n = state.gefolgteIds.size
Text("❤ Gefolgt" + if (state.nurGefolgte && n > 0) " ($n)" else "",
fontSize = 12.sp)
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
selectedLabelColor = NsAccent),
)
}
if (state.nurGefolgte && indizes.isEmpty()) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("❤", fontSize = 30.sp)
Spacer(Modifier.height(8.dp))
Text("Du folgst noch niemandem", fontFamily = Outfit,
fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = MaterialTheme.colorScheme.onSurface)
Spacer(Modifier.height(4.dp))
Text("Auf einer Künstlerseite oben rechts aufs Herz tippen.",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
}
return@Column
}
Box(Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
modifier = Modifier.fillMaxSize(),
) {
state.artistIndexes.forEach { idx ->
indizes.forEach { idx ->
item(key = "header_${idx.name}") {
Text(
text = idx.name,
@@ -316,6 +374,7 @@ fun LibraryScreen(
}
}
}
}
}
}
@@ -327,9 +386,11 @@ fun LibraryScreen(
modifier = Modifier.fillMaxSize(),
) {
items(state.playlists, key = { it.id }) { pl ->
// Besitzer nur bei fremden (geteilten) Listen — bei den eigenen waere er Rauschen.
val von = pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }
PlaylistCard(
name = pl.name,
subtitle = "${pl.songCount} Titel",
subtitle = "${pl.songCount} Titel" + (von?.let { " · von $it" } ?: ""),
coverUrl = coverUrl(pl.coverArt),
downloaded = pl.id in downloadedPlaylistIds,
onClick = { onPlaylistClick(pl) },
@@ -24,7 +24,8 @@ fun LoginScreen(onLoggedIn: () -> Unit) {
val vm: LoginViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
var serverUrl by remember { mutableStateOf("") }
// Serveradresse vorbelegen, falls im Build hinterlegt — dann bleiben nur Benutzer und Passwort.
var serverUrl by remember { mutableStateOf(com.novasonic.app.BuildConfig.DEFAULT_ND_URL) }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var showPw by remember { mutableStateOf(false) }
@@ -60,6 +60,11 @@ import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.LyricsViewModel
import kotlinx.coroutines.launch
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.ui.input.pointer.positionChange
private enum class NpTab { PLAYER, LYRICS, QUEUE }
@@ -78,9 +83,14 @@ fun NowPlayingScreen(
val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) }
// Swipe left/right between Player / Lyrics / Queue, kept in sync with the tab pills (like
// Erkunden). A horizontal drag that starts on the cover still skips tracks — that gesture is a
// child pointerInput and consumes the drag before the pager sees it.
// Waagerechtes Wischen: standardmaessig wechselt es den TITEL — das ist die Geste, die man hier
// staendig braucht. Wer lieber zwischen Player, Warteschlange und Text blaettert, stellt es in
// den Einstellungen um; dann uebernimmt wieder der Pager. Die Reiter oben schalten in beiden
// Faellen weiter, es geht also nie eine Ansicht verloren.
val prefsCtx = LocalContext.current
val wischTitel by produceState(initialValue = true, prefsCtx) {
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings.collect { value = it.wischenWechseltTitel }
}
val pagerState = rememberPagerState(initialPage = tab.ordinal) { NpTab.entries.size }
LaunchedEffect(tab) {
if (pagerState.currentPage != tab.ordinal) pagerState.animateScrollToPage(tab.ordinal)
@@ -227,11 +237,31 @@ fun NowPlayingScreen(
// ── Swipeable tab content (Player / Lyrics / Queue) ────────────
HorizontalPager(
state = pagerState,
userScrollEnabled = !wischTitel,
modifier = Modifier.weight(1f).fillMaxWidth(),
) { page ->
// Die ganze Player-Seite nimmt die Wischgeste an, nicht nur das Cover: sonst muesste
// man genau das Bild treffen, und darunter liegt die halbe Seite. Auf Warteschlange
// und Text liegt sie NICHT — dort wuerde sie mit dem Ziehen in der Liste streiten.
var seitenDrag by remember { mutableStateOf(0f) }
val wischbar = wischTitel && NpTab.entries[page] == NpTab.PLAYER
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.then(
if (!wischbar) Modifier else Modifier.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragEnd = {
if (seitenDrag < -80) controller.skipNext()
else if (seitenDrag > 80) controller.skipPrev()
seitenDrag = 0f
},
onDragCancel = { seitenDrag = 0f },
onHorizontalDrag = { _, d -> seitenDrag += d },
)
}
),
) {
when (NpTab.entries[page]) {
// ── PLAYER TAB ─────────────────────────────────────────────────
@@ -386,7 +416,7 @@ fun NowPlayingScreen(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) {
NpCircleBtn(onClick = { controller.toggleShuffle() }) {
NpCircleBtn(onClick = { controller.toggleShuffle() }, aktiv = state.shuffled) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(22.dp),
tint = if (state.shuffled) NsAccent else Color.White.copy(0.65f))
}
@@ -412,7 +442,8 @@ fun NowPlayingScreen(
NpCircleBtn(onClick = { controller.skipNext() }) {
Icon(Icons.Default.SkipNext, null, modifier = Modifier.size(34.dp), tint = Color.White)
}
NpCircleBtn(onClick = { controller.toggleRepeat() }) {
val wiederholt = state.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF
NpCircleBtn(onClick = { controller.toggleRepeat() }, aktiv = wiederholt) {
val (icon, tint) = when (state.repeatMode) {
androidx.media3.common.Player.REPEAT_MODE_ONE -> Icons.Default.RepeatOne to NsAccent
androidx.media3.common.Player.REPEAT_MODE_ALL -> Icons.Default.Repeat to NsAccent
@@ -430,6 +461,13 @@ fun NowPlayingScreen(
val lyricsListState = rememberLazyListState()
val density = LocalDensity.current
var lyricsViewH by remember { mutableIntStateOf(0) }
// Mitlaufen: Standard AUS. Der Schalter sitzt hier im Reiter, nicht in den
// Einstellungen — man entscheidet beim Lesen, nicht vorher.
val folgtMit by produceState(initialValue = false, prefsCtx) {
com.novasonic.app.data.PrefsStore(prefsCtx).appSettings
.collect { value = it.lyricsFolgenAutomatisch }
}
val folgenScope = rememberCoroutineScope()
// Anchor the active line at 40% from the top — slightly above middle reads more naturally.
val anchorFrac = 0.40f
@@ -440,7 +478,10 @@ fun NowPlayingScreen(
// Center the active line using its *measured* position — no cumulative drift, so
// wrapped (multi-line) lyrics stay centered. Re-runs once the viewport is measured.
LaunchedEffect(activeIdx, lyricsViewH) {
LaunchedEffect(activeIdx, lyricsViewH, folgtMit) {
// Ohne Mitlaufen wird nur hervorgehoben, nicht gescrollt — sonst springt der
// Text unter dem Finger weg, sobald die naechste Zeile beginnt.
if (!folgtMit) return@LaunchedEffect
if (activeIdx < 0 || lyricsState.lines.isEmpty() || lyricsViewH == 0) return@LaunchedEffect
val info = lyricsListState.layoutInfo
val anchor = info.viewportStartOffset +
@@ -458,6 +499,41 @@ fun NowPlayingScreen(
}
}
if (lyricsState.lines.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.End,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(
if (folgtMit) NsAccent.copy(0.18f) else Color.White.copy(0.08f)
)
.clickable {
folgenScope.launch {
com.novasonic.app.data.PrefsStore(prefsCtx)
.saveLyricsFolgen(!folgtMit)
}
}
.padding(horizontal = 11.dp, vertical = 6.dp),
) {
Icon(
Icons.Default.KeyboardArrowDown, null,
modifier = Modifier.size(15.dp),
tint = if (folgtMit) NsAccent else Color.White.copy(0.55f),
)
Spacer(Modifier.width(5.dp))
Text(
if (folgtMit) "Folgt" else "Mitlaufen",
fontFamily = Outfit, fontSize = 11.sp, fontWeight = FontWeight.Bold,
color = if (folgtMit) NsAccent else Color.White.copy(0.55f),
)
}
}
}
when {
lyricsState.isLoading -> Box(
Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center,
@@ -573,18 +649,38 @@ fun NowPlayingScreen(
color = Color.White.copy(0.4f))
}
} else {
// ── Warteschlange bearbeiten ────────────────────────────
// Gezogen wird am Griff, nicht an der Zeile: die Liste selbst scrollt, und
// beides an derselben Stelle liesse sich nicht auseinanderhalten.
// Getauscht wird LAUFEND, sobald der Finger eine Zeilenhoehe zurueckgelegt
// hat — dadurch braucht es kein Geisterbild, die Liste zeigt die neue
// Reihenfolge sofort.
val zeilePx = with(LocalDensity.current) { 56.dp.toPx() }
var ziehIdx by remember { mutableIntStateOf(-1) }
var ziehOffset by remember { mutableFloatStateOf(0f) }
val queueLaenge = rememberUpdatedState(state.queue.size)
LazyColumn(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = PaddingValues(vertical = 4.dp),
) {
itemsIndexed(state.queue, key = { idx, _ -> idx }) { idx, qSong ->
val active = state.currentIndex == idx
val wirdGezogen = ziehIdx == idx
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
if (wirdGezogen) { translationY = ziehOffset; shadowElevation = 12f }
}
.clip(RoundedCornerShape(10.dp))
.background(if (active) Color.White.copy(0.08f) else Color.Transparent)
.background(
when {
wirdGezogen -> Color.White.copy(0.16f)
active -> Color.White.copy(0.08f)
else -> Color.Transparent
}
)
.clickable { controller.seekToItem(idx) }
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
@@ -612,6 +708,53 @@ fun NowPlayingScreen(
Icon(Icons.AutoMirrored.Filled.VolumeUp, null,
tint = NsAccent, modifier = Modifier.size(16.dp))
}
IconButton(
onClick = { controller.entferneAusQueue(idx) },
modifier = Modifier.size(32.dp),
) {
Icon(Icons.Default.Close, "Aus der Warteschlange entfernen",
tint = Color.White.copy(0.45f), modifier = Modifier.size(16.dp))
}
Icon(
Icons.Default.DragHandle, "Zum Verschieben ziehen",
tint = Color.White.copy(if (wirdGezogen) 0.9f else 0.45f),
modifier = Modifier
.size(32.dp)
.padding(6.dp)
.pointerInput(Unit) {
awaitEachGesture {
// Der erste Kontakt wird sofort verbraucht, sonst
// beansprucht die LazyColumn die Geste, sobald die
// Wischschwelle faellt.
val start = awaitFirstDown(requireUnconsumed = false)
start.consume()
var aktuell = idx
var rest = 0f
ziehIdx = aktuell
try {
while (true) {
val ev = awaitPointerEvent()
val p = ev.changes.firstOrNull { it.id == start.id } ?: break
if (!p.pressed) break
rest += p.positionChange().y
p.consume()
while (rest >= zeilePx && aktuell < queueLaenge.value - 1) {
controller.verschiebeInQueue(aktuell, aktuell + 1)
aktuell++; rest -= zeilePx
}
while (rest <= -zeilePx && aktuell > 0) {
controller.verschiebeInQueue(aktuell, aktuell - 1)
aktuell--; rest += zeilePx
}
ziehIdx = aktuell
ziehOffset = rest
}
} finally {
ziehIdx = -1; ziehOffset = 0f
}
}
},
)
}
}
}
@@ -696,11 +839,34 @@ private fun NpSeekBar(
}
@Composable
private fun NpCircleBtn(onClick: () -> Unit, content: @Composable () -> Unit) {
private fun NpCircleBtn(
onClick: () -> Unit,
aktiv: Boolean = false,
content: @Composable () -> Unit,
) {
// Ein eingefaerbtes Symbol allein ist als "an" schwer zu erkennen — auf dem dunklen Cover
// sieht Akzentfarbe wie eine Schattierung aus. Ist der Schalter an, liegt deshalb zusaetzlich
// ein getoenter Kreis darunter und ein Punkt darunter, wie in gaengigen Playern.
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(onClick = onClick),
) { content() }
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(if (aktiv) NsAccent.copy(alpha = 0.22f) else Color.Transparent)
.clickable(onClick = onClick),
) {
content()
if (aktiv) {
Box(
Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 5.dp)
.size(4.dp)
.clip(CircleShape)
.background(NsAccent)
)
}
}
}
private fun formatMs(ms: Long): String {
@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -31,11 +32,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Song
import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.toSong
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.OfflineDetailViewModel
import com.novasonic.app.ui.component.Rollbalken
@Composable
fun OfflineDetailScreen(
@@ -62,124 +65,154 @@ fun OfflineDetailScreen(
return
}
val songs = remember(tracks) { tracks.map { it.toSong() } }
// Nur FERTIGE Titel sind offline abspielbar. Die Liste zeigt weiter alle (auch wartende und
// fehlgeschlagene), aber in die Warteschlange dürfen sie nicht: für sie gibt es keine lokale
// Datei, also liefert streamUrl() die Netz-Adresse — und die tut offline genau nichts.
val spielbar = remember(tracks) {
tracks.filter { it.state == DownloadState.DONE }.map { it.toSong() }
}
val ctx = androidx.compose.ui.platform.LocalContext.current
val spieleAb: (List<com.novasonic.app.api.model.Song>, Int) -> Unit = { liste, i ->
val fehlend = songs.size - spielbar.size
if (fehlend > 0) android.widget.Toast.makeText(
ctx, "$fehlend Titel noch nicht heruntergeladen — übersprungen",
android.widget.Toast.LENGTH_SHORT).show()
onPlay(liste, i)
}
LazyColumn(Modifier.fillMaxSize()) {
item {
// ── Hero ─────────────────────────────────────────────────────────
Box(Modifier.fillMaxWidth()) {
AsyncImage(
model = coverUrl(g.coverArt),
contentDescription = g.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2),
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(
Brush.verticalGradient(
0.0f to Color.Transparent,
0.40f to NsBackground.copy(alpha = 0.35f),
1.0f to NsBackground,
val listState = rememberLazyListState()
Box(Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), state = listState) {
item {
// ── Hero ─────────────────────────────────────────────────────────
Box(Modifier.fillMaxWidth()) {
AsyncImage(
model = coverUrl(g.coverArt),
contentDescription = g.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(NsSurface2),
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(
Brush.verticalGradient(
0.0f to Color.Transparent,
0.40f to NsBackground.copy(alpha = 0.35f),
1.0f to NsBackground,
)
)
)
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp))
}
Column(
Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
g.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
)
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp))
Spacer(Modifier.height(4.dp))
Text(
"Offline · ${tracks.size} Titel",
fontFamily = Outfit, fontSize = 13.sp,
color = Color.White.copy(alpha = 0.65f),
)
}
}
Column(
Modifier.align(Alignment.BottomStart).padding(horizontal = 20.dp, vertical = 20.dp),
// ── Play / Shuffle / Edit row ─────────────────────────────────────
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
g.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(4.dp))
Text(
"Offline · ${tracks.size} Titel",
fontFamily = Outfit, fontSize = 13.sp,
color = Color.White.copy(alpha = 0.65f),
)
Button(
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
OutlinedButton(
onClick = { if (spielbar.isNotEmpty()) spieleAb(spielbar, -1) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
}
OutlinedButton(
onClick = { editMode = !editMode },
modifier = Modifier.height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
) {
Icon(
if (editMode) Icons.Default.Done else Icons.Default.Edit, null,
modifier = Modifier.size(18.dp),
tint = if (editMode) NsAccent else NsOnSurface2,
)
}
}
HorizontalDivider(color = NsBorder)
}
// ── Play / Shuffle / Edit row ─────────────────────────────────────
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { if (songs.isNotEmpty()) onPlay(songs, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
}
OutlinedButton(
onClick = { if (songs.isNotEmpty()) onPlay(songs.shuffled(), 0) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
}
OutlinedButton(
onClick = { editMode = !editMode },
modifier = Modifier.height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
) {
Icon(
if (editMode) Icons.Default.Done else Icons.Default.Edit, null,
modifier = Modifier.size(18.dp),
tint = if (editMode) NsAccent else NsOnSurface2,
)
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(songs, key = { _, s -> s.id }) { index, song ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
onPlay = { onPlay(songs, index) },
)
}
if (editMode) {
IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) {
Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen",
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp))
itemsIndexed(songs, key = { _, s -> s.id }) { index, song ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
// Position in der abspielbaren Liste suchen — die Anzeige enthält auch
// Titel, die noch fehlen, die Indizes decken sich also nicht.
onPlay = {
val i = spielbar.indexOfFirst { it.id == song.id }
if (i >= 0) onPlay(spielbar, i)
else android.widget.Toast.makeText(
ctx, "Dieser Titel ist noch nicht heruntergeladen",
android.widget.Toast.LENGTH_SHORT).show()
},
)
}
if (editMode) {
IconButton(onClick = { vm.deleteSong(song.id) }, modifier = Modifier.padding(end = 8.dp)) {
Icon(Icons.Default.RemoveCircle, "Vom Gerät löschen",
tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(22.dp))
}
}
}
}
item { Spacer(Modifier.height(16.dp)) }
}
item { Spacer(Modifier.height(16.dp)) }
Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
)
}
}
@@ -132,7 +132,7 @@ fun PartialAlbumScreen(
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.background(NsSurface2)
.clickable { onPlay(downloadedSongs.shuffled(), 0) }
.clickable { onPlay(downloadedSongs, -1) }
.padding(vertical = 11.dp),
) {
Icon(Icons.Default.Shuffle, null, tint = NsOnSurface, modifier = Modifier.size(18.dp))
@@ -7,6 +7,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -39,6 +40,8 @@ import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.PlaylistViewModel
import com.novasonic.app.ui.component.Rollbalken
import com.novasonic.app.ui.formatGesamtdauer
@Composable
fun PlaylistDetailScreen(
@@ -91,205 +94,218 @@ fun PlaylistDetailScreen(
return
}
LazyColumn(Modifier.fillMaxSize()) {
item {
// ── Hero ─────────────────────────────────────────────────────────
Box(Modifier.fillMaxWidth()) {
AsyncImage(
model = coverUrl(pl.coverArt),
contentDescription = pl.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(
Brush.verticalGradient(
0.0f to Color.Transparent,
0.40f to NsBackground.copy(alpha = 0.35f),
1.0f to NsBackground,
val listState = rememberLazyListState()
Box(Modifier.fillMaxSize()) {
LazyColumn(Modifier.fillMaxSize(), state = listState) {
item {
// ── Hero ─────────────────────────────────────────────────────────
Box(Modifier.fillMaxWidth()) {
AsyncImage(
model = coverUrl(pl.coverArt),
contentDescription = pl.name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(NsSurface2),
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(
Brush.verticalGradient(
0.0f to Color.Transparent,
0.40f to NsBackground.copy(alpha = 0.35f),
1.0f to NsBackground,
)
)
)
)
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp),
)
}
// Edit + Delete buttons (top-right)
Row(
Modifier
.statusBarsPadding()
.padding(end = 14.dp, top = 14.dp)
.align(Alignment.TopEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.statusBarsPadding()
.padding(start = 14.dp, top = 14.dp)
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { showRenameDialog = true },
.clickable(onClick = onBack)
.align(Alignment.TopStart),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Edit, "Bearbeiten",
tint = Color.White, modifier = Modifier.size(18.dp))
}
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { showDeleteDialog = true },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Delete, "Löschen",
tint = Color.White, modifier = Modifier.size(18.dp))
}
}
Column(
Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
pl.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
val meta = listOfNotNull(
pl.owner?.takeIf { it.isNotBlank() },
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
).joinToString(" · ")
if (meta.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
meta, fontFamily = Outfit, fontSize = 13.sp,
color = Color.White.copy(alpha = 0.65f),
)
}
if (!pl.comment.isNullOrBlank()) {
Spacer(Modifier.height(3.dp))
Text(
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.45f),
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
}
}
}
// ── Play / Shuffle / Edit / Download ──────────────────────────────
// Four buttons in one row left no room for labels (the Play/Shuffle text was being
// clipped away), so all four are equal-width and icon-only. Play stays filled as the
// primary action; the rest are outlined and tint accent when active/done.
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
}
OutlinedButton(
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
}
// Toggle edit mode for removing songs
OutlinedButton(
onClick = { editMode = !editMode },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(
if (editMode) Icons.Default.Done else Icons.Default.Edit,
if (editMode) "Bearbeiten beenden" else "Titel entfernen",
modifier = Modifier.size(20.dp),
tint = if (editMode) NsAccent else NsOnSurface2,
)
}
// Download button — tappable unless fully done; resumes / re-downloads missing songs
val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false
OutlinedButton(
onClick = { if (!dlDone) vm.downloadPlaylist() },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
when {
dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent)
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent)
else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2)
}
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(pl.entry, idx) },
onStarToggle = { onStarToggle(song) },
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
)
}
if (editMode) {
IconButton(
onClick = { vm.removeSong(idx) },
modifier = Modifier.padding(end = 4.dp),
) {
Icon(
Icons.Default.RemoveCircleOutline,
"Entfernen",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(22.dp),
Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
tint = Color.White, modifier = Modifier.size(18.dp),
)
}
// Edit + Delete buttons (top-right)
Row(
Modifier
.statusBarsPadding()
.padding(end = 14.dp, top = 14.dp)
.align(Alignment.TopEnd),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { showRenameDialog = true },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Edit, "Bearbeiten",
tint = Color.White, modifier = Modifier.size(18.dp))
}
Box(
Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = 0.45f))
.clickable { showDeleteDialog = true },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Delete, "Löschen",
tint = Color.White, modifier = Modifier.size(18.dp))
}
}
Column(
Modifier
.align(Alignment.BottomStart)
.padding(horizontal = 20.dp, vertical = 20.dp),
) {
Text(
pl.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold,
fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
// Der Besitzer stand hier bisher bei JEDER Liste, auch bei den eigenen, und ohne
// Beschriftung — bei einer geteilten Liste ist genau das die interessante Angabe.
val meta = listOfNotNull(
if (pl.songCount > 0) "${pl.songCount} Songs" else null,
// Gesamtdauer: Subsonic liefert sie beim Playlist-Objekt mit, es kostet
// also keinen zusaetzlichen Aufruf.
if (pl.duration > 0) formatGesamtdauer(pl.duration) else null,
pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }?.let { "von $it" },
).joinToString(" · ")
if (meta.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
meta, fontFamily = Outfit, fontSize = 13.sp,
color = Color.White.copy(alpha = 0.65f),
)
}
if (!pl.comment.isNullOrBlank()) {
Spacer(Modifier.height(3.dp))
Text(
pl.comment!!, fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.45f),
maxLines = 2, overflow = TextOverflow.Ellipsis,
)
}
}
}
// ── Play / Shuffle / Edit / Download ──────────────────────────────
// Four buttons in one row left no room for labels (the Play/Shuffle text was being
// clipped away), so all four are equal-width and icon-only. Play stays filled as the
// primary action; the rest are outlined and tint accent when active/done.
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Button(
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, 0) },
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
}
OutlinedButton(
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry, -1) }, // -1 = gemischt, der Player mischt
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
}
// Toggle edit mode for removing songs
OutlinedButton(
onClick = { editMode = !editMode },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(
if (editMode) Icons.Default.Done else Icons.Default.Edit,
if (editMode) "Bearbeiten beenden" else "Titel entfernen",
modifier = Modifier.size(20.dp),
tint = if (editMode) NsAccent else NsOnSurface2,
)
}
// Download button — tappable unless fully done; resumes / re-downloads missing songs
val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false
OutlinedButton(
onClick = { if (!dlDone) vm.downloadPlaylist() },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
when {
dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent)
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent)
else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2)
}
}
}
HorizontalDivider(color = NsBorder)
}
itemsIndexed(pl.entry, key = { idx, s -> "${idx}_${s.id}" }) { idx, song ->
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.weight(1f)) {
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(pl.entry, idx) },
onStarToggle = { onStarToggle(song) },
onMore = if (!editMode) onShowContextMenu?.let { cb -> { cb(song) } } else null,
)
}
if (editMode) {
IconButton(
onClick = { vm.removeSong(idx) },
modifier = Modifier.padding(end = 4.dp),
) {
Icon(
Icons.Default.RemoveCircleOutline,
"Entfernen",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(22.dp),
)
}
}
}
}
item { Spacer(Modifier.height(16.dp)) }
}
item { Spacer(Modifier.height(16.dp)) }
Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
)
}
// ── Rename dialog ─────────────────────────────────────────────────────────
@@ -122,6 +122,76 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {})
)
}
// ── ▶ Wiedergabe ────────────────────────────────────────────────────
SectionHeader("▶ Wiedergabe")
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.weight(1f)) {
Text("Wischen wechselt den Titel", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text(
if (state.wischenWechseltTitel)
"Im Now-Playing: seitwärts wischen springt zum nächsten oder vorherigen Titel"
else
"Im Now-Playing: seitwärts wischen blättert zwischen Player, Warteschlange und Text",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
)
}
Switch(
checked = state.wischenWechseltTitel,
onCheckedChange = { vm.onWischenWechseltTitel(it) },
colors = SwitchDefaults.colors(
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
uncheckedThumbColor = NsOnSurface2,
),
)
}
// ── 📥 Downloads ─────────────────────────────────────────────────────
SectionHeader("📥 Offline-Downloads")
var wlanWarnung by remember { mutableStateOf(false) }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.weight(1f)) {
Text("Nur über WLAN herunterladen", fontFamily = Outfit, fontWeight = FontWeight.Medium, fontSize = 14.sp, color = NsOnSurface)
Text("Pausiert unterwegs und macht im WLAN von selbst weiter", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Switch(
// Ausschalten nur mit Rueckfrage: eine grosse Playlist sind schnell mehrere Gigabyte,
// und der Downloader arbeitet sich ohne Zutun durch, bis alles da ist.
checked = state.downloadWifiOnly,
onCheckedChange = { an -> if (an) vm.onDownloadWifiOnly(true) else wlanWarnung = true },
colors = SwitchDefaults.colors(
checkedThumbColor = NsBackground, checkedTrackColor = NsAccent,
uncheckedThumbColor = NsOnSurface2,
),
)
}
if (wlanWarnung) {
AlertDialog(
onDismissRequest = { wlanWarnung = false },
title = { Text("Über mobile Daten laden?", fontFamily = Outfit, fontWeight = FontWeight.Bold) },
text = {
Text(
"Downloads laufen dann auch ohne WLAN weiter — und zwar so lange, bis alles " +
"geladen ist. Eine Playlist mit 1000 Titeln sind leicht mehrere Gigabyte.",
fontFamily = Outfit, fontSize = 14.sp,
)
},
confirmButton = {
TextButton(onClick = { vm.onDownloadWifiOnly(false); wlanWarnung = false }) {
Text("Trotzdem erlauben", fontFamily = Outfit, color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { wlanWarnung = false }) {
Text("Abbrechen", fontFamily = Outfit, color = NsAccent)
}
},
)
}
Spacer(Modifier.height(4.dp))
ExposedDropdownMenuBox(expanded = scrobbleExpanded, onExpandedChange = { scrobbleExpanded = it }) {
OutlinedTextField(
value = SCROBBLE_OPTIONS.firstOrNull { it.first == state.scrobbleMode }?.second ?: "30 Sekunden",
@@ -194,6 +264,9 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {})
label = "Last.fm API Key",
hint = "Kostenlos unter last.fm/api/account/create",
)
// Vorbelegt aus BuildConfig (siehe build.gradle.kts): auf einem frischen Geraet muss nichts
// eingetippt werden. Das Feld bleibt trotzdem bedienbar — zieht der Dienst um, laesst sich
// das hier korrigieren, ohne ein neues APK zu bauen.
NsTextField(
value = state.backendUrl, onValueChange = vm::onBackendUrl,
label = "NovaSonic Backend URL",
@@ -210,6 +283,7 @@ fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {})
)
ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble)
Spacer(Modifier.height(4.dp))
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
@@ -1,8 +1,19 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import com.novasonic.app.ui.component.AlbumCard
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -18,9 +29,13 @@ import com.novasonic.app.app
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.ui.component.Rollbalken
enum class StarredSort { NEWEST, OLDEST, AZ, BY_ARTIST }
/** So viele gefolgte Kuenstler stehen in der Reihe, der Rest hinter "Alle anzeigen". */
private const val GEZEIGT = 10
@Composable
fun StarredScreen(
coverUrl: (String?) -> String?,
@@ -28,18 +43,29 @@ fun StarredScreen(
onPlay: (List<Song>, Int) -> Unit,
onStarToggle: (Song) -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
onArtistClick: (String) -> Unit = {},
onAlbumClick: (String) -> Unit = {},
onOpenNews: () -> Unit = {},
onOpenFollowed: () -> Unit = {},
) {
val context = LocalContext.current
val repo = context.app.repository
var songs by remember { mutableStateOf<List<Song>>(emptyList()) }
// getStarred2 liefert Kuenstler und Alben im SELBEN Paket wie die Songs — die Uebersicht
// "wem folge ich" kostet also keinen zusaetzlichen Aufruf.
var gefolgteKuenstler by remember { mutableStateOf<List<com.novasonic.app.api.model.Artist>>(emptyList()) }
var gemerkteAlben by remember { mutableStateOf<List<com.novasonic.app.api.model.Album>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var sort by remember { mutableStateOf(StarredSort.NEWEST) }
LaunchedEffect(Unit) {
try {
songs = repo.getStarred().song
val markiert = repo.getStarred()
songs = markiert.song
gefolgteKuenstler = markiert.artist.sortedBy { it.name.lowercase() }
gemerkteAlben = markiert.album.sortedByDescending { it.starred ?: "" }
loading = false
} catch (e: Exception) {
error = e.message
@@ -57,12 +83,25 @@ fun StarredScreen(
}
Column(Modifier.fillMaxSize()) {
Text(
"Favoriten",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 20.dp, top = 24.dp, bottom = 8.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 24.dp, bottom = 8.dp),
) {
Text(
"Favoriten",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
// Einstieg in die Zeitleiste: hier stehen die gefolgten Künstler, also liegt die Frage
// "was gibt es Neues von denen" nahe.
if (gefolgteKuenstler.isNotEmpty()) {
TextButton(onClick = onOpenNews) {
Text("✨ Neu", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsAccent)
}
}
}
// Sort chips
Row(
@@ -100,25 +139,118 @@ fun StarredScreen(
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(error!!, color = MaterialTheme.colorScheme.error)
}
} else if (sorted.isEmpty()) {
} else if (sorted.isEmpty() && gefolgteKuenstler.isEmpty() && gemerkteAlben.isEmpty()) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text("Noch keine Favoriten", color = NsOnSurface2)
}
} else {
LazyColumn {
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(sorted, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
val listState = rememberLazyListState()
Box(Modifier.fillMaxSize()) {
LazyColumn(state = listState) {
if (gefolgteKuenstler.isNotEmpty()) {
item { FavoritenLabel("Künstler, denen du folgst (${gefolgteKuenstler.size})") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
// Die LazyRow baut zwar nur Sichtbares, aber eine endlose Reihe ist
// zum Durchsehen untauglich — der Rest gehoert in die gefilterte
// Kuenstlerliste. GEZEIGT deckt zwei Bildschirmbreiten ab.
items(gefolgteKuenstler.take(GEZEIGT), key = { it.id }) { a ->
GefolgterKuenstler(a.name, coverUrl(a.coverArt), a.albumCount) { onArtistClick(a.id) }
}
if (gefolgteKuenstler.size > GEZEIGT) {
item(key = "alle_kuenstler") {
AlleAnzeigen(gefolgteKuenstler.size - GEZEIGT, onOpenFollowed)
}
}
}
Spacer(Modifier.height(8.dp))
}
}
if (gemerkteAlben.isNotEmpty()) {
item { FavoritenLabel("Gemerkte Alben (${gemerkteAlben.size})") }
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
items(gemerkteAlben, key = { it.id }) { al ->
AlbumCard(album = al, coverUrl = coverUrl(al.coverArt)) { onAlbumClick(al.id) }
}
}
Spacer(Modifier.height(8.dp))
}
}
if (sorted.isNotEmpty() && (gefolgteKuenstler.isNotEmpty() || gemerkteAlben.isNotEmpty())) {
item { FavoritenLabel("Songs (${sorted.size})") }
}
itemsIndexed(sorted, key = { _, s -> s.id }) { idx, song ->
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
starred = song.starred != null,
onPlay = { onPlay(sorted, idx) },
onStarToggle = { onStarToggle(song) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
item { Spacer(Modifier.height(8.dp)) }
}
item { Spacer(Modifier.height(8.dp)) }
Rollbalken(
state = listState,
modifier = Modifier.align(Alignment.CenterEnd).padding(vertical = 4.dp),
farbe = NsOnSurface2,
)
}
}
}
}
@Composable
private fun FavoritenLabel(titel: String) {
Text(
titel,
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
color = NsOnSurface2,
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 8.dp),
)
}
@Composable
private fun GefolgterKuenstler(name: String, bild: String?, albumCount: Int, onClick: () -> Unit) {
Column(Modifier.width(110.dp).clickable(onClick = onClick)) {
AsyncImage(
model = bild, contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
maxLines = 1, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface)
Text("$albumCount Alben", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 1)
}
}
@Composable
private fun AlleAnzeigen(weitere: Int, onClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(110.dp).clickable(onClick = onClick),
) {
Box(
Modifier.size(110.dp).clip(CircleShape).background(NsSurface2),
contentAlignment = Alignment.Center,
) {
Text("→", fontSize = 26.sp, color = NsAccent)
}
Spacer(Modifier.height(6.dp))
Text("Alle anzeigen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
maxLines = 1, color = MaterialTheme.colorScheme.onSurface)
Text("+$weitere weitere", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2, maxLines = 1)
}
}
@@ -85,6 +85,20 @@ val NS_THEMES = listOf(
Color(0xFF162A21), Color(0xFF1E3A2D),
Color(0xFFE8FFF1), Color(0xFF8FBFA4), Color(0xFF557A63),
),
NsTheme(
"violet", "Violet",
Color(0xFFA78BFA), Color(0xFF7C5CF0),
Color(0xFF0D0916), Color(0xFF140E20),
Color(0xFF221835), Color(0xFF2D2145),
Color(0xFFF2EDFF), Color(0xFF9B8BBD), Color(0xFF655780),
),
NsTheme(
"crimson", "Crimson",
Color(0xFFE05263), Color(0xFFB03445),
Color(0xFF120708), Color(0xFF1A0B0D),
Color(0xFF2B1316), Color(0xFF3A1A1E),
Color(0xFFFFEEF0), Color(0xFFBF8D94), Color(0xFF7D545C),
),
)
fun applyNsTheme(themeId: String) {
@@ -21,6 +21,10 @@ data class AlbumUiState(
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository
/** Album merken — optimistisch ueber StarState, faellt bei einem Fehlschlag zurueck. */
fun merkenUmschalten(albumId: String, aktuellMarkiert: Boolean) =
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, albumId, aktuellMarkiert)
private val offlineRepo = app.app.offlineRepository
private val albumId: String? = savedState["albumId"]
@@ -0,0 +1,169 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.ReleasesClient
import com.novasonic.app.api.model.ArtistRef
import com.novasonic.app.api.model.ArtistKonzert
import com.novasonic.app.api.model.ArtistRelease
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.app
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/** Welcher Reiter gezeigt wird. Getrennte Listen, weil die Zeit in verschiedene Richtungen läuft. */
enum class NewsReiter { RELEASES, KONZERTE }
data class ArtistNewsUiState(
val reiter: NewsReiter = NewsReiter.RELEASES,
val releases: List<ArtistRelease> = emptyList(),
val konzerte: List<ArtistKonzert> = emptyList(),
val keinSchluessel: Boolean = false,
/** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */
val vorhanden: Set<String> = emptySet(),
val ohneZuordnung: List<String> = emptyList(),
val tage: Int = 90,
val isLoading: Boolean = true,
val keineGefolgten: Boolean = false,
val keinBackend: Boolean = false,
val error: String? = null,
)
/**
* "Neu von deinen Künstlern". Gefolgt = Navidromes Stern, die Künstlerliste kommt also aus
* getStarred2. Die Deezer-Auflösung macht das Backend — dort liegen die Pins und der gemeinsame
* Cache, sonst würde jedes Gerät bei jedem Öffnen dieselben Abfragen wiederholen.
*/
class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(ArtistNewsUiState())
val state: StateFlow<ArtistNewsUiState> = _state
init { laden(90) }
/** Reiter wechseln und, falls noch nichts geladen ist, nachladen. */
fun zeigeReiter(r: NewsReiter) {
_state.value = _state.value.copy(reiter = r)
if (r == NewsReiter.KONZERTE && _state.value.konzerte.isEmpty()) ladeKonzerte()
}
/** Zeitfenster setzen — gibt es nur bei den Veroeffentlichungen. */
fun setzeTage(tage: Int) = laden(tage)
// Kein Zeitfenster: Ticketmaster liefert ohnehin nur Anstehendes, und dort will man alles
// Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
fun ladeKonzerte() = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null,
keineGefolgten = false, keinBackend = false, keinSchluessel = false)
try {
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
if (backendUrl.isBlank()) {
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
return@launch
}
val kuenstler = repo.getStarred().artist.map { ArtistRef(it.id, it.name) }
if (kuenstler.isEmpty()) {
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
return@launch
}
val antwort = ReleasesClient.konzerte(backendUrl, kuenstler)
_state.value = _state.value.copy(
konzerte = antwort.konzerte,
keinSchluessel = antwort.keinSchluessel,
isLoading = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(isLoading = false, error = e.message)
}
}
fun laden(tage: Int = _state.value.tage) = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, tage = tage, error = null,
reiter = NewsReiter.RELEASES, keineGefolgten = false, keinBackend = false)
try {
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
if (backendUrl.isBlank()) {
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
return@launch
}
val markiert = repo.getStarred()
val kuenstler = markiert.artist.map { ArtistRef(it.id, it.name) }
if (kuenstler.isEmpty()) {
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
return@launch
}
val antwort = ReleasesClient.fetch(backendUrl, kuenstler, tage)
// Was liegt schon in der Bibliothek? Verglichen wird über Künstler + Albumname —
// Deezers Album-Id hat mit Navidromes nichts zu tun.
val vorhanden = withContext(Dispatchers.IO) {
val ausFavoriten = markiert.album.map { schluessel(it.artist ?: "", it.name) }
val ausBibliothek = kuenstler.chunked(6).flatMap { haeppchen ->
haeppchen.map { k ->
async {
runCatching { repo.getArtist(k.id).album.map { schluessel(k.name, it.name) } }
.getOrDefault(emptyList())
}
}.awaitAll().flatten()
}
(ausFavoriten + ausBibliothek).toSet()
}
_state.value = _state.value.copy(
releases = antwort.releases,
ohneZuordnung = antwort.ohneZuordnung,
vorhanden = vorhanden,
isLoading = false,
)
} catch (e: Exception) {
_state.value = _state.value.copy(isLoading = false, error = e.message)
}
}
fun hatSchon(rel: ArtistRelease): Boolean =
schluessel(rel.artistName, rel.title) in _state.value.vorhanden
fun herunterladen(rel: ArtistRelease, onDone: (Boolean, String) -> Unit) {
viewModelScope.launch {
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
if (backendUrl.isBlank()) { onDone(false, "Kein Backend konfiguriert"); return@launch }
val request = DownloadRequest(
jobId = "job_${System.currentTimeMillis()}",
type = "album",
title = rel.title,
artist = rel.artistName,
artworkUrl = rel.cover.takeIf { it.isNotBlank() },
collectionId = rel.albumId.toLongOrNull(),
itunesId = rel.albumId.toLongOrNull(),
)
val client = ImporterClient(backendUrl)
runCatching { client.download(request) }
.onSuccess {
NotificationStore.startPoll(
backendUrl, request.jobId, request.title, request.artist ?: "",
navType = "album", navAlbum = rel.title, navArtist = rel.artistName,
)
onDone(true, "✓ Download gestartet")
}
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
client.close()
}
}
private companion object {
fun norm(s: String) = s.lowercase()
.replace(Regex("[^a-z0-9]"), "")
fun schluessel(kuenstler: String, album: String) = "${norm(kuenstler)}~~${norm(album)}"
}
}
@@ -63,6 +63,12 @@ data class ArtistUiState(
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository
/** Folgen = Navidromes Stern auf dem Kuenstler. Laeuft ueber StarState, damit der Zustand
* sofort umspringt und bei einem Fehlschlag von selbst zurueckfaellt. */
fun folgenUmschalten(artistId: String, aktuellMarkiert: Boolean) =
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, artistId, aktuellMarkiert)
private val player = app.app.playerController
private val preview = app.app.previewController
private val artistId: String? = savedState["artistId"]
@@ -70,6 +76,12 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
private val _state = MutableStateFlow(ArtistUiState())
val state: StateFlow<ArtistUiState> = _state
// Scroll of the artist page + the "alle anzeigen" overlay, hoisted so tapping an album/artist and
// coming back returns you to the same spot (the artist route entry, hence this VM, is retained).
val pageListState = androidx.compose.foundation.lazy.LazyListState()
val allAlbumsGrid = androidx.compose.foundation.lazy.grid.LazyGridState()
val allAlbumsQuery = androidx.compose.runtime.mutableStateOf("")
init {
if (artistId.isNullOrBlank()) {
_state.value = ArtistUiState(isLoading = false, error = "Keine Künstler-ID übergeben")
@@ -95,7 +107,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
val artist = artistDef.await()
val info = infoDef.await()
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
val topDef = async { runCatching { repo.getTopSongs(artist.name, artist.id) }.getOrDefault(emptyList()) }
// A manual pin (shared, server-side) fixes same-name collisions — resolve it up front
// so the Deezer enrichment below uses the pinned artist, not a name-guess.
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() }
@@ -278,7 +290,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
}
_state.value = _state.value.copy(
isShuffling = false,
pendingShufflePlay = allSongs.shuffled().ifEmpty { null },
pendingShufflePlay = allSongs.ifEmpty { null }, // gemischt wird im Player
)
}
}
@@ -79,6 +79,10 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
// Global-search results scroll — hoisted so it survives an album/artist drill-in and back.
val searchListState = LazyListState()
// "Alle anzeigen" overlay (Deezer artist albums): scroll + search query, hoisted so an album
// drill-in and back keeps both (the overlay itself leaves composition on drill-in).
val allAlbumsGrid = androidx.compose.foundation.lazy.grid.LazyGridState()
val allAlbumsQuery = androidx.compose.runtime.mutableStateOf("")
private var searchJob: Job? = null
@@ -176,6 +180,16 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(albumTracks = null, albumItem = null)
}
/** Vom geöffneten Album zu dessen Künstler springen. Deezer legt das artist-Objekt samt id
* jedem Albumtreffer bei, der Sprung kostet also keinen zusätzlichen Suchaufruf. Die
* Albumansicht wird geschlossen, sonst läge sie weiter über der Künstlerseite. */
fun openArtistOfAlbum(album: ItunesItem) {
val id = album.artistId ?: return
val name = album.artistName ?: return
_state.value = _state.value.copy(albumTracks = null, albumItem = null, partialAlbum = null)
openArtistDetail(ItunesItem(wrapperType = "artist", artistId = id, artistName = name))
}
fun openArtistAllAlbums(title: String) { _state.value = _state.value.copy(artistAllAlbums = title) }
fun closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) }
@@ -189,15 +203,22 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
val artistName = artist.artistName ?: return@launch
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) }
// Bewusst ohne id: artistId ist hier DEEZERS Kuenstler-ID, nicht Navidromes. Sie hier
// mitzugeben wuerde Navidrome eine fremde ID unterschieben.
val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) }
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
val ndAlbums = ndAlbumsD.await()
val iAlbums = iAlbumsD.await()
// Aus einem Album konstruierte Künstler tragen weder Bild noch Fanzahl — einmal
// nachschlagen, damit die Kopfzeile genauso aussieht wie aus der Künstlersuche.
val voll = if (artistId != null && artist.artworkUrl100 == null && artist.fanCount == null)
runCatching { ItunesClient.lookupArtist(artistId) }.getOrNull() ?: artist
else artist
_state.value = _state.value.copy(
artistDetail = ItunesArtistState(
artist = artist,
artist = voll,
isLoading = false,
ndAlbums = ndAlbums,
iAlbums = iAlbums,
@@ -29,6 +29,11 @@ data class ImporterUiState(
val jobTotal: Int = 0,
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null
val previewingId: Long? = null,
// Playlist aus dem Import anlegen (wie im Web-Importer). Nur sinnvoll ab zwei Titeln.
val createPlaylist: Boolean = true,
val playlistName: String = "",
val playlistStatus: String? = null,
)
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
@@ -69,6 +74,9 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
fun stopPreview() = preview.stop()
fun setCreatePlaylist(v: Boolean) { _state.value = _state.value.copy(createPlaylist = v) }
fun setPlaylistName(v: String) { _state.value = _state.value.copy(playlistName = v) }
fun onLink(v: String) {
_state.value = _state.value.copy(
link = v,
@@ -173,6 +181,11 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
)
if (st.status == "done" || st.status == "error") {
_state.value = _state.value.copy(isDownloading = false)
val cur = _state.value
if (st.status == "done" && cur.createPlaylist && cur.resolved!!.tracks.size > 1) {
buildPlaylist(cur.playlistName.ifBlank { cur.resolved.name ?: "Import" },
cur.resolved.tracks)
}
return
}
}
@@ -182,6 +195,47 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
}
}
/**
* Legt nach dem Import eine Navidrome-Playlist aus den importierten Titeln an — dieselbe Abfolge
* wie im Web: Scan anstossen, kurz warten, jeden Titel suchen, gefundene IDs in eine neue
* Playlist. Titel, die Navidrome (noch) nicht kennt, werden gezaehlt statt zu scheitern.
*/
private suspend fun buildPlaylist(name: String, tracks: List<ImportTrack>) {
_state.value = _state.value.copy(playlistStatus = "Navidrome scannt…")
runCatching { repo.startScan() }
delay(4000)
val norm = { s: String -> s.lowercase().replace(Regex("[^\\p{L}\\p{N}]+"), "") }
val ids = mutableListOf<String>()
var fehlend = 0
for (t in tracks) {
val titel = t.trackName ?: continue
val treffer = runCatching {
val q = listOfNotNull(t.artistName, titel).joinToString(" ")
val gefunden = repo.search(q).song
gefunden.firstOrNull { norm(it.title) == norm(titel) }
?: repo.search(titel).song.firstOrNull { norm(it.title) == norm(titel) }
}.getOrNull()
if (treffer != null) ids += treffer.id else fehlend++
_state.value = _state.value.copy(
playlistStatus = "Playlist: ${ids.size}/${tracks.size} gefunden…")
}
if (ids.isEmpty()) {
_state.value = _state.value.copy(
playlistStatus = "Keine Titel in Navidrome gefunden — Playlist nicht angelegt.")
return
}
val res = runCatching { repo.createPlaylist(name, ids) }
_state.value = _state.value.copy(
playlistStatus = res.fold(
{ "Playlist \"$name\" angelegt: ${ids.size}/${tracks.size} Titel" +
if (fehlend > 0) " ($fehlend nicht gefunden)" else "" },
{ "Playlist fehlgeschlagen: ${it.message}" },
),
)
}
private fun mapError(code: String): String = when (code) {
"EDITORIAL_PLAYLIST" ->
"Editorial Playlist: Spotify sperrt eigene kuratierte Playlists. Kopiere sie in eine eigene Playlist und importiere diese."
@@ -11,6 +11,7 @@ import com.novasonic.app.api.model.Playlist
import com.novasonic.app.app
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -24,7 +25,12 @@ data class LibraryUiState(
val albumsByName: List<Album> = emptyList(), // type=alphabeticalByName (for AZ + Oldest)
val albumSort: AlbumSort = AlbumSort.NEWEST,
val artistIndexes: List<ArtistIndex> = emptyList(),
/** IDs der markierten ("gefolgten") Künstler — getArtists liefert kein `starred` mit. */
val gefolgteIds: Set<String> = emptySet(),
val nurGefolgte: Boolean = false,
val playlists: List<Playlist> = emptyList(),
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
val currentUser: String = "",
val isLoading: Boolean = false,
val error: String? = null,
)
@@ -42,10 +48,21 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
val albumsGridState = LazyGridState()
val artistsListState = LazyListState()
init { loadPlaylists() }
init {
loadPlaylists()
viewModelScope.launch {
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
_state.value = _state.value.copy(currentUser = u)
}
}
fun setAlbumSort(sort: AlbumSort) {
if (sort == _state.value.albumSort) return
_state.value = _state.value.copy(albumSort = sort)
// Eine andere Sortierung heißt eine andere Liste — dort oben anfangen. Bewusst hier und
// nicht als LaunchedEffect im Screen: der feuert auch bei jedem Neu-Composen (z. B. nach
// dem Zurück aus einem Album) und hätte die gemerkte Scrollposition jedes Mal verworfen.
viewModelScope.launch { albumsGridState.scrollToItem(0) }
}
fun selectTab(tab: LibraryTab) {
@@ -79,6 +96,18 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
}
}
fun nurGefolgteUmschalten(an: Boolean) {
_state.value = _state.value.copy(nurGefolgte = an)
if (an) ladeGefolgte()
}
/** Markierte Künstler nachladen. Frisch bei jedem Einschalten, damit ein eben entfolgter
* Künstler nicht noch in der Liste steht. */
private fun ladeGefolgte() = viewModelScope.launch {
runCatching { repo.getStarred().artist.map { it.id }.toSet() }
.onSuccess { _state.value = _state.value.copy(gefolgteIds = it) }
}
private fun loadArtists() = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Song
import com.novasonic.app.app
import com.novasonic.app.offline.DownloadGroupEntity
import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.toSong
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
@@ -27,6 +28,11 @@ class OfflineViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch { offlineRepo.deleteGroup(groupId) }
}
/** Nur FERTIGE Titel: wartende oder fehlgeschlagene haben keine lokale Datei und wuerden aus
* dem Netz gezogen — offline schlaegt die Wiedergabe dann fehl, bei grossen Playlists
* gleich beim ersten Titel. */
suspend fun songsForGroup(groupId: String): List<Song> =
offlineRepo.tracksForGroup(groupId).map { it.toSong() }
offlineRepo.tracksForGroup(groupId)
.filter { it.state == DownloadState.DONE }
.map { it.toSong() }
}
@@ -11,6 +11,7 @@ import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.GroupType
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -22,6 +23,8 @@ data class PlaylistUiState(
val isSaving: Boolean = false,
val deleted: Boolean = false,
val error: String? = null,
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
val currentUser: String = "",
)
class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
@@ -42,7 +45,13 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi
.map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
init { load() }
init {
load()
viewModelScope.launch {
val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("")
_state.value = _state.value.copy(currentUser = u)
}
}
fun load() {
val id = playlistId
@@ -36,6 +36,8 @@ data class SettingsUiState(
val absUrl: String = "",
val absApiKey: String = "",
val absChapterTrackMode: Boolean = false,
val downloadWifiOnly: Boolean = true,
val wischenWechseltTitel: Boolean = true,
val isSaving: Boolean = false,
val savedOk: Boolean = false,
val error: String? = null,
@@ -46,7 +48,8 @@ data class SettingsUiState(
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val repo = app.app.repository
private val offlineRepo = app.app.offlineRepository
// Seed the theme with the already-applied one so the picker doesn't jump before DataStore emits.
private val _state = MutableStateFlow(SettingsUiState(theme = NsColors.themeId))
@@ -68,6 +71,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
absUrl = s.absUrl,
absApiKey = s.absApiKey,
absChapterTrackMode = s.absChapterTrackMode,
downloadWifiOnly = s.downloadWifiOnly,
wischenWechseltTitel = s.wischenWechseltTitel,
)
}
}
@@ -84,6 +89,8 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
fun onAbsApiKey(v: String) = update { it.copy(absApiKey = v, savedOk = false, absStatus = ConnStatus.Idle) }
fun onAbsChapterTrackMode(v: Boolean) = update { it.copy(absChapterTrackMode = v, savedOk = false) }
fun onDownloadWifiOnly(v: Boolean) = update { it.copy(downloadWifiOnly = v, savedOk = false) }
fun onWischenWechseltTitel(v: Boolean) = update { it.copy(wischenWechseltTitel = v, savedOk = false) }
fun onTheme(id: String) {
update { it.copy(theme = id) }
@@ -177,7 +184,12 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
absUrl = s.absUrl.trimEnd('/'),
absApiKey = s.absApiKey.trim(),
absChapterTrackMode = s.absChapterTrackMode,
downloadWifiOnly = s.downloadWifiOnly,
wischenWechseltTitel = s.wischenWechseltTitel,
))
// Die Netzbedingung steckt in der bereits eingereihten Arbeit — ohne Neueinreihen
// wuerde der WLAN-Schalter erst beim naechsten App-Start greifen.
offlineRepo.netzbedingungAktualisieren()
_state.value = _state.value.copy(isSaving = false, savedOk = true)
} catch (e: Exception) {
_state.value = _state.value.copy(isSaving = false, error = e.message)
@@ -0,0 +1,51 @@
<!-- Benachrichtigungen: Honigtopf mit Brief statt der ueblichen Glocke.
Reine Strichzeichnung wie die uebrigen Symbole, damit Icon(tint = …) sie einfaerben kann. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Die Zeichnung fuellt ihr Feld nur zu etwa zwei Dritteln, Material-Symbole fuellen es ganz.
Ohne dieses Aufziehen wirkt der Topf neben dem Zahnrad kleiner, obwohl beide 24 dp sind. -->
<group
android:pivotX="12"
android:pivotY="11.95"
android:scaleX="1.38"
android:scaleY="1.38">
<!-- Deckelrand -->
<path
android:pathData="M4.7,4.6 L19.3,4.6 A1.1,1.1 0 0 1 20.4,5.7 L20.4,6.5 A1.1,1.1 0 0 1 19.3,7.6 L4.7,7.6 A1.1,1.1 0 0 1 3.6,6.5 L3.6,5.7 A1.1,1.1 0 0 1 4.7,4.6 Z"
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="1.35"
android:strokeLineJoin="round" />
<!-- Topfkoerper, nach unten leicht verjuengt -->
<path
android:pathData="M5.5,7.6 c-1.1,3.9 -0.7,8.2 0.7,10.4 a2,2 0 0 0 1.7,0.9 h8.2 a2,2 0 0 0 1.7,-0.9 c1.4,-2.2 1.8,-6.5 0.7,-10.4"
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="1.35"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
<!-- Brief -->
<path
android:pathData="M9,11.7 L15,11.7 A0.7,0.7 0 0 1 15.7,12.4 L15.7,15.6 A0.7,0.7 0 0 1 15,16.3 L9,16.3 A0.7,0.7 0 0 1 8.3,15.6 L8.3,12.4 A0.7,0.7 0 0 1 9,11.7 Z"
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="1.25"
android:strokeLineJoin="round" />
<!-- Brieflasche -->
<path
android:pathData="M8.6,12.1 L12,14.4 L15.4,12.1"
android:fillColor="#00000000"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="1.25"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</group>
</vector>
Vendored Regular → Executable
View File
File mode changed.
+32
View File
@@ -0,0 +1,32 @@
# NovaSonic download backend — bakes the heavy deps (ffmpeg, Deno, yt-dlp, node libs) so restarts are
# instant instead of re-installing everything on every boot. Build context = ./backend.
FROM node:22-slim
# System deps: python (yt-dlp), ffmpeg (tagging/convert), curl/git/unzip (installers), ca-certs.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip ffmpeg curl git unzip ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# yt-dlp (nightly) + the Spotify helper tooling.
RUN pip install --break-system-packages -U --pre 'yt-dlp[default]' \
&& pip install --break-system-packages spotapi 'git+https://github.com/TzurSoffer/spotify-downloader'
# Deno — yt-dlp uses it to solve YouTube's JS signature/nsig challenge.
RUN curl -fsSL https://deno.land/install.sh | sh
ENV PATH="/root/.deno/bin:${PATH}"
# Node libs the server loads (express, kuromoji) — kept out of the app dir like the compose setup did.
WORKDIR /deps
RUN npm install express kuromoji
ENV NODE_PATH=/deps/node_modules
# App code.
WORKDIR /app
COPY . /app/
ENV PORT=3700 DATA_PATH=/data
EXPOSE 3700
# Refresh yt-dlp on start (heavy deps already baked, so this is a quick pull) — non-fatal if offline,
# the baked version still works. Keeps the "always-latest yt-dlp" behaviour of the old compose command.
CMD ["sh","-c","pip install --break-system-packages -U --pre 'yt-dlp[default]' >/dev/null 2>&1 || true; node novasonic-server.js"]
+130
View File
@@ -0,0 +1,130 @@
// ─── KONZERTE GEFOLGTER KÜNSTLER ──────────────────────────────────────────────
// Liefert zu einer Liste von Navidrome-Künstlern die anstehenden Termine, damit die App den
// Reiter "Konzerte" in der Zeitleiste füllen kann. Gegenstück zu releases.js.
//
// Warum serverseitig: bei 50 gefolgten Künstlern wären das 50 Ticketmaster-Aufrufe — pro Gerät und
// pro App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam.
//
// Bewusste Einschränkung (25.9.2026): gesucht wird über den NAMEN (`keyword=`), nicht über
// Ticketmasters `attractionId`. Das ist dieselbe Unschärfe wie bei der Deezer-Zuordnung — ein
// kurzer Künstlername trifft als Stichwort auch Fremdes. Deshalb wird jeder Treffer gegengeprüft:
// der Name muss im Veranstaltungs- oder Attraktionsnamen vorkommen. Wer es genauer braucht, löst
// später die attractionId einmal pro Künstler auf (siehe Backlog im CHANGELOG).
const https = require('https');
const _cache = new Map(); // nameLower → { ts, termine }
const TTL = 6 * 60 * 60 * 1000; // 6 h — Termine ändern sich nicht minütlich
const GLEICHZEITIG = 4; // höflich gegenüber Ticketmaster, trotzdem zügig
const norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '')
.replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
function holen(url) {
return new Promise((resolve, reject) => {
https.get(url, { headers: { Accept: 'application/json' } }, up => {
let body = '';
up.on('data', d => body += d);
up.on('end', () => {
try {
const data = JSON.parse(body);
if (up.statusCode !== 200 || data.fault || data.errors) {
return reject(new Error(`Ticketmaster ${up.statusCode}`));
}
resolve(data);
} catch (e) { reject(e); }
});
}).on('error', reject);
});
}
/** Anstehende Termine eines Künstlers, gecacht. Bei einem Fehler kommt der alte Stand zurück. */
async function termineFuer(name, apiKey) {
const key = norm(name);
if (!key) return [];
const c = _cache.get(key);
if (c && Date.now() - c.ts < TTL) return c.termine;
const ab = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const url = 'https://app.ticketmaster.com/discovery/v2/events.json' +
`?keyword=${encodeURIComponent(name)}&classificationName=music` +
`&startDateTime=${ab}&size=20&sort=date,asc&apikey=${apiKey}`;
try {
const data = await holen(url);
const roh = data?._embedded?.events || [];
const termine = roh.map(e => {
const ort = e._embedded?.venues?.[0] || {};
const start = e.dates?.start || {};
// Die ORTSZEIT nehmen, nicht die UTC-Zeit: ein Konzert um 20:00 sonst als 08:00Z angezeigt.
// Als naive lokale Zeichenkette ohne Zone weitergereicht, die Clients zeigen sie unverändert.
const zeit = (start.localDate
? `${start.localDate}T${start.localTime || '00:00:00'}`
: start.dateTime) || null;
const aus = e.dates?.status?.code === 'offsale' || e.dates?.status?.code === 'cancelled';
const attraktionen = (e._embedded?.attractions || []).map(a => a.name || '');
return {
eventId: e.id || '',
titel: e.name || '',
datum: zeit,
ort: ort.name || '',
stadt: ort.city?.name || '',
land: ort.country?.name || '',
url: e.url || '',
abgesagt: aus,
_namen: [e.name || '', ...attraktionen],
};
}).filter(t => t.datum);
// Gegenprobe zur Stichwortsuche: der Künstlername muss irgendwo im Veranstaltungs- oder
// Attraktionsnamen stehen. Ohne das liefert "MORE" oder "TPR" fremde Veranstaltungen.
const gesucht = key;
const gefiltert = termine.filter(t => t._namen.some(n => norm(n).includes(gesucht)));
const sauber = gefiltert.map(({ _namen, ...rest }) => rest);
_cache.set(key, { ts: Date.now(), termine: sauber });
return sauber;
} catch (e) {
// Bei einem Fehler den abgelaufenen Stand ausliefern statt einer leeren Liste — aber den
// Zeitstempel NICHT auffrischen, damit er beim nächsten Mal wieder frisch geholt wird.
if (c) return c.termine;
throw e;
}
}
async function inHaeppchen(items, groesse, fn) {
const raus = [];
for (let i = 0; i < items.length; i += groesse) {
raus.push(...await Promise.all(items.slice(i, i + groesse).map(fn)));
}
return raus;
}
/**
* @param {Array<{id,name}>} kuenstler Navidrome-Künstler
* @param {string} apiKey Ticketmaster-Schlüssel
* @param {number} tage Zeitfenster nach vorn; 0 = kein Fenster (Vorgabe)
* @returns {{konzerte:Array, ohneTermine:Array<string>}}
*/
async function kommendeKonzerte(kuenstler, apiKey, tage = 0) {
// Kein Fenster als Vorgabe: Ticketmaster liefert ohnehin nur Anstehendes, und bei Konzerten
// will man ALLES sehen, was angekuendigt ist — eine Tagesgrenze schneidet nur willkuerlich ab.
const grenze = tage > 0 ? Date.now() + tage * 24 * 60 * 60 * 1000 : Infinity;
const ohneTermine = [];
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
let termine = [];
try { termine = await termineFuer(k.name, apiKey); } catch { return []; }
if (!termine.length) { ohneTermine.push(k.name); return []; }
return termine
.filter(t => new Date(t.datum).getTime() <= grenze)
.map(t => ({ artistId: k.id, artistName: k.name, ...t }));
});
// Nach vorn sortiert: das naechste Konzert zuerst — anders als bei den Veroeffentlichungen,
// die rueckwaerts laufen.
const konzerte = proKuenstler.flat()
.sort((a, b) => new Date(a.datum) - new Date(b.datum));
return { konzerte, ohneTermine };
}
module.exports = { kommendeKonzerte, termineFuer, _cache };
+88 -3
View File
@@ -6,6 +6,19 @@ function sanitize(s) {
return (s || 'Unknown').replace(/[\\/:*?"<>|]/g, '_').trim().slice(0, 100);
}
// Welcher Kuenstler in den DATEINAMEN gehoert. Bisher stand dort die vollstaendige Gaesteliste
// ("K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine,League of Legends"). Das ist nirgends
// ueblich — Picard, beets und Lidarr schreiben hoechstens einen Kuenstler in den Namen —, und es
// hatte zwei handfeste Nachteile: sanitize() schneidet bei 100 Zeichen hart ab, also mitten im
// letzten Namen, und mit dem Dateisystemlimit von 255 Byte wird es bei mehrbytigen Namen eng.
// Die vollstaendige Liste steht ohnehin im artist-Tag, und nur den liest Navidrome.
// Genommen wird der ERSTE Kuenstler — bei gemischten Alben der des Titels, nicht der des Albums,
// damit ein Soundtrack-Titel im Namen weiterhin seinen eigenen Interpreten traegt.
function dateinamensKuenstler(artist, fallback) {
const erster = (s) => String(s || '').split(/[;,]|\s+&\s+/)[0].trim();
return erster(artist) || erster(fallback) || '';
}
function navidromeArtist(name) {
if (!name) return name;
return name
@@ -61,14 +74,21 @@ const _VERSION_KW = /\b(?:remix|live|acoustic|instrumental|version|vip|flip|boot
// release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
// "recognize - Single.mp3" vs "recognize.mp3" (drift between runs).
function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
const norm = s => (s || '').toLowerCase()
const norm = s => (s || '')
// Akzente abtrennen und verwerfen: dieselbe Kuenstlerin kommt je nach Quelle als
// "Cécile Corbel" oder "Cecile Corbel" — ohne das galt sie als zwei Personen und derselbe
// Song wurde ein zweites Mal geladen. Betrifft nur den VERGLEICH, nicht Tags oder Dateinamen.
.normalize('NFD').replace(/\p{M}+/gu, '')
.toLowerCase()
// Strip bracketed noise (feat. X, Official Video, Prod. by …) but KEEP version markers
// (Remix/Live/Acoustic/…) so distinct recordings aren't collapsed onto the base track.
.replace(/[\[((]([^\]))]*)[\]))]/g, (m, inner) => _VERSION_KW.test(inner) ? m : '')
// Filesystem-sanitized chars (/ \ : * ? " < > |) become "_" on disk — map those AND "_"
// to a space so a title tag "Kwik Flip | YTPMV" matches the file "Kwik Flip _ YTPMV".
.replace(/[/\\:*?"<>|_]/g, ' ')
.replace(/[,.'!]/g, '')
// Auch die typografischen Apostrophe: Anbieter schreiben "Arrietty’s Song" (U+2019), eine
// spaetere Anfrage kommt mit geradem "'" — ohne das galten beide als verschiedene Titel.
.replace(/[,.!'\u2018\u2019\u02bc\u00b4`]/g, '')
.replace(/\s+/g, ' ')
.trim();
const normArtist = norm(firstArtist);
@@ -111,6 +131,70 @@ function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
return null;
}
const AUDIO_EXT_RE = /\.(mp3|flac|opus|m4a|ogg|wav)$/i;
// Vereinheitlicht einen Dateinamen-Stamm fuer den lockeren Vergleich:
// 1. fuehrende Track-Praefixe wie "01-15 – " oder "01 - " abschneiden
// 2. alle Strichvarianten (en/em dash) auf " - " bringen
function normStem(s) {
return s
.replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '')
.replace(/\s*[–—]\s*/g, ' - ');
}
// Tiefensuche nach einer Audiodatei, deren Stamm zum Titel passt. Letzter Ausweg, wenn der von
// Navidrome gemeldete Pfad nicht existiert (Ordner umbenannt, Tag-Pfad statt echtem Pfad).
//
// WICHTIG: der Titel muss ein VOLLSTAENDIGES Feld des Dateinamens sein, nie nur dessen Ende.
// Ein blosses endsWith(titel) liess "Bad Omens - Like A Villain" als Treffer fuer den Titel
// "VILLAIN" durchgehen — ein voellig anderer Song derselben Endsilbe. Genau solche Fehlgriffe
// landen sonst im Metadaten-Editor oder, schlimmer, im Loeschen-Endpunkt.
function findFileByStem(dir, titleStem, depth = 0) {
if (depth > 4) return null;
const normTitle = normStem(titleStem);
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const found = findFileByStem(path.join(dir, entry.name), titleStem, depth + 1);
if (found) return found;
} else if (AUDIO_EXT_RE.test(entry.name)) {
const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '');
const bareStem = normStem(bare.toLowerCase());
// Treffer: exakt, "Kuenstler - Titel", oder Release-Suffix-Versatz ("… - Single").
if (bareStem === normTitle ||
bareStem.endsWith(' - ' + normTitle) ||
bareStem.startsWith(normTitle + ' - ') ||
normTitle.startsWith(bareStem + ' - ')) {
return path.join(dir, entry.name);
}
}
}
} catch {}
return null;
}
// Navidrome gibt ueber die Subsonic-API standardmaessig keinen echten Pfad heraus, sondern baut
// einen aus den Tags: {AlbumArtist}/{Album}/{DD}-{TT} - {Titel}.{Endung}. Steht ein "/" IM Namen
// — "K/DA", "POP/STARS" —, wird daraus eine zusaetzliche Verzeichnisebene, waehrend auf der Platte
// sanitize() ein "_" geschrieben hat ("K_DA/POP_STARS"). Der gemeldete Pfad existiert dann nicht,
// und die Suche faellt auf Aehnlichkeit zurueck. Diese Funktion baut den Ordner stattdessen genau
// so zusammen, wie der Downloader ihn angelegt haette.
function albumDirFromTags(musicRoot, albumArtist, album) {
if (!musicRoot || !albumArtist) return null;
// Der Downloader legt den Ordner unter dem ERSTEN Kuenstler an (mainArtist.split(/[,;&]/)[0]).
const ersterKuenstler = String(albumArtist).split(/[,;&]/)[0].trim();
const kandidaten = [];
for (const k of new Set([ersterKuenstler, String(albumArtist).trim()])) {
if (!k) continue;
if (album) kandidaten.push(path.join(musicRoot, sanitize(k), sanitize(album)));
kandidaten.push(path.join(musicRoot, sanitize(k)));
}
for (const c of kandidaten) {
try { if (fs.statSync(c).isDirectory()) return c; } catch {}
}
return null;
}
// Fetch plain + synced lyrics from LRCLIB
async function fetchLyrics(artist, title, album) {
try {
@@ -131,6 +215,7 @@ async function fetchLyrics(artist, title, album) {
// fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums.
module.exports = {
sanitize, navidromeArtist,
sanitize, navidromeArtist, dateinamensKuenstler,
downloadArtwork, findExistingTrack,
normStem, findFileByStem, albumDirFromTags,
};
+95
View File
@@ -0,0 +1,95 @@
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ───────────────────────────────
// Liefert zu einer Liste von Navidrome-Künstlern die jüngsten Deezer-Releases, damit die App eine
// Zeitleiste "Neu von deinen Künstlern" zeigen kann.
//
// Warum serverseitig: bei 30 gefolgten Künstlern wären das 30 Deezer-Aufrufe — pro Gerät und pro
// App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam.
const { dzGet } = require('./deezer');
const _cache = new Map(); // deezerArtistId → { ts, alben }
const _idCache = new Map(); // nameLower → { ts, id }
const TTL = 6 * 60 * 60 * 1000; // 6 h — Alben erscheinen nicht minütlich
const ID_TTL = 24 * 60 * 60 * 1000; // 24 h — die Zuordnung Name → Deezer ändert sich kaum
const GLEICHZEITIG = 4; // höflich gegenüber Deezer, trotzdem zügig
const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || '';
// Künstler stilisieren ihren Namen mit führenden Zeichen (".diedlonely", "†…") — ohne das Abschneiden
// findet Deezers Suche sie nicht. Gleiche Regel wie in deezer.js.
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
/** Deezer-ID zu einem Künstler: erst die gepinnte, sonst über den Namen gesucht (und gemerkt). */
async function deezerIdFuer(name, pinId) {
if (pinId) return String(pinId);
const key = stripLeadSym(name).toLowerCase();
if (!key) return null;
const c = _idCache.get(key);
if (c && Date.now() - c.ts < ID_TTL) return c.id;
try {
const treffer = (await dzGet(`search/artist?q=${encodeURIComponent(stripLeadSym(name))}&limit=5`)).data || [];
// Deezer sortiert nicht nach Bekanntheit — die Fan-Zahl trifft den gemeinten Künstler deutlich
// öfter als der erste Treffer. Ein Pin schlägt das trotzdem, siehe oben.
const best = treffer.slice().sort((a, b) => (b.nb_fan || 0) - (a.nb_fan || 0))[0];
const id = best ? String(best.id) : null;
_idCache.set(key, { ts: Date.now(), id });
return id;
} catch { return null; }
}
/** Albumliste eines Deezer-Künstlers, gecacht. */
async function albenFuer(deezerId) {
const c = _cache.get(deezerId);
if (c && Date.now() - c.ts < TTL) return c.alben;
try {
const alben = (await dzGet(`artist/${deezerId}/albums?limit=50`)).data || [];
_cache.set(deezerId, { ts: Date.now(), alben });
return alben;
} catch {
// Bei einem Fehler lieber den alten Stand als gar nichts — abgelaufen, aber nicht falsch.
return c ? c.alben : [];
}
}
/** Führt eine Aufgabenliste mit begrenzter Gleichzeitigkeit aus. */
async function inHaeppchen(items, groesse, fn) {
const raus = [];
for (let i = 0; i < items.length; i += groesse) {
raus.push(...await Promise.all(items.slice(i, i + groesse).map(fn)));
}
return raus;
}
/**
* @param {Array<{id,name,deezerArtistId?}>} kuenstler Navidrome-Künstler (id + name)
* @param {number} tage Zeitfenster
* @returns {{releases:Array, ohneZuordnung:Array<string>}}
*/
async function neueReleases(kuenstler, tage = 90) {
const grenze = Date.now() - tage * 24 * 60 * 60 * 1000;
const ohneZuordnung = [];
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
const dzId = await deezerIdFuer(k.name, k.deezerArtistId);
if (!dzId) { ohneZuordnung.push(k.name); return []; }
const alben = await albenFuer(dzId);
return alben
.filter(a => a.release_date && new Date(a.release_date).getTime() >= grenze)
.map(a => ({
artistId: k.id,
artistName: k.name,
deezerArtistId: dzId,
albumId: String(a.id),
title: a.title || '',
cover: coverXl(a),
releaseDate: a.release_date,
recordType: a.record_type || 'album',
trackCount: a.nb_tracks ?? null,
explicit: !!a.explicit_lyrics,
}));
});
const releases = proKuenstler.flat()
.sort((a, b) => new Date(b.releaseDate) - new Date(a.releaseDate));
return { releases, ohneZuordnung };
}
module.exports = { neueReleases, deezerIdFuer, albenFuer, _cache, _idCache };
+1 -1
View File
@@ -115,7 +115,7 @@ const ROMAJI_TTL = 20 * 60 * 1000; // 20 min
async function _fetchNdSongs(ndUrl, user, pass) {
const salt = Math.random().toString(36).slice(2, 10);
const token = createHash('md5').update(pass + salt).digest('hex');
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json&albumCount=0&artistCount=0`;
const base = `${ndUrl}/rest/search3.view?u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json&albumCount=0&artistCount=0`;
const songs = [];
let offset = 0;
while (true) {
+173 -58
View File
@@ -4,7 +4,7 @@ const fs = require('fs');
const CONFIG = require('./config');
const { log } = require('./jobs');
const {
sanitize, navidromeArtist,
sanitize, navidromeArtist, dateinamensKuenstler,
downloadArtwork, findExistingTrack,
} = require('./metadata');
const { romajiForStorage, isTokenizerReady } = require('./romaji');
@@ -12,11 +12,37 @@ const { romajiForStorage, isTokenizerReady } = require('./romaji');
// Matches hiragana, katakana, CJK — used to detect Japanese content
const JP_RE = /[ぁ-んァ-ン一-龯]/;
function buildTitleKeywords(title) {
// For Japanese titles: Art Tracks on YTM/YouTube are often romanized
// ("Souiunatsu" for "そういう夏") — a Japanese keyword filter would always
// reject the correct video. Channel + duration are the quality gates instead.
if (JP_RE.test(title)) return [];
function buildTitleKeywords(title, romaji = '') {
// Japanische Titel haben keine Wortgrenzen, die Zwei-laengste-Woerter-Regel greift also nicht.
// Frueher lieferte diese Funktion dafuer eine LEERE Liste — und damit hatte der Filter bei einem
// japanischen Titel gar keine Titelpruefung mehr. Zusammen mit den Kaskadenschritten ohne
// Kuenstlerpruefung blieb allein die Dauer uebrig, und bei einem Soundtrack-Album passt fast
// jeder andere Titel desselben Komponisten in dasselbe Dauerfenster. Genau so kam am 24.9.2026
// ein falsches Stueck aus "HUNTER×HUNTER" herein.
//
// Stattdessen wird jetzt eine Alternative gefordert: der ORIGINALTITEL oder seine Romaji-Fassung.
// Art Tracks tragen ueblicherweise das eine oder das andere. Leerzeichen und Bindestriche der
// Romaji-Fassung bleiben dabei frei, weil Uploader sie unterschiedlich setzen
// ("Ginpatsu no Shounen" / "Ginpatsu-no-Shounen").
if (JP_RE.test(title)) {
const escRe = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const alternativen = [escRe(title.trim())];
const r = (romaji || '').trim();
if (r && r.toLowerCase() !== title.trim().toLowerCase() && !JP_RE.test(r)) {
// Die Umschrift des Tokenizers ist EINE Schreibweise von mehreren. Uploader schreiben
// "Shonen" statt "Shounen", setzen Bindestriche oder lassen den Katakana-Laengungsstrich
// weg. Das Muster laesst diese Abweichungen zu — sonst waere die Alternative so streng,
// dass sie praktisch nie greift.
alternativen.push(
escRe(r)
.replace(/ー/g, '') // kommt in lateinischen Titeln nie vor
.replace(/\s+/g, '[ -]?')
.replace(/ou/gi, '(?:ou|o|\u014d|oh)')
.replace(/uu/gi, '(?:uu|u|\u016b)')
);
}
return [`(?:${alternativen.join('|')})`];
}
const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']);
const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with','single','ep','album','remaster','remastered','deluxe','edition','version','live','bonus']);
const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit'];
@@ -26,14 +52,57 @@ function buildTitleKeywords(title) {
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter(w => w.length >= 3 && !VARIANT_WORDS.has(w.toLowerCase()) && !STOPWORDS.has(w.toLowerCase()));
const top2 = words.sort((a, b) => b.length - a.length).slice(0, 2);
const toPattern = w => (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
const result = top2.map(toPattern);
// Numbers are the most distinctive token a title has and they are usually SHORT, so the
// longest-two rule threw them away: "700 Main Street" kept only Street+Main and happily matched
// "777 Main Street" by a different artist. Numbers are therefore always required, on top of the
// two longest words.
// Ausnahme: die Jahreszahl einer Neuauflage ("… - 2020 Remaster") steht auf der Veroeffentlichung,
// aber nie im YouTube-Titel — als Pflicht-Keyword wuerde sie den richtigen Upload ablehnen. Nur
// wenn ein solches Qualifier-Wort daneben steht; ein Titel, der SELBST eine Jahreszahl ist
// ("1999", "Berlin 2020"), behaelt sie.
const RELEASE_QUALIFIER = /\b(remaster|remastered|version|edition|anniversary|mono|stereo)\b/i;
const isEditionYear = w => /^(19|20)\d{2}$/.test(w) && RELEASE_QUALIFIER.test(title || '');
const isRequiredNum = w => /^\d+$/.test(w) && !isEditionYear(w);
const numbers = words.filter(isRequiredNum);
const wordsNoNum = words.filter(w => !isRequiredNum(w));
const top2 = wordsNoNum.sort((a, b) => b.length - a.length).slice(0, 2);
// Uploaders abbreviate the same word the release spells out ("700 Main St" vs "700 Main Street"),
// which rejected the official Art Track in every cascade step. Accept either spelling; \b keeps
// the short form from matching inside another word.
const ABBREV = { street: 'st', saint: 'st', mount: 'mt', doctor: 'dr', versus: 'vs', number: 'no' };
const toPattern = w => {
const abbr = ABBREV[w.toLowerCase()];
if (abbr) return `\\b(?:${w}|${abbr})\\b`;
return (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
};
const result = [...numbers.map(n => `\\b${n}\\b`), ...top2.map(toPattern)];
const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null;
if (qualifier && !top2.some(w => w.toLowerCase() === qualifier)) result.push(qualifier);
return result;
}
// Aus der YTM-Suchausgabe (--flat-playlist --print webpage_url) die Song-URLs herausziehen.
// Eine Suche liefert drei Sorten Zeilen: watch?v=… (ein Song), browse/MPREb_… (ein Album) und
// browse/UC… (ein Kuenstlerkanal). Nur die erste ist ein Kandidat; die anderen beiden sind
// Listeneintraege und werden uebersprungen.
//
// Frueher galt eine browse/UC-Zeile als "yt-dlp hat einen Kanal aufgeklappt" und die GESAMTE
// Trefferliste wurde verworfen — auch die bereits gesammelten Songs davor. Bei "DAT ADAM UFO" stand
// der Kanal auf Platz 9 und riss sieben gute Treffer mit, darunter den offiziellen Art Track auf
// Platz 1; uebrig blieb der YouTube-Fallback, der einen Live-Mitschnitt zog. Aufklappen kann hier
// ohnehin nicht passieren: mit --flat-playlist druckt yt-dlp einen Kanal als EINE Zeile.
function pickYtmVideoUrls(stdout) {
const urls = [];
let skipped = 0;
for (const line of (stdout || '').split('\n')) {
const u = line.trim();
if (!u) continue;
if (u.includes('watch?v=')) urls.push(u);
else if (u.includes('/browse/')) skipped++;
}
return { urls, skipped };
}
function buildVariantFilter(title) {
const t = (title || '').toLowerCase();
// !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that
@@ -50,8 +119,16 @@ function buildVariantFilter(title) {
{ key: 'cover', expr: 'title !~= (?i)\\bcover\\b' },
{ key: 'acoustic', expr: 'title !~= (?i)\\bacoustic\\b' },
{ key: 'instrumental', expr: 'title !~= (?i)\\binstrumental\\b' },
// Konzertmitschnitte: `!is_live` heisst nur "laeuft nicht gerade als Stream" und laesst eine
// Live-AUFNAHME durch. Auf dem echten Kuenstlerkanal ist die sonst nicht vom Studiotrack zu
// unterscheiden — so landete "Dat Adam - Ufo Video Days 2015 Live" in der Bibliothek.
// skipRe statt includes(): "deliver"/"delivery" enthalten "live" als Teilstring und wuerden
// die Regel sonst faelschlich abschalten.
{ key: 'live', expr: 'title !~= (?i)\\blive\\b', skipRe: /\blive\b/i },
];
const parts = RULES.filter(r => !t.includes(r.key)).map(r => r.expr);
// Eine Regel entfaellt, wenn der GESUCHTE Titel das Wort selbst traegt — wer "Live Forever" oder
// bewusst eine Live-Version sucht, soll sie auch bekommen.
const parts = RULES.filter(r => r.skipRe ? !r.skipRe.test(t) : !t.includes(r.key)).map(r => r.expr);
return parts.length ? parts.join(' & ') : '';
}
@@ -89,11 +166,22 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
const safeTitle = sanitize(cleanTitle);
const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
// Filename format: "{01 - }Artist1, Artist2 - Title.ext"
const safeArtistForFile = sanitize((artist || '').replace(/;/g, ','));
// Filename format: "{01 - }Artist - Title.ext" — nur der erste Kuenstler, siehe
// dateinamensKuenstler() in lib/metadata.js. Die volle Liste steht im artist-Tag.
const safeArtistForFile = sanitize(dateinamensKuenstler(artist, mainArtist));
const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle);
// Dublettenpruefung: findExistingTrack durchsucht NUR den Ordner des uebergebenen Kuenstlers.
// Bei Soundtracks und Compilations weicht der Album-Kuenstler vom Titel-Kuenstler ab, und je
// nachdem, ob der Song als Album oder einzeln geladen wird, landet er unter dem einen ODER dem
// anderen. Die Pruefung sah dann jeweils im falschen Ordner nach und lud ihn erneut. Deshalb wird
// bei abweichendem Titel-Kuenstler auch dessen Ordner geprueft — zweiter Durchlauf nur in genau
// diesem Fall, gemessen ~0,6 ms zusaetzlich bei knapp 29.000 Dateien.
const trackArtist = sanitize((artist || '').split(/[,;&]/)[0].trim());
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle)
|| (trackArtist && trackArtist !== safeArtist
? findExistingTrack(outputDir || CONFIG.musicPath, trackArtist, cleanTitle)
: null);
if (existing) {
log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`);
return { success: true, dir, skipped: true };
@@ -111,7 +199,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
const searchTitle = cleanTitle;
const variantFilter = buildVariantFilter(searchTitle);
const titleKws = buildTitleKeywords(searchTitle);
// Romaji nur, wenn der Tokenizer bereit ist — sonst liefert romajiForStorage den Text
// unveraendert zurueck und die Alternative waere doppelt.
const titleRomaji = isTokenizerReady() ? romajiForStorage(searchTitle) : '';
const titleKws = buildTitleKeywords(searchTitle, titleRomaji);
const variantPart = variantFilter ? ` & ${variantFilter}` : '';
const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : '';
@@ -126,8 +217,24 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
// audio — even when the channel doesn't contain the artist name (BANGTANTV/HYBE/…). Verified with
// "BTS Pied Piper": ONLY the official upload had artist=BTS; every fan/live/lyric reupload had
// none. Used as an OR alternative to the channel check so those official uploads still pass strict.
const artistMetaPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord))
// Fuer japanische Namen wird der KANAL nicht geprueft (Kanal-Namen stehen kaum je in Kanji),
// das Feld `artist` der Art Tracks dagegen sehr wohl — dort steht der Originalname. Ohne diese
// Ausnahme hatte ein japanischer Kuenstler ueberhaupt keine Identitaetspruefung.
const artistMetaPart = (artistFirstWord.length >= 3 || (JP_RE.test(artistFirstWord) && artistFirstWord.length >= 2))
? ` & artist ~= (?i)${escRe(artistFirstWord)}` : '';
// Kanji-Kuenstler: Kanaele und Videotitel schreiben den Namen meist lateinisch. Ohne diese
// Alternative bliebe fuer sie nur das artist-Feld in Originalschrift — und steht dort die
// lateinische Schreibweise, faellt der richtige Upload durch.
const artistRomajiWord = (JP_RE.test(artistFirstWord) && isTokenizerReady())
? ((romajiForStorage(artistFirstWord) || '').split(/\s+/)
.filter(w => w.length >= 3 && !JP_RE.test(w))
.sort((a, b) => b.length - a.length)[0] || '')
: '';
const artistRomajiParts = artistRomajiWord
? [` & channel ~= (?i)${escRe(artistRomajiWord)}`, ` & artist ~= (?i)${escRe(artistRomajiWord)}`]
: [];
// Alle Wege, den Kuenstler zu erkennen. yt-dlp verodert mehrere --match-filter.
const identitaet = [artistChannelPart, artistMetaPart, ...artistRomajiParts].filter(Boolean);
// Use a space (not " - ") between artist and title: this matches how users type
// in the YTM search box and ranks the exact official track first. A dash skews
@@ -202,14 +309,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
// result and treating its uploads as the search result set.
//
// When YTM returns an artist channel as the top hit, yt-dlp prints the channel's
// browse URL (/browse/UC…) instead of video URLs. We detect this on stdout and
// return [] so the caller falls through to the ytsearch fallback.
// NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search
// page processing, not just channel expansion, and would wrongly return [] always.
const getYtmVideoUrls = (maxItems) => new Promise(resolve => {
const urls = [];
let channelExpanded = false;
let out = '';
const proc = spawn(CONFIG.ytdlpBin, [
ytmUrl,
'--flat-playlist',
@@ -221,21 +324,13 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
// native Songs shelf (where the actual artist track lives).
'--extractor-args', 'youtube:player_client=web_music,default',
], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
proc.stdout.on('data', d => {
const text = d.toString();
if (text.includes('/browse/UC')) { channelExpanded = true; return; }
if (!channelExpanded) {
text.split('\n').forEach(line => {
const u = line.trim();
if (u.includes('watch?v=')) urls.push(u);
});
}
});
proc.stdout.on('data', d => { out += d.toString(); });
proc.on('error', () => resolve([]));
proc.on('close', () => {
if (channelExpanded) log(jobId, `⚠️ YTM: Artist-Channel in Suchergebnissen — überspringe`);
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden`);
resolve(channelExpanded ? [] : urls);
const { urls, skipped } = pickYtmVideoUrls(out);
if (!urls.length) log(jobId, `⚠️ YTM: keine Song-Treffer (${skipped} Listeneintrag/-eintraege)`);
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden${skipped ? ` (${skipped} Album/Kanal uebersprungen)` : ''}`);
resolve(urls);
});
});
@@ -250,16 +345,24 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
durPart = '';
}
const base = `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`;
// Non-strict, or no artist constraint (short / Japanese name): single filter.
if (!strict || !artistChannelPart) return ['--match-filter', base];
// Strict: pass if the channel name matches the artist OR yt-dlp's music `artist` metadata does
// (catches official label-channel uploads like BANGTANTV). Two --match-filter args = OR in yt-dlp.
return [
'--match-filter', `${base}${artistChannelPart}`,
'--match-filter', `${base}${artistMetaPart}`,
];
// Ohne Titelpruefung bliebe ohne Kuenstlerpruefung nur die Dauer — das ist keine Pruefung,
// sondern ein Muenzwurf. Solche Schritte laufen deshalb IMMER mit Kuenstlerpruefung, auch
// wenn die Kaskade sie eigentlich lockern wollte.
const mitKuenstler = strict || !titleKwPart;
if (!mitKuenstler || !identitaet.length) return ['--match-filter', base];
// Mehrere --match-filter verodert yt-dlp: Kanalname ODER `artist`-Metadatum (faengt offizielle
// Label-Uploads wie BANGTANTV, deren Kanal den Kuenstler nicht im Namen traegt).
return identitaet.flatMap(teil => ['--match-filter', `${base}${teil}`]);
};
// Weder Titel- noch Kuenstlermerkmal pruefbar: dann gaebe es nichts, woran sich der richtige
// Treffer erkennen liesse, und der erste Suchtreffer passender Laenge wuerde gespeichert.
// Lieber sauber scheitern — der Direktlink im Importer ist dafuer der vorgesehene Weg.
if (!titleKwPart && !identitaet.length) {
log(jobId, '⚠️ Weder Titel- noch Künstlermerkmal prüfbar — bitte Direktlink im Importer nutzen');
return { success: false, notFound: true, dir };
}
const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1'];
const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1'];
const PI_UNFILTERED = ['--playlist-items', '1'];
@@ -287,29 +390,41 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]);
}
// Findet sich im Dauerfenster nichts, wird ZUERST die DAUER gelockert und der Kuenstler
// BEHALTEN — nicht umgekehrt. Der Kuenstler ist das staerkere Merkmal: die erwartete Dauer
// stammt aus den Metadaten und gehoert oft zu einer anderen Fassung, waehrend Kanalname und
// artist-Feld den Song tatsaechlich benennen.
//
// Vorher lief es andersherum, und genau daran ist am 24.9.2026 "Dearly Beloved" von TPR
// gescheitert: erwartet wurden 230 s (Deezer kennt TPR nicht und lieferte "Dearly Beloved I"
// von Project Destati), der richtige Upload dauert 136 s und fiel aus dem Fenster — waehrend
// eine Rockband namens DEARLY BELOVED mit ihrem Stueck "LSD" bei 250 s mitten hinein passte.
// Deren BANDNAME erfuellte die Titelpruefung. Der Schritt ohne Kanalpruefung griff zuerst zu.
//
// Es faellt KEIN Schritt weg, nur die Reihenfolge dreht sich: was vorher gefunden wurde, wird
// weiter gefunden, nur gegebenenfalls eine Stufe spaeter (geprueft, siehe scripts/filter-probe.mjs).
if (!hasAudio()) {
// 3. YTM all-10 — no channel check (label channels, etc.)
log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…');
await tryYtm(0, mf(dur, true));
}
if (!hasAudio()) {
// 4. YT top-10 — no channel check
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
}
// No duration match. "Mittelweg": relax the duration constraint but REQUIRE the artist
// channel (+ title). This catches legit tracks whose YT upload duration drifts from the
// iTunes value, while never grabbing a same-titled video by someone else. If nothing
// matches title+channel we download NOTHING and the job fails cleanly (see end of fn) —
// far better than silently saving the wrong song (e.g. a "recognize" fan clip).
if (!hasAudio()) {
// 5. YTM all — no duration, but channel + title required
// 3. YTM all — ohne Dauer, aber Kuenstler + Titel verlangt
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…');
await tryYtm(0, mf(null, true, true));
}
if (!hasAudio()) {
// 6. YT top-10 — no duration, but channel + title required
// 4. YT top-10 — ohne Dauer, aber Kuenstler + Titel verlangt
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(null, true, true), ...baseArgs]);
}
// Letzter Ausweg: Kanalpruefung fallen lassen, dafuer die Dauer wieder verlangen. Das faengt
// offizielle Uploads von Label-Kanaelen, die den Kuenstler weder im Kanalnamen noch im
// artist-Feld tragen. Bleibt auch das ohne Treffer, wird NICHTS geladen und der Auftrag
// scheitert sauber — besser als still den falschen Song zu speichern.
if (!hasAudio()) {
// 5. YTM all-10 — ohne Kanalpruefung, dafuer im Dauerfenster
log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…');
await tryYtm(0, mf(dur, true));
}
if (!hasAudio()) {
// 6. YT top-10 — ohne Kanalpruefung, dafuer im Dauerfenster
await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]);
}
} else {
log(jobId, `🔍 Suche YT Music: "${query}"`);
// 1. YTM top-5 — strict: title + variant + channel check
@@ -424,4 +539,4 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
return { success: true, dir };
}
module.exports = { downloadTrack, embedMetadata };
module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter, pickYtmVideoUrls };
+337 -76
View File
@@ -18,7 +18,7 @@ const crypto = require('crypto');
const CONFIG = require('./lib/config');
const { jobs, updateJob, log } = require('./lib/jobs');
const { sanitize, findExistingTrack } = require('./lib/metadata');
const { sanitize, findExistingTrack, findFileByStem, albumDirFromTags, dateinamensKuenstler } = require('./lib/metadata');
const dz = require('./lib/deezer');
const tm = require('./lib/trackmeta'); // Deezer → MusicBrainz → clean fallback (single tracks)
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
@@ -94,57 +94,17 @@ function _findFileInDir(dir, basename) {
return null;
}
// Last-resort: deep recursive search by title stem (handles folder renames after metadata edit).
// Convention: MusicRoot/AlbumArtist/Album/{01 - }Artist - Title.ext
// The title stem is everything after the optional "NN - Artist - " prefix.
// We match any audio file whose stem ends with the same title words.
// Normalize a file stem for loose matching:
// 1. Strip compound track prefixes like "01-15 – " or "01 - " (handles en-dash, em-dash, hyphen)
// 2. Normalize all dash variants (en-dash, em-dash) to " - "
function _normStem(s) {
return s
.replace(/^\d[\d\-–—]*\s*[-–—]\s*/, '') // strip leading "01-15 – " or "01 - "
.replace(/\s*[–—]\s*/g, ' - '); // normalize remaining en/em-dash to " - "
}
function _findFileByStem(dir, titleStem, depth = 0) {
if (depth > 4) return null;
const normTitle = _normStem(titleStem);
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) {
const found = _findFileByStem(path.join(dir, entry.name), titleStem, depth + 1);
if (found) return found;
} else if (AUDIO_EXT_RE.test(entry.name)) {
// Strip extension + leading track prefix, then normalize dashes
const bare = entry.name.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '');
const bareStem = _normStem(bare.toLowerCase());
// Match: exact, or "Artist - Title" ending with our title stem
if (bareStem === normTitle ||
bareStem.endsWith(' - ' + normTitle) ||
bareStem.endsWith(normTitle) ||
// Release-suffix desync: Navidrome's title-derived stem ("… - recognize")
// may lack the "- Single"/"- EP" suffix the on-disk filename still carries
// ("… - recognize - single"), or vice-versa. Match on the structured prefix.
bareStem.startsWith(normTitle + ' - ') ||
normTitle.startsWith(bareStem + ' - ')) {
return path.join(dir, entry.name);
}
}
}
} catch {}
return null;
}
// Like _findFileByStem but also tries a romaji-converted stem.
// Die Tiefensuche nach Titelstamm liegt jetzt in lib/metadata.js (dort getestet) — sie ist der
// letzte Ausweg, wenn Navidromes Pfad nicht existiert, und darf deshalb nicht raten.
// Wie findFileByStem, versucht zusaetzlich einen Romaji-Stamm.
// Handles the case where Navidrome has a Japanese path in its DB but the file
// on disk was stored with a romaji filename by the download pipeline.
function _findFileByStemOrRomaji(dir, stem) {
const direct = _findFileByStem(dir, stem);
const direct = findFileByStem(dir, stem);
if (direct) return direct;
try {
const rt = (romajiForStorage(stem) || '').toLowerCase().trim();
if (rt && rt !== stem) return _findFileByStem(dir, rt);
if (rt && rt !== stem) return findFileByStem(dir, rt);
} catch {}
return null;
}
@@ -266,6 +226,65 @@ app.post('/api/artist-overrides', (req, res) => {
res.json({ ok: true });
});
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ─────────────────────────────
// POST /api/artist-releases { artists: [{id, name}], days }
// Die Deezer-Zuordnung passiert HIER, weil die Pins ohnehin serverseitig liegen — der Client
// schickt also nur seine Navidrome-Künstler und muss nichts über Deezer wissen.
app.post('/api/artist-releases', async (req, res) => {
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
const days = Math.min(Math.max(parseInt(req.body?.days, 10) || 90, 1), 365);
if (!artists.length) return res.json({ releases: [], ohneZuordnung: [] });
// Obergrenze, damit ein kaputter Client nicht hunderte Deezer-Aufrufe auslöst.
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
const pins = _loadArtistOverrides();
const mitPin = artists
.filter(a => a && a.id && a.name)
.map(a => ({
id: String(a.id),
name: String(a.name),
deezerArtistId: pins[String(a.id)]?.deezerArtistId || null,
}));
try {
const { neueReleases } = require('./lib/releases');
const ergebnis = await neueReleases(mitPin, days);
res.json(ergebnis);
} catch (e) {
console.error('[releases]', e.message);
res.status(502).json({ error: e.message });
}
});
// POST /api/artist-concerts { artists: [{id, name}], days }
// Gegenstueck zu /api/artist-releases: anstehende Termine aller gefolgten Kuenstler.
// Ohne Ortsfilter — Ticketmaster sucht weltweit. Das ist bewusst so (siehe CHANGELOG-Backlog):
// eine Einschraenkung per .env kommt spaeter, fuers Erste reicht, was die API liefert.
app.post('/api/artist-concerts', async (req, res) => {
const key = CONFIG.ticketmasterKey;
if (!key) return res.json({ konzerte: [], ohneTermine: [], keinSchluessel: true });
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
// Ohne `days` gibt es KEIN Fenster — Konzerte sind ohnehin nur Zukunft, und dort will man
// alles Angekuendigte sehen. Ein mitgeschicktes Fenster wird weiterhin beachtet.
const roh = parseInt(req.body?.days, 10);
const days = Number.isFinite(roh) && roh > 0 ? Math.min(roh, 3650) : 0;
if (!artists.length) return res.json({ konzerte: [], ohneTermine: [] });
// Obergrenze, damit ein kaputter Client nicht hunderte Ticketmaster-Aufrufe ausloest.
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
const sauber = artists.filter(a => a && a.id && a.name)
.map(a => ({ id: String(a.id), name: String(a.name) }));
try {
const { kommendeKonzerte } = require('./lib/konzerte');
res.json(await kommendeKonzerte(sauber, key, days));
} catch (e) {
console.error('[concerts]', e.message);
res.status(502).json({ error: e.message });
}
});
// DELETE /api/artist-overrides/:ndArtistId — remove a pin
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
const id = decodeURIComponent(req.params.ndArtistId);
@@ -503,6 +522,19 @@ function _giteaApiJson(pathAndQuery) {
});
}
// Gitea baut seine browser_download_url aus der eigenen ROOT_URL — die kann eine ganz andere Adresse
// sein als die, unter der das Backend Gitea erreicht (interne IP vs. Reverse-Proxy-Domain). Deshalb
// Schema+Host+Port immer durch GITEA_URL ersetzen und nur den Pfad übernehmen.
function _viaGiteaHost(urlStr) {
if (!urlStr || !GITEA_URL) return urlStr;
try {
const base = new URL(GITEA_URL);
const u = new URL(urlStr);
if (u.origin === base.origin) return urlStr;
return new URL(u.pathname + u.search, base).toString();
} catch { return urlStr; }
}
// The .apk asset of the fixed release, or null if unavailable.
async function _giteaApkAsset() {
const rel = await _giteaApiJson(`/releases/tags/${encodeURIComponent(GITEA_APK_TAG)}`);
@@ -513,7 +545,7 @@ async function _giteaApkAsset() {
const vcMatch = /versionCode\s*[=:]\s*(\d+)/i.exec(rel.body || '');
return {
size: asset.size,
url: asset.browser_download_url,
url: _viaGiteaHost(asset.browser_download_url),
version: rel.name || rel.tag_name || null,
versionCode: vcMatch ? parseInt(vcMatch[1], 10) : null,
updatedAt: new Date(asset.created_at || rel.published_at || Date.now()).getTime(),
@@ -523,7 +555,13 @@ async function _giteaApkAsset() {
function _serveLocalApk(res) {
if (res.headersSent) return;
fs.stat(APK_PATH, (err) => {
if (err) return res.status(404).json({ error: 'Keine APK hinterlegt' });
// Ohne lokale Datei UND ohne erreichbares Gitea-Asset: sagen, welcher der beiden Wege fehlt,
// sonst sucht man die APK an der falschen Stelle (die Logs oben nennen den Grund).
if (err) return res.status(404).json({
error: (GITEA_URL && GITEA_REPO)
? `APK-Release nicht erreichbar (${GITEA_URL}, Tag ${GITEA_APK_TAG}) und keine lokale Datei unter ${APK_PATH}`
: 'Keine APK hinterlegt',
});
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"');
res.sendFile(APK_PATH);
@@ -539,14 +577,21 @@ function _streamApkFromUrl(urlStr, res, redirectsLeft = 3) {
lib.get(u, { headers }, up => {
if ([301, 302, 303, 307, 308].includes(up.statusCode) && up.headers.location && redirectsLeft > 0) {
up.resume();
return _streamApkFromUrl(new URL(up.headers.location, u).toString(), res, redirectsLeft - 1);
return _streamApkFromUrl(_viaGiteaHost(new URL(up.headers.location, u).toString()), res, redirectsLeft - 1);
}
if (up.statusCode !== 200) {
up.resume();
console.warn(`[apk] Gitea-Asset lieferte HTTP ${up.statusCode} (${u.origin}) — fallback auf ${APK_PATH}`);
return _serveLocalApk(res);
}
if (up.statusCode !== 200) { up.resume(); return _serveLocalApk(res); }
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"');
if (up.headers['content-length']) res.setHeader('Content-Length', up.headers['content-length']);
up.pipe(res);
}).on('error', () => _serveLocalApk(res));
}).on('error', (e) => {
console.warn(`[apk] Gitea-Asset nicht erreichbar (${u.origin}): ${e.message} — fallback auf ${APK_PATH}`);
_serveLocalApk(res);
});
}
app.get('/api/apk/info', async (req, res) => {
@@ -956,7 +1001,7 @@ app.post('/api/fix-artist-sort', async (req, res) => {
try {
const { s, t } = mkTok();
const r = await fetch(
`${ndUrl}/rest/search3?query=${encodeURIComponent(artistKanji)}&artistCount=0&albumCount=0&songCount=500&u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`,
`${ndUrl}/rest/search3?query=${encodeURIComponent(artistKanji)}&artistCount=0&albumCount=0&songCount=500&u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json`,
{ signal: AbortSignal.timeout(15000) }
);
const d = await r.json();
@@ -1078,7 +1123,7 @@ app.post('/api/fix-artist-sort', async (req, res) => {
// Trigger Navidrome rescan
try {
const { s, t } = mkTok();
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) });
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(5000) });
} catch {}
const firstErr = errorDetails[0] ? ` (${errorDetails[0]})` : '';
@@ -1217,13 +1262,14 @@ app.get('/api/musicbrainz/lookup', async (req, res) => {
app.get('/api/song-sorttags', async (req, res) => {
const { ndUrl, user, pass, songId } = req.query;
if (!ndUrl || !user || !pass || !songId) return res.status(400).json({ error: 'ndUrl, user, pass, songId required' });
let filePath;
let filePath, ndSong;
try {
const salt = Math.random().toString(36).slice(2, 10);
const token = crypto.createHash('md5').update(pass + salt).digest('hex');
const ndRes = await fetch(`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(8000) });
const ndRes = await fetch(`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(8000) });
const nd = await ndRes.json();
filePath = nd?.['subsonic-response']?.song?.path;
ndSong = nd?.['subsonic-response']?.song;
filePath = ndSong?.path;
if (!filePath) return res.status(404).json({ error: 'Song not found' });
} catch (e) { return res.status(502).json({ error: e.message }); }
if (!path.isAbsolute(filePath)) filePath = path.join(CONFIG.musicPath, filePath);
@@ -1232,9 +1278,11 @@ app.get('/api/song-sorttags', async (req, res) => {
const rebased = _rebasePath(filePath, CONFIG.musicPath);
const rebasedDir = rebased ? path.dirname(rebased) : null;
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
const found = rebased
|| _findFileRecursive(CONFIG.musicPath, fileName)
|| _findFileInDir(rebasedDir || path.dirname(filePath), fileName)
|| (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
if (found) filePath = found; else return res.status(404).json({ error: 'File not found' });
}
@@ -1264,7 +1312,7 @@ async function _ndApi(ndUrl, user, pass, method, extra = '') {
const s = Math.random().toString(36).slice(2, 10);
const t = crypto.createHash('md5').update(pass + s).digest('hex');
const r = await fetch(
`${ndUrl}/rest/${method}?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=novasonic&f=json${extra}`,
`${ndUrl}/rest/${method}?u=${encodeURIComponent(user)}&t=${t}&s=${s}&v=1.16.1&c=NovaSonic-Backend&f=json${extra}`,
{ signal: AbortSignal.timeout(8000) }
);
return r.json();
@@ -1316,6 +1364,43 @@ async function _migratePlaylists(ndUrl, user, pass, plIds, oldId, newId) {
}
// ─── METADATA EDIT ───────────────────────────────────────────────────────────
// Liest die vorhandenen Tags einer Datei. Wird gebraucht, um einen ueber Namensaehnlichkeit
// gefundenen Kandidaten zu PRUEFEN, bevor er ueberschrieben wird.
async function _readTags(file) {
return new Promise((resolve) => {
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format_tags', file]);
let buf = '';
proc.stdout.on('data', d => buf += d);
proc.on('error', () => resolve({}));
proc.on('close', () => { try { resolve(JSON.parse(buf)?.format?.tags || {}); } catch { resolve({}); } });
});
}
const _tagNorm = (v) => (v || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
// Steckbrief einer Datei fuer den Dublettenvergleich: Groesse, Laenge, Titel/Kuenstler.
// Zwei Kopien desselben Songs unterscheiden sich fast immer nur in der Groesse (andere Bitrate),
// nie nennenswert in der Laenge — deshalb ist die Laenge das aussagekraeftige Feld.
async function _dateiSteckbrief(file) {
let groesse = null;
try { groesse = fs.statSync(file).size; } catch {}
const dauer = await new Promise((resolve) => {
const proc = spawn('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_entries', 'format=duration', file]);
let buf = '';
proc.stdout.on('data', d => buf += d);
proc.on('error', () => resolve(null));
proc.on('close', () => {
try { resolve(Math.round(parseFloat(JSON.parse(buf)?.format?.duration)) || null); } catch { resolve(null); }
});
});
const tags = await _readTags(file);
return {
pfad: file, groesse, dauer,
titel: tags.title || tags.TITLE || null,
artist: tags.artist || tags.ARTIST || null,
album: tags.album || tags.ALBUM || null,
};
}
app.post('/api/edit-metadata', async (req, res) => {
const { ndUrl, user, pass, songId, title, artist, albumArtist, album, year, trackNum, totalTracks,
discNum, discTotal, genre, artworkBase64, artworkMime, artworkUrl: newArtworkUrl,
@@ -1338,7 +1423,7 @@ app.post('/api/edit-metadata', async (req, res) => {
try {
const salt = Math.random().toString(36).slice(2, 10);
const token = crypto.createHash('md5').update(pass + salt).digest('hex');
const ndApi = `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`;
const ndApi = `${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`;
const ndRes = await fetch(ndApi, { signal: AbortSignal.timeout(8000) });
const ndData = await ndRes.json();
ndSong = ndData?.['subsonic-response']?.song;
@@ -1367,16 +1452,45 @@ app.post('/api/edit-metadata', async (req, res) => {
// but Navidrome's index is stale and still points to the old name.
const originalDir = path.dirname(filePath);
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
// Schraegstrich im Kuenstler-/Albumnamen ("K/DA", "POP/STARS"): Navidromes Tag-Pfad macht
// daraus eine Verzeichnisebene, auf der Platte steht "_". Den Ordner deshalb aus den Tags
// nachbauen und DORT suchen — das ist gezielt und geht jeder Aehnlichkeitssuche vor.
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
const found = rebased
|| _findFileRecursive(CONFIG.musicPath, fileName)
|| _findFileInDir(rebasedDir || originalDir, fileName)
|| (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem)
// Last resort: Subsonic getSong returns a *virtual* (tag-built) path, so the folder
// can be "[Unknown Album]" while the file actually sits in "Singles" with an artist
// prefix. findExistingTrack matches by normalized artist+title across all album folders.
|| findExistingTrack(CONFIG.musicPath, ndSong.albumArtist || ndSong.artist || '', ndSong.title || '');
if (found) { filePath = found; }
else return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
if (!found) {
return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
}
// Der Treffer stammt aus einer Aehnlichkeitssuche ueber Dateiname bzw. Kuenstler+Titel. Liegt
// derselbe Song mehrfach auf der Platte — etwa ein Fehlgriff des Downloaders neben der richtigen
// Datei —, kann das die FALSCHE Kopie sein. Die wurde dann getaggt, waehrend der bearbeitete Song
// unveraendert blieb: die Aenderung "sprang zurueck". Deshalb wird der Kandidat gegen die Tags
// geprueft, die Navidrome fuer diesen Song meldet, und im Zweifel abgebrochen statt geraten.
const kandidatTags = await _readTags(found);
const istTitel = _tagNorm(kandidatTags.title || kandidatTags.TITLE);
const istArtist = _tagNorm(kandidatTags.artist || kandidatTags.ARTIST);
const sollTitel = _tagNorm(ndSong?.title);
const sollArtist = _tagNorm(ndSong?.artist);
const titelPasst = !sollTitel || !istTitel || istTitel === sollTitel;
const artistPasst = !sollArtist || !istArtist ||
istArtist.includes(sollArtist) || sollArtist.includes(istArtist);
if (!titelPasst || !artistPasst) {
return res.status(409).json({
error: `Datei nicht eindeutig zuzuordnen. Navidrome meldet "${ndSong?.artist || '?'} – ` +
`${ndSong?.title || '?'}", die gefundene Datei enthaelt aber "` +
`${kandidatTags.artist || '?'} – ${kandidatTags.title || '?'}" (${found}). ` +
`Vermutlich liegt derselbe Song mehrfach in der Bibliothek — bitte die Dubletten ` +
`aufraeumen, sonst wuerde die falsche Datei bearbeitet.`,
});
}
filePath = found;
}
// Resolve artwork: base64 > URL
@@ -1455,19 +1569,33 @@ app.post('/api/edit-metadata', async (req, res) => {
// ── Post-save: rename file and/or move to correct artist/album folder ──
let currentFile = filePath;
// Umbenennen und Verschieben werden uebersprungen, wenn am Ziel schon eine Datei liegt. Das war
// bisher stumm — der Song blieb dann unter dem alten Namen bzw. im alten Ordner liegen, ohne dass
// es jemand erfuhr. Solche Faelle kommen jetzt als Warnung mit in die Antwort.
const warnungen = [];
// Faellt beim Verschieben eine gleichnamige Datei am Ziel auf, ist das bereits der
// Dublettenfall. Statt nur zu melden, werden beide Dateien verglichen und die Angaben
// mitgeschickt, damit die Oberflaeche das Aufraeumen direkt anbieten kann.
let dublette = null;
// 1. Rename file if title changed — convention: {01 - }?{Artist} - {Title}.ext
// Use original names (may be Japanese); romaji goes only into sort tags.
if (title) {
const safeTitle = sanitize(title);
const fileArtist = (normalArtist || ndSong?.artist || '').replace(/;/g, ',');
const fileArtist = dateinamensKuenstler(normalArtist, ndSong?.artist);
const safeArtistPart = fileArtist ? sanitize(fileArtist) + ' - ' : '';
const prefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : '';
const newName = `${prefix}${safeArtistPart}${safeTitle}${fileExt}`;
const newPath = path.join(path.dirname(currentFile), newName);
if (newPath !== currentFile && !fs.existsSync(newPath)) {
if (newPath !== currentFile && fs.existsSync(newPath)) {
warnungen.push(`Datei nicht umbenannt: "${path.basename(newPath)}" existiert bereits im ` +
`selben Ordner. Die Tags wurden geschrieben, der Dateiname bleibt alt.`);
} else if (newPath !== currentFile) {
try { fs.renameSync(currentFile, newPath); currentFile = newPath; }
catch (e) { console.error('[edit-metadata] file rename:', e.message); }
catch (e) {
console.error('[edit-metadata] file rename:', e.message);
warnungen.push(`Umbenennen fehlgeschlagen: ${e.message}`);
}
}
}
@@ -1482,7 +1610,23 @@ app.post('/api/edit-metadata', async (req, res) => {
try {
fs.mkdirSync(newDir, { recursive: true });
const destFile = path.join(newDir, path.basename(currentFile));
if (!fs.existsSync(destFile)) {
if (fs.existsSync(destFile)) {
const [ziel, hier] = await Promise.all([
_dateiSteckbrief(destFile), _dateiSteckbrief(currentFile),
]);
// Gleiche Laenge (± 2 s) UND gleicher Titel: dann sind es zwei Kopien desselben Songs
// und die ueberzaehlige kann weg. Sonst nur melden — Loeschen ist unwiderruflich.
const laengeGleich = ziel.dauer != null && hier.dauer != null &&
Math.abs(ziel.dauer - hier.dauer) <= 2;
const titelGleich = _tagNorm(ziel.titel) === _tagNorm(hier.titel);
dublette = {
behalten: ziel, // liegt schon am richtigen Platz
ueberzaehlig: hier, // die gerade bearbeitete Datei im alten Ordner
gleich: !!(laengeGleich && titelGleich),
};
warnungen.push(`Nicht verschoben: "${destFile}" existiert bereits. Die Datei bleibt in ` +
`"${oldAlbumDir}" — dieselbe Aufnahme liegt doppelt in der Bibliothek.`);
} else {
fs.renameSync(currentFile, destFile);
currentFile = destFile;
}
@@ -1509,20 +1653,42 @@ app.post('/api/edit-metadata', async (req, res) => {
try {
const salt2 = Math.random().toString(36).slice(2, 10);
const tok2 = crypto.createHash('md5').update(pass + salt2).digest('hex');
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${tok2}&s=${salt2}&v=1.16.1&c=novasonic&f=json`, { signal: AbortSignal.timeout(5000) });
await fetch(`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${tok2}&s=${salt2}&v=1.16.1&c=NovaSonic-Backend&f=json`, { signal: AbortSignal.timeout(5000) });
} catch {}
// Restore playlist membership after rename/move — the rescan gives the renamed song a new ID
if (affectedPlaylists.length > 0) {
// Umbenennen und Verschieben geben dem Song beim naechsten Scan eine NEUE Navidrome-ID. Alles,
// was an der alten ID hing, waere sonst weg: die Playlist-Zugehoerigkeit — und der Stern, der
// bisher stillschweigend verlorenging. Beides wird auf die neue ID uebertragen.
const warStarred = !!ndSong?.starred;
if (affectedPlaylists.length > 0 || warStarred) {
const effectiveTitle = title || ndSong?.title || '';
const effectiveArtist = (artist || ndSong?.artist || '').split(';')[0].trim();
const newSong = await _findSongAfterRename(ndUrl, user, pass, effectiveTitle, effectiveArtist, songId);
if (newSong && newSong.id !== songId) {
await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id);
if (affectedPlaylists.length > 0) {
await _migratePlaylists(ndUrl, user, pass, affectedPlaylists, songId, newSong.id);
}
if (warStarred) {
// Der Stern ist in Navidrome pro Nutzer gespeichert; uebertragen laesst sich nur der des
// bearbeitenden Nutzers — fremde Markierungen sind ueber die Subsonic-API weder lesbar
// noch setzbar. Schlaegt es fehl, bleibt es bei einer Warnung statt eines Abbruchs.
try {
const st = await _ndApi(ndUrl, user, pass, 'star', `&id=${encodeURIComponent(newSong.id)}`);
if (st?.['subsonic-response']?.status !== 'ok') throw new Error('star abgelehnt');
} catch (e) {
warnungen.push(`Favorit konnte nicht uebertragen werden (${e.message}) — der Song muss ` +
`erneut markiert werden.`);
}
}
} else if (warStarred && !newSong) {
warnungen.push('Der Song wurde nach dem Umbenennen nicht wiedergefunden — die Markierung ' +
'als Favorit konnte nicht uebertragen werden.');
}
}
res.json({ ok: true });
// Den tatsaechlich bearbeiteten Pfad mitgeben: wer eine Aenderung "zurueckspringen" sieht, kann
// damit sofort erkennen, ob die richtige Datei erwischt wurde.
res.json({ ok: true, file: currentFile, warnings: warnungen, duplicate: dublette });
} catch (e) {
try { if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile); } catch {}
if (artFile) try { fs.unlinkSync(artFile); } catch {}
@@ -1541,16 +1707,17 @@ app.post('/api/delete-song', async (req, res) => {
return res.status(400).json({ error: 'ndUrl, user, pass, songId required' });
// Resolve the file path from Navidrome
let filePath;
let filePath, ndSong;
try {
const salt = Math.random().toString(36).slice(2, 10);
const token = crypto.createHash('md5').update(pass + salt).digest('hex');
const ndRes = await fetch(
`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=novasonic&f=json`,
`${ndUrl}/rest/getSong?id=${songId}&u=${encodeURIComponent(user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic-Backend&f=json`,
{ signal: AbortSignal.timeout(8000) }
);
const ndData = await ndRes.json();
filePath = ndData?.['subsonic-response']?.song?.path;
ndSong = ndData?.['subsonic-response']?.song;
filePath = ndSong?.path;
if (!filePath) return res.status(404).json({ error: 'Song not found in Navidrome' });
} catch (e) {
return res.status(502).json({ error: `Navidrome lookup failed: ${e.message}` });
@@ -1561,14 +1728,36 @@ app.post('/api/delete-song', async (req, res) => {
if (!fs.existsSync(filePath)) {
const fileName = path.basename(filePath);
const titleStem = fileName.replace(AUDIO_EXT_RE, '').replace(/^\d+\s*-\s*/, '').toLowerCase();
const tagDir = albumDirFromTags(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist, ndSong?.album);
const found = _rebasePath(filePath, CONFIG.musicPath)
|| _findFileRecursive(CONFIG.musicPath, fileName)
|| _findFileInDir(path.dirname(filePath), fileName)
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
if (found) filePath = found;
else return res.status(404).json({
|| (tagDir && (_findFileInDir(tagDir, fileName) || _findFileByStemOrRomaji(tagDir, titleStem)))
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem)
|| findExistingTrack(CONFIG.musicPath, ndSong?.albumArtist || ndSong?.artist || '', ndSong?.title || '');
if (!found) return res.status(404).json({
error: `File not found on disk. Navidrome path: "${ndPath}" — music root: "${CONFIG.musicPath}" — searched for: "${fileName}"`,
});
// Derselbe Abgleich wie im Metadaten-Editor, und hier WICHTIGER: der Treffer stammt aus einer
// Aehnlichkeitssuche, und geloescht wird unwiderruflich. Passen die Tags der gefundenen Datei
// nicht zu dem, was Navidrome fuer diesen Song meldet, wird abgebrochen statt geraten.
const delTags = await _readTags(found);
const istTitel = _tagNorm(delTags.title || delTags.TITLE);
const istArtist = _tagNorm(delTags.artist || delTags.ARTIST);
const sollTitel = _tagNorm(ndSong?.title);
const sollArtist = _tagNorm(ndSong?.artist);
const titelPasst = !sollTitel || !istTitel || istTitel === sollTitel;
const artistPasst = !sollArtist || !istArtist ||
istArtist.includes(sollArtist) || sollArtist.includes(istArtist);
if (!titelPasst || !artistPasst) {
return res.status(409).json({
error: `Datei nicht eindeutig zuzuordnen. Navidrome meldet "${ndSong?.artist || '?'} – ` +
`${ndSong?.title || '?'}", die gefundene Datei enthaelt aber "` +
`${delTags.artist || '?'} – ${delTags.title || '?'}" (${found}). ` +
`Es wird nichts geloescht — bitte die Dubletten aufraeumen.`,
});
}
filePath = found;
}
// Safety: file must be inside the music root and must be an audio file
@@ -1610,7 +1799,7 @@ app.post('/api/delete-song', async (req, res) => {
const s2 = Math.random().toString(36).slice(2, 10);
const t2 = crypto.createHash('md5').update(pass + s2).digest('hex');
await fetch(
`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=novasonic&f=json`,
`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=NovaSonic-Backend&f=json`,
{ signal: AbortSignal.timeout(5000) }
);
} catch {}
@@ -1618,6 +1807,78 @@ app.post('/api/delete-song', async (req, res) => {
res.json({ ok: true });
});
// ─── DUBLETTE AUFLOESEN ──────────────────────────────────────────────────────
// Nach einem abgelehnten Verschieben ("am Ziel liegt schon eine Datei") liegt derselbe Song
// zweimal auf der Platte. Dieser Endpunkt loescht die ueberzaehlige Kopie — aber nur, wenn er
// selbst nachgeprueft hat, dass es wirklich eine Dublette ist. Er nimmt Pfade entgegen, deshalb
// prueft er strenger als das Loeschen per songId:
// 1. beide Pfade liegen im Musikordner und sind Audiodateien
// 2. beide existieren
// 3. gleiche Laenge (± 2 s) UND gleicher Titel-Tag — sonst sind es zwei verschiedene Aufnahmen
// Erst dann faellt die zu loeschende Datei.
app.post('/api/resolve-duplicate', async (req, res) => {
const { ndUrl, user, pass, behalten, ueberzaehlig } = req.body;
if (!ndUrl || !user || !pass || !behalten || !ueberzaehlig)
return res.status(400).json({ error: 'ndUrl, user, pass, behalten, ueberzaehlig required' });
if (behalten === ueberzaehlig)
return res.status(400).json({ error: 'Beide Pfade sind identisch' });
const realRoot = path.resolve(CONFIG.musicPath);
const rootSlash = realRoot.endsWith(path.sep) ? realRoot : realRoot + path.sep;
const pruefe = (pfad, name) => {
const abs = path.resolve(pfad);
if (!abs.startsWith(rootSlash)) return `${name}: Pfad liegt ausserhalb des Musikordners`;
if (!AUDIO_EXT_RE.test(path.extname(abs))) return `${name}: keine Audiodatei`;
if (!fs.existsSync(abs)) return `${name}: existiert nicht mehr`;
return null;
};
const fehler = pruefe(behalten, 'behalten') || pruefe(ueberzaehlig, 'ueberzaehlig');
if (fehler) return res.status(400).json({ error: fehler });
const [a, b] = await Promise.all([
_dateiSteckbrief(path.resolve(behalten)), _dateiSteckbrief(path.resolve(ueberzaehlig)),
]);
const laengeGleich = a.dauer != null && b.dauer != null && Math.abs(a.dauer - b.dauer) <= 2;
const titelGleich = _tagNorm(a.titel) === _tagNorm(b.titel);
if (!laengeGleich || !titelGleich) {
return res.status(409).json({
error: `Das sind keine zwei Kopien desselben Songs — "${a.titel || '?'}" (${a.dauer ?? '?'} s) ` +
`gegen "${b.titel || '?'}" (${b.dauer ?? '?'} s). Es wird nichts geloescht.`,
});
}
const ziel = path.resolve(ueberzaehlig);
try { fs.unlinkSync(ziel); }
catch (e) { return res.status(500).json({ error: `Loeschen fehlgeschlagen: ${e.message}` }); }
// Leere Album- und Kuenstlerordner aufraeumen — gleiche Tiefenbegrenzung wie beim Song-Loeschen.
const albumDir = path.dirname(ziel);
const artistDir = path.dirname(albumDir);
if (albumDir !== realRoot && albumDir.startsWith(rootSlash)) {
try {
if (fs.readdirSync(albumDir).filter(f => !f.startsWith('.')).length === 0) {
fs.rmdirSync(albumDir);
if (artistDir !== realRoot && artistDir.startsWith(rootSlash)) {
try {
if (fs.readdirSync(artistDir).filter(f => !f.startsWith('.')).length === 0) fs.rmdirSync(artistDir);
} catch {}
}
}
} catch {}
}
try {
const s2 = Math.random().toString(36).slice(2, 10);
const t2 = crypto.createHash('md5').update(pass + s2).digest('hex');
await fetch(
`${ndUrl}/rest/startScan?u=${encodeURIComponent(user)}&t=${t2}&s=${s2}&v=1.16.1&c=NovaSonic-Backend&f=json`,
{ signal: AbortSignal.timeout(5000) }
);
} catch {}
res.json({ ok: true, geloescht: ziel, verbleibt: path.resolve(behalten) });
});
// ─── ABS PROXY ───────────────────────────────────────────────────────────────
// Forwards API calls to Audiobookshelf to avoid CORS restrictions in the browser.
// The browser sends X-ABS-URL and X-ABS-Token headers; audio streaming uses
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env node
// Regressionsprobe fuer den Download-Filter.
//
// Warum es das gibt: jede Verschaerfung am Filter, die einen Fehlgriff verhindert, kann eine ganze
// KLASSE richtiger Treffer abweisen — und der naechste Bericht kommt dann aus der anderen Richtung.
// Damit der Umbau nicht im Kreis laeuft, wird vor jeder Aenderung gegen bekannte Faelle geprueft.
//
// Was es tut: holt fuer jeden Fall EINMAL die echten Suchtreffer (yt-dlp, mit Metadaten, damit auch
// das `artist`-Feld der Art Tracks vorliegt) und spielt dann die Kaskadenstufen lokal durch —
// in der ALTEN und der NEUEN Reihenfolge. Gemeldet wird, welcher Treffer jeweils gewinnt.
//
// WICHTIG: beide Quellen werden abgefragt, YT MUSIC ZUERST. Die erste Fassung dieser Probe sah nur
// die gewoehnliche YouTube-Suche und meldete deshalb fuer "Get Lucky" einen Grammy-Mitschnitt als
// Treffer — in Wahrheit liefert YT Music dort die offizielle Tonspur an erster Stelle, und genau
// die laedt die Kaskade. Eine Probe, die eine Stufe auslaesst, erfindet Fehler.
//
// docker run --rm --network host --entrypoint node <backend-image> /app/scripts/filter-probe.mjs
//
// Die Filterbedingungen sind hier nachgebaut, nicht importiert: geprueft werden soll, was yt-dlp
// mit --match-filter tut, und das ist ein Ausdruck ueber dieselben Felder.
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { buildTitleKeywords, buildVariantFilter } = require('../lib/ytdlp.js');
const JP_RE = /[ぁ-んァ-ン一-龯]/;
// ── Faelle ────────────────────────────────────────────────────────────────────
// Jeder Fall nennt, was herauskommen MUSS (erwartetFragment) — ein Stueck des Kanalnamens oder
// Titels des richtigen Uploads. Faelle mit `darfNicht` nennen zusaetzlich einen bekannten Fehlgriff.
const FAELLE = [
{
name: 'TPR — Dearly Beloved (Deezer kennt TPR nicht, Dauer gehoert zu anderer Fassung)',
artist: 'TPR', titel: 'Dearly Beloved', dauer: 230,
erwartetFragment: 'TPR', darfNicht: 'LSD',
},
{
name: 'BTS — Pied Piper (Label-Kanal BANGTANTV, Kuenstler nur im artist-Feld)',
artist: 'BTS', titel: 'Pied Piper', dauer: 246,
erwartetFragment: 'Pied Piper',
},
{
name: 'Yoshihisa Hirano — 銀髪の少年 (japanischer Titel, Soundtrack-Album)',
artist: 'Yoshihisa Hirano', titel: '銀髪の少年', dauer: 129,
// Der richtige Upload heisst romanisiert "Ginpatsu no Shonen" — ohne das lange o.
// Genau dafuer ist das tolerante Umschrift-Muster da.
romaji: 'Ginpatsu No Shounen', erwartetFragment: 'Hirano',
},
{
name: 'Rising Insane — DEATHRACE (normaler westlicher Fall)',
artist: 'Rising Insane', titel: 'DEATHRACE', dauer: 216,
erwartetFragment: 'Rising Insane',
},
{
name: 'Daft Punk — Get Lucky (Klassiker, viele Coverversionen)',
artist: 'Daft Punk', titel: 'Get Lucky', dauer: 369,
erwartetFragment: 'Daft Punk',
},
];
// ── Suchtreffer holen ─────────────────────────────────────────────────────────
const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
/** YT-Music-Suche: erst die URLs der Songs-Leiste, dann deren Metadaten. */
async function sucheYtm(query, n) {
const url = `https://music.youtube.com/search?q=${encodeURIComponent(query)}&params=${YTM_SONGS_PARAM}`;
const urls = await new Promise(resolve => {
const p = spawn('yt-dlp', [
url, '--flat-playlist', '--playlist-items', `1-${n}`, '--print', 'webpage_url',
'--no-warnings', '--extractor-args', 'youtube:player_client=web_music,default',
]);
let aus = '';
p.stdout.on('data', d => aus += d.toString());
p.on('error', () => resolve([]));
p.on('close', () => resolve(aus.split('\n').map(z => z.trim()).filter(z => z.includes('watch?v='))));
});
if (!urls.length) return [];
return metadaten(urls);
}
function suche(query, n) {
return metadaten([`ytsearch${n}:${query}`]);
}
/** Metadaten zu einer Liste von URLs (oder einer Suchangabe) holen. */
function metadaten(ziele) {
return new Promise(resolve => {
const felder = '%(duration)s%(channel)s%(artist)s%(title)s';
const p = spawn('yt-dlp', [
...ziele, '--simulate', '--no-warnings', '--ignore-errors',
'--print', felder,
]);
let aus = '';
p.stdout.on('data', d => aus += d.toString());
p.on('error', () => resolve([]));
p.on('close', () => resolve(
aus.split('\n').filter(Boolean).map(z => {
const [dauer, kanal, kuenstler, titel] = z.split('');
return {
dauer: Number(dauer) || 0,
kanal: kanal === 'NA' ? '' : kanal,
kuenstler: kuenstler === 'NA' ? '' : kuenstler,
titel: titel || '',
};
})
));
});
}
// ── Filterbedingungen, wie mf() sie baut ──────────────────────────────────────
function pruefer(fall) {
const kws = buildTitleKeywords(fall.titel, fall.romaji || '');
const variant = buildVariantFilter(fall.titel);
const ersterK = fall.artist.split(/[\s,/]+/)[0];
const rand = Math.max(10, Math.round(fall.dauer * 0.10));
const fenster = { min: Math.max(30, fall.dauer - rand), max: fall.dauer + rand };
const titelPasst = k =>
kws.every(muster => new RegExp(muster, 'i').test(k.titel));
// buildVariantFilter liefert einen yt-dlp-Ausdruck: mehrere "title !~= (?i)MUSTER", mit & verknuepft.
// Hier werden die Muster herausgeloest und als "darf auf KEINES passen" geprueft.
const verboten = [...(variant || '').matchAll(/title !~= \(\?i\)([^&]+)/g)]
.map(m => m[1].trim()).filter(Boolean);
const variantePasst = k => !verboten.some(m => new RegExp(m, 'i').test(k.titel));
const kuenstlerPasst = k => {
if (ersterK.length < 3 && !JP_RE.test(ersterK)) return true; // kein Merkmal -> keine Huerde
const re = new RegExp(ersterK.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
return re.test(k.kanal) || re.test(k.kuenstler);
};
const imFenster = k => k.dauer >= fenster.min && k.dauer <= fenster.max;
const unterKappe = k => k.dauer > 0 && k.dauer < fall.dauer * 2;
return {
fenster,
// streng: Dauer + Titel + Variante + Kuenstler
streng: k => imFenster(k) && titelPasst(k) && variantePasst(k) && kuenstlerPasst(k),
// Dauer behalten, Kuenstler fallen lassen
ohneKuenstler: k => imFenster(k) && titelPasst(k) && variantePasst(k),
// Kuenstler behalten, Dauer lockern (2x-Kappe bleibt)
ohneDauer: k => unterKappe(k) && titelPasst(k) && variantePasst(k) && kuenstlerPasst(k),
};
}
const ersterTreffer = (kandidaten, pruefung) => kandidaten.find(pruefung) || null;
function kaskade(ytm, yt, p, reihenfolge) {
const stufen = reihenfolge === 'neu'
? [['1/2 streng', p.streng], ['3/4 ohne Dauer, mit Künstler', p.ohneDauer], ['5/6 mit Dauer, ohne Künstler', p.ohneKuenstler]]
: [['1/2 streng', p.streng], ['3/4 mit Dauer, ohne Künstler', p.ohneKuenstler], ['5/6 ohne Dauer, mit Künstler', p.ohneDauer]];
for (const [stufe, pruefung] of stufen) {
// Jede Stufe fragt erst YT Music, dann die gewoehnliche YouTube-Suche — so laeuft die Kaskade.
const t = ersterTreffer(ytm, pruefung) || ersterTreffer(yt, pruefung);
if (t) return { stufe: `${stufe}${ytm.includes(t) ? ' · YTM' : ' · YT'}`, treffer: t };
}
return { stufe: 'kein Treffer', treffer: null };
}
const zeig = t => t ? `${String(t.dauer).padStart(4)}s | ${t.kanal || '?'} | ${t.titel}` : '— nichts —';
(async () => {
let abweichungen = 0, fehler = 0;
for (const fall of FAELLE) {
const query = `${fall.artist} ${fall.titel}`;
process.stdout.write(`\n▶ ${fall.name}\n Suche: "${query}" `);
const ytm = await sucheYtm(query, 10);
const yt = await suche(query, 10);
console.log(`(YTM ${ytm.length}, YouTube ${yt.length})`);
if (!ytm.length && !yt.length) { console.log(' ⚠️ keine Suchtreffer — übersprungen'); continue; }
const p = pruefer(fall);
console.log(` Dauerfenster ${p.fenster.min}–${p.fenster.max}s`);
const alt = kaskade(ytm, yt, p, 'alt');
const neu = kaskade(ytm, yt, p, 'neu');
console.log(` ALT [${alt.stufe}] ${zeig(alt.treffer)}`);
console.log(` NEU [${neu.stufe}] ${zeig(neu.treffer)}`);
const passt = t => t && (t.kanal + ' ' + t.titel).toLowerCase().includes(fall.erwartetFragment.toLowerCase());
const schlecht = t => fall.darfNicht && t && (t.kanal + ' ' + t.titel).toLowerCase().includes(fall.darfNicht.toLowerCase());
if (!neu.treffer && alt.treffer) { console.log(' ❌ REGRESSION: neu findet nichts, alt schon'); fehler++; }
else if (schlecht(neu.treffer)) { console.log(' ❌ neu trifft weiterhin den bekannten Fehlgriff'); fehler++; }
else if (!passt(neu.treffer)) { console.log(` ⚠️ neu trifft nicht "${fall.erwartetFragment}"`); fehler++; }
else { console.log(' ✅ neu in Ordnung'); }
if (zeig(alt.treffer) !== zeig(neu.treffer)) abweichungen++;
}
console.log(`\n${FAELLE.length} Fälle, ${abweichungen} Unterschied(e) zwischen alt und neu, ${fehler} Problem(e)`);
process.exit(fehler ? 1 : 0);
})();
+117
View File
@@ -0,0 +1,117 @@
const { test, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const https = require('https');
const { EventEmitter } = require('events');
const { kommendeKonzerte, _cache } = require('../lib/konzerte');
// ─── Ticketmaster-Antwort einschieben ────────────────────────────────────────
// Kein Schluessel, kein Netz: https.get wird ersetzt und liefert eine feste Antwort. Geprueft
// wird damit genau das, was uns gehoert — Ortszeit, Gegenprobe des Namens, Fenster, Sortierung.
let _antworten = {};
let _aufrufe = [];
const echtesGet = https.get;
function stub() {
https.get = (url, _opts, cb) => {
_aufrufe.push(url);
const name = decodeURIComponent((url.match(/keyword=([^&]*)/) || [])[1] || '');
const res = new EventEmitter();
res.statusCode = 200;
process.nextTick(() => {
res.emit('data', JSON.stringify(_antworten[name] ?? { _embedded: { events: [] } }));
res.emit('end');
});
cb(res);
return new EventEmitter(); // das Request-Objekt, auf dem .on('error') gesetzt wird
};
}
stub();
process.on('exit', () => { https.get = echtesGet; });
const inTagen = n => {
const d = new Date(Date.now() + n * 864e5);
return d.toISOString().slice(0, 10);
};
beforeEach(() => { _cache.clear(); _aufrufe = []; _antworten = {}; });
const ereignis = (name, tage, extra = {}) => ({
id: 'e' + tage,
name,
dates: { start: { localDate: inTagen(tage), localTime: '20:00:00', dateTime: `${inTagen(tage)}T18:00:00Z` }, ...extra.dates },
_embedded: { venues: [{ name: 'Halle', city: { name: 'Köln' }, country: { name: 'Germany' } }] },
url: 'https://tm.example/' + tage,
...extra,
});
test('Ortszeit schlaegt UTC — 20:00 bleibt 20:00', async () => {
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
assert.equal(konzerte.length, 1);
assert.ok(konzerte[0].datum.endsWith('T20:00:00'), `unerwartet: ${konzerte[0].datum}`);
});
test('Gegenprobe: fremde Veranstaltung mit demselben Stichwort faellt raus', async () => {
_antworten['MORE'] = { _embedded: { events: [
ereignis('MORE Tour 2026', 10),
ereignis('Sing Something MORE Festival', 20), // enthaelt das Wort → bleibt
ereignis('Ganz was anderes', 30), // enthaelt es nicht → faellt raus
] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'MORE' }], 'K', 365);
assert.deepEqual(konzerte.map(k => k.titel), ['MORE Tour 2026', 'Sing Something MORE Festival']);
});
test('Name im Attraktionsnamen zaehlt auch', async () => {
_antworten['K/DA'] = { _embedded: { events: [
{ ...ereignis('League Music Night', 15), _embedded: {
venues: [{ name: 'Arena', city: { name: 'Berlin' }, country: { name: 'Germany' } }],
attractions: [{ name: 'K/DA' }],
} },
] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'K/DA' }], 'K', 365);
assert.equal(konzerte.length, 1, 'ueber die Attraktion erkannt');
});
test('Termine hinter dem Fenster fallen weg', async () => {
_antworten['Coldplay'] = { _embedded: { events: [ereignis('Coldplay Live', 20), ereignis('Coldplay Later', 200)] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'Coldplay' }], 'K', 30);
assert.equal(konzerte.length, 1);
assert.equal(konzerte[0].titel, 'Coldplay Live');
});
test('Sortierung laeuft NACH VORN — naechstes Konzert zuerst', async () => {
_antworten['A'] = { _embedded: { events: [ereignis('A spaet', 90)] } };
_antworten['B'] = { _embedded: { events: [ereignis('B frueh', 5)] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'A' }, { id: '2', name: 'B' }], 'K', 365);
assert.deepEqual(konzerte.map(k => k.titel), ['B frueh', 'A spaet']);
});
test('Kuenstler ohne Termine werden gemeldet, nicht verschwiegen', async () => {
_antworten['Leer'] = { _embedded: { events: [] } };
const { konzerte, ohneTermine } = await kommendeKonzerte([{ id: '1', name: 'Leer' }], 'K', 365);
assert.equal(konzerte.length, 0);
assert.deepEqual(ohneTermine, ['Leer']);
});
test('Zweiter Aufruf kommt aus dem Cache — kein weiterer Abruf', async () => {
_antworten['Rammstein'] = { _embedded: { events: [ereignis('Rammstein Live', 30)] } };
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
const nachErstem = _aufrufe.length;
await kommendeKonzerte([{ id: '1', name: 'Rammstein' }], 'K', 365);
assert.equal(_aufrufe.length, nachErstem, 'zweiter Aufruf darf nicht erneut abrufen');
});
test('Abgesagt wird durchgereicht statt stillschweigend gezeigt', async () => {
_antworten['X'] = { _embedded: { events: [
{ ...ereignis('X Tour', 40), dates: { start: { localDate: inTagen(40), localTime: '20:00:00' }, status: { code: 'cancelled' } } },
] } };
const { konzerte } = await kommendeKonzerte([{ id: '1', name: 'X' }], 'K', 365);
assert.equal(konzerte[0].abgesagt, true);
});
test('Ein Kuenstlername ohne Buchstaben loest keinen Abruf aus', async () => {
const { konzerte } = await kommendeKonzerte([{ id: '1', name: '???' }], 'K', 365);
assert.equal(konzerte.length, 0);
assert.equal(_aufrufe.length, 0);
});
+172
View File
@@ -22,6 +22,15 @@ before(() => {
'01-01 - Kingdom Hearts 2 - Dearly Beloved - Main Theme.mp3');
// Normal "Artist - Title" file
touch('Some Artist', 'Therapy - Single', '01 - Some Artist - Therapy.mp3');
// Akzent-Schreibweise: liegt MIT Akzent auf der Platte
touch('Cécile Corbel', 'SongBook Vol. 2', '03 - Cécile Corbel - Arrietty\u2019s Song.mp3');
// Soundtrack: liegt unter dem ALBUM-Kuenstler, nicht unter dem Titel-Kuenstler
touch('Arcane', 'Arcane League of Legends (Soundtrack)', '01 - Bea Miller - Playground.mp3');
// Zwei verschiedene Kuenstler mit demselben Songtitel — duerfen sich NIE gegenseitig treffen
touch('Cro', 'Raop', '03 - Cro - Easy.mp3');
touch('Kraftklub', 'Mit K', '05 - Kraftklub - Easy.mp3');
// Aehnlicher, aber anderer Kuenstlername
touch('Cecil Taylor', 'Unit Structures', '01 - Cecil Taylor - Steps.mp3');
});
after(() => { try { fs.rmSync(ROOT, { recursive: true, force: true }); } catch {} });
@@ -48,3 +57,166 @@ test('remix stays distinct from the base track (no regression from the version-m
assert.equal(findExistingTrack(ROOT, 'Some Artist', 'Therapy (Remix)'), null);
assert.ok(findExistingTrack(ROOT, 'Some Artist', 'Therapy')); // base still found
});
// ── Akzente (Cécile Corbel) ────────────────────────────────────────────────────
test('findet den Song auch ohne Akzent in der Anfrage', () => {
assert.ok(findExistingTrack(ROOT, 'Cecile Corbel', "Arrietty's Song"),
'ASCII-Schreibweise muss die Datei mit Akzent finden');
});
test('findet ihn ebenso mit Akzent', () => {
assert.ok(findExistingTrack(ROOT, 'Cécile Corbel', "Arrietty's Song"));
});
test('kein Fehlalarm: aehnlicher Name ist nicht dieselbe Person', () => {
// "Cecil Taylor" darf nicht auf "Cécile Corbel" passen — und umgekehrt.
assert.equal(findExistingTrack(ROOT, 'Cécile Corbel', 'Steps'), null);
assert.equal(findExistingTrack(ROOT, 'Cecil Taylor', "Arrietty's Song"), null);
});
test('kein Fehlalarm: Akzent-Normalisierung kollabiert keine echten Titel', () => {
assert.equal(findExistingTrack(ROOT, 'Cecile Corbel', 'Ein anderer Song'), null);
});
// ── Gleicher Titel, verschiedene Kuenstler ─────────────────────────────────────
test('kein Fehlalarm: gleicher Titel bei zwei Kuenstlern bleibt getrennt', () => {
const cro = findExistingTrack(ROOT, 'Cro', 'Easy');
const kk = findExistingTrack(ROOT, 'Kraftklub', 'Easy');
assert.ok(cro && cro.includes('Cro'), 'Cro-Version muss im Cro-Ordner gefunden werden');
assert.ok(kk && kk.includes('Kraftklub'), 'Kraftklub-Version im Kraftklub-Ordner');
assert.notEqual(cro, kk, 'die beiden duerfen nicht dieselbe Datei sein');
// Ein dritter Kuenstler mit demselben Titel findet nichts.
assert.equal(findExistingTrack(ROOT, 'Irgendwer', 'Easy'), null);
});
// ── Soundtrack: Album-Kuenstler vs. Titel-Kuenstler ────────────────────────────
test('Soundtrack unter dem Album-Kuenstler wird ueber diesen gefunden', () => {
assert.ok(findExistingTrack(ROOT, 'Arcane', 'Playground'));
});
test('unter dem Titel-Kuenstler allein wird er NICHT gefunden (Grund fuer die Zweitpruefung)', () => {
// Genau diese Luecke erzeugte die Dublette: geprueft wurde nur "Bea Miller".
assert.equal(findExistingTrack(ROOT, 'Bea Miller', 'Playground'), null);
});
test('die Zweitpruefung schliesst die Luecke, ohne Fremdes zu treffen', () => {
const pruefe = (albumArtist, trackArtist, titel) =>
findExistingTrack(ROOT, albumArtist, titel)
|| (trackArtist !== albumArtist ? findExistingTrack(ROOT, trackArtist, titel) : null);
// Soundtrack-Fall: wird jetzt erkannt.
assert.ok(pruefe('Bea Miller', 'Bea Miller', 'Playground') === null,
'ohne Album-Kuenstler weiterhin nichts — richtig, die Datei liegt unter Arcane');
assert.ok(pruefe('Arcane', 'Bea Miller', 'Playground'), 'mit Album-Kuenstler gefunden');
// Fremder Titel im selben Ordner darf trotz zweier Durchlaeufe nicht anschlagen.
assert.equal(pruefe('Arcane', 'Bea Miller', 'Irgendein anderer Titel'), null);
// Remix bleibt auch ueber zwei Durchlaeufe hinweg eigenstaendig.
assert.equal(pruefe('Arcane', 'Bea Miller', 'Playground (Remix)'), null);
});
// ─── Dateisuche: Titelstamm und Schraegstrich im Namen ───────────────────────
// Regression zu "K/DA – VILLAIN": Navidromes Tag-Pfad wurde zu "K/DA/ALL OUT/…", auf der Platte
// steht "K_DA/ALL OUT/…". Der Pfad fand nichts, und die Aehnlichkeitssuche lieferte
// "Bad Omens - Like A Villain", weil deren Stamm auf "villain" ENDET.
const { findFileByStem, albumDirFromTags } = require('../lib/metadata');
let SLASH_ROOT;
before(() => {
SLASH_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-slash-'));
const put = (...segs) => {
const file = segs.pop();
const dir = path.join(SLASH_ROOT, ...segs);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, file), '');
};
put('K_DA', 'ALL OUT', '03 - K_DA - VILLAIN.mp3');
put('K_DA', 'POP_STARS', '01 - K_DA - POP_STARS.mp3');
put('Bad Omens', 'THE DEATH OF PEACE OF MIND', '06 - Bad Omens - Like A Villain.mp3');
});
after(() => { try { fs.rmSync(SLASH_ROOT, { recursive: true, force: true }); } catch {} });
test('Titelstamm trifft nicht auf blosse Endsilbe', () => {
// "Like A Villain" darf NICHT als "VILLAIN" durchgehen
const t = findFileByStem(path.join(SLASH_ROOT, 'Bad Omens'), 'villain');
assert.equal(t, null);
});
test('Titelstamm trifft "Kuenstler - Titel" weiterhin', () => {
const t = findFileByStem(path.join(SLASH_ROOT, 'Bad Omens'), 'like a villain');
assert.ok(t && t.endsWith('06 - Bad Omens - Like A Villain.mp3'));
});
test('Ganze Bibliothek: VILLAIN findet K_DA, nicht Bad Omens', () => {
const t = findFileByStem(SLASH_ROOT, 'villain');
assert.ok(t && t.includes(path.join('K_DA', 'ALL OUT')), `unerwartet: ${t}`);
});
test('Schraegstrich im Kuenstlernamen fuehrt zum richtigen Ordner', () => {
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'ALL OUT'), path.join(SLASH_ROOT, 'K_DA', 'ALL OUT'));
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'POP/STARS'), path.join(SLASH_ROOT, 'K_DA', 'POP_STARS'));
});
test('Unbekanntes Album faellt auf den Kuenstlerordner zurueck', () => {
assert.equal(albumDirFromTags(SLASH_ROOT, 'K/DA', 'Gibt Es Nicht'), path.join(SLASH_ROOT, 'K_DA'));
});
test('Mehrere Kuenstler: Ordner haengt am ersten', () => {
assert.equal(
albumDirFromTags(SLASH_ROOT, 'K/DA, Madison Beer, Kim Petras', 'ALL OUT'),
path.join(SLASH_ROOT, 'K_DA', 'ALL OUT'),
);
});
test('Kein Treffer bleibt null', () => {
assert.equal(albumDirFromTags(SLASH_ROOT, 'Niemand', 'Nichts'), null);
});
// ─── Kuenstler im Dateinamen ────────────────────────────────────────────────
// Frueher stand die vollstaendige Gaesteliste im Namen und wurde bei 100 Zeichen mitten im
// letzten Namen abgeschnitten. Jetzt nur noch der erste Kuenstler.
const { dateinamensKuenstler } = require('../lib/metadata');
test('Nur der erste Kuenstler landet im Dateinamen', () => {
assert.equal(
dateinamensKuenstler('K/DA;Madison Beer;i-dle;Lexie Liu;Jaira Burns;Seraphine;League of Legends'),
'K/DA',
);
assert.equal(dateinamensKuenstler('Dove Cameron, Sofia Carson'), 'Dove Cameron');
assert.equal(dateinamensKuenstler('Calvin Harris & Dua Lipa'), 'Calvin Harris');
});
test('Bandnamen mit & bleiben ganz, wenn sie keine Liste sind', () => {
// "&" trennt nur MIT umgebenden Leerzeichen — "Earth, Wind & Fire" waere ohnehin durch das
// Komma getrennt, aber ein reiner Bandname ohne Liste darf nicht zerlegt werden.
assert.equal(dateinamensKuenstler('AC/DC'), 'AC/DC');
assert.equal(dateinamensKuenstler('Simon&Garfunkel'), 'Simon&Garfunkel');
});
test('Leerer Kuenstler faellt auf den Album-Kuenstler zurueck', () => {
assert.equal(dateinamensKuenstler('', 'K/DA;Madison Beer'), 'K/DA');
assert.equal(dateinamensKuenstler(undefined, undefined), '');
});
test('Der Dateiname bleibt kurz genug fuers Dateisystem', () => {
const lang = 'K/DA;Madison Beer;(G)I-DLE;Lexie Liu;Jaira Burns;Seraphine;League of Legends;Bea Miller;Wolftyla;Duckwrth';
const teil = dateinamensKuenstler(lang);
assert.equal(teil, 'K/DA');
assert.ok(teil.length < 100, 'wird nie von sanitize() abgeschnitten');
});
test('Dublettenpruefung findet auch die alt benannten Dateien', () => {
// Nach der Umstellung liegen beide Namensformen nebeneinander. Beide muessen als "schon da"
// erkannt werden, sonst laedt der Downloader die alten Dateien ein zweites Mal.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-namen-'));
const put = (album, file) => {
const dir = path.join(root, 'K_DA', album);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, file), '');
};
put('MORE', '01 - K_DA,Madison Beer,i-dle,Lexie Liu,Jaira Burns,Seraphine - MORE.mp3'); // alt
put('ALL OUT', '03 - K_DA - VILLAIN.mp3'); // neu
assert.ok(findExistingTrack(root, 'K/DA', 'MORE'), 'alte Namensform nicht erkannt');
assert.ok(findExistingTrack(root, 'K/DA', 'VILLAIN'), 'neue Namensform nicht erkannt');
assert.equal(findExistingTrack(root, 'K/DA', 'POP/STARS'), null);
fs.rmSync(root, { recursive: true, force: true });
});
+167
View File
@@ -0,0 +1,167 @@
const test = require('node:test');
const assert = require('node:assert');
const { buildTitleKeywords } = require('../lib/ytdlp.js');
// The keywords become `title ~= (?i)<kw>` match-filters in yt-dlp, i.e. every keyword must match
// the candidate title case-insensitively. This mirrors that check.
const accepts = (title, candidate) =>
buildTitleKeywords(title).every(kw => new RegExp(kw, 'i').test(candidate));
test('a number in the title is always required, however short', () => {
const kws = buildTitleKeywords('700 Main Street');
assert.ok(kws.some(k => k.includes('700')), `700 fehlt in ${JSON.stringify(kws)}`);
});
test('rejects a same-shaped title with a different number', () => {
// The real regression: "777 Main Street" (Release - Topic) was downloaded for DAT ADAM's
// "700 Main Street", because only the two longest words (Street, Main) were required.
assert.equal(accepts('700 Main Street', '777 Main Street'), false);
assert.equal(accepts('700 Main Street', '700 Main Street'), true);
});
test('accepts the abbreviated spelling uploaders use', () => {
assert.equal(accepts('700 Main Street', '700 Main st (cr-Version)'), true);
assert.equal(accepts('700 Main Street', 'DAT ADAM - 700 Main St [FULL SONG]'), true);
});
test('the abbreviation does not match inside another word', () => {
assert.equal(accepts('700 Main Street', '700 Main Stuff'), false);
});
test('titles without numbers keep the previous two-longest-words behaviour', () => {
assert.deepEqual(buildTitleKeywords('Bohemian Rhapsody'), ['Bohemian', 'Rhapsody']);
assert.equal(accepts('Bohemian Rhapsody', 'Queen - Bohemian Rhapsody (Official Video)'), true);
assert.equal(accepts('Bohemian Rhapsody', 'Queen - Another One Bites The Dust'), false);
});
test('a remaster year is not required — YouTube titles never carry it', () => {
// "… - 2020 Remaster" steht auf der Veröffentlichung, der Upload heißt schlicht
// "Take Me Home, Country Roads". Vor dieser Ausnahme hätte die 2020 ihn abgelehnt.
assert.equal(accepts('Take Me Home, Country Roads - 2020 Remaster',
'John Denver - Take Me Home, Country Roads'), true);
});
test('a year that IS the title stays required', () => {
assert.equal(accepts('1999', 'Prince - 1999 (Official Music Video)'), true);
assert.equal(accepts('1999', 'Prince - 1984 (Official Music Video)'), false);
});
const { buildVariantFilter } = require('../lib/ytdlp.js');
// Der Variantenfilter ist eine yt-dlp-Ausdruckskette "title !~= (?i)\bx\b & …".
// Diese Hilfsfunktion wertet sie so aus, wie yt-dlp es tut.
const variantAccepts = (searchTitle, candidate) => {
const f = buildVariantFilter(searchTitle);
if (!f) return true;
return f.split(' & ').every(part => {
const m = /^title !~= \(\?i\)(.+)$/.exec(part);
return !new RegExp(m[1], 'i').test(candidate);
});
};
test('rejects a live recording — is_live only covers running streams', () => {
// Der echte Fall: auf dem Kuenstlerkanal, Dauer im Fenster, Titel passend.
assert.equal(variantAccepts('UFO', 'Dat Adam - Ufo Video Days 2015 Live'), false);
assert.equal(variantAccepts('UFO', 'DAT ADAM - 700 Main St | ROCK AM RING 2017 [LIVE]'), false);
assert.equal(variantAccepts('UFO', 'UFO'), true);
});
test('a deliberately live title keeps its live results', () => {
assert.equal(variantAccepts('Live Forever', 'Oasis - Live Forever'), true);
assert.equal(variantAccepts('Unplugged in New York (Live)', 'Nirvana - All Apologies (Live)'), true);
});
test('"live" inside another word does not disable the rule', () => {
// "deliver" enthaelt "live" als Teilstring — die Regel muss trotzdem greifen.
assert.equal(variantAccepts('Deliver', 'Artist - Deliver (Live at Wembley)'), false);
assert.equal(variantAccepts('Deliver', 'Artist - Deliver'), true);
});
const { pickYtmVideoUrls } = require('../lib/ytdlp.js');
// Echte Ausgabe der YTM-Suche "DAT ADAM UFO": Songs, ein Album (MPREb_) und der
// Kuenstlerkanal (UC…) auf Platz 9.
const YTM_OUT = [
'https://music.youtube.com/watch?v=ZG4XhPn3Yb4',
'https://music.youtube.com/watch?v=2KVU6aikZF0',
'https://music.youtube.com/browse/MPREb_IhxJPixC1QT',
'https://music.youtube.com/watch?v=phLOTtJaqZk',
'https://music.youtube.com/watch?v=p935nH6aj8Y',
'https://music.youtube.com/watch?v=Pb2YcDBm5fA',
'https://music.youtube.com/browse/MPREb_S7MMKVMduma',
'https://music.youtube.com/watch?v=mNfsX2RbZ2M',
'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA',
'https://music.youtube.com/watch?v=O034JUlf1J4',
].join('\n');
test('a channel hit no longer discards the whole result list', () => {
const { urls, skipped } = pickYtmVideoUrls(YTM_OUT);
assert.equal(urls.length, 7);
assert.equal(skipped, 3); // 2 Alben + 1 Kanal
assert.match(urls[0], /ZG4XhPn3Yb4/); // der offizielle Art Track
assert.ok(!urls.some(u => u.includes('/browse/')));
});
test('nothing but list entries yields no candidates', () => {
const only = 'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA';
assert.deepEqual(pickYtmVideoUrls(only).urls, []);
assert.deepEqual(pickYtmVideoUrls('').urls, []);
});
// ─── Japanische Titel bekommen wieder eine Titelpruefung ────────────────────
// Regression zum 24.9.2026: "銀髪の少年" (HUNTER×HUNTER, Yoshihisa Hirano) lieferte ein anderes
// Stueck desselben Komponisten. Ursache: fuer japanische Titel gab buildTitleKeywords eine LEERE
// Liste zurueck, und die Kaskadenschritte 3/4 lassen die Kuenstlerpruefung fallen — uebrig blieb
// allein das Dauerfenster, in das auf einem Soundtrack-Album fast jeder Titel passt.
test('japanischer Titel liefert jetzt ein Muster statt gar nichts', () => {
const kws = buildTitleKeywords('銀髪の少年');
assert.equal(kws.length, 1);
assert.ok(new RegExp(kws[0], 'i').test('銀髪の少年'), 'Originaltitel muss passen');
assert.ok(!new RegExp(kws[0], 'i').test('闘う者たち'), 'anderes Stueck darf NICHT passen');
});
test('Romaji-Fassung wird als Alternative akzeptiert', () => {
const kws = buildTitleKeywords('銀髪の少年', 'ginpatsu no shounen');
const re = new RegExp(kws[0], 'i');
assert.ok(re.test('銀髪の少年'), 'Original');
assert.ok(re.test('Ginpatsu no Shounen'), 'Romaji mit Leerzeichen');
assert.ok(re.test('Ginpatsu-no-Shounen'), 'Romaji mit Bindestrichen');
assert.ok(re.test('Hunter x Hunter OST - Ginpatsu no Shounen'), 'als Teil eines laengeren Titels');
assert.ok(!re.test('Requiem Aranea'), 'anderes Stueck desselben Albums');
});
test('Romaji, die gar keine sind, werden nicht als Alternative genommen', () => {
// romajiForStorage gibt den Text unveraendert zurueck, wenn der Tokenizer fehlt
const kws = buildTitleKeywords('銀髪の少年', '銀髪の少年');
assert.equal(kws.length, 1);
assert.ok(!kws[0].includes('|'), 'keine doppelte Alternative');
});
test('Sonderzeichen im japanischen Titel bleiben woertlich', () => {
const kws = buildTitleKeywords('君の名は。(Movie ver.)');
const re = new RegExp(kws[0], 'i');
assert.ok(re.test('君の名は。(Movie ver.)'));
assert.ok(!re.test('君の名はX Movie verY'), 'Punkt darf nicht als Platzhalter wirken');
});
test('lateinische Titel bleiben unveraendert', () => {
assert.deepEqual(buildTitleKeywords('Bohemian Rhapsody'), ['Bohemian', 'Rhapsody']);
assert.deepEqual(buildTitleKeywords('Dearly Beloved'), ['Beloved', 'Dearly']);
});
test('Umschrift toleriert die ueblichen Schreibvarianten', () => {
const re = new RegExp(buildTitleKeywords('銀髪の少年', 'Ginpatsu No Shounen')[0], 'i');
assert.ok(re.test('Ginpatsu no Shounen'), 'wie der Tokenizer schreibt');
assert.ok(re.test('Ginpatsu no Shonen'), 'ohne langes o — die haeufigste Variante');
assert.ok(re.test('Ginpatsu-no-Shonen'), 'mit Bindestrichen');
assert.ok(re.test('GINPATSU NO SHŌNEN'), 'mit Makron');
assert.ok(!re.test('Ginpatsu no Otona'), 'anderer Titel faellt weiterhin durch');
});
test('Katakana-Laengungsstrich faellt aus der Umschrift', () => {
// romajiForStorage laesst "ー" stehen ("TeーZe"); in einem lateinischen Titel steht es nie.
const re = new RegExp(buildTitleKeywords('残酷な天使のテーゼ', 'Zankoku Na Tenshi No TeーZe')[0], 'i');
assert.ok(re.test('Zankoku na Tenshi no Teze'), 'ohne Strich geschrieben');
assert.ok(re.test('残酷な天使のテーゼ'), 'Originaltitel weiterhin');
});
+67
View File
@@ -0,0 +1,67 @@
# NovaSonic — DEV/Selbstbauen: baut lokal aus den Dockerfiles statt Images zu ziehen. Die schweren
# Deps (ffmpeg, Deno, yt-dlp, node libs) stecken in den Images, also sind RESTARTS sofort da und ein
# Rebuild nach Code-Änderungen ist schnell (Docker cacht die Dep-Layer — nur der COPY-Layer ändert sich).
#
# git pull
# docker compose -f docker-compose.dev.yml up -d --build # geänderte Images neu bauen + starten
# docker compose -f docker-compose.dev.yml up -d # nur starten, mit den gebauten Images
#
# Keine Registry nötig. Der Normalfall (fertige Images ziehen) ist docker-compose.yml.
services:
# ── Frontend (nginx serving the built PWA) ───────────────────────────────────
novasonic-frontend:
build: .
container_name: novasonic-frontend
restart: unless-stopped
ports:
- "8080:80" # route this port through your reverse proxy
environment:
- NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen
- BACKEND_URL=${BACKEND_URL}
- SCROBBLE_URL=${SCROBBLE_URL}
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
novasonic-backend:
build:
context: ./backend
container_name: novasonic-backend
restart: unless-stopped
volumes:
- ${MUSIC_PATH}:${MUSIC_PATH} # exact path from .env, no remapping
- novasonic-data:/data # writable data dir for overrides, state, etc.
ports:
- "3700:3700"
environment:
- PORT=3700
- MUSIC_PATH=${MUSIC_PATH}
- DATA_PATH=/data
- AUDIO_FMT=${AUDIO_FMT:-mp3}
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
- MAX_PARALLEL=${MAX_PARALLEL:-2}
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
# Android APK download button — backend proxies the fixed Gitea release ("apk-latest").
- GITEA_URL=${GITEA_URL:-}
- GITEA_REPO=${GITEA_REPO:-}
- GITEA_TOKEN=${GITEA_TOKEN:-}
- GITEA_APK_TAG=${GITEA_APK_TAG:-apk-latest}
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ────────
novasonic-scrobble:
build:
context: ./scrobble
container_name: novasonic-scrobble
restart: unless-stopped
volumes:
- novasonic-scrobble-data:/data # own DB volume, isolated from downloads
ports:
- "3710:3710"
environment:
- PORT=3710
- DATA_PATH=/data
- NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification
volumes:
novasonic-data:
novasonic-scrobble-data:
+24 -35
View File
@@ -1,75 +1,64 @@
# NovaSonic — Standard/Produktiv: zieht die fertigen Images aus der Registry, baut nichts.
# Auf dem Server nur .env danebenlegen (siehe .env.example) und:
#
# docker compose pull
# docker compose up -d
#
# Die Images kommen von der Dev-Maschine: `npm run push-images` (baut die drei Dockerfiles und
# pusht nach ${REGISTRY}). Selbst bauen statt ziehen: docker-compose.dev.yml.
services:
# ── Frontend (nginx) ──────────────────────────────────────────────────────────
# ── Frontend (nginx serving the built PWA) ───────────────────────────────────
novasonic-frontend:
build: .
image: ${REGISTRY}/novasonic-frontend:${TAG:-latest}
container_name: novasonic-frontend
restart: unless-stopped
ports:
- "8080:80" # route this port through your reverse proxy
environment:
- NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen
- BACKEND_URL=${BACKEND_URL} # pre-filled on the setup screen
- SCROBBLE_URL=${SCROBBLE_URL} # play-log / stats service (setup screen)
- BACKEND_URL=${BACKEND_URL}
- SCROBBLE_URL=${SCROBBLE_URL}
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
novasonic-backend:
image: node:22-slim
image: ${REGISTRY}/novasonic-backend:${TAG:-latest}
container_name: novasonic-backend
restart: unless-stopped
working_dir: /app
volumes:
- ./backend:/app:ro # mount entire backend dir (server + lib/)
- ${MUSIC_PATH}:${MUSIC_PATH} # exact path from .env, no remapping
- novasonic-data:/data # writable data dir for overrides, state, etc.
ports:
- "3700:3700" # route this port through your reverse proxy
- "3700:3700"
environment:
- PORT=3700
- MUSIC_PATH=${MUSIC_PATH} # same path inside the container
- MUSIC_PATH=${MUSIC_PATH}
- DATA_PATH=/data
- AUDIO_FMT=${AUDIO_FMT:-mp3}
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
- MAX_PARALLEL=${MAX_PARALLEL:-2}
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
# Android APK download button — backend proxies a fixed Gitea release ("apk-latest"), same LAN.
# Optional: leave unset to use the local /data/novasonic.apk fallback instead.
- GITEA_URL=${GITEA_URL:-} # internal Gitea base, e.g. http://gitea:3000
- GITEA_REPO=${GITEA_REPO:-} # owner/repo, e.g. Docker/NovaSonic
- GITEA_TOKEN=${GITEA_TOKEN:-} # read token (only for a private repo)
# Android APK download button — backend proxies the fixed Gitea release ("apk-latest").
- GITEA_URL=${GITEA_URL:-}
- GITEA_REPO=${GITEA_REPO:-}
- GITEA_TOKEN=${GITEA_TOKEN:-}
- GITEA_APK_TAG=${GITEA_APK_TAG:-apk-latest}
command: >
sh -c "
apt-get update && apt-get install -y --no-install-recommends python3 python3-pip ffmpeg curl git unzip && rm -rf /var/lib/apt/lists/* &&
pip install --break-system-packages -U --pre 'yt-dlp[default]' &&
curl -fsSL https://deno.land/install.sh | sh &&
pip install --break-system-packages spotapi 'git+https://github.com/TzurSoffer/spotify-downloader' &&
mkdir -p /deps && cd /deps && npm install express kuromoji &&
cd /app && PATH=/root/.deno/bin:$$PATH NODE_PATH=/deps/node_modules node novasonic-server.js
"
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ────────
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ───────
novasonic-scrobble:
image: node:22-slim
image: ${REGISTRY}/novasonic-scrobble:${TAG:-latest}
container_name: novasonic-scrobble
restart: unless-stopped
working_dir: /app
volumes:
- ./scrobble:/app:ro # server code (read-only)
- novasonic-scrobble-data:/data # own DB volume, isolated from downloads
ports:
- "3710:3710" # route this port through your reverse proxy
- "3710:3710"
environment:
- PORT=3710
- DATA_PATH=/data
- NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification
command: >
sh -c "
mkdir -p /deps && cd /deps && npm install express &&
cd /app && NODE_PATH=/deps/node_modules node --experimental-sqlite scrobble-server.js
"
volumes:
novasonic-data: # persistent storage for romaji overrides and app state
novasonic-scrobble-data: # persistent play-log DB (novasonic-plays.db)
novasonic-data:
novasonic-scrobble-data:
+114
View File
@@ -0,0 +1,114 @@
# Teilen per Link
Ein geteilter Link sieht so aus:
```
https://<deine-web-adresse>/?song=<navidrome-song-id>
https://<deine-web-adresse>/?album=<navidrome-album-id>
```
Er trägt **nur die Id**, keine Zugangsdaten. Der Empfänger sieht den Inhalt mit seinem eigenen
Navidrome-Konto auf derselben Instanz — wer dort keinen Zugang hat, sieht nichts.
## Web
Funktioniert ohne weitere Einrichtung. Der Link wird beim Start ausgelesen (`?song=` / `?album=`),
das Album geöffnet und der Song abgespielt; danach verschwindet der Parameter aus der Adresszeile,
damit ein Neuladen nicht erneut startet. Als installierte PWA landet der Link dank
`launch_handler: navigate-existing` im bereits offenen Fenster statt in einem neuen.
## Android
Zwei Dinge sind nötig, damit ein Link **direkt in der App** landet statt im Browser:
**1. Web-Adresse in der App hinterlegen** — Einstellungen → „NovaSonic Web-Adresse". Ohne sie
erscheint der Teilen-Eintrag im Kontextmenü gar nicht erst, weil der Link sonst ins Leere zeigt.
**2. Den Host beim Bauen mitgeben** und die Signatur hinterlegen:
```bash
./gradlew assembleRelease -PnovasonicHost=music.example.com
```
Dann den SHA-256 des **Release**-Signaturschlüssels ermitteln …
```bash
keytool -list -v -keystore <dein-keystore>.jks -alias <alias> | grep SHA256
```
… und in `public/.well-known/assetlinks.json` anstelle von `PLATZHALTER_RELEASE_SHA256` eintragen.
Die Datei muss unter `https://<deine-web-adresse>/.well-known/assetlinks.json` erreichbar sein —
sie liegt im `public/`-Verzeichnis und wird vom Frontend-Image mit ausgeliefert.
## APK veröffentlichen
`npm run publish-apk` gibt den Host jetzt an Gradle weiter. Dafür in `scripts/.env.release`
ergänzen (die Datei wird nie eingecheckt):
```
NOVASONIC_HOST=music.deine-domain.de
# optional während eines Domainwechsels:
# NOVASONIC_HOST2=alt.deine-domain.de
```
Fehlt der Eintrag, warnt das Skript und baut mit dem Platzhalter weiter — das APK ist dann
installierbar, aber eingehende Links öffnen im Browser statt in der App.
## Domain wechseln
**Ausgehende Links** folgen der Einstellung „NovaSonic Web-Adresse" — Adresse ändern, fertig, kein
neues APK nötig.
**Eingehende Links** stehen fest im Manifest und brauchen ein neues APK. Damit während des Umzugs
beide Adressen funktionieren, lassen sich zwei Hosts angeben:
```bash
./gradlew assembleRelease -PnovasonicHost=neu.example.com -PnovasonicHost2=alt.example.com
```
Beide müssen eine gültige `assetlinks.json` ausliefern. Das ist keine Feinheit: **auf Android 11 und
älter verifiziert das System nur, wenn ALLE angegebenen Hosts eine passende Datei liefern** — ein
toter Host blockiert also auch den funktionierenden. Sobald die alte Domain abgeschaltet wird, das
APK deshalb ohne `novasonicHost2` neu bauen.
### Ablauf eines Umzugs
1. **Neue Domain aufsetzen**, Web-App und `/.well-known/assetlinks.json` dort erreichbar machen.
Die alte Domain bleibt vorerst unverändert online.
2. **APK mit beiden Hosts bauen** (`-PnovasonicHost=neu -PnovasonicHost2=alt`) und installieren.
Ab jetzt öffnen Links beider Adressen in der App.
3. **In der App die „NovaSonic Web-Adresse" auf die neue Domain umstellen.** Neu geteilte Links
zeigen ab sofort dorthin, alte funktionieren weiter.
4. **Warten**, bis auf allen Geräten das neue APK läuft.
5. **Alte Domain abschalten** — und danach das APK ohne `novasonicHost2` neu bauen.
Schritt 5 nicht vergessen: Android verifiziert die Domains bei jeder Installation neu, und auf
Android 11 und älter genügt ein einziger nicht erreichbarer Host, damit die Verifikation komplett
fehlschlägt. Bereits verifizierte Installationen laufen zwar weiter, aber die nächste Neuinstallation
hätte gar keine funktionierenden App Links mehr.
Zwei weitere Fallen:
- **Signaturschlüssel muss derselbe bleiben.** Ein anders signiertes APK lässt sich nur nach
Deinstallation installieren — und die nimmt die Offline-Downloads mit.
- **Alte Links nach der Abschaltung** öffnen nur dann noch etwas, wenn die alte Domain auf die neue
weiterleitet. Dann landen sie allerdings im Browser statt in der App, weil Android eine
Weiterleitung nicht erneut an die App übergibt.
Wildcards (`*.example.com`) sind für App Links nicht dokumentiert und werden hier bewusst nicht
verwendet — die Verifikation läuft pro Host.
Bereits verschickte Links auf die alte Domain öffnen nach deren Abschaltung gar nichts mehr; das
lässt sich nur mit einer Weiterleitung von alt nach neu abfangen.
Prüfen lässt sich das Ergebnis mit:
```bash
adb shell pm get-app-links com.novasonic.app
```
Steht dort `verified`, öffnet Android die Links ohne Nachfrage.
**Ohne diesen Schritt** ist der Link trotzdem nicht verloren: er öffnet im Browser die Web-App.
Wer die App bevorzugt, kann sie in den Systemeinstellungen unter *Standardmäßig öffnen* → *Links*
manuell für die Domain zulassen.
+90 -38
View File
@@ -98,32 +98,33 @@
<div class="mobile-overlay" id="mobile-overlay" onclick="closeSidebar()"></div>
<!-- MAIN -->
<main class="main" id="main-content">
<!-- Mobile header -->
<div id="mobile-header" style="position:sticky;top:0;background:var(--bg);z-index:10;border-bottom:1px solid var(--border)">
<div style="display:flex;align-items:center;gap:8px;padding:10px 12px">
<div class="header-search-wrap">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input id="header-search-input" type="text" placeholder="Suchen…" oninput="onHeaderSearch(this.value)" autocomplete="off">
<button id="header-search-clear" onclick="closeHeaderSearch()" style="display:none;background:none;border:none;color:var(--text3);cursor:pointer;font-size:15px;padding:0;line-height:1;flex-shrink:0">✕</button>
<!-- Kopfzeile liegt AUSSERHALB des Rollbereichs: sonst laeuft der Rollbalken bis ganz
nach oben neben die Suchleiste, obwohl dort nichts rollt. -->
<div class="main-wrap">
<div id="mobile-header" style="position:sticky;top:0;background:var(--bg);z-index:10;border-bottom:1px solid var(--border)">
<div style="display:flex;align-items:center;gap:8px;padding:10px 12px">
<div class="header-search-wrap">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input id="header-search-input" type="text" placeholder="Suchen…" oninput="onHeaderSearch(this.value)" autocomplete="off">
<button id="header-search-clear" onclick="closeHeaderSearch()" style="display:none;background:none;border:none;color:var(--text3);cursor:pointer;font-size:15px;padding:0;line-height:1;flex-shrink:0">✕</button>
</div>
<div style="display:flex;align-items:center;gap:2px;flex-shrink:0">
<button class="hbtn notif-bell-btn" onclick="toggleNotifPanel()" title="Benachrichtigungen" style="padding:8px;color:var(--text2)">
<!-- Honigtopf mit Brief statt Glocke --><svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.35" stroke-linecap="round" stroke-linejoin="round"><g transform="translate(-4.56 -4.54) scale(1.38)"><rect x="3.6" y="4.6" width="16.8" height="3" rx="1.1"/><path d="M5.5 7.6c-1.1 3.9-.7 8.2.7 10.4a2 2 0 0 0 1.7.9h8.2a2 2 0 0 0 1.7-.9c1.4-2.2 1.8-6.5.7-10.4"/><rect x="8.3" y="11.7" width="7.4" height="4.6" rx="0.7" stroke-width="1.25"/><path d="M8.6 12.1 12 14.4l3.4-2.3" stroke-width="1.25"/></g></svg>
<span class="notif-badge" style="display:none">0</span>
</button>
<button class="hbtn" onclick="openSettings()" title="Einstellungen" style="padding:8px;color:var(--text2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
</div>
</div>
<div style="display:flex;align-items:center;gap:2px;flex-shrink:0">
<button class="hbtn notif-bell-btn" onclick="toggleNotifPanel()" title="Benachrichtigungen" style="padding:8px;color:var(--text2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
<span class="notif-badge" style="display:none">0</span>
</button>
<button class="hbtn" onclick="openSettings()" title="Einstellungen" style="padding:8px;color:var(--text2)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<!-- Results panel: spans the full main-content width (anchored to the header, which already
sits right of the sidebar) so it never covers the sidebar and leaves no dead scroll strip -->
<div id="header-search-overlay" style="display:none">
<div id="header-search-results"></div>
</div>
</div>
<!-- Results panel: spans the full main-content width (anchored to the header, which already
sits right of the sidebar) so it never covers the sidebar and leaves no dead scroll strip -->
<div id="header-search-overlay" style="display:none">
<div id="header-search-results"></div>
</div>
</div>
<main class="main" id="main-content">
<!-- HOME -->
<div class="page active" id="page-home">
@@ -144,6 +145,14 @@
<!-- ARTISTS -->
<div class="page" id="page-artists">
<div class="page-header"><div class="page-title">Künstler</div></div>
<div id="artists-filter" class="sort-chips">
<button class="sort-chip active" data-filter="all" onclick="loadArtists('all')">Alle</button>
<button class="sort-chip" data-filter="followed" onclick="loadArtists('followed')">❤ Gefolgt <span id="artists-followed-count"></span></button>
</div>
<div class="list-search-wrap">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="artists-search" class="list-search" type="text" placeholder="Künstler suchen…" oninput="filterArtists(this.value)">
</div>
<div id="artists-grid" class="grid grid-4"></div>
</div>
@@ -228,6 +237,7 @@
<div class="album-actions">
<button class="btn-play-all" id="btn-play-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>Abspielen</button>
<button class="btn-outline" id="btn-dl-album"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Download</button>
<button class="btn-outline" id="btn-star-album" title="Album markieren"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg><span>Merken</span></button>
</div>
</div>
</div>
@@ -253,7 +263,14 @@
<!-- STARRED -->
<div class="page" id="page-starred">
<div class="page-header"><div class="page-title">Favoriten ⭐</div></div>
<div class="page-header">
<div class="page-title">Favoriten ⭐</div>
<!-- Einstieg in die Zeitleiste steht dort, wo auch die gefolgten Kuenstler stehen —
gleiche Stelle wie in Android. Erscheint nur, wenn man ueberhaupt jemandem folgt. -->
<button id="starred-news-btn" class="btn-outline" style="display:none;font-size:12px;padding:8px 14px" onclick="navigate('artist-news')">
✨ Neu
</button>
</div>
<div id="starred-sort-chips" class="sort-chips">
<button class="sort-chip active" data-sort="newest" onclick="loadStarred('newest')">Neueste</button>
<button class="sort-chip" data-sort="oldest" onclick="loadStarred('oldest')">Älteste</button>
@@ -265,6 +282,9 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="starred-search" class="list-search" type="text" placeholder="In Favoriten suchen…" oninput="filterStarred(this.value)">
</div>
<div id="starred-artists-section"></div>
<div id="starred-albums-section"></div>
<div id="starred-songs-title" class="search-section-title" style="display:none;margin:18px 0 8px">Songs</div>
<div id="starred-songs" class="song-list"></div>
</div>
@@ -402,6 +422,26 @@
</div>
<!-- NEW RELEASES -->
<div class="page" id="page-artist-news">
<button class="back-btn" id="artist-news-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Favoriten</button>
<div class="page-header">
<div class="page-title">Neu von <em>deinen Künstlern</em></div>
</div>
<!-- Zwei Reiter statt zweier Knoepfe in den Favoriten: Neuheiten und Konzerte sind
dasselbe Thema. Nicht in EINE Liste gemischt — Veroeffentlichungen liegen hinter,
Konzerte vor dem heutigen Tag, chronologisch gemischt waere das nicht zu lesen. -->
<div id="artist-news-tabs" class="sort-chips" style="margin-bottom:6px">
<button class="sort-chip active" data-tab="releases" onclick="showArtistNewsTab('releases')">Neu erschienen</button>
<button class="sort-chip" data-tab="konzerte" onclick="showArtistNewsTab('konzerte')">Konzerte</button>
</div>
<div id="artist-news-sort" class="sort-chips">
<button class="sort-chip active" data-days="90" onclick="setArtistNewsDays(90)">90 Tage</button>
<button class="sort-chip" data-days="30" onclick="setArtistNewsDays(30)">30 Tage</button>
<button class="sort-chip" data-days="365" onclick="setArtistNewsDays(365)">1 Jahr</button>
</div>
<div id="artist-news-list"></div>
</div>
<div class="page" id="page-new-releases">
<div class="page-header">
<div class="page-title">Neue <em>Releases</em></div>
@@ -446,12 +486,18 @@
</div>
</main>
</main>
</div>
</div>
<!-- QUEUE PANEL -->
<div class="queue-panel" id="queue-panel">
<div class="queue-title">Warteschlange</div>
<div class="queue-head">
<div class="queue-title">Warteschlange</div>
<button class="queue-close" onclick="toggleQueue()" title="Warteschlange schließen" aria-label="Schließen">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<div class="queue-list" id="queue-list"></div>
</div>
@@ -605,18 +651,6 @@
<div id="s-cover-color-bg" class="toggle" onclick="this.classList.toggle('on')"></div>
</div>
</div>
<div class="settings-section" id="s-apk-section" style="display:none">
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">🤖</span>
Android App
</h3>
<div style="font-size:12px;color:var(--text2);margin-bottom:10px;line-height:1.6">Installiere die NovaSonic Android-App direkt aufs Handy. <span id="s-apk-meta" style="color:var(--text3)"></span></div>
<a id="s-apk-btn" href="#" download style="display:inline-flex;align-items:center;gap:8px;padding:10px 16px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff;font-weight:700;font-size:13px;text-decoration:none;box-shadow:0 4px 12px var(--accent-glow)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
APK herunterladen
</a>
<div style="font-size:11px;color:var(--text3);margin-top:8px">Danach im Handy „Installation aus unbekannten Quellen" für den Browser erlauben.</div>
</div>
<div class="settings-section">
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">📊</span>
@@ -709,6 +743,18 @@
<div class="field"><label>Benutzername</label><input id="s-user" type="text" placeholder="admin"></div>
<div class="field"><label>Passwort</label><input id="s-pass" type="password" placeholder="(unverändert lassen)"></div>
</div>
<div class="settings-section" id="s-apk-section" style="display:none">
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">🤖</span>
Android App
</h3>
<div style="font-size:12px;color:var(--text2);margin-bottom:10px;line-height:1.6">Installiere die NovaSonic Android-App direkt aufs Handy. <span id="s-apk-meta" style="color:var(--text3)"></span></div>
<a id="s-apk-btn" href="#" download style="display:inline-flex;align-items:center;gap:8px;padding:10px 16px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff;font-weight:700;font-size:13px;text-decoration:none;box-shadow:0 4px 12px var(--accent-glow)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
APK herunterladen
</a>
<div style="font-size:11px;color:var(--text3);margin-top:8px">Danach im Handy „Installation aus unbekannten Quellen" für den Browser erlauben.</div>
</div>
</div>
<!-- Sticky footer -->
<div style="display:flex;flex-direction:column;gap:8px;padding:12px 24px 16px;border-top:1px solid var(--border);flex-shrink:0;background:var(--surface);border-radius:0 0 18px 18px">
@@ -1132,6 +1178,12 @@
<div id="np-lyrics-view">
<div id="np-lyrics-content"></div>
</div>
<!-- Mitlaufen an/aus. Nur sichtbar, wenn es synchrone Zeilen gibt — bei reinem Text
gibt es nichts mitzuführen. -->
<button class="np-follow" id="np-follow-btn" onclick="toggleLyricsFolgen()" title="Automatisch mitlaufen">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="12 5 12 19"/><polyline points="6 13 12 19 18 13"/></svg>
<span id="np-follow-text">Mitlaufen</span>
</button>
<div id="np-queue-view">
<div id="np-queue-list"></div>
+2 -1
View File
@@ -6,7 +6,8 @@
"build": "vite build",
"preview": "vite preview",
"icons": "node scripts/gen-icons.mjs",
"publish-apk": "node scripts/publish-apk.mjs"
"publish-apk": "node scripts/publish-apk.mjs",
"push-images": "node scripts/build-push-images.mjs"
},
"devDependencies": {
"sharp": "^0.34.5",
+14
View File
@@ -0,0 +1,14 @@
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "com.novasonic.app",
"sha256_cert_fingerprints": [
"10:39:A0:0E:D3:93:D3:93:CB:50:5D:C5:0D:73:97:2B:F7:40:7C:F8:08:CC:DB:EE:67:D0:E3:CD:64:AB:01:CF"
]
}
}
]
+6 -2
View File
@@ -27,5 +27,9 @@
"type": "image/svg+xml",
"purpose": "maskable"
}
]
}
],
"launch_handler": {
"client_mode": "navigate-existing"
},
"handle_links": "preferred"
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
// Build the three NovaSonic images and push them to your registry, so the production host just PULLS
// (docker-compose.yml) instead of building (that's docker-compose.dev.yml). Run from your dev machine:
//
// npm run push-images # builds + pushes REGISTRY/*:TAG (and :latest)
//
// Config (env or a gitignored scripts/.env.release / repo .env):
// REGISTRY e.g. gitea.honeyy.duckdns.org/docker (Gitea uses a LOWERCASE owner)
// TAG image tag (default: latest)
// You must `docker login <registry-host>` once first. Plain-HTTP internal registries also need the
// daemon's insecure-registries list (see .env.example).
import { spawnSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
// Load REGISTRY/TAG from a local env file if present (repo .env, then scripts/.env.release).
for (const f of [join(root, '.env'), join(root, 'scripts', '.env.release')]) {
if (!existsSync(f)) continue;
for (const line of readFileSync(f, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
const REGISTRY = (process.env.REGISTRY || '').replace(/\/$/, '');
const TAG = process.env.TAG || 'latest';
if (!REGISTRY) { console.error('\n✖ REGISTRY muss gesetzt sein (env oder .env). Siehe .env.example.'); process.exit(1); }
// name → build context (relative to repo root). Frontend uses the root Dockerfile.
const images = {
'novasonic-frontend': '.',
'novasonic-backend': 'backend',
'novasonic-scrobble': 'scrobble',
};
function run(cmd, args) {
console.log(`\n$ ${cmd} ${args.join(' ')}`);
const r = spawnSync(cmd, args, { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' });
if (r.status !== 0) { console.error(`\n✖ Fehlgeschlagen: ${cmd} ${args.join(' ')}`); process.exit(1); }
}
for (const [name, ctx] of Object.entries(images)) {
const ref = `${REGISTRY}/${name}:${TAG}`;
const latest = `${REGISTRY}/${name}:latest`;
console.log(`\n=== ${name} → ${ref} ===`);
run('docker', ['build', '-t', ref, '-t', latest, ctx]);
run('docker', ['push', ref]);
if (latest !== ref) run('docker', ['push', latest]);
}
console.log(`\n✔ Alle Images gebaut + gepusht (${REGISTRY}/*:${TAG}). Auf dem Server:`);
console.log(' docker compose pull && docker compose up -d');
+23 -1
View File
@@ -37,6 +37,17 @@ const GITEA_REPO = process.env.GITEA_REPO || '';
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
const TAG = process.env.GITEA_APK_TAG || 'apk-latest';
const TASK = process.env.APK_GRADLE_TASK || 'assembleDebug';
// Host fuer die App-Links geteilter Songs (https://<host>/?song=…). Steht fest im Manifest, muss
// also beim BAUEN mitgegeben werden — ohne ihn traegt das APK den Platzhalter aus build.gradle.kts
// und eingehende Links landen im Browser statt in der App. NOVASONIC_HOST2 optional fuer einen
// Domainwechsel (beide Adressen oeffnen dann in der App), siehe docs/share-links.md.
const HOST = process.env.NOVASONIC_HOST || '';
const HOST2 = process.env.NOVASONIC_HOST2 || '';
// Vorgabe-Adressen, die ins APK gebacken werden, damit auf einem frischen Geraet nur noch Benutzer
// und Passwort einzutippen sind. Zugangsdaten kommen hier bewusst NICHT vor.
const ND_URL = process.env.NOVASONIC_NAVIDROME || '';
const BACKEND_URL = process.env.NOVASONIC_BACKEND || '';
const SCROBBLE_URL= process.env.NOVASONIC_SCROBBLE || '';
function die(msg) { console.error(`\n✖ ${msg}`); process.exit(1); }
if (!GITEA_URL || !GITEA_REPO || !GITEA_TOKEN)
@@ -68,7 +79,18 @@ const relBody = `versionCode=${versionCode}\nversionName=${version}`;
const isWin = process.platform === 'win32';
const gradlew = join(root, 'android', isWin ? 'gradlew.bat' : 'gradlew');
console.log(`▶ Baue APK: ${TASK} …`);
const build = spawnSync(gradlew, [`:app:${TASK}`], {
if (!HOST) {
console.warn('⚠ NOVASONIC_HOST ist nicht gesetzt — das APK bekommt den Platzhalter-Host aus');
console.warn(' build.gradle.kts, geteilte Links oeffnen dann NICHT in der App.');
console.warn(' In scripts/.env.release eintragen: NOVASONIC_HOST=music.deine-domain.de');
}
const gradleArgs = [`:app:${TASK}`];
if (HOST) gradleArgs.push(`-PnovasonicHost=${HOST}`);
if (HOST2) gradleArgs.push(`-PnovasonicHost2=${HOST2}`);
if (ND_URL) gradleArgs.push(`-PnovasonicNavidrome=${ND_URL}`);
if (BACKEND_URL) gradleArgs.push(`-PnovasonicBackend=${BACKEND_URL}`);
if (SCROBBLE_URL) gradleArgs.push(`-PnovasonicScrobble=${SCROBBLE_URL}`);
const build = spawnSync(gradlew, gradleArgs, {
cwd: join(root, 'android'), stdio: 'inherit', shell: isWin,
});
if (build.status !== 0) die('Gradle-Build fehlgeschlagen.');
+15
View File
@@ -0,0 +1,15 @@
# NovaSonic scrobble service — isolated timestamped play-log. Uses Node's built-in SQLite
# (--experimental-sqlite), so the only dependency is express. Build context = ./scrobble.
FROM node:22-slim
WORKDIR /deps
RUN npm install express
ENV NODE_PATH=/deps/node_modules
WORKDIR /app
COPY . /app/
ENV PORT=3710 DATA_PATH=/data
EXPOSE 3710
CMD ["node","--experimental-sqlite","scrobble-server.js"]
+829
View File
@@ -0,0 +1,829 @@
{
"name": "novasonic-scrobble",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "novasonic-scrobble",
"dependencies": {
"express": "^4.22.2"
},
"engines": {
"node": ">=22"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
}
}
}
+4 -2
View File
@@ -2,11 +2,13 @@
"name": "novasonic-scrobble",
"private": true,
"description": "Isolated timestamped play-log service for NovaSonic (stats + Wrapped).",
"engines": { "node": ">=22" },
"engines": {
"node": ">=22"
},
"scripts": {
"start": "node --experimental-sqlite scrobble-server.js"
},
"dependencies": {
"express": "^4.18.0"
"express": "^4.22.2"
}
}
+108 -20
View File
@@ -92,7 +92,7 @@ async function verifyUser(req) {
if (cached && cached.exp > now) return cached.ok ? u : null;
try {
const url = `${NAVIDROME_URL}/rest/ping.view?u=${encodeURIComponent(u)}&t=${encodeURIComponent(t)}&s=${encodeURIComponent(s)}&v=1.16.1&c=NovaSonic&f=json`;
const url = `${NAVIDROME_URL}/rest/ping.view?u=${encodeURIComponent(u)}&t=${encodeURIComponent(t)}&s=${encodeURIComponent(s)}&v=1.16.1&c=NovaSonic-Scrobble&f=json`;
const r = await fetch(url, { signal: AbortSignal.timeout(8000) });
const j = await r.json();
const ok = j?.['subsonic-response']?.status === 'ok';
@@ -172,6 +172,14 @@ app.get('/scrobble/history', async (req, res) => {
// ?tz= seconds east of UTC for local-day bucketing (default 0)
// ?bucket= day | month | year — granularity of the time series (default day)
// ?limit= top-list size (default 20, max 100)
// Gruppierungsschluessel fuer Texte: Klein-/Grossschreibung, Rand-Leerzeichen und mehrfache
// Leerzeichen duerfen keine zwei Eintraege aus einem machen. Genau das passiert sonst, sobald
// jemand Metadaten bearbeitet ("Dat Adam" -> "DAT ADAM" waeren zwei Kuenstler in der Statistik).
// SQLite kennt kein Regex, deshalb wird die Leerzeichen-Faltung zweimal angewandt (deckt bis zu
// vier aufeinanderfolgende Leerzeichen ab — mehr kommt in Tags praktisch nicht vor).
const normKey = (col) =>
`lower(trim(replace(replace(replace(COALESCE(${col},''), char(9), ' '), ' ', ' '), ' ', ' ')))`;
// Buckets are computed on the fly (GROUP BY strftime) — no rollup tables.
const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' };
@@ -202,31 +210,62 @@ app.get('/scrobble/stats', async (req, res) => {
// navigator so you can't step before logging began.
const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null;
// Anzeigename aus dem JUENGSTEN Play, nicht per MAX(): sonst gewinnt lexikografisch die alte
// Schreibweise und die Statistik zeigt nach einer Korrektur weiter den alten Namen.
const topArtists = db.prepare(`
SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
GROUP BY lower(artist) ORDER BY plays DESC, seconds DESC LIMIT :limit
WITH neueste AS (
SELECT ${normKey('artist')} AS k, artist, artist_id, MAX(played_at)
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
), zaehler AS (
SELECT ${normKey('artist')} AS k, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
)
SELECT n.artist AS name, n.artist_id AS artistId, z.plays AS plays, z.seconds AS seconds
FROM neueste n JOIN zaehler z ON z.k = n.k
ORDER BY z.plays DESC, z.seconds DESC LIMIT :limit
`).all(winL);
const topAlbums = db.prepare(`
SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist,
MAX(cover_art) AS coverArt, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> ''
GROUP BY lower(album), lower(COALESCE(artist,'')) ORDER BY plays DESC LIMIT :limit
WITH neueste AS (
SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2,
album, album_id, artist, cover_art, MAX(played_at)
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
), zaehler AS (
SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
)
SELECT n.album AS name, n.album_id AS albumId, n.artist AS artist,
n.cover_art AS coverArt, z.plays AS plays
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
ORDER BY z.plays DESC LIMIT :limit
`).all(winL);
// Nicht nach song_id gruppieren: wird eine Datei beim Metadaten-Bearbeiten umbenannt, vergibt
// Navidrome eine NEUE id — der Titel erschiene dann zweimal mit geteilten Zaehlern. Schluessel ist
// deshalb Titel+Kuenstler; Anzeigefelder (inkl. song_id fuer die Navigation) kommen aus dem
// JUENGSTEN Play, also mit den frisch bearbeiteten Tags. Das `MAX(played_at)` mit ansonsten
// blanken Spalten ist dokumentiertes SQLite-Verhalten: es liefert die Zeile des Maximums.
const topTracks = db.prepare(`
SELECT MAX(title) AS title, MAX(artist) AS artist, MAX(album) AS album,
MAX(album_id) AS albumId, MAX(artist_id) AS artistId, MAX(cover_art) AS coverArt,
song_id AS songId, COUNT(*) AS plays
FROM plays WHERE ${WHERE}
GROUP BY song_id ORDER BY plays DESC LIMIT :limit
WITH neueste AS (
SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2,
title, artist, album, album_id, artist_id, cover_art, song_id, MAX(played_at)
FROM plays WHERE ${WHERE}
GROUP BY k1, k2
), zaehler AS (
SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
FROM plays WHERE ${WHERE}
GROUP BY k1, k2
)
SELECT n.title AS title, n.artist AS artist, n.album AS album, n.album_id AS albumId,
n.artist_id AS artistId, n.cover_art AS coverArt, n.song_id AS songId, z.plays AS plays
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
ORDER BY z.plays DESC LIMIT :limit
`).all(winL);
const topGenres = db.prepare(`
SELECT MAX(genre) AS name, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND genre IS NOT NULL AND genre <> ''
GROUP BY lower(genre) ORDER BY plays DESC LIMIT :limit
GROUP BY ${normKey('genre')} ORDER BY plays DESC LIMIT :limit
`).all(winL);
const series = db.prepare(`
@@ -263,6 +302,9 @@ app.get('/scrobble/users', async (req, res) => {
res.json({ me, users: rows });
});
// Gegenstueck zu normKey() in JS — fuer Abgleiche, die nicht in SQL stattfinden.
const _norm = (v) => (v || '').toLowerCase().replace(/[\t\s]+/g, ' ').trim();
// GET /scrobble/compare?with=NAME&from= — Head-to-Head "Musik-Match" of the authed user (A) vs
// another user (B). Taste similarity = cosine of the two artist-play vectors (robust, 0..100).
// Also returns shared artists/tracks and each side's unique top artists.
@@ -280,14 +322,14 @@ app.get('/scrobble/compare', async (req, res) => {
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
const artistsOf = u => db.prepare(`
SELECT lower(artist) AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
SELECT ${normKey('artist')} AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
GROUP BY lower(artist)
GROUP BY ${normKey('artist')}
`).all({ user: u, from, to });
const tracksOf = u => db.prepare(`
SELECT song_id AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
SELECT MAX(song_id) AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays
FROM plays WHERE ${WHERE} GROUP BY song_id
FROM plays WHERE ${WHERE} GROUP BY ${normKey('title')}, ${normKey('artist')}
`).all({ user: u, from, to });
const totalsOf = u => db.prepare(
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`
@@ -316,11 +358,15 @@ app.get('/scrobble/compare', async (req, res) => {
.sort((x, y) => y.plays - x.plays).slice(0, 8)
.map(r => ({ name: r.name, artistId: r.artistId, plays: r.plays }));
// Gemeinsame Titel ueber Titel+Kuenstler abgleichen, NICHT ueber song_id: haben beide denselben
// Song aus verschiedenen Dateien gehoert — oder wurde eine Datei zwischendurch umbenannt, wobei
// Navidrome eine neue id vergibt —, galt er sonst faelschlich als nicht gemeinsam.
const tKey = (r) => `${_norm(r.title)}\u0000${_norm(r.artist)}`;
const aTracks = tracksOf(userA);
const bTrackMap = new Map(tracksOf(userB).map(r => [r.songId, r]));
const bTrackMap = new Map(tracksOf(userB).map(r => [tKey(r), r]));
const sharedTracks = [];
for (const ta of aTracks) {
const tb = bTrackMap.get(ta.songId);
const tb = bTrackMap.get(tKey(ta));
if (tb) sharedTracks.push({
songId: ta.songId, title: ta.title, artist: ta.artist, artistId: ta.artistId,
albumId: ta.albumId, coverArt: ta.coverArt, playsA: ta.plays, playsB: tb.plays,
@@ -328,9 +374,51 @@ app.get('/scrobble/compare', async (req, res) => {
}
sharedTracks.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
// ── Anzeigetafel: Kategorien, die je einen Sieger haben ──────────────────────
// Bewusst Kennzahlen, die etwas ueber die HOERWEISE sagen statt ueber schiere Menge — sonst
// gewinnt immer, wer laenger dabei ist. Unentschieden ist erlaubt und zaehlt fuer niemanden.
const nachtAnteil = u => {
const r = db.prepare(`
SELECT COUNT(*) AS n,
SUM(CASE WHEN CAST(strftime('%H', played_at + :tz, 'unixepoch') AS INTEGER) < 5
THEN 1 ELSE 0 END) AS nacht
FROM plays WHERE ${WHERE}
`).get({ user: u, from, to, tz: Math.trunc(Number(req.query.tz) || 0) });
return r.n ? (r.nacht || 0) / r.n : 0;
};
const tA = totalsOf(userA), tB = totalsOf(userB);
const exklusivAnteil = (mine, other) =>
mine.length ? mine.filter(r => !other.has(r.k)).length / mine.length : 0;
const tiefe = (t, artists) => (artists ? t.plays / artists : 0);
const kat = (schluessel, label, a, b, format, hoeherGewinnt = true) => {
const gleich = Math.abs(a - b) < 1e-9;
return {
key: schluessel, label,
a, b,
textA: format(a), textB: format(b),
sieger: gleich ? null : ((a > b) === hoeherGewinnt ? 'A' : 'B'),
};
};
const std = v => `${(v / 3600).toFixed(1)} h`;
const proz = v => `${Math.round(v * 100)} %`;
const zahl = v => v.toLocaleString('de-DE');
const komma = v => v.toFixed(1);
const kategorien = [
kat('hoerzeit', 'Hörzeit', tA.seconds, tB.seconds, std),
kat('vielfalt', 'Vielfalt', aArtists.length, bArtists.length, zahl),
kat('tiefe', 'Tiefe', tiefe(tA, aArtists.length), tiefe(tB, bArtists.length), komma),
kat('nachteule', 'Nachteule', nachtAnteil(userA), nachtAnteil(userB), proz),
kat('entdecker', 'Entdeckerdrang', exklusivAnteil(aArtists, bMap), exklusivAnteil(bArtists, aMap), proz),
];
const punkteA = kategorien.filter(k => k.sieger === 'A').length;
const punkteB = kategorien.filter(k => k.sieger === 'B').length;
res.json({
userA, userB, range: { from, to },
matchScore,
battle: { kategorien, punkteA, punkteB },
totalsA: { ...totalsOf(userA), artists: aArtists.length },
totalsB: { ...totalsOf(userB), artists: bArtists.length },
sharedArtists: sharedArtists.slice(0, 30),
+41 -4
View File
@@ -20,7 +20,7 @@ import {
navigate, _navGoBack, _navOnPopState, _navLoad, onHeaderSearch, closeHeaderSearch,
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
searchSimilarArtist, searchAndPreview, loadDeezerAlbum, closeEventsModal,
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
loadAlbums, loadArtists, filterArtists, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
} from './views/views.js';
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
@@ -37,7 +37,7 @@ import {
} from './views/abs.js';
import {
openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum,
openNpMenu, closeNpMenu,
openNpMenu, closeNpMenu, toggleLyricsFolgen,
} from './player/nowplaying.js';
import {
openSettings, closeSettings, saveSettings, confirmDisconnect,
@@ -111,6 +111,12 @@ async function startApp() {
} else {
navigate('home');
}
// Geteilter Link (?song=… / ?album=…): erst NACH der Anmeldung aufloesen — vorher gibt es keine
// Zugangsdaten, mit denen sich der Inhalt laden liesse. Ueberschreibt bewusst die
// wiederhergestellte Ansicht: wer einen Link oeffnet, will dorthin und nicht dahin, wo er zuletzt war.
import('./ui/share.js')
.then(m => m.handleShareParam(navigate, playQueue))
.catch(() => {});
initNotifBell();
initPlayLog(); // flush any queued plays to the scrobble service (offline-safe)
loadArtistOverrides().catch(() => {}); // pinned same-name artist → Deezer mappings (shared)
@@ -120,6 +126,8 @@ async function startApp() {
const sr = localStorage.getItem('ns_repeat');
if(sr === 'all' || sr === 'one') S.repeat = sr;
if(localStorage.getItem('ns_shuffle') === '1') S.shuffle = true;
// Mitlaufen der Lyrics: Vorgabe AUS, nur ein ausdruecklich gesetztes '1' schaltet es ein.
S.lyricsFolgen = localStorage.getItem('ns_lyrics_folgen') === '1';
_applyShuffleRepeat();
}
@@ -210,6 +218,12 @@ window.addEventListener('load', () => {
// backgrounded (that routes through togglePlay → S._userPaused=true), which used to wrongly
// auto-resume on foreground because _wasPlayingBeforeHide was still true from hide-time.
if(!S.audio.paused) return;
// Am ENDE der Warteschlange nicht fortsetzen. Ein beendetes <audio> ist zwar "paused", aber
// play() spult dann auf Anfang zurueck und spielt denselben Titel NOCH EINMAL. Bei
// ausgeschaltetem Repeat steht nach dem letzten Titel nichts mehr an, trotzdem lief er bei
// jedem Zurueckholen der Seite wieder los — genau das sah wie "der letzte Titel wiederholt
// sich mehrfach" aus, besonders bei Shuffle, wo der Letzte ein beliebiger ist.
if(S.audio.ended) return;
if(s && S.audio.src && _wasPlayingBeforeHide && !S._userPaused) S.audio.play().catch(() => { S._trackChanging = false; });
}
@@ -310,6 +324,25 @@ window.addEventListener('load', () => {
});
window.addEventListener('popstate', (e) => _navOnPopState(e.state));
// Im installierten Fenster ist "display": "standalone" — es gibt keine Zurueck-/Vorwaerts-Knoepfe,
// und die Maustasten werden dort nicht auf den Verlauf umgelenkt: Zurueck war im Programmfenster am
// PC also ueberhaupt nicht erreichbar, im Browser-Tab dagegen schon. Deshalb uebernehmen wir X1/X2
// selbst — aber NUR im Standalone-Fenster, im Tab macht das der Browser und wir wuerden doppelt
// navigieren. (Alt+Links/Rechts bleibt dem Browser ueberlassen, das kann er auch dort selbst.)
const _imAppFenster = () => {
try { return window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; }
catch { return false; }
};
window.addEventListener('mouseup', (e) => {
if (!_imAppFenster()) return;
if (e.button === 3) { e.preventDefault(); _navGoBack(); }
else if (e.button === 4) { e.preventDefault(); try { history.forward(); } catch {} }
});
// Ohne das zeigt Chrome im Fenster das Kontextmenue der Seitentaste.
window.addEventListener('auxclick', (e) => {
if (_imAppFenster() && (e.button === 3 || e.button === 4)) e.preventDefault();
});
document.addEventListener('keydown', e => {
if(e.key !== 'Backspace') return;
const t = document.activeElement?.tagName;
@@ -325,13 +358,17 @@ Object.assign(window, {
navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
loadDeezerAlbum, closeEventsModal,
// Lazy: die Zeitleiste zieht nur beim Öffnen ihren Code nach.
loadArtistNews: (tage) => import('./views/artistNews.js').then(m => m.loadArtistNews(tage)),
showArtistNewsTab: (reiter) => import('./views/artistNews.js').then(m => m.showArtistNewsTab(reiter)),
setArtistNewsDays: (tage) => import('./views/artistNews.js').then(m => m.setArtistNewsDays(tage)),
// Player
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
toggleStar, dlSongLocal, toggleRadioMode, stopPreview, npToggleStar,
// Now Playing
openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum,
openNpMenu, closeNpMenu,
openNpMenu, closeNpMenu, toggleLyricsFolgen,
npSeek: () => {},
// UI / Modals
openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend,
@@ -340,7 +377,7 @@ Object.assign(window, {
openCompare, closeCompare,
closeArtistPin, clearArtistPin,
// Views
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
loadAlbums, loadArtists, filterArtists, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
onImporterInput, resolveImporterLink, onGlobalInput, tryImporterResolve,
+32 -3
View File
@@ -5,6 +5,7 @@ import { coverUrl } from '../api.js';
import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar, _syncOverlayState, isPlaying, seekToMs, updatePlayerUI } from './player.js';
import { navigate } from '../views/views.js';
import { openPlModal, showSongInfo } from '../ui/ui.js';
import { macheQueueBearbeitbar, QUEUE_WERKZEUGE } from './queueEdit.js';
// ─── NOW PLAYING OVERLAY ───
export function openNowPlaying() {
@@ -122,9 +123,32 @@ function _renderSyncedLines(content, lines) {
const bot = document.createElement('div');
bot.style.cssText = 'height:40vh;flex-shrink:0';
content.appendChild(bot);
_zeigeFolgenSchalter(true);
updateLyricsHighlight(S.audio.currentTime * 1000);
}
// ─── MITLAUFEN ───
// Der Schalter erscheint nur bei synchronen Zeilen: ohne Zeitstempel gibt es nichts mitzufuehren.
function _zeigeFolgenSchalter(hatSync) {
const btn = document.getElementById('np-follow-btn');
if (!btn) return;
btn.classList.toggle('hat-sync', !!hatSync);
btn.classList.toggle('active', !!S.lyricsFolgen);
const txt = document.getElementById('np-follow-text');
if (txt) txt.textContent = S.lyricsFolgen ? 'Folgt' : 'Mitlaufen';
btn.title = S.lyricsFolgen
? 'Mitlaufen aus — Text von Hand scrollen'
: 'Mitlaufen an — Text springt zur laufenden Zeile';
}
export function toggleLyricsFolgen() {
S.lyricsFolgen = !S.lyricsFolgen;
localStorage.setItem('ns_lyrics_folgen', S.lyricsFolgen ? '1' : '0');
_zeigeFolgenSchalter(_lyricsLines.length > 0);
// Beim Einschalten sofort zur laufenden Zeile — sonst wartet man bis zur naechsten.
if (S.lyricsFolgen) updateLyricsHighlight(S.audio.currentTime * 1000);
}
function _parseLrc(text) {
let offsetMs = 0;
const lines = [];
@@ -142,7 +166,7 @@ async function _loadNpLyrics(songId) {
const content = document.getElementById('np-lyrics-content');
if(!content) return;
if(_lyricsSongId === songId && (_lyricsLines.length || content.querySelector('.lyrics-plain,.lyrics-none'))) {
if(_lyricsLines.length) updateLyricsHighlight(S.audio.currentTime * 1000);
if(_lyricsLines.length) { _zeigeFolgenSchalter(true); updateLyricsHighlight(S.audio.currentTime * 1000); }
return;
}
_lyricsSongId = songId;
@@ -252,6 +276,7 @@ function _showLyricsResult(content, result) {
const parsed = _parseLrc(result.syncedLyrics);
if(parsed.length) { _renderSyncedLines(content, parsed); return; }
}
_zeigeFolgenSchalter(false);
if(result?.plainLyrics) {
const el = document.createElement('div');
el.className = 'lyrics-plain';
@@ -278,7 +303,8 @@ export function updateLyricsHighlight(currentMs) {
line.el.classList.toggle('active', i === activeIdx);
line.el.classList.toggle('past', i < activeIdx);
});
if(activeIdx >= 0) {
// Hervorheben passiert immer, Scrollen nur wenn Mitlaufen eingeschaltet ist.
if(activeIdx >= 0 && S.lyricsFolgen) {
const activeEl = _lyricsLines[activeIdx].el;
const container = document.getElementById('np-lyrics-view');
if(container) {
@@ -293,6 +319,7 @@ export function onNpSongChange(songId) {
_lyricsLines = [];
const content = document.getElementById('np-lyrics-content');
if(content) content.innerHTML = '';
_zeigeFolgenSchalter(false);
// Warm the cache shortly AFTER the track starts — deferred so the lyrics requests
// don't compete with the audio stream and delay playback start.
clearTimeout(_prefetchTimer);
@@ -313,11 +340,13 @@ export function renderNpQueue() {
S.queue.forEach((s, i) => {
const item = document.createElement('div');
item.className = 'np-queue-item' + (i===S.qIdx ? ' nq-active' : '');
item.dataset.qidx = i;
const cv = coverUrl(s.coverArt, 128);
item.innerHTML = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>`;
item.innerHTML = `<div class="nqi-num">${i+1}</div><div class="nqi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="nqi-info"><div class="nqi-title">${esc(s.title)}</div><div class="nqi-artist">${esc(s.artist||'')}</div></div>` + QUEUE_WERKZEUGE;
item.onclick = () => { S.qIdx=i; playCurrent(); renderNpQueue(); };
el.appendChild(item);
});
macheQueueBearbeitbar('np-queue-list');
el.querySelector('.nq-active')?.scrollIntoView({block:'nearest'});
}
+64 -15
View File
@@ -3,8 +3,10 @@ import { esc, formatDur, toast } from '../utils.js';
import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from '../api.js';
// views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from '../views/views.js';
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js';
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying, renderNpQueue } from './nowplaying.js';
import { logPlay } from './scrobbleLog.js';
import { macheQueueBearbeitbar, QUEUE_WERKZEUGE } from './queueEdit.js';
import { queueNachEntfernen, queueNachVerschieben, mischeListe } from './queueMath.js';
// ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
// True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl).
@@ -364,7 +366,7 @@ export function playQueue(songs, startIdx, opts = {}) {
// opts.noShuffle = true skips shuffle (e.g. clicking a specific song on the artist page).
if (S.shuffle && songs.length > 1 && !opts.noShuffle) {
S._origQueue = songs.map(s => ({...s}));
S.queue = [...songs].sort(() => Math.random() - 0.5).map(s => ({...s}));
S.queue = mischeListe(songs).map(s => ({...s}));
S.qIdx = 0;
} else {
S.queue = songs.map(s => ({...s}));
@@ -791,7 +793,7 @@ export function toggleShuffle() {
if (S.shuffle) {
const cur = S.queue[S.qIdx];
S._origQueue = [...S.queue];
const rest = S.queue.filter((_, i) => i !== S.qIdx).sort(() => Math.random() - 0.5);
const rest = mischeListe(S.queue.filter((_, i) => i !== S.qIdx));
S.queue = [cur, ...rest];
S.qIdx = 0;
} else if (S._origQueue) {
@@ -869,8 +871,12 @@ export async function fillRadioQueue() {
if (gen !== _queueGen) return;
const rr = await api('getRandomSongs', '&size=20');
const randomFresh = (rr.randomSongs?.song || []).filter(s => !recentIds.has(s.id));
// If random is also exhausted, allow repeats to keep music playing
newSongs = randomFresh.length > 0 ? randomFresh : newSongs.slice(0, 5);
// Ist auch der Zufall erschoepft, sind Wiederholungen erlaubt, damit Musik laeuft — aber
// NIE der gerade laufende Titel: der landete sonst direkt hinter sich selbst und spielte
// zweimal in Folge.
newSongs = randomFresh.length > 0
? randomFresh
: newSongs.filter(s => s.id !== seed?.id).slice(0, 5);
} else {
newSongs = deduped;
}
@@ -1085,16 +1091,19 @@ export function updateMediaSession(s) {
{ src: _cachedCoverUrl(s.coverArt, 512), sizes:'512x512', type:'image/jpeg' },
{ src: _cachedCoverUrl(s.coverArt, 1024), sizes:'1024x1024', type:'image/jpeg' },
] : [];
// Mutate the existing metadata in place rather than assigning a new MediaMetadata —
// replacing the object makes Windows tear down & rebuild the media tile (visible
// flicker when skipping). Spotify-style: keep one object, just update its fields.
if (ms.metadata) {
ms.metadata.title = s.title;
ms.metadata.artist = s.artist || '';
ms.metadata.album = s.album || '';
ms.metadata.artwork = artwork;
} else {
// Feldweise mutieren statt ein neues MediaMetadata zuzuweisen vermeidet unter Windows das
// Neuaufbauen der Medienkachel (sichtbares Flackern beim Skippen). Auf Linux kam davon bei MPRIS
// aber nichts an: Desktop-Widgets zeigten dauerhaft den ZUERST gespielten Titel, weil nur die
// erste Zuweisung gemeldet wurde. Deshalb jetzt zweigeteilt — bei echtem Titelwechsel ein neues
// Objekt (garantiert gemeldet), sonst weiter in-place (z. B. wenn nur das Cover nachgeladen wird).
const wechsel = !ms.metadata
|| ms.metadata.title !== s.title
|| ms.metadata.artist !== (s.artist || '')
|| ms.metadata.album !== (s.album || '');
if (wechsel) {
ms.metadata = new MediaMetadata({ title:s.title, artist:s.artist||'', album:s.album||'', artwork });
} else {
ms.metadata.artwork = artwork;
}
// Action handlers are stable — set them once, not on every track change.
if (_msHandlersSet) return;
@@ -1184,14 +1193,53 @@ export function renderQueue() {
S.queue.forEach((s, i) => {
const item = document.createElement('div');
item.className = 'queue-item' + (i === S.qIdx ? ' active' : '');
item.dataset.qidx = i;
const cv = coverUrl(s.coverArt, 128);
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(dispTitle(s))}</div><div class="qi-artist">${esc(dispArtist(s))}</div></div>`;
item.innerHTML = `<div class="qi-idx">${i+1}</div><div class="qi-art">${cv ? `<img src="${cv}">` : '♪'}</div><div class="qi-info"><div class="qi-title">${esc(dispTitle(s))}</div><div class="qi-artist">${esc(dispArtist(s))}</div></div>` + QUEUE_WERKZEUGE;
item.onclick = () => { S.qIdx = i; playCurrent(); };
el.appendChild(item);
});
macheQueueBearbeitbar('queue-list');
el.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
}
// ─── WARTESCHLANGE BEARBEITEN ─────────────────────────────────────────────
// S.queue ist eine einfache Liste, S.qIdx zeigt auf den laufenden Titel. Beim Aendern muss der
// Zeiger mitwandern — sonst spielt nach dem Entfernen eines Titels weiter oben ploetzlich der
// falsche. Gemerkt wird deshalb die ID des laufenden Titels, nicht seine Position.
/** Entfernt den Titel an Position i. Traf es den laufenden, laeuft der naechste an. */
export function queueEntfernen(i) {
if (i < 0 || i >= S.queue.length) return;
const { qIdx, warAktuell } = queueNachEntfernen(S.queue.length, S.qIdx, i);
S.queue.splice(i, 1);
S.qIdx = qIdx;
_renderedQueueKey = '';
if (!S.queue.length) {
S.qIdx = -1;
try { S._audioEls?.forEach(a => { a.pause(); }); } catch {}
setPlayIcon(false);
renderQueue(); renderNpQueue();
toast('Warteschlange geleert');
return;
}
if (warAktuell) playCurrent();
renderQueue(); renderNpQueue();
}
/** Verschiebt den Titel von Position `von` auf Position `nach`. */
export function queueVerschieben(von, nach) {
const n = S.queue.length;
if (von === nach || von < 0 || von >= n || nach < 0 || nach >= n) return;
const { qIdx } = queueNachVerschieben(S.queue.map(x => x.id), S.qIdx, von, nach);
const [titel] = S.queue.splice(von, 1);
S.queue.splice(nach, 0, titel);
S.qIdx = qIdx;
_renderedQueueKey = '';
renderQueue(); renderNpQueue();
}
export function toggleQueue() {
S.queueOpen = !S.queueOpen;
document.getElementById('queue-panel').classList.toggle('open', S.queueOpen);
@@ -1385,5 +1433,6 @@ export function _restoreLastPlayed() {
// Web path: prime the audio element so play button works immediately.
S.audio.src = streamUrl(s.id);
S.audio.load();
} catch {}
}
+90
View File
@@ -0,0 +1,90 @@
import { queueEntfernen, queueVerschieben } from './player.js';
// ─── WARTESCHLANGE BEARBEITEN (Web) ─────────────────────────────────────────
// Ziehen und Entfernen fuer beide Queue-Listen: die Seitenleiste und die im Now-Playing.
//
// Bewusst ueber Zeiger-Ereignisse statt HTML5-Drag-and-Drop: dessen `dragstart` gibt es auf
// Touch-Geraeten gar nicht, die Warteschlange waere am Handy also unbearbeitbar geblieben.
// `setPointerCapture` liefert dafuer Maus und Finger ueber denselben Weg.
const ZIEH_SCHWELLE = 4; // px, bevor aus einem Tippen ein Ziehen wird
export function macheQueueBearbeitbar(listeId) {
const liste = document.getElementById(listeId);
if (!liste || liste.dataset.bearbeitbar) return;
liste.dataset.bearbeitbar = '1';
liste.addEventListener('click', (e) => {
const weg = e.target.closest('.q-del');
if (!weg) return;
e.stopPropagation();
const zeile = weg.closest('[data-qidx]');
if (zeile) queueEntfernen(Number(zeile.dataset.qidx));
});
liste.addEventListener('pointerdown', (e) => {
const griff = e.target.closest('.q-grip');
if (!griff || e.button > 0) return;
const zeile = griff.closest('[data-qidx]');
if (!zeile) return;
e.preventDefault();
e.stopPropagation();
ziehen(liste, zeile, e);
});
}
function ziehen(liste, zeile, start) {
const zeilen = [...liste.querySelectorAll('[data-qidx]')];
const vonIdx = Number(zeile.dataset.qidx);
const hoehe = zeile.offsetHeight || 1;
const startY = start.clientY;
let zielIdx = vonIdx;
let aktiv = false;
const bewegen = (e) => {
const dy = e.clientY - startY;
if (!aktiv) {
if (Math.abs(dy) < ZIEH_SCHWELLE) return;
aktiv = true;
zeile.classList.add('q-dragging');
liste.classList.add('q-dragging-aktiv');
}
zeile.style.transform = `translateY(${dy}px)`;
zielIdx = Math.max(0, Math.min(zeilen.length - 1, vonIdx + Math.round(dy / hoehe)));
// Die Zeilen zwischen Start und Ziel weichen um eine Zeilenhoehe aus, damit die Luecke
// dorthin wandert, wo losgelassen wird.
zeilen.forEach((z, i) => {
if (z === zeile) return;
let v = 0;
if (vonIdx < zielIdx && i > vonIdx && i <= zielIdx) v = -hoehe;
else if (vonIdx > zielIdx && i >= zielIdx && i < vonIdx) v = hoehe;
z.style.transform = v ? `translateY(${v}px)` : '';
});
};
const beenden = () => {
liste.releasePointerCapture?.(start.pointerId);
window.removeEventListener('pointermove', bewegen);
window.removeEventListener('pointerup', beenden);
window.removeEventListener('pointercancel', beenden);
zeilen.forEach(z => { z.style.transform = ''; z.classList.remove('q-dragging'); });
liste.classList.remove('q-dragging-aktiv');
if (aktiv && zielIdx !== vonIdx) queueVerschieben(vonIdx, zielIdx);
};
try { liste.setPointerCapture(start.pointerId); } catch {}
window.addEventListener('pointermove', bewegen);
window.addEventListener('pointerup', beenden);
window.addEventListener('pointercancel', beenden);
}
/** Griff und Entfernen-Knopf als HTML — in beiden Listen identisch. */
export const QUEUE_WERKZEUGE =
'<button class="q-del" title="Aus der Warteschlange entfernen" aria-label="Entfernen">' +
'<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' +
'</button>' +
'<span class="q-grip" title="Zum Verschieben ziehen" aria-label="Verschieben">' +
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
'<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/></svg>' +
'</span>';
+58
View File
@@ -0,0 +1,58 @@
// Reine Indexrechnung der Warteschlange — bewusst OHNE Importe, damit sie sich ohne DOM und
// ohne Wiedergabe pruefen laesst. Hier steckt der Teil, bei dem ein Fehler nicht auffaellt,
// sondern spaeter den falschen Titel spielt.
/**
* Entfernt Position `i`. Liefert den neuen Zeiger und ob der LAUFENDE Titel getroffen wurde.
* Der Zeiger wandert mit: faellt ein Titel VOR dem laufenden weg, rutscht dieser eine Stelle vor.
*/
export function queueNachEntfernen(laenge, qIdx, i) {
if (i < 0 || i >= laenge) return { qIdx, warAktuell: false, leer: laenge === 0 };
const warAktuell = i === qIdx;
const neueLaenge = laenge - 1;
if (neueLaenge === 0) return { qIdx: -1, warAktuell, leer: true };
let neu = qIdx;
if (!warAktuell && i < qIdx) neu--;
// Traf es den laufenden Titel, zeigt der Index jetzt auf den nachrueckenden — am Ende der
// Liste gibt es keinen, dann auf den davor.
if (warAktuell) neu = Math.min(neu, neueLaenge - 1);
return { qIdx: neu, warAktuell, leer: false };
}
/**
* Verschiebt `von` nach `nach` und liefert die neue Reihenfolge samt nachgefuehrtem Zeiger.
* Der Zeiger wird ueber die POSITION des laufenden Titels neu bestimmt, nicht gerechnet — das
* stimmt auch, wenn er selbst gezogen wurde oder ein anderer ueber ihn hinweggezogen ist.
*/
export function queueNachVerschieben(ids, qIdx, von, nach) {
const n = ids.length;
if (von === nach || von < 0 || von >= n || nach < 0 || nach >= n) return { ids, qIdx };
const laufende = ids[qIdx];
const neu = ids.slice();
const [x] = neu.splice(von, 1);
neu.splice(nach, 0, x);
const gefunden = neu.indexOf(laufende);
return { ids: neu, qIdx: gefunden >= 0 ? gefunden : qIdx };
}
/**
* Echte Mischung (Fisher-Yates). Liefert eine NEUE Liste, die Eingabe bleibt unberuehrt.
*
* Warum nicht `liste.sort(() => Math.random() - 0.5)`: ein Vergleicher, der bei derselben Paarung
* mal so und mal anders antwortet, ist fuer eine Sortierung ungueltig, und das Ergebnis ist nicht
* gleichverteilt. Gemessen (V8, 3000 Durchlaeufe je Laenge) bleibt der ERSTE Titel viel zu oft
* vorn: bei 10 Titeln in 18,8 % der Faelle statt 10 %, bei 100 in 3,1 % statt 1 %, bei 2000 in
* 0,23 % statt 0,05 % — also durchweg das Drei- bis Fuenffache. Die Grobordnung sieht dabei
* gemischt aus, weshalb es lange nicht auffaellt; systematisch bevorzugt werden trotzdem immer
* dieselben Titel. Fisher-Yates zieht fuer jede Position gleichverteilt aus dem Rest und traf in
* derselben Messung 9,7 % / 0,87 % / 0,03 %.
*/
export function mischeListe(liste, rnd = Math.random) {
const a = liste.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
+3
View File
@@ -22,6 +22,9 @@ export const S = {
npOpen: false,
npQueueMode: false,
npLyricsMode: false,
// Lyrics: automatisch mitlaufen oder von Hand scrollen. Standard AUS — wer liest, will nicht,
// dass der Text unter dem Finger wegspringt; einschalten geht im Lyrics-Tab mit einem Druck.
lyricsFolgen: false,
// Navigation history
_navStack: [],
+57 -1
View File
@@ -124,8 +124,64 @@ export const THEMES = [
'--danger': '#e05555',
},
}
},
{
id: 'violet',
name: 'Violet',
accent: '#a78bfa',
bg: '#0d0916',
vars: {
'--bg': '#0d0916', // Tiefes Violettschwarz
'--bg2': '#140e20',
'--bg3': '#1a1229',
'--surface': '#221835',
'--surface2': '#2d2145',
'--border': 'rgba(255,255,255,0.07)',
'--border2': 'rgba(255,255,255,0.13)',
'--accent': '#a78bfa', // Helles Lila — auf dunklem Grund gut lesbar
'--accent2': '#c4b5fd',
'--accent-dark':'#7c5cf0',
'--accent-dim': 'rgba(167,139,250,0.15)',
'--accent-glow':'rgba(167,139,250,0.32)',
'--text': '#f2edff',
'--text2': '#9b8bbd',
'--text3': '#655780',
'--danger': '#e05555',
},
},
{
id: 'crimson',
name: 'Crimson',
accent: '#e05263',
bg: '#120708',
vars: {
'--bg': '#120708', // Dunkles Weinrot-Schwarz
'--bg2': '#1a0b0d',
'--bg3': '#210e11',
'--surface': '#2b1316',
'--surface2': '#3a1a1e',
'--border': 'rgba(255,255,255,0.07)',
'--border2': 'rgba(255,255,255,0.13)',
// Bewusst kein reines Rot: das laege zu nah an --danger, und Loeschknoepfe saehen
// dann aus wie gewoehnliche Akzente.
'--accent': '#e05263',
'--accent2': '#f2788a',
'--accent-dark':'#b03445',
'--accent-dim': 'rgba(224,82,99,0.16)',
'--accent-glow':'rgba(224,82,99,0.32)',
'--text': '#ffeef0',
'--text2': '#bf8d94',
'--text3': '#7d545c',
'--danger': '#ff7a6b', // heller als der Akzent, damit Warnungen abstechen
},
},
];
+72 -34
View File
@@ -35,7 +35,7 @@ function _buildOverlay() {
_overlay.id = 'compare-overlay';
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeCompare(); });
_overlay.innerHTML = `
<div class="modal-card" style="max-width:720px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
<div class="modal-card" style="max-width:940px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
<div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
<span style="font-size:18px">🎧</span>
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Musik-Match</div>
@@ -126,35 +126,41 @@ async function _loadCompare() {
}
function _renderCompare(body, d) {
const score = Math.max(0, Math.min(100, d.matchScore || 0));
const verdict =
score >= 80 ? 'Seelenverwandte 🎯' :
score >= 60 ? 'Starkes Match 🔥' :
score >= 40 ? 'Solide Überschneidung 👍' :
score >= 20 ? 'Ein paar Gemeinsamkeiten 🙂' :
'Ziemlich unterschiedlich 🌗';
const b = d.battle;
const urteil = !b ? '' :
b.punkteA === b.punkteB ? 'Kopf-an-Kopf — kein Sieger.' :
b.punkteA > b.punkteB ? `${d.userA} gewinnt ${b.punkteA}:${b.punkteB}.` :
`${d.userB} gewinnt ${b.punkteB}:${b.punkteA}.`;
body.innerHTML = [
_scoreRing(d, score, verdict),
_totals(d),
_sharedArtists(d.sharedArtists),
_sharedTracks(d.sharedTracks),
_uniqueCols(d),
].join('');
body.innerHTML = `<div class="cmp-body">
<div class="cmp-wide">${_scoreboard(d, b, urteil)}</div>
<div class="cmp-wide">${_totals(d)}</div>
<div>${_streitpunkte(d)}</div>
<div>${_sharedArtists(d.sharedArtists)}</div>
<div>${_sharedTracks(d.sharedTracks)}</div>
<div class="cmp-wide">${_uniqueCols(d)}</div>
</div>`;
}
function _scoreRing(d, score, verdict) {
const deg = Math.round(score * 3.6);
return `<div style="display:flex;flex-direction:column;align-items:center;gap:10px;margin:6px 0 22px">
<div style="width:132px;height:132px;border-radius:50%;background:conic-gradient(var(--accent) ${deg}deg, var(--surface2) ${deg}deg);display:flex;align-items:center;justify-content:center">
<div style="width:104px;height:104px;border-radius:50%;background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center">
<div style="font-size:32px;font-weight:800;letter-spacing:-1px;line-height:1">${score}<span style="font-size:16px">%</span></div>
<div style="font-size:11px;color:var(--text2);margin-top:2px">Match</div>
</div>
// Anzeigetafel: pro Kategorie ein Sieger, unten der Gesamtstand. Ersetzt den frueheren
// Prozentring — nicht "wie aehnlich seid ihr", sondern "wer gewinnt worin".
function _scoreboard(d, b, urteil) {
if (!b?.kategorien?.length) return '';
const zeilen = b.kategorien.map(k => {
const gewinnA = k.sieger === 'A', gewinnB = k.sieger === 'B';
return `<div class="cmp-cat ${gewinnA ? 'win-a' : gewinnB ? 'win-b' : ''}">
<div class="cmp-val a ${gewinnA ? 'win' : gewinnB ? 'lose' : ''}">${esc(k.textA)}</div>
<div class="cmp-cat-label">${esc(k.label)}</div>
<div class="cmp-val b ${gewinnB ? 'win' : gewinnA ? 'lose' : ''}">${esc(k.textB)}</div>
</div>`;
}).join('');
return `<div class="cmp-head">
<div class="cmp-name">${esc(d.userA)}</div>
<div class="cmp-score">${b.punkteA} : ${b.punkteB}</div>
<div class="cmp-name">${esc(d.userB)}</div>
</div>
<div style="font-size:15px;font-weight:700">${esc(verdict)}</div>
<div style="font-size:13px;color:var(--text2)"><b>${esc(d.userA)}</b> &nbsp;⟷&nbsp; <b>${esc(d.userB)}</b></div>
</div>`;
<div class="cmp-verdict">${esc(urteil)}</div>
${zeilen}`;
}
function _totals(d) {
@@ -172,20 +178,52 @@ function _h(t) {
// Shared artist row: a centered name with each side's play count on its flank + a
// mirrored bar so you can see who plays them more.
// Streitpunkte: gemeinsame Künstler, bei denen die Zählerstände am weitesten auseinanderliegen.
// Umgekehrt zu "Gemeinsame Künstler", das nach Summe sortiert und damit zeigt, worin ihr euch einig
// seid. Sortiert wird nach relativem Ungleichgewicht, sonst stünden nur die meistgehörten oben.
function _streitpunkte(d) {
const items = (d.sharedArtists || [])
.map(a => {
const summe = a.playsA + a.playsB;
return { ...a, unwucht: summe ? Math.abs(a.playsA - a.playsB) / summe : 0, summe };
})
// Ein einzelner Play gegen null ist kein Streit, sondern Zufall — Mindestmasse verlangen.
.filter(a => a.summe >= 4 && a.unwucht >= 0.5)
.sort((x, y) => (y.unwucht - x.unwucht) || (y.summe - x.summe))
.slice(0, 8);
if (!items.length) return `${_h('Streitpunkte')}${_info('Ihr hört gemeinsame Künstler erstaunlich ausgewogen.')}`;
const rows = items.map(a => {
const anteilA = Math.round((a.playsA / a.summe) * 100);
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
return `<div ${nav} style="padding:7px 0">
<div style="display:flex;justify-content:space-between;gap:8px;font-size:13px;margin-bottom:4px">
<span class="cmp-num">${a.playsA}</span>
<span style="font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(a.name)}</span>
<span class="cmp-num">${a.playsB}</span>
</div>
<div style="display:flex;height:6px;border-radius:3px;overflow:hidden;background:var(--surface2)">
<div style="width:${anteilA}%;background:var(--accent)"></div>
<div style="width:${100 - anteilA}%;background:var(--accent);opacity:.35"></div>
</div>
</div>`;
}).join('');
return `${_h('Streitpunkte')}<div>${rows}</div>`;
}
function _sharedArtists(items) {
if (!items?.length) return `${_h('Gemeinsame Künstler')}${_info('Keine gemeinsamen Künstler in diesem Zeitraum.')}`;
const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1);
const rows = items.slice(0, 15).map(a => {
const rows = items.slice(0, 12).map(a => {
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
return `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:7px 0">
<div style="width:34px;text-align:right;font-size:12px;color:var(--text2);font-weight:700">${a.playsA}</div>
<div style="flex:1;display:flex;justify-content:flex-end"><div style="height:6px;background:var(--accent);border-radius:3px;width:${Math.round((a.playsA / max) * 100)}%"></div></div>
<div style="flex:1.6;text-align:center;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">${esc(a.name)}</div>
<div style="flex:1;display:flex;justify-content:flex-start"><div style="height:6px;background:var(--accent-dark, var(--accent));opacity:.7;border-radius:3px;width:${Math.round((a.playsB / max) * 100)}%"></div></div>
<div style="width:34px;text-align:left;font-size:12px;color:var(--text2);font-weight:700">${a.playsB}</div>
return `<div ${nav} class="cmp-row">
<div class="cmp-num" style="text-align:right">${a.playsA}</div>
<div style="display:flex;justify-content:flex-end"><div class="cmp-bar" style="width:${Math.round((a.playsA / max) * 100)}%"></div></div>
<div class="cmp-row-name">${esc(a.name)}</div>
<div><div class="cmp-bar" style="width:${Math.round((a.playsB / max) * 100)}%;opacity:.65"></div></div>
<div class="cmp-num">${a.playsB}</div>
</div>`;
}).join('');
return `${_h('Gemeinsame Künstler')}<div style="margin-bottom:22px">${rows}</div>`;
return `${_h('Gemeinsame Künstler')}<div>${rows}</div>`;
}
function _sharedTracks(items) {
+80 -2
View File
@@ -38,6 +38,9 @@ function _renderInfoModal(s) {
['Bitrate', s.bitRate ? s.bitRate+' kbps' : null],
['Format', s.suffix?.toUpperCase()],
['Dateigröße', s.size ? Math.round(s.size/1024/1024*10)/10+' MB' : null],
// Kommt so aus der Subsonic-API. Ohne "Report Real Path" am Player ist das NICHT der echte
// Dateiname, sondern von Navidrome aus den Tags gebaut (fakePath(): AlbumArtist/Album/
// DD-TT - Titel.ext) — siehe CHANGELOG 2.4, Known Bugs.
['Pfad', s.path],
['Plays', s.playCount],
].filter(([,v]) => v!=null && v!=='' && v!==undefined);
@@ -281,16 +284,91 @@ export async function saveMetadata() {
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
status.textContent = '✅ Gespeichert! Navidrome scannt…';
toast('✅ Metadaten gespeichert');
invalidateRomajiIndex();
invalidateApiCache('getArtists');
// Warnungen des Backends (Dublette am Zielnamen, nicht verschoben, …) sichtbar machen und das
// Fenster offen lassen — sonst sieht es nach "gespeichert" aus, obwohl die Datei liegen blieb.
if (d.warnings?.length) {
status.innerHTML = '⚠ Gespeichert, aber: ' + d.warnings.map(esc).join('<br>⚠ ') +
'<br><span style="color:var(--text3)">Datei: ' + esc(d.file || '?') + '</span>';
// Liegt derselbe Song doppelt, kann das Aufraeumen hier passieren statt auf der Konsole.
if (d.duplicate) _zeigeDublette(status, d.duplicate);
toast('⚠ Gespeichert — mit Hinweisen, siehe Fenster');
return;
}
status.textContent = '✅ Gespeichert! Navidrome scannt…';
toast('✅ Metadaten gespeichert');
setTimeout(() => { _leaveEditMode(); closeInfoModal(); }, 1500);
} catch (e) {
status.textContent = '❌ Fehler: ' + e.message;
}
}
// ─── DUBLETTE AUFLOESEN ───
// Das Verschieben wurde abgelehnt, weil am Ziel schon eine Datei liegt — derselbe Song liegt also
// zweimal auf der Platte. Statt den Nutzer zum Dateimanager zu schicken, werden beide Kopien
// gegenuebergestellt und die ueberzaehlige zum Loeschen angeboten.
const _mb = (n) => (n == null ? '?' : (n / 1048576).toFixed(1) + ' MB');
const _sek = (n) => (n == null ? '?' : formatDur(n));
function _zeigeDublette(status, dub) {
const box = document.createElement('div');
box.className = 'dublette-box';
const zeile = (titel, f) =>
`<div class="dublette-datei"><b>${esc(titel)}</b>` +
`<div class="dublette-pfad">${esc(f.pfad || '?')}</div>` +
`<div class="dublette-meta">${esc(f.artist || '?')} — ${esc(f.titel || '?')} · ` +
`${_sek(f.dauer)} · ${_mb(f.groesse)}</div></div>`;
box.innerHTML =
zeile('Bleibt liegen (richtiger Ordner)', dub.behalten) +
zeile('Überzählig (gerade bearbeitet)', dub.ueberzaehlig) +
(dub.gleich
? '<div class="dublette-urteil ok">Gleiche Länge und gleicher Titel — zwei Kopien desselben Songs.</div>'
: '<div class="dublette-urteil warn">Länge oder Titel weichen ab. Das Backend lehnt das Löschen ab, ' +
'solange das so ist — bitte erst selbst nachsehen.</div>');
const btn = document.createElement('button');
btn.className = 'btn-danger';
btn.textContent = '🗑 Überzählige Kopie löschen';
btn.onclick = () => _loescheDublette(btn, status, dub);
box.appendChild(btn);
status.appendChild(box);
}
async function _loescheDublette(btn, status, dub) {
const ok = await showConfirm(
'Dublette löschen',
`"${dub.ueberzaehlig.pfad}" wird unwiderruflich von der Festplatte gelöscht. ` +
`Die Kopie im richtigen Ordner bleibt bestehen.`,
'Löschen'
);
if (!ok) return;
btn.disabled = true;
btn.textContent = '🗑 Löschen…';
try {
const r = await fetch(`${S.cfg.backendUrl}/api/resolve-duplicate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass,
behalten: dub.behalten.pfad, ueberzaehlig: dub.ueberzaehlig.pfad,
}),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
invalidateApiCache('getArtists');
invalidateRomajiIndex();
btn.remove();
status.insertAdjacentHTML('beforeend',
'<div class="dublette-urteil ok">✅ Gelöscht. Navidrome scannt — danach steht der Song nur noch einmal da.</div>');
toast('✅ Dublette gelöscht');
} catch (e) {
btn.disabled = false;
btn.textContent = '🗑 Überzählige Kopie löschen';
toast('❌ ' + e.message);
status.insertAdjacentHTML('beforeend',
'<div class="dublette-urteil warn">' + esc(e.message) + '</div>');
}
}
export async function deleteSong() {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const s = S.infoSong;
+1
View File
@@ -31,6 +31,7 @@ export function showCtxMenu(e, song, list, idx) {
}},
{ sep: true },
{ icon: '💿', label: 'Zum Album', fn: () => song.albumId && navigate('album-detail', { id: song.albumId, name: song.album||'', artist: song.artist||'', artistId: song.artistId||'', coverArt: song.coverArt||'' }) },
{ icon: '🔗', label: 'Teilen', fn: () => import('./share.js').then(m => m.shareSong(song)) },
{ icon: 'ℹ', label: 'Song-Info', fn: () => import('./metadata.js').then(m => m.showSongInfo(song)) },
{ icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) },
];
+87
View File
@@ -0,0 +1,87 @@
import { S } from '../state.js';
import { toast } from '../utils.js';
import { api } from '../api.js';
// ─── TEILEN PER LINK ───
// Ein Link traegt NUR eine Navidrome-Id, keine Zugangsdaten: der Empfaenger sieht den Inhalt mit
// seinen eigenen Anmeldedaten auf derselben Instanz. Als Query-Parameter (?song=…) statt als Pfad,
// damit der Webserver keine SPA-Umschreibung braucht — die App liest ihn beim Start selbst aus.
export function buildShareLink(type, id) {
const base = `${location.origin}${location.pathname.replace(/index\.html$/, '')}`;
return `${base}?${type}=${encodeURIComponent(id)}`;
}
/**
* Teilt einen Link ueber das System (Handy: Teilen-Dialog) und faellt sonst auf die Zwischenablage
* zurueck. navigator.share gibt es nur in sicheren Kontexten und nicht auf jedem Desktop-Browser.
*/
export async function shareLink(type, id, title, subtitle) {
const url = buildShareLink(type, id);
const text = subtitle ? `${title} — ${subtitle}` : title;
if (navigator.share) {
try {
await navigator.share({ title, text, url });
return;
} catch (e) {
// AbortError = der Nutzer hat den Dialog geschlossen; das ist kein Fehler.
if (e?.name === 'AbortError') return;
}
}
try {
await navigator.clipboard.writeText(url);
toast('🔗 Link kopiert');
} catch {
// Zwischenablage kann blockiert sein (kein HTTPS, kein Nutzergesten-Kontext) — dann zeigen wir
// den Link, damit er wenigstens von Hand kopiert werden kann.
toast('🔗 ' + url);
}
}
export const shareSong = (s) => shareLink('song', s.id, s.title || 'Song', s.artist || '');
export const shareAlbum = (a) => shareLink('album', a.id, a.name || 'Album', a.artist || '');
/**
* Loest einen beim Start uebergebenen Link auf: Song abspielen bzw. Album oeffnen. Wird NACH der
* Anmeldung aufgerufen — ohne gueltige Zugangsdaten gibt es nichts abzuspielen. Der Parameter wird
* anschliessend aus der Adresszeile entfernt, damit ein Neuladen nicht erneut losspielt.
*/
export async function handleShareParam(navigate, playQueue) {
const p = new URLSearchParams(location.search);
const songId = p.get('song');
const albumId = p.get('album');
if (!songId && !albumId) return false;
const sauber = () => {
try { history.replaceState(history.state, '', location.pathname); } catch {}
};
try {
if (songId) {
const r = await api('getSong', `&id=${encodeURIComponent(songId)}`);
const song = r.song;
if (!song) { toast('Song nicht gefunden'); sauber(); return false; }
if (song.albumId) {
navigate('album-detail', {
id: song.albumId, name: song.album || '', artist: song.artist || '',
artistId: song.artistId || '', coverArt: song.coverArt || '',
});
}
playQueue([song], 0);
toast(`▶ ${song.title}`);
} else {
const r = await api('getAlbum', `&id=${encodeURIComponent(albumId)}`);
const al = r.album;
if (!al) { toast('Album nicht gefunden'); sauber(); return false; }
navigate('album-detail', {
id: al.id, name: al.name || '', artist: al.artist || '',
artistId: al.artistId || '', coverArt: al.coverArt || '',
});
}
sauber();
return true;
} catch (e) {
toast('Link konnte nicht geöffnet werden: ' + e.message);
sauber();
return false;
}
}
+11
View File
@@ -47,6 +47,17 @@ export function formatDur(s) {
return `${m}:${sec.toString().padStart(2,'0')}`;
}
// Gesamtdauer einer Playlist oder eines Albums. Ab einer Stunde werden Stunden ausgewiesen —
// "134 Min." muss man sonst selbst umrechnen, und bei einer Playlist mit 2000 Titeln steht dort
// sonst eine vierstellige Minutenzahl.
export function formatGesamtdauer(sekunden) {
const min = Math.round((sekunden || 0) / 60);
if (min < 60) return `${min} Min.`;
const std = Math.floor(min / 60);
const rest = min % 60;
return rest ? `${std} Std. ${rest} Min.` : `${std} Std.`;
}
// Fan/count formatting in German: full grouped number up to a million (559534 → "559.542"),
// then compact millions (1200000 → "1,2 Mio.").
export function fmtCount(n) {
+309
View File
@@ -0,0 +1,309 @@
import { S } from '../state.js';
import { esc } from '../utils.js';
import { api } from '../api.js';
import { openDlModal } from '../ui/ui.js';
// Zirkulär — sicher, weil nur in Ereignisbehandlern aufgerufen
import { navigate, _navGoBack } from './navigation.js';
// ─── NEU VON DEINEN KÜNSTLERN ───────────────────────────────────────────────
// Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. "Folgen" ist
// Navidromes Stern, die Liste kommt also aus getStarred2 — kein eigener Speicher.
// Die Deezer-Auflösung macht das Backend, weil dort auch die Künstler-Pins liegen.
let _tage = 90;
let _reiter = 'releases';
let _laeuft = false;
/** Reiter wechseln: "releases" oder "konzerte". */
export function showArtistNewsTab(reiter) {
_reiter = reiter === 'konzerte' ? 'konzerte' : 'releases';
document.querySelectorAll('#artist-news-tabs .sort-chip').forEach(c =>
c.classList.toggle('active', c.dataset.tab === _reiter));
const titel = document.querySelector('#page-artist-news .page-title');
if (titel) {
titel.innerHTML = _reiter === 'konzerte'
? 'Konzerte <em>deiner Künstler</em>'
: 'Neu von <em>deinen Künstlern</em>';
}
// Das Zeitfenster gilt nur fuer die Veroeffentlichungen. Konzerte sind ohnehin Zukunft, und
// dort will man alles Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
const tageReihe = document.getElementById('artist-news-sort');
if (tageReihe) tageReihe.style.display = _reiter === 'konzerte' ? 'none' : '';
if (_reiter === 'konzerte') loadKonzerte();
else loadArtistNews();
}
/** Zeitfenster setzen — gibt es nur bei den Veroeffentlichungen. */
export function setArtistNewsDays(tage) {
_tage = tage;
loadArtistNews();
}
function _markiereTage(tage) {
document.querySelectorAll('#artist-news-sort .sort-chip').forEach(c =>
c.classList.toggle('active', Number(c.dataset.days) === tage));
}
export async function loadArtistNews(tage = _tage) {
_tage = tage;
_reiter = 'releases';
const tageReihe = document.getElementById('artist-news-sort');
if (tageReihe) tageReihe.style.display = '';
// Die Seite haengt an den Favoriten (Knopf "✨ Neu") und nicht mehr an der Seitenleiste,
// deshalb braucht sie einen Weg zurueck wie jede andere Unterseite.
const zurueck = document.getElementById('artist-news-back-btn');
if (zurueck) zurueck.onclick = () => _navGoBack();
_markiereTage(tage);
const el = document.getElementById('artist-news-list');
if (!el || _laeuft) return;
if (!S.cfg?.backendUrl) {
el.innerHTML = '<div class="empty-state"><div class="icon">🔌</div><p>Kein Backend konfiguriert</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Neue Veröffentlichungen kommen über das Backend von Deezer.</p></div>';
return;
}
_laeuft = true;
el.innerHTML = '<div class="spinner"></div>';
try {
const r = await api('getStarred2');
const kuenstler = (r.starred2?.artist || []).map(a => ({ id: a.id, name: a.name }));
if (!kuenstler.length) {
el.innerHTML = '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>';
return;
}
const res = await fetch(`${S.cfg.backendUrl}/api/artist-releases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artists: kuenstler, days: tage }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { releases = [], ohneZuordnung = [] } = await res.json();
// Was hat man schon? Verglichen wird über Künstler + Albumname — Deezers Album-Id hat mit
// Navidromes nichts zu tun.
const vorhanden = new Set();
(r.starred2?.album || []).forEach(a => vorhanden.add(_schluessel(a.artist, a.name)));
await _ergaenzeVorhandene(kuenstler, vorhanden);
el.innerHTML = '';
if (ohneZuordnung.length) el.appendChild(_hinweisOhneZuordnung(ohneZuordnung));
if (!releases.length) {
const leer = document.createElement('div');
leer.className = 'empty-state';
leer.innerHTML = `<div class="icon">🌙</div><p>Nichts Neues in den letzten ${tage} Tagen</p>`;
el.appendChild(leer);
return;
}
_renderZeitleiste(el, releases, vorhanden);
} catch (e) {
el.innerHTML = `<p style="color:var(--danger)">${esc(e.message)}</p>`;
} finally {
_laeuft = false;
}
}
const _norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '')
.replace(/[^a-z0-9]/g, '');
const _schluessel = (kuenstler, album) => `${_norm(kuenstler)}~~${_norm(album)}`;
/** Alben der gefolgten Künstler aus der eigenen Bibliothek nachtragen (parallel, begrenzt). */
async function _ergaenzeVorhandene(kuenstler, vorhanden) {
const haeppchen = 6;
for (let i = 0; i < kuenstler.length; i += haeppchen) {
await Promise.all(kuenstler.slice(i, i + haeppchen).map(async k => {
try {
const a = await api('getArtist', `&id=${encodeURIComponent(k.id)}`);
(a.artist?.album || []).forEach(al => vorhanden.add(_schluessel(k.name, al.name)));
} catch {}
}));
}
}
function _hinweisOhneZuordnung(namen) {
const d = document.createElement('div');
d.className = 'artist-news-hint';
d.innerHTML = `⚠ Für ${namen.length} Künstler wurde kein Deezer-Profil gefunden: `
+ `<strong>${namen.slice(0, 4).map(esc).join(', ')}</strong>${namen.length > 4 ? ' …' : ''}. `
+ `Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.`;
return d;
}
function _renderZeitleiste(el, releases, vorhanden) {
let letzterMonat = '';
releases.forEach(rel => {
const monat = new Date(rel.releaseDate)
.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
if (monat !== letzterMonat) {
letzterMonat = monat;
const h = document.createElement('div');
h.className = 'search-section-title';
h.style.cssText = 'margin:18px 0 8px';
h.textContent = monat;
el.appendChild(h);
}
el.appendChild(_zeile(rel, vorhanden.has(_schluessel(rel.artistName, rel.title))));
});
}
const _TYP = { album: 'Album', single: 'Single', ep: 'EP', compile: 'Compilation' };
function _zeile(rel, schonDa) {
const row = document.createElement('div');
row.className = 'artist-news-row';
const datum = new Date(rel.releaseDate).toLocaleDateString('de-DE',
{ day: '2-digit', month: '2-digit', year: 'numeric' });
const typ = _TYP[rel.recordType] || rel.recordType;
row.innerHTML = `
<div class="artist-news-art">${rel.cover ? `<img src="${esc(rel.cover)}" loading="lazy" alt="">` : '<div class="no-art">💿</div>'}</div>
<div class="artist-news-info">
<div class="artist-news-title">${esc(rel.title)}</div>
<div class="artist-news-sub"><span class="artist-news-artist">${esc(rel.artistName)}</span> · ${typ}${rel.trackCount ? ` · ${rel.trackCount} Titel` : ''}</div>
<div class="artist-news-date">${datum}</div>
</div>`;
row.querySelector('.artist-news-artist').onclick = (e) => {
e.stopPropagation();
navigate('artist-detail', { id: rel.artistId, name: rel.artistName });
};
const aktion = document.createElement('div');
aktion.className = 'artist-news-action';
if (schonDa) {
aktion.innerHTML = '<span class="artist-news-have" title="In deiner Bibliothek">✓ Hast du</span>';
} else {
const btn = document.createElement('button');
btn.className = 'btn-outline blue';
btn.style.cssText = 'font-size:12px;padding:7px 12px';
btn.textContent = '⬇ Laden';
btn.onclick = () => openDlModal({
artistName: rel.artistName,
trackName: rel.title,
collectionName: rel.title,
artworkUrl100: rel.cover || '',
isAlbum: true,
collectionId: rel.albumId,
deezerData: {
collectionId: rel.albumId, collectionName: rel.title,
artistName: rel.artistName, artworkUrl100: rel.cover || '',
releaseDate: rel.releaseDate, trackCount: rel.trackCount,
},
});
aktion.appendChild(btn);
}
row.appendChild(aktion);
return row;
}
// ─── KONZERTE DEINER KÜNSTLER ───────────────────────────────────────────────
// Gegenstück zur Release-Zeitleiste. Die Liste läuft NACH VORN: das nächste Konzert zuerst.
// Die Termine kommen über das Backend von Ticketmaster, gecacht für alle Geräte gemeinsam.
export async function loadKonzerte() {
_reiter = 'konzerte';
const zurueck = document.getElementById('artist-news-back-btn');
if (zurueck) zurueck.onclick = () => _navGoBack();
const tageReihe = document.getElementById('artist-news-sort');
if (tageReihe) tageReihe.style.display = 'none';
const el = document.getElementById('artist-news-list');
if (!el || _laeuft) return;
if (!S.cfg?.backendUrl) {
el.innerHTML = '<div class="empty-state"><div class="icon">🔌</div><p>Kein Backend konfiguriert</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Konzerttermine kommen über das Backend von Ticketmaster.</p></div>';
return;
}
_laeuft = true;
el.innerHTML = '<div class="spinner"></div>';
try {
const r = await api('getStarred2');
const kuenstler = (r.starred2?.artist || []).map(a => ({ id: a.id, name: a.name }));
if (!kuenstler.length) {
el.innerHTML = '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>';
return;
}
const res = await fetch(`${S.cfg.backendUrl}/api/artist-concerts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// Ohne `days`: das Backend liefert alles Angekuendigte.
body: JSON.stringify({ artists: kuenstler }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { konzerte = [], keinSchluessel } = await res.json();
el.innerHTML = '';
if (keinSchluessel) {
el.innerHTML = '<div class="empty-state"><div class="icon">🎟</div><p>Kein Ticketmaster-Schlüssel hinterlegt</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Im Backend <code>TICKETMASTER_API_KEY</code> setzen.</p></div>';
return;
}
if (!konzerte.length) {
const leer = document.createElement('div');
leer.className = 'empty-state';
leer.innerHTML = '<div class="icon">🎪</div><p>Keine anstehenden Termine</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Gesucht wird weltweit über den Künstlernamen.</p>';
el.appendChild(leer);
return;
}
_renderKonzerte(el, konzerte);
} catch (e) {
el.innerHTML = `<p style="color:var(--danger)">${esc(e.message)}</p>`;
} finally {
_laeuft = false;
}
}
function _renderKonzerte(el, konzerte) {
let letzterMonat = '';
konzerte.forEach(k => {
const d = new Date(k.datum);
const monat = d.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
if (monat !== letzterMonat) {
letzterMonat = monat;
const h = document.createElement('div');
h.className = 'search-section-title';
h.style.margin = '18px 0 8px';
h.textContent = monat;
el.appendChild(h);
}
const zeile = document.createElement('div');
zeile.className = 'artist-news-row konzert-row' + (k.abgesagt ? ' konzert-abgesagt' : '');
const ort = [k.ort, k.stadt, k.land].filter(Boolean).join(' · ');
const uhr = d.getHours() || d.getMinutes()
? d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }) : '';
zeile.innerHTML =
`<div class="konzert-datum"><span class="kd-tag">${d.getDate()}</span>`
+ `<span class="kd-monat">${d.toLocaleDateString('de-DE', { month: 'short' })}</span></div>`
+ `<div class="artist-news-info">`
+ `<div class="artist-news-title">${esc(k.titel || k.artistName)}</div>`
+ `<div class="artist-news-sub"><span class="artist-news-artist">${esc(k.artistName)}</span>`
+ `${ort ? ' · ' + esc(ort) : ''}${uhr ? ' · ' + uhr + ' Uhr' : ''}</div></div>`;
// Künstlername führt zur Künstlerseite, wie in der Release-Liste.
zeile.querySelector('.artist-news-artist').onclick = (e) => {
e.stopPropagation();
navigate('artist-detail', { id: k.artistId, name: k.artistName });
};
const rechts = document.createElement('div');
if (k.abgesagt) {
rechts.className = 'artist-news-have';
rechts.textContent = 'abgesagt';
} else if (k.url) {
const a = document.createElement('a');
a.className = 'btn-outline konzert-ticket';
a.href = k.url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.textContent = 'Tickets';
rechts.appendChild(a);
}
zeile.appendChild(rechts);
el.appendChild(zeile);
});
}
+95 -7
View File
@@ -1,6 +1,6 @@
import { S } from '../state.js';
import { esc, toast, fmtCount } from '../utils.js';
import { api, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
import { esc, toast, fmtCount, formatGesamtdauer } from '../utils.js';
import { api, invalidateApiCache, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
import { openArtistPin } from '../ui/artistPin.js';
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js';
@@ -105,6 +105,15 @@ export async function loadArtistDetail(artist) {
pinBtn.style.cssText = `position:absolute;top:10px;right:10px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:${_pin ? 'var(--accent)' : 'rgba(255,255,255,.72)'};font-size:14px;line-height:1;cursor:pointer;backdrop-filter:blur(4px)`;
pinBtn.onclick = () => openArtistPin(artist.id, artist.name);
hero.appendChild(pinBtn);
// Folgen gehoert an den Kuenstler, nicht in die Aktionsleiste: die Leiste ist "was ich jetzt
// tue" (abspielen, mischen), Folgen ist ein Zustand. Deshalb als Herz ins Banner, neben das
// Zahnrad. Verdrahtet wird es weiter unten, sobald `starred` aus getArtist da ist.
hero.querySelector('.artist-follow-btn')?.remove();
const folgeBtn = document.createElement('button');
folgeBtn.className = 'artist-follow-btn';
folgeBtn.style.cssText = 'position:absolute;top:10px;right:46px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:rgba(255,255,255,.72);cursor:pointer;backdrop-filter:blur(4px)';
hero.appendChild(folgeBtn);
}
// Fire all requests immediately so they run in parallel.
@@ -112,7 +121,11 @@ export async function loadArtistDetail(artist) {
: hasND ? getArtistImage(artist.id) : Promise.resolve(null);
const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null);
const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null);
const topP = hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
// id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
// Namen zurueck, wenn es sie nicht kennt (aeltere Versionen ignorieren id einfach). Der Namensweg
// sucht mit LIKE und Max:1 — bei zwei gleichnamigen Kuenstlern in der Bibliothek gewinnt also der
// erste Treffer, und das war nicht zwingend der gemeinte.
const topP = hasND ? api('getTopSongs', `&id=${encodeURIComponent(artist.id)}&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
const iArtistP = knownDeezer ? Promise.resolve([{ artistId: knownDeezer }]) : deezerSearch(artist.name, 'musicArtist', 5);
const eventsP = fetchEvents(artist.name);
@@ -122,6 +135,24 @@ export async function loadArtistDetail(artist) {
const [albumRes] = await Promise.allSettled([albumP]);
const _ndArtist = albumRes.status === 'fulfilled' ? albumRes.value?.artist : null;
if (hasND && artist.id) {
const folgeBtn = hero?.querySelector('.artist-follow-btn');
_verdrahteMerken(
folgeBtn, artist.id, _ndArtist?.starred, '', '',
`Du folgst ${artist.name}`, `${artist.name} folgen`,
);
// Ohne Pinnung laeuft die Deezer-Zuordnung ueber den Namen — bei gleichnamigen Kuenstlern
// kaemen dauerhaft die Neuheiten des Falschen.
if (folgeBtn && !_pin && !_ndArtist?.starred) {
folgeBtn.addEventListener('click', () => {
setTimeout(() => {
if (folgeBtn.classList.contains('is-starred')) {
toast('Tipp: Über ⚙ daneben den richtigen Deezer-Künstler festpinnen');
}
}, 900);
}, { once: true });
}
}
if (_ndArtist) cacheArtistSortNames([_ndArtist]);
const _firstAlbums = _ndArtist?.album || [];
const _bgCover = _firstAlbums[0] ? coverUrl(_firstAlbums[0].coverArt, 512) : null;
@@ -331,6 +362,7 @@ export async function loadArtistDetail(artist) {
evBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Konzerte${events.length ? ` <span style="font-size:11px;opacity:.7">(${events.length})</span>` : ''}`;
evBtn.onclick = () => openEventsModal(events, artist.name);
actionBar.appendChild(evBtn);
}
if(albEl) _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist);
@@ -479,10 +511,57 @@ export async function loadArtistAllSongs({ albums, artist }) {
});
}
// ─── MARKIEREN (Künstler + Album) ───
// Navidrome wirft beim Markieren `id`, `albumId` und `artistId` in eine Liste und behandelt sie
// gleich (media_annotation.go) — derselbe Aufruf taugt also für Songs, Alben und Künstler. Der
// Zustand kommt aus `starred` in getArtist/getAlbum.
const _HERZ = (voll) => `<svg width="13" height="13" viewBox="0 0 24 24" fill="${voll ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`;
/**
* Verdrahtet einen Knopf als Markieren-Umschalter. Optimistisch: der Knopf springt sofort um und
* kehrt zurück, wenn der Aufruf scheitert — sonst fühlt sich das Markieren träge an.
*/
function _verdrahteMerken(btn, id, markiert, textAn, textAus, meldungAn, meldungAus) {
if (!btn || !id) return;
let an = !!markiert;
const malen = () => {
const txt = an ? textAn : textAus;
btn.innerHTML = _HERZ(an) + (txt ? `<span>${txt}</span>` : '');
btn.classList.toggle('is-starred', an);
if (!txt) btn.title = an ? meldungAn : meldungAus;
};
malen();
btn.onclick = async () => {
const vorher = an;
an = !an; malen();
try {
await api(vorher ? 'unstar' : 'star', `&id=${encodeURIComponent(id)}`);
invalidateApiCache('getStarred2');
// Der "Gefolgt"-Filter auf der Künstlerseite hält eine eigene kurze Liste — die muss mit weg,
// sonst zeigt er nach dem Entfolgen noch eine halbe Minute den alten Stand.
import('./home.js').then(m => m.invalidateGefolgt?.()).catch(() => {});
toast(an ? meldungAn : meldungAus);
} catch (e) {
an = vorher; malen();
toast('Fehler: ' + e.message);
}
};
}
export async function loadDeezerAlbumDetail(album) {
document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack();
document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—';
document.getElementById('deezer-album-detail-artist').textContent = album.artistName || '';
// Deezer liefert die Künstler-ID in jedem Albumtreffer gleich mit — ohne sie ist der Name nur
// toter Text (z. B. bei Alben aus /artist/{id}/albums, wo Deezer das artist-Objekt weglässt).
const dzArtistEl = document.getElementById('deezer-album-detail-artist');
dzArtistEl.textContent = album.artistName || '';
dzArtistEl.onclick = null;
dzArtistEl.classList.toggle('is-link', !!(album.artistId && album.artistName));
if (album.artistId && album.artistName) {
dzArtistEl.onclick = () => navigate('artist-detail', {
id: null, name: album.artistName, deezerArtistId: album.artistId, deezerFanCount: null,
});
}
const cw = document.getElementById('deezer-album-cover-wrap');
const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600');
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
@@ -572,8 +651,13 @@ export async function loadPartialAlbumDetail({ nd, deezer }) {
// ─── ALBUM DETAIL ───
export async function loadAlbumDetail(album) {
if (S.cfg.showRomaji) await ensureArtistSortMap().catch(() => {});
// Zurück heißt zurück: einen Eintrag im Verlauf zurückgehen, wie auf jeder anderen Detailseite.
// Vorher navigierte dieser Knopf VORWÄRTS auf die Albenliste (bzw. den Artist) — das landete nicht
// dort, wo man das Album angeklickt hatte, und schob zusätzlich einen neuen History-Eintrag auf den
// Stapel, sodass der Browser-Zurück-Knopf sich durch diesen Müll arbeiten musste statt nach Home.
// Nur wenn es keinen Verlauf gibt (Direkteinstieg auf ein Album), bleibt die Albenliste das Ziel.
document.getElementById('album-back-btn').onclick = () =>
album._fromArtist ? navigate('artist-detail', album._fromArtist) : navigate('albums');
S._navCursor > 0 ? _navGoBack() : navigate('albums');
document.getElementById('album-detail-name').textContent = dispAlbumName(album);
const artistEl = document.getElementById('album-detail-artist');
artistEl.innerHTML = parseArtists(album)
@@ -587,8 +671,12 @@ export async function loadAlbumDetail(album) {
try {
const r = await api('getAlbum', `&id=${album.id}`);
const songs = r.album?.song || [];
const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60);
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`;
const gesamt = songs.reduce((s,t) => s+(t.duration||0), 0);
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${formatGesamtdauer(gesamt)}`;
_verdrahteMerken(
document.getElementById('btn-star-album'), album.id, r.album?.starred,
'Gemerkt', 'Merken', '❤ Album gemerkt', 'Aus den Favoriten entfernt',
);
// r.album has sortName (album sort tag) — use it; nav album may not have sortName
document.getElementById('album-detail-name').textContent = dispAlbumName(r.album);
// Album artist comes from the album's own album-artist tags (r.album.artists / .artist), NOT the
+56 -16
View File
@@ -3,6 +3,7 @@ import { esc } from '../utils.js';
import { api, apiCached, apiIsCached, invalidateApiCache, coverUrl, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames } from '../api.js';
import { makeCard, makeAlbumCard, makeSongItem } from '../ui/components.js';
import { playQueue } from '../player/player.js';
import { mischeListe } from '../player/queueMath.js';
// Circular — safe (called inside event handlers)
import { navigate, _navGoBack } from './navigation.js';
@@ -161,7 +162,7 @@ async function playGenre(genre) {
const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=200`);
const songs = r.songsByGenre?.song || [];
if (!songs.length) return;
playQueue([...songs].sort(() => Math.random() - 0.5), 0);
playQueue(mischeListe(songs), 0);
} catch {}
}
@@ -187,7 +188,7 @@ export async function loadGenreDetail(g) {
document.getElementById('genre-detail-stats').textContent = `${songs.length} Titel`;
document.getElementById('btn-play-genre').onclick = () => {
playQueue([...songs].sort(() => Math.random() - 0.5), 0);
playQueue(mischeListe(songs), 0);
};
list.innerHTML = '';
@@ -210,7 +211,12 @@ export async function loadAlbumRow(id, method, extra, force = false) {
}
// ─── ARTISTS ───
export async function loadArtists() {
let _artistsFilter = 'all';
export async function loadArtists(filter = _artistsFilter) {
_artistsFilter = filter;
document.querySelectorAll('#artists-filter .sort-chip').forEach(c =>
c.classList.toggle('active', c.dataset.filter === filter));
const g = document.getElementById('artists-grid');
if (!apiIsCached('getArtists')) g.innerHTML = '<div class="spinner"></div>';
try {
@@ -218,27 +224,61 @@ export async function loadArtists() {
const all = (r.artists?.index || []).flatMap(i => i.artist || []);
cacheArtistSortNames(all);
if (S.cfg.showRomaji) await fetchRomajiIndex().catch(() => {});
// "Gefolgt" = Navidromes Stern. getArtists liefert kein `starred` mit, die Liste kommt also
// aus getStarred2 — zwischengespeichert, damit das Umschalten nicht jedes Mal nachfragt.
const gefolgt = await _gefolgteIds();
const zaehler = document.getElementById('artists-followed-count');
if (zaehler) zaehler.textContent = gefolgt.size ? `(${gefolgt.size})` : '';
const liste = filter === 'followed' ? all.filter(a => gefolgt.has(a.id)) : all;
g.innerHTML = '';
all.forEach(a => {
const c = makeCard(dispArtistById(a.id, a.name), a.albumCount+' Alben', null, '🎤');
const suche = document.getElementById('artists-search');
if (suche) suche.value = '';
if (!liste.length) {
g.innerHTML = filter === 'followed'
? '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p><p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>'
: '<div class="empty-state"><div class="icon">🎤</div><p>Keine Künstler</p></div>';
return;
}
// Das Bild kommt direkt aus `coverArt`, das getArtists ohnehin mitliefert (Navidrome loest
// Kuenstlerbilder selbst auf: artist.*, album/artist.*, extern — und cacht sie). Frueher lief
// hier PRO KUENSTLER ein eigener getArtistInfo2-Aufruf nur um die Bild-Adresse zu erfahren:
// bei 200 Kuenstlern 200 API-Aufrufe auf einmal, die der Browser zu sechst abarbeitet, und
// jeder davon konnte Navidrome zu einer externen Abfrage zwingen. Ist coverArt leer, hat der
// Kuenstler schlicht kein Bild — dann sparen wir uns den Versuch ganz.
liste.forEach(a => {
const bild = a.coverArt ? coverUrl(a.coverArt, 300) : null;
const c = makeCard(dispArtistById(a.id, a.name), a.albumCount+' Alben', bild, '🎤');
c.classList.add('artist-card');
c.onclick = () => navigate('artist-detail', a);
g.appendChild(c);
getArtistImage(a.id).then(imgUrl => {
if(!imgUrl) return;
const artEl = c.querySelector('.card-art');
if(!artEl) return;
const overlay = artEl.querySelector('.play-overlay');
const img = document.createElement('img');
img.src = imgUrl; img.loading = 'lazy'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;transition:transform .4s';
artEl.innerHTML = ''; artEl.appendChild(img); if(overlay) artEl.appendChild(overlay);
c.addEventListener('mouseenter', () => img.style.transform = 'scale(1.05)');
c.addEventListener('mouseleave', () => img.style.transform = 'scale(1)');
});
});
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
/** IDs der markierten Künstler; kurz gecacht, damit das Umschalten der Chips flott bleibt. */
let _gefolgtCache = { ts: 0, ids: new Set() };
/** Nach einem Herz-Klick verwerfen — sonst zeigte der Filter bis zu 30 s den alten Stand. */
export function invalidateGefolgt() { _gefolgtCache = { ts: 0, ids: _gefolgtCache.ids }; }
async function _gefolgteIds() {
if (Date.now() - _gefolgtCache.ts < 30_000) return _gefolgtCache.ids;
try {
const r = await api('getStarred2');
_gefolgtCache = { ts: Date.now(), ids: new Set((r.starred2?.artist || []).map(a => a.id)) };
} catch { /* Filter bleibt beim letzten bekannten Stand */ }
return _gefolgtCache.ids;
}
export function filterArtists(val) {
const q = val.trim().toLowerCase();
document.querySelectorAll('#artists-grid .card').forEach(c => {
const name = (c.querySelector('.card-title')?.textContent || '').toLowerCase();
c.style.display = !q || name.includes(q) ? '' : 'none';
});
}
// ─── ALBUMS ───
let _albumSort = 'alphabeticalByName';
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more