Files
TermorServer/internal/library/service.go
benya 83b7addb88 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.
2026-04-02 22:41:33 +03:00

520 lines
13 KiB
Go

package library
import (
"context"
"database/sql"
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
type Artist struct {
ID string `json:"id"`
Name string `json:"name"`
AlbumCount int `json:"albumCount"`
CoverArtID string `json:"coverArtId"`
}
type Album struct {
ID string `json:"id"`
ArtistID string `json:"artistId"`
ArtistName string `json:"artistName"`
Title string `json:"title"`
Year int `json:"year"`
TrackCount int `json:"trackCount"`
CoverArtID string `json:"coverArtId"`
}
type Track struct {
ID string `json:"id"`
AlbumID string `json:"albumId"`
ArtistID string `json:"artistId"`
Title string `json:"title"`
ArtistName string `json:"artistName"`
AlbumTitle string `json:"albumTitle"`
TrackNumber int `json:"trackNumber"`
DurationSecs int `json:"durationSeconds"`
FilePath string `json:"filePath"`
ContentType string `json:"contentType"`
CoverArtID string `json:"coverArtId"`
}
type HomePayload struct {
RecentAlbums []Album `json:"recentAlbums"`
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
}
func NewService(db *sql.DB) *Service {
return &Service{db: db}
}
func (s *Service) Home(ctx context.Context) (HomePayload, error) {
albums, err := s.RecentAlbums(ctx, 6)
if err != nil {
return HomePayload{}, err
}
artists, err := s.Artists(ctx, 12)
if err != nil {
return HomePayload{}, err
}
return HomePayload{
RecentAlbums: albums,
Artists: artists,
}, nil
}
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
ORDER BY a.name ASC
LIMIT ?`,
limit,
)
if err != nil {
return nil, fmt.Errorf("query 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 artist: %w", err)
}
artists = append(artists, artist)
}
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,
`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
GROUP BY al.id, al.artist_id, a.name, al.title, al.year
ORDER BY al.updated_at DESC, al.title ASC
LIMIT ?`,
limit,
)
if err != nil {
return nil, fmt.Errorf("query 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 album: %w", err)
}
albums = append(albums, album)
}
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,
`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
ORDER BY a.name ASC, al.year DESC, al.title ASC, t.disc_number ASC, t.track_number ASC
LIMIT ?`,
limit,
)
if err != nil {
return nil, fmt.Errorf("query 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 track: %w", err)
}
tracks = append(tracks, track)
}
return tracks, rows.Err()
}
func (s *Service) TrackByID(ctx context.Context, id string) (Track, error) {
var track Track
err := s.db.QueryRowContext(
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.id = ?`,
id,
).Scan(
&track.ID,
&track.AlbumID,
&track.ArtistID,
&track.Title,
&track.ArtistName,
&track.AlbumTitle,
&track.TrackNumber,
&track.DurationSecs,
&track.FilePath,
&track.ContentType,
&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
err := s.db.QueryRowContext(
ctx,
`SELECT cover_art_id FROM albums WHERE id = ? AND cover_art_id <> ''`,
id,
).Scan(&path)
if err == nil {
return path, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", fmt.Errorf("query album cover art: %w", err)
}
err = s.db.QueryRowContext(
ctx,
`SELECT al.cover_art_id
FROM tracks t
JOIN albums al ON al.id = t.album_id
WHERE t.id = ? AND al.cover_art_id <> ''`,
id,
).Scan(&path)
if err == nil {
return path, nil
}
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
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()
}