Files
minstrel/internal/library/watcher.go
T
bvandeusen c3f3a17c6d
test-go / test (push) Successful in 59s
test-go / integration (push) Successful in 4m49s
feat(library): a missing file stays in the playlist, greyed and unplayable — #2527
Every browse, discover and mix query filters missing_since, so a track
whose file vanished disappears from the places Minstrel chooses music.
A playlist is different: the entry is there because the user put it
there, and silently dropping it rewrites their list behind their back.

So playlists keep the row and mark it instead. ListPlaylistTracks now
carries missing_since (still deliberately unfiltered), the service
layer surfaces it as PlaylistTrack.Unavailable, and the wire gains
"unavailable" on each entry.

A missing entry also loses its stream_url. Refusing to hand out a URL
that cannot serve is stronger than trusting every client to honour the
flag, and "stream_url": null is a shape the clients already model --
PlaylistWire.streamUrl is documented nullable for the track-removed
case -- so an older build degrades to "present but not playable" with
no change.

Nothing is deleted here and nothing should be: the row, its play
history, its likes and its taste contribution all survive a file going
missing, because the file may come back (and #2528 will adopt it if it
comes back renamed).

Also corrects two comments that had drifted into lying. delete.go still
claimed the file-gone case was NOT auto-reconciled and told admins to
delete rows by hand -- untrue since f6d1cf24, and that exact staleness
is what produced drift #572. It now says what DeleteTrackFile really is:
the destructive admin action, which CASCADEs play_events and likes, and
is emphatically not the missing-file path. watcher.go claimed the
safety-net scan "covers anything missed"; the walk only covers
additions, and it is reconcile that covers removals.
2026-08-16 11:43:05 -04:00

258 lines
7.6 KiB
Go

package library
import (
"context"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/fsnotify/fsnotify"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
)
// watcherDebounce is the quiet period after the last filesystem event before
// the watcher processes the pending batch. Copying an album emits a burst of
// write events; waiting for quiet coalesces the whole album into one scan.
const watcherDebounce = 3 * time.Second
// watchAction is the pure decision for a single filesystem event.
type watchAction int
const (
watchIgnore watchAction = iota // not a relevant change
watchEnqueueFile // an audio file was created/written
watchScanDir // a directory appeared; scan it recursively
)
// classifyEvent decides what to do with a filesystem event, separated from I/O
// so it is unit-testable. Only Create/Write matter (removals are not pruned
// here -- see the watcher's design non-goals). A newly-created directory is
// scanned recursively because fsnotify does not emit events for files that
// already existed inside a directory that was moved/copied in wholesale.
func classifyEvent(op fsnotify.Op, isDir, isAudio bool) watchAction {
if op&(fsnotify.Create|fsnotify.Write) == 0 {
return watchIgnore
}
if isDir {
if op&fsnotify.Create != 0 {
return watchScanDir
}
return watchIgnore
}
if isAudio {
return watchEnqueueFile
}
return watchIgnore
}
// Watcher delivers near-instant library updates by watching the scan roots
// with fsnotify and scanning just the changed files (plus inline cover-art
// enrichment for the affected albums), instead of waiting for a full scan.
// It is recursive: a watch is added per directory, and new directories get a
// watch as they appear. inotify watch-limit exhaustion on huge libraries is
// logged, not fatal -- the periodic safety-net scan covers anything missed.
//
// "Covers anything missed" is true in two different ways, worth separating
// because the walk alone only ever gave one of them: the walk re-visits every
// path that EXISTS, which catches additions and edits, and the reconcile pass
// that runs with it compares the whole tracks table against what the walk saw,
// which is what catches removals (#2523). classifyEvent still ignores fsnotify
// removals by design, so a deleted file surfaces at the next scan, not the
// next inotify event.
type Watcher struct {
scanner *Scanner
enricher *coverart.Enricher
logger *slog.Logger
roots []string
debounce time.Duration
}
func NewWatcher(
scanner *Scanner, enricher *coverart.Enricher, logger *slog.Logger, roots []string,
) *Watcher {
return &Watcher{
scanner: scanner,
enricher: enricher,
logger: logger,
roots: roots,
debounce: watcherDebounce,
}
}
// Run blocks until ctx is cancelled. Returns a non-nil error only when the
// fsnotify watcher cannot be created at all; per-path watch failures and
// fsnotify errors are logged and tolerated.
func (w *Watcher) Run(ctx context.Context) error {
if len(w.roots) == 0 {
w.logger.Info("library watcher: no scan paths configured, watcher disabled")
return nil
}
fsw, err := fsnotify.NewWatcher()
if err != nil {
w.logger.Error("library watcher: create failed", "err", err)
return err
}
defer func() { _ = fsw.Close() }()
for _, root := range w.roots {
w.addRecursive(fsw, root)
}
w.logger.Info("library watcher: started", "roots", len(w.roots))
batches := make(chan []string, batchQueueDepth)
go w.processLoop(ctx, batches)
w.eventLoop(ctx, fsw, batches)
return nil
}
// eventLoop reads fsnotify events, coalesces them via a debounce timer, and
// hands drained batches to the process loop. Returns on ctx cancellation.
func (w *Watcher) eventLoop(ctx context.Context, fsw *fsnotify.Watcher, batches chan<- []string) {
pending := make(map[string]struct{})
timer := time.NewTimer(w.debounce)
timer.Stop()
var timerC <-chan time.Time
for {
select {
case <-ctx.Done():
timer.Stop()
w.logger.Info("library watcher: stopped")
return
case event, ok := <-fsw.Events:
if !ok {
return
}
if w.handleEvent(fsw, event, pending) {
timer.Reset(w.debounce)
timerC = timer.C
}
case err, ok := <-fsw.Errors:
if ok {
w.logger.Warn("library watcher: fsnotify error", "err", err)
}
case <-timerC:
timerC = nil
paths := drainPending(pending)
if len(paths) == 0 {
continue
}
select {
case batches <- paths:
case <-ctx.Done():
return
}
}
}
}
// handleEvent applies one event to the pending set and reports whether the
// debounce timer should be (re)armed.
func (w *Watcher) handleEvent(
fsw *fsnotify.Watcher, event fsnotify.Event, pending map[string]struct{},
) bool {
info, err := os.Stat(event.Name)
isDir := err == nil && info.IsDir()
isAudio := audioExtensions[strings.ToLower(filepath.Ext(event.Name))]
switch classifyEvent(event.Op, isDir, isAudio) {
case watchScanDir:
w.addRecursive(fsw, event.Name)
return w.enqueueDirAudio(event.Name, pending)
case watchEnqueueFile:
pending[event.Name] = struct{}{}
return true
case watchIgnore:
return false
default:
return false
}
}
// processLoop serializes batch processing so a slow remote enrichment never
// blocks the fsnotify event loop.
func (w *Watcher) processLoop(ctx context.Context, batches <-chan []string) {
for {
select {
case <-ctx.Done():
return
case paths, ok := <-batches:
if !ok {
return
}
w.process(ctx, paths)
}
}
}
// process scans the changed files then enriches just the affected albums.
func (w *Watcher) process(ctx context.Context, paths []string) {
changed, err := w.scanner.ScanFiles(ctx, paths)
if err != nil {
w.logger.Warn("library watcher: scan batch failed", "err", err)
}
if w.enricher != nil {
for _, albumID := range changed {
if ctx.Err() != nil {
return
}
if eerr := w.enricher.EnrichAlbum(ctx, albumID); eerr != nil {
w.logger.Warn("library watcher: enrich album failed", "err", eerr)
}
}
}
if len(changed) > 0 {
w.logger.Info("library watcher: applied", "files", len(paths), "albums", len(changed))
}
}
// addRecursive adds an fsnotify watch for root and every subdirectory under it.
// Per-directory failures (inotify limits, permissions) are logged and skipped.
func (w *Watcher) addRecursive(fsw *fsnotify.Watcher, root string) {
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || !d.IsDir() {
return nil //nolint:nilerr // tolerate unreadable subtrees
}
if aerr := fsw.Add(path); aerr != nil {
w.logger.Warn("library watcher: add watch failed", "path", path, "err", aerr)
}
return nil
})
}
// enqueueDirAudio enqueues every audio file under dir (used when a directory
// appears wholesale, since fsnotify emits no events for its existing children).
func (w *Watcher) enqueueDirAudio(dir string, pending map[string]struct{}) bool {
added := false
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil //nolint:nilerr // tolerate unreadable subtrees
}
if audioExtensions[strings.ToLower(filepath.Ext(path))] {
pending[path] = struct{}{}
added = true
}
return nil
})
return added
}
// drainPending returns the pending paths and clears the set.
func drainPending(pending map[string]struct{}) []string {
if len(pending) == 0 {
return nil
}
paths := make([]string, 0, len(pending))
for p := range pending {
paths = append(paths, p)
}
clear(pending)
return paths
}
// batchQueueDepth bounds how many debounced batches can be in flight before the
// event loop would block; generous since each batch is processed quickly.
const batchQueueDepth = 16