feat: M3 weighted shuffle v1 — real /api/radio with scoring #21

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 11:00:29 -04:00
5 changed files with 130 additions and 0 deletions
Showing only changes of commit 4b38623229 - Show all commits
@@ -6,11 +6,15 @@ import androidx.room.TypeConverters
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity
import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
@@ -34,6 +38,8 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedAlbumEntity::class,
CachedTrackEntity::class,
CachedLikeEntity::class,
CachedPlaylistEntity::class,
CachedPlaylistTrackEntity::class,
],
version = 1,
exportSchema = true,
@@ -45,4 +51,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun cachedAlbumDao(): CachedAlbumDao
abstract fun cachedTrackDao(): CachedTrackDao
abstract fun cachedLikeDao(): CachedLikeDao
abstract fun cachedPlaylistDao(): CachedPlaylistDao
abstract fun cachedPlaylistTrackDao(): CachedPlaylistTrackDao
}
@@ -0,0 +1,38 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedPlaylistDao {
/** All playlists, user + system, sorted by name. */
@Query("SELECT * FROM cached_playlists ORDER BY name COLLATE NOCASE ASC")
fun observeAll(): Flow<List<CachedPlaylistEntity>>
/** User playlists only (systemVariant IS NULL) — for the add-to-playlist sheet. */
@Query(
"SELECT * FROM cached_playlists " +
"WHERE systemVariant IS NULL ORDER BY name COLLATE NOCASE ASC",
)
fun observeUserPlaylists(): Flow<List<CachedPlaylistEntity>>
/** System playlists for the home screen. */
@Query(
"SELECT * FROM cached_playlists " +
"WHERE systemVariant IS NOT NULL ORDER BY name COLLATE NOCASE ASC",
)
fun observeSystemPlaylists(): Flow<List<CachedPlaylistEntity>>
@Query("SELECT * FROM cached_playlists WHERE id = :id")
suspend fun getById(id: String): CachedPlaylistEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
@Query("DELETE FROM cached_playlists WHERE id IN (:ids)")
suspend fun deleteByIds(ids: List<String>)
}
@@ -0,0 +1,36 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface CachedPlaylistTrackDao {
@Query(
"SELECT * FROM cached_playlist_tracks " +
"WHERE playlistId = :playlistId ORDER BY position ASC",
)
fun observeByPlaylist(playlistId: String): Flow<List<CachedPlaylistTrackEntity>>
@Query(
"SELECT * FROM cached_playlist_tracks " +
"WHERE playlistId = :playlistId ORDER BY position ASC",
)
suspend fun getByPlaylist(playlistId: String): List<CachedPlaylistTrackEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(rows: List<CachedPlaylistTrackEntity>)
/** Replace-all pattern for a playlist; called after a sync delta lands. */
@Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
suspend fun deleteByPlaylist(playlistId: String)
@Query(
"DELETE FROM cached_playlist_tracks " +
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
)
suspend fun deleteTracksFromPlaylist(playlistId: String, trackIds: List<String>)
}
@@ -0,0 +1,29 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
/**
* Cache row for one playlist (user or system). Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylists` Drift table.
*
* `systemVariant` is null for user playlists and one of
* "for_you" / "songs_like_artist" / "discover" / "todays_mix" / etc.
* for system-generated mixes. Lets the add-to-playlist sheet filter
* system playlists locally without a REST round-trip.
*/
@Entity(tableName = "cached_playlists")
data class CachedPlaylistEntity(
@PrimaryKey val id: String,
val userId: String,
val name: String,
val description: String = "",
val isPublic: Boolean = false,
val coverPath: String? = null,
val trackCount: Int = 0,
val durationSec: Int = 0,
val systemVariant: String? = null,
val fetchedAt: Instant = Clock.System.now(),
)
@@ -0,0 +1,19 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
/**
* Ordered membership of tracks within a playlist. Mirrors
* `flutter_client/lib/cache/db.dart`'s `CachedPlaylistTracks` Drift table.
* Composite PK so the same track can only appear once per playlist;
* `position` carries the ordering.
*/
@Entity(
tableName = "cached_playlist_tracks",
primaryKeys = ["playlistId", "trackId"],
)
data class CachedPlaylistTrackEntity(
val playlistId: String,
val trackId: String,
val position: Int = 0,
)