diff --git a/CHANGELOG.md b/CHANGELOG.md index d2f8583..00c7c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,25 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d toter Zurück-Druck gleich zu Beginn. Vorwärts funktioniert unverändert (mitgetestet). ### Fixed +- **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, diff --git a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt index 47c830d..3f8535b 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt @@ -57,6 +57,8 @@ class NovaSonicApp : Application() { playerController = PlayerController(applicationContext, repository) playerController.connect() absPlayerController = AbsPlayerController(applicationContext) + // Angefangene Downloads weiterlaufen lassen (grosse Playlists brauchen viele Laeufe). + offlineRepository.setzeUnvollstaendigeFort() // Flush any plays queued while offline to the scrobble service. CoroutineScope(Dispatchers.IO + SupervisorJob()).launch { runCatching { repository.flushPlayLog() } diff --git a/android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt b/android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt index 8539870..687f2a7 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/offline/DownloadWorker.kt @@ -21,6 +21,10 @@ class DownloadWorker( companion object { const val EXTRA_GROUP_ID = "group_id" + private const val TAG = "DownloadWorker" + /** Obergrenze fuer selbst angeforderte Wiederholungen, damit dauerhaft kaputte Titel + * (auf dem Server geloescht, immer 404) die Kette nicht endlos weiterlaufen lassen. */ + private const val MAX_VERSUCHE = 20 } private val offlineRepo = applicationContext.app.offlineRepository @@ -39,7 +43,13 @@ class DownloadWorker( val dao = OfflineDatabase.get(applicationContext).offlineDao() return try { + var gestoppt = false for (songId in songIds) { + // WorkManager raeumt Hintergrundarbeit rund 10 Minuten ein und stoppt sie dann. + // Ohne diese Pruefung laeuft die Schleife blind weiter, bis sie hart abgebrochen + // wird — der angefangene Titel geht verloren und niemand startet neu. So steigen + // wir sauber aus und fordern den naechsten Lauf an; Fertiges wird uebersprungen. + if (isStopped) { gestoppt = true; break } if (offlineRepo.getTrackState(songId) == DownloadState.DONE) { done++ dao.updateGroupProgress(groupId, done) @@ -69,17 +79,39 @@ class DownloadWorker( offlineRepo.markDone(songId, audioFile.absolutePath, coverPath) done++ } catch (e: Exception) { - Log.e("DownloadWorker", "Failed to download song $songId", e) + Log.e(TAG, "Titel $songId fehlgeschlagen", e) audioFile.delete() offlineRepo.markFailed(songId) } dao.updateGroupProgress(groupId, done) } - Result.success() + + val offen = dao.offeneTitel(groupId) + when { + gestoppt -> { + Log.i(TAG, "Gestoppt bei $done/${songIds.size} — fordere naechsten Lauf an") + Result.retry() + } + offen == 0 -> { + Log.i(TAG, "Gruppe $groupId vollstaendig (${songIds.size} Titel)") + Result.success() + } + runAttemptCount < MAX_VERSUCHE -> { + Log.i(TAG, "$offen Titel offen (Versuch $runAttemptCount) — noch ein Lauf") + Result.retry() + } + else -> { + // Aufgeben, aber nicht stillschweigend: die Gruppenzeile zeigt weiterhin + // "x/y Titel", und ein Tipp auf Herunterladen startet die Kette neu. + Log.w(TAG, "$offen Titel bleiben offen — nach $runAttemptCount Versuchen aufgegeben") + Result.success() + } + } } catch (e: Exception) { - Log.e("DownloadWorker", "Worker failed for group $groupId", e) - Result.failure() + Log.e(TAG, "Lauf fuer Gruppe $groupId abgebrochen", e) + // Frueher Result.failure(): ein einzelner Netzfehler beendete die ganze Kette endgueltig. + if (runAttemptCount < MAX_VERSUCHE) Result.retry() else Result.failure() } } 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 bdaa0cb..9d4687b 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,10 @@ interface OfflineDao { @Query("SELECT * FROM download_groups WHERE groupId = :groupId") suspend fun getGroup(groupId: String): DownloadGroupEntity? + /** 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 + @Query("DELETE FROM download_groups WHERE groupId = :groupId") suspend fun deleteGroup(groupId: String) @@ -84,6 +88,14 @@ interface OfflineDao { """) fun tracksForGroupFlow(groupId: String): Flow> + /** Wie viele Titel der Gruppe noch nicht fertig sind — eine Abfrage statt einer pro Titel. */ + @Query(""" + SELECT COUNT(*) FROM downloaded_tracks t + INNER JOIN group_track_cross_refs r ON t.songId = r.songId + WHERE r.groupId = :groupId AND t.state != 'DONE' + """) + suspend fun offeneTitel(groupId: String): Int + @Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId") suspend fun removeCrossRef(groupId: String, songId: String) 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 6d2fb22..6b39a43 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 @@ -154,6 +154,14 @@ class OfflineRepository(context: Context) { } dao.updateGroupProgress(groupId, alreadyDone) + starteWorker(groupId) + } + + /** + * Startet (oder setzt fort) die Downloadarbeit einer Gruppe. KEEP heisst: laeuft schon etwas + * fuer diese Gruppe, passiert nichts — sonst beginnt ein Lauf, der alles Fertige ueberspringt. + */ + private fun starteWorker(groupId: String) { val request = OneTimeWorkRequestBuilder() .setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId)) .setConstraints( @@ -162,12 +170,33 @@ class OfflineRepository(context: Context) { .setRequiresStorageNotLow(true) .build() ) + // Ohne Backoff wuerde ein Result.retry() sofort erneut anlaufen; 30 s linear geben dem + // Geraet Luft und halten die Kette trotzdem in Gang. + .setBackoffCriteria(BackoffPolicy.LINEAR, 30, java.util.concurrent.TimeUnit.SECONDS) .build() WorkManager.getInstance(appContext) .enqueueUniqueWork(groupId, ExistingWorkPolicy.KEEP, request) } + /** + * Beim App-Start unvollstaendige Gruppen weiterlaufen lassen. WorkManager stoppt einen + * Hintergrund-Worker nach rund 10 Minuten; eine Playlist mit 1900 Titeln braucht daher viele + * Laeufe. Der Worker fordert selbst einen neuen an (Result.retry()), aber wenn die Kette doch + * einmal reisst — Neustart des Geraets, aufgebrauchte Versuche — faengt das hier es auf, statt + * darauf zu warten, dass jemand von Hand erneut auf Herunterladen tippt. + */ + fun setzeUnvollstaendigeFort() { + scope.launch { + val offen = runCatching { dao.incompleteGroups() }.getOrDefault(emptyList()) + offen.forEach { g -> + Log.i("OfflineRepository", + "Setze fort: ${g.groupId} (${g.downloadedTracks}/${g.totalTracks})") + starteWorker(g.groupId) + } + } + } + // ── Delete with reference-count file cleanup ────────────────────────────── /** Removes a group and any tracks that are no longer referenced by another group. */