Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126
@@ -57,6 +57,14 @@ class ShuffleSource @Inject constructor(
|
|||||||
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
|
private suspend fun materialize(orderedIds: List<String>): List<TrackRef> {
|
||||||
if (orderedIds.isEmpty()) return emptyList()
|
if (orderedIds.isEmpty()) return emptyList()
|
||||||
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
|
val byId = trackDao.getByIds(orderedIds).associateBy { it.id }
|
||||||
return orderedIds.mapNotNull { byId[it]?.toDomain() }
|
return orderedIds.mapNotNull { id ->
|
||||||
|
// Clear the server's missing mark (#2704). Every id reaching here
|
||||||
|
// came through residentIdsByRecency, which already proved the
|
||||||
|
// AUDIO is in the local cache — so these play regardless of what
|
||||||
|
// the server has lost, and the queue filter in PlayerController
|
||||||
|
// would otherwise throw away tracks that work perfectly. Missing
|
||||||
|
// means "cannot stream", not "cannot play".
|
||||||
|
byId[id]?.toDomain()?.copy(unavailable = false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,13 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
DiagnosticEventEntity::class,
|
DiagnosticEventEntity::class,
|
||||||
],
|
],
|
||||||
|
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
||||||
|
// so cache-first surfaces stop offering files that cannot stream.
|
||||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch.
|
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||||
version = 7,
|
// which is exactly right here: the next sync refills every row with the
|
||||||
|
// new column populated, so there is nothing to migrate by hand.
|
||||||
|
version = 8,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
|
|||||||
+5
@@ -8,6 +8,10 @@ import kotlinx.datetime.Instant
|
|||||||
/**
|
/**
|
||||||
* Cache row for one track. Mirrors the Flutter client's
|
* Cache row for one track. Mirrors the Flutter client's
|
||||||
* `CachedTracks` Drift table.
|
* `CachedTracks` Drift table.
|
||||||
|
*
|
||||||
|
* [missing] carries the server's missing-file mark (#2704). Every read that
|
||||||
|
* can put a track in front of the user — or in a queue — must exclude it, and
|
||||||
|
* the DAO queries do that rather than each call site remembering to.
|
||||||
*/
|
*/
|
||||||
@Entity(tableName = "cached_tracks")
|
@Entity(tableName = "cached_tracks")
|
||||||
data class CachedTrackEntity(
|
data class CachedTrackEntity(
|
||||||
@@ -21,5 +25,6 @@ data class CachedTrackEntity(
|
|||||||
val filePath: String? = null,
|
val filePath: String? = null,
|
||||||
val fileFormat: String? = null,
|
val fileFormat: String? = null,
|
||||||
val genre: String? = null,
|
val genre: String? = null,
|
||||||
|
val missing: Boolean = false,
|
||||||
val fetchedAt: Instant = Clock.System.now(),
|
val fetchedAt: Instant = Clock.System.now(),
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -219,4 +219,5 @@ private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
|
|||||||
filePath = filePath,
|
filePath = filePath,
|
||||||
fileFormat = fileFormat,
|
fileFormat = fileFormat,
|
||||||
genre = genre,
|
genre = genre,
|
||||||
|
missing = missing,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ fun CachedTrackEntity.toDomain(
|
|||||||
trackNumber = trackNumber,
|
trackNumber = trackNumber,
|
||||||
discNumber = discNumber,
|
discNumber = discNumber,
|
||||||
durationSec = durationMs.millisToSeconds(),
|
durationSec = durationMs.millisToSeconds(),
|
||||||
|
unavailable = missing,
|
||||||
// Deterministic from track id; matches the server's stream_url
|
// Deterministic from track id; matches the server's stream_url
|
||||||
// (internal/api/convert.go:75 streamURL builder). Cached rows
|
// (internal/api/convert.go:75 streamURL builder). Cached rows
|
||||||
// didn't carry streamUrl before, which left MetadataProvider-
|
// didn't carry streamUrl before, which left MetadataProvider-
|
||||||
@@ -121,6 +122,7 @@ fun TrackWire.toDomain(): TrackRef =
|
|||||||
discNumber = discNumber,
|
discNumber = discNumber,
|
||||||
durationSec = durationSec,
|
durationSec = durationSec,
|
||||||
streamUrl = streamUrl,
|
streamUrl = streamUrl,
|
||||||
|
unavailable = unavailable,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun ArtistWire.toDomain(): ArtistRef =
|
fun ArtistWire.toDomain(): ArtistRef =
|
||||||
|
|||||||
@@ -31,6 +31,16 @@ data class TrackRef(
|
|||||||
val discNumber: Int? = null,
|
val discNumber: Int? = null,
|
||||||
val durationSec: Int = 0,
|
val durationSec: Int = 0,
|
||||||
val streamUrl: String = "",
|
val streamUrl: String = "",
|
||||||
|
/**
|
||||||
|
* The server has no file for this track right now (#2704). It still
|
||||||
|
* belongs to the library, keeps its history, and may come back — but
|
||||||
|
* streaming it will fail, so nothing should queue it.
|
||||||
|
*
|
||||||
|
* NOT the same as unplayable on this device: audio already resident in
|
||||||
|
* the local cache plays regardless of what the server has, which is why
|
||||||
|
* the offline pools in ShuffleSource deliberately ignore this.
|
||||||
|
*/
|
||||||
|
val unavailable: Boolean = false,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
|
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
|
||||||
|
|||||||
@@ -42,6 +42,15 @@ data class SyncTrackWire(
|
|||||||
@SerialName("file_path") val filePath: String? = null,
|
@SerialName("file_path") val filePath: String? = null,
|
||||||
@SerialName("file_format") val fileFormat: String? = null,
|
@SerialName("file_format") val fileFormat: String? = null,
|
||||||
val genre: String? = null,
|
val genre: String? = null,
|
||||||
|
// The file is currently absent from disk server-side (#2704). Shipped as
|
||||||
|
// state rather than the row being withheld, because a missing file is
|
||||||
|
// expected to return — dropping it would churn the cache on every
|
||||||
|
// transient unmount and discard the identity #2528 preserves.
|
||||||
|
//
|
||||||
|
// Defaults false so a server predating the field deserialises cleanly and
|
||||||
|
// its tracks stay playable, which is the correct reading of "this server
|
||||||
|
// has nothing to say about missing files".
|
||||||
|
val missing: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,4 +26,9 @@ data class TrackWire(
|
|||||||
@SerialName("disc_number") val discNumber: Int? = null,
|
@SerialName("disc_number") val discNumber: Int? = null,
|
||||||
@SerialName("duration_sec") val durationSec: Int = 0,
|
@SerialName("duration_sec") val durationSec: Int = 0,
|
||||||
@SerialName("stream_url") val streamUrl: String = "",
|
@SerialName("stream_url") val streamUrl: String = "",
|
||||||
|
// Omitted by the server when false, so the default carries most rows
|
||||||
|
// (#2704). True only from the direct-lookup surfaces — album detail and
|
||||||
|
// search — which return a track the user asked for by name or container
|
||||||
|
// rather than one Minstrel chose.
|
||||||
|
val unavailable: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -260,8 +260,16 @@ class PlayerController @Inject constructor(
|
|||||||
autoplay: Boolean = true,
|
autoplay: Boolean = true,
|
||||||
) {
|
) {
|
||||||
val controller = mediaController ?: return
|
val controller = mediaController ?: return
|
||||||
queueRefs = tracks
|
// One choke point for #2704: a track whose file the server has lost
|
||||||
val items = tracks.map { it.toMediaItem(source) }
|
// must not take a queue slot, whichever surface built the list.
|
||||||
|
// Playlists already drop them earlier (toPlayableTrackRefs), but
|
||||||
|
// album play-all, search, radio and cold-boot resume all arrive here
|
||||||
|
// too, and catching it once beats remembering at five call sites.
|
||||||
|
val playable = dropUnavailable(tracks, initialIndex)
|
||||||
|
if (playable.tracks.isEmpty()) return
|
||||||
|
queueRefs = playable.tracks
|
||||||
|
val items = playable.tracks.map { it.toMediaItem(source) }
|
||||||
|
val startIndex = playable.initialIndex
|
||||||
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
// Drift #562 cold-boot resume calls this from a non-Main suspend
|
||||||
// context after awaitReady() unblocks (ResumeController launches
|
// context after awaitReady() unblocks (ResumeController launches
|
||||||
// on Dispatchers.Default by the time it reaches us). MediaController
|
// on Dispatchers.Default by the time it reaches us). MediaController
|
||||||
@@ -270,7 +278,7 @@ class PlayerController @Inject constructor(
|
|||||||
// if we're already there, run directly to avoid the re-dispatch
|
// if we're already there, run directly to avoid the re-dispatch
|
||||||
// latency UI callers depend on.
|
// latency UI callers depend on.
|
||||||
runOnControllerThread(controller) {
|
runOnControllerThread(controller) {
|
||||||
controller.setMediaItems(items, initialIndex, /* startPositionMs = */ 0L)
|
controller.setMediaItems(items, startIndex, /* startPositionMs = */ 0L)
|
||||||
controller.prepare()
|
controller.prepare()
|
||||||
if (autoplay) controller.play()
|
if (autoplay) controller.play()
|
||||||
}
|
}
|
||||||
@@ -874,3 +882,37 @@ data class PlaybackErrorEvent(
|
|||||||
val title: String,
|
val title: String,
|
||||||
val detail: String? = null,
|
val detail: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A queue with the server-missing tracks removed, and the caller's starting
|
||||||
|
* index moved to match (#2704).
|
||||||
|
*/
|
||||||
|
data class PlayableQueue(val tracks: List<TrackRef>, val initialIndex: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop tracks the server has no file for, keeping [initialIndex] pointing at
|
||||||
|
* the same music.
|
||||||
|
*
|
||||||
|
* The index is the fiddly half and the reason this is a function rather than
|
||||||
|
* a `filter` at the call site: removing entries before the requested position
|
||||||
|
* would otherwise start playback on the wrong track. The new index is the
|
||||||
|
* count of surviving tracks ahead of it, which also gives the right behaviour
|
||||||
|
* when the requested track is ITSELF missing — playback starts at the next
|
||||||
|
* one that can play, i.e. it gets skipped.
|
||||||
|
*
|
||||||
|
* Returns an empty queue when nothing survives, which the caller treats as
|
||||||
|
* "don't touch the player": replacing a playing queue with silence because a
|
||||||
|
* stale list turned out to be entirely missing would be worse than ignoring
|
||||||
|
* the request.
|
||||||
|
*/
|
||||||
|
fun dropUnavailable(tracks: List<TrackRef>, initialIndex: Int): PlayableQueue {
|
||||||
|
if (tracks.none { it.unavailable }) return PlayableQueue(tracks, initialIndex)
|
||||||
|
val kept = ArrayList<TrackRef>(tracks.size)
|
||||||
|
var newIndex = 0
|
||||||
|
tracks.forEachIndexed { i, track ->
|
||||||
|
if (track.unavailable) return@forEachIndexed
|
||||||
|
if (i < initialIndex) newIndex++
|
||||||
|
kept.add(track)
|
||||||
|
}
|
||||||
|
return PlayableQueue(kept, newIndex.coerceAtMost((kept.size - 1).coerceAtLeast(0)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The queue filter for #2704. The index arithmetic is the part worth pinning:
|
||||||
|
* getting it wrong starts playback on the wrong track, which is a subtler and
|
||||||
|
* more annoying bug than the one being fixed.
|
||||||
|
*/
|
||||||
|
class DropUnavailableTest {
|
||||||
|
|
||||||
|
private fun track(id: String, unavailable: Boolean = false) =
|
||||||
|
TrackRef(id = id, title = id, unavailable = unavailable)
|
||||||
|
|
||||||
|
private fun queue(vararg spec: Pair<String, Boolean>) =
|
||||||
|
spec.map { (id, missing) -> track(id, missing) }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a queue with nothing missing is returned untouched`() {
|
||||||
|
val tracks = queue("a" to false, "b" to false, "c" to false)
|
||||||
|
val out = dropUnavailable(tracks, initialIndex = 1)
|
||||||
|
assertEquals(tracks, out.tracks)
|
||||||
|
assertEquals(1, out.initialIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `missing tracks are dropped`() {
|
||||||
|
val out = dropUnavailable(queue("a" to false, "b" to true, "c" to false), 0)
|
||||||
|
assertEquals(listOf("a", "c"), out.tracks.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The whole reason for the index math: two removals ahead of the target
|
||||||
|
* would otherwise start playback two tracks early.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `the starting index follows its track past earlier removals`() {
|
||||||
|
val out = dropUnavailable(
|
||||||
|
queue("a" to true, "b" to true, "c" to false, "d" to false),
|
||||||
|
initialIndex = 2,
|
||||||
|
)
|
||||||
|
assertEquals(listOf("c", "d"), out.tracks.map { it.id })
|
||||||
|
assertEquals(0, out.initialIndex)
|
||||||
|
assertEquals("c", out.tracks[out.initialIndex].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `removals after the starting index leave it alone`() {
|
||||||
|
val out = dropUnavailable(
|
||||||
|
queue("a" to false, "b" to false, "c" to true),
|
||||||
|
initialIndex = 1,
|
||||||
|
)
|
||||||
|
assertEquals("b", out.tracks[out.initialIndex].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tapping a missing track starts the next playable one — it gets skipped. */
|
||||||
|
@Test
|
||||||
|
fun `asking to start on a missing track starts on the next playable one`() {
|
||||||
|
val out = dropUnavailable(
|
||||||
|
queue("a" to false, "b" to true, "c" to false),
|
||||||
|
initialIndex = 1,
|
||||||
|
)
|
||||||
|
assertEquals("c", out.tracks[out.initialIndex].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a missing track at the end cannot push the index out of bounds`() {
|
||||||
|
val out = dropUnavailable(
|
||||||
|
queue("a" to false, "b" to false, "c" to true),
|
||||||
|
initialIndex = 2,
|
||||||
|
)
|
||||||
|
assertTrue(out.initialIndex in out.tracks.indices)
|
||||||
|
assertEquals("b", out.tracks[out.initialIndex].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller treats this as "don't touch the player". Replacing what is
|
||||||
|
* currently playing with silence, because a stale list turned out to be
|
||||||
|
* entirely missing, would be worse than ignoring the request.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an entirely missing queue comes back empty`() {
|
||||||
|
val out = dropUnavailable(queue("a" to true, "b" to true), 0)
|
||||||
|
assertTrue(out.tracks.isEmpty())
|
||||||
|
assertEquals(0, out.initialIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty queue stays empty`() {
|
||||||
|
val out = dropUnavailable(emptyList(), 0)
|
||||||
|
assertTrue(out.tracks.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,7 @@ func trackRefFrom(t dbq.Track, albumTitle, artistName string) TrackRef {
|
|||||||
ArtistName: artistName,
|
ArtistName: artistName,
|
||||||
DurationSec: durationMsToSec(t.DurationMs),
|
DurationSec: durationMsToSec(t.DurationMs),
|
||||||
StreamURL: streamURL(t.ID),
|
StreamURL: streamURL(t.ID),
|
||||||
|
Unavailable: t.MissingSince.Valid,
|
||||||
}
|
}
|
||||||
if t.TrackNumber != nil {
|
if t.TrackNumber != nil {
|
||||||
ref.TrackNumber = int(*t.TrackNumber)
|
ref.TrackNumber = int(*t.TrackNumber)
|
||||||
|
|||||||
@@ -79,6 +79,19 @@ type trackSyncView struct {
|
|||||||
FilePath string `json:"file_path"`
|
FilePath string `json:"file_path"`
|
||||||
FileFormat string `json:"file_format"`
|
FileFormat string `json:"file_format"`
|
||||||
Genre *string `json:"genre"`
|
Genre *string `json:"genre"`
|
||||||
|
// Missing reports that the file is currently absent from disk (#2704).
|
||||||
|
//
|
||||||
|
// Shipped as state rather than filtered out of the feed, because a
|
||||||
|
// missing file is expected to come back: the scanner clears the mark
|
||||||
|
// when it does, and adopts the row if it returns under a new name
|
||||||
|
// (#2528). Dropping the row instead would mean a delete-and-recreate on
|
||||||
|
// every client for what is often a transient unmount, churning caches
|
||||||
|
// and discarding the identity #2528 works to preserve.
|
||||||
|
//
|
||||||
|
// A bool rather than the timestamp: clients need it to decide whether a
|
||||||
|
// track is playable, which is a yes/no. The "gone since" clock is an
|
||||||
|
// operator concern and lives on the admin surface.
|
||||||
|
Missing bool `json:"missing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toTrackSyncView(t dbq.Track) trackSyncView {
|
func toTrackSyncView(t dbq.Track) trackSyncView {
|
||||||
@@ -93,6 +106,7 @@ func toTrackSyncView(t dbq.Track) trackSyncView {
|
|||||||
FilePath: t.FilePath,
|
FilePath: t.FilePath,
|
||||||
FileFormat: t.FileFormat,
|
FileFormat: t.FileFormat,
|
||||||
Genre: t.Genre,
|
Genre: t.Genre,
|
||||||
|
Missing: t.MissingSince.Valid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
@@ -75,9 +76,26 @@ func TestTrackSyncView_WireKeys(t *testing.T) {
|
|||||||
assertJSONKeys(t, "track", b, []string{
|
assertJSONKeys(t, "track", b, []string{
|
||||||
"id", "album_id", "artist_id", "title", "duration_ms",
|
"id", "album_id", "artist_id", "title", "duration_ms",
|
||||||
"track_number", "disc_number", "file_path", "file_format", "genre",
|
"track_number", "disc_number", "file_path", "file_format", "genre",
|
||||||
|
"missing",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The mark is what the client filters on, so a wrong value here silently
|
||||||
|
// re-introduces #2704: a present track marked missing vanishes from the
|
||||||
|
// client's library, a missing one stays playable and fails at the speaker.
|
||||||
|
func TestTrackSyncView_MissingReflectsTheMark(t *testing.T) {
|
||||||
|
present := dbq.Track{ID: validUUID, AlbumID: validUUID, ArtistID: validUUID}
|
||||||
|
if toTrackSyncView(present).Missing {
|
||||||
|
t.Error("a track with no missing_since must not be marked missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
gone := present
|
||||||
|
gone.MissingSince = pgtype.Timestamptz{Time: time.Now(), Valid: true}
|
||||||
|
if !toTrackSyncView(gone).Missing {
|
||||||
|
t.Error("a track with missing_since must be marked missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPlaylistSyncView_WireKeys(t *testing.T) {
|
func TestPlaylistSyncView_WireKeys(t *testing.T) {
|
||||||
variant := "discover"
|
variant := "discover"
|
||||||
p := dbq.Playlist{
|
p := dbq.Playlist{
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ type TrackRef struct {
|
|||||||
DiscNumber int `json:"disc_number,omitempty"`
|
DiscNumber int `json:"disc_number,omitempty"`
|
||||||
DurationSec int `json:"duration_sec"`
|
DurationSec int `json:"duration_sec"`
|
||||||
StreamURL string `json:"stream_url"`
|
StreamURL string `json:"stream_url"`
|
||||||
|
// Unavailable reports that the file is missing from disk (#2704).
|
||||||
|
//
|
||||||
|
// Present on every TrackRef rather than only where it can be true: the
|
||||||
|
// selection surfaces (recommendation, discover, mixes, browse) filter
|
||||||
|
// missing tracks out entirely, so it is always false there. The surfaces
|
||||||
|
// that CAN return one are the direct lookups — album detail and search —
|
||||||
|
// where the user asked for that specific thing by name or container and
|
||||||
|
// hiding it would be the wrong answer. Marking it lets the client grey
|
||||||
|
// it and keep it out of a queue.
|
||||||
|
Unavailable bool `json:"unavailable,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtistDetail is the response body of GET /api/artists/{id}. Embeds the ref
|
// ArtistDetail is the response body of GET /api/artists/{id}. Embeds the ref
|
||||||
|
|||||||
Reference in New Issue
Block a user