Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126
@@ -47,7 +47,10 @@ data class PlaylistRef(
|
||||
* `trackId` and `streamUrl` are nullable because the upstream track can
|
||||
* be removed from the library while the row stays in the playlist —
|
||||
* those tiles render grey + unplayable per Flutter's `isAvailable`
|
||||
* convention.
|
||||
* convention. [unavailable] is the second, softer case: the track is
|
||||
* still there but its file is missing. Both render grey and refuse to
|
||||
* play; only the second is worth explaining to the user, because it
|
||||
* can fix itself.
|
||||
*/
|
||||
data class PlaylistTrackRef(
|
||||
val position: Int,
|
||||
@@ -59,8 +62,21 @@ data class PlaylistTrackRef(
|
||||
val artistName: String = "",
|
||||
val durationSec: Int = 0,
|
||||
val streamUrl: String? = null,
|
||||
/**
|
||||
* The track is still in the library but its file is missing from
|
||||
* disk (#2527). Unlike a null [trackId] this is expected to be
|
||||
* temporary — the scanner clears it when the file returns, and
|
||||
* adopts the row if it returns under a new name (#2528) — so the
|
||||
* row keeps its identity, its likes and its play history.
|
||||
*/
|
||||
val unavailable: Boolean = false,
|
||||
) {
|
||||
val isAvailable: Boolean get() = trackId != null
|
||||
/**
|
||||
* Playable-ness, covering both ways a row can outlive its audio.
|
||||
* Everything that greys a row or refuses to queue it reads this, so
|
||||
* neither concern has to be re-derived at a call site.
|
||||
*/
|
||||
val isAvailable: Boolean get() = trackId != null && !unavailable
|
||||
|
||||
/**
|
||||
* Cover URL derived from the parent album's `/api/albums/{id}/cover`
|
||||
|
||||
@@ -41,6 +41,14 @@ data class PlaylistsListWire(
|
||||
* / `artistId` / `streamUrl` are nullable because the upstream track
|
||||
* may have been removed from the library while the row stays in the
|
||||
* playlist with its display fields preserved.
|
||||
*
|
||||
* [unavailable] is the other way a row outlives its audio (#2527): the
|
||||
* track is still in the library, with its history and likes, but its
|
||||
* file is missing from disk. The server withholds `stream_url` in that
|
||||
* case too, so a client that only checked the URL would already skip
|
||||
* it — the flag is what lets the UI say WHY instead of rendering a
|
||||
* mysteriously dead row. Defaults false so a server that predates the
|
||||
* field deserialises cleanly.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlaylistTrackWire(
|
||||
@@ -53,6 +61,7 @@ data class PlaylistTrackWire(
|
||||
@SerialName("artist_name") val artistName: String = "",
|
||||
@SerialName("duration_sec") val durationSec: Int = 0,
|
||||
@SerialName("stream_url") val streamUrl: String? = null,
|
||||
val unavailable: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+1
@@ -326,4 +326,5 @@ private fun PlaylistTrackWire.toDomain(): PlaylistTrackRef =
|
||||
artistName = artistName,
|
||||
durationSec = durationSec,
|
||||
streamUrl = streamUrl,
|
||||
unavailable = unavailable,
|
||||
)
|
||||
|
||||
+8
-1
@@ -577,9 +577,16 @@ private fun TrackRow(
|
||||
) {
|
||||
val enabled = row.isAvailable
|
||||
val rowAlpha = if (enabled) 1f else UNAVAILABLE_ALPHA
|
||||
// A greyed row with no explanation reads as a bug. Say which kind of
|
||||
// dead it is: a missing file (#2527) is the library's problem and can
|
||||
// fix itself when the file returns, so it earns a line the user can
|
||||
// act on. A removed track needs no note — grey and unplayable is the
|
||||
// whole story once it's gone from the library.
|
||||
val subtitle = row.artistName.ifEmpty { row.albumTitle }
|
||||
val artistLine = if (row.unavailable) "$subtitle · File missing" else subtitle
|
||||
TrackRow(
|
||||
title = row.title,
|
||||
artist = row.artistName.ifEmpty { row.albumTitle },
|
||||
artist = artistLine,
|
||||
trackId = row.trackId.orEmpty(),
|
||||
onClick = onClick,
|
||||
nowPlaying = nowPlaying,
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.fabledsword.minstrel.playlists.data
|
||||
|
||||
import com.fabledsword.minstrel.models.PlaylistTrackRef
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* A playlist row can outlive its audio two ways — the track was removed
|
||||
* from the library, or the track is still there but its file is missing
|
||||
* (#2527). Both must be unplayable, and neither may be silently dropped
|
||||
* from the list the user curated.
|
||||
*/
|
||||
class PlaylistTrackAvailabilityTest {
|
||||
|
||||
private fun row(
|
||||
position: Int = 0,
|
||||
trackId: String? = "t-$position",
|
||||
streamUrl: String? = "/api/tracks/t-$position/stream",
|
||||
unavailable: Boolean = false,
|
||||
) = PlaylistTrackRef(
|
||||
position = position,
|
||||
trackId = trackId,
|
||||
title = "Track $position",
|
||||
albumId = "a-1",
|
||||
albumTitle = "Album",
|
||||
artistId = "ar-1",
|
||||
artistName = "Artist",
|
||||
durationSec = 137,
|
||||
streamUrl = streamUrl,
|
||||
unavailable = unavailable,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a playable row is available`() {
|
||||
assertTrue(row().isAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a removed track is unavailable`() {
|
||||
assertFalse(row(trackId = null, streamUrl = null).isAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing file is unavailable even though the track id survives`() {
|
||||
assertFalse(row(unavailable = true, streamUrl = null).isAvailable)
|
||||
}
|
||||
|
||||
/**
|
||||
* The flag has to stand on its own. The server also withholds the
|
||||
* stream URL, but a cached detail fetched before the file went missing
|
||||
* can still carry a stale URL, and that must not resurrect the row.
|
||||
*/
|
||||
@Test
|
||||
fun `the flag disqualifies a row that still has a stream url`() {
|
||||
assertFalse(row(unavailable = true).isAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `queue building drops both kinds of dead row and keeps the rest`() {
|
||||
val queue = listOf(
|
||||
row(position = 0),
|
||||
row(position = 1, trackId = null, streamUrl = null),
|
||||
row(position = 2, unavailable = true, streamUrl = null),
|
||||
row(position = 3),
|
||||
).toPlayableTrackRefs()
|
||||
|
||||
assertEquals(listOf("t-0", "t-3"), queue.map { it.id })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user