musicbrainz-provider #2
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ config.yaml
|
|||||||
data/
|
data/
|
||||||
coverage.out
|
coverage.out
|
||||||
navidrome_cov.out
|
navidrome_cov.out
|
||||||
|
.serena/
|
||||||
|
|||||||
2
.serena/.gitignore
vendored
2
.serena/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
/cache
|
|
||||||
/project.local.yml
|
|
||||||
@@ -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: []
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user