Files
minstrel/internal/api/types.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

176 lines
7.2 KiB
Go

package api
import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
// LoginRequest is the POST /api/auth/login body. Kept boring on purpose —
// web and Flutter both send the same payload.
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// LoginResponse carries the opaque session token AND the authenticated user
// so the SPA can hydrate its auth store in one round-trip. The token is also
// set as a cookie; SPAs ignore the body token, Flutter uses it as bearer.
type LoginResponse struct {
Token string `json:"token"`
User UserView `json:"user"`
}
// UserView is the /api/* view of a user. Narrower than dbq.User — no hash,
// no api_token, no subsonic_password.
type UserView struct {
ID pgtype.UUID `json:"id"`
Username string `json:"username"`
IsAdmin bool `json:"is_admin"`
}
// Page is the generic pagination envelope used by list and search endpoints.
// Items is always non-nil at JSON encode time — handlers must initialize
// empty slices explicitly so absent results render as [] rather than null.
type Page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
// ArtistRef is the lightweight artist shape used in lists and search results.
// SortName is exposed so the SPA's alphabetical-grid divider can group rows
// by the catalogue sort order ("The Beatles" → "B"). CoverURL is derived from
// a representative album (most-recent with cover_art_path); empty when the
// artist's discography has no cover art.
type ArtistRef struct {
ID string `json:"id"`
Name string `json:"name"`
SortName string `json:"sort_name"`
AlbumCount int `json:"album_count"`
CoverURL string `json:"cover_url"`
}
// AlbumRef is the lightweight album shape used in lists, artist details,
// and search results. SortTitle is exposed so the SPA's alphabetical-grid
// divider can group rows by sort order ("The Wall" → "W"). CoverURL points
// to /api/albums/{id}/cover.
type AlbumRef struct {
ID string `json:"id"`
Title string `json:"title"`
SortTitle string `json:"sort_title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
Year int `json:"year,omitempty"`
TrackCount int `json:"track_count"`
DurationSec int `json:"duration_sec"`
CoverURL string `json:"cover_url"`
CoverArtSource *string `json:"cover_art_source"`
}
// TrackRef is the lightweight track shape used in album details and search.
// StreamURL points to /api/tracks/{id}/stream which Plan 3 implements.
type TrackRef struct {
ID string `json:"id"`
Title string `json:"title"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
TrackNumber int `json:"track_number,omitempty"`
DiscNumber int `json:"disc_number,omitempty"`
DurationSec int `json:"duration_sec"`
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
// so callers read id/name/album_count from the same path as list entries.
type ArtistDetail struct {
ArtistRef
Albums []AlbumRef `json:"albums"`
// Genres carried by this artist's tracks, for quick-jump chips (#367).
// Always non-nil at JSON so the client can iterate without a null check.
Genres []string `json:"genres"`
}
// AlbumDetail is the response body of GET /api/albums/{id}.
type AlbumDetail struct {
AlbumRef
Tracks []TrackRef `json:"tracks"`
// Genres carried by this album's tracks, for quick-jump chips (#367).
// Derived from the tracks rather than stored on the album, because genre
// lives on tracks and an album's tracks can disagree. Non-nil at JSON.
Genres []string `json:"genres"`
}
// SearchResponse is the body of GET /api/search. Each facet carries its own
// total + limit + offset; the client sends one shared limit/offset and the
// server applies it uniformly to each facet's LIMIT/OFFSET query.
type SearchResponse struct {
Artists Page[ArtistRef] `json:"artists"`
Albums Page[AlbumRef] `json:"albums"`
Tracks Page[TrackRef] `json:"tracks"`
}
// HomePayload is the response body of GET /api/home. Field counts:
// 50 (recently_added) / 25 (rediscover_albums) / 25 (rediscover_artists)
// / 75 (most_played) / 25 (last_played). All slices are non-nil at JSON
// encode time so empty sections render as [] rather than null.
type HomePayload struct {
RecentlyAddedAlbums []AlbumRef `json:"recently_added_albums"`
RediscoverAlbums []AlbumRef `json:"rediscover_albums"`
RediscoverArtists []ArtistRef `json:"rediscover_artists"`
MostPlayedTracks []TrackRef `json:"most_played_tracks"`
LastPlayedArtists []ArtistRef `json:"last_played_artists"`
// You-might-like: in-library albums/artists predicted from the user's
// listening that they don't actively spin. Built daily; gated on a
// minimum listening history so a thin profile gets empty rows.
YouMightLikeAlbums []AlbumRef `json:"you_might_like_albums"`
YouMightLikeArtists []ArtistRef `json:"you_might_like_artists"`
}
// HomeIndexPayload is the response body of GET /api/home/index — the
// per-item rendering variant of /api/home. Same sections, same caps,
// but each section is a flat slice of entity IDs (UUID strings)
// instead of denormalized objects. Slices are non-nil at JSON encode
// time so empty sections render as `[]`.
//
// Client fetches this, then hydrates each tile against the per-entity
// endpoints (/api/albums/{id}, /api/artists/{id}, /api/tracks/{id})
// for genuine progressive rendering. The section name implies the
// entity type so no per-entry type tag is needed.
type HomeIndexPayload struct {
RecentlyAddedAlbums []string `json:"recently_added_albums"`
RediscoverAlbums []string `json:"rediscover_albums"`
RediscoverArtists []string `json:"rediscover_artists"`
MostPlayedTracks []string `json:"most_played_tracks"`
LastPlayedArtists []string `json:"last_played_artists"`
YouMightLikeAlbums []string `json:"you_might_like_albums"`
YouMightLikeArtists []string `json:"you_might_like_artists"`
}
// HistoryEvent is one play in a user's listening history. Used by
// /api/me/history; one event per row from play_events.
type HistoryEvent struct {
ID string `json:"id"` // play_events.id
PlayedAt time.Time `json:"played_at"` // play_events.started_at
Track TrackRef `json:"track"`
}
// HistoryResponse is the body of GET /api/me/history.
type HistoryResponse struct {
Events []HistoryEvent `json:"events"`
HasMore bool `json:"has_more"`
}