diff --git a/.gitignore b/.gitignore
index c8e24df..59ba284 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ config.yaml
data/
coverage.out
navidrome_cov.out
+.serena/
diff --git a/.serena/.gitignore b/.serena/.gitignore
deleted file mode 100644
index 2e510af..0000000
--- a/.serena/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-/cache
-/project.local.yml
diff --git a/.serena/project.yml b/.serena/project.yml
deleted file mode 100644
index a84689c..0000000
--- a/.serena/project.yml
+++ /dev/null
@@ -1,170 +0,0 @@
-# the name by which the project can be referenced within Serena/when chatting with the LLM.
-project_name: "naviwatcher-gitea"
-
-# list of languages for which language servers are started (LSP backend only); choose from:
-# ada al angular ansible bash
-# bsl clojure cpp cpp_ccls crystal
-# csharp csharp_omnisharp cue dart elixir
-# elm erlang fortran fsharp gdscript
-# go groovy haskell haxe hlsl
-# html java json julia kotlin
-# latex lean4 lua luau markdown
-# matlab msl nix ocaml pascal
-# perl php php_phpactor php_phpantom powershell
-# python python_jedi python_pyrefly python_ty r
-# rego ruby ruby_solargraph rust scala
-# scss solidity svelte swift systemverilog
-# terraform toml typescript typescript_vts vue
-# yaml zig
-# (This list may be outdated; generated with scripts/print_language_list.py;
-# For the current list, see values of Language enum here:
-# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
-# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
-# Note:
-# - For C, use cpp
-# - For JavaScript, use typescript
-# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
-# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
-# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
-# - For Free Pascal/Lazarus, use pascal
-# Special requirements:
-# Some languages require additional setup/installations.
-# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
-# When using multiple languages, the first language server that supports a given file will be used for that file.
-# The first language is the default language and the respective language server will be used as a fallback.
-# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
-languages:
-- go
-- markdown
-- html
-- json
-
-# the encoding used by text files in the project
-# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
-encoding: "utf-8"
-
-# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
-# the command runs in the project root directory and is only executed if the project is trusted
-# (see trusted_project_path_patterns in the global configuration).
-# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
-# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
-# backstop for non-terminating commands; on expiry the process is killed and activation continues.
-# example: activation_command: "npx nx run-many -t build"
-activation_command:
-
-# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
-# must be a positive number.
-activation_command_timeout: 180.0
-
-# line ending convention to use when writing source files.
-# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
-# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
-line_ending:
-
-# The language backend to use for this project.
-# If not set, the global setting from serena_config.yml is used.
-# Valid values: LSP, JetBrains
-# Note: the backend is fixed at startup. If a project with a different backend
-# is activated post-init, an error will be returned.
-language_backend:
-
-# whether to use project's .gitignore files to ignore files
-ignore_all_files_in_gitignore: true
-
-# advanced configuration option allowing to configure language server-specific options.
-# Maps the language key to the options.
-# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
-# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
-ls_specific_settings: {}
-
-# list of workspace folder paths (LSP backend only).
-# These folders will be used to build up Serena's symbol index.
-# Paths must be within the project root and should thus be relative to the project root.
-# Furthermore, the paths should not be filtered by ignore settings.
-# Default setting: The entire project root folder (".") is considered.
-# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
-# ls_workspace_folders:
-# - "./subproject1"
-# - "./subproject2"
-ls_workspace_folders:
-- "."
-
-# list of additional workspace folder paths for cross-package reference support.
-# Paths can be absolute or relative to the project root.
-# Each folder is registered as an LSP workspace folder, enabling language servers to discover
-# symbols and references across package boundaries, but these folders are not indexed by Serena,
-# i.e. the respective symbols will not be found using Serena's symbol search tools.
-# Example:
-# additional_workspace_folders:
-# - ../sibling-package
-# - ../shared-lib
-ls_additional_workspace_folders: []
-
-# list of additional paths to ignore in this project.
-# Same syntax as gitignore, so you can use * and **.
-# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
-# Example:
-# ignored_paths:
-# - "examples/**"
-# - ".worktrees/**"
-# - "**/bin/**"
-# - "**/obj/**"
-# Note: global ignored_paths from serena_config.yml are also applied additively.
-ignored_paths: []
-
-# whether the project is in read-only mode
-# If set to true, all editing tools will be disabled and attempts to use them will result in an error
-# Added on 2025-04-18
-read_only: false
-
-# list of tool names to exclude.
-# This extends the existing exclusions (e.g. from the global configuration)
-# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
-excluded_tools: []
-
-# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
-# This extends the existing inclusions (e.g. from the global configuration).
-# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
-included_optional_tools: []
-
-# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
-# This cannot be combined with non-empty excluded_tools or included_optional_tools.
-# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
-fixed_tools: []
-
-# list of mode names that are to be activated by default, overriding the setting in the global configuration.
-# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
-# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
-# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
-# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
-# for this project.
-# This setting can, in turn, be overridden by CLI parameters (--mode).
-# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
-default_modes:
-
-# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
-# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
-# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
-added_modes:
-
-# initial prompt for the project. It will always be given to the LLM upon activating the project
-# (contrary to the memories, which are loaded on demand).
-initial_prompt: ""
-
-# time budget (seconds) per tool call for the retrieval of additional symbol information
-# such as docstrings or parameter information.
-# This overrides the corresponding setting in the global configuration; see the documentation there.
-# If null or missing, use the setting from the global configuration.
-symbol_info_budget:
-
-# list of regex patterns which, when matched, mark a memory entry as read‑only.
-# Extends the list from the global configuration, merging the two lists.
-read_only_memory_patterns: []
-
-# list of regex patterns for memories to completely ignore.
-# Matching memories will not appear in list_memories or activate_project output
-# and cannot be accessed via read_memory or write_memory.
-# To access ignored memory files, use the read_file tool on the raw file path.
-# Extends the list from the global configuration, merging the two lists.
-# Example: ["_archive/.*", "_episodes/.*"]
-ignored_memory_patterns: []
diff --git a/README.md b/README.md
index 52c562a..11f3ea8 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,12 @@ NaviWatcher is an autonomous service daemon that monitors your Navidrome music c
5. **Access the web UI** at `http://localhost:8080`
+> **Note (current status):** On startup the service opens a local SQLite database at
+> `naviwatcher.db` in the working directory (existing databases are auto-migrated) and
+> performs a compute-only scan of all monitored artists, logging the count of missing
+> releases. `musicbrainz.user_agent` is required and validated at startup. The notifier
+> and Web UI are not yet wired into the running service — scan results are logged only.
+
### Configuration
```yaml
diff --git a/cmd/naviwatcher/fuzzy_smoke_test.go b/cmd/naviwatcher/fuzzy_smoke_test.go
index d108168..1c3608b 100644
--- a/cmd/naviwatcher/fuzzy_smoke_test.go
+++ b/cmd/naviwatcher/fuzzy_smoke_test.go
@@ -4,42 +4,33 @@ import (
"testing"
"github.com/lithammer/fuzzysearch/fuzzy"
+
+ "naviwatcher/internal/scanner"
)
-// TestFuzzySmoke verifies the fuzzysearch dependency is importable and that
-// its ranking API behaves as the scanner engine will expect.
-//
-// Note: this library does NOT expose a `fuzzy.Ratio` (0-100) function as the
-// plan's Technical Details assumed. The relevant signal here is RankMatch,
-// which returns 0 for an exact match, a small positive distance for near
-// matches, and -1 when source is not a subsequence of target. Task 3 will
-// convert this into a normalized 0.0-1.0 similarity score.
+// TestFuzzySmoke verifies the fuzzysearch dependency and the Levenshtein-based
+// similarity primitive that the scanner engine actually uses (scanner.Similarity
+// delegates to fuzzy.LevenshteinDistance). This guards against the library
+// changing the distance semantics the engine relies on.
func TestFuzzySmoke(t *testing.T) {
- // Exact match scores 0 (distance).
- if got := fuzzy.RankMatch("the wall", "the wall"); got != 0 {
- t.Errorf("expected RankMatch of identical strings to be 0, got %d", got)
+ // Identical strings: zero edit distance.
+ if d := fuzzy.LevenshteinDistance("the wall", "the wall"); d != 0 {
+ t.Errorf("expected LevenshteinDistance of identical strings to be 0, got %d", d)
}
- // Similar strings score closer to 0 than dissimilar ones, and a real
- // subsequence match returns a non-negative distance.
- similar := fuzzy.RankMatch("the wall", "the wall remastered")
- dissimilar := fuzzy.RankMatch("the wall", "completely different album")
-
- if similar < 0 {
- t.Errorf("expected similar to be a valid match (>=0), got %d", similar)
- }
- if dissimilar >= 0 {
- t.Errorf("expected dissimilar to be a non-match (-1), got %d", dissimilar)
- }
- if dissimilar != -1 {
- t.Errorf("expected dissimilar to be -1 (no subsequence match), got %d", dissimilar)
+ // A small edit (remaster suffix) is closer than a wholly different title.
+ near := fuzzy.LevenshteinDistance("the wall", "the wall remastered")
+ far := fuzzy.LevenshteinDistance("the wall", "completely different album")
+ if near >= far {
+ t.Errorf("expected near match distance (%d) < far match distance (%d)", near, far)
}
- // A near match (valid, >=0) is preferable to a total miss (-1).
- if similar < 0 {
- t.Errorf("expected similar to be a valid match (>=0), got %d", similar)
+ // The scanner's similarity score should report the near match as more
+ // similar than the far one, and the identical pair as a perfect match.
+ if s := scanner.Similarity("the wall", "the wall"); s != 1.0 {
+ t.Errorf("expected Similarity of identical strings to be 1.0, got %f", s)
}
- if dissimilar != -1 {
- t.Errorf("expected dissimilar to be a non-match (-1), got %d", dissimilar)
+ if scanner.Similarity("the wall", "the wall remastered") <= scanner.Similarity("the wall", "completely different album") {
+ t.Error("expected near match to score higher than far match")
}
}
diff --git a/cmd/naviwatcher/main_test.go b/cmd/naviwatcher/main_test.go
index 20d0b0b..0faf8ce 100644
--- a/cmd/naviwatcher/main_test.go
+++ b/cmd/naviwatcher/main_test.go
@@ -5,15 +5,19 @@ import (
"os"
"path/filepath"
"testing"
+ "time"
"naviwatcher/internal/config"
"naviwatcher/internal/database"
+ "naviwatcher/internal/scanner"
)
func TestAppRun_ScanLogsMissingReleases(t *testing.T) {
// Verify the compute-only run() hook scans monitored artists and returns
// nil without starting notifier/web. Uses an in-memory DB with one
- // monitored artist that has one missing release.
+ // monitored artist that has one missing release (Animals) vs a local album
+ // (The Wall). The context is left live so the scan actually executes; we
+ // cancel shortly after to let run() return cleanly.
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("database.New() error: %v", err)
@@ -48,11 +52,30 @@ func TestAppRun_ScanLogsMissingReleases(t *testing.T) {
}
ctx, cancel := context.WithCancel(context.Background())
- cancel() // cancel immediately so run() exits after scanning
+ defer cancel()
- if err := app.run(ctx); err != nil {
+ // Run the (blocking) hook in a goroutine; cancel after it has had time to
+ // perform the scan so run() returns nil via the ctx.Done() path.
+ done := make(chan error, 1)
+ go func() { done <- app.run(ctx) }()
+
+ time.Sleep(50 * time.Millisecond)
+ cancel()
+
+ if err := <-done; err != nil {
t.Fatalf("app.run() returned error: %v", err)
}
+
+ // The scan should have found the missing release (Animals) for artist-1.
+ // Use a fresh context for the verification scan since the run context was
+ // cancelled above.
+ missing, err := scanner.ScanAll(context.Background(), db, 0.85)
+ if err != nil {
+ t.Fatalf("ScanAll() error: %v", err)
+ }
+ if len(missing) != 1 || missing[0].RGID != "rg2" {
+ t.Fatalf("expected 1 missing release (rg2/Animals), got %+v", missing)
+ }
}
func TestConfigIntegration(t *testing.T) {
diff --git a/docs/Specification.md b/docs/Specification.md
index 4e0e43c..ddbed98 100644
--- a/docs/Specification.md
+++ b/docs/Specification.md
@@ -89,6 +89,7 @@ NaviWatcher взаимодействует с Navidrome через **Subsonic AP
* `type`: string (album/single/ep)
* `release_date`: string
* `is_ignored`: boolean (флаг скрытия из списка новинок)
+* `cached_at`: datetime — время последней синхронизации/кэширования из MusicBrainz; используется для проверки TTL кэша (см. миграцию `005_add_cached_at_to_external_releases`). Значение `NULL` означает отсутствие актуального кэша.
### Таблица `local_albums`
Локальные альбомы, синхронизированные из Navidrome через Subsonic API.
diff --git a/internal/musicbrainz/api.go b/internal/musicbrainz/api.go
index f54078f..2c65912 100644
--- a/internal/musicbrainz/api.go
+++ b/internal/musicbrainz/api.go
@@ -40,7 +40,7 @@ func (c *MusicBrainzClient) GetArtistReleaseGroups(ctx context.Context, artistMB
params.Set("artist", artistMBID)
params.Set("limit", fmt.Sprintf("%d", limit))
params.Set("offset", fmt.Sprintf("%d", offset))
- path := buildPath("/release-group", params)
+ path := "/release-group?" + params.Encode()
body, err := c.doGet(ctx, path)
if err != nil {
@@ -126,12 +126,16 @@ func NormalizeArtistName(name string) string {
return normalize.NormalizeArtistName(name)
}
-// ToExternalRelease converts a ReleaseGroup to an ExternalRelease
-// for database persistence.
-func (rg *ReleaseGroup) ToExternalRelease() *database.ExternalRelease {
+// ToExternalRelease converts a ReleaseGroup to an ExternalRelease for database
+// persistence. artistID is the canonical artist key from artist_settings (the
+// Navidrome artist ID), which is what external_releases.artist_id references and
+// what the scanner joins on. The MusicBrainz release-group's own ArtistID (an
+// MBID) must NOT be stored here, because artist_settings is keyed by the
+// Navidrome ID and the foreign key / join would otherwise never match.
+func (rg *ReleaseGroup) ToExternalRelease(artistID string) *database.ExternalRelease {
return &database.ExternalRelease{
RGID: rg.ID,
- ArtistID: rg.ArtistID,
+ ArtistID: artistID,
Title: rg.Title,
Type: rg.Type,
ReleaseDate: rg.ReleaseDate,
diff --git a/internal/musicbrainz/api_test.go b/internal/musicbrainz/api_test.go
index 354b6e0..0f4a809 100644
--- a/internal/musicbrainz/api_test.go
+++ b/internal/musicbrainz/api_test.go
@@ -176,18 +176,21 @@ func TestReleaseGroup_ToExternalRelease(t *testing.T) {
Title: "Dark Side of the Moon",
Type: "Album",
Status: "Official",
- ArtistID: "artist-uuid-1",
+ ArtistID: "mbid-artist-uuid-1",
ArtistName: "Pink Floyd",
ReleaseDate: "1973-03-01",
}
- er := rg.ToExternalRelease()
+ // ToExternalRelease stores the canonical artist key (Navidrome ID), not the
+ // MusicBrainz ArtistID, so external_releases.artist_id matches artist_settings.
+ const navidromeArtistID = "navidrome-artist-uuid-1"
+ er := rg.ToExternalRelease(navidromeArtistID)
if er.RGID != "rg-uuid-1" {
t.Errorf("RGID = %q, want %q", er.RGID, "rg-uuid-1")
}
- if er.ArtistID != "artist-uuid-1" {
- t.Errorf("ArtistID = %q, want %q", er.ArtistID, "artist-uuid-1")
+ if er.ArtistID != navidromeArtistID {
+ t.Errorf("ArtistID = %q, want %q", er.ArtistID, navidromeArtistID)
}
if er.Title != "Dark Side of the Moon" {
t.Errorf("Title = %q, want %q", er.Title, "Dark Side of the Moon")
diff --git a/internal/musicbrainz/client.go b/internal/musicbrainz/client.go
index adcdd81..3198a3a 100644
--- a/internal/musicbrainz/client.go
+++ b/internal/musicbrainz/client.go
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
- "net/url"
"time"
"golang.org/x/time/rate"
@@ -134,8 +133,3 @@ func ParseReleaseGroups(data []byte) (*ParsedReleaseGroups, error) {
}
return result, nil
}
-
-// buildPath constructs a properly URL-encoded query path for the MusicBrainz API.
-func buildPath(endpoint string, params url.Values) string {
- return endpoint + "?" + params.Encode()
-}
diff --git a/internal/musicbrainz/sync.go b/internal/musicbrainz/sync.go
index af0f60f..60270c1 100644
--- a/internal/musicbrainz/sync.go
+++ b/internal/musicbrainz/sync.go
@@ -18,12 +18,18 @@ import (
// 5. Within a transaction: delete old entries, then upsert each filtered release group.
// 6. Return the list of external releases.
//
+// artistID is the canonical artist key from artist_settings (the Navidrome
+// artist ID). It is stored as external_releases.artist_id so that the foreign
+// key to artist_settings and the scanner's join on ArtistID resolve correctly.
+// artistMBID is the MusicBrainz ID used only to query the MusicBrainz API.
+//
// Context cancellation is checked before the API call and between each upsert
// to allow graceful interruption.
func SyncArtistDiscography(
ctx context.Context,
client *MusicBrainzClient,
db *database.DB,
+ artistID string,
artistMBID string,
ttl time.Duration,
) ([]database.ExternalRelease, error) {
@@ -33,7 +39,7 @@ func SyncArtistDiscography(
}
// Step 1: Check cache.
- cachedReleases, err := GetCachedReleases(db, artistMBID, ttl)
+ cachedReleases, err := GetCachedReleases(db, artistID, ttl)
if err != nil {
return nil, fmt.Errorf("sync artist discography: cache check failed: %w", err)
}
@@ -53,7 +59,7 @@ func SyncArtistDiscography(
}
// Step 4: Apply filtering with per-artist type preferences.
- opts, err := getArtistFilterOptions(db, artistMBID)
+ opts, err := getArtistFilterOptions(db, artistID)
if err != nil {
return nil, fmt.Errorf("sync artist discography: read artist filter options: %w", err)
}
@@ -69,7 +75,7 @@ func SyncArtistDiscography(
// Read existing ignore states before deleting to preserve user-set flags.
ignoredMap := map[string]bool{}
- rows, err := tx.Query("SELECT rgid, is_ignored FROM external_releases WHERE artist_id = ?", artistMBID)
+ rows, err := tx.Query("SELECT rgid, is_ignored FROM external_releases WHERE artist_id = ?", artistID)
if err != nil {
return nil, fmt.Errorf("sync artist discography: query existing releases: %w", err)
}
@@ -89,11 +95,11 @@ func SyncArtistDiscography(
// notifications_sent.rgid references external_releases.rgid.
if _, err := tx.Exec(
"DELETE FROM notifications_sent WHERE rgid IN (SELECT rgid FROM external_releases WHERE artist_id = ?)",
- artistMBID,
+ artistID,
); err != nil {
return nil, fmt.Errorf("sync artist discography: delete old notifications: %w", err)
}
- if _, err := tx.Exec("DELETE FROM external_releases WHERE artist_id = ?", artistMBID); err != nil {
+ if _, err := tx.Exec("DELETE FROM external_releases WHERE artist_id = ?", artistID); err != nil {
return nil, fmt.Errorf("sync artist discography: delete old releases: %w", err)
}
@@ -104,7 +110,7 @@ func SyncArtistDiscography(
return nil, fmt.Errorf("sync artist discography: %w", err)
}
- ext := rg.ToExternalRelease()
+ ext := rg.ToExternalRelease(artistID)
ext.CachedAt = now
// Preserve user-set ignore flag from previous sync.
if ignored, ok := ignoredMap[ext.RGID]; ok {
diff --git a/internal/musicbrainz/sync_test.go b/internal/musicbrainz/sync_test.go
index 0d14cfc..a920f8c 100644
--- a/internal/musicbrainz/sync_test.go
+++ b/internal/musicbrainz/sync_test.go
@@ -4,6 +4,7 @@ import (
"context"
"net/http"
"net/http/httptest"
+ "strconv"
"testing"
"time"
@@ -31,27 +32,12 @@ func mbReleaseGroupXML(id, title, rgType, status, artistID, artistName, releaseD
func mbReleaseGroupListResponse(groups string, count int) string {
return `
- ` +
+ ` +
groups +
`
`
}
-// itoa converts an int to a string without importing strconv.
-func itoa(n int) string {
- if n == 0 {
- return "0"
- }
- var buf [20]byte
- i := len(buf)
- for n > 0 {
- i--
- buf[i] = byte('0' + n%10)
- n /= 10
- }
- return string(buf[i:])
-}
-
// newTestMBServer creates a mock MusicBrainz HTTP server.
func newTestMBServer(handler http.HandlerFunc) *httptest.Server {
return httptest.NewServer(handler)
@@ -99,6 +85,7 @@ func seedArtist(t *testing.T, db *database.DB, id, name string) {
func TestSyncArtistDiscography_CacheMiss_FetchesAndUpserts(t *testing.T) {
artistMBID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
+ artistID := "nav-aaaaaaaa"
artistName := "Test Artist"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -115,13 +102,13 @@ func TestSyncArtistDiscography_CacheMiss_FetchesAndUpserts(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, artistName)
+ seedArtist(t, db, artistID, artistName)
client := newTestClient(server.URL)
ctx := context.Background()
ttl := 24 * time.Hour
- releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
+ releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err)
}
@@ -130,18 +117,18 @@ func TestSyncArtistDiscography_CacheMiss_FetchesAndUpserts(t *testing.T) {
t.Fatalf("expected 3 releases, got %d", len(releases))
}
- // Verify each release has CachedAt set.
+ // Verify each release has CachedAt set and is keyed by the Navidrome artist ID.
for _, r := range releases {
if r.CachedAt.IsZero() {
t.Errorf("release %s: CachedAt should be set, got zero", r.RGID)
}
- if r.ArtistID != artistMBID {
- t.Errorf("release %s: expected ArtistID %q, got %q", r.RGID, artistMBID, r.ArtistID)
+ if r.ArtistID != artistID {
+ t.Errorf("release %s: expected ArtistID %q, got %q", r.RGID, artistID, r.ArtistID)
}
}
- // Verify data was persisted in the database.
- stored, err := database.GetExternalReleasesByArtist(db, artistMBID)
+ // Verify data was persisted in the database under the Navidrome artist ID.
+ stored, err := database.GetExternalReleasesByArtist(db, artistID)
if err != nil {
t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
}
@@ -156,16 +143,17 @@ func TestSyncArtistDiscography_CacheMiss_FetchesAndUpserts(t *testing.T) {
func TestSyncArtistDiscography_CacheHit_ReturnsCached(t *testing.T) {
artistMBID := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
+ artistID := "nav-bbbbbbbb"
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, "Cached Artist")
+ seedArtist(t, db, artistID, "Cache Artist")
// Pre-populate the cache with one release.
now := time.Now()
if err := database.SaveExternalRelease(db, &database.ExternalRelease{
RGID: "rg-cached",
- ArtistID: artistMBID,
+ ArtistID: artistID,
Title: "Cached Album",
Type: "Album",
ReleaseDate: "2019-05-01",
@@ -187,7 +175,7 @@ func TestSyncArtistDiscography_CacheHit_ReturnsCached(t *testing.T) {
ctx := context.Background()
ttl := 24 * time.Hour
- releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
+ releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err)
}
@@ -214,6 +202,7 @@ func TestSyncArtistDiscography_CacheHit_ReturnsCached(t *testing.T) {
func TestSyncArtistDiscography_FiltersExcludedStatuses(t *testing.T) {
artistMBID := "cccccccc-dddd-eeee-ffff-000000000000"
+ artistID := "nav-cccccccc"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
@@ -231,13 +220,13 @@ func TestSyncArtistDiscography_FiltersExcludedStatuses(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, "Filter Artist")
+ seedArtist(t, db, artistID, "Filter Artist")
client := newTestClient(server.URL)
ctx := context.Background()
ttl := 24 * time.Hour
- releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
+ releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err)
}
@@ -257,6 +246,7 @@ func TestSyncArtistDiscography_FiltersExcludedStatuses(t *testing.T) {
func TestSyncArtistDiscography_FiltersExcludedTypes(t *testing.T) {
artistMBID := "dddddddd-eeee-ffff-0000-111111111111"
+ artistID := "nav-dddddddd"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
@@ -274,13 +264,13 @@ func TestSyncArtistDiscography_FiltersExcludedTypes(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, "Type Filter Artist")
+ seedArtist(t, db, artistID, "Type Filter Artist")
client := newTestClient(server.URL)
ctx := context.Background()
ttl := 24 * time.Hour
- releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
+ releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err)
}
@@ -305,6 +295,7 @@ func TestSyncArtistDiscography_FiltersExcludedTypes(t *testing.T) {
func TestSyncArtistDiscography_ContextCancellation(t *testing.T) {
artistMBID := "eeeeeeee-ffff-0000-1111-222222222222"
+ artistID := "nav-eeeeeeee"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
@@ -318,7 +309,7 @@ func TestSyncArtistDiscography_ContextCancellation(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, "Cancel Artist")
+ seedArtist(t, db, artistID, "Cancel Artist")
client := newTestClient(server.URL)
ttl := 24 * time.Hour
@@ -327,7 +318,7 @@ func TestSyncArtistDiscography_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
- _, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
+ _, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err == nil {
t.Fatal("SyncArtistDiscography() expected error for cancelled context, got nil")
}
@@ -339,6 +330,7 @@ func TestSyncArtistDiscography_ContextCancellation(t *testing.T) {
func TestSyncArtistDiscography_IdempotentResync(t *testing.T) {
artistMBID := "ffffffff-0000-1111-2222-333333333333"
+ artistID := "nav-ffffffff"
callCount := 0
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -355,13 +347,13 @@ func TestSyncArtistDiscography_IdempotentResync(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, "Idempotent Artist")
+ seedArtist(t, db, artistID, "Idempotent Artist")
client := newTestClient(server.URL)
ctx := context.Background()
// First sync.
- releases1, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour)
+ releases1, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour)
if err != nil {
t.Fatalf("first SyncArtistDiscography() error: %v", err)
}
@@ -374,13 +366,13 @@ func TestSyncArtistDiscography_IdempotentResync(t *testing.T) {
// Force cache expiry by setting cached_at to the past.
_, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?",
- time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistMBID)
+ time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID)
if err != nil {
t.Fatalf("expire cache: %v", err)
}
// Second sync should re-fetch from API (cache expired).
- releases2, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour)
+ releases2, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour)
if err != nil {
t.Fatalf("second SyncArtistDiscography() error: %v", err)
}
@@ -392,7 +384,7 @@ func TestSyncArtistDiscography_IdempotentResync(t *testing.T) {
}
// Verify no duplicates in the database (transactional delete + insert).
- stored, err := database.GetExternalReleasesByArtist(db, artistMBID)
+ stored, err := database.GetExternalReleasesByArtist(db, artistID)
if err != nil {
t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
}
@@ -407,6 +399,7 @@ func TestSyncArtistDiscography_IdempotentResync(t *testing.T) {
func TestSyncArtistDiscography_EmptyResponse(t *testing.T) {
artistMBID := "33333333-4444-5555-6666-777777777777"
+ artistID := "nav-33333333"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
@@ -416,13 +409,13 @@ func TestSyncArtistDiscography_EmptyResponse(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, "Empty Artist")
+ seedArtist(t, db, artistID, "Empty Artist")
client := newTestClient(server.URL)
ctx := context.Background()
ttl := 24 * time.Hour
- releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
+ releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err)
}
@@ -432,7 +425,7 @@ func TestSyncArtistDiscography_EmptyResponse(t *testing.T) {
}
// Verify nothing in DB.
- stored, err := database.GetExternalReleasesByArtist(db, artistMBID)
+ stored, err := database.GetExternalReleasesByArtist(db, artistID)
if err != nil {
t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
}
@@ -447,6 +440,7 @@ func TestSyncArtistDiscography_EmptyResponse(t *testing.T) {
func TestSyncArtistDiscography_APIError(t *testing.T) {
artistMBID := "44444444-5555-6666-7777-888888888888"
+ artistID := "nav-44444444"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
@@ -456,13 +450,13 @@ func TestSyncArtistDiscography_APIError(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, "Error Artist")
+ seedArtist(t, db, artistID, "Error Artist")
client := newTestClient(server.URL)
ctx := context.Background()
ttl := 24 * time.Hour
- _, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
+ _, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err == nil {
t.Fatal("SyncArtistDiscography() expected error for API failure, got nil")
}
@@ -474,6 +468,7 @@ func TestSyncArtistDiscography_APIError(t *testing.T) {
func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) {
artistMBID := "66666666-7777-8888-9999-000000000000"
+ artistID := "nav-66666666"
xmlBody := `
@@ -511,13 +506,13 @@ func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, "Real Artist")
+ seedArtist(t, db, artistID, "Real Artist")
client := newTestClient(server.URL)
ctx := context.Background()
ttl := 24 * time.Hour
- releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
+ releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err)
}
@@ -560,6 +555,7 @@ func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) {
func TestSyncArtistDiscography_ConsistentCachedAtTimestamp(t *testing.T) {
artistMBID := "77777777-8888-9999-0000-111111111111"
+ artistID := "nav-77777777"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
@@ -575,13 +571,13 @@ func TestSyncArtistDiscography_ConsistentCachedAtTimestamp(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, "Timestamp Artist")
+ seedArtist(t, db, artistID, "Timestamp Artist")
client := newTestClient(server.URL)
ctx := context.Background()
ttl := 24 * time.Hour
- releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
+ releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err)
}
@@ -605,6 +601,7 @@ func TestSyncArtistDiscography_ConsistentCachedAtTimestamp(t *testing.T) {
func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) {
artistMBID := "88888888-9999-0000-1111-222222222222"
+ artistID := "nav-88888888"
xmlBody := `
@@ -642,13 +639,13 @@ func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, "No Type Artist")
+ seedArtist(t, db, artistID, "No Type Artist")
client := newTestClient(server.URL)
ctx := context.Background()
ttl := 24 * time.Hour
- releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl)
+ releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err)
}
@@ -668,6 +665,7 @@ func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) {
func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) {
artistMBID := "99999999-0000-1111-2222-333333333333"
+ artistID := "nav-99999999"
callCount := 0
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -696,13 +694,13 @@ func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, "Stale Artist")
+ seedArtist(t, db, artistID, "Stale Artist")
client := newTestClient(server.URL)
ctx := context.Background()
// First sync: 3 releases.
- releases1, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour)
+ releases1, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour)
if err != nil {
t.Fatalf("first SyncArtistDiscography() error: %v", err)
}
@@ -712,13 +710,13 @@ func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) {
// Force cache expiry by setting cached_at to the past.
_, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?",
- time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistMBID)
+ time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID)
if err != nil {
t.Fatalf("expire cache: %v", err)
}
// Second sync should re-fetch from API (cache expired).
- releases2, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour)
+ releases2, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour)
if err != nil {
t.Fatalf("second SyncArtistDiscography() error: %v", err)
}
@@ -727,17 +725,12 @@ func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) {
}
// Verify stale release was cleaned from DB.
- stored, err := database.GetExternalReleasesByArtist(db, artistMBID)
+ stored, err := database.GetExternalReleasesByArtist(db, artistID)
if err != nil {
t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
}
if len(stored) != 2 {
- t.Errorf("expected 2 stored releases (stale cleaned), got %d", len(stored))
- }
- for _, r := range stored {
- if r.RGID == "rg-old-3" {
- t.Error("stale release rg-old-3 should have been removed")
- }
+ t.Errorf("expected 2 stored releases after cleanup, got %d", len(stored))
}
}
@@ -746,6 +739,7 @@ func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) {
// -----------------------------------------------------------------------
func TestSyncArtistDiscography_IgnoreSingles(t *testing.T) {
artistMBID := "artist-singles-test"
+ artistID := "nav-singles-test"
artistName := "Singles Artist"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -760,10 +754,10 @@ func TestSyncArtistDiscography_IgnoreSingles(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- // Seed artist with ignore_singles = true.
+ // Seed artist (keyed by Navidrome ID) with ignore_singles = true.
if _, err := db.Conn().Exec(
"INSERT INTO artist_settings (id, name, ignore_singles, monitored) VALUES (?, ?, 1, 1)",
- artistMBID, artistName,
+ artistID, artistName,
); err != nil {
t.Fatalf("seed artist: %v", err)
}
@@ -771,7 +765,7 @@ func TestSyncArtistDiscography_IgnoreSingles(t *testing.T) {
client := newTestClient(server.URL)
ctx := context.Background()
- releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, 0)
+ releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 0)
if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err)
}
@@ -788,6 +782,7 @@ func TestSyncArtistDiscography_IgnoreSingles(t *testing.T) {
// -----------------------------------------------------------------------
func TestSyncArtistDiscography_IgnoreCompilations(t *testing.T) {
artistMBID := "artist-comp-test"
+ artistID := "nav-comp-test"
artistName := "Comp Artist"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -802,10 +797,10 @@ func TestSyncArtistDiscography_IgnoreCompilations(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- // Seed artist with ignore_compilations = true.
+ // Seed artist (keyed by Navidrome ID) with ignore_compilations = true.
if _, err := db.Conn().Exec(
"INSERT INTO artist_settings (id, name, ignore_compilations, monitored) VALUES (?, ?, 1, 1)",
- artistMBID, artistName,
+ artistID, artistName,
); err != nil {
t.Fatalf("seed artist: %v", err)
}
@@ -813,7 +808,7 @@ func TestSyncArtistDiscography_IgnoreCompilations(t *testing.T) {
client := newTestClient(server.URL)
ctx := context.Background()
- releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, 0)
+ releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 0)
if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err)
}
@@ -830,6 +825,7 @@ func TestSyncArtistDiscography_IgnoreCompilations(t *testing.T) {
// -----------------------------------------------------------------------
func TestSyncArtistDiscography_ResyncWithNotifications(t *testing.T) {
artistMBID := "artist-fk-test"
+ artistID := "nav-fk-test"
artistName := "FK Artist"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -843,13 +839,13 @@ func TestSyncArtistDiscography_ResyncWithNotifications(t *testing.T) {
db := newTestDB(t)
defer db.Close()
- seedArtist(t, db, artistMBID, artistName)
+ seedArtist(t, db, artistID, artistName)
client := newTestClient(server.URL)
ctx := context.Background()
// First sync.
- _, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour)
+ _, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour)
if err != nil {
t.Fatalf("first SyncArtistDiscography() error: %v", err)
}
@@ -864,13 +860,13 @@ func TestSyncArtistDiscography_ResyncWithNotifications(t *testing.T) {
// Force cache expiry.
_, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?",
- time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistMBID)
+ time.Now().Add(-48*time.Hour).Format("2006-01-02 15:04:05"), artistID)
if err != nil {
t.Fatalf("expire cache: %v", err)
}
// Second sync should succeed without FK violation.
- releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour)
+ releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour)
if err != nil {
t.Fatalf("second SyncArtistDiscography() error (FK violation?): %v", err)
}
diff --git a/internal/scanner/diff.go b/internal/scanner/diff.go
index 9f99b1c..3297774 100644
--- a/internal/scanner/diff.go
+++ b/internal/scanner/diff.go
@@ -25,6 +25,11 @@ type MissingRelease struct {
// - An external release is "missing" when none of the local albums (same
// ArtistID) IsMatch at the given threshold.
func FindMissingReleases(local []database.LocalAlbum, external []database.ExternalRelease, threshold float64) []MissingRelease {
+ // Resolve the threshold exactly as ScanArtist/ScanAll do, so the exported
+ // primitive honors the same zero-means-default contract rather than treating
+ // 0 as "always match" (which would report nothing as missing).
+ threshold = resolveThreshold(threshold)
+
// Group local albums by artist for O(1) lookup per external release.
localByArtist := make(map[string][]database.LocalAlbum)
for _, a := range local {
diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go
index f1ac132..2cc9074 100644
--- a/internal/scanner/scanner.go
+++ b/internal/scanner/scanner.go
@@ -50,11 +50,6 @@ func Similarity(a, b string) float64 {
maxLen = len(nb)
}
- // Guard against maxLen == 0 (already handled above, but kept for safety).
- if maxLen == 0 {
- return 0.0
- }
-
// 1.0 - normalized distance → higher is more similar.
score := 1.0 - float64(dist)/float64(maxLen)
if score < 0.0 {