Merge pull request 'feat(subsonic): browse endpoints — getArtists/getAlbum/search3/… (#295)' (#8) from dev into main
This commit was merged in pull request #8.
This commit is contained in:
@@ -59,6 +59,96 @@ func (q *Queries) GetAlbumByID(ctx context.Context, id pgtype.UUID) (Album, erro
|
|||||||
return i, err
|
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
|
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
|
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
|
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
|
const upsertAlbum = `-- name: UpsertAlbum :one
|
||||||
INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path)
|
INSERT INTO albums (title, sort_title, artist_id, release_date, mbid, cover_art_path)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
|||||||
@@ -78,6 +78,46 @@ func (q *Queries) ListArtists(ctx context.Context) ([]Artist, error) {
|
|||||||
return items, nil
|
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
|
const upsertArtist = `-- name: UpsertArtist :one
|
||||||
INSERT INTO artists (name, sort_name, mbid)
|
INSERT INTO artists (name, sort_name, mbid)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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
|
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
|
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
|
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
|
const upsertTrack = `-- name: UpsertTrack :one
|
||||||
INSERT INTO tracks (
|
INSERT INTO tracks (
|
||||||
title, album_id, artist_id, track_number, disc_number,
|
title, album_id, artist_id, track_number, disc_number,
|
||||||
|
|||||||
@@ -20,3 +20,36 @@ SELECT * FROM albums WHERE artist_id = $1 AND title = $2 LIMIT 1;
|
|||||||
|
|
||||||
-- name: ListAlbumsByArtist :many
|
-- name: ListAlbumsByArtist :many
|
||||||
SELECT * FROM albums WHERE artist_id = $1 ORDER BY release_date NULLS LAST, sort_title;
|
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;
|
||||||
|
|||||||
@@ -18,3 +18,9 @@ SELECT * FROM artists WHERE name = $1 LIMIT 1;
|
|||||||
|
|
||||||
-- name: ListArtists :many
|
-- name: ListArtists :many
|
||||||
SELECT * FROM artists ORDER BY sort_name;
|
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;
|
||||||
|
|||||||
@@ -27,3 +27,12 @@ SELECT * FROM tracks WHERE file_path = $1;
|
|||||||
|
|
||||||
-- name: ListTracksByAlbum :many
|
-- name: ListTracksByAlbum :many
|
||||||
SELECT * FROM tracks WHERE album_id = $1 ORDER BY disc_number NULLS LAST, track_number NULLS LAST;
|
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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,10 +18,19 @@ import (
|
|||||||
// GET and POST are accepted; Subsonic's auth params live in the query string
|
// GET and POST are accepted; Subsonic's auth params live in the query string
|
||||||
// either way.
|
// either way.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config) {
|
||||||
|
b := &browseHandlers{pool: pool}
|
||||||
r.Route("/rest", func(sub chi.Router) {
|
r.Route("/rest", func(sub chi.Router) {
|
||||||
sub.Use(Middleware(pool, cfg))
|
sub.Use(Middleware(pool, cfg))
|
||||||
register(sub, "/ping", handlePing)
|
register(sub, "/ping", handlePing)
|
||||||
register(sub, "/getLicense", handleGetLicense)
|
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
|
_ = logger
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user