release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m26s
release / Build signed APK (releases and dev) (push) Successful in 5m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
Rewrites the internal/library comment blocks to rule 32. Task, milestone, decision and rule ids are gone; the incidents behind them are restated as the constraint they left (why a file is deleted before its row, why the missing mark is a mark, why duplicates block on duration). Counts and album names from one library are dropped from production comments; test comments keep the names their fixtures use. The tagReadVersion log keeps each version's before and after, without the measurements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
7.8 KiB
Go
201 lines
7.8 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
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.
|
|
//
|
|
// The walk only visits paths that exist, so without this a row whose file is
|
|
// gone would never be scanned, never error, never be counted. The watcher
|
|
// ignores removals by design (see classifyEvent), and the safety-net scan is
|
|
// the same walk, so it covers additions only. Unmarked, such rows would keep
|
|
// being offered to recommendations and fail 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 an admin probably
|
|
// didn't intend to lose.
|
|
s.logger.Warn("library scan: tracks marked missing (files not found)",
|
|
"count", n, "library_total", len(rows))
|
|
// Admins are told after the scan has adopted moves and folded
|
|
// replacements, about the ones still gone (notifyStillMissing).
|
|
stats.marked = toMark
|
|
return nil
|
|
}
|
|
|
|
// logTrackChanges tells the delta sync that these tracks changed, so clients
|
|
// pick up the missing mark.
|
|
//
|
|
// Without this the mark would be 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, and a missing file is expected to come back.
|
|
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 {
|
|
roots := s.Roots()
|
|
if len(roots) == 0 {
|
|
return errors.New("no scan roots configured")
|
|
}
|
|
for _, root := range roots {
|
|
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
|
|
}
|