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 @@
Uses Lidarr's indexers and download clients (Usenet/torrent) for album downloads. +

+ Best for full-album grabs. Lidarr is album-first by design — track-name searches usually return nothing useful, so in hybrid mode Lidarr is mostly a no-op for playlists and falls through to your other sources. Use Lidarr Only when you specifically want to grab whole albums via your indexers.
diff --git a/webui/static/helper.js b/webui/static/helper.js index 24a5efd8..041c1717 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3444,6 +3444,7 @@ const WHATS_NEW = { '2.4.2': [ // --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- { date: 'Unreleased — 2.4.2 dev cycle' }, + { title: 'Lidarr: Right Track Lands on Disk + Profile Lookup Stops Failing', desc: 'lidarr is an album-grabber — when you ask for one track it grabs the whole album, then we pick the wanted track out. old code blindly took the first imported file as the result, so any track you asked for got mistagged as track 1 of the album. now matches the wanted title against lidarr\'s track list (with punctuation-tolerant fuzzy compare) and copies only that file. also fixed a hardcoded `metadataProfileId=1` that broke artist-add on installs where someone had renamed/recreated profiles, and a polling-loop bug where the inner break never escaped the outer poll loop so completion detection was delayed. settings tooltip updated to be honest: lidarr is best for full-album grabs and effectively a no-op for playlist sync (track searches return nothing useful, hybrid mode falls through to your other sources).', page: 'settings' }, { title: 'SoundCloud as a Download Source', desc: 'discord request (toasti): some tracks (DJ mixes, sets, removed-from-spotify exclusives) only live on soundcloud. soundcloud now plugs into the existing download-source picker on settings → downloads — pick "SoundCloud Only" or include it in the hybrid order alongside soulseek / youtube / tidal / qobuz / hifi / deezer / lidarr. anonymous-only (no account needed); quality is whatever soundcloud serves anonymously, typically 128 kbps mp3 or aac depending on the upload. soundcloud doesn\'t expose lossless to anyone, so don\'t expect flac. follows the exact same wiring contract as every other download source — search dispatch, hybrid fallback, queue / cancel / clear, sidebar source label, provenance + library history all work plug-and-play.', page: 'settings' }, { title: 'Fix Qobuz Connection Not Sticking After Login', desc: 'logging in via the qobuz connect button on settings showed "connected: (active)" but underneath an error said "qobuz not authenticated...", and the dashboard indicator stayed yellow. cause: two separate qobuz client instances run side by side (one for the auth flow, one for the enrichment worker) and login only updated the first one. now the worker\'s client gets synced from config the moment login / token / logout completes, so the dashboard indicator goes green and connection-test stops yelling.', page: 'settings' }, { title: 'Fix Lossy Copy Not Deleting Original FLAC', desc: 'with lossy copy enabled and "delete original" turned on (you wanted an mp3-only library), every download still left both the flac and the converted mp3 sitting in the same folder. the setting was being read but never acted on during the conversion step. now the original gets removed right after a successful conversion, with a same-path safety check + graceful handling if the original is already gone or locked.', page: 'settings' },