feat: web UI server auth foundation (/api/* login, logout, me) #14

Merged
bvandeusen merged 262 commits from dev into main 2026-04-20 22:51:46 -04:00
2 changed files with 62 additions and 3 deletions
Showing only changes of commit 6967d62c09 - Show all commits
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -35,4 +36,35 @@ interface CachedPlaylistDao {
@Query("DELETE FROM cached_playlists WHERE id IN (:ids)") @Query("DELETE FROM cached_playlists WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>) 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.AppendTracksRequest
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi 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.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
@@ -39,6 +42,7 @@ class PlaylistsRepository @Inject constructor(
private val playlistDao: CachedPlaylistDao, private val playlistDao: CachedPlaylistDao,
private val playlistTrackDao: CachedPlaylistTrackDao, private val playlistTrackDao: CachedPlaylistTrackDao,
private val mutationQueue: MutationQueue, private val mutationQueue: MutationQueue,
private val authController: AuthController,
retrofit: Retrofit, retrofit: Retrofit,
) { ) {
private val api: PlaylistsApi = retrofit.create() private val api: PlaylistsApi = retrofit.create()
@@ -67,8 +71,21 @@ class PlaylistsRepository @Inject constructor(
*/ */
suspend fun refreshList() { suspend fun refreshList() {
val wire = api.list(kind = "all") val wire = api.list(kind = "all")
val all = wire.owned + wire.public val all = (wire.owned + wire.public).map { it.toEntity() }
playlistDao.upsertAll(all.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. * track-membership IDs, not the display fields.
*/ */
suspend fun refreshDetail(id: String): PlaylistDetailRef { 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())) playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
playlistTrackDao.deleteByPlaylist(id) playlistTrackDao.deleteByPlaylist(id)
playlistTrackDao.upsertAll( playlistTrackDao.upsertAll(