# Changelog 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. --- ## Platform Parity Matrix | Feature | Web (PWA) | Android | |----------------------------------|:---------:|:-------:| | 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 | ✅ | ✅ | | Star / favorite song | ✅ | ✅ | | Multi-artist navigation | ✅ | ✅ | | Album detail page | ✅ | ✅ | | Artist page (Navidrome) | ✅ | ✅ | | Artist page (Deezer fallback) | ✅ | ✅ | | Global search source | Deezer | Deezer | | Same-name artist disambiguation | ✅ | ✅ | | Artist fan count shown | ✅ | ✅ | | 30s preview control bar | ✅ | ✅ | | Concert events (Ticketmaster) | ✅ | ✅ | | Genre browsing | ✅ | ✅ | | Global search | ✅ | ✅ | | 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 | ✅ | ✅ | | Import notification → nav | ✅ | ✅ | | ABS (Audiobookshelf) playback | ✅ | ✅ | | ABS chapter navigation | ✅ | ✅ | | ABS sleep timer | ✅ | ✅ | | ABS playback speed | ✅ | ✅ | | ABS library search | ✅ | ✅ | | ABS book descriptions (HTML) | ✅ | ✅ | | Media notification | ✅ | ✅ | | ABS notification (modern style) | ❌ | ✅ | | Android Auto | — | ✅ | | Offline download (albums/playlists) | ❌ | ✅ | | Downloads nur über WLAN | — | ✅ | | Offline download (single songs) | ❌ | ✅ | | Offline playback | ❌ | ✅ | | Offline detail / manage downloads | ❌ | ✅ | | Player bar on all screens | ✅ | ✅ | | Timestamped play-log (own scrobble) | ✅ | ✅ | | Listening statistics view | ✅ | ✅ | | Stats cover art (artist/album) | ✅ | ✅ | | Musik-Match (Head-to-Head) | ✅ | ✅ | | Yearly Wrapped | 🔜 | 🔜 | | Romaji / Japanese sort | ✅ | ❌ | | Settings (Navidrome, ABS, etc.) | ✅ | ✅ | --- ## [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=`. 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 `, 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=` 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, like Android), concert events become **reliable across web/Android**, several Android gaps close, and **YouTube downloads are recovered** after they broke on two independent fronts. ### Added - **In-app updater (Android)** — on launch the app asks the backend (`/api/apk/info`) whether a newer **versionCode** than this build is available and, if so, offers "Laden & installieren": it downloads the APK from `/api/apk` (which proxies the fixed Gitea release) into a single cache file, then hands it to the system installer. No APK pile-up (the cache file is wiped before each download); an update keeps app data / offline downloads (same package + signature = in-place install). The user still confirms the system install and grants "unknown apps" once — Android requires that for sideloading. `publish-apk` now **auto-increments `versionCode`** each build and writes it into the release so the check is a reliable integer compare (commit the bumped `build.gradle.kts`). - **Android APK download in Settings (Web)** — a "🤖 Android App" section in Settings offers the latest APK so co-users self-serve instead of the maintainer hand-delivering builds. The button appears only when an APK is available and shows version/size/date. The backend serves it (`GET /api/apk`, metadata at `/api/apk/info`) from one of two sources, in order: 1. **A fixed Gitea release** (tag `apk-latest`) whose `.apk` asset you overwrite each build — constant ~30 MB, uploadable from anywhere. The backend fetches it **server-side** over the LAN (`GITEA_URL`/`GITEA_REPO`/`GITEA_TOKEN`/`GITEA_APK_TAG` env), so Gitea can stay internal-only and the token never reaches the browser. Release *assets* live in Gitea's attachment store, not the git history, so `clone`/`pull` stay small. 2. **Local fallback** — `docker cp app-release.apk novasonic-backend:/data/novasonic.apk` (optional `/data/novasonic.version.txt`). The Android *source* is already in the repo; only build artifacts stay out of git. - **Unified Global Search field (Web)** — the separate Song-Importer page/sidebar entry is gone. The one search field now auto-detects: a **link** (Spotify/SoundCloud/YouTube) opens the importer preview, plain **text** runs a Deezer search. Cleaner left nav, same behaviour as Android. - **30s preview in the importer (Android)** — each resolved track now has a ▶/⏹ button that plays its Deezer/Spotify preview clip through the same app-level preview bar as Explore (library playback pauses; the row highlights while playing). Parity with the web importer. - **Scroll & search position remembered on Back (Web + Android)** — going into an album/artist and back now returns you to the same scroll spot with your typed query still there, instead of jumping to the top. Web: each history entry's scroll (and the open local/header search) is snapshotted and restored on Back/Forward; a genuine new navigation still starts at the top. Android: the Alben / Künstler lists and the global/local search-results scroll are hoisted into their ViewModels, so they survive a drill-in/back for as long as you're in that tab. - **Partial album — Play / Shuffle + track menu (Android)** — an incomplete album's downloaded tracks now have **Abspielen** and **Zufall** buttons and a per-row 3-dot menu, so you can still play/shuffle what you have while the rest downloads. - **Swipe between tabs (Android)** — a left/right swipe now switches tabs in **Now Playing** (Player ↔ Lyrics ↔ Queue) and in the **Bibliothek** (Playlists ↔ Alben ↔ Künstler ↔ Offline), two-way synced with the tab bar, matching the existing Erkunden swipe. On Now Playing the cover's own swipe (skip track) still wins, since it's a child gesture. ### Fixed - **Concert events reliability (Web + Android)** — Ticketmaster's free `keyword` search intermittently returns a partial subset (or none), so back-to-back calls disagreed (web showed 7, a later live call got only 2, Android sometimes showed 0). The web had a private 30-min cache masking this; the cache now lives **on the backend** and **prefers the larger recent result**, so a flaky response can't shrink a good list and every client stays consistent (1 h TTL self-heals). - **Concert date/time (Web + Android)** — shows the **venue-local** time (Ticketmaster's `localDate`/ `localTime`) instead of the UTC instant, so a 20:00 show no longer displayed as 08:00. Android parses the naive local datetime (was crashing on zone-less values → missing times). - **Concert rows uneven (Android)** — rebuilt each row with a fixed-width calendar-style date block (day big, month/year + time small), so every row is the same height and the divider/venue align. - **Concert load failures were silent (Android)** — the fetch swallowed errors and showed an empty list. Errors now surface ("Konzerte konnten nicht geladen werden" + reason), with a request timeout and non-2xx treated as a real failure. - **Web importer preview layout** — the SoundCloud track row was missing its grid number-cell, so the 4-column `.gtrack-item` grid squeezed the cover into 28px and the title into 52px (title wrapped one character per line). Added the missing cell. - **Header search dropdown covered the sidebar (Web)** — `#header-search-overlay` was `position:fixed; left:0; right:0` (full viewport), overlapping the left sidebar. It's now anchored to the search bar (`position:absolute` inside the wrap), so it drops down at the bar's width and the sidebar stays visible. - **Android artist albums incomplete** — `lookupArtistAlbums` filtered to `record_type == "album"`, dropping EPs and singles that Deezer (and the web app) show. Now returns all, sorted album → EP → single. - **Japanese/CJK albums vanished from the artist discography (Web + Android)** — the album-name normaliser stripped everything except `a-z0-9`, so any Japanese/CJK title collapsed to an empty string. All of an artist's JP singles then normalised identically (`""`), so they de-duplicated into one entry and even "matched" each other — e.g. inabakumori (稲葉曇) showed only 2 of 9 releases, the rest hidden behind one album. The normaliser now keeps Unicode letters/numbers (`\p{L}\p{N}`), so JP titles stay distinct and the full discography (In Library / Unvollständig / Noch nicht heruntergeladen) shows correctly. Same fix applied to the partial-album track match and the Deezer-artist name match. - **Android "Unvollständig" (partial albums) never showed** — Deezer's `/artist/{id}/albums` omits `nb_tracks`, so an owned album's real total was unknown (0) and it never got flagged incomplete; every partly-downloaded album looked complete. The artist VM now resolves the real track count for **matched (owned)** albums via a per-album lookup (like web), so the "Unvollständig" section — with the downloaded/total badge and the downloaded-vs-missing split screen — populates correctly on both the library and Deezer artist pages. - **"Falscher Künstler?" pin button minimised (Web + Android)** — reduced to a small ⚙ icon-only pill in the artist hero's corner instead of a full-width labelled button. - **Downloads from search/Bibliothek were silent (Android)** — a download started from the global search / Bibliothek-Global tab fired only a transient toast and then registered no success/failure notification (only the importer and artist page did). It now goes through the same app-lifetime `NotificationStore` poll, so it notifies on completion even after you leave the screen. - **YouTube downloads (HTTP 403 + "Signature solving failed")** — two things had broken downloads: 1. Recent yt-dlp needs an **external JavaScript runtime** to solve YouTube's signature/nsig challenge — without it, formats were skipped and the media 403'd. The backend now installs **Deno**, which yt-dlp uses automatically. 2. A bug in the current yt-dlp **stable** 403'd the `android_vr` client on *every* video ([yt-dlp#17456](https://github.com/yt-dlp/yt-dlp/issues/17456), fixed in nightly). The backend now pulls yt-dlp from the **nightly** channel (`pip install --pre 'yt-dlp[default]'`). No cookies, PO-token service, or proxy needed — the earlier 403 that looked like an IP block was this stable bug. --- ## [2.1] — 2026-08-14 Builds on the 2.0 listening archive with a **head-to-head "Musik-Match"**, brings the **artist pin** to Android at parity with web, and fixes a batch of same-name-artist and player rough edges. ### Added - **Musik-Match (Head-to-Head) — Web + Android** — compare two users' listening taste from the shared play-log. New scrobble routes `GET /scrobble/users` (partner picker) and `GET /scrobble/compare?with=` — the match score is the **cosine of the two users' artist-play vectors** (0–100). Shows shared artists (mirrored bars), shared tracks, and each side's unique top artists. Opened from Settings ("🎧 Musik-Match"). Only aggregates are exposed, never one user's raw history. - **Artist pin on Android (parity with web)** — read AND write the shared server-side pins (`/api/artist-overrides`) that fix same-name-artist collisions. A "⚙ Falscher Künstler?/Gepinnt" pill on the artist hero opens a picker (Deezer search + a **paste-a-Deezer-link/id** route, the reliable way when `/search/artist` can't surface a low-profile same-name artist); when pinned, the page enriches straight from the pinned Deezer id (albums / top tracks / fans / image). Pins set on either platform apply on both. - **Swipe between Erkunden sub-tabs (Android)** — a left/right swipe switches Bibliothek ↔ Global, two-way synced with the tab bar (HorizontalPager). - **Similar Artists with profile pictures (Web)** — the artist page's "Ähnliche Künstler" now render as avatar chips, matching the Android app (previously plain text). ### Fixed - **A failed direct-link download reported success** — the direct-URL path returned `success` even when no audio was produced (e.g. a YouTube 403), so the job showed "Fertig!" and the UI (web + Android) said the track was added. It now reports failure with a clear message. - **Artist pin — search & similar artists (Web + Android)** — a pinned artist now shows the correct Deezer photo in library search (not the wrong same-name person's), and its "Similar Artists" use Deezer's **related** for the pinned artist instead of Last.fm's list (which was keyed to the merged/wrong Navidrome artist and went "weird"). - **Android player — seek broke after seamless track transitions** — skipping through already-buffered tracks left `durationMs` stuck at 0 (the player stayed READY, so `syncState()` wasn't re-called and `ctrl.duration` was still `TIME_UNSET`), so the seek bar snapped playback to the start (or to a stale offset). `durationMs` now self-heals via the ticker + a fallback to the song's known duration. No app restart needed. - **Erkunden swipe polish (Android)** — pre-compose the neighbour page so the first swipe doesn't hitch, drive the tab indicator off the pager so it moves with the swipe, and keep the Top-Alben charts anchored to the top (were vertically centered). - **Web** — the pin picker crashed (`esc()` on a numeric Deezer id) and the pin button moved to the banner's top-right; pinning by a pasted Deezer link/id was added. - **Android** — Settings spacing: the gap now sits below "Musik-Match", grouping it with "Meine Statistiken". --- ## [2.0] — 2026-08-07 Big change: **NovaSonic gets its own self-hosted, multi-user listening archive.** Navidrome only stores a play *count* and the *last* play date — no timestamped history — so per-day/month/year stats and a yearly "Wrapped" were impossible. Koito and Spindle (the off-the-shelf options) are effectively single-user. Since NovaSonic *is* the player, it now records every counted play into an **isolated scrobble service** with its own database, and shows the results in a **statistics view on web and Android**. This runs **in addition to** the existing Navidrome/Last.fm scrobble — it never replaces it. ### Added - **New `novasonic-scrobble` service** — a small, isolated container (`node:22-slim` + built-in `node:sqlite`, own volume) that owns the play-log. Kept separate from the download backend on purpose: the backend is heavy (yt-dlp/ffmpeg) and restarts regularly, while an archive needs uptime + data integrity. Routes: - `POST /scrobble/plays` — batch ingest, **idempotent by uuid** (safe offline-retry) - `GET /scrobble/history` — raw chronological log (the archive) - `GET /scrobble/stats` — totals, top artists/albums/tracks, day/month/year series, 24h listening clock (on-the-fly `GROUP BY strftime`, local-tz bucketing) - `DELETE /scrobble/plays?user=` — purge one user's rows (test cleanup) - `GET /scrobble/health` — liveness - **Multi-user**: every request carries the same Subsonic token creds the app already uses; the service verifies them via Navidrome `ping.view` (cached), so the stored `user` is real, not spoofable. No new login. - **Play capture (Web + Android)** — fires at the *existing* counted-play moment (respects the user's `scrobbleMode`, consistent with Last.fm), with an **offline-safe queue** (localStorage on web, a JSON file on Android) flushed as an idempotent batch on start and when connectivity returns. - **Statistics view (Web + Android)** — opened from Settings ("📊 Meine Statistiken"): totals tiles (**Plays** + listening time), a day/month/year play series, top artists/tracks/albums, and a 24h listening clock, with period tabs (Alles / Dieses Jahr / Letzte 30 Tage). - **Cover art in stats (Web + Android)** — top artists show a round artist image, tracks/albums a square cover, resolved client-side from Navidrome (the scrobble service stays cover-agnostic). - **`SCROBBLE_URL`** environment variable (frontend) + a **Scrobble URL** field with a test button in Settings on both platforms. ### Changed - **Backend** — base image bumped `node:20-slim` → **`node:22-slim`** (Node 20 reaches end-of-life April 2026; enables the built-in `node:sqlite`). ### Fixed - **Downloads** — remix / live / acoustic / instrumental tracks were skipped as duplicates of the base track: duplicate detection stripped *all* bracketed content, collapsing "Song (Remix)" → "Song". Version-marker brackets are now preserved, so a distinct recording downloads (noise brackets like `(feat. X)` / `(Official Video)` are still stripped). Verified against Deezer + unit-tested both directions. - **UI** — dragging the mouse near a field edge (Global search, importer) started selecting the surrounding titles/labels instead of feeling like an app. `user-select` is now disabled on the `#app` shell; real text fields and lyrics stay selectable. ### Notes - The scrobble subdomain **must be excluded from any SSO / forward-auth gateway** (Authelia/Authentik/etc.), exactly like the download backend — it self-authenticates via `ping.view`, and a gateway in front 302-redirects the browser's API calls to the login portal (breaks `fetch`). - Deploy: add `SCROBBLE_URL` to `.env`, `docker compose up -d novasonic-scrobble`, and route port **3710** through your reverse proxy. - Planned next: a yearly **Wrapped** (`/scrobble/wrapped` + story page) and an optional **ListenBrainz relay** — not in this release. --- ## [1.3] — 2026-07-31 Big change: **the entire metadata/search stack moved from Apple's iTunes to Deezer** — on web, backend downloads *and* Android. Apple's iTunes `/search` returns **403 from server/datacenter IPs**, which had broken global search, charts, artist/album/single downloads and the metadata-editor autofill in production. Deezer is free, needs no key, allows more requests, and returns explicit versions + real artist images + track durations. Web and Android now share the **same** source, so an artist resolves to one consistent name everywhere. ### Changed - **Web + Android + Backend** — Global search, album/artist lookups, artist top tracks and the "Neu & Beliebt" charts all run on **Deezer** (`api.deezer.com`). Results are mapped to the existing item shape, so the UI is unchanged; artist cards now show real photos (iTunes artist entities had none) - **Backend (download)** — Single-track, album and **artist** downloads, plus the metadata-editor autofill, now enrich from Deezer: - `backend/lib/deezer.js` — new module: `lookupTrackMeta`, `fetchAlbumTracks`, `searchAlbumTracks`, `fetchArtistAlbums` - Album/artist `album_artist` still comes from the album-level artist (Deezer `album.artist`), keeping soundtracks from splitting - Genre comes from the Deezer album (one album-level genre); per-track artist is preserved for mixed-artist albums - The autofill endpoint moved `/api/itunes/lookup` → **`/api/meta/lookup`** - **Web + Android** — All user-visible "iTunes" / "Apple Music" labels are now "Deezer" (search badge, charts badge, preview bar, editor autofill button, artist "populär" heading) - **Backend** — Removed the dead iTunes `/search` proxy and the Apple charts RSS proxy; the Deezer proxy (`/api/deezer/*`) now also serves charts and the editor lookup - **Web** — Renamed the leftover `itunes*` frontend identifiers to `deezer*` (`itunesSearch` → `deezerSearch`, `itunesLookup*` → `deezerLookup*`, `itunesData`/`itunesArtistId` → `deezerData`/`deezerArtistId`, the `itunes-album-detail` route/DOM ids, the `badge-itunes` CSS class, …). Purely internal — no behaviour change; only the factual "Apple's iTunes /search 403" comments keep the old name ### Added - **Web + Android** — **Same-name artist disambiguation**: Deezer's artist search isn't ranked by popularity, so a name collision (5 artists named "Joji") returned a niche one first — the artist page then loaded the wrong discography and owned songs didn't show as downloaded. Artists are now sorted by Deezer fan count, and on a local artist page (web) with a real collision the pick is tie-broken by **song overlap** with your library (each artist has different songs). The **fan count is shown** on search artist cards and on both the Deezer *and* local Navidrome artist pages (web + Android) - **Android** — **Global 30s-preview control bar**: a preview started on an album/artist/Explore screen used to keep playing with no way to stop it once you navigated away (the local artist page had its *own* uncontrollable preview too). All previews now run through one app-level player with a bar (above the mini player, on every screen) to pause/resume/stop it; starting real playback cancels the preview - **Backend** — Unit tests for the Deezer mapping/matching logic (`backend/test/deezer.test.js`, run with `npm test`), incl. per-track numbering, mixed-artist album-artist stability, explicit flags and the Japanese-title match guard. A live smoke test runs with `RUN_LIVE=1` ### Fixed - **Android** — After **tabbing out and back into the app** with NowPlaying (or an ABS player) open, pressing back first popped the NavHost **invisibly to Home** and only *then* dismissed the overlay — the focus was lost. Returning from the background re-registers the NavController's own back callback on top of the dispatcher, stealing back-priority from the overlays. The overlay back-callbacks are now re-asserted on `ON_RESUME` (and on navigation), so back closes the overlay first again - **Web + Android** — A finished-download **notification led to the wrong song**: it resolved the album by name via `search3`, and when the exact artist match wasn't found (e.g. the new album isn't scanned yet), it fell back to a same-named album by a **different** artist (or the first result). Several artists share an album title ("I LIKE IT!"), so tapping the notification opened a completely different song. Now the artist must match when known (fuzzy for multi-artist/album-artist drift); otherwise it shows "wird evtl. gerade gescannt" instead of opening a wrong album - **Web** — Downloading a not-yet-owned album from an artist page searched YouTube for "**Unknown Artist** <album>" and failed. Deezer's `/artist/{id}/albums` omits the `artist` object, so the discography cards had an empty artist name → the modal fell back to the literal `"Unknown Artist"`. Fixed by backfilling the known artist name in `deezerLookupArtistAlbums` and defaulting the download modal to **Album** for album cards (was always **Track**) - **Web + Android** — Owned albums were wrongly shown as **"Unvollständig 1 / 0 Tracks"** on the artist page. Deezer's `/artist/{id}/albums` list omits `nb_tracks`, so the total read as 0 and every matched album looked incomplete. Web now resolves the real track count from the Deezer album endpoint for **matched (owned) albums only** and treats an unknown total as complete; Android treats an unknown total as complete (never a false partial) ### Notes - Deploy: `git pull`, then rebuild the frontend (`docker compose up -d --build novasonic-frontend`) and restart the backend (`docker compose restart novasonic-backend`). Service worker bumped to `ns-v54` - Titles a user picked stay as chosen; only derived fields (album, album-artist, genre, year, artwork) follow Deezer --- ## [1.2] — 2026-07-25 Follow-up to 1.1: web history navigation, correct download tagging for mixed-artist albums, and UI polish. ### Added - **Android** — Directional bottom-nav tab transitions: the slide follows the tapped tab's position (a tab to the right slides in from the right, one to the left from the left) instead of a fixed, sometimes-random-feeling direction. Applied across all four enter/exit/pop transitions; detail-screen push/pop is unchanged - **Web** — Pressing **Enter** in the importer link field starts the import (same as the Importieren button) ### Fixed - **Web** — Browser / mouse **Forward** navigation now works (previously only Back did): `popstate` applies the target history entry (`nsIdx`) for both directions, and the back handler no longer `pushState()`s over the browser's forward stack. In-app back buttons route through `history.back()` so the browser and app stacks stay in sync - **Web** — Deleting a song or playlist refreshes the current view immediately (drops the cached list) instead of leaving the removed item visible for up to ~3 minutes - **Web** — Album detail header showed the **first track's** artist (e.g. "TWICE" on a soundtrack) instead of the album artist; it now reads the album's own album-artist fields from `getAlbum` (matches the Android app) - **Web** — Metadata autofill (iTunes / MusicBrainz) normalizes the trailing `" & "` in a multi-artist credit ("A, B & C" → "A; B; C"), but only when the value is already a list, so band names like "Simon & Garfunkel" stay intact - **Android** — Erkunden (Apple) overlay back no longer *sometimes* jumped to Home: the root-level back callback is re-asserted on every navigation, so it can't lose back-priority to the NavHost after an album drill-down or tab switch - **Backend (download)** — Mixed-artist albums search **per track**: the YTM/YouTube query and channel filter use each track's own performer, so soundtrack tracks by other artists (TWICE's "Strategy", MeloMance's "Love, Maybe") are found instead of failing with "Nicht eindeutig gefunden" - **Backend (download)** — `album_artist` is derived from iTunes' album-level `collectionArtistName` (identical across the album) instead of the request's artist — which could be the lead single's performer, so a whole KPop soundtrack was tagged `album_artist = TWICE`. Applies to album and artist downloads; each track still gets its own per-track artist tag - **Backend (download)** — Official audio is matched by yt-dlp's `artist` metadata, not just the channel name, so uploads on label channels (BANGTANTV, HYBE, "… - Topic") win over fan reuploads — e.g. BTS "Pied Piper" now grabs the official track. (`channel_is_verified` was rejected as a signal: lyric videos and fan MVs are also on verified channels) - **Backend (download)** — Western artists are no longer tagged with the Japanese store's katakana album-artist ("DJスネイク" for DJ Snake): the JP-first iTunes lookup now keeps Japanese names only for Japanese songs and uses the US/English names otherwise (affects the `album_artist` tag and the on-disk folder) - **Backend (download)** — Release year is back-filled from iTunes on single-track downloads where the source omitted it (the year tag was previously left empty) ### Changed - **Web + Android** — Playing a song from search results queues only that song; Radio mode (if on) fills the rest — closer to Spotify. Album / playlist play still queues the whole list - **Android** — NowPlaying track info is ordered title → album → artists, matching the web player - **Web** — NowPlaying favorite star is vertically centered with the whole title / album / artist block instead of pinned to the title line --- ## [1.1] — 2026-07-19 Polish release: Android navigation and offline resilience, download accuracy for artists with stylised names, and a fix for the lyrics outage caused by lrclib rate-limiting. ### Added - **Android** — Mini player is now visible on every screen (album, artist, playlist detail), not only the top-level tabs; the tab bar stays top-level-only - **Android** — Song search results (local library) got the 3-dot context menu: play, insert next / queue, add to playlist, favorite, go to album, song info, download - **Android** — Artist images in the Apple-Music (Erkunden) search: iTunes artist entities carry no artwork at all, so each artist now borrows the cover of one of their albums; a person icon replaces the empty grey circle where none is found - **Web + Android** — Playlist cover art is shown in the "add to playlist" picker, so playlists are identifiable at a glance - **Web** — "Now playing" scrobble is sent at track start (`scrobble?submission=false`), so Navidrome shows the current track while it plays instead of only on completion ### Fixed - **Web + Android** — Lyrics stopped loading entirely: a burst of requests (prefetch + the 4-request cascade + fast skipping) got the client IP throttled by lrclib, so every lookup hung until it timed out. Fixed with per-song in-flight de-duplication, a prefetch debounced to 1.5 s after track start, a descriptive User-Agent, and a longer 8 s timeout - **Android** — App lag and stutter while scrolling and switching tabs: `coverArtUrl()` and `streamUrl()` built a fresh Ktor `HttpClient` for every cover-art request and never closed it — heavy allocation and GC churn. Both now reuse the cached client - **Android** — Star / favorite gave no visual feedback: a global `StarState` now flips the icon optimistically the moment it's tapped (everywhere it appears) and rolls back if the API call fails - **Android** — Back from an Erkunden (Apple) artist or album jumped straight to Home. Those screens are in-screen overlays on the `erkunden` destination and their `BackHandler` lost back priority to the NavHost, which popped the whole tab. Back is handled at the app root now: album → artist, artist → search - **Android** — The Erkunden artist's "show all albums" view was lost when drilling into an album and back (it lived in local `remember` state); the open section now lives in the ViewModel and survives both route and overlay round-trips - **Android** — Album back-navigation from a local artist returned to the collapsed artist page instead of the "all albums" view it was opened from - **Android** — The mini player sat behind the phone's gesture/navigation bar on detail screens, where there is no tab bar to absorb the system inset - **Android** — The download sheet for new songs and the song context sheet opened only half-expanded, hiding the download button behind the system navigation bar until dragged up; both open fully now - **Android** — The Offline tab was blocked by the online library's loading spinner, so downloads were unreachable while the library loaded or the server was unreachable. It reads from the local Room DB and now loads independently - **Backend (download)** — Artist lookups ignored leading aesthetic punctuation (e.g. `.diedlonely`), so the iTunes match missed, fell through to a title-only match, and grabbed an unrelated same-titled track — whose wrong duration then also misled the YouTube search filter. Leading non-alphanumeric characters are trimmed before lookup - **Backend (metadata)** — The metadata editor showed "[Unknown Album]" where the library shows "Singles": tracks downloaded without an album had no album tag written at all. The album tag now always gets a value (defaulting to `Singles`), and file resolution falls back to `findExistingTrack` for stale or rebased paths - **Web** — Now Playing showed a stale cover after rapid skips - **Web** — Home row scroll arrows caused layout thrash while scrolling: arrow visibility is a rAF-throttled class toggle now, with `getBoundingClientRect()` only on resize / content change ### Changed - **Android** — Favorite is a heart everywhere; the Now Playing screen still used a star - **Android** — Playlist detail actions are uniform, equal-width and icon-only (play / shuffle / edit / download). Since the offline-download button was added, four buttons shared one row and the Play/Shuffle labels were being clipped away - **Android** — The "add to playlist" search field matches the styling of the other search bars - **Web** — Track switching uses a gapless hand-off between two `