release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
- A failed scan run sends scan_failed. Each failure adds to the count and the notice shows the latest error. A scan cut short by shutdown says nothing. - Marking tracks missing sends tracks_missing with a running count. - A duplicate sweep that proposes a group it had not proposed before sends duplicates_found, counting everything awaiting review. A sweep that only re-finds known groups stays quiet, so a read notice isn't repeated every sweep (CountDuplicateGroupsDetectedSince). - A playback-error report sends playback_errors, counting the unresolved errors (CountUnresolvedPlaybackErrors). The library package gets its notifier as a package-level SetNotifier beside SetEventBus, for the same reason the bus is package-level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
8.0 KiB
Go
203 lines
8.0 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
|
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
|
)
|
|
|
|
// trackReconciler is the slice of dbq.Queries reconcileMissing needs. Narrowed
|
|
// to an interface so the guard logic — which is the part that can do damage —
|
|
// is unit-testable against a fake without a database.
|
|
type trackReconciler interface {
|
|
ListTrackPathsForReconcile(ctx context.Context) ([]dbq.ListTrackPathsForReconcileRow, error)
|
|
MarkTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
|
|
ClearTracksMissing(ctx context.Context, ids []pgtype.UUID) (int64, error)
|
|
InsertLibraryChanges(ctx context.Context, arg dbq.InsertLibraryChangesParams) error
|
|
}
|
|
|
|
// Reconcile marks tracks whose files have disappeared (#2523).
|
|
//
|
|
// Why this exists: nothing in Minstrel used to notice a deleted file. The walk
|
|
// only visits paths that exist, so a row whose file is gone was never scanned,
|
|
// never errored, never counted — permanently invisible. The watcher ignores
|
|
// removals by design (see classifyEvent), and the safety-net scan is the same
|
|
// walk, so it covers additions only. Rows accumulated forever, kept being
|
|
// offered to recommendations, and failed at playback.
|
|
//
|
|
// Why it MARKS rather than deletes: a missing file is a claim about the
|
|
// filesystem, and the filesystem lies transiently — an unmounted volume, a
|
|
// network-storage blip, a container that started before its media mount
|
|
// attached. Every other sweep in this codebase (internal/gc) resolves a truth
|
|
// *inside* the database and is safe to run blind. This one isn't, so the
|
|
// destructive step is deliberately not here. Marking is reversible: the next
|
|
// good scan clears it.
|
|
|
|
// missingMarkMaxFraction caps how much of the library one reconcile may newly
|
|
// mark missing. A partially-attached mount is the failure this defends against:
|
|
// the roots resolve, the walk succeeds, and it legitimately sees only part of
|
|
// the library — evidence indistinguishable from a mass deletion.
|
|
//
|
|
// A quarter is deliberately conservative. A genuine bulk deletion trips it and
|
|
// gets logged rather than applied, which needs a second scan (or operator
|
|
// action) to take effect. That's the right trade: the cost of over-refusing is
|
|
// a stale row and a log line, and the cost of over-marking is a chunk of the
|
|
// library silently vanishing from every mix.
|
|
const missingMarkMaxFraction = 0.25
|
|
|
|
// reconcileMissing diffs the paths the walk saw against every row in the table.
|
|
// Rows not seen get marked; rows seen that carry a mark get cleared.
|
|
//
|
|
// seen must come from a COMPLETE walk of every configured root. Callers with a
|
|
// partial view must not call this.
|
|
func (s *Scanner) reconcileMissing(
|
|
ctx context.Context, q trackReconciler, seen map[string]struct{}, stats *Stats,
|
|
) error {
|
|
if err := s.verifyRootsPresent(); err != nil {
|
|
return err
|
|
}
|
|
// Roots resolved but the walk found nothing. Either the library is genuinely
|
|
// empty — in which case there is nothing to reconcile — or the mount is
|
|
// hollow. Both mean: don't act.
|
|
if len(seen) == 0 {
|
|
return errors.New("walk saw no audio files; refusing to reconcile")
|
|
}
|
|
|
|
rows, err := q.ListTrackPathsForReconcile(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("list track paths: %w", err)
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var toMark, toClear []pgtype.UUID
|
|
for _, row := range rows {
|
|
_, present := seen[row.FilePath]
|
|
switch {
|
|
case !present && !row.MissingSince.Valid:
|
|
toMark = append(toMark, row.ID)
|
|
case present && row.MissingSince.Valid:
|
|
toClear = append(toClear, row.ID)
|
|
}
|
|
}
|
|
|
|
// Clear before marking, and unconditionally. Restoring a file is never the
|
|
// dangerous direction, so it must not be blocked by the guard below —
|
|
// otherwise a library that tripped the cap once could never recover its
|
|
// marks even after the mount came back.
|
|
if len(toClear) > 0 {
|
|
if err := logTrackChanges(ctx, q, toClear); err != nil {
|
|
return fmt.Errorf("log restored changes: %w", err)
|
|
}
|
|
n, err := q.ClearTracksMissing(ctx, toClear)
|
|
if err != nil {
|
|
return fmt.Errorf("clear missing marks: %w", err)
|
|
}
|
|
stats.Restored = int(n)
|
|
s.logger.Info("library scan: files returned", "count", n)
|
|
}
|
|
|
|
if len(toMark) == 0 {
|
|
return nil
|
|
}
|
|
if fraction := float64(len(toMark)) / float64(len(rows)); fraction > missingMarkMaxFraction {
|
|
return fmt.Errorf(
|
|
"refusing to mark %d of %d tracks missing (%.0f%% > %.0f%% cap): "+
|
|
"this looks like an unavailable mount rather than a deletion",
|
|
len(toMark), len(rows), fraction*100, missingMarkMaxFraction*100,
|
|
)
|
|
}
|
|
|
|
if err := logTrackChanges(ctx, q, toMark); err != nil {
|
|
return fmt.Errorf("log missing changes: %w", err)
|
|
}
|
|
n, err := q.MarkTracksMissing(ctx, toMark)
|
|
if err != nil {
|
|
return fmt.Errorf("mark tracks missing: %w", err)
|
|
}
|
|
stats.Missing = int(n)
|
|
// Warn, not Info: every one of these is a library entry the operator
|
|
// probably didn't intend to lose, and the only place it surfaces today is
|
|
// this line.
|
|
s.logger.Warn("library scan: tracks marked missing (files not found)",
|
|
"count", n, "library_total", len(rows))
|
|
if n > 0 {
|
|
notifyAdmins(ctx, notifications.KindTracksMissing, notifications.Payload{Count: n})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// logTrackChanges tells the delta sync that these tracks changed, so clients
|
|
// pick up the missing mark (#2704).
|
|
//
|
|
// Without this the mark was invisible to every client: MarkTracksMissing and
|
|
// ClearTracksMissing are plain UPDATEs, and /api/library/sync is a change-log
|
|
// feed — a row that never produces a change is never re-sent, so a client
|
|
// would keep its stale copy until an unrelated edit touched the track or the
|
|
// cursor fell out of the retention window.
|
|
//
|
|
// Logged BEFORE the mutation, deliberately, which is the opposite of the
|
|
// scanner's log-after-success pattern. The two failure modes are not
|
|
// symmetric: log-then-fail-to-mark makes clients re-fetch a track that has
|
|
// not changed, which costs one wasted read. Mark-then-fail-to-log leaves the
|
|
// mark in place with no change row, and because both statements are
|
|
// idempotent (`missing_since IS NULL` / `IS NOT NULL` guards) the next scan
|
|
// will not retry the pair — so the client never learns, permanently. A
|
|
// spurious re-read is the cheaper mistake.
|
|
func logTrackChanges(ctx context.Context, q trackReconciler, ids []pgtype.UUID) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
strIDs := make([]string, 0, len(ids))
|
|
for _, id := range ids {
|
|
strIDs = append(strIDs, syncpkg.FormatUUID(id))
|
|
}
|
|
// OpUpsert, not OpDelete: the track still exists and keeps its history —
|
|
// only its playability changed. A delete would tell clients to drop the
|
|
// row, which is the behaviour #2704 deliberately rejected.
|
|
return q.InsertLibraryChanges(ctx, dbq.InsertLibraryChangesParams{
|
|
EntityType: string(syncpkg.EntityTrack),
|
|
EntityIds: strIDs,
|
|
Op: string(syncpkg.OpUpsert),
|
|
})
|
|
}
|
|
|
|
// verifyRootsPresent is the first and most important guard. If a configured root
|
|
// doesn't resolve to a readable directory, the walk beneath it found nothing and
|
|
// every row under it would look deleted. An unmounted media volume is the
|
|
// obvious case, and it is common enough — a container restart racing its volume
|
|
// mount does exactly this.
|
|
func (s *Scanner) verifyRootsPresent() error {
|
|
if len(s.paths) == 0 {
|
|
return errors.New("no scan roots configured")
|
|
}
|
|
for _, root := range s.paths {
|
|
info, err := os.Stat(root)
|
|
if err != nil {
|
|
return fmt.Errorf("scan root %q unavailable: %w", root, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("scan root %q is not a directory", root)
|
|
}
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return fmt.Errorf("scan root %q unreadable: %w", root, err)
|
|
}
|
|
// An empty root is the signature of a mount point with nothing mounted
|
|
// on it. `os.Stat` succeeds on the bare directory, so this is the only
|
|
// cheap way to tell the two apart.
|
|
if len(entries) == 0 {
|
|
return fmt.Errorf("scan root %q is empty; refusing to reconcile", root)
|
|
}
|
|
}
|
|
return nil
|
|
}
|