musicbrainz-provider #2

Merged
Mrixs merged 72 commits from musicbrainz-provider into master 2026-08-05 20:19:16 +00:00
14 changed files with 152 additions and 299 deletions
Showing only changes of commit 8a5b58a817 - Show all commits

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ config.yaml
data/ data/
coverage.out coverage.out
navidrome_cov.out navidrome_cov.out
.serena/

2
.serena/.gitignore vendored
View File

@@ -1,2 +0,0 @@
/cache
/project.local.yml

View File

@@ -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 readonly.
# 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: []

View File

@@ -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` 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 ### Configuration
```yaml ```yaml

View File

@@ -4,42 +4,33 @@ import (
"testing" "testing"
"github.com/lithammer/fuzzysearch/fuzzy" "github.com/lithammer/fuzzysearch/fuzzy"
"naviwatcher/internal/scanner"
) )
// TestFuzzySmoke verifies the fuzzysearch dependency is importable and that // TestFuzzySmoke verifies the fuzzysearch dependency and the Levenshtein-based
// its ranking API behaves as the scanner engine will expect. // similarity primitive that the scanner engine actually uses (scanner.Similarity
// // delegates to fuzzy.LevenshteinDistance). This guards against the library
// Note: this library does NOT expose a `fuzzy.Ratio` (0-100) function as the // changing the distance semantics the engine relies on.
// 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.
func TestFuzzySmoke(t *testing.T) { func TestFuzzySmoke(t *testing.T) {
// Exact match scores 0 (distance). // Identical strings: zero edit distance.
if got := fuzzy.RankMatch("the wall", "the wall"); got != 0 { if d := fuzzy.LevenshteinDistance("the wall", "the wall"); d != 0 {
t.Errorf("expected RankMatch of identical strings to be 0, got %d", got) 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 // A small edit (remaster suffix) is closer than a wholly different title.
// subsequence match returns a non-negative distance. near := fuzzy.LevenshteinDistance("the wall", "the wall remastered")
similar := fuzzy.RankMatch("the wall", "the wall remastered") far := fuzzy.LevenshteinDistance("the wall", "completely different album")
dissimilar := fuzzy.RankMatch("the wall", "completely different album") if near >= far {
t.Errorf("expected near match distance (%d) < far match distance (%d)", near, far)
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 near match (valid, >=0) is preferable to a total miss (-1). // The scanner's similarity score should report the near match as more
if similar < 0 { // similar than the far one, and the identical pair as a perfect match.
t.Errorf("expected similar to be a valid match (>=0), got %d", similar) 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 { if scanner.Similarity("the wall", "the wall remastered") <= scanner.Similarity("the wall", "completely different album") {
t.Errorf("expected dissimilar to be a non-match (-1), got %d", dissimilar) t.Error("expected near match to score higher than far match")
} }
} }

View File

@@ -5,15 +5,19 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"naviwatcher/internal/config" "naviwatcher/internal/config"
"naviwatcher/internal/database" "naviwatcher/internal/database"
"naviwatcher/internal/scanner"
) )
func TestAppRun_ScanLogsMissingReleases(t *testing.T) { func TestAppRun_ScanLogsMissingReleases(t *testing.T) {
// Verify the compute-only run() hook scans monitored artists and returns // Verify the compute-only run() hook scans monitored artists and returns
// nil without starting notifier/web. Uses an in-memory DB with one // 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:") db, err := database.New(":memory:")
if err != nil { if err != nil {
t.Fatalf("database.New() error: %v", err) t.Fatalf("database.New() error: %v", err)
@@ -48,11 +52,30 @@ func TestAppRun_ScanLogsMissingReleases(t *testing.T) {
} }
ctx, cancel := context.WithCancel(context.Background()) 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) 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) { func TestConfigIntegration(t *testing.T) {

View File

@@ -89,6 +89,7 @@ NaviWatcher взаимодействует с Navidrome через **Subsonic AP
* `type`: string (album/single/ep) * `type`: string (album/single/ep)
* `release_date`: string * `release_date`: string
* `is_ignored`: boolean (флаг скрытия из списка новинок) * `is_ignored`: boolean (флаг скрытия из списка новинок)
* `cached_at`: datetime — время последней синхронизации/кэширования из MusicBrainz; используется для проверки TTL кэша (см. миграцию `005_add_cached_at_to_external_releases`). Значение `NULL` означает отсутствие актуального кэша.
### Таблица `local_albums` ### Таблица `local_albums`
Локальные альбомы, синхронизированные из Navidrome через Subsonic API. Локальные альбомы, синхронизированные из Navidrome через Subsonic API.

View File

@@ -40,7 +40,7 @@ func (c *MusicBrainzClient) GetArtistReleaseGroups(ctx context.Context, artistMB
params.Set("artist", artistMBID) params.Set("artist", artistMBID)
params.Set("limit", fmt.Sprintf("%d", limit)) params.Set("limit", fmt.Sprintf("%d", limit))
params.Set("offset", fmt.Sprintf("%d", offset)) params.Set("offset", fmt.Sprintf("%d", offset))
path := buildPath("/release-group", params) path := "/release-group?" + params.Encode()
body, err := c.doGet(ctx, path) body, err := c.doGet(ctx, path)
if err != nil { if err != nil {
@@ -126,12 +126,16 @@ func NormalizeArtistName(name string) string {
return normalize.NormalizeArtistName(name) return normalize.NormalizeArtistName(name)
} }
// ToExternalRelease converts a ReleaseGroup to an ExternalRelease // ToExternalRelease converts a ReleaseGroup to an ExternalRelease for database
// for database persistence. // persistence. artistID is the canonical artist key from artist_settings (the
func (rg *ReleaseGroup) ToExternalRelease() *database.ExternalRelease { // 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{ return &database.ExternalRelease{
RGID: rg.ID, RGID: rg.ID,
ArtistID: rg.ArtistID, ArtistID: artistID,
Title: rg.Title, Title: rg.Title,
Type: rg.Type, Type: rg.Type,
ReleaseDate: rg.ReleaseDate, ReleaseDate: rg.ReleaseDate,

View File

@@ -176,18 +176,21 @@ func TestReleaseGroup_ToExternalRelease(t *testing.T) {
Title: "Dark Side of the Moon", Title: "Dark Side of the Moon",
Type: "Album", Type: "Album",
Status: "Official", Status: "Official",
ArtistID: "artist-uuid-1", ArtistID: "mbid-artist-uuid-1",
ArtistName: "Pink Floyd", ArtistName: "Pink Floyd",
ReleaseDate: "1973-03-01", 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" { if er.RGID != "rg-uuid-1" {
t.Errorf("RGID = %q, want %q", er.RGID, "rg-uuid-1") t.Errorf("RGID = %q, want %q", er.RGID, "rg-uuid-1")
} }
if er.ArtistID != "artist-uuid-1" { if er.ArtistID != navidromeArtistID {
t.Errorf("ArtistID = %q, want %q", er.ArtistID, "artist-uuid-1") t.Errorf("ArtistID = %q, want %q", er.ArtistID, navidromeArtistID)
} }
if er.Title != "Dark Side of the Moon" { if er.Title != "Dark Side of the Moon" {
t.Errorf("Title = %q, want %q", er.Title, "Dark Side of the Moon") t.Errorf("Title = %q, want %q", er.Title, "Dark Side of the Moon")

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"time" "time"
"golang.org/x/time/rate" "golang.org/x/time/rate"
@@ -134,8 +133,3 @@ func ParseReleaseGroups(data []byte) (*ParsedReleaseGroups, error) {
} }
return result, nil 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()
}

View File

@@ -18,12 +18,18 @@ import (
// 5. Within a transaction: delete old entries, then upsert each filtered release group. // 5. Within a transaction: delete old entries, then upsert each filtered release group.
// 6. Return the list of external releases. // 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 // Context cancellation is checked before the API call and between each upsert
// to allow graceful interruption. // to allow graceful interruption.
func SyncArtistDiscography( func SyncArtistDiscography(
ctx context.Context, ctx context.Context,
client *MusicBrainzClient, client *MusicBrainzClient,
db *database.DB, db *database.DB,
artistID string,
artistMBID string, artistMBID string,
ttl time.Duration, ttl time.Duration,
) ([]database.ExternalRelease, error) { ) ([]database.ExternalRelease, error) {
@@ -33,7 +39,7 @@ func SyncArtistDiscography(
} }
// Step 1: Check cache. // Step 1: Check cache.
cachedReleases, err := GetCachedReleases(db, artistMBID, ttl) cachedReleases, err := GetCachedReleases(db, artistID, ttl)
if err != nil { if err != nil {
return nil, fmt.Errorf("sync artist discography: cache check failed: %w", err) 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. // Step 4: Apply filtering with per-artist type preferences.
opts, err := getArtistFilterOptions(db, artistMBID) opts, err := getArtistFilterOptions(db, artistID)
if err != nil { if err != nil {
return nil, fmt.Errorf("sync artist discography: read artist filter options: %w", err) 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. // Read existing ignore states before deleting to preserve user-set flags.
ignoredMap := map[string]bool{} 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 { if err != nil {
return nil, fmt.Errorf("sync artist discography: query existing releases: %w", err) 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. // notifications_sent.rgid references external_releases.rgid.
if _, err := tx.Exec( if _, err := tx.Exec(
"DELETE FROM notifications_sent WHERE rgid IN (SELECT rgid FROM external_releases WHERE artist_id = ?)", "DELETE FROM notifications_sent WHERE rgid IN (SELECT rgid FROM external_releases WHERE artist_id = ?)",
artistMBID, artistID,
); err != nil { ); err != nil {
return nil, fmt.Errorf("sync artist discography: delete old notifications: %w", err) 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) 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) return nil, fmt.Errorf("sync artist discography: %w", err)
} }
ext := rg.ToExternalRelease() ext := rg.ToExternalRelease(artistID)
ext.CachedAt = now ext.CachedAt = now
// Preserve user-set ignore flag from previous sync. // Preserve user-set ignore flag from previous sync.
if ignored, ok := ignoredMap[ext.RGID]; ok { if ignored, ok := ignoredMap[ext.RGID]; ok {

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv"
"testing" "testing"
"time" "time"
@@ -31,27 +32,12 @@ func mbReleaseGroupXML(id, title, rgType, status, artistID, artistName, releaseD
func mbReleaseGroupListResponse(groups string, count int) string { func mbReleaseGroupListResponse(groups string, count int) string {
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#"> <metadata xmlns="http://musicbrainz.org/ns/mmd-2.0#">
<release-group-list count="` + itoa(count) + `">` + <release-group-list count="` + strconv.Itoa(count) + `">` +
groups + groups +
`</release-group-list> `</release-group-list>
</metadata>` </metadata>`
} }
// 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. // newTestMBServer creates a mock MusicBrainz HTTP server.
func newTestMBServer(handler http.HandlerFunc) *httptest.Server { func newTestMBServer(handler http.HandlerFunc) *httptest.Server {
return httptest.NewServer(handler) 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) { func TestSyncArtistDiscography_CacheMiss_FetchesAndUpserts(t *testing.T) {
artistMBID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" artistMBID := "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
artistID := "nav-aaaaaaaa"
artistName := "Test Artist" artistName := "Test Artist"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -115,13 +102,13 @@ func TestSyncArtistDiscography_CacheMiss_FetchesAndUpserts(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, artistName) seedArtist(t, db, artistID, artistName)
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
ttl := 24 * time.Hour ttl := 24 * time.Hour
releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl) releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil { if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err) 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)) 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 { for _, r := range releases {
if r.CachedAt.IsZero() { if r.CachedAt.IsZero() {
t.Errorf("release %s: CachedAt should be set, got zero", r.RGID) t.Errorf("release %s: CachedAt should be set, got zero", r.RGID)
} }
if r.ArtistID != artistMBID { if r.ArtistID != artistID {
t.Errorf("release %s: expected ArtistID %q, got %q", r.RGID, artistMBID, r.ArtistID) t.Errorf("release %s: expected ArtistID %q, got %q", r.RGID, artistID, r.ArtistID)
} }
} }
// Verify data was persisted in the database. // Verify data was persisted in the database under the Navidrome artist ID.
stored, err := database.GetExternalReleasesByArtist(db, artistMBID) stored, err := database.GetExternalReleasesByArtist(db, artistID)
if err != nil { if err != nil {
t.Fatalf("GetExternalReleasesByArtist() error: %v", err) t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
} }
@@ -156,16 +143,17 @@ func TestSyncArtistDiscography_CacheMiss_FetchesAndUpserts(t *testing.T) {
func TestSyncArtistDiscography_CacheHit_ReturnsCached(t *testing.T) { func TestSyncArtistDiscography_CacheHit_ReturnsCached(t *testing.T) {
artistMBID := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff" artistMBID := "bbbbbbbb-cccc-dddd-eeee-ffffffffffff"
artistID := "nav-bbbbbbbb"
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, "Cached Artist") seedArtist(t, db, artistID, "Cache Artist")
// Pre-populate the cache with one release. // Pre-populate the cache with one release.
now := time.Now() now := time.Now()
if err := database.SaveExternalRelease(db, &database.ExternalRelease{ if err := database.SaveExternalRelease(db, &database.ExternalRelease{
RGID: "rg-cached", RGID: "rg-cached",
ArtistID: artistMBID, ArtistID: artistID,
Title: "Cached Album", Title: "Cached Album",
Type: "Album", Type: "Album",
ReleaseDate: "2019-05-01", ReleaseDate: "2019-05-01",
@@ -187,7 +175,7 @@ func TestSyncArtistDiscography_CacheHit_ReturnsCached(t *testing.T) {
ctx := context.Background() ctx := context.Background()
ttl := 24 * time.Hour ttl := 24 * time.Hour
releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl) releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil { if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err) t.Fatalf("SyncArtistDiscography() error: %v", err)
} }
@@ -214,6 +202,7 @@ func TestSyncArtistDiscography_CacheHit_ReturnsCached(t *testing.T) {
func TestSyncArtistDiscography_FiltersExcludedStatuses(t *testing.T) { func TestSyncArtistDiscography_FiltersExcludedStatuses(t *testing.T) {
artistMBID := "cccccccc-dddd-eeee-ffff-000000000000" artistMBID := "cccccccc-dddd-eeee-ffff-000000000000"
artistID := "nav-cccccccc"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml") w.Header().Set("Content-Type", "application/xml")
@@ -231,13 +220,13 @@ func TestSyncArtistDiscography_FiltersExcludedStatuses(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, "Filter Artist") seedArtist(t, db, artistID, "Filter Artist")
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
ttl := 24 * time.Hour ttl := 24 * time.Hour
releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl) releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil { if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err) t.Fatalf("SyncArtistDiscography() error: %v", err)
} }
@@ -257,6 +246,7 @@ func TestSyncArtistDiscography_FiltersExcludedStatuses(t *testing.T) {
func TestSyncArtistDiscography_FiltersExcludedTypes(t *testing.T) { func TestSyncArtistDiscography_FiltersExcludedTypes(t *testing.T) {
artistMBID := "dddddddd-eeee-ffff-0000-111111111111" artistMBID := "dddddddd-eeee-ffff-0000-111111111111"
artistID := "nav-dddddddd"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml") w.Header().Set("Content-Type", "application/xml")
@@ -274,13 +264,13 @@ func TestSyncArtistDiscography_FiltersExcludedTypes(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, "Type Filter Artist") seedArtist(t, db, artistID, "Type Filter Artist")
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
ttl := 24 * time.Hour ttl := 24 * time.Hour
releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl) releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil { if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err) t.Fatalf("SyncArtistDiscography() error: %v", err)
} }
@@ -305,6 +295,7 @@ func TestSyncArtistDiscography_FiltersExcludedTypes(t *testing.T) {
func TestSyncArtistDiscography_ContextCancellation(t *testing.T) { func TestSyncArtistDiscography_ContextCancellation(t *testing.T) {
artistMBID := "eeeeeeee-ffff-0000-1111-222222222222" artistMBID := "eeeeeeee-ffff-0000-1111-222222222222"
artistID := "nav-eeeeeeee"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml") w.Header().Set("Content-Type", "application/xml")
@@ -318,7 +309,7 @@ func TestSyncArtistDiscography_ContextCancellation(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, "Cancel Artist") seedArtist(t, db, artistID, "Cancel Artist")
client := newTestClient(server.URL) client := newTestClient(server.URL)
ttl := 24 * time.Hour ttl := 24 * time.Hour
@@ -327,7 +318,7 @@ func TestSyncArtistDiscography_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
_, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl) _, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err == nil { if err == nil {
t.Fatal("SyncArtistDiscography() expected error for cancelled context, got 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) { func TestSyncArtistDiscography_IdempotentResync(t *testing.T) {
artistMBID := "ffffffff-0000-1111-2222-333333333333" artistMBID := "ffffffff-0000-1111-2222-333333333333"
artistID := "nav-ffffffff"
callCount := 0 callCount := 0
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -355,13 +347,13 @@ func TestSyncArtistDiscography_IdempotentResync(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, "Idempotent Artist") seedArtist(t, db, artistID, "Idempotent Artist")
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
// First sync. // 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 { if err != nil {
t.Fatalf("first SyncArtistDiscography() error: %v", err) 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. // Force cache expiry by setting cached_at to the past.
_, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?", _, 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 { if err != nil {
t.Fatalf("expire cache: %v", err) t.Fatalf("expire cache: %v", err)
} }
// Second sync should re-fetch from API (cache expired). // 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 { if err != nil {
t.Fatalf("second SyncArtistDiscography() error: %v", err) 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). // Verify no duplicates in the database (transactional delete + insert).
stored, err := database.GetExternalReleasesByArtist(db, artistMBID) stored, err := database.GetExternalReleasesByArtist(db, artistID)
if err != nil { if err != nil {
t.Fatalf("GetExternalReleasesByArtist() error: %v", err) t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
} }
@@ -407,6 +399,7 @@ func TestSyncArtistDiscography_IdempotentResync(t *testing.T) {
func TestSyncArtistDiscography_EmptyResponse(t *testing.T) { func TestSyncArtistDiscography_EmptyResponse(t *testing.T) {
artistMBID := "33333333-4444-5555-6666-777777777777" artistMBID := "33333333-4444-5555-6666-777777777777"
artistID := "nav-33333333"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml") w.Header().Set("Content-Type", "application/xml")
@@ -416,13 +409,13 @@ func TestSyncArtistDiscography_EmptyResponse(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, "Empty Artist") seedArtist(t, db, artistID, "Empty Artist")
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
ttl := 24 * time.Hour ttl := 24 * time.Hour
releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl) releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil { if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err) t.Fatalf("SyncArtistDiscography() error: %v", err)
} }
@@ -432,7 +425,7 @@ func TestSyncArtistDiscography_EmptyResponse(t *testing.T) {
} }
// Verify nothing in DB. // Verify nothing in DB.
stored, err := database.GetExternalReleasesByArtist(db, artistMBID) stored, err := database.GetExternalReleasesByArtist(db, artistID)
if err != nil { if err != nil {
t.Fatalf("GetExternalReleasesByArtist() error: %v", err) t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
} }
@@ -447,6 +440,7 @@ func TestSyncArtistDiscography_EmptyResponse(t *testing.T) {
func TestSyncArtistDiscography_APIError(t *testing.T) { func TestSyncArtistDiscography_APIError(t *testing.T) {
artistMBID := "44444444-5555-6666-7777-888888888888" artistMBID := "44444444-5555-6666-7777-888888888888"
artistID := "nav-44444444"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@@ -456,13 +450,13 @@ func TestSyncArtistDiscography_APIError(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, "Error Artist") seedArtist(t, db, artistID, "Error Artist")
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
ttl := 24 * time.Hour ttl := 24 * time.Hour
_, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl) _, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err == nil { if err == nil {
t.Fatal("SyncArtistDiscography() expected error for API failure, got 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) { func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) {
artistMBID := "66666666-7777-8888-9999-000000000000" artistMBID := "66666666-7777-8888-9999-000000000000"
artistID := "nav-66666666"
xmlBody := `<?xml version="1.0" encoding="UTF-8"?> xmlBody := `<?xml version="1.0" encoding="UTF-8"?>
<metadata> <metadata>
@@ -511,13 +506,13 @@ func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, "Real Artist") seedArtist(t, db, artistID, "Real Artist")
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
ttl := 24 * time.Hour ttl := 24 * time.Hour
releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl) releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil { if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err) t.Fatalf("SyncArtistDiscography() error: %v", err)
} }
@@ -560,6 +555,7 @@ func TestSyncArtistDiscography_FullXMLPipeline(t *testing.T) {
func TestSyncArtistDiscography_ConsistentCachedAtTimestamp(t *testing.T) { func TestSyncArtistDiscography_ConsistentCachedAtTimestamp(t *testing.T) {
artistMBID := "77777777-8888-9999-0000-111111111111" artistMBID := "77777777-8888-9999-0000-111111111111"
artistID := "nav-77777777"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml") w.Header().Set("Content-Type", "application/xml")
@@ -575,13 +571,13 @@ func TestSyncArtistDiscography_ConsistentCachedAtTimestamp(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, "Timestamp Artist") seedArtist(t, db, artistID, "Timestamp Artist")
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
ttl := 24 * time.Hour ttl := 24 * time.Hour
releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl) releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil { if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err) t.Fatalf("SyncArtistDiscography() error: %v", err)
} }
@@ -605,6 +601,7 @@ func TestSyncArtistDiscography_ConsistentCachedAtTimestamp(t *testing.T) {
func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) { func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) {
artistMBID := "88888888-9999-0000-1111-222222222222" artistMBID := "88888888-9999-0000-1111-222222222222"
artistID := "nav-88888888"
xmlBody := `<?xml version="1.0" encoding="UTF-8"?> xmlBody := `<?xml version="1.0" encoding="UTF-8"?>
<metadata> <metadata>
@@ -642,13 +639,13 @@ func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, "No Type Artist") seedArtist(t, db, artistID, "No Type Artist")
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
ttl := 24 * time.Hour ttl := 24 * time.Hour
releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, ttl) releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, ttl)
if err != nil { if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err) t.Fatalf("SyncArtistDiscography() error: %v", err)
} }
@@ -668,6 +665,7 @@ func TestSyncArtistDiscography_XMLNoTypeAttribute(t *testing.T) {
func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) { func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) {
artistMBID := "99999999-0000-1111-2222-333333333333" artistMBID := "99999999-0000-1111-2222-333333333333"
artistID := "nav-99999999"
callCount := 0 callCount := 0
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -696,13 +694,13 @@ func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, "Stale Artist") seedArtist(t, db, artistID, "Stale Artist")
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
// First sync: 3 releases. // 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 { if err != nil {
t.Fatalf("first SyncArtistDiscography() error: %v", err) 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. // Force cache expiry by setting cached_at to the past.
_, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?", _, 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 { if err != nil {
t.Fatalf("expire cache: %v", err) t.Fatalf("expire cache: %v", err)
} }
// Second sync should re-fetch from API (cache expired). // 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 { if err != nil {
t.Fatalf("second SyncArtistDiscography() error: %v", err) t.Fatalf("second SyncArtistDiscography() error: %v", err)
} }
@@ -727,17 +725,12 @@ func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) {
} }
// Verify stale release was cleaned from DB. // Verify stale release was cleaned from DB.
stored, err := database.GetExternalReleasesByArtist(db, artistMBID) stored, err := database.GetExternalReleasesByArtist(db, artistID)
if err != nil { if err != nil {
t.Fatalf("GetExternalReleasesByArtist() error: %v", err) t.Fatalf("GetExternalReleasesByArtist() error: %v", err)
} }
if len(stored) != 2 { if len(stored) != 2 {
t.Errorf("expected 2 stored releases (stale cleaned), got %d", len(stored)) t.Errorf("expected 2 stored releases after cleanup, 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")
}
} }
} }
@@ -746,6 +739,7 @@ func TestSyncArtistDiscography_CleansStaleReleases(t *testing.T) {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
func TestSyncArtistDiscography_IgnoreSingles(t *testing.T) { func TestSyncArtistDiscography_IgnoreSingles(t *testing.T) {
artistMBID := "artist-singles-test" artistMBID := "artist-singles-test"
artistID := "nav-singles-test"
artistName := "Singles Artist" artistName := "Singles Artist"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -760,10 +754,10 @@ func TestSyncArtistDiscography_IgnoreSingles(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
// Seed artist with ignore_singles = true. // Seed artist (keyed by Navidrome ID) with ignore_singles = true.
if _, err := db.Conn().Exec( if _, err := db.Conn().Exec(
"INSERT INTO artist_settings (id, name, ignore_singles, monitored) VALUES (?, ?, 1, 1)", "INSERT INTO artist_settings (id, name, ignore_singles, monitored) VALUES (?, ?, 1, 1)",
artistMBID, artistName, artistID, artistName,
); err != nil { ); err != nil {
t.Fatalf("seed artist: %v", err) t.Fatalf("seed artist: %v", err)
} }
@@ -771,7 +765,7 @@ func TestSyncArtistDiscography_IgnoreSingles(t *testing.T) {
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, 0) releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 0)
if err != nil { if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err) t.Fatalf("SyncArtistDiscography() error: %v", err)
} }
@@ -788,6 +782,7 @@ func TestSyncArtistDiscography_IgnoreSingles(t *testing.T) {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
func TestSyncArtistDiscography_IgnoreCompilations(t *testing.T) { func TestSyncArtistDiscography_IgnoreCompilations(t *testing.T) {
artistMBID := "artist-comp-test" artistMBID := "artist-comp-test"
artistID := "nav-comp-test"
artistName := "Comp Artist" artistName := "Comp Artist"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -802,10 +797,10 @@ func TestSyncArtistDiscography_IgnoreCompilations(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
// Seed artist with ignore_compilations = true. // Seed artist (keyed by Navidrome ID) with ignore_compilations = true.
if _, err := db.Conn().Exec( if _, err := db.Conn().Exec(
"INSERT INTO artist_settings (id, name, ignore_compilations, monitored) VALUES (?, ?, 1, 1)", "INSERT INTO artist_settings (id, name, ignore_compilations, monitored) VALUES (?, ?, 1, 1)",
artistMBID, artistName, artistID, artistName,
); err != nil { ); err != nil {
t.Fatalf("seed artist: %v", err) t.Fatalf("seed artist: %v", err)
} }
@@ -813,7 +808,7 @@ func TestSyncArtistDiscography_IgnoreCompilations(t *testing.T) {
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
releases, err := SyncArtistDiscography(ctx, client, db, artistMBID, 0) releases, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 0)
if err != nil { if err != nil {
t.Fatalf("SyncArtistDiscography() error: %v", err) t.Fatalf("SyncArtistDiscography() error: %v", err)
} }
@@ -830,6 +825,7 @@ func TestSyncArtistDiscography_IgnoreCompilations(t *testing.T) {
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
func TestSyncArtistDiscography_ResyncWithNotifications(t *testing.T) { func TestSyncArtistDiscography_ResyncWithNotifications(t *testing.T) {
artistMBID := "artist-fk-test" artistMBID := "artist-fk-test"
artistID := "nav-fk-test"
artistName := "FK Artist" artistName := "FK Artist"
server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) { server := newTestMBServer(func(w http.ResponseWriter, r *http.Request) {
@@ -843,13 +839,13 @@ func TestSyncArtistDiscography_ResyncWithNotifications(t *testing.T) {
db := newTestDB(t) db := newTestDB(t)
defer db.Close() defer db.Close()
seedArtist(t, db, artistMBID, artistName) seedArtist(t, db, artistID, artistName)
client := newTestClient(server.URL) client := newTestClient(server.URL)
ctx := context.Background() ctx := context.Background()
// First sync. // First sync.
_, err := SyncArtistDiscography(ctx, client, db, artistMBID, 24*time.Hour) _, err := SyncArtistDiscography(ctx, client, db, artistID, artistMBID, 24*time.Hour)
if err != nil { if err != nil {
t.Fatalf("first SyncArtistDiscography() error: %v", err) t.Fatalf("first SyncArtistDiscography() error: %v", err)
} }
@@ -864,13 +860,13 @@ func TestSyncArtistDiscography_ResyncWithNotifications(t *testing.T) {
// Force cache expiry. // Force cache expiry.
_, err = db.Conn().Exec("UPDATE external_releases SET cached_at = ? WHERE artist_id = ?", _, 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 { if err != nil {
t.Fatalf("expire cache: %v", err) t.Fatalf("expire cache: %v", err)
} }
// Second sync should succeed without FK violation. // 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 { if err != nil {
t.Fatalf("second SyncArtistDiscography() error (FK violation?): %v", err) t.Fatalf("second SyncArtistDiscography() error (FK violation?): %v", err)
} }

View File

@@ -25,6 +25,11 @@ type MissingRelease struct {
// - An external release is "missing" when none of the local albums (same // - An external release is "missing" when none of the local albums (same
// ArtistID) IsMatch at the given threshold. // ArtistID) IsMatch at the given threshold.
func FindMissingReleases(local []database.LocalAlbum, external []database.ExternalRelease, threshold float64) []MissingRelease { 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. // Group local albums by artist for O(1) lookup per external release.
localByArtist := make(map[string][]database.LocalAlbum) localByArtist := make(map[string][]database.LocalAlbum)
for _, a := range local { for _, a := range local {

View File

@@ -50,11 +50,6 @@ func Similarity(a, b string) float64 {
maxLen = len(nb) 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. // 1.0 - normalized distance → higher is more similar.
score := 1.0 - float64(dist)/float64(maxLen) score := 1.0 - float64(dist)/float64(maxLen)
if score < 0.0 { if score < 0.0 {