From e577f3cf1fd34c01504342e17b2b841d14687ae7 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 3 May 2026 15:49:38 -0700 Subject: [PATCH] Fix three Lidarr bugs that prevented it from being a real download source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigation surfaced that Lidarr was wired into the orchestrator but the actual download flow had blockers: 1. **Wrong file misfiled.** Lidarr grabs whole albums; SoulSync's matched-context post-processing wants the SPECIFIC track the user requested. Old code copied every track in the album and reported `imported_files[0]` as `file_path` — almost always pointing to track 1, not the user's actual track. Post-processing then tagged track 1 with the requested track's metadata. Misfiling on every real download. Fix: parse the wanted track title out of the dispatch display name (which `_search_sync` already builds as `f"{artist} - {album} - {track_title}"`), look it up against Lidarr's `track` API, resolve the matching `trackFileId` to a path, and copy ONLY that file. Punctuation-tolerant fuzzy match handles the common "m.A.A.d city" vs "maad city" case. Album-level dispatches (no track in the display) preserve the old first-file fallback so existing album-grab UX is unchanged. 2. **Hardcoded `metadataProfileId=1`.** Required by Lidarr's artist-add API. On installs where the user deleted/recreated metadata profiles, that id no longer exists and the call fails with HTTP 400 — which silently breaks every download flow that needs to add an artist. Real-world Lidarr installs do this all the time. Fix: `_get_metadata_profile_id()` calls Lidarr's `metadataprofile` API and returns the first available id. Falls back to 1 only when the API call fails entirely (preserves previous behavior so this change can't make things worse). 3. **Polling never broke the outer loop on completion.** The inner `for item in queue['records']` had `break` statements at status transitions, but those only escaped the queue iteration — the outer `for poll in range(max_polls)` kept spinning until the 600-poll timeout even after the album was clearly imported. `for/else` semantics didn't apply because completion was detected inside the inner loop, not by it running to exhaustion. Fix: replaced with an explicit `download_complete` flag set when `album/{id}` reports `trackFileCount > 0` (the authoritative completion signal — works even when the queue record disappeared between polls). Outer loop breaks immediately once the flag flips. Helper functions added: `_extract_wanted_track_title` (staticmethod, splits the display name; >=3 parts → track dispatch, 2 parts → album dispatch), `_normalize_for_match` (lowercase + strip punctuation + collapse whitespace for fuzzy compare), `_title_similarity` (cheap score: equal=1.0, substring=0.85, token-overlap-ratio otherwise), `_pick_track_file_for_wanted` (orchestrates the API calls). Settings tooltip updated to be honest about Lidarr's natural shape: album-grabber, no-op for playlist sync, hybrid mode falls through to other sources for track searches. Sets correct expectations. Tests: `tests/test_lidarr_download_client.py` — 21 isolated tests covering pure helpers (title extraction, normalization, similarity) and the file-picker integration paths (matching path, punctuation tolerance, below-threshold fallback, missing trackFileId, missing file on disk, API failures, malformed responses). No live Lidarr needed — `_api_get` mocked at the client boundary. Isolation: ONLY touches `core/lidarr_download_client.py`, the Lidarr settings tooltip in `webui/index.html`, the Lidarr WHATS_NEW entry in `webui/static/helper.js`, and the new test file. No changes to the orchestrator, other download clients, the import pipeline, side_effects, web_server.py, settings.js, or any shared validation / monitor / task_worker code. Other download sources are not affected in any way. Verified: 1753 tests pass (21 new), ruff clean. --- core/lidarr_download_client.py | 260 +++++++++++++++++++---- tests/test_lidarr_download_client.py | 297 +++++++++++++++++++++++++++ webui/index.html | 2 + webui/static/helper.js | 1 + 4 files changed, 518 insertions(+), 42 deletions(-) create mode 100644 tests/test_lidarr_download_client.py diff --git a/core/lidarr_download_client.py b/core/lidarr_download_client.py index f6a640f3..8a71b179 100644 --- a/core/lidarr_download_client.py +++ b/core/lidarr_download_client.py @@ -277,11 +277,12 @@ class LidarrDownloadClient: root_folder = self._get_root_folder() quality_profile_id = self._get_quality_profile_id() + metadata_profile_id = self._get_metadata_profile_id() add_artist = { 'foreignArtistId': artist_data.get('foreignArtistId', ''), 'artistName': artist_data.get('artistName', ''), 'qualityProfileId': quality_profile_id, - 'metadataProfileId': 1, + 'metadataProfileId': metadata_profile_id, 'rootFolderPath': root_folder, 'monitored': False, 'addOptions': {'monitor': 'none', 'searchForMissingAlbums': False}, @@ -331,8 +332,15 @@ class LidarrDownloadClient: self._set_error(download_id, f'Failed to trigger download: {e}') return - # Step 4: Poll queue for progress + # Step 4: Poll until Lidarr reports the album has imported files. + # + # Old approach used `for/else` with `break` from the inner queue + # loop, but inner-break only escaped the queue iteration — the + # outer poll loop kept spinning even after we'd detected + # completion. Replaced with an explicit `download_complete` flag + # that breaks the OUTER loop once trackFileCount > 0. max_polls = 600 # 10 minutes max + download_complete = False for poll in range(max_polls): if self.shutdown_check and self.shutdown_check(): self._set_error(download_id, 'Server shutting down') @@ -350,72 +358,125 @@ class LidarrDownloadClient: for item in queue['records']: item_album = item.get('album', {}) if item_album.get('foreignAlbumId') == album.get('foreignAlbumId', ''): - # Found our download in the queue + # Surface progress while still downloading. status = item.get('status', '').lower() - progress = 100.0 - (item.get('sizeleft', 0) / max(item.get('size', 1), 1) * 100) + size_left = item.get('sizeleft', 0) + size_total = max(item.get('size', 1), 1) + progress = 100.0 - (size_left / size_total * 100) with self._download_lock: self.active_downloads[download_id]['progress'] = min(progress, 95.0) - if status in ('completed', 'imported'): - break - elif status in ('failed', 'warning'): + if status in ('failed', 'warning'): self._set_error(download_id, f'Lidarr download failed: {status}') return + # 'completed' / 'imported' in the queue is + # transient — Lidarr drops the item once + # import finishes. Don't break here; let the + # trackFileCount check below decide. - else: - # Not in queue — might be completed already - # Check if album has files - if poll > 10: # Give it at least 10 seconds - album_check = self._api_get(f'album/{lidarr_album_id}') - if album_check and album_check.get('statistics', {}).get('trackFileCount', 0) > 0: - break - else: - # No queue data — check if completed - if poll > 10: - album_check = self._api_get(f'album/{lidarr_album_id}') - if album_check and album_check.get('statistics', {}).get('trackFileCount', 0) > 0: - break + # Authoritative completion signal: album has imported + # files. Cheap to call (single GET on a known id) and + # works even when the queue record disappeared between + # polls. + if poll > 5: # Give Lidarr a few seconds to start + album_check = self._api_get(f'album/{lidarr_album_id}') + if (album_check + and album_check.get('statistics', {}).get('trackFileCount', 0) > 0): + download_complete = True + break except Exception as e: logger.debug(f"Queue poll error: {e}") time.sleep(1) - else: + + if not download_complete: self._set_error(download_id, 'Download timed out') return - # Step 5: Find and import downloaded files + # Step 5: Find and import the wanted track. + # + # Lidarr grabs whole albums; SoulSync's matched-context + # post-processing wants the SPECIFIC track the user + # requested. Old behavior copied every track in the album + # and reported `imported_files[0]` as `file_path` — which + # almost always pointed to track 1, not the user's actual + # track. Post-processing then tagged track 1 with the + # requested track's metadata. Misfiling guaranteed. + # + # New behavior: identify the wanted track by title (parsed + # from display_name), look up its trackFile via Lidarr's + # `track` API, copy ONLY that file. For album-level + # dispatches (no specific track in display_name), fall back + # to copying the first imported file so existing + # album-grab UX still works. with self._download_lock: self.active_downloads[download_id]['progress'] = 96.0 try: - # Get track files from Lidarr - track_files = self._api_get('trackfile', params={'albumId': lidarr_album_id}) - if not track_files: - self._set_error(download_id, 'No files found after download') - return + wanted_title = self._extract_wanted_track_title(display_name) + wanted_src = self._pick_track_file_for_wanted(lidarr_album_id, wanted_title) - # Copy files to SoulSync's download path - imported_files = [] - for tf in track_files: - src_path = tf.get('path', '') - if src_path and os.path.exists(src_path): - dst_path = os.path.join(str(self.download_path), os.path.basename(src_path)) - try: - shutil.copy2(src_path, dst_path) - imported_files.append(dst_path) - except Exception as e: - logger.warning(f"Failed to copy {src_path}: {e}") + if wanted_src: + # Copy ONLY the matched track. Other album files stay + # in Lidarr's root folder and will be cleaned up by + # the cleanup step (Step 6) when configured. + dst_path = os.path.join(str(self.download_path), + os.path.basename(wanted_src)) + try: + shutil.copy2(wanted_src, dst_path) + except Exception as e: + self._set_error(download_id, f'Failed to copy wanted track: {e}') + return - if imported_files: with self._download_lock: self.active_downloads[download_id]['state'] = 'Completed, Succeeded' self.active_downloads[download_id]['progress'] = 100.0 - self.active_downloads[download_id]['file_path'] = imported_files[0] - logger.info(f"Lidarr download complete: {display_name} ({len(imported_files)} files)") + self.active_downloads[download_id]['file_path'] = dst_path + logger.info( + f"Lidarr download complete: {display_name} " + f"-> {os.path.basename(dst_path)}" + ) else: - self._set_error(download_id, 'Failed to import files') + # No specific track wanted (album dispatch) OR fuzzy + # match failed. Fall back to copying the first imported + # file so something always lands on disk; album-level + # callers still get a usable file_path. + track_files = self._api_get('trackfile', params={'albumId': lidarr_album_id}) + if not track_files: + self._set_error(download_id, 'No files found after download') + return + + imported_files = [] + for tf in track_files: + src_path = tf.get('path', '') + if src_path and os.path.exists(src_path): + dst_path = os.path.join(str(self.download_path), + os.path.basename(src_path)) + try: + shutil.copy2(src_path, dst_path) + imported_files.append(dst_path) + except Exception as e: + logger.warning(f"Failed to copy {src_path}: {e}") + + if imported_files: + with self._download_lock: + self.active_downloads[download_id]['state'] = 'Completed, Succeeded' + self.active_downloads[download_id]['progress'] = 100.0 + self.active_downloads[download_id]['file_path'] = imported_files[0] + if wanted_title: + logger.warning( + f"Lidarr: wanted track '{wanted_title}' not matched in album " + f"— falling back to first imported file ({len(imported_files)} total)" + ) + else: + logger.info( + f"Lidarr album-level download complete: {display_name} " + f"({len(imported_files)} files)" + ) + else: + self._set_error(download_id, 'Failed to import files') except Exception as e: self._set_error(download_id, f'Import failed: {e}') @@ -440,6 +501,102 @@ class LidarrDownloadClient: self.active_downloads[download_id]['error'] = error logger.error(f"Lidarr download error: {error}") + @staticmethod + def _extract_wanted_track_title(display_name: str) -> str: + """Pull the track title out of the dispatch display string. + + ``_search_sync`` builds two display shapes: + - Track dispatch: ``f"{artist} - {album} - {track_title}"`` + - Album dispatch: ``f"{artist} - {album}"`` + + Need >=3 parts to confidently identify a track. 2-part strings + are album-level dispatches — return empty so the caller falls + back to copying the first file (correct behavior for "give me + the whole album"). Track titles that themselves contain ``' - '`` + (e.g. live versions) get rejoined from parts[2:]. + """ + if not display_name: + return '' + parts = display_name.split(' - ') + if len(parts) < 3: + return '' + return ' - '.join(parts[2:]).strip() + + def _pick_track_file_for_wanted(self, lidarr_album_id: int, + wanted_title: str) -> Optional[str]: + """Find the on-disk path of the imported file matching the wanted track. + + Walks Lidarr's `track` API to map track titles → trackFileIds, + then resolves the trackFileId to a path via `trackfile`. Returns + None when the album has no usable wanted-track match (caller + falls back to the first imported file in that case so + album-level dispatches still work). + """ + if not wanted_title: + return None + + tracks = self._api_get('track', params={'albumId': lidarr_album_id}) + if not tracks or not isinstance(tracks, list): + return None + + # Normalize for case-insensitive fuzzy match. Lidarr's track titles + # come from MusicBrainz so they're usually canonical, but + # punctuation / casing varies. + wanted_norm = self._normalize_for_match(wanted_title) + best_track_file_id: Optional[int] = None + best_score = 0.0 + for t in tracks: + track_title = t.get('title', '') or '' + track_file_id = t.get('trackFileId') + if not track_file_id: + continue + score = self._title_similarity(wanted_norm, + self._normalize_for_match(track_title)) + if score > best_score: + best_score = score + best_track_file_id = track_file_id + + # 0.7 threshold avoids picking the wrong track when none match + # well — caller falls back to first-imported behavior in that case. + if best_score < 0.7 or best_track_file_id is None: + return None + + # Resolve trackFileId → path. /trackfile/{id} returns one record. + tf = self._api_get(f'trackfile/{best_track_file_id}') + if not tf: + return None + path = tf.get('path', '') + if path and os.path.exists(path): + return path + return None + + @staticmethod + def _normalize_for_match(s: str) -> str: + """Lower + strip punctuation + collapse whitespace for fuzzy compare.""" + if not s: + return '' + cleaned = re.sub(r'[^\w\s]', '', s.lower()) + return ' '.join(cleaned.split()) + + @staticmethod + def _title_similarity(a: str, b: str) -> float: + """Cheap title similarity: equal → 1.0, substring → 0.85, + token overlap ratio otherwise. Avoids pulling SequenceMatcher + for every comparison since this runs in the hot download path.""" + if not a or not b: + return 0.0 + if a == b: + return 1.0 + if a in b or b in a: + return 0.85 + a_tokens = set(a.split()) + b_tokens = set(b.split()) + if not a_tokens or not b_tokens: + return 0.0 + intersection = a_tokens & b_tokens + union = a_tokens | b_tokens + return len(intersection) / len(union) if union else 0.0 + async def get_all_downloads(self) -> List[DownloadStatus]: with self._download_lock: return [self._to_status(dl) for dl in self.active_downloads.values()] @@ -536,3 +693,22 @@ class LidarrDownloadClient: if p.get('name', '').lower() == self._quality_profile.lower(): return p['id'] return profiles[0].get('id', 1) if profiles else 1 + + def _get_metadata_profile_id(self) -> int: + """Resolve a usable metadataProfileId for adding artists. + + Lidarr requires `metadataProfileId` when creating artist records. + The default profile is usually id=1, but on installs where the + user deleted/recreated profiles, that id may not exist — leading + to the API rejecting the artist-add with a 400. Fetch live to + pick whatever's actually configured. Falls back to 1 only when + the API call fails entirely (preserves previous behavior so this + change can't make things worse). + """ + profiles = self._api_get('metadataprofile') + if profiles and isinstance(profiles, list): + for p in profiles: + pid = p.get('id') + if isinstance(pid, int): + return pid + return 1 diff --git a/tests/test_lidarr_download_client.py b/tests/test_lidarr_download_client.py new file mode 100644 index 00000000..4469475a --- /dev/null +++ b/tests/test_lidarr_download_client.py @@ -0,0 +1,297 @@ +"""Regression tests for ``core/lidarr_download_client.py``. + +The original implementation had three bugs that prevented Lidarr from +being a viable download source: + +1. **File misfiling.** Lidarr grabs whole albums; the user requested a + specific track. Old code copied every track in the album and reported + ``imported_files[0]`` as ``file_path`` — almost always pointing to + track 1, not the user's actual track. Post-processing then tagged + track 1 with the wrong metadata. +2. **Hardcoded ``metadataProfileId=1``.** On Lidarr installs where the + user deleted/recreated metadata profiles, that id no longer exists + and the artist-add API call fails with HTTP 400. +3. **Polling never broke the outer loop on completion.** The inner + ``break`` only exited the queue iteration, so the outer poll loop + kept spinning until the 600-poll timeout even after the album was + imported. + +These tests pin the fixed behavior in isolation: pure-function helpers +(title similarity, title extraction, normalization) plus integration +tests of the file-picker that go through ``_api_get`` mocked at the +client boundary so we don't need a live Lidarr instance. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest + +from core.lidarr_download_client import LidarrDownloadClient + + +# --------------------------------------------------------------------------- +# Pure-function helpers (no mocking needed) +# --------------------------------------------------------------------------- + + +def test_extract_wanted_track_title_parses_three_part_display() -> None: + title = LidarrDownloadClient._extract_wanted_track_title( +'Kendrick Lamar - GNX - wacced out murals', + ) + assert title == 'wacced out murals' + + +def test_extract_wanted_track_title_handles_dashes_in_track_name() -> None: + """Track titles can contain ' - ' themselves (live versions, mixes, + etc). Rejoin parts[2:] so 'Artist - Album - Track - Live Version' + returns 'Track - Live Version' as the wanted title.""" + title = LidarrDownloadClient._extract_wanted_track_title( + 'Artist - Album - Some Track - Live Version', + ) + assert title == 'Some Track - Live Version' + + +def test_extract_wanted_track_title_returns_empty_for_album_dispatch() -> None: + """Album-level dispatch (no track in display) → empty string, + caller falls back to first-file behavior.""" + title = LidarrDownloadClient._extract_wanted_track_title( +'Kendrick Lamar - GNX', + ) + assert title == '' + + +def test_extract_wanted_track_title_handles_empty_input() -> None: + title = LidarrDownloadClient._extract_wanted_track_title('') + assert title == '' + + +def test_normalize_for_match_strips_punctuation_and_lowercases() -> None: + norm = LidarrDownloadClient._normalize_for_match + assert norm("M.A.A.D City") == 'maad city' + assert norm("Don't Kill My Vibe") == 'dont kill my vibe' + assert norm("HUMBLE.") == 'humble' + assert norm(" Multiple Spaces ") == 'multiple spaces' + assert norm('') == '' + + +def test_title_similarity_exact_match() -> None: + sim = LidarrDownloadClient._title_similarity + assert sim('humble', 'humble') == 1.0 + + +def test_title_similarity_substring_match() -> None: + sim = LidarrDownloadClient._title_similarity + # 'mine' fully contained in 'mine taylors version' + assert sim('mine', 'mine taylors version') == 0.85 + # Reverse direction + assert sim('mine taylors version', 'mine') == 0.85 + + +def test_title_similarity_token_overlap() -> None: + """Token overlap ratio for partial matches: shared tokens / union.""" + sim = LidarrDownloadClient._title_similarity + # 2 shared tokens, 3 total → 2/3 ≈ 0.67 + score = sim('hello world', 'hello cruel world') + assert 0.6 < score < 0.7 + + +def test_title_similarity_no_overlap() -> None: + sim = LidarrDownloadClient._title_similarity + assert sim('completely different', 'unrelated track') == 0.0 + + +def test_title_similarity_empty_inputs() -> None: + sim = LidarrDownloadClient._title_similarity + assert sim('', 'something') == 0.0 + assert sim('something', '') == 0.0 + assert sim('', '') == 0.0 + + +# --------------------------------------------------------------------------- +# File-picker (mocks Lidarr API responses) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def client(tmp_path: Path) -> LidarrDownloadClient: + """Construct a client without touching real Lidarr config.""" + c = LidarrDownloadClient(download_path=str(tmp_path / 'downloads')) + return c + + +def test_pick_track_file_for_wanted_returns_matching_path(client, tmp_path: Path) -> None: + """Happy path: tracks list includes the wanted title, trackfile API + returns a real on-disk path. Picker returns it.""" + real_file = tmp_path / 'wacced.flac' + real_file.write_bytes(b'audio') + + tracks_response = [ + {'title': 'wacced out murals', 'trackFileId': 42}, + {'title': 'squabble up', 'trackFileId': 43}, + ] + trackfile_response = {'id': 42, 'path': str(real_file)} + + def _api_get_stub(endpoint, params=None): + if endpoint == 'track': + return tracks_response + if endpoint == 'trackfile/42': + return trackfile_response + return None + + with patch.object(client, '_api_get', side_effect=_api_get_stub): + result = client._pick_track_file_for_wanted( + lidarr_album_id=999, wanted_title='wacced out murals', + ) + assert result == str(real_file) + + +def test_pick_track_file_for_wanted_handles_punctuation_difference(client, tmp_path: Path) -> None: + """Lidarr says 'm.A.A.d city', user dispatched as 'maad city'. + Normalization should match them via token-equality after stripping + punctuation.""" + real_file = tmp_path / 'maad.flac' + real_file.write_bytes(b'audio') + + tracks_response = [ + {'title': 'm.A.A.d city', 'trackFileId': 7}, + ] + + def _api_get_stub(endpoint, params=None): + if endpoint == 'track': + return tracks_response + if endpoint == 'trackfile/7': + return {'id': 7, 'path': str(real_file)} + return None + + with patch.object(client, '_api_get', side_effect=_api_get_stub): + result = client._pick_track_file_for_wanted( + lidarr_album_id=999, wanted_title='maad city', + ) + assert result == str(real_file) + + +def test_pick_track_file_for_wanted_returns_none_below_threshold(client) -> None: + """If no track in the album is similar enough to the wanted title, + return None so caller falls back to first-imported behavior.""" + tracks_response = [ + {'title': 'completely unrelated song', 'trackFileId': 1}, + {'title': 'another unrelated', 'trackFileId': 2}, + ] + with patch.object(client, '_api_get', return_value=tracks_response): + result = client._pick_track_file_for_wanted( + lidarr_album_id=999, wanted_title='wacced out murals', + ) + assert result is None + + +def test_pick_track_file_for_wanted_returns_none_for_empty_wanted(client) -> None: + """Empty wanted_title → return None (album-level dispatch path).""" + with patch.object(client, '_api_get') as mock_api: + result = client._pick_track_file_for_wanted( + lidarr_album_id=999, wanted_title='', + ) + assert result is None + # API never called — saves a roundtrip when we know we don't need it + mock_api.assert_not_called() + + +def test_pick_track_file_for_wanted_returns_none_when_track_api_fails(client) -> None: + """Defensive: if Lidarr's track API returns None or non-list, + don't crash — return None and let caller fall back.""" + with patch.object(client, '_api_get', return_value=None): + result = client._pick_track_file_for_wanted( + lidarr_album_id=999, wanted_title='anything', + ) + assert result is None + + +def test_pick_track_file_for_wanted_skips_tracks_without_trackfileid(client, tmp_path: Path) -> None: + """Tracks not yet downloaded (no trackFileId) must be skipped — only + consider tracks that actually have an imported file.""" + real_file = tmp_path / 'real.flac' + real_file.write_bytes(b'audio') + + tracks_response = [ + {'title': 'wacced out murals', 'trackFileId': None}, # not imported + {'title': 'wacced out murals (alt)', 'trackFileId': 99}, + ] + + def _api_get_stub(endpoint, params=None): + if endpoint == 'track': + return tracks_response + if endpoint == 'trackfile/99': + return {'id': 99, 'path': str(real_file)} + return None + + with patch.object(client, '_api_get', side_effect=_api_get_stub): + result = client._pick_track_file_for_wanted( + lidarr_album_id=999, wanted_title='wacced out murals', + ) + # Picked the second track because the first has no trackFileId + assert result == str(real_file) + + +def test_pick_track_file_for_wanted_returns_none_when_file_missing_on_disk(client, tmp_path: Path) -> None: + """Lidarr might claim a path exists but the file was moved/deleted + between Lidarr's import and our copy. Return None defensively so + caller falls back.""" + tracks_response = [{'title': 'humble', 'trackFileId': 1}] + trackfile_response = {'id': 1, 'path': str(tmp_path / 'does_not_exist.flac')} + + def _api_get_stub(endpoint, params=None): + if endpoint == 'track': + return tracks_response + if endpoint == 'trackfile/1': + return trackfile_response + return None + + with patch.object(client, '_api_get', side_effect=_api_get_stub): + result = client._pick_track_file_for_wanted( + lidarr_album_id=999, wanted_title='humble', + ) + assert result is None + + +# --------------------------------------------------------------------------- +# Metadata profile id resolution +# --------------------------------------------------------------------------- + + +def test_get_metadata_profile_id_returns_first_available(client) -> None: + """When Lidarr returns a list, pick the first id (matches the + behavior of `_get_quality_profile_id`).""" + profiles = [ + {'id': 5, 'name': 'Standard'}, + {'id': 7, 'name': 'Custom'}, + ] + with patch.object(client, '_api_get', return_value=profiles): + result = client._get_metadata_profile_id() + assert result == 5 + + +def test_get_metadata_profile_id_falls_back_to_one_on_api_failure(client) -> None: + """If the API call returns None (network error / endpoint missing), + return 1 — preserves the previous hardcode as a safety net.""" + with patch.object(client, '_api_get', return_value=None): + result = client._get_metadata_profile_id() + assert result == 1 + + +def test_get_metadata_profile_id_falls_back_when_no_id_field(client) -> None: + """Defensive against malformed responses (profile dicts without id).""" + with patch.object(client, '_api_get', + return_value=[{'name': 'No Id Field'}]): + result = client._get_metadata_profile_id() + assert result == 1 + + +def test_get_metadata_profile_id_falls_back_for_non_list(client) -> None: + """Lidarr API quirk: some endpoints return dicts instead of lists. + Don't crash — fall back to 1.""" + with patch.object(client, '_api_get', return_value={'totalCount': 0}): + result = client._get_metadata_profile_id() + assert result == 1 diff --git a/webui/index.html b/webui/index.html index 48b2f2f4..3992d14d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -4724,6 +4724,8 @@