Files
TermorServer/internal/scanner/service.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()
}