Files
TermorServer/internal/scanner/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

451 lines
9.3 KiB
Go

package scanner
import (
"context"
"crypto/sha1"
"database/sql"
"encoding/hex"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/dhowden/tag"
)
var supportedExtensions = []string{
".aac",
".flac",
".m4a",
".mp3",
".ogg",
".oga",
".opus",
".wav",
".wma",
}
type Result struct {
Artists int `json:"artists"`
Albums int `json:"albums"`
Tracks int `json:"tracks"`
}
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 {
ID string
Name string
}
type scannedAlbum struct {
ID string
ArtistID string
Title string
Year int
Genre string
CoverArt string
}
type scannedTrack struct {
ID string
AlbumID string
ArtistID string
Title string
TrackNumber int
DiscNumber int
DurationSecs int
FilePath string
ContentType string
}
func NewService(db *sql.DB, mediaRoot string) *Service {
return &Service{db: db, mediaRoot: mediaRoot}
}
func (s *Service) HasMediaFiles() bool {
found := false
_ = filepath.WalkDir(s.mediaRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
if isSupportedAudio(path) {
found = true
return fs.SkipAll
}
return nil
})
return found
}
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 == "" {
err := fmt.Errorf("media root is empty")
s.markFailed(err)
return Result{}, err
}
if _, err := os.Stat(s.mediaRoot); err != nil {
wrapped := fmt.Errorf("media root unavailable: %w", err)
s.markFailed(wrapped)
return Result{}, wrapped
}
artists := map[string]scannedArtist{}
albums := map[string]scannedAlbum{}
var tracks []scannedTrack
err := filepath.WalkDir(s.mediaRoot, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !isSupportedAudio(path) {
return nil
}
item, err := s.scanFile(path)
if err != nil {
return nil
}
artists[item.artist.ID] = item.artist
albums[item.album.ID] = item.album
tracks = append(tracks, item.track)
return nil
})
if err != nil {
wrapped := fmt.Errorf("walk media root: %w", err)
s.markFailed(wrapped)
return Result{}, wrapped
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
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()
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()
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()
wrapped := fmt.Errorf("clear artists: %w", err)
s.markFailed(wrapped)
return Result{}, wrapped
}
now := time.Now().UTC().Format(time.RFC3339)
artistKeys := make([]string, 0, len(artists))
for key := range artists {
artistKeys = append(artistKeys, key)
}
slices.Sort(artistKeys)
for _, key := range artistKeys {
artist := artists[key]
if _, err := tx.ExecContext(
ctx,
`INSERT INTO artists (id, name, sort_name, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
artist.ID,
artist.Name,
artist.Name,
"",
now,
now,
); err != nil {
_ = tx.Rollback()
wrapped := fmt.Errorf("insert artist: %w", err)
s.markFailed(wrapped)
return Result{}, wrapped
}
}
albumKeys := make([]string, 0, len(albums))
for key := range albums {
albumKeys = append(albumKeys, key)
}
slices.Sort(albumKeys)
for _, key := range albumKeys {
album := albums[key]
if _, err := tx.ExecContext(
ctx,
`INSERT INTO albums (id, artist_id, title, sort_title, year, genre, cover_art_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
album.ID,
album.ArtistID,
album.Title,
album.Title,
album.Year,
album.Genre,
album.CoverArt,
now,
now,
); err != nil {
_ = tx.Rollback()
wrapped := fmt.Errorf("insert album: %w", err)
s.markFailed(wrapped)
return Result{}, wrapped
}
}
for _, track := range tracks {
if _, err := tx.ExecContext(
ctx,
`INSERT INTO tracks (id, album_id, artist_id, title, track_number, disc_number, duration_seconds, file_path, content_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
track.ID,
track.AlbumID,
track.ArtistID,
track.Title,
track.TrackNumber,
track.DiscNumber,
track.DurationSecs,
track.FilePath,
track.ContentType,
now,
now,
); err != nil {
_ = tx.Rollback()
wrapped := fmt.Errorf("insert track: %w", err)
s.markFailed(wrapped)
return Result{}, wrapped
}
}
if err := tx.Commit(); err != nil {
wrapped := fmt.Errorf("commit scan transaction: %w", err)
s.markFailed(wrapped)
return Result{}, wrapped
}
result := Result{
Artists: len(artists),
Albums: len(albums),
Tracks: len(tracks),
}
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 {
artist scannedArtist
album scannedAlbum
track scannedTrack
}
func (s *Service) scanFile(path string) (scannedItem, error) {
file, err := os.Open(path)
if err != nil {
return scannedItem{}, err
}
defer file.Close()
metadata, err := tag.ReadFrom(file)
title := strings.TrimSpace(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
artistName := "Unknown Artist"
albumTitle := "Unknown Album"
genre := ""
year := 0
trackNumber := 0
discNumber := 0
if err == nil {
if value := strings.TrimSpace(metadata.Title()); value != "" {
title = value
}
if value := strings.TrimSpace(metadata.Artist()); value != "" {
artistName = value
}
if value := strings.TrimSpace(metadata.Album()); value != "" {
albumTitle = value
}
if value := strings.TrimSpace(metadata.Genre()); value != "" {
genre = value
}
year = metadata.Year()
trackNumber, _ = metadata.Track()
discNumber, _ = metadata.Disc()
}
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{
ID: artistID,
Name: artistName,
},
album: scannedAlbum{
ID: albumID,
ArtistID: artistID,
Title: albumTitle,
Year: year,
Genre: genre,
CoverArt: coverArt,
},
track: scannedTrack{
ID: trackID,
AlbumID: albumID,
ArtistID: artistID,
Title: title,
TrackNumber: trackNumber,
DiscNumber: discNumber,
DurationSecs: 0,
FilePath: path,
ContentType: detectContentType(path),
},
}, nil
}
func isSupportedAudio(path string) bool {
ext := strings.ToLower(filepath.Ext(path))
for _, supported := range supportedExtensions {
if ext == supported {
return true
}
}
return false
}
func detectContentType(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".mp3":
return "audio/mpeg"
case ".flac":
return "audio/flac"
case ".m4a":
return "audio/mp4"
case ".ogg", ".oga":
return "audio/ogg"
case ".opus":
return "audio/ogg"
case ".wav":
return "audio/wav"
case ".aac":
return "audio/aac"
case ".wma":
return "audio/x-ms-wma"
default:
return "application/octet-stream"
}
}
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()
}