#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.
176 lines
7.2 KiB
Go
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"`
|
|
}
|