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.
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dhowden/tag"
|
||||
@@ -37,6 +38,18 @@ type Result struct {
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
mediaRoot string
|
||||
mu sync.RWMutex
|
||||
status Status
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Scanning bool `json:"scanning"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
Artists int `json:"artists"`
|
||||
Albums int `json:"albums"`
|
||||
Tracks int `json:"tracks"`
|
||||
}
|
||||
|
||||
type scannedArtist struct {
|
||||
@@ -50,6 +63,7 @@ type scannedAlbum struct {
|
||||
Title string
|
||||
Year int
|
||||
Genre string
|
||||
CoverArt string
|
||||
}
|
||||
|
||||
type scannedTrack struct {
|
||||
@@ -84,12 +98,23 @@ func (s *Service) HasMediaFiles() bool {
|
||||
}
|
||||
|
||||
func (s *Service) Scan(ctx context.Context) (Result, error) {
|
||||
if !s.tryMarkStarted() {
|
||||
return Result{}, fmt.Errorf("scan already running")
|
||||
}
|
||||
return s.runScan(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) runScan(ctx context.Context) (Result, error) {
|
||||
if s.mediaRoot == "" {
|
||||
return Result{}, fmt.Errorf("media root is empty")
|
||||
err := fmt.Errorf("media root is empty")
|
||||
s.markFailed(err)
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(s.mediaRoot); err != nil {
|
||||
return Result{}, fmt.Errorf("media root unavailable: %w", err)
|
||||
wrapped := fmt.Errorf("media root unavailable: %w", err)
|
||||
s.markFailed(wrapped)
|
||||
return Result{}, wrapped
|
||||
}
|
||||
|
||||
artists := map[string]scannedArtist{}
|
||||
@@ -115,25 +140,35 @@ func (s *Service) Scan(ctx context.Context) (Result, error) {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("walk media root: %w", err)
|
||||
wrapped := fmt.Errorf("walk media root: %w", err)
|
||||
s.markFailed(wrapped)
|
||||
return Result{}, wrapped
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("begin scan transaction: %w", err)
|
||||
wrapped := fmt.Errorf("begin scan transaction: %w", err)
|
||||
s.markFailed(wrapped)
|
||||
return Result{}, wrapped
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM tracks`); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return Result{}, fmt.Errorf("clear tracks: %w", err)
|
||||
wrapped := fmt.Errorf("clear tracks: %w", err)
|
||||
s.markFailed(wrapped)
|
||||
return Result{}, wrapped
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM albums`); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return Result{}, fmt.Errorf("clear albums: %w", err)
|
||||
wrapped := fmt.Errorf("clear albums: %w", err)
|
||||
s.markFailed(wrapped)
|
||||
return Result{}, wrapped
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM artists`); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return Result{}, fmt.Errorf("clear artists: %w", err)
|
||||
wrapped := fmt.Errorf("clear artists: %w", err)
|
||||
s.markFailed(wrapped)
|
||||
return Result{}, wrapped
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
@@ -157,7 +192,9 @@ func (s *Service) Scan(ctx context.Context) (Result, error) {
|
||||
now,
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return Result{}, fmt.Errorf("insert artist: %w", err)
|
||||
wrapped := fmt.Errorf("insert artist: %w", err)
|
||||
s.markFailed(wrapped)
|
||||
return Result{}, wrapped
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,12 +215,14 @@ func (s *Service) Scan(ctx context.Context) (Result, error) {
|
||||
album.Title,
|
||||
album.Year,
|
||||
album.Genre,
|
||||
"",
|
||||
album.CoverArt,
|
||||
now,
|
||||
now,
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return Result{}, fmt.Errorf("insert album: %w", err)
|
||||
wrapped := fmt.Errorf("insert album: %w", err)
|
||||
s.markFailed(wrapped)
|
||||
return Result{}, wrapped
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,19 +243,44 @@ func (s *Service) Scan(ctx context.Context) (Result, error) {
|
||||
now,
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return Result{}, fmt.Errorf("insert track: %w", err)
|
||||
wrapped := fmt.Errorf("insert track: %w", err)
|
||||
s.markFailed(wrapped)
|
||||
return Result{}, wrapped
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return Result{}, fmt.Errorf("commit scan transaction: %w", err)
|
||||
wrapped := fmt.Errorf("commit scan transaction: %w", err)
|
||||
s.markFailed(wrapped)
|
||||
return Result{}, wrapped
|
||||
}
|
||||
|
||||
return Result{
|
||||
result := Result{
|
||||
Artists: len(artists),
|
||||
Albums: len(albums),
|
||||
Tracks: len(tracks),
|
||||
}, nil
|
||||
}
|
||||
|
||||
s.markFinished(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) ScanAsync(ctx context.Context) bool {
|
||||
if !s.tryMarkStarted() {
|
||||
return false
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, _ = s.runScan(ctx)
|
||||
}()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) Status() Status {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.status
|
||||
}
|
||||
|
||||
type scannedItem struct {
|
||||
@@ -263,6 +327,7 @@ func (s *Service) scanFile(path string) (scannedItem, error) {
|
||||
artistID := hashID("artist", artistName)
|
||||
albumID := hashID("album", artistName, albumTitle, fmt.Sprintf("%d", year))
|
||||
trackID := hashID("track", path)
|
||||
coverArt := findCoverArt(filepath.Dir(path))
|
||||
|
||||
return scannedItem{
|
||||
artist: scannedArtist{
|
||||
@@ -275,6 +340,7 @@ func (s *Service) scanFile(path string) (scannedItem, error) {
|
||||
Title: albumTitle,
|
||||
Year: year,
|
||||
Genre: genre,
|
||||
CoverArt: coverArt,
|
||||
},
|
||||
track: scannedTrack{
|
||||
ID: trackID,
|
||||
@@ -327,3 +393,58 @@ func hashID(parts ...string) string {
|
||||
sum := sha1.Sum([]byte(strings.Join(parts, "::")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func findCoverArt(dir string) string {
|
||||
candidates := []string{
|
||||
"cover.jpg",
|
||||
"cover.jpeg",
|
||||
"cover.png",
|
||||
"folder.jpg",
|
||||
"folder.jpeg",
|
||||
"folder.png",
|
||||
"front.jpg",
|
||||
"front.jpeg",
|
||||
"front.png",
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
path := filepath.Join(dir, candidate)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) tryMarkStarted() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.status.Scanning {
|
||||
return false
|
||||
}
|
||||
s.status.Scanning = true
|
||||
s.status.StartedAt = time.Now().UTC()
|
||||
s.status.FinishedAt = time.Time{}
|
||||
s.status.LastError = ""
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Service) markFinished(result Result) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.status.Scanning = false
|
||||
s.status.FinishedAt = time.Now().UTC()
|
||||
s.status.LastError = ""
|
||||
s.status.Artists = result.Artists
|
||||
s.status.Albums = result.Albums
|
||||
s.status.Tracks = result.Tracks
|
||||
}
|
||||
|
||||
func (s *Service) markFailed(err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.status.Scanning = false
|
||||
s.status.FinishedAt = time.Now().UTC()
|
||||
s.status.LastError = err.Error()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user