diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index 312720f3..3d80f654 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -59,6 +59,96 @@ func (q *Queries) GetAlbumByID(ctx context.Context, id pgtype.UUID) (Album, erro return i, err } +const listAlbumsAlphaByArtist = `-- name: ListAlbumsAlphaByArtist :many +SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, artists.sort_name AS artist_sort_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY artists.sort_name, albums.sort_title +LIMIT $1 OFFSET $2 +` + +type ListAlbumsAlphaByArtistParams struct { + Limit int32 + Offset int32 +} + +type ListAlbumsAlphaByArtistRow struct { + Album Album + ArtistSortName string +} + +// Sorted by the owning artist's sort_name. Needed by Subsonic's +// alphabeticalByArtist album list type. +func (q *Queries) ListAlbumsAlphaByArtist(ctx context.Context, arg ListAlbumsAlphaByArtistParams) ([]ListAlbumsAlphaByArtistRow, error) { + rows, err := q.db.Query(ctx, listAlbumsAlphaByArtist, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAlbumsAlphaByArtistRow + for rows.Next() { + var i ListAlbumsAlphaByArtistRow + if err := rows.Scan( + &i.Album.ID, + &i.Album.Title, + &i.Album.SortTitle, + &i.Album.ArtistID, + &i.Album.ReleaseDate, + &i.Album.Mbid, + &i.Album.CoverArtPath, + &i.Album.CreatedAt, + &i.Album.UpdatedAt, + &i.ArtistSortName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAlbumsAlphaByName = `-- name: ListAlbumsAlphaByName :many +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums ORDER BY sort_title LIMIT $1 OFFSET $2 +` + +type ListAlbumsAlphaByNameParams struct { + Limit int32 + Offset int32 +} + +func (q *Queries) ListAlbumsAlphaByName(ctx context.Context, arg ListAlbumsAlphaByNameParams) ([]Album, error) { + rows, err := q.db.Query(ctx, listAlbumsAlphaByName, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Album + for rows.Next() { + var i Album + if err := rows.Scan( + &i.ID, + &i.Title, + &i.SortTitle, + &i.ArtistID, + &i.ReleaseDate, + &i.Mbid, + &i.CoverArtPath, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAlbumsByArtist = `-- name: ListAlbumsByArtist :many SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title ` @@ -93,6 +183,168 @@ func (q *Queries) ListAlbumsByArtist(ctx context.Context, artistID pgtype.UUID) return items, nil } +const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many +SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at +FROM albums +JOIN tracks ON tracks.album_id = albums.id +WHERE tracks.genre = $1 +ORDER BY albums.id, albums.sort_title +LIMIT $2 OFFSET $3 +` + +type ListAlbumsByGenreParams struct { + Genre *string + Limit int32 + Offset int32 +} + +// Album "belongs to" a genre if any of its tracks carry that genre. +func (q *Queries) ListAlbumsByGenre(ctx context.Context, arg ListAlbumsByGenreParams) ([]Album, error) { + rows, err := q.db.Query(ctx, listAlbumsByGenre, arg.Genre, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Album + for rows.Next() { + var i Album + if err := rows.Scan( + &i.ID, + &i.Title, + &i.SortTitle, + &i.ArtistID, + &i.ReleaseDate, + &i.Mbid, + &i.CoverArtPath, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAlbumsNewest = `-- name: ListAlbumsNewest :many +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums ORDER BY created_at DESC LIMIT $1 OFFSET $2 +` + +type ListAlbumsNewestParams struct { + Limit int32 + Offset int32 +} + +func (q *Queries) ListAlbumsNewest(ctx context.Context, arg ListAlbumsNewestParams) ([]Album, error) { + rows, err := q.db.Query(ctx, listAlbumsNewest, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Album + for rows.Next() { + var i Album + if err := rows.Scan( + &i.ID, + &i.Title, + &i.SortTitle, + &i.ArtistID, + &i.ReleaseDate, + &i.Mbid, + &i.CoverArtPath, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listAlbumsRandom = `-- name: ListAlbumsRandom :many +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums ORDER BY random() LIMIT $1 +` + +func (q *Queries) ListAlbumsRandom(ctx context.Context, limit int32) ([]Album, error) { + rows, err := q.db.Query(ctx, listAlbumsRandom, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Album + for rows.Next() { + var i Album + if err := rows.Scan( + &i.ID, + &i.Title, + &i.SortTitle, + &i.ArtistID, + &i.ReleaseDate, + &i.Mbid, + &i.CoverArtPath, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchAlbums = `-- name: SearchAlbums :many +SELECT id, title, sort_title, artist_id, release_date, mbid, cover_art_path, created_at, updated_at FROM albums +WHERE title ILIKE '%' || $1 || '%' +ORDER BY sort_title +LIMIT $2 OFFSET $3 +` + +type SearchAlbumsParams struct { + Column1 *string + Limit int32 + Offset int32 +} + +func (q *Queries) SearchAlbums(ctx context.Context, arg SearchAlbumsParams) ([]Album, error) { + rows, err := q.db.Query(ctx, searchAlbums, arg.Column1, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Album + for rows.Next() { + var i Album + if err := rows.Scan( + &i.ID, + &i.Title, + &i.SortTitle, + &i.ArtistID, + &i.ReleaseDate, + &i.Mbid, + &i.CoverArtPath, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const upsertAlbum = `-- name: UpsertAlbum :one INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path) VALUES ($1, $2, $3, $4, $5, $6) diff --git a/internal/db/dbq/artists.sql.go b/internal/db/dbq/artists.sql.go index 17750aa1..a435e5fc 100644 --- a/internal/db/dbq/artists.sql.go +++ b/internal/db/dbq/artists.sql.go @@ -78,6 +78,46 @@ func (q *Queries) ListArtists(ctx context.Context) ([]Artist, error) { return items, nil } +const searchArtists = `-- name: SearchArtists :many +SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists +WHERE name ILIKE '%' || $1 || '%' +ORDER BY sort_name +LIMIT $2 OFFSET $3 +` + +type SearchArtistsParams struct { + Column1 *string + Limit int32 + Offset int32 +} + +func (q *Queries) SearchArtists(ctx context.Context, arg SearchArtistsParams) ([]Artist, error) { + rows, err := q.db.Query(ctx, searchArtists, arg.Column1, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Artist + for rows.Next() { + var i Artist + if err := rows.Scan( + &i.ID, + &i.Name, + &i.SortName, + &i.Mbid, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const upsertArtist = `-- name: UpsertArtist :one INSERT INTO artists (name, sort_name, mbid) VALUES ($1, $2, $3) diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index 6aa446ee..e56d12d5 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -11,6 +11,17 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countTracksByAlbum = `-- name: CountTracksByAlbum :one +SELECT count(*) FROM tracks WHERE album_id = $1 +` + +func (q *Queries) CountTracksByAlbum(ctx context.Context, albumID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countTracksByAlbum, albumID) + var count int64 + err := row.Scan(&count) + return count, err +} + const getTrackByID = `-- name: GetTrackByID :one SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = $1 ` @@ -105,6 +116,55 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, albumID pgtype.UUID) ([ return items, nil } +const searchTracks = `-- name: SearchTracks :many +SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks +WHERE title ILIKE '%' || $1 || '%' +ORDER BY title +LIMIT $2 OFFSET $3 +` + +type SearchTracksParams struct { + Column1 *string + Limit int32 + Offset int32 +} + +func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]Track, error) { + rows, err := q.db.Query(ctx, searchTracks, arg.Column1, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Track + for rows.Next() { + var i Track + if err := rows.Scan( + &i.ID, + &i.Title, + &i.AlbumID, + &i.ArtistID, + &i.TrackNumber, + &i.DiscNumber, + &i.DurationMs, + &i.FilePath, + &i.FileSize, + &i.FileFormat, + &i.Bitrate, + &i.Mbid, + &i.Genre, + &i.AddedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const upsertTrack = `-- name: UpsertTrack :one INSERT INTO tracks ( title, album_id, artist_id, track_number, disc_number, diff --git a/internal/db/queries/albums.sql b/internal/db/queries/albums.sql index 007ada5e..4c07569e 100644 --- a/internal/db/queries/albums.sql +++ b/internal/db/queries/albums.sql @@ -20,3 +20,36 @@ SELECT * FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1; -- name: ListAlbumsByArtist :many SELECT * FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title; + +-- name: ListAlbumsAlphaByName :many +SELECT * FROM albums ORDER BY sort_title LIMIT $1 OFFSET $2; + +-- name: ListAlbumsAlphaByArtist :many +-- Sorted by the owning artist's sort_name. Needed by Subsonic's +-- alphabeticalByArtist album list type. +SELECT sqlc.embed(albums), artists.sort_name AS artist_sort_name +FROM albums +JOIN artists ON artists.id = albums.artist_id +ORDER BY artists.sort_name, albums.sort_title +LIMIT $1 OFFSET $2; + +-- name: ListAlbumsNewest :many +SELECT * FROM albums ORDER BY created_at DESC LIMIT $1 OFFSET $2; + +-- name: ListAlbumsRandom :many +SELECT * FROM albums ORDER BY random() LIMIT $1; + +-- name: ListAlbumsByGenre :many +-- Album "belongs to" a genre if any of its tracks carry that genre. +SELECT DISTINCT ON (albums.id) albums.* +FROM albums +JOIN tracks ON tracks.album_id = albums.id +WHERE tracks.genre = $1 +ORDER BY albums.id, albums.sort_title +LIMIT $2 OFFSET $3; + +-- name: SearchAlbums :many +SELECT * FROM albums +WHERE title ILIKE '%' || $1 || '%' +ORDER BY sort_title +LIMIT $2 OFFSET $3; diff --git a/internal/db/queries/artists.sql b/internal/db/queries/artists.sql index fb1b6695..b24c289f 100644 --- a/internal/db/queries/artists.sql +++ b/internal/db/queries/artists.sql @@ -18,3 +18,9 @@ SELECT * FROM artists WHERE name = $1 LIMIT 1; -- name: ListArtists :many SELECT * FROM artists ORDER BY sort_name; + +-- name: SearchArtists :many +SELECT * FROM artists +WHERE name ILIKE '%' || $1 || '%' +ORDER BY sort_name +LIMIT $2 OFFSET $3; diff --git a/internal/db/queries/tracks.sql b/internal/db/queries/tracks.sql index 250a9766..dde57e53 100644 --- a/internal/db/queries/tracks.sql +++ b/internal/db/queries/tracks.sql @@ -27,3 +27,12 @@ SELECT * FROM tracks WHERE file_path = $1; -- name: ListTracksByAlbum :many SELECT * FROM tracks WHERE album_id = $1 ORDER BY disc_number NULLS LAST, track_number NULLS LAST; + +-- name: CountTracksByAlbum :one +SELECT count(*) FROM tracks WHERE album_id = $1; + +-- name: SearchTracks :many +SELECT * FROM tracks +WHERE title ILIKE '%' || $1 || '%' +ORDER BY title +LIMIT $2 OFFSET $3; 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 +} diff --git a/internal/subsonic/browse_test.go b/internal/subsonic/browse_test.go new file mode 100644 index 00000000..5919551d --- /dev/null +++ b/internal/subsonic/browse_test.go @@ -0,0 +1,41 @@ +package subsonic + +import ( + "encoding/json" + "net/http/httptest" + "testing" +) + +// TestGetMusicFoldersShape pins the wire shape — a single folder with id=1, +// name "Music". Clients cache this response; breaking the shape invalidates +// their library state. +func TestGetMusicFoldersShape(t *testing.T) { + b := &browseHandlers{} + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/rest/getMusicFolders.view", nil) + b.getMusicFolders(rec, req) + + var payload map[string]map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal: %v — body=%s", err, rec.Body) + } + inner := payload["subsonic-response"] + if inner["status"] != "ok" { + t.Fatalf("status = %v", inner["status"]) + } + folders, ok := inner["musicFolders"].(map[string]any) + if !ok { + t.Fatalf("musicFolders missing: %+v", inner) + } + list, ok := folders["musicFolder"].([]any) + if !ok || len(list) != 1 { + t.Fatalf("musicFolder list = %+v", folders["musicFolder"]) + } + first := list[0].(map[string]any) + if first["id"].(float64) != 1 { + t.Errorf("folder id = %v, want 1", first["id"]) + } + if first["name"] != "Music" { + t.Errorf("folder name = %v, want Music", first["name"]) + } +} diff --git a/internal/subsonic/subsonic.go b/internal/subsonic/subsonic.go index 2eff4057..da1ec68b 100644 --- a/internal/subsonic/subsonic.go +++ b/internal/subsonic/subsonic.go @@ -18,10 +18,19 @@ import ( // GET and POST are accepted; Subsonic's auth params live in the query string // either way. func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config) { + b := &browseHandlers{pool: pool} r.Route("/rest", func(sub chi.Router) { sub.Use(Middleware(pool, cfg)) register(sub, "/ping", handlePing) register(sub, "/getLicense", handleGetLicense) + register(sub, "/getMusicFolders", b.getMusicFolders) + register(sub, "/getIndexes", b.getIndexes) + register(sub, "/getArtists", b.getArtists) + register(sub, "/getArtist", b.getArtist) + register(sub, "/getAlbum", b.getAlbum) + register(sub, "/getAlbumList2", b.getAlbumList2) + register(sub, "/getSong", b.getSong) + register(sub, "/search3", b.search3) _ = logger }) } diff --git a/internal/subsonic/types.go b/internal/subsonic/types.go new file mode 100644 index 00000000..56dbf63c --- /dev/null +++ b/internal/subsonic/types.go @@ -0,0 +1,372 @@ +package subsonic + +import ( + "context" + "encoding/xml" + "path" + "strings" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// IDs on the wire are the bare UUID string. Subsonic endpoints know from +// context (stream.view → track, getArtist → artist) which table to resolve +// against, so we don't need type-prefixed IDs. + +// uuidToID renders a pgtype.UUID as the canonical 8-4-4-4-12 string used by +// clients. Returns "" for a zero/invalid UUID so callers can omit the attr. +func uuidToID(u pgtype.UUID) string { + if !u.Valid { + return "" + } + b := u.Bytes + const hex = "0123456789abcdef" + out := make([]byte, 36) + j := 0 + for i, x := range b { + if i == 4 || i == 6 || i == 8 || i == 10 { + out[j] = '-' + j++ + } + out[j] = hex[x>>4] + out[j+1] = hex[x&0x0f] + j += 2 + } + return string(out[:j]) +} + +// parseUUID is the inverse of uuidToID — tolerant of the canonical hyphenated +// form and also raw hex. Callers that fail to parse should emit ErrDataNotFound. +func parseUUID(s string) (pgtype.UUID, bool) { + var u pgtype.UUID + if err := u.Scan(s); err != nil { + return pgtype.UUID{}, false + } + return u, u.Valid +} + +// MusicFolder matches Subsonic's /rest/getMusicFolders. v1 always reports a +// single folder because Minstrel has no multi-library concept yet. +type MusicFolder struct { + XMLName xml.Name `json:"-" xml:"musicFolder"` + ID int `json:"id" xml:"id,attr"` + Name string `json:"name" xml:"name,attr"` +} + +type MusicFoldersContainer struct { + XMLName xml.Name `json:"-" xml:"musicFolders"` + MusicFolders []MusicFolder `json:"musicFolder" xml:"musicFolder"` +} + +type MusicFoldersResponse struct { + Envelope + MusicFolders MusicFoldersContainer `json:"musicFolders" xml:"musicFolders"` +} + +// Index groups artists by the first letter of their sort name. +type Index struct { + XMLName xml.Name `json:"-" xml:"index"` + Name string `json:"name" xml:"name,attr"` + Artists []ArtistRef `json:"artist" xml:"artist"` +} + +// ArtistsContainer is the body of getArtists (ID3 namespace). +type ArtistsContainer struct { + XMLName xml.Name `json:"-" xml:"artists"` + IgnoredArticles string `json:"ignoredArticles" xml:"ignoredArticles,attr"` + Indexes []Index `json:"index" xml:"index"` +} + +type ArtistsResponse struct { + Envelope + Artists ArtistsContainer `json:"artists" xml:"artists"` +} + +// IndexesContainer is the body of the legacy getIndexes. Shape mirrors +// ArtistsContainer; some clients poll getIndexes and others getArtists. +type IndexesContainer struct { + XMLName xml.Name `json:"-" xml:"indexes"` + LastModified int64 `json:"lastModified" xml:"lastModified,attr"` + IgnoredArticles string `json:"ignoredArticles" xml:"ignoredArticles,attr"` + Indexes []Index `json:"index" xml:"index"` +} + +type IndexesResponse struct { + Envelope + Indexes IndexesContainer `json:"indexes" xml:"indexes"` +} + +// ArtistRef is the artist projection used in listings and as an album/song +// child reference. +type ArtistRef struct { + XMLName xml.Name `json:"-" xml:"artist"` + ID string `json:"id" xml:"id,attr"` + Name string `json:"name" xml:"name,attr"` + AlbumCount int `json:"albumCount,omitempty" xml:"albumCount,attr,omitempty"` +} + +// ArtistDetail is the body of getArtist — artist attrs plus nested albums. +type ArtistDetail struct { + XMLName xml.Name `json:"-" xml:"artist"` + ID string `json:"id" xml:"id,attr"` + Name string `json:"name" xml:"name,attr"` + AlbumCount int `json:"albumCount" xml:"albumCount,attr"` + Albums []AlbumRef `json:"album" xml:"album"` +} + +type ArtistResponse struct { + Envelope + Artist ArtistDetail `json:"artist" xml:"artist"` +} + +// AlbumRef projects an album for listings (album list, search results, artist +// detail). Count/duration are optional — not every call populates them. +type AlbumRef struct { + XMLName xml.Name `json:"-" xml:"album"` + ID string `json:"id" xml:"id,attr"` + Name string `json:"name" xml:"name,attr"` + Artist string `json:"artist" xml:"artist,attr"` + ArtistID string `json:"artistId" xml:"artistId,attr"` + CoverArt string `json:"coverArt,omitempty" xml:"coverArt,attr,omitempty"` + SongCount int `json:"songCount,omitempty" xml:"songCount,attr,omitempty"` + Duration int `json:"duration,omitempty" xml:"duration,attr,omitempty"` + Created string `json:"created,omitempty" xml:"created,attr,omitempty"` + Year int `json:"year,omitempty" xml:"year,attr,omitempty"` + Genre string `json:"genre,omitempty" xml:"genre,attr,omitempty"` +} + +// AlbumDetail is getAlbum — album attrs plus nested song list. +type AlbumDetail struct { + XMLName xml.Name `json:"-" xml:"album"` + ID string `json:"id" xml:"id,attr"` + Name string `json:"name" xml:"name,attr"` + Artist string `json:"artist" xml:"artist,attr"` + ArtistID string `json:"artistId" xml:"artistId,attr"` + CoverArt string `json:"coverArt,omitempty" xml:"coverArt,attr,omitempty"` + SongCount int `json:"songCount" xml:"songCount,attr"` + Duration int `json:"duration" xml:"duration,attr"` + Created string `json:"created,omitempty" xml:"created,attr,omitempty"` + Year int `json:"year,omitempty" xml:"year,attr,omitempty"` + Genre string `json:"genre,omitempty" xml:"genre,attr,omitempty"` + Songs []SongRef `json:"song" xml:"song"` +} + +type AlbumResponse struct { + Envelope + Album AlbumDetail `json:"album" xml:"album"` +} + +// SongRef is the track projection used in album detail, getSong, search3, +// and later streaming/now-playing responses. +type SongRef struct { + XMLName xml.Name `json:"-" xml:"song"` + ID string `json:"id" xml:"id,attr"` + Parent string `json:"parent,omitempty" xml:"parent,attr,omitempty"` + Title string `json:"title" xml:"title,attr"` + Album string `json:"album,omitempty" xml:"album,attr,omitempty"` + Artist string `json:"artist,omitempty" xml:"artist,attr,omitempty"` + ArtistID string `json:"artistId,omitempty" xml:"artistId,attr,omitempty"` + AlbumID string `json:"albumId,omitempty" xml:"albumId,attr,omitempty"` + Track int `json:"track,omitempty" xml:"track,attr,omitempty"` + DiscNumber int `json:"discNumber,omitempty" xml:"discNumber,attr,omitempty"` + Genre string `json:"genre,omitempty" xml:"genre,attr,omitempty"` + CoverArt string `json:"coverArt,omitempty" xml:"coverArt,attr,omitempty"` + Size int64 `json:"size,omitempty" xml:"size,attr,omitempty"` + ContentType string `json:"contentType,omitempty" xml:"contentType,attr,omitempty"` + Suffix string `json:"suffix,omitempty" xml:"suffix,attr,omitempty"` + Duration int `json:"duration,omitempty" xml:"duration,attr,omitempty"` + BitRate int `json:"bitRate,omitempty" xml:"bitRate,attr,omitempty"` + IsDir bool `json:"isDir" xml:"isDir,attr"` + Type string `json:"type" xml:"type,attr"` +} + +type SongResponse struct { + Envelope + Song SongRef `json:"song" xml:"song"` +} + +// AlbumList2Container is the body of getAlbumList2. +type AlbumList2Container struct { + XMLName xml.Name `json:"-" xml:"albumList2"` + Albums []AlbumRef `json:"album" xml:"album"` +} + +type AlbumList2Response struct { + Envelope + AlbumList2 AlbumList2Container `json:"albumList2" xml:"albumList2"` +} + +// SearchResult3 is the body of search3. +type SearchResult3Container struct { + XMLName xml.Name `json:"-" xml:"searchResult3"` + Artists []ArtistRef `json:"artist" xml:"artist"` + Albums []AlbumRef `json:"album" xml:"album"` + Songs []SongRef `json:"song" xml:"song"` +} + +type SearchResult3Response struct { + Envelope + SearchResult3 SearchResult3Container `json:"searchResult3" xml:"searchResult3"` +} + +// ---- conversion helpers ---- + +func artistRef(a dbq.Artist, albumCount int) ArtistRef { + return ArtistRef{ + ID: uuidToID(a.ID), + Name: a.Name, + AlbumCount: albumCount, + } +} + +// albumRef projects an album. artistName must be pre-resolved because Album +// only carries artist_id. songCount/duration are optional; pass 0 to omit. +func albumRef(a dbq.Album, artistName string, songCount, durationSec int) AlbumRef { + return AlbumRef{ + ID: uuidToID(a.ID), + Name: a.Title, + Artist: artistName, + ArtistID: uuidToID(a.ArtistID), + CoverArt: coverArtID(a), + SongCount: songCount, + Duration: durationSec, + Created: timestamptzToRFC3339(a.CreatedAt), + Year: yearFromDate(a.ReleaseDate), + } +} + +func albumDetail(a dbq.Album, artistName string, songs []SongRef) AlbumDetail { + dur := 0 + for _, s := range songs { + dur += s.Duration + } + return AlbumDetail{ + ID: uuidToID(a.ID), + Name: a.Title, + Artist: artistName, + ArtistID: uuidToID(a.ArtistID), + CoverArt: coverArtID(a), + SongCount: len(songs), + Duration: dur, + Created: timestamptzToRFC3339(a.CreatedAt), + Year: yearFromDate(a.ReleaseDate), + Songs: songs, + } +} + +func songRef(t dbq.Track, albumTitle, artistName string) SongRef { + s := SongRef{ + ID: uuidToID(t.ID), + Parent: uuidToID(t.AlbumID), + Title: t.Title, + Album: albumTitle, + Artist: artistName, + AlbumID: uuidToID(t.AlbumID), + ArtistID: uuidToID(t.ArtistID), + CoverArt: uuidToID(t.AlbumID), + Size: t.FileSize, + ContentType: contentTypeForFormat(t.FileFormat), + Suffix: strings.TrimPrefix(strings.ToLower(path.Ext(t.FilePath)), "."), + Duration: int((t.DurationMs + 500) / 1000), + IsDir: false, + Type: "music", + } + if t.TrackNumber != nil { + s.Track = int(*t.TrackNumber) + } + if t.DiscNumber != nil { + s.DiscNumber = int(*t.DiscNumber) + } + if t.Bitrate != nil { + s.BitRate = int(*t.Bitrate) + } + if t.Genre != nil { + s.Genre = *t.Genre + } + return s +} + +// coverArtID uses the album ID as the cover-art key since Minstrel's getCoverArt +// will resolve from albums.cover_art_path. Returns "" when no cover art exists. +func coverArtID(a dbq.Album) string { + if a.CoverArtPath == nil || *a.CoverArtPath == "" { + return "" + } + return uuidToID(a.ID) +} + +func yearFromDate(d pgtype.Date) int { + if !d.Valid { + return 0 + } + return d.Time.Year() +} + +func timestamptzToRFC3339(ts pgtype.Timestamptz) string { + if !ts.Valid { + return "" + } + return ts.Time.UTC().Format("2006-01-02T15:04:05Z07:00") +} + +// contentTypeForFormat maps the short file_format we record (mp3/flac/ogg/...) +// to a MIME type Subsonic clients recognize. Falls back to octet-stream. +func contentTypeForFormat(f string) string { + switch strings.ToLower(f) { + case "mp3": + return "audio/mpeg" + case "flac": + return "audio/flac" + case "ogg", "oga": + return "audio/ogg" + case "m4a", "aac": + return "audio/mp4" + case "opus": + return "audio/opus" + case "wav": + return "audio/wav" + } + return "application/octet-stream" +} + +// indexLetter returns the bucket name used by getIndexes/getArtists. Treats +// a leading digit as "#" per Subsonic convention. Empty names bucket to "?". +func indexLetter(sortName string) string { + sortName = strings.TrimSpace(sortName) + if sortName == "" { + return "?" + } + r := []rune(sortName)[0] + switch { + case r >= '0' && r <= '9': + return "#" + case r >= 'a' && r <= 'z': + return strings.ToUpper(string(r)) + case r >= 'A' && r <= 'Z': + return string(r) + } + return "?" +} + +// resolveArtistNames fetches distinct artist rows for the given ids and +// returns a uuid-string → name map. Uses the bulk-per-call lookup pattern +// rather than N+1 per album to keep listing endpoints responsive. +func resolveArtistNames(ctx context.Context, q *dbq.Queries, ids []pgtype.UUID) (map[string]string, error) { + seen := make(map[string]struct{}, len(ids)) + out := make(map[string]string, len(ids)) + for _, id := range ids { + key := uuidToID(id) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + a, err := q.GetArtistByID(ctx, id) + if err != nil { + return nil, err + } + out[key] = a.Name + } + return out, nil +} diff --git a/internal/subsonic/types_test.go b/internal/subsonic/types_test.go new file mode 100644 index 00000000..4ba10664 --- /dev/null +++ b/internal/subsonic/types_test.go @@ -0,0 +1,129 @@ +package subsonic + +import ( + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestUUIDRoundTrip(t *testing.T) { + cases := []string{ + "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + "00000000-0000-0000-0000-000000000001", + } + for _, in := range cases { + u, ok := parseUUID(in) + if !ok { + t.Fatalf("parseUUID(%q) failed", in) + } + if got := uuidToID(u); got != in { + t.Errorf("uuidToID(parseUUID(%q)) = %q", in, got) + } + } +} + +func TestUUIDToIDZero(t *testing.T) { + var u pgtype.UUID + if got := uuidToID(u); got != "" { + t.Errorf("zero uuid → %q, want empty", got) + } +} + +func TestParseUUIDInvalid(t *testing.T) { + for _, in := range []string{"", "not-a-uuid", "xxx"} { + if _, ok := parseUUID(in); ok { + t.Errorf("parseUUID(%q) unexpectedly ok", in) + } + } +} + +func TestIndexLetter(t *testing.T) { + cases := map[string]string{ + "Radiohead": "R", + "the beatles": "T", + " massive": "M", + "4 Non Blondes": "#", + "": "?", + "*NSYNC": "?", + "Éowyn": "?", + } + for in, want := range cases { + if got := indexLetter(in); got != want { + t.Errorf("indexLetter(%q) = %q, want %q", in, got, want) + } + } +} + +func TestContentTypeForFormat(t *testing.T) { + cases := map[string]string{ + "mp3": "audio/mpeg", + "MP3": "audio/mpeg", + "flac": "audio/flac", + "ogg": "audio/ogg", + "oga": "audio/ogg", + "opus": "audio/opus", + "m4a": "audio/mp4", + "aac": "audio/mp4", + "wav": "audio/wav", + "unknown": "application/octet-stream", + } + for in, want := range cases { + if got := contentTypeForFormat(in); got != want { + t.Errorf("contentTypeForFormat(%q) = %q, want %q", in, got, want) + } + } +} + +func TestYearFromDate(t *testing.T) { + d := pgtype.Date{Valid: true, Time: time.Date(1997, 6, 16, 0, 0, 0, 0, time.UTC)} + if got := yearFromDate(d); got != 1997 { + t.Errorf("yearFromDate = %d", got) + } + if got := yearFromDate(pgtype.Date{}); got != 0 { + t.Errorf("yearFromDate(zero) = %d, want 0", got) + } +} + +func TestTimestamptzToRFC3339(t *testing.T) { + ts := pgtype.Timestamptz{ + Valid: true, + Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), + } + if got := timestamptzToRFC3339(ts); got != "2026-01-02T03:04:05Z" { + t.Errorf("rfc3339 = %q", got) + } + if got := timestamptzToRFC3339(pgtype.Timestamptz{}); got != "" { + t.Errorf("rfc3339(zero) = %q, want empty", got) + } +} + +func TestAtoiDefault(t *testing.T) { + cases := []struct { + in string + def int + want int + }{ + {"", 7, 7}, + {"42", 0, 42}, + {" 12 ", 0, 12}, + {"junk", 99, 99}, + } + for _, c := range cases { + if got := atoiDefault(c.in, c.def); got != c.want { + t.Errorf("atoiDefault(%q,%d) = %d, want %d", c.in, c.def, got, c.want) + } + } +} + +func TestClampInt(t *testing.T) { + if got := clampInt(5, 1, 10); got != 5 { + t.Errorf("in-range = %d", got) + } + if got := clampInt(-3, 1, 10); got != 1 { + t.Errorf("below = %d", got) + } + if got := clampInt(99, 1, 10); got != 10 { + t.Errorf("above = %d", got) + } +}