fix: index albums through the endpoint Lidarr does not throw from
Build and publish container / build (pull_request) Successful in 8m1s

Indexing fetched albums one artist at a time, and `GET /api/v1/album?artistId=`
is Lidarr's unguarded path. It maps straight from the album service with no
hydration: the mapper then dereferences model.Images and model.SecondaryTypes
without a null check, follows model.Artist?.Value where only the first link is
guarded, and selects the monitored release with SingleOrDefault, which throws
outright when an album has two of them. Any of those is a 500 that aborts the
whole index.

The unfiltered `GET /api/v1/album` builds its own artist and release lookups and
skips an album whose metadata is missing rather than dereferencing it. Use that
instead, once, and group by artistId locally. It is the defensive path and it
costs N fewer requests.

Tracks and files have no unfiltered endpoint -- Lidarr rejects a call with no
filter at all -- so those stay per artist. A failure on one artist now skips
that artist rather than ending the run, but the count is recorded in the store
and the coverage report leads with it: a missing artist makes their played music
look unplayed, which is precisely the error that costs music later, so an
incomplete index must not be culled against.

Errors now carry the request URL and whatever the server put in the body. The
original report of this failure was "album: HTTP 500", which points at the URL
and the credentials -- neither of which was at fault.
This commit is contained in:
Emma Thorpe
2026-08-24 14:05:21 +01:00
parent 9b26cc4aa3
commit 3e78f8ebd4
4 changed files with 164 additions and 11 deletions
+68 -9
View File
@@ -665,13 +665,36 @@ class Lidarr:
try:
body = self.transport(url, timeout=self.timeout, headers={"X-Api-Key": self.api_key})
except urllib.error.HTTPError as error:
raise LidarrError(f"{path}: HTTP {error.code}") from error
detail = error_detail(error)
raise LidarrError(f"GET {url}: HTTP {error.code}{': ' + detail if detail else ''}")
except (urllib.error.URLError, TimeoutError) as error:
raise LidarrError(f"{path}: {error}") from error
raise LidarrError(f"GET {url}: {error}") from error
try:
return json.loads(body)
except json.JSONDecodeError as error:
raise LidarrError(f"{path}: malformed response") from error
raise LidarrError(f"GET {url}: malformed response") from error
def error_detail(error, limit=300):
"""Return whatever the server said about a failure, for the log.
A bare status code sends you looking in the wrong place; Lidarr puts the
actual exception in the body.
"""
try:
body = error.read().decode("utf-8", "replace").strip()
except Exception: # noqa: BLE001 - diagnostics must never raise
return ""
if not body:
return ""
try:
payload = json.loads(body)
except json.JSONDecodeError:
return body[:limit]
for key in ("message", "description", "error"):
if payload.get(key):
return str(payload[key])[:limit]
return body[:limit]
def parse_added(value):
@@ -693,6 +716,15 @@ def index_library(client, store):
"""
artists = client.get("artist")
artist_rows, album_rows, track_rows = [], [], []
skipped = []
# Albums come back in one unfiltered call rather than one per artist. That
# endpoint is the only one Lidarr hydrates defensively -- it skips an album
# whose artist metadata is missing, where `?artistId=` dereferences it and
# returns a 500 -- and it costs N fewer requests into the bargain.
albums_by_artist = {}
for album in client.get("album"):
albums_by_artist.setdefault(album.get("artistId"), []).append(album)
for artist in artists:
artist_id = artist["id"]
@@ -708,7 +740,7 @@ def index_library(client, store):
)
)
for album in client.get("album", {"artistId": artist_id}):
for album in albums_by_artist.get(artist_id, []):
album_rows.append(
(
album["id"],
@@ -720,11 +752,24 @@ def index_library(client, store):
)
)
# Paths and the date a track landed live on the file, not the track.
files = {
handle["id"]: handle for handle in client.get("trackfile", {"artistId": artist_id})
}
for track in client.get("track", {"artistId": artist_id}):
# Tracks and files have no unfiltered endpoint -- Lidarr rejects a call
# with no filter at all -- so these stay per artist. One artist it
# cannot serve must not cost the whole index, but it cannot pass
# silently either: their tracks end up absent, and every scrobble of
# theirs then reads as unmatched.
try:
# Paths and the date a track landed live on the file, not the track.
files = {
handle["id"]: handle
for handle in client.get("trackfile", {"artistId": artist_id})
}
tracks = client.get("track", {"artistId": artist_id})
except LidarrError as error:
logger.warning("could not index %s: %s", name or artist_id, error)
skipped.append(name or str(artist_id))
continue
for track in tracks:
handle = files.get(track.get("trackFileId") or 0) or {}
title = track.get("title") or ""
track_rows.append(
@@ -744,6 +789,7 @@ def index_library(client, store):
)
store.replace_library(artist_rows, album_rows, track_rows)
store.set_state("index_skipped", str(len(skipped)))
logger.info(
"library indexed: %d artists, %d albums, %d tracks (%d with files)",
len(artist_rows),
@@ -751,6 +797,12 @@ def index_library(client, store):
len(track_rows),
sum(row[7] for row in track_rows),
)
if skipped:
logger.warning(
"%d artists could not be indexed, so their tracks are missing: %s",
len(skipped),
", ".join(sorted(skipped)[:10]),
)
return len(artist_rows), len(track_rows)
@@ -817,6 +869,13 @@ def coverage_report(store):
return
logger.info("--- match coverage ---")
skipped = int(store.get_state("index_skipped") or 0)
if skipped:
logger.warning(
"the index is incomplete: %d artists are missing their tracks, so every"
" figure below is a floor. Do not cull against it.",
skipped,
)
for method in ("mbid", "name", "none"):
row = store.connection.execute(
"SELECT COUNT(*) AS pairs, COALESCE(SUM(plays), 0) AS plays"