Git Repository
miniaudiolib
miniaudiolib scannt lokale Musik- und Hörbuchordner, liest Audio-Metadaten und Cover, verwaltet Favoriten und Playlists und streamt Titel direkt an den Browser.
HTTPS
https://zanvex.de/git/miniaudiolib.gitinternal/musicscanner/scanner.go
Zum Verzeichnispackage musicscanner
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"miniaudiolib/internal/config"
"miniaudiolib/internal/musicmeta"
)
type Status struct {
Running bool `json:"running"`
Discovered int64 `json:"discovered"`
Processed int64 `json:"processed"`
Updated int64 `json:"updated"`
Removed int64 `json:"removed"`
Errors int64 `json:"errors"`
Phase string `json:"phase"`
}
type Scanner struct {
db *sql.DB
cfg config.Config
log *slog.Logger
running atomic.Bool
mu sync.RWMutex
status Status
}
func New(db *sql.DB, cfg config.Config, log *slog.Logger) *Scanner {
return &Scanner{db: db, cfg: cfg, log: log}
}
func (s *Scanner) Running() bool { return s.running.Load() }
func (s *Scanner) Status() Status { s.mu.RLock(); defer s.mu.RUnlock(); return s.status }
func (s *Scanner) SetConfig(c config.Config) { s.mu.Lock(); s.cfg = c.Clone(); s.mu.Unlock() }
var extensions = map[string]bool{".mp3": true, ".flac": true, ".m4a": true, ".aac": true, ".ogg": true, ".opus": true, ".wav": true, ".wma": true, ".aiff": true, ".ape": true}
func (s *Scanner) Run(ctx context.Context) error {
if !s.running.CompareAndSwap(false, true) {
return errors.New("scan already running")
}
defer s.running.Store(false)
s.set(Status{Running: true, Phase: "discovering"})
defer func() { st := s.Status(); st.Running = false; st.Phase = "complete"; s.set(st) }()
s.mu.RLock()
cfg := s.cfg.Clone()
s.mu.RUnlock()
seen := map[string]bool{}
for _, root := range cfg.Libraries {
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
s.bump(func(x *Status) { x.Errors++ })
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
if info.Mode().IsRegular() && extensions[strings.ToLower(filepath.Ext(path))] {
abs, _ := filepath.Abs(path)
seen[abs] = true
s.bump(func(x *Status) { x.Discovered++ })
changed, e := s.process(ctx, cfg, abs, info)
s.bump(func(x *Status) {
x.Processed++
if changed {
x.Updated++
}
if e != nil {
x.Errors++
}
})
if e != nil {
s.log.Warn("scan track", "path", abs, "error", e)
}
}
return nil
})
if err != nil && !errors.Is(err, context.Canceled) {
s.log.Warn("scan library", "path", root, "error", err)
}
}
audiobookSeen := map[string]bool{}
for _, root := range cfg.AudiobookLibraries {
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
s.bump(func(x *Status) { x.Errors++ })
return nil
}
if ctx.Err() != nil {
return ctx.Err()
}
if info.Mode().IsRegular() && extensions[strings.ToLower(filepath.Ext(path))] {
abs, _ := filepath.Abs(path)
audiobookSeen[abs] = true
s.bump(func(x *Status) { x.Discovered++ })
changed, e := s.processAudiobook(ctx, cfg, root, abs, info)
s.bump(func(x *Status) {
x.Processed++
if changed {
x.Updated++
}
if e != nil {
x.Errors++
}
})
if e != nil {
s.log.Warn("scan audiobook chapter", "path", abs, "error", e)
}
}
return nil
})
if err != nil && !errors.Is(err, context.Canceled) {
s.log.Warn("scan audiobook library", "path", root, "error", err)
}
}
rows, err := s.db.QueryContext(ctx, "SELECT id,path,cover FROM tracks")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id int64
var path, cover string
_ = rows.Scan(&id, &path, &cover)
if !seen[path] {
_, _ = s.db.ExecContext(ctx, "DELETE FROM tracks WHERE id=?", id)
if cover != "" {
_ = os.Remove(cover)
}
s.bump(func(x *Status) { x.Removed++ })
}
}
rows.Close()
audioRows, err := s.db.QueryContext(ctx, "SELECT id,path FROM audiobook_chapters")
if err != nil {
return err
}
defer audioRows.Close()
for audioRows.Next() {
var id int64
var path string
_ = audioRows.Scan(&id, &path)
if !audiobookSeen[path] {
_, _ = s.db.ExecContext(ctx, "DELETE FROM audiobook_chapters WHERE id=?", id)
s.bump(func(x *Status) { x.Removed++ })
}
}
return ctx.Err()
}
func (s *Scanner) processAudiobook(ctx context.Context, cfg config.Config, root, path string, info os.FileInfo) (bool, error) {
var size, mod int64
err := s.db.QueryRowContext(ctx, "SELECT size,modified_at FROM audiobook_chapters WHERE path=?", path).Scan(&size, &mod)
if err == nil && size == info.Size() && mod == info.ModTime().Unix() {
return false, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
}
p, err := musicmeta.Probe(ctx, cfg.FFprobePath, path)
if err != nil {
return false, err
}
rootAbs, _ := filepath.Abs(root)
rel, _ := filepath.Rel(rootAbs, path)
parts := strings.Split(rel, string(os.PathSeparator))
bookPath := rootAbs
if len(parts) > 1 {
bookPath = filepath.Join(rootAbs, parts[0])
}
bookTitle := filepath.Base(bookPath)
title := p.Title
if title == "" {
title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
_, err = s.db.ExecContext(ctx, `INSERT INTO audiobook_chapters(path,filename,book_path,book_title,title,duration,container,size,modified_at) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(path) DO UPDATE SET filename=excluded.filename,book_path=excluded.book_path,book_title=excluded.book_title,title=excluded.title,duration=excluded.duration,container=excluded.container,size=excluded.size,modified_at=excluded.modified_at`, path, filepath.Base(path), bookPath, bookTitle, title, p.Duration, p.Container, info.Size(), info.ModTime().Unix())
if err != nil {
return false, err
}
var id int64
_ = s.db.QueryRowContext(ctx, "SELECT id FROM audiobook_chapters WHERE path=?", path).Scan(&id)
coverDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "audiobook-covers")
_ = os.MkdirAll(coverDir, 0755)
cover := filepath.Join(coverDir, fmtID(id)+".jpg")
if musicmeta.ExtractCover(ctx, cfg.FFmpegPath, path, cover) == nil {
_, _ = s.db.ExecContext(ctx, "UPDATE audiobook_chapters SET cover=? WHERE book_path=?", cover, bookPath)
}
return true, nil
}
func (s *Scanner) process(ctx context.Context, cfg config.Config, path string, info os.FileInfo) (bool, error) {
var id, size, mod int64
err := s.db.QueryRowContext(ctx, "SELECT id,size,modified_at FROM tracks WHERE path=?", path).Scan(&id, &size, &mod)
if err == nil && size == info.Size() && mod == info.ModTime().Unix() {
return false, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
}
p, err := musicmeta.Probe(ctx, cfg.FFprobePath, path)
if err != nil {
_, _ = s.db.ExecContext(context.Background(), `INSERT INTO scan_errors VALUES(?,?,?) ON CONFLICT(path) DO UPDATE SET error=excluded.error,failed_at=excluded.failed_at`, path, err.Error(), time.Now().Unix())
return false, err
}
if p.Title == "" {
p.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
if p.Artist == "" {
p.Artist = "Unbekannter Künstler"
}
if p.Album == "" {
p.Album = "Unbekanntes Album"
}
if p.AlbumArtist == "" {
p.AlbumArtist = p.Artist
}
h, _ := quickHash(path, info.Size())
now := time.Now().Unix()
_, err = s.db.ExecContext(ctx, `INSERT INTO tracks(path,filename,title,artist,album_artist,album,genre,year,track_number,disc_number,duration,codec,bitrate,sample_rate,channels,container,size,hash,modified_at,added_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(path) DO UPDATE SET filename=excluded.filename,title=excluded.title,artist=excluded.artist,album_artist=excluded.album_artist,album=excluded.album,genre=excluded.genre,year=excluded.year,track_number=excluded.track_number,disc_number=excluded.disc_number,duration=excluded.duration,codec=excluded.codec,bitrate=excluded.bitrate,sample_rate=excluded.sample_rate,channels=excluded.channels,container=excluded.container,size=excluded.size,hash=excluded.hash,modified_at=excluded.modified_at`, path, filepath.Base(path), p.Title, p.Artist, p.AlbumArtist, p.Album, p.Genre, p.Year, p.Track, p.Disc, p.Duration, p.Codec, p.Bitrate, p.SampleRate, p.Channels, p.Container, info.Size(), h, info.ModTime().Unix(), now)
if err != nil {
return false, err
}
_ = s.db.QueryRowContext(ctx, "SELECT id FROM tracks WHERE path=?", path).Scan(&id)
coverDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "covers")
_ = os.MkdirAll(coverDir, 0o755)
cover := filepath.Join(coverDir, fmtID(id)+".jpg")
if musicmeta.ExtractCover(ctx, cfg.FFmpegPath, path, cover) == nil {
_, _ = s.db.ExecContext(ctx, "UPDATE tracks SET cover=? WHERE id=?", cover, id)
}
_, _ = s.db.ExecContext(ctx, "DELETE FROM scan_errors WHERE path=?", path)
return true, nil
}
func (s *Scanner) set(v Status) { s.mu.Lock(); s.status = v; s.mu.Unlock() }
func (s *Scanner) bump(f func(*Status)) { s.mu.Lock(); f(&s.status); s.mu.Unlock() }
func fmtID(id int64) string {
const digits = "0123456789"
if id == 0 {
return "0"
}
b := make([]byte, 0, 20)
for id > 0 {
b = append(b, digits[id%10])
id /= 10
}
for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}
return string(b)
}
func quickHash(path string, size int64) (string, error) {
f, e := os.Open(path)
if e != nil {
return "", e
}
defer f.Close()
h := sha256.New()
_, e = io.CopyN(h, f, min(size, 1<<20))
if e != nil && e != io.EOF {
return "", e
}
return hex.EncodeToString(h.Sum(nil)), nil
}