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.
234 lines
5.6 KiB
Go
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)
|
|
}
|