fix: survive an album with two monitored releases
Build and publish container / build (pull_request) Successful in 8m11s

Fetching every album in one unfiltered request avoided Lidarr's unguarded
per-artist path, but not the exception underneath it. Every album endpoint maps
through AlbumResource.ToResource, which selects the release with
SingleOrDefault(x => x.Monitored). An album with two monitored releases makes
that throw -- "Sequence contains more than one element" -- and the bulk call
loses the entire library to one bad row.

Keep the unfiltered call as the first attempt, since it is a single request and
is still the only path that skips albums whose artist metadata is missing. When
it fails, fall back to one request per artist. That cannot dodge the exception
either, but it confines the loss to whichever artist owns the offending album
and names them, which is the only practical way to find it in a large library.

Album failures are counted separately from artist failures because they do not
mean the same thing. Tracks come from a different endpoint with a different
mapper, so an artist whose albums cannot be fetched still gets indexed and still
matches; it is the cull that cannot run. The report distinguishes the two rather
than lumping them into one warning that overstates the damage.
This commit is contained in:
Emma Thorpe
2026-08-24 14:24:49 +01:00
parent e6fa030d9d
commit 997627f4fe
3 changed files with 101 additions and 15 deletions
+51 -7
View File
@@ -707,6 +707,40 @@ def parse_added(value):
return None
def fetch_albums(client, artists):
"""Return albums grouped by artist id, plus the artists whose albums failed.
Every album endpoint maps through a resource that picks the release with
`SingleOrDefault(x => x.Monitored)`, which throws for an album with two
monitored releases -- "Sequence contains more than one element" -- and takes
the entire response down with it.
The unfiltered endpoint is one request and is the only one that also skips
albums whose artist metadata is missing, so it is tried first. When it dies,
asking per artist confines the loss to whichever artist owns the offending
album, and names them, which is the only way to find it.
"""
try:
grouped = {}
for album in client.get("album"):
grouped.setdefault(album.get("artistId"), []).append(album)
return grouped, []
except LidarrError as error:
logger.warning("fetching all albums failed (%s)", error)
logger.warning("falling back to one request per artist to isolate the bad album")
grouped, failed = {}, []
for artist in artists:
artist_id = artist["id"]
try:
grouped[artist_id] = client.get("album", {"artistId": artist_id})
except LidarrError as error:
name = artist.get("artistName") or str(artist_id)
logger.warning("could not fetch albums for %s: %s", name, error)
failed.append(name)
return grouped, failed
def index_library(client, store):
"""Rebuild the library index from Lidarr. Returns (artists, tracks).
@@ -718,13 +752,7 @@ def index_library(client, store):
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)
albums_by_artist, album_failures = fetch_albums(client, artists)
for artist in artists:
artist_id = artist["id"]
@@ -790,6 +818,7 @@ def index_library(client, store):
store.replace_library(artist_rows, album_rows, track_rows)
store.set_state("index_skipped", str(len(skipped)))
store.set_state("index_albums_skipped", str(len(album_failures)))
logger.info(
"library indexed: %d artists, %d albums, %d tracks (%d with files)",
len(artist_rows),
@@ -803,6 +832,14 @@ def index_library(client, store):
len(skipped),
", ".join(sorted(skipped)[:10]),
)
if album_failures:
logger.warning(
"no albums indexed for %d artists: %s."
" In Lidarr, open each one and check the Releases tab of their albums:"
" exactly one release per album may be monitored.",
len(album_failures),
", ".join(sorted(album_failures)[:10]),
)
return len(artist_rows), len(track_rows)
@@ -876,6 +913,13 @@ def coverage_report(store):
" figure below is a floor. Do not cull against it.",
skipped,
)
albums_skipped = int(store.get_state("index_albums_skipped") or 0)
if albums_skipped:
logger.warning(
"%d artists have no albums indexed. Matching is unaffected -- it runs off"
" tracks -- but a cull cannot be run until this is fixed.",
albums_skipped,
)
for method in ("mbid", "name", "none"):
row = store.connection.execute(
"SELECT COUNT(*) AS pairs, COALESCE(SUM(plays), 0) AS plays"