# Changelog All notable changes to this project are documented here, in reverse chronological order. ## Unreleased ## v0.4.0 - 2026-07-24 ### Changed - The results page now uses infinite scroll instead of Prev/Next pagination: further pages of candidates load automatically as you scroll, via a new `GET /rows` fragment endpoint. - "Add to Lidarr", "Ignore", and "Unignore" now submit via `fetch()` instead of a normal form POST, so a click no longer redirects back to page 1 - the button's row is swapped in place with its updated state. Clients without JS still get the previous redirect-to-index fallback, now fixed to preserve `page`/`min_score`/`sort` instead of always landing on page 1. ## v0.3.1 - 2026-07-22 ### Changed - Added an MIT `LICENSE` and `license = "MIT"` in `pyproject.toml`. - Fixed an outdated README claim that getting listed in the public Unraid CA app store requires a PR against `github.com/Squidly271/AppFeed` - that repo/process no longer exists; it's now an interactive submission at [ca.unraid.net/submit](https://ca.unraid.net/submit) under the submitter's own GitHub account. - Top-level descriptions (GitHub repo "About", `pyproject.toml`, README opening line) now credit Deezer and ListenBrainz alongside Last.fm, matching the detailed breakdown already in the README body. ## v0.3.0 - 2026-07-22 ### Added - Optional password protection for the web UI: setting `AUTH_PASSWORD` (via `/config` or the environment) gates every route behind a `/login` page and a 30-day session cookie; leaving it unset keeps the previous no-login behavior, so upgrading never locks anyone out. New `AUTH_SKIP_LOCAL` toggle skips the login page entirely for requests from private/loopback addresses (RFC 1918, `127.0.0.0/8`), checked via `X-Forwarded-For` when present so it still works behind a reverse proxy. New `lidarr_similar/auth.py` (in-memory sessions, constant-time password check, local-address detection) and a new "ACCESS" module on `/config`. Verified live: unauthenticated requests redirect to `/login`, a wrong password re-shows the login page with an error, a correct password grants access, `AUTH_SKIP_LOCAL` bypasses login for a simulated LAN client, and logging out revokes the session. ## v0.2.0 - 2026-07-22 ### Changed - Redesigned `/config` from a single flat table of every variable into a "patch bay" of grouped modules (Last.fm, Lidarr, Enrichment, Storage), each a bordered panel with its own header LED summarizing that module's state (green/amber/red/dim) and per-row LEDs next to each variable, echoing the results page's own instrument-panel visual language instead of a generic settings table. "Test Lidarr connection" now lives inside the Lidarr module itself rather than at the page bottom. - Normalized the "Last updated" timestamp on the results page to a readable `YYYY-MM-DD HH:MM UTC` instead of the raw ISO8601 string (with microseconds) SQLite stored it as. - `docker-compose.yml` and the README now point at the published `ghcr.io/drachenhort/lidarr-similar:latest` image by default, with building from source (`build: .`) documented as a fallback. ### Added - Sort-by dropdown on the results page: Similarity score (default), Deezer fans, LB listeners, or Not in library - the last one surfaces candidates worth adding without scrolling past everything already in the Lidarr library. - App version badge next to the wordmark in the header, sourced from `lidarr_similar.__version__`. - "Test Lidarr connection" button on `/config`, next to "Save configuration" - calls Lidarr's `/api/v1/system/status` and reports success (with the reported Lidarr version) or the specific failure reason, using whatever's currently in the LIDARR_URL/LIDARR_API_KEY fields (not necessarily saved yet, via `formaction`), falling back to the saved/env API key when that field is left blank. New `LidarrClient.system_status()` and `POST /config/test-lidarr`. Verified live against a real Lidarr instance. - "Edit & test" overlay for LIDARR_API_KEY on `/config`: opens a modal dialog with a single password field that tests itself against the current LIDARR_URL as you type (debounced, via a new JSON endpoint `POST /config/test-lidarr-key`), showing live success (with the connected Lidarr version) or failure feedback instead of requiring a full save-then-check round trip. "Save key" stays disabled until a test succeeds, then submits the existing save form. Verified live against a real Lidarr instance with both a valid and a deliberately wrong key. - Published Docker image at `ghcr.io/drachenhort/lidarr-similar` (public, no login required to pull), plus a GitHub Actions workflow (`.github/workflows/docker-publish.yml`) that builds and pushes it automatically on every push to `master` and on version tags - no more manual `docker build`/`docker push` from a local machine. - Unraid Community Applications container template (`unraid-template.xml`) declaring the image, WebUI port, `/data` path, and all env vars with descriptions/required-vs-advanced grouping, usable directly as a CA template repository or pasted into "Add Container"'s template field. Includes a generated icon (`icon.png`) matching the web UI's dark amber/teal theme. ## v0.1.0 - 2026-07-22 ### Changed - Redesigned the web UI's visual style: dark "mixing console" theme (deep ink background, amber primary accent, teal for "in library", rose for "ignored"), a serif wordmark, and monospace formatting for numeric table data. Signature touch: each row's similarity score is shown as a small amber/teal meter bar next to the number, echoing an analog VU meter. All 116 existing tests passed unchanged, confirming the redesign only touched presentation, not behavior. - Fixed a layout bug found live right after the redesign: with many data columns, the results table overflows a typical browser window and the rightmost Actions column (Add to Lidarr/Ignore/Unignore buttons) scrolled out of view, requiring users to hunt for a horizontal scrollbar to reach them. That column is now `position: sticky; right: 0` within the table's scroll container, so it stays reachable regardless of horizontal scroll position. Verified live. ### Fixed - "Add to Lidarr" failed with `400 Bad Request` for every artist, reported live ("Failed to add Diorama: Client error '400 Bad Request'"). Reproduced directly against the real Lidarr API: it also requires `metadataProfileId` in the add-artist payload, which was never sent, defaulting to 0 and failing Lidarr's `'Metadata Profile Id' must be greater than '0'` validation. Added `LidarrClient.metadata_profiles()`, a new `LIDARR_METADATA_PROFILE_ID` setting (dropdown on `/config`, same pattern as the quality profile fix), and threaded it through `add_artist()`, `_is_lidarr_add_enabled()`, and the `/add` endpoint. Verified live end to end: selected "Standard (id 1)", saved, and successfully added "Diorama" to the real Lidarr library (confirmed via a follow-up library query). - Clicking "Run discovery now" could silently produce zero results with no visible error, reported live: three compounding bugs. (1) `LidarrClient` used httpx's 5s default timeout, which intermittently timed out on `/api/v1/artist` for a real 962-artist library (confirmed live: failed under 5s, succeeded at 15s) - now 30s. (2) That timeout happened inside `_run_discovery`'s main try block, so it aborted the *entire* run (including Last.fm/Deezer, which had nothing to do with Lidarr) instead of just degrading gracefully like Discogs/Deezer/ListenBrainz already do - Lidarr's existing-library lookup is now isolated in its own try/except, continuing with no known-library data on failure. (3) Even when an error *was* set, `httpx.ReadTimeout`'s `str()` is empty, and the error banner only rendered when `status.error` was truthy - so the failure was completely invisible. New `_describe()` helper falls back to the exception's type name so an error message is never blank; applied everywhere `_status.error` gets set. With these fixed, a full run now visibly shows "Discovery running..." for its whole real duration (confirmed live: ~3 minutes, 207 candidates) instead of dying within 5 seconds unnoticed - directly addressing the user's related request to make in-progress runs clearly visible. ### Added - `/config` is now an editable settings page, not just a status display: LASTFM_API_KEY, LASTFM_USERNAME, DISCOGS_TOKEN, the DISCOGS/DEEZER/LISTENBRAINZ enabled toggles, and all four LIDARR_* variables can be set from the browser and saved. New `SettingsStore` (SQLite, same store file) holds these; `Config.from_env()` now checks it first and falls back to the environment variable of the same name, via new `config.get_effective()`/`_load_overrides()`. `CACHE_PATH`/`STORE_PATH` stay environment-only since that's where the settings themselves live. Secret fields are never pre-filled or echoed back into the page - leaving one blank on save keeps its current value, verified by test. - `LIDARR_QUALITY_PROFILE_ID` is now a dropdown of the connected Lidarr instance's actual quality profiles (`LidarrClient.quality_profiles()`, fetched live), instead of a plain number field - Lidarr's own UI only shows profile names (e.g. "Standard"), not their numeric ID, which is exactly the mistake behind the earlier `LIDARR_QUALITY_PROFILE_ID="Standard"` crash-then-silent-failure. Falls back to a text input if Lidarr isn't reachable yet. Verified live: selecting "Standard (id 3)" and saving correctly set the numeric ID and made the "Add to Lidarr" button appear. - Configuration status page (`/config`, linked from the index page) showing every environment variable the app reads, whether it's set, whether it's valid where checkable (e.g. flags a non-numeric `LIDARR_QUALITY_PROFILE_ID`), and which feature it's needed for - without ever displaying secret values (API keys/tokens), only presence/validity. New `config.describe_config()`/`ConfigItem` in `config.py`. Verified live against a real `.env`: correctly showed the core pipeline as configured and flagged the known-bad `LIDARR_QUALITY_PROFILE_ID=Standard` value with the same explanation as the earlier crash-fix. ### Fixed - Ignoring an artist looked like it silently failed: it was actually persisted correctly (verified in the store), but the "Ignored artists" panel didn't render at all when empty (nothing to click before you knew it existed) and stayed collapsed by default even once populated - meanwhile the ignored artist itself vanished from view, pushed to the bottom of a different results page. Found live via user report. Both panels now always render (with a "None yet." placeholder when empty) and auto-expand via the `open` attribute as soon as they have entries. ### Added - ListenBrainz popularity enrichment: `Candidate.listenbrainz_listeners`, shown as an "LB Listeners" column alongside Deezer's "Popularity" in both the preview CLI and web UI. ListenBrainz's dedicated `/1/popularity/artist` endpoint is disabled server-side ("high load", confirmed live), so this uses its per-artist `/1/stats/artist/{mbid}/listeners` stats endpoint instead. Requires a candidate's MBID (from Last.fm, same one now used for library matching), so coverage is roughly the same ~65% as MBID availability; skipped entirely for candidates without one. New `LISTENBRAINZ_ENABLED` config (default `true`) and `--no-listenbrainz` preview flag. ### Fixed - `ListenBrainzClient.enrich_popularity()` crashed with an uncaught `JSONDecodeError` on a 204 (No Content) response - which ListenBrainz returns for any artist with zero recorded listener data, a common and legitimate outcome given its much smaller user base than Last.fm/Deezer - since an empty body isn't valid JSON and 204 isn't an `httpx.HTTPError`. This silently aborted the *rest* of the enrichment loop for every subsequent candidate in a real run, not just the one that hit it. Found live: a full discovery run only recorded ListenBrainz data for 15 of 140 eligible candidates. Fixed by explicitly handling 204 as "no data" and broadening the catch to include `ValueError`; verified live afterward that 96 of 140 got real data, with the remaining 44 correctly resolved as genuine no-data (204) cases rather than crashes. - MusicBrainz ID (MBID) based library matching: `LidarrClient.existing_artist_identifiers()` now also returns Lidarr's `foreignArtistId` (a MusicBrainz UUID - Lidarr is itself MusicBrainz-based, confirmed live against the real API), and `discover_candidates()` prefers an exact MBID match over normalized-name matching whenever Last.fm supplied a candidate's `mbid`. This is a pure correctness improvement over name matching alone (which still can't catch genuine spelling variants, only punctuation/case/diacritics) and required no new API calls or dependencies - `Candidate.mbid` was already being populated by `LastFmClient` and just never used. Verified live: 140/217 candidates in a real run had an MBID available for the more reliable match path. - `Candidate.popularity`: a Deezer fan-count based popularity score, shown as a "Popularity" column in both the preview CLI and web UI. Deezer's `/search/artist` results already include `nb_fan`, so this reuses the artist lookup `enrich_genre()` already makes for genre enrichment - no extra API call. Verified live with real fan counts (e.g. Seether: 553,711). - "Ignored artists" panel at the top of the web UI (above the discovery controls), listing every entry in `IgnoreList` with its own Unignore button, so the full ignore list is reviewable without hunting through paginated results. Verified live. - Genre banning: a new `GenreIgnoreList` (SQLite) lets you ban whole genres (e.g. "Rap") via a form in a new "Ignored genres" panel, or a one-click × on any genre tag in the results table. Matching is a case-insensitive substring check against a candidate's combined Discogs genres/styles and Deezer genre, since granularity differs between sources (Discogs "Hip Hop" vs Deezer "Rap/Hip Hop"). Banned candidates are tagged `ignored_genre` (new `Candidate`/store field) like artist-ignores, pushed to the bottom, but have no per-row Unignore since undoing affects every artist in that genre - only the panel's Unignore does. Wired into `_run_discovery`'s `on_progress` (checked as each candidate's genres become known post-enrichment) and covered by the same mid-run flag-preservation fix as artist ignores. Verified live: banning "Rap" mid-run correctly flagged 10 candidates with `Rap/Hip Hop`/`Hip Hop` genres, survived the rest of the run, and unignoring restored them. - Web UI now updates incrementally instead of only showing results once a full run finishes: `discover_candidates()` takes an `on_progress` callback, invoked once after the initial merge and again after each candidate is enriched, and the web UI persists each snapshot to the store immediately. The page shows an "N/M enriched" counter while a run is in progress. Verified live: results (217 candidates) appeared within ~16s of starting a run, well before the ~60s full run completed. - Pagination on the web UI (50 candidates/page, Prev/Next controls) so large result sets don't render as one huge table. - "Add to Lidarr" button per candidate row in the web UI - looks the artist up and adds it via `LidarrClient` without leaving the page, then marks it `already_in_library`. Requires `LIDARR_URL`, `LIDARR_API_KEY`, `LIDARR_ROOT_FOLDER`, and `LIDARR_QUALITY_PROFILE_ID` (new config fields); shows an explanatory hint instead of the button when any are missing. - "Ignore" button per candidate row - persists to a new `IgnoreList` (SQLite, same store file), excluded from enrichment API calls on future `discover_candidates()` runs via a new `ignored_names` parameter, but kept visible rather than hidden: `Candidate.ignored` tags the row "ignored" and pushes it to the bottom of the list (regardless of score) with an **Unignore** button, so a previously-ignored artist that resurfaces is called out instead of silently disappearing. Verified live: an ignored artist stayed tagged and excluded from enrichment across a subsequent full discovery run, and unignoring correctly restored it. ### Fixed - The index page's "Add to Lidarr" button visibility only checked raw `os.environ`, missing SettingsStore overrides entirely and treating any non-empty `LIDARR_QUALITY_PROFILE_ID` as valid (so a name like "Standard" would still show the button, which would then fail on click). Found while testing the new settings page live. Now derived from `describe_config()`'s already-correct presence+validity per field, covered by two new regression tests. - Tests without an explicit `STORE_PATH` were silently creating real SQLite files in the repo directory once `Config.from_env()`/`describe_config()` started touching `SettingsStore` - found by noticing `lidarr_similar.sqlite3`/`lidarr_similar_store.sqlite3` appear after a test run. Fixed with an autouse fixture defaulting `STORE_PATH` to `tmp_path` for every web test. - `normalize_name()` now also strips punctuation, not just diacritics/case, so artists match across sources that stylize names differently - found live via user report: Lidarr stores "[:SITD:]" and "38 Special" while Last.fm/Deezer return "SITD" and ".38 Special", so neither matched and `already_in_library` was never set. Verified fixed live against the real Lidarr library. - `Config.from_env()` crashed the whole app (500 on every request) if `LIDARR_QUALITY_PROFILE_ID` was set to a non-numeric value, e.g. a profile name like "Standard" instead of its numeric ID - found live when testing the Add-to-Lidarr feature. Now treated as unset, same as every other optional config field, with a docstring note on `_parse_int` clarifying it must be the numeric ID. - Ignoring (or adding) an artist while a discovery run was still in progress got silently reverted by the run's next progress snapshot, since `discover_candidates()` computes `already_in_library`/`ignored` once from a snapshot taken before the run started, and each `on_progress` call overwrote the store with that stale computation. Found via a live test: ignoring an artist mid-run, then waiting for the run to finish, showed it un-ignored again. Fixed by having the web UI's `on_progress` handler preserve any `already_in_library`/`ignored` flags already set in the store before each snapshot write. - `lidarr_similar/naming.py`: extracted `normalize_name()` out of `pipeline.py` into a shared module so `store.py`'s `IgnoreList` can use the same case/diacritic-insensitive matching without a pipeline->store dependency; `pipeline.normalize_name` still re-exports it for backward compatibility. - `Candidate.already_in_library` and a "Last Release" column: candidates already in the connected Lidarr library are now kept in the results (both CLIs and the web UI) and flagged with a notice, instead of being silently dropped. Last-release info comes from Discogs' release search sorted by year descending (`discogs_latest_release_year`), reusing the existing genre/style enrichment call rather than adding a new one; verified against the live API. - Web UI (`lidarr_similar/web.py`, FastAPI): a browsable dashboard showing discovered candidates, persisted across restarts via `CandidateStore` (SQLite). Includes a "Run discovery now" button; since a full run can take minutes (mostly Discogs' 60 req/min rate limit), refresh runs as a background task and the page polls itself while in progress rather than blocking the request - verified by a live run that took ~70s and returned 217 candidates. Supports `?min_score=` filtering like the preview CLI. - `Dockerfile` and `docker-compose.yml` for running the web UI in a container (e.g. on Unraid); built and smoke-tested locally. `STORE_PATH`/`CACHE_PATH` default under `/data`, meant to be volume-mounted for persistence across container updates. ### Changed - Discovery no longer silently excludes artists already in your Lidarr library - they're returned like any other candidate but marked `already_in_library=True`, so the CLIs and web UI can surface a notice instead of hiding them. ### Fixed - Candidate merge and Lidarr-library dedupe now match names case- and diacritic-insensitively (`normalize_name()`), so e.g. Last.fm's "L'Âme Immortelle" and Deezer's "L'âme Immortelle" merge into one entry instead of appearing twice. Found via a live preview run against a real library. ### Added - `--no-min-score` flag on the preview CLI to reset `--min-score` back to 0.0 (show every candidate), while `--limit` keeps its default of 25. - `--min-score` flag on `python -m lidarr_similar.preview` to drop candidates below a similarity threshold before display. - `DeezerClient.enrich_genre()` — genre enrichment from Deezer, no API token required. Deezer artist objects carry no genre field, so this resolves an artist's top album's `genre_id` to a genre name; cached and best-effort like `DiscogsEnricher`. Wired into `discover_candidates()` and shown alongside Discogs genres in both CLIs. - `python -m lidarr_similar.preview` — terminal preview CLI showing a ranked table of candidates (score, contributing sources, Discogs genres) without ever calling Lidarr's add-artist endpoint. Lidarr credentials are now optional; when unset, preview shows all candidates without library dedupe. Supports `--limit`, `--seed-artists`, `--similar-per-artist`, `--no-deezer`, `--no-discogs`, `--no-lidarr`. - Initial project scaffold: async, typed Python package (`lidarr_similar`) discovering artists similar to your Last.fm listening history and adding them to Lidarr. - `LastFmClient` — pulls top artists from scrobble history and Last.fm's `artist.getSimilar` candidates. - `DeezerClient` — second, independent similar-artist source via Deezer's public (unofficial) API; synthesizes a rank-decayed similarity score since Deezer has no numeric match value. - Candidate merge logic (`pipeline.merge_candidates`) — unions candidates from Last.fm and Deezer by artist name, keeps the higher similarity score, and applies a `+0.15` boost (capped at 1.0) when an artist is surfaced by more than one source. - `DiscogsEnricher` — optional, non-blocking enrichment stage that attaches genre/style metadata to candidates after merge; a miss or API error leaves the candidate unchanged so the core discovery flow is unaffected. - SQLite-backed `Cache` for enrichment lookups, shared across sources, to avoid repeat API calls across runs. - `LidarrClient` — dedupes candidates against the existing library and adds new artists. - `discover_candidates()` pipeline wiring: seed artists → Last.fm + Deezer candidates → merge/boost → Discogs enrichment → sort by similarity. - CLI entrypoint (`python -m lidarr_similar`) reading configuration from environment variables (`LASTFM_API_KEY`, `LASTFM_USERNAME`, `DISCOGS_TOKEN`, `DISCOGS_ENABLED`, `DEEZER_ENABLED`, `LIDARR_URL`, `LIDARR_API_KEY`, `CACHE_PATH`). - Test suite (`pytest`, `respx` for HTTP mocking) covering the cache, Discogs enrichment (exact match, fuzzy match, no match, API error, caching), Deezer client, and merge/dedupe logic. ### Notes / future ideas - A "music genome"-style audio-feature/embedding stage (e.g. Essentia, OpenL3) was discussed as a longer-term idea for acoustic similarity based on the actual audio in the library, rather than listener co-occurrence — parked as a someday item, not scheduled.