Files
minstrel/internal/api/library_sync_views.go
T
bvandeusen 366692a1fc
test-go / test (push) Successful in 2m3s
android / Build + lint + test (push) Failing after 4m24s
test-go / integration (push) Successful in 5m8s
fix: stop the sync feed hiding missing files from clients — #2704
#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.
2026-08-17 12:56:31 -04:00

138 lines
4.7 KiB
Go

package api
// Wire shapes for /api/library/sync upserts. The sqlc-generated row
// structs (dbq.Artist, dbq.Album, dbq.Track, dbq.Playlist) have no
// JSON tags (sqlc.yaml: emit_json_tags=false), so a raw json.Marshal
// of them produces PascalCase field names that the Flutter sync
// controller (which reads snake_case keys) can't parse.
//
// These view structs add the JSON tag layer + flatten pgtype.UUID and
// pgtype.Timestamptz / Date into strings, mirroring the pattern
// playlistRowView already established for /api/playlists.
//
// Field names match what the Android client deserialises in
// models/wire/SyncResponseWire.kt (Sync{Artist,Album,Track}Wire) exactly.
// Adding a field server-side requires a matching @SerialName there or it is
// silently dropped — kotlinx.serialization ignores unknown keys, so the
// failure is a missing value at runtime, not an error at parse time.
import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
type artistSyncView struct {
ID string `json:"id"`
Name string `json:"name"`
SortName string `json:"sort_name"`
Mbid *string `json:"mbid"`
ArtistThumbPath *string `json:"artist_thumb_path"`
ArtistFanartPath *string `json:"artist_fanart_path"`
}
func toArtistSyncView(a dbq.Artist) artistSyncView {
return artistSyncView{
ID: syncpkg.FormatUUID(a.ID),
Name: a.Name,
SortName: a.SortName,
Mbid: a.Mbid,
ArtistThumbPath: a.ArtistThumbPath,
ArtistFanartPath: a.ArtistFanartPath,
}
}
type albumSyncView struct {
ID string `json:"id"`
ArtistID string `json:"artist_id"`
Title string `json:"title"`
SortTitle string `json:"sort_title"`
ReleaseDate *string `json:"release_date"`
CoverArtPath *string `json:"cover_art_path"`
Mbid *string `json:"mbid"`
}
func toAlbumSyncView(a dbq.Album) albumSyncView {
var releaseDate *string
if a.ReleaseDate.Valid {
s := a.ReleaseDate.Time.Format("2006-01-02")
releaseDate = &s
}
return albumSyncView{
ID: syncpkg.FormatUUID(a.ID),
ArtistID: syncpkg.FormatUUID(a.ArtistID),
Title: a.Title,
SortTitle: a.SortTitle,
ReleaseDate: releaseDate,
CoverArtPath: a.CoverArtPath,
Mbid: a.Mbid,
}
}
type trackSyncView struct {
ID string `json:"id"`
AlbumID string `json:"album_id"`
ArtistID string `json:"artist_id"`
Title string `json:"title"`
DurationMs int32 `json:"duration_ms"`
TrackNumber *int32 `json:"track_number"`
DiscNumber *int32 `json:"disc_number"`
FilePath string `json:"file_path"`
FileFormat string `json:"file_format"`
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 {
return trackSyncView{
ID: syncpkg.FormatUUID(t.ID),
AlbumID: syncpkg.FormatUUID(t.AlbumID),
ArtistID: syncpkg.FormatUUID(t.ArtistID),
Title: t.Title,
DurationMs: t.DurationMs,
TrackNumber: t.TrackNumber,
DiscNumber: t.DiscNumber,
FilePath: t.FilePath,
FileFormat: t.FileFormat,
Genre: t.Genre,
Missing: t.MissingSince.Valid,
}
}
type playlistSyncView struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
CoverPath *string `json:"cover_path"`
TrackCount int32 `json:"track_count"`
DurationSec int32 `json:"duration_sec"`
SystemVariant *string `json:"system_variant"`
}
func toPlaylistSyncView(p dbq.Playlist) playlistSyncView {
return playlistSyncView{
ID: syncpkg.FormatUUID(p.ID),
UserID: syncpkg.FormatUUID(p.UserID),
Name: p.Name,
Description: p.Description,
IsPublic: p.IsPublic,
CoverPath: p.CoverPath,
TrackCount: p.TrackCount,
DurationSec: p.DurationSec,
SystemVariant: p.SystemVariant,
}
}