From 6967d62c09c83920616c036e607b3fd9a61f52f9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 30 May 2026 00:02:20 -0400 Subject: [PATCH] fix(android): reconcile playlist cache to prune stale system-playlist UUIDs Tapping certain playlists showed 'That playlist no longer exists' because the server's BuildSystemPlaylists rotates system-playlist UUIDs every rebuild, and the LIST refresh only upserted -- stale UUIDs lingered in the cache, tapping them triggered a 404. Mirrors playlists_provider.dart: PlaylistsRepository.refreshList now deletes the user's cached rows not in the fresh owned set (catches both deleted user playlists AND old system UUIDs since system playlists are user-scoped) before upserting the fresh all, atomically via a new replaceList @Transaction on the DAO. Also deletes the cached row when refreshDetail 404s so a stale tap self-heals the list. Inject AuthController for the current user id. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cache/db/dao/CachedPlaylistDao.kt | 32 ++++++++++++++++++ .../playlists/data/PlaylistsRepository.kt | 33 +++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt index 1f0c2adc..bb3276da 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/CachedPlaylistDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity import kotlinx.coroutines.flow.Flow @@ -35,4 +36,35 @@ interface CachedPlaylistDao { @Query("DELETE FROM cached_playlists WHERE id IN (:ids)") suspend fun deleteByIds(ids: List) + + @Query("DELETE FROM cached_playlists WHERE userId = :userId") + suspend fun deleteAllOwned(userId: String) + + @Query("DELETE FROM cached_playlists WHERE userId = :userId AND id NOT IN (:keepIds)") + suspend fun deleteOwnedNotIn(userId: String, keepIds: List) + + /** + * Atomically reconciles the cache against the fresh list response. + * Mirrors `flutter_client/lib/playlists/playlists_provider.dart:54` — + * `BuildSystemPlaylists` rotates system-playlist UUIDs every + * rebuild, so upsert alone leaves stale rows whose detail fetch + * 404s. Delete any of the user's rows not in [freshOwnedIds] (this + * catches both deleted owned playlists AND old system-playlist + * UUIDs, since system playlists are user-scoped and appear in + * [all] under their new id rather than in [freshOwnedIds]), then + * upsert the fresh set. + */ + @Transaction + suspend fun replaceList( + userId: String, + freshOwnedIds: List, + all: List, + ) { + if (freshOwnedIds.isEmpty()) { + deleteAllOwned(userId) + } else { + deleteOwnedNotIn(userId, freshOwnedIds) + } + upsertAll(all) + } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt index dba1000c..5bbd26a7 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/playlists/data/PlaylistsRepository.kt @@ -2,6 +2,9 @@ package com.fabledsword.minstrel.playlists.data import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest import com.fabledsword.minstrel.api.endpoints.PlaylistsApi +import com.fabledsword.minstrel.auth.AuthController +import retrofit2.HttpException +import java.net.HttpURLConnection import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity @@ -39,6 +42,7 @@ class PlaylistsRepository @Inject constructor( private val playlistDao: CachedPlaylistDao, private val playlistTrackDao: CachedPlaylistTrackDao, private val mutationQueue: MutationQueue, + private val authController: AuthController, retrofit: Retrofit, ) { private val api: PlaylistsApi = retrofit.create() @@ -67,8 +71,21 @@ class PlaylistsRepository @Inject constructor( */ suspend fun refreshList() { val wire = api.list(kind = "all") - val all = wire.owned + wire.public - playlistDao.upsertAll(all.map { it.toEntity() }) + val all = (wire.owned + wire.public).map { it.toEntity() } + val userId = authController.currentUser.value?.id + if (userId != null) { + // Reconcile: BuildSystemPlaylists rotates system-playlist + // UUIDs every rebuild, so upsert alone leaves stale rows + // whose detail fetch 404s ("That playlist no longer + // exists"). Mirrors playlists_provider.dart's deleteWhere. + playlistDao.replaceList( + userId = userId, + freshOwnedIds = wire.owned.map { it.id }, + all = all, + ) + } else { + playlistDao.upsertAll(all) + } } /** @@ -79,7 +96,17 @@ class PlaylistsRepository @Inject constructor( * track-membership IDs, not the display fields. */ suspend fun refreshDetail(id: String): PlaylistDetailRef { - val wire = api.get(id) + val wire = try { + api.get(id) + } catch (e: HttpException) { + // Server says this playlist is gone — drop the stale cache + // row so the list stops showing it. Mirrors + // playlists_provider.dart's deleteWhere on detail failure. + if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) { + playlistDao.deleteByIds(listOf(id)) + } + throw e + } playlistDao.upsertAll(listOf(wire.toPlaylistEntity())) playlistTrackDao.deleteByPlaylist(id) playlistTrackDao.upsertAll(