diff --git a/internal/subsonic/browse.go b/internal/subsonic/browse.go new file mode 100644 index 00000000..9405bd65 --- /dev/null +++ b/internal/subsonic/browse.go @@ -0,0 +1,451 @@ +package subsonic + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// browseHandlers carries the shared pgxpool used by every browse endpoint. +// Handlers are methods so Mount can register them without package globals. +type browseHandlers struct { + pool *pgxpool.Pool +} + +// Subsonic's "ignoredArticles" is advisory — it tells clients which leading +// words to strip when computing their own index buckets. The server is +// responsible for storing sort_name in the desired form; this attr exists +// for backwards compatibility with clients that regroup client-side. +const ignoredArticles = "The El La Los Las Le Les" + +// getMusicFolders always returns a single folder. Minstrel has no multi-library +// concept in M1; clients use the folder id as an opaque cookie so we return 1. +func (b *browseHandlers) getMusicFolders(w http.ResponseWriter, r *http.Request) { + Write(w, r, MusicFoldersResponse{ + Envelope: NewEnvelope("ok"), + MusicFolders: MusicFoldersContainer{ + MusicFolders: []MusicFolder{{ID: 1, Name: "Music"}}, + }, + }) +} + +// getIndexes is the legacy namespace — identical shape to getArtists but with +// a lastModified attr. We report the max(updated_at) across artists so clients +// can short-circuit re-sync when nothing has changed. +func (b *browseHandlers) getIndexes(w http.ResponseWriter, r *http.Request) { + q := dbq.New(b.pool) + indexes, lastMod, err := b.buildArtistIndexes(r.Context(), q) + if err != nil { + WriteFail(w, r, ErrGeneric, "Failed to load artists") + return + } + Write(w, r, IndexesResponse{ + Envelope: NewEnvelope("ok"), + Indexes: IndexesContainer{ + LastModified: lastMod, + IgnoredArticles: ignoredArticles, + Indexes: indexes, + }, + }) +} + +// getArtists is the ID3 equivalent of getIndexes. Same bucketing, no +// lastModified. Most modern clients prefer this endpoint. +func (b *browseHandlers) getArtists(w http.ResponseWriter, r *http.Request) { + q := dbq.New(b.pool) + indexes, _, err := b.buildArtistIndexes(r.Context(), q) + if err != nil { + WriteFail(w, r, ErrGeneric, "Failed to load artists") + return + } + Write(w, r, ArtistsResponse{ + Envelope: NewEnvelope("ok"), + Artists: ArtistsContainer{ + IgnoredArticles: ignoredArticles, + Indexes: indexes, + }, + }) +} + +// buildArtistIndexes fetches every artist, buckets them by first letter, and +// returns indexes + the unix-ms timestamp of the most recently updated artist. +func (b *browseHandlers) buildArtistIndexes(ctx context.Context, q *dbq.Queries) ([]Index, int64, error) { + artists, err := q.ListArtists(ctx) + if err != nil { + return nil, 0, err + } + buckets := make(map[string][]ArtistRef) + var order []string + var lastMod int64 + for _, a := range artists { + if a.UpdatedAt.Valid { + ms := a.UpdatedAt.Time.UnixMilli() + if ms > lastMod { + lastMod = ms + } + } + albums, err := q.ListAlbumsByArtist(ctx, a.ID) + if err != nil { + return nil, 0, err + } + ref := artistRef(a, len(albums)) + letter := indexLetter(a.SortName) + if _, seen := buckets[letter]; !seen { + order = append(order, letter) + } + buckets[letter] = append(buckets[letter], ref) + } + indexes := make([]Index, 0, len(order)) + for _, letter := range order { + indexes = append(indexes, Index{Name: letter, Artists: buckets[letter]}) + } + return indexes, lastMod, nil +} + +// getArtist returns one artist with its nested albums. id is a UUID string. +func (b *browseHandlers) getArtist(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id") + return + } + id, ok := parseUUID(idStr) + if !ok { + WriteFail(w, r, ErrDataNotFound, "Artist not found") + return + } + q := dbq.New(b.pool) + artist, err := q.GetArtistByID(r.Context(), id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + WriteFail(w, r, ErrDataNotFound, "Artist not found") + return + } + WriteFail(w, r, ErrGeneric, "Failed to load artist") + return + } + albums, err := q.ListAlbumsByArtist(r.Context(), id) + if err != nil { + WriteFail(w, r, ErrGeneric, "Failed to load albums") + return + } + refs := make([]AlbumRef, 0, len(albums)) + for _, a := range albums { + count, _ := q.CountTracksByAlbum(r.Context(), a.ID) + refs = append(refs, albumRef(a, artist.Name, int(count), 0)) + } + Write(w, r, ArtistResponse{ + Envelope: NewEnvelope("ok"), + Artist: ArtistDetail{ + ID: uuidToID(artist.ID), + Name: artist.Name, + AlbumCount: len(albums), + Albums: refs, + }, + }) +} + +// getAlbum returns one album with its full track list. +func (b *browseHandlers) getAlbum(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id") + return + } + id, ok := parseUUID(idStr) + if !ok { + WriteFail(w, r, ErrDataNotFound, "Album not found") + return + } + q := dbq.New(b.pool) + album, err := q.GetAlbumByID(r.Context(), id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + WriteFail(w, r, ErrDataNotFound, "Album not found") + return + } + WriteFail(w, r, ErrGeneric, "Failed to load album") + return + } + artist, err := q.GetArtistByID(r.Context(), album.ArtistID) + if err != nil { + WriteFail(w, r, ErrGeneric, "Failed to load album artist") + return + } + tracks, err := q.ListTracksByAlbum(r.Context(), id) + if err != nil { + WriteFail(w, r, ErrGeneric, "Failed to load tracks") + return + } + songs := make([]SongRef, 0, len(tracks)) + for _, t := range tracks { + songs = append(songs, songRef(t, album.Title, artist.Name)) + } + Write(w, r, AlbumResponse{ + Envelope: NewEnvelope("ok"), + Album: albumDetail(album, artist.Name, songs), + }) +} + +// getSong returns a single track by id. Parent metadata (album/artist name) +// is included so clients can render the song outside an album context. +func (b *browseHandlers) getSong(w http.ResponseWriter, r *http.Request) { + idStr := r.URL.Query().Get("id") + if idStr == "" { + WriteFail(w, r, ErrMissingParameter, "Missing required parameter: id") + return + } + id, ok := parseUUID(idStr) + if !ok { + WriteFail(w, r, ErrDataNotFound, "Song not found") + return + } + q := dbq.New(b.pool) + track, err := q.GetTrackByID(r.Context(), id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + WriteFail(w, r, ErrDataNotFound, "Song not found") + return + } + WriteFail(w, r, ErrGeneric, "Failed to load song") + return + } + album, err := q.GetAlbumByID(r.Context(), track.AlbumID) + if err != nil { + WriteFail(w, r, ErrGeneric, "Failed to load song album") + return + } + artist, err := q.GetArtistByID(r.Context(), track.ArtistID) + if err != nil { + WriteFail(w, r, ErrGeneric, "Failed to load song artist") + return + } + Write(w, r, SongResponse{ + Envelope: NewEnvelope("ok"), + Song: songRef(track, album.Title, artist.Name), + }) +} + +// getAlbumList2 returns a paged album list filtered by type. Supported types +// match the Subsonic spec § getAlbumList2. "recent"/"frequent" require play +// history (M2) and currently return an empty list rather than an error so +// clients that poll them don't break. +func (b *browseHandlers) getAlbumList2(w http.ResponseWriter, r *http.Request) { + params := r.URL.Query() + listType := params.Get("type") + if listType == "" { + WriteFail(w, r, ErrMissingParameter, "Missing required parameter: type") + return + } + size := clampInt(atoiDefault(params.Get("size"), 10), 1, 500) + offset := atoiDefault(params.Get("offset"), 0) + if offset < 0 { + offset = 0 + } + + q := dbq.New(b.pool) + var albums []dbq.Album + var err error + + switch listType { + case "newest": + albums, err = q.ListAlbumsNewest(r.Context(), dbq.ListAlbumsNewestParams{ + Limit: int32(size), Offset: int32(offset), + }) + case "alphabeticalByName": + albums, err = q.ListAlbumsAlphaByName(r.Context(), dbq.ListAlbumsAlphaByNameParams{ + Limit: int32(size), Offset: int32(offset), + }) + case "alphabeticalByArtist": + rows, rerr := q.ListAlbumsAlphaByArtist(r.Context(), dbq.ListAlbumsAlphaByArtistParams{ + Limit: int32(size), Offset: int32(offset), + }) + if rerr != nil { + err = rerr + break + } + albums = make([]dbq.Album, len(rows)) + for i, row := range rows { + albums[i] = row.Album + } + case "random": + albums, err = q.ListAlbumsRandom(r.Context(), int32(size)) + case "byGenre": + genre := params.Get("genre") + if genre == "" { + WriteFail(w, r, ErrMissingParameter, "Missing required parameter: genre") + return + } + albums, err = q.ListAlbumsByGenre(r.Context(), dbq.ListAlbumsByGenreParams{ + Genre: &genre, Limit: int32(size), Offset: int32(offset), + }) + case "recent", "frequent": + // Play history lands in M2; return empty to keep clients happy. + albums = nil + default: + WriteFail(w, r, ErrGeneric, "Invalid list type: "+listType) + return + } + if err != nil { + WriteFail(w, r, ErrGeneric, "Failed to list albums") + return + } + + refs, err := b.albumRefs(r.Context(), q, albums) + if err != nil { + WriteFail(w, r, ErrGeneric, "Failed to resolve albums") + return + } + Write(w, r, AlbumList2Response{ + Envelope: NewEnvelope("ok"), + AlbumList2: AlbumList2Container{Albums: refs}, + }) +} + +// search3 runs three independent searches — artist name, album title, song +// title — each bounded by its own count/offset. The Subsonic spec lets the +// caller page each facet separately, which is why the params triplicate. +func (b *browseHandlers) search3(w http.ResponseWriter, r *http.Request) { + params := r.URL.Query() + query := params.Get("query") + if query == "" { + WriteFail(w, r, ErrMissingParameter, "Missing required parameter: query") + return + } + artistCount := clampInt(atoiDefault(params.Get("artistCount"), 20), 0, 500) + artistOffset := atoiDefault(params.Get("artistOffset"), 0) + albumCount := clampInt(atoiDefault(params.Get("albumCount"), 20), 0, 500) + albumOffset := atoiDefault(params.Get("albumOffset"), 0) + songCount := clampInt(atoiDefault(params.Get("songCount"), 20), 0, 500) + songOffset := atoiDefault(params.Get("songOffset"), 0) + + q := dbq.New(b.pool) + // Subsonic clients send "" as a wildcard in some implementations; we pass + // the literal query through sqlc's ILIKE param so it matches substrings. + needle := &query + + // JSON marshaling of nil slices produces `null`, which some clients reject. + // Initialize each facet to an empty slice so absent matches render as `[]`. + result := SearchResult3Container{ + Artists: []ArtistRef{}, + Albums: []AlbumRef{}, + Songs: []SongRef{}, + } + + if artistCount > 0 { + artists, err := q.SearchArtists(r.Context(), dbq.SearchArtistsParams{ + Column1: needle, Limit: int32(artistCount), Offset: int32(artistOffset), + }) + if err != nil { + WriteFail(w, r, ErrGeneric, "Artist search failed") + return + } + for _, a := range artists { + albums, aerr := q.ListAlbumsByArtist(r.Context(), a.ID) + if aerr != nil { + WriteFail(w, r, ErrGeneric, "Artist search failed") + return + } + result.Artists = append(result.Artists, artistRef(a, len(albums))) + } + } + + if albumCount > 0 { + albums, err := q.SearchAlbums(r.Context(), dbq.SearchAlbumsParams{ + Column1: needle, Limit: int32(albumCount), Offset: int32(albumOffset), + }) + if err != nil { + WriteFail(w, r, ErrGeneric, "Album search failed") + return + } + refs, rerr := b.albumRefs(r.Context(), q, albums) + if rerr != nil { + WriteFail(w, r, ErrGeneric, "Album search failed") + return + } + result.Albums = refs + } + + if songCount > 0 { + tracks, err := q.SearchTracks(r.Context(), dbq.SearchTracksParams{ + Column1: needle, Limit: int32(songCount), Offset: int32(songOffset), + }) + if err != nil { + WriteFail(w, r, ErrGeneric, "Song search failed") + return + } + for _, t := range tracks { + album, aerr := q.GetAlbumByID(r.Context(), t.AlbumID) + if aerr != nil { + WriteFail(w, r, ErrGeneric, "Song search failed") + return + } + artist, aerr := q.GetArtistByID(r.Context(), t.ArtistID) + if aerr != nil { + WriteFail(w, r, ErrGeneric, "Song search failed") + return + } + result.Songs = append(result.Songs, songRef(t, album.Title, artist.Name)) + } + } + + Write(w, r, SearchResult3Response{ + Envelope: NewEnvelope("ok"), + SearchResult3: result, + }) +} + +// albumRefs converts a list of dbq.Album rows to AlbumRef, resolving artist +// name and track count per album. Uses resolveArtistNames for the artist side +// (one query per distinct artist) and CountTracksByAlbum per album. For small +// page sizes (≤ 500) this is well within budget. +func (b *browseHandlers) albumRefs(ctx context.Context, q *dbq.Queries, albums []dbq.Album) ([]AlbumRef, error) { + // Always return a non-nil slice — JSON clients expect [] on empty results, + // not null. Same rationale applies to search3 facet slices. + if len(albums) == 0 { + return []AlbumRef{}, nil + } + ids := make([]pgtype.UUID, 0, len(albums)) + for _, a := range albums { + ids = append(ids, a.ArtistID) + } + names, err := resolveArtistNames(ctx, q, ids) + if err != nil { + return nil, err + } + refs := make([]AlbumRef, 0, len(albums)) + for _, a := range albums { + count, _ := q.CountTracksByAlbum(ctx, a.ID) + refs = append(refs, albumRef(a, names[uuidToID(a.ArtistID)], int(count), 0)) + } + return refs, nil +} + +func atoiDefault(s string, def int) int { + if s == "" { + return def + } + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return def + } + return n +} + +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +}