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.

Projektseite ↗
HTTPShttps://zanvex.de/git/miniaudiolib.git

internal/musicscanner/scanner.go

Zum Verzeichnis
package 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
}