#2523 filtered missing tracks out of every path that CHOOSES music, but the client sync feed was never touched: GetTracksByIDs has no filter and the wire had no field for it. So every Android client held a cached library containing tracks whose files are gone, with no way to tell, and could queue them from any cache-first path -- the exact failure #2523 existed to prevent, reached by a different route. Ships the state rather than filtering the feed, of the two options the ticket weighed. A missing file is expected to come back: the scanner clears the mark, and adopts the row if it returns renamed (#2528). Withholding the row would mean a delete-and-recreate on every client for what is usually a transient unmount, churning caches and throwing away the identity #2528 works to preserve. Room goes to v8. No hand-written migration: the pre-v1 destructive fallback rebuilds from sync, which repopulates every row with the new column -- exactly the case that policy exists for. The interesting part was working out what "missing" means to a client, and it is NOT "unplayable". Two findings shaped the fix: Server search and album detail never filtered missing tracks either, and that turns out to be right rather than an oversight. The consistent rule the codebase already follows is that Minstrel never PICKS a missing track for you -- recommendation, discover, mixes and browse all exclude them -- but it does not hide one you went looking for by name or opened an album to find. Hiding track 4 makes an album look wrong. So the fix is to mark and to keep it out of queues, not to hide it. And a track whose server file is missing still plays perfectly if its audio is already in the device cache. ShuffleSource's offline pools filter to exactly those residents, so it now clears the mark on the way out: the bytes are local and the server's loss is irrelevant. Without that, the queue filter below would have thrown away tracks that work, turning a fix into an offline regression. The queue protection is one choke point rather than five call sites. setQueue is where playlists, album play-all, search, radio and cold-boot resume all converge. dropUnavailable is pure so the index arithmetic is pinned by tests -- removing entries ahead of the requested position would otherwise start playback on the wrong track, and asking to start on a missing track now starts the next playable one, which is the "gets skipped" behaviour the operator asked for. An entirely missing queue returns empty and the caller leaves the player alone rather than replacing what is playing with silence.
122 lines
3.7 KiB
Go
122 lines
3.7 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// These tests pin the wire-format keys for /api/library/sync upserts.
|
|
// Without them, sqlc model field-name drift or accidental
|
|
// `json.Marshal(rawStruct)` regressions would silently break the Android
|
|
// client, which reads these snake_case keys via @SerialName in
|
|
// models/wire/SyncResponseWire.kt. "Silently" is the operative word:
|
|
// kotlinx.serialization skips unknown keys, so a renamed field arrives as
|
|
// a default value rather than an error.
|
|
|
|
// validUUID is a deterministic test UUID — not a real value, just
|
|
// something that pgtype.UUID.Valid will accept.
|
|
var validUUID = pgtype.UUID{
|
|
Bytes: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
|
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
|
|
Valid: true,
|
|
}
|
|
|
|
func assertJSONKeys(t *testing.T, label string, b []byte, want []string) {
|
|
t.Helper()
|
|
var m map[string]json.RawMessage
|
|
if err := json.Unmarshal(b, &m); err != nil {
|
|
t.Fatalf("%s: invalid json: %v\nbody: %s", label, err, b)
|
|
}
|
|
for _, k := range want {
|
|
if _, ok := m[k]; !ok {
|
|
t.Errorf("%s: missing key %q in %s", label, k, b)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestArtistSyncView_WireKeys(t *testing.T) {
|
|
a := dbq.Artist{ID: validUUID, Name: "Aphex Twin", SortName: "Aphex Twin"}
|
|
b, err := json.Marshal(toArtistSyncView(a))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertJSONKeys(t, "artist", b, []string{
|
|
"id", "name", "sort_name", "mbid",
|
|
"artist_thumb_path", "artist_fanart_path",
|
|
})
|
|
}
|
|
|
|
func TestAlbumSyncView_WireKeys(t *testing.T) {
|
|
a := dbq.Album{ID: validUUID, ArtistID: validUUID, Title: "Drukqs", SortTitle: "Drukqs"}
|
|
b, err := json.Marshal(toAlbumSyncView(a))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertJSONKeys(t, "album", b, []string{
|
|
"id", "artist_id", "title", "sort_title",
|
|
"release_date", "cover_art_path", "mbid",
|
|
})
|
|
}
|
|
|
|
func TestTrackSyncView_WireKeys(t *testing.T) {
|
|
tr := dbq.Track{
|
|
ID: validUUID, AlbumID: validUUID, ArtistID: validUUID,
|
|
Title: "Avril 14th", DurationMs: 121_000,
|
|
FilePath: "x", FileFormat: "flac",
|
|
}
|
|
b, err := json.Marshal(toTrackSyncView(tr))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertJSONKeys(t, "track", b, []string{
|
|
"id", "album_id", "artist_id", "title", "duration_ms",
|
|
"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) {
|
|
variant := "discover"
|
|
p := dbq.Playlist{
|
|
ID: validUUID, UserID: validUUID, Name: "Test",
|
|
IsPublic: true, TrackCount: 5, DurationSec: 600,
|
|
SystemVariant: &variant,
|
|
}
|
|
b, err := json.Marshal(toPlaylistSyncView(p))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertJSONKeys(t, "playlist", b, []string{
|
|
"id", "user_id", "name", "description",
|
|
"is_public", "cover_path", "track_count",
|
|
"duration_sec", "system_variant",
|
|
})
|
|
// Sanity: system_variant carries the value, not just present.
|
|
var m map[string]any
|
|
_ = json.Unmarshal(b, &m)
|
|
if got := m["system_variant"]; got != "discover" {
|
|
t.Errorf("system_variant: want 'discover', got %v", got)
|
|
}
|
|
}
|