feat: add DB-backed scanner entrypoint and compute-only main hook

Implements ScanArtist/ScanAll in internal/scanner loading local/external
releases via the database layer with ctx-cancellation checks, plus a
compute-only run() hook that logs missing-release counts. Add table-driven
tests using in-memory SQLite fixtures.
This commit is contained in:
2026-07-19 18:19:57 +03:00
parent a4f0460664
commit c2da8c2095
6 changed files with 348 additions and 8 deletions

66
internal/scanner/scan.go Normal file
View File

@@ -0,0 +1,66 @@
package scanner
import (
"context"
"naviwatcher/internal/database"
)
// ScanArtist loads the local albums and external releases for a single artist
// from the database and computes the list of missing releases.
//
// threshold is the fuzzy-similarity cutoff; pass 0 to use DefaultThreshold.
// The context is checked before querying the database; if it is already
// cancelled, no work is performed and the sentinel error ctx.Err() is returned.
func ScanArtist(ctx context.Context, db *database.DB, artistID string, threshold float64) ([]MissingRelease, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
local, err := database.GetLocalAlbumsByArtist(db, artistID)
if err != nil {
return nil, err
}
external, err := database.GetExternalReleasesByArtist(db, artistID)
if err != nil {
return nil, err
}
missing := FindMissingReleases(local, external, resolveThreshold(threshold))
return missing, nil
}
// ScanAll iterates over all monitored artists (those with Monitored == true)
// and computes the missing releases for each. Results are concatenated into a
// single slice across all artists.
//
// ctx.Err() is checked between artists; if cancellation occurs mid-iteration,
// scanning stops early and the accumulated results so far are returned along
// with the cancellation error. threshold follows the same contract as
// ScanArtist (0 → DefaultThreshold).
func ScanAll(ctx context.Context, db *database.DB, threshold float64) ([]MissingRelease, error) {
settings, err := database.GetAllArtistSettings(db)
if err != nil {
return nil, err
}
resolved := resolveThreshold(threshold)
var all []MissingRelease
for _, s := range settings {
if err := ctx.Err(); err != nil {
return all, err
}
if !s.Monitored {
continue
}
missing, err := ScanArtist(ctx, db, s.ID, resolved)
if err != nil {
return all, err
}
all = append(all, missing...)
}
return all, nil
}

View File

@@ -0,0 +1,195 @@
package scanner
import (
"context"
"testing"
"naviwatcher/internal/database"
)
// newTestDB creates an in-memory SQLite database with all migrations applied.
func newTestDB(t *testing.T) *database.DB {
t.Helper()
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
}
return db
}
// seedArtist inserts a minimal artist_settings row so FK constraints pass.
func seedArtist(t *testing.T, db *database.DB, id, name string) {
t.Helper()
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: id,
Name: name,
Monitored: true,
}); err != nil {
t.Fatalf("seedArtist(%s) error: %v", id, err)
}
}
// seedLocalAlbum inserts a local_albums row for an artist.
func seedLocalAlbum(t *testing.T, db *database.DB, id, artistID, title string) {
t.Helper()
if err := database.SaveLocalAlbum(db, &database.LocalAlbum{
ID: id,
ArtistID: artistID,
Title: title,
}); err != nil {
t.Fatalf("seedLocalAlbum(%s) error: %v", id, err)
}
}
// seedExternalRelease inserts an external_releases row for an artist.
func seedExternalRelease(t *testing.T, db *database.DB, rgid, artistID, title string, ignored bool) {
t.Helper()
if err := database.SaveExternalRelease(db, &database.ExternalRelease{
RGID: rgid,
ArtistID: artistID,
Title: title,
IsIgnored: ignored,
}); err != nil {
t.Fatalf("seedExternalRelease(%s) error: %v", rgid, err)
}
}
func TestScanArtist(t *testing.T) {
db := newTestDB(t)
defer db.Close()
seedArtist(t, db, "artist-1", "Pink Floyd")
// Local collection has "The Wall" but not "Animals".
seedLocalAlbum(t, db, "l1", "artist-1", "The Wall")
seedExternalRelease(t, db, "rg1", "artist-1", "The Wall", false)
seedExternalRelease(t, db, "rg2", "artist-1", "Animals", false)
missing, err := ScanArtist(context.Background(), db, "artist-1", 0)
if err != nil {
t.Fatalf("ScanArtist() error: %v", err)
}
rgids := map[string]bool{}
for _, m := range missing {
rgids[m.RGID] = true
}
if !rgids["rg2"] {
t.Errorf("expected rg2 (Animals) to be missing, got %v", rgids)
}
if rgids["rg1"] {
t.Errorf("did not expect rg1 (The Wall) to be missing, got %v", rgids)
}
}
func TestScanArtist_IgnoredNotReported(t *testing.T) {
db := newTestDB(t)
defer db.Close()
seedArtist(t, db, "artist-1", "Pink Floyd")
seedExternalRelease(t, db, "rg1", "artist-1", "Animals", true)
missing, err := ScanArtist(context.Background(), db, "artist-1", 0.85)
if err != nil {
t.Fatalf("ScanArtist() error: %v", err)
}
if len(missing) != 0 {
t.Errorf("ignored release should not be reported, got %v", missing)
}
}
func TestScanArtist_RemasteredVariantNotMissing(t *testing.T) {
db := newTestDB(t)
defer db.Close()
seedArtist(t, db, "artist-1", "Pink Floyd")
seedLocalAlbum(t, db, "l1", "artist-1", "The Wall (Remastered)")
seedExternalRelease(t, db, "rg1", "artist-1", "The Wall", false)
missing, err := ScanArtist(context.Background(), db, "artist-1", 0)
if err != nil {
t.Fatalf("ScanArtist() error: %v", err)
}
if len(missing) != 0 {
t.Errorf("remastered local should match external, got %v", missing)
}
}
func TestScanArtist_CtxCancelled(t *testing.T) {
db := newTestDB(t)
defer db.Close()
seedArtist(t, db, "artist-1", "Pink Floyd")
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := ScanArtist(ctx, db, "artist-1", 0.85); err == nil {
t.Fatal("expected error from cancelled context, got nil")
}
}
func TestScanAll(t *testing.T) {
db := newTestDB(t)
defer db.Close()
// Monitored artist with one missing release.
seedArtist(t, db, "artist-1", "Pink Floyd")
seedLocalAlbum(t, db, "l1", "artist-1", "The Wall")
seedExternalRelease(t, db, "rg1", "artist-1", "The Wall", false)
seedExternalRelease(t, db, "rg2", "artist-1", "Animals", false)
// Unmonitored artist — must be skipped entirely.
seedArtistUnmonitored(t, db, "artist-2", "Other")
seedExternalRelease(t, db, "rg3", "artist-2", "Some Album", false)
missing, err := ScanAll(context.Background(), db, 0)
if err != nil {
t.Fatalf("ScanAll() error: %v", err)
}
rgids := map[string]bool{}
for _, m := range missing {
rgids[m.RGID] = true
}
if !rgids["rg2"] {
t.Errorf("expected rg2 (Animals) missing, got %v", rgids)
}
if rgids["rg1"] {
t.Errorf("did not expect rg1 (The Wall) missing, got %v", rgids)
}
if rgids["rg3"] {
t.Errorf("unmonitored artist's release must not be scanned, got %v", rgids)
}
}
func TestScanAll_CtxCancelledMidIteration(t *testing.T) {
db := newTestDB(t)
defer db.Close()
seedArtist(t, db, "artist-1", "Pink Floyd")
seedArtist(t, db, "artist-2", "Other")
// Cancel before scanning starts.
ctx, cancel := context.WithCancel(context.Background())
cancel()
missing, err := ScanAll(ctx, db, 0.85)
if err == nil {
t.Fatal("expected error from cancelled context, got nil")
}
if missing != nil {
t.Errorf("expected nil results on early cancellation, got %v", missing)
}
}
// seedArtistUnmonitored inserts an artist_settings row with Monitored=false.
func seedArtistUnmonitored(t *testing.T, db *database.DB, id, name string) {
t.Helper()
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
ID: id,
Name: name,
Monitored: false,
}); err != nil {
t.Fatalf("seedArtistUnmonitored(%s) error: %v", id, err)
}
}

View File

@@ -10,6 +10,20 @@ import (
"naviwatcher/internal/normalize"
)
// DefaultThreshold is the fallback similarity threshold used when a caller
// passes threshold == 0. It matches config.Scanner.FuzzyThreshold default.
const DefaultThreshold = 0.85
// resolveThreshold returns the provided threshold, or DefaultThreshold when
// the caller passes zero (unset). This keeps the engine usable when config
// defaults are not threaded through explicitly.
func resolveThreshold(threshold float64) float64 {
if threshold == 0 {
return DefaultThreshold
}
return threshold
}
// Similarity returns a normalized similarity score in the range [0.0, 1.0]
// between two strings. The strings are normalized first (lowercased,
// bracketed/parenthesized content and years stripped, special characters