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 {