Files
TermorServer/internal/library/service.go
benya f8880aa9a4 feat: add scan status and cover art endpoints
Track scanner status for the web API and Subsonic-compatible scan endpoints, add authenticated cover art serving, and wire album artwork into the web UI. Keep Subsonic auth limited to legacy password mode for now so behavior stays honest with the current bcrypt-based user storage.
2026-04-02 22:37:10 +03:00

234 lines
5.6 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"`
}
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 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
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); err != nil {
return nil, fmt.Errorf("scan artist: %w", err)
}
artists = append(artists, artist)
}
return artists, rows.Err()
}
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) 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 {
return Track{}, fmt.Errorf("query track by id: %w", err)
}
return track, 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)
}