diff --git a/CHANGELOG.md b/CHANGELOG.md index 915134a..54e0c12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,8 +77,9 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d ## [2.6] — 2026-09-12 -**Android:** 1.9 (versionCode 12) → 1.11 (versionCode 14). 1.9 brachte den Besitzer geteilter -Playlists und die Offline-Korrekturen, 1.10 den Zählfehler im Downloader, 1.11 den WLAN-Schalter. +**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 @@ -110,6 +111,23 @@ Playlists und die Offline-Korrekturen, 1.10 den Zählfehler im Downloader, 1.11 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 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3a12964..9939316 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.novasonic.app" minSdk = 26 targetSdk = 35 - versionCode = 14 - versionName = "1.11" + versionCode = 15 + versionName = "1.12" // Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in // gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der // Platzhalter stehen und der App-Link greift schlicht nicht. diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt index 9d4687b..44d448f 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineDao.kt @@ -53,6 +53,22 @@ interface OfflineDao { @Query("SELECT * FROM download_groups WHERE groupId = :groupId") suspend fun getGroup(groupId: String): DownloadGroupEntity? + /** + * Rechnet den gespeicherten Fortschritt ALLER Gruppen aus dem tatsaechlichen Zustand der Titel + * neu. Noetig, weil der Worker bis 1.10 doppelt zaehlte: der Wert konnte totalTracks + * uebersteigen, die Gruppe galt als "Vollstaendig", der Herunterladen-Knopf wurde unantastbar + * und das Fortsetzen uebersprang sie — der falsche Wert konnte sich also nie mehr korrigieren. + * Eine Anweisung fuer alle Gruppen, laeuft beim App-Start. + */ + @Query(""" + UPDATE download_groups SET downloadedTracks = ( + SELECT COUNT(*) FROM group_track_cross_refs r + INNER JOIN downloaded_tracks t ON t.songId = r.songId + WHERE r.groupId = download_groups.groupId AND t.state = 'DONE' + ) + """) + suspend fun fortschrittNeuBerechnen() + /** Gruppen, bei denen noch Titel fehlen — Grundlage fuers Fortsetzen beim App-Start. */ @Query("SELECT * FROM download_groups WHERE totalTracks > 0 AND downloadedTracks < totalTracks") suspend fun incompleteGroups(): List @@ -99,6 +115,10 @@ interface OfflineDao { @Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId") suspend fun removeCrossRef(groupId: String, songId: String) + /** Wie viele Titel dieses Cover noch benutzen — Cover werden geteilt gespeichert. */ + @Query("SELECT COUNT(*) FROM downloaded_tracks WHERE coverArt = :coverArtId") + suspend fun titelMitCover(coverArtId: String): Int + /** Find a locally-stored cover path by Navidrome coverArt ID (deduped storage). */ @Query("SELECT coverPath FROM downloaded_tracks WHERE coverArt = :coverArtId AND state = 'DONE' AND coverPath IS NOT NULL LIMIT 1") suspend fun localCoverPathByCoverArt(coverArtId: String): String? diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt index c0b7563..4a75000 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/offline/OfflineRepository.kt @@ -211,6 +211,10 @@ class OfflineRepository(context: Context) { */ fun setzeUnvollstaendigeFort() { scope.launch { + // Erst den gespeicherten Fortschritt begradigen: Gruppen mit einem zu hohen Wert aus + // der Zeit vor 1.10 sehen sonst vollstaendig aus und werden hier gar nicht gefunden. + runCatching { dao.fortschrittNeuBerechnen() } + .onFailure { Log.w("OfflineRepository", "Fortschritt neu berechnen fehlgeschlagen", it) } val offen = runCatching { dao.incompleteGroups() }.getOrDefault(emptyList()) offen.forEach { g -> Log.i("OfflineRepository", @@ -220,27 +224,47 @@ class OfflineRepository(context: Context) { } } + /** + * Entfernt alle Dateien eines Titels und dann die Datenbankzeile. + * + * Frueher lief das ueber _audioMap — die kennt aber nur FERTIGE Titel. Ein abgebrochener oder + * fehlgeschlagener Download hinterliess seine Datei also unberuehrt. Jetzt kommt der Pfad aus + * der Zeile selbst, dazu die angefangene .tmp und das Cover, sofern es kein anderer Titel mehr + * benutzt (Cover werden geteilt gespeichert). + */ + private suspend fun loescheTitelVollstaendig(songId: String) { + val track = dao.getTrackById(songId) + val pfade = listOfNotNull(track?.localPath, _audioMap.value[songId]) + pfade.distinct().forEach { p -> + runCatching { File(p).delete(); File("$p.tmp").delete() } + } + track?.coverArt?.let { coverArtId -> + // Diese Zeile zaehlt noch mit, deshalb ist 1 der letzte Benutzer. + if (dao.titelMitCover(coverArtId) <= 1) { + runCatching { File(coversDir(), "$coverArtId.jpg").delete() } + track.coverPath?.let { runCatching { File(it).delete() } } + } + } + dao.deleteTrackById(songId) + } + // ── Delete with reference-count file cleanup ────────────────────────────── /** Removes a group and any tracks that are no longer referenced by another group. */ suspend fun deleteGroup(groupId: String) { val songIds = dao.songIdsForGroup(groupId) + // Laufende Downloadarbeit stoppen, sonst laedt sie noch Minuten weiter ins Leere. + WorkManager.getInstance(appContext).cancelUniqueWork(groupId) dao.deleteGroup(groupId) // cascades GroupTrackCrossRef rows for (songId in songIds) { - if (dao.groupCountForSong(songId) == 0) { - _audioMap.value[songId]?.let { File(it).delete() } - dao.deleteTrackById(songId) - } + if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId) } } /** Removes a single track from a group, deleting the file if no other group still needs it. */ suspend fun removeSongFromGroup(groupId: String, songId: String) { dao.removeCrossRef(groupId, songId) - if (dao.groupCountForSong(songId) == 0) { - _audioMap.value[songId]?.let { File(it).delete() } - dao.deleteTrackById(songId) - } + if (dao.groupCountForSong(songId) == 0) loescheTitelVollstaendig(songId) recountGroup(groupId) }