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
Showing only changes of commit e2f87ce940 - Show all commits
@@ -8,7 +8,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import timber.log.Timber
import javax.inject.Inject
@@ -41,21 +40,25 @@ class ResumeController @Inject constructor(
}
suspend fun restore() {
// runCatching folds row-missing + decode-failure + empty-tracks into
// a single null-or-go-ahead expression so detekt's ReturnCount rule
// is satisfied. The .onFailure side effect drops a corrupted row
// rather than leaving a poison pill across launches.
val row = dao.get() ?: return
val payload =
try {
json.decodeFromString<ResumePayload>(row.json)
} catch (e: SerializationException) {
// Schema drift or corruption — drop the row and start clean
// rather than crashing or leaving a poison pill.
Timber.w(e, "ResumeController: dropping unreadable resume state")
dao.clear()
return
}
if (payload.tracks.isEmpty()) return
runCatching { json.decodeFromString<ResumePayload>(row.json) }
.onFailure {
Timber.w(it, "ResumeController: dropping unreadable resume state")
dao.clear()
}
.getOrNull()
?.takeIf { it.tracks.isNotEmpty() }
?: return
playerController.setQueue(
tracks = payload.tracks,
initialIndex = payload.queueIndex.coerceAtLeast(0).coerceAtMost(payload.tracks.lastIndex),
initialIndex = payload.queueIndex
.coerceAtLeast(0)
.coerceAtMost(payload.tracks.lastIndex),
source = payload.source,
)
// PositionMs not seeked yet — Player connection may still be