fix(android): große Offline-Downloads laufen jetzt bis zum Ende
WorkManager stoppt einen Hintergrund-Worker nach ~10 Minuten (ein Vordergrunddienst scheidet aus, FOREGROUND_SERVICE_TYPE_DATA_SYNC stürzt unter Android 15 ab). Der Worker prüfte nie isStopped, lief blind weiter und meldete Result.failure() ohne Backoff — WorkManager startete nichts neu, bei 1900 Titeln musste man wiederholt von Hand antippen. Jetzt: sauberer Ausstieg bei isStopped mit Result.retry() und linearem Backoff, Selbst-Fortsetzung solange Titel offen sind (max. 20 Versuche, damit dauerhaft kaputte Titel keine Endlosschleife auslösen), Retry statt Failure im äußeren catch, und beim App-Start werden unvollständige Gruppen weitergeführt. Dazu: die Zahl offener Titel kam aus einer Abfrage pro Titel am Ende jedes Laufs — jetzt ein einzelnes COUNT mit JOIN. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
35e36067af
commit
8bc03da88e
5 files changed
+97
-3
No files matched your search
@@ -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).
|
toter Zurück-Druck gleich zu Beginn. Vorwärts funktioniert unverändert (mitgetestet).
|
||||||
|
|
||||||
### Fixed
|
### 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
|
- **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
|
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,
|
Gruppe gebaut, ohne Rücksicht auf den Download-Zustand. Die Karte, aus der die lokale Datei kommt,
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class NovaSonicApp : Application() {
|
|||||||
playerController = PlayerController(applicationContext, repository)
|
playerController = PlayerController(applicationContext, repository)
|
||||||
playerController.connect()
|
playerController.connect()
|
||||||
absPlayerController = AbsPlayerController(applicationContext)
|
absPlayerController = AbsPlayerController(applicationContext)
|
||||||
|
// Angefangene Downloads weiterlaufen lassen (grosse Playlists brauchen viele Laeufe).
|
||||||
|
offlineRepository.setzeUnvollstaendigeFort()
|
||||||
// Flush any plays queued while offline to the scrobble service.
|
// Flush any plays queued while offline to the scrobble service.
|
||||||
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
|
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
|
||||||
runCatching { repository.flushPlayLog() }
|
runCatching { repository.flushPlayLog() }
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class DownloadWorker(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val EXTRA_GROUP_ID = "group_id"
|
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
|
private val offlineRepo = applicationContext.app.offlineRepository
|
||||||
@@ -39,7 +43,13 @@ class DownloadWorker(
|
|||||||
val dao = OfflineDatabase.get(applicationContext).offlineDao()
|
val dao = OfflineDatabase.get(applicationContext).offlineDao()
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
|
var gestoppt = false
|
||||||
for (songId in songIds) {
|
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) {
|
if (offlineRepo.getTrackState(songId) == DownloadState.DONE) {
|
||||||
done++
|
done++
|
||||||
dao.updateGroupProgress(groupId, done)
|
dao.updateGroupProgress(groupId, done)
|
||||||
@@ -69,17 +79,39 @@ class DownloadWorker(
|
|||||||
offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
|
offlineRepo.markDone(songId, audioFile.absolutePath, coverPath)
|
||||||
done++
|
done++
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("DownloadWorker", "Failed to download song $songId", e)
|
Log.e(TAG, "Titel $songId fehlgeschlagen", e)
|
||||||
audioFile.delete()
|
audioFile.delete()
|
||||||
offlineRepo.markFailed(songId)
|
offlineRepo.markFailed(songId)
|
||||||
}
|
}
|
||||||
|
|
||||||
dao.updateGroupProgress(groupId, done)
|
dao.updateGroupProgress(groupId, done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val offen = dao.offeneTitel(groupId)
|
||||||
|
when {
|
||||||
|
gestoppt -> {
|
||||||
|
Log.i(TAG, "Gestoppt bei $done/${songIds.size} — fordere naechsten Lauf an")
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
offen == 0 -> {
|
||||||
|
Log.i(TAG, "Gruppe $groupId vollstaendig (${songIds.size} Titel)")
|
||||||
Result.success()
|
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) {
|
} catch (e: Exception) {
|
||||||
Log.e("DownloadWorker", "Worker failed for group $groupId", e)
|
Log.e(TAG, "Lauf fuer Gruppe $groupId abgebrochen", e)
|
||||||
Result.failure()
|
// Frueher Result.failure(): ein einzelner Netzfehler beendete die ganze Kette endgueltig.
|
||||||
|
if (runAttemptCount < MAX_VERSUCHE) Result.retry() else Result.failure()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ interface OfflineDao {
|
|||||||
@Query("SELECT * FROM download_groups WHERE groupId = :groupId")
|
@Query("SELECT * FROM download_groups WHERE groupId = :groupId")
|
||||||
suspend fun getGroup(groupId: String): DownloadGroupEntity?
|
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<DownloadGroupEntity>
|
||||||
|
|
||||||
@Query("DELETE FROM download_groups WHERE groupId = :groupId")
|
@Query("DELETE FROM download_groups WHERE groupId = :groupId")
|
||||||
suspend fun deleteGroup(groupId: String)
|
suspend fun deleteGroup(groupId: String)
|
||||||
|
|
||||||
@@ -84,6 +88,14 @@ interface OfflineDao {
|
|||||||
""")
|
""")
|
||||||
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>>
|
fun tracksForGroupFlow(groupId: String): Flow<List<DownloadedTrackEntity>>
|
||||||
|
|
||||||
|
/** Wie viele Titel der Gruppe noch nicht fertig sind — eine Abfrage statt einer pro Titel. */
|
||||||
|
@Query("""
|
||||||
|
SELECT COUNT(*) FROM downloaded_tracks t
|
||||||
|
INNER JOIN group_track_cross_refs r ON t.songId = r.songId
|
||||||
|
WHERE r.groupId = :groupId AND t.state != 'DONE'
|
||||||
|
""")
|
||||||
|
suspend fun offeneTitel(groupId: String): Int
|
||||||
|
|
||||||
@Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId")
|
@Query("DELETE FROM group_track_cross_refs WHERE groupId = :groupId AND songId = :songId")
|
||||||
suspend fun removeCrossRef(groupId: String, songId: String)
|
suspend fun removeCrossRef(groupId: String, songId: String)
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ class OfflineRepository(context: Context) {
|
|||||||
}
|
}
|
||||||
dao.updateGroupProgress(groupId, alreadyDone)
|
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<DownloadWorker>()
|
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
|
||||||
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
|
.setInputData(workDataOf(DownloadWorker.EXTRA_GROUP_ID to groupId))
|
||||||
.setConstraints(
|
.setConstraints(
|
||||||
@@ -162,12 +170,33 @@ class OfflineRepository(context: Context) {
|
|||||||
.setRequiresStorageNotLow(true)
|
.setRequiresStorageNotLow(true)
|
||||||
.build()
|
.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()
|
.build()
|
||||||
|
|
||||||
WorkManager.getInstance(appContext)
|
WorkManager.getInstance(appContext)
|
||||||
.enqueueUniqueWork(groupId, ExistingWorkPolicy.KEEP, request)
|
.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 ──────────────────────────────
|
// ── Delete with reference-count file cleanup ──────────────────────────────
|
||||||
|
|
||||||
/** Removes a group and any tracks that are no longer referenced by another group. */
|
/** Removes a group and any tracks that are no longer referenced by another group. */
|
||||||
|
|||||||
Reference in new issue
Block a user