625 lines
13 KiB
Go
625 lines
13 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io/fs"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/dhowden/tag"
|
|
"github.com/hajimehoshi/go-mp3"
|
|
"github.com/mewkiz/flac"
|
|
)
|
|
|
|
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
|
|
BitrateKbps 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, bitrate_kbps, file_path, content_type, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
track.ID,
|
|
track.AlbumID,
|
|
track.ArtistID,
|
|
track.Title,
|
|
track.TrackNumber,
|
|
track.DiscNumber,
|
|
track.DurationSecs,
|
|
track.BitrateKbps,
|
|
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))
|
|
}
|
|
|
|
fileInfo, statErr := file.Stat()
|
|
fileSize := int64(0)
|
|
if statErr == nil {
|
|
fileSize = fileInfo.Size()
|
|
}
|
|
contentType := detectContentType(path)
|
|
durationSecs, bitrateKbps := scanAudioProperties(path, contentType, fileSize)
|
|
|
|
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: durationSecs,
|
|
BitrateKbps: bitrateKbps,
|
|
FilePath: path,
|
|
ContentType: contentType,
|
|
},
|
|
}, 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 scanAudioProperties(path, contentType string, fileSize int64) (int, int) {
|
|
durationSecs := extractDurationSeconds(path, contentType)
|
|
bitrateKbps := calculateBitrateKbps(fileSize, durationSecs)
|
|
return durationSecs, bitrateKbps
|
|
}
|
|
|
|
func extractDurationSeconds(path, contentType string) int {
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".flac":
|
|
return extractFLACDurationSeconds(path)
|
|
case ".mp3":
|
|
return extractMP3DurationSeconds(path)
|
|
case ".wav":
|
|
return extractWAVDurationSeconds(path)
|
|
default:
|
|
_ = contentType
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func extractFLACDurationSeconds(path string) int {
|
|
stream, err := flac.ParseFile(path)
|
|
if err != nil || stream == nil || stream.Info == nil || stream.Info.SampleRate == 0 || stream.Info.NSamples == 0 {
|
|
return 0
|
|
}
|
|
return int(math.Round(float64(stream.Info.NSamples) / float64(stream.Info.SampleRate)))
|
|
}
|
|
|
|
func extractMP3DurationSeconds(path string) int {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer file.Close()
|
|
|
|
decoder, err := mp3.NewDecoder(file)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
if decoder.SampleRate() == 0 || decoder.Length() <= 0 {
|
|
return 0
|
|
}
|
|
seconds := float64(decoder.Length()) / 4 / float64(decoder.SampleRate())
|
|
return int(math.Round(seconds))
|
|
}
|
|
|
|
func extractWAVDurationSeconds(path string) int {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer file.Close()
|
|
|
|
header := make([]byte, 12)
|
|
if _, err := file.Read(header); err != nil {
|
|
return 0
|
|
}
|
|
if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" {
|
|
return 0
|
|
}
|
|
|
|
var byteRate uint32
|
|
var dataSize uint32
|
|
|
|
for {
|
|
chunkHeader := make([]byte, 8)
|
|
if _, err := file.Read(chunkHeader); err != nil {
|
|
break
|
|
}
|
|
chunkSize := binary.LittleEndian.Uint32(chunkHeader[4:8])
|
|
chunkID := string(chunkHeader[0:4])
|
|
|
|
switch chunkID {
|
|
case "fmt ":
|
|
if chunkSize < 16 {
|
|
return 0
|
|
}
|
|
chunkData := make([]byte, chunkSize)
|
|
if _, err := file.Read(chunkData); err != nil {
|
|
return 0
|
|
}
|
|
byteRate = binary.LittleEndian.Uint32(chunkData[8:12])
|
|
case "data":
|
|
dataSize = chunkSize
|
|
if _, err := file.Seek(int64(chunkSize), 1); err != nil {
|
|
return 0
|
|
}
|
|
default:
|
|
if _, err := file.Seek(int64(chunkSize), 1); err != nil {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
if chunkSize%2 == 1 {
|
|
if _, err := file.Seek(1, 1); err != nil {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
if byteRate > 0 && dataSize > 0 {
|
|
return int(math.Round(float64(dataSize) / float64(byteRate)))
|
|
}
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
func calculateBitrateKbps(fileSize int64, durationSecs int) int {
|
|
if fileSize <= 0 || durationSecs <= 0 {
|
|
return 0
|
|
}
|
|
return int(math.Round(float64(fileSize*8) / float64(durationSecs) / 1000))
|
|
}
|
|
|
|
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()
|
|
}
|