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 }