Last bullet of #367. From an album you like, one click to everything else from that year or in that genre. Year was free — AlbumRef already carried it. Genre was not: AlbumDetail is AlbumRef + tracks and neither carried genre, because genre lives on TRACKS. So both detail responses gained a derived `genres` array, computed from the entity's tracks rather than stored, since an album's tracks can legitimately disagree about genre. Split and trimmed identically to the browse index. That's the invariant this whole task turned on: if the chip's matching diverged from the index's splitting, a chip would lead to a page that doesn't contain the album you clicked from. No year link on artist detail. An artist spans many years, so a single one would be a lie about the discography — genres only there. Genre lookup failure is logged and degrades to no chips rather than failing the request; a navigation nicety must not 404 a detail page that otherwise loaded. `genres` is always an array at JSON, never null, matching how every other list field in this package is emitted. ## Type widening, and the TypeScript version of a lesson from earlier today Adding a required field to AlbumDetail/ArtistDetail breaks every typed fixture that constructs one. Six of them across three test files. That's the same shape as the Go signature changes that cost three CI rounds in #2453 — change a type, then go find everything that builds it — so I searched for the constructions before pushing instead of after. All six updated. Tests: the encoded href for a slash-bearing genre ("Rock/Pop" → ?g=Rock%2FPop), the year href, and the no-tags case rendering no chips at all. gofmt verified clean via docker rather than guessed.
166 lines
6.6 KiB
Go
166 lines
6.6 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"`
|
|
}
|
|
|
|
// 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"`
|
|
}
|