feat: add browse api and subsonic read endpoints
Expose internal browse endpoints for artists, albums, tracks, and search using the SQLite-backed library service. Add Subsonic-compatible getArtist, getAlbum, getSong, and stream.view handlers by mapping the same persistence layer into lightweight response envelopes.
This commit is contained in:
@@ -13,6 +13,7 @@ type Artist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AlbumCount int `json:"albumCount"`
|
||||
CoverArtID string `json:"coverArtId"`
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
@@ -44,6 +45,22 @@ type HomePayload struct {
|
||||
Artists []Artist `json:"artists"`
|
||||
}
|
||||
|
||||
type ArtistDetail struct {
|
||||
Artist
|
||||
Albums []Album `json:"albums"`
|
||||
}
|
||||
|
||||
type AlbumDetail struct {
|
||||
Album
|
||||
Tracks []Track `json:"tracks"`
|
||||
}
|
||||
|
||||
type SearchResults struct {
|
||||
Artists []Artist `json:"artists"`
|
||||
Albums []Album `json:"albums"`
|
||||
Tracks []Track `json:"tracks"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
}
|
||||
@@ -73,6 +90,7 @@ func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT a.id, a.name, COUNT(al.id) AS album_count
|
||||
, COALESCE(a.cover_art_id, '')
|
||||
FROM artists a
|
||||
LEFT JOIN albums al ON al.artist_id = a.id
|
||||
GROUP BY a.id, a.name
|
||||
@@ -88,7 +106,7 @@ func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) {
|
||||
var artists []Artist
|
||||
for rows.Next() {
|
||||
var artist Artist
|
||||
if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount); err != nil {
|
||||
if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount, &artist.CoverArtID); err != nil {
|
||||
return nil, fmt.Errorf("scan artist: %w", err)
|
||||
}
|
||||
artists = append(artists, artist)
|
||||
@@ -97,6 +115,34 @@ func (s *Service) Artists(ctx context.Context, limit int) ([]Artist, error) {
|
||||
return artists, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) ArtistByID(ctx context.Context, id string) (ArtistDetail, error) {
|
||||
var artist ArtistDetail
|
||||
|
||||
err := s.db.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT a.id, a.name, COUNT(al.id) AS album_count, COALESCE(a.cover_art_id, '')
|
||||
FROM artists a
|
||||
LEFT JOIN albums al ON al.artist_id = a.id
|
||||
WHERE a.id = ?
|
||||
GROUP BY a.id, a.name, a.cover_art_id`,
|
||||
id,
|
||||
).Scan(&artist.ID, &artist.Name, &artist.AlbumCount, &artist.CoverArtID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ArtistDetail{}, ErrNotFound
|
||||
}
|
||||
return ArtistDetail{}, fmt.Errorf("query artist by id: %w", err)
|
||||
}
|
||||
|
||||
albums, err := s.albumsByArtistID(ctx, id)
|
||||
if err != nil {
|
||||
return ArtistDetail{}, err
|
||||
}
|
||||
artist.Albums = albums
|
||||
|
||||
return artist, nil
|
||||
}
|
||||
|
||||
func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
@@ -127,6 +173,44 @@ func (s *Service) RecentAlbums(ctx context.Context, limit int) ([]Album, error)
|
||||
return albums, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) AlbumByID(ctx context.Context, id string) (AlbumDetail, error) {
|
||||
var album AlbumDetail
|
||||
|
||||
err := s.db.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0),
|
||||
COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '')
|
||||
FROM albums al
|
||||
JOIN artists a ON a.id = al.artist_id
|
||||
LEFT JOIN tracks t ON t.album_id = al.id
|
||||
WHERE al.id = ?
|
||||
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id`,
|
||||
id,
|
||||
).Scan(
|
||||
&album.ID,
|
||||
&album.ArtistID,
|
||||
&album.ArtistName,
|
||||
&album.Title,
|
||||
&album.Year,
|
||||
&album.TrackCount,
|
||||
&album.CoverArtID,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AlbumDetail{}, ErrNotFound
|
||||
}
|
||||
return AlbumDetail{}, fmt.Errorf("query album by id: %w", err)
|
||||
}
|
||||
|
||||
tracks, err := s.tracksByAlbumID(ctx, id)
|
||||
if err != nil {
|
||||
return AlbumDetail{}, err
|
||||
}
|
||||
album.Tracks = tracks
|
||||
|
||||
return album, nil
|
||||
}
|
||||
|
||||
func (s *Service) Tracks(ctx context.Context, limit int) ([]Track, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
@@ -194,12 +278,40 @@ func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
|
||||
&track.CoverArtID,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Track{}, ErrNotFound
|
||||
}
|
||||
return Track{}, fmt.Errorf("query track by id: %w", err)
|
||||
}
|
||||
|
||||
return track, nil
|
||||
}
|
||||
|
||||
func (s *Service) Search(ctx context.Context, query string, limit int) (SearchResults, error) {
|
||||
pattern := "%" + query + "%"
|
||||
|
||||
artists, err := s.searchArtists(ctx, pattern, limit)
|
||||
if err != nil {
|
||||
return SearchResults{}, err
|
||||
}
|
||||
|
||||
albums, err := s.searchAlbums(ctx, pattern, limit)
|
||||
if err != nil {
|
||||
return SearchResults{}, err
|
||||
}
|
||||
|
||||
tracks, err := s.searchTracks(ctx, pattern, limit)
|
||||
if err != nil {
|
||||
return SearchResults{}, err
|
||||
}
|
||||
|
||||
return SearchResults{
|
||||
Artists: artists,
|
||||
Albums: albums,
|
||||
Tracks: tracks,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string, error) {
|
||||
var path string
|
||||
|
||||
@@ -231,3 +343,177 @@ func (s *Service) CoverArtPathByEntityID(ctx context.Context, id string) (string
|
||||
}
|
||||
return "", fmt.Errorf("query track cover art: %w", err)
|
||||
}
|
||||
|
||||
func (s *Service) albumsByArtistID(ctx context.Context, artistID string) ([]Album, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id) AS track_count, COALESCE(al.cover_art_id, '')
|
||||
FROM albums al
|
||||
JOIN artists a ON a.id = al.artist_id
|
||||
LEFT JOIN tracks t ON t.album_id = al.id
|
||||
WHERE al.artist_id = ?
|
||||
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id
|
||||
ORDER BY al.year DESC, al.title ASC`,
|
||||
artistID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query albums by artist: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var albums []Album
|
||||
for rows.Next() {
|
||||
var album Album
|
||||
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil {
|
||||
return nil, fmt.Errorf("scan album by artist: %w", err)
|
||||
}
|
||||
albums = append(albums, album)
|
||||
}
|
||||
|
||||
return albums, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) tracksByAlbumID(ctx context.Context, albumID string) ([]Track, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
||||
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
|
||||
FROM tracks t
|
||||
JOIN artists a ON a.id = t.artist_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
WHERE t.album_id = ?
|
||||
ORDER BY t.disc_number ASC, t.track_number ASC, t.title ASC`,
|
||||
albumID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query tracks by album: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tracks []Track
|
||||
for rows.Next() {
|
||||
var track Track
|
||||
if err := rows.Scan(
|
||||
&track.ID,
|
||||
&track.AlbumID,
|
||||
&track.ArtistID,
|
||||
&track.Title,
|
||||
&track.ArtistName,
|
||||
&track.AlbumTitle,
|
||||
&track.TrackNumber,
|
||||
&track.DurationSecs,
|
||||
&track.FilePath,
|
||||
&track.ContentType,
|
||||
&track.CoverArtID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan tracks by album: %w", err)
|
||||
}
|
||||
tracks = append(tracks, track)
|
||||
}
|
||||
|
||||
return tracks, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) searchArtists(ctx context.Context, pattern string, limit int) ([]Artist, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT a.id, a.name, COUNT(al.id) AS album_count, COALESCE(a.cover_art_id, '')
|
||||
FROM artists a
|
||||
LEFT JOIN albums al ON al.artist_id = a.id
|
||||
WHERE a.name LIKE ?
|
||||
GROUP BY a.id, a.name, a.cover_art_id
|
||||
ORDER BY a.name ASC
|
||||
LIMIT ?`,
|
||||
pattern,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search artists: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var artists []Artist
|
||||
for rows.Next() {
|
||||
var artist Artist
|
||||
if err := rows.Scan(&artist.ID, &artist.Name, &artist.AlbumCount, &artist.CoverArtID); err != nil {
|
||||
return nil, fmt.Errorf("scan searched artist: %w", err)
|
||||
}
|
||||
artists = append(artists, artist)
|
||||
}
|
||||
return artists, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) searchAlbums(ctx context.Context, pattern string, limit int) ([]Album, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT al.id, al.artist_id, a.name, al.title, COALESCE(al.year, 0), COUNT(t.id), COALESCE(al.cover_art_id, '')
|
||||
FROM albums al
|
||||
JOIN artists a ON a.id = al.artist_id
|
||||
LEFT JOIN tracks t ON t.album_id = al.id
|
||||
WHERE al.title LIKE ? OR a.name LIKE ?
|
||||
GROUP BY al.id, al.artist_id, a.name, al.title, al.year, al.cover_art_id
|
||||
ORDER BY al.year DESC, al.title ASC
|
||||
LIMIT ?`,
|
||||
pattern,
|
||||
pattern,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search albums: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var albums []Album
|
||||
for rows.Next() {
|
||||
var album Album
|
||||
if err := rows.Scan(&album.ID, &album.ArtistID, &album.ArtistName, &album.Title, &album.Year, &album.TrackCount, &album.CoverArtID); err != nil {
|
||||
return nil, fmt.Errorf("scan searched album: %w", err)
|
||||
}
|
||||
albums = append(albums, album)
|
||||
}
|
||||
return albums, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Service) searchTracks(ctx context.Context, pattern string, limit int) ([]Track, error) {
|
||||
rows, err := s.db.QueryContext(
|
||||
ctx,
|
||||
`SELECT t.id, t.album_id, t.artist_id, t.title, a.name, al.title, COALESCE(t.track_number, 0),
|
||||
COALESCE(t.duration_seconds, 0), t.file_path, COALESCE(t.content_type, ''), COALESCE(al.cover_art_id, '')
|
||||
FROM tracks t
|
||||
JOIN artists a ON a.id = t.artist_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
WHERE t.title LIKE ? OR a.name LIKE ? OR al.title LIKE ?
|
||||
ORDER BY a.name ASC, al.title ASC, t.track_number ASC
|
||||
LIMIT ?`,
|
||||
pattern,
|
||||
pattern,
|
||||
pattern,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search tracks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tracks []Track
|
||||
for rows.Next() {
|
||||
var track Track
|
||||
if err := rows.Scan(
|
||||
&track.ID,
|
||||
&track.AlbumID,
|
||||
&track.ArtistID,
|
||||
&track.Title,
|
||||
&track.ArtistName,
|
||||
&track.AlbumTitle,
|
||||
&track.TrackNumber,
|
||||
&track.DurationSecs,
|
||||
&track.FilePath,
|
||||
&track.ContentType,
|
||||
&track.CoverArtID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan searched track: %w", err)
|
||||
}
|
||||
tracks = append(tracks, track)
|
||||
}
|
||||
return tracks, rows.Err()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user