Add a dedicated artwork cache directory and save embedded album art from audio tags during library scans. Prefer embedded artwork for album cover resolution while keeping sidecar image files as the fallback path.
496 lines
10 KiB
Go
496 lines
10 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
|
|
artworkRoot 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, artworkRoot string) *Service {
|
|
return &Service{db: db, mediaRoot: mediaRoot, artworkRoot: artworkRoot}
|
|
}
|
|
|
|
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 := ""
|
|
if err == nil {
|
|
coverArt = s.extractEmbeddedCoverArt(metadata, albumID)
|
|
}
|
|
if coverArt == "" {
|
|
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) extractEmbeddedCoverArt(metadata tag.Metadata, albumID string) string {
|
|
if metadata == nil {
|
|
return ""
|
|
}
|
|
|
|
picture := metadata.Picture()
|
|
if picture == nil || len(picture.Data) == 0 {
|
|
return ""
|
|
}
|
|
|
|
if s.artworkRoot == "" {
|
|
return ""
|
|
}
|
|
|
|
if err := os.MkdirAll(s.artworkRoot, 0o755); err != nil {
|
|
return ""
|
|
}
|
|
|
|
filename := albumID + coverExtension(picture.MIMEType)
|
|
path := filepath.Join(s.artworkRoot, filename)
|
|
if err := os.WriteFile(path, picture.Data, 0o644); err != nil {
|
|
return ""
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
func coverExtension(mimeType string) string {
|
|
switch strings.ToLower(mimeType) {
|
|
case "image/png":
|
|
return ".png"
|
|
case "image/webp":
|
|
return ".webp"
|
|
default:
|
|
return ".jpg"
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|