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) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 00:02:20 -04:00
parent 7bed0c226b
commit 6967d62c09
2 changed files with 62 additions and 3 deletions
@@ -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<String>)
@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<String>)
/**
* 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<String>,
all: List<CachedPlaylistEntity>,
) {
if (freshOwnedIds.isEmpty()) {
deleteAllOwned(userId)
} else {
deleteOwnedNotIn(userId, freshOwnedIds)
}
upsertAll(all)
}
}
@@ -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(