Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites
Catches the silent excepts the awk-based earlier sweeps missed:
- Bare `except:` followed by `pass` (also swallows KeyboardInterrupt
and SystemExit — actively wrong). Upgraded to `except Exception as
e: logger.debug("...: %s", e)`. ~14 sites across connection_detect,
soulseek_client, listenbrainz_manager, watchlist_scanner,
youtube_client, navidrome_client, jellyfin_client, web_server.
- `except Exception:` + pass that the awk pattern missed (e.g.
multi-line or unusual whitespace). ~31 sites across automation_engine,
database_update_worker, music_database, spotify_client, web_server,
others.
- 14 legitimate cleanup sites left silent with explicit `# noqa: S110`
+ comment explaining why (atexit handlers, finally-block conn.close
calls). Logging during shutdown can itself crash because file handles
get torn down before the handler fires.
Also enables `S110` rule in pyproject.toml so this pattern fails CI
going forward — drift fails at PR review instead of at runtime against
a wedged worker thread. Tests path keeps S110 ignored (test fixtures
legitimately use try-except-pass for cleanup).
Adds a WHATS_NEW entry to helper.js summarizing the full #369 sweep.
Verified: `python -m ruff check .` → All checks passed.
Verified: `python -m pytest tests/` → 2188 passed.
Closes #369
This commit is contained in:
parent
aa54bed818
commit
9602d1827c
25 changed files with 125 additions and 108 deletions
|
|
@ -2612,7 +2612,7 @@ class BeatportUnifiedScraper:
|
||||||
result['title'] = title
|
result['title'] = title
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass # Silently handle URL extraction errors
|
logger.debug("URL slug extraction: %s", e)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -455,8 +455,10 @@ class AutomationEngine:
|
||||||
if delay_minutes and delay_minutes > 0:
|
if delay_minutes and delay_minutes > 0:
|
||||||
# Initialize progress BEFORE delay so card glows during wait
|
# Initialize progress BEFORE delay so card glows during wait
|
||||||
if self._progress_init_fn:
|
if self._progress_init_fn:
|
||||||
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
try:
|
||||||
except Exception: pass
|
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("event progress init (delay): %s", e)
|
||||||
_delay_already_inited = True
|
_delay_already_inited = True
|
||||||
|
|
||||||
delay_seconds = int(delay_minutes) * 60
|
delay_seconds = int(delay_minutes) * 60
|
||||||
|
|
@ -486,13 +488,17 @@ class AutomationEngine:
|
||||||
logger.info(f"Event automation '{auto.get('name')}' skipped — {action_type} busy")
|
logger.info(f"Event automation '{auto.get('name')}' skipped — {action_type} busy")
|
||||||
# If progress was initialized during delay, finalize it
|
# If progress was initialized during delay, finalize it
|
||||||
if _delay_already_inited and self._progress_finish_fn:
|
if _delay_already_inited and self._progress_finish_fn:
|
||||||
try: self._progress_finish_fn(automation_id, result)
|
try:
|
||||||
except Exception: pass
|
self._progress_finish_fn(automation_id, result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("event progress finish (skipped): %s", e)
|
||||||
else:
|
else:
|
||||||
# Initialize progress tracking (skip if already done during delay)
|
# Initialize progress tracking (skip if already done during delay)
|
||||||
if not _delay_already_inited and self._progress_init_fn:
|
if not _delay_already_inited and self._progress_init_fn:
|
||||||
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
try:
|
||||||
except Exception: pass
|
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("event progress init: %s", e)
|
||||||
try:
|
try:
|
||||||
result = handler_info['handler'](action_config) or {}
|
result = handler_info['handler'](action_config) or {}
|
||||||
logger.info(f"Event automation '{auto.get('name')}' executed: {result.get('status', 'ok')}")
|
logger.info(f"Event automation '{auto.get('name')}' executed: {result.get('status', 'ok')}")
|
||||||
|
|
@ -501,8 +507,10 @@ class AutomationEngine:
|
||||||
logger.error(f"Event automation '{auto.get('name')}' action failed: {e}")
|
logger.error(f"Event automation '{auto.get('name')}' action failed: {e}")
|
||||||
# Finalize progress tracking
|
# Finalize progress tracking
|
||||||
if self._progress_finish_fn:
|
if self._progress_finish_fn:
|
||||||
try: self._progress_finish_fn(automation_id, result)
|
try:
|
||||||
except Exception: pass
|
self._progress_finish_fn(automation_id, result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("event progress finish: %s", e)
|
||||||
|
|
||||||
# Merge event data into result for then-action variables
|
# Merge event data into result for then-action variables
|
||||||
merged = {**event_data, **result}
|
merged = {**event_data, **result}
|
||||||
|
|
@ -580,8 +588,10 @@ class AutomationEngine:
|
||||||
if not skip_delay and delay_minutes and delay_minutes > 0:
|
if not skip_delay and delay_minutes and delay_minutes > 0:
|
||||||
# Initialize progress BEFORE delay so card glows during wait
|
# Initialize progress BEFORE delay so card glows during wait
|
||||||
if self._progress_init_fn:
|
if self._progress_init_fn:
|
||||||
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
try:
|
||||||
except Exception: pass
|
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("scheduled progress init (delay): %s", e)
|
||||||
_delay_already_inited = True
|
_delay_already_inited = True
|
||||||
|
|
||||||
delay_seconds = int(delay_minutes) * 60
|
delay_seconds = int(delay_minutes) * 60
|
||||||
|
|
@ -603,15 +613,19 @@ class AutomationEngine:
|
||||||
logger.info(f"Automation '{auto['name']}' skipped — {action_type} already running")
|
logger.info(f"Automation '{auto['name']}' skipped — {action_type} already running")
|
||||||
# If progress was initialized during delay, finalize it
|
# If progress was initialized during delay, finalize it
|
||||||
if _delay_already_inited and self._progress_finish_fn:
|
if _delay_already_inited and self._progress_finish_fn:
|
||||||
try: self._progress_finish_fn(automation_id, result)
|
try:
|
||||||
except Exception: pass
|
self._progress_finish_fn(automation_id, result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("scheduled progress finish (skipped): %s", e)
|
||||||
self._finish_run(auto, automation_id, result, error=None)
|
self._finish_run(auto, automation_id, result, error=None)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Initialize progress tracking (skip if already done during delay)
|
# Initialize progress tracking (skip if already done during delay)
|
||||||
if not _delay_already_inited and self._progress_init_fn:
|
if not _delay_already_inited and self._progress_init_fn:
|
||||||
try: self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
try:
|
||||||
except Exception: pass
|
self._progress_init_fn(automation_id, auto.get('name', ''), action_type)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("scheduled progress init: %s", e)
|
||||||
|
|
||||||
# Execute the action
|
# Execute the action
|
||||||
error = None
|
error = None
|
||||||
|
|
@ -637,8 +651,10 @@ class AutomationEngine:
|
||||||
|
|
||||||
# Finalize progress tracking
|
# Finalize progress tracking
|
||||||
if self._progress_finish_fn:
|
if self._progress_finish_fn:
|
||||||
try: self._progress_finish_fn(automation_id, result)
|
try:
|
||||||
except Exception: pass
|
self._progress_finish_fn(automation_id, result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("scheduled progress finish: %s", e)
|
||||||
|
|
||||||
# Execute then-actions (notifications + fire_signal)
|
# Execute then-actions (notifications + fire_signal)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,8 @@ def run_detection(server_type):
|
||||||
if api_response.status_code == 200 and 'MediaContainer' in api_response.text:
|
if api_response.status_code == 200 and 'MediaContainer' in api_response.text:
|
||||||
return f"http://{ip}:{port}"
|
return f"http://{ip}:{port}"
|
||||||
|
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("plex probe %s: %s", ip, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def test_jellyfin_server(ip, port=8096):
|
def test_jellyfin_server(ip, port=8096):
|
||||||
|
|
@ -102,8 +102,8 @@ def run_detection(server_type):
|
||||||
if web_response.status_code == 200 and 'jellyfin' in web_response.text.lower():
|
if web_response.status_code == 200 and 'jellyfin' in web_response.text.lower():
|
||||||
return f"http://{ip}:{port}"
|
return f"http://{ip}:{port}"
|
||||||
|
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("jellyfin probe %s: %s", ip, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def test_slskd_server(ip, port=5030):
|
def test_slskd_server(ip, port=5030):
|
||||||
|
|
@ -117,8 +117,8 @@ def run_detection(server_type):
|
||||||
if response.status_code in [200, 401]:
|
if response.status_code in [200, 401]:
|
||||||
return f"http://{ip}:{port}"
|
return f"http://{ip}:{port}"
|
||||||
|
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("slskd probe %s: %s", ip, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def test_navidrome_server(ip, port=4533):
|
def test_navidrome_server(ip, port=4533):
|
||||||
|
|
@ -140,8 +140,8 @@ def run_detection(server_type):
|
||||||
# Check for Subsonic/Navidrome API response structure
|
# Check for Subsonic/Navidrome API response structure
|
||||||
if 'subsonic-response' in data:
|
if 'subsonic-response' in data:
|
||||||
return f"http://{ip}:{port}"
|
return f"http://{ip}:{port}"
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("navidrome json parse: %s", e)
|
||||||
|
|
||||||
# Also try the web interface
|
# Also try the web interface
|
||||||
web_url = f"http://{ip}:{port}/"
|
web_url = f"http://{ip}:{port}/"
|
||||||
|
|
@ -149,8 +149,8 @@ def run_detection(server_type):
|
||||||
if web_response.status_code == 200 and 'navidrome' in web_response.text.lower():
|
if web_response.status_code == 200 and 'navidrome' in web_response.text.lower():
|
||||||
return f"http://{ip}:{port}"
|
return f"http://{ip}:{port}"
|
||||||
|
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("navidrome probe %s: %s", ip, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1051,8 +1051,8 @@ class DatabaseUpdateWorker:
|
||||||
batch + [self.server_type])
|
batch + [self.server_type])
|
||||||
cascade_album_ids.update(row[0] for row in cursor.fetchall())
|
cascade_album_ids.update(row[0] for row in cursor.fetchall())
|
||||||
removed_album_ids -= cascade_album_ids
|
removed_album_ids -= cascade_album_ids
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # If this optimization fails, double-delete is harmless
|
logger.debug("cascade album cleanup optimization: %s", e)
|
||||||
|
|
||||||
if not removed_artist_ids and not removed_album_ids:
|
if not removed_artist_ids and not removed_album_ids:
|
||||||
logger.info("Removal detection: no stale content found")
|
logger.info("Removal detection: no stale content found")
|
||||||
|
|
|
||||||
|
|
@ -1441,8 +1441,8 @@ class JellyfinClient(MediaServerClient):
|
||||||
response = requests.delete(url, headers=headers, timeout=10)
|
response = requests.delete(url, headers=headers, timeout=10)
|
||||||
if response.status_code in [200, 204]:
|
if response.status_code in [200, 204]:
|
||||||
logger.info(f"Deleted existing backup playlist '{target_name}'")
|
logger.info(f"Deleted existing backup playlist '{target_name}'")
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Target doesn't exist, which is fine
|
logger.debug("backup playlist precheck: %s", e)
|
||||||
|
|
||||||
# Create new playlist with copied tracks
|
# Create new playlist with copied tracks
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ def find_library_track_by_external_id(
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -287,7 +287,7 @@ def find_provenance_by_external_id(
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -506,7 +506,7 @@ def load_album_and_tracks(db, album_id):
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1329,7 +1329,7 @@ def reorganize_album(
|
||||||
try:
|
try:
|
||||||
if os.path.isdir(staging_album_dir):
|
if os.path.isdir(staging_album_dir):
|
||||||
shutil.rmtree(staging_album_dir, ignore_errors=True)
|
shutil.rmtree(staging_album_dir, ignore_errors=True)
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Best-effort cleanup of source directories. For each touched dir
|
# Best-effort cleanup of source directories. For each touched dir
|
||||||
|
|
@ -1343,14 +1343,14 @@ def reorganize_album(
|
||||||
if _has_remaining_audio(src_dir):
|
if _has_remaining_audio(src_dir):
|
||||||
continue
|
continue
|
||||||
_delete_album_sidecars(src_dir)
|
_delete_album_sidecars(src_dir)
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if cleanup_empty_dir_fn:
|
if cleanup_empty_dir_fn:
|
||||||
for src_dir in src_dirs_touched:
|
for src_dir in src_dirs_touched:
|
||||||
try:
|
try:
|
||||||
cleanup_empty_dir_fn(src_dir)
|
cleanup_empty_dir_fn(src_dir)
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Prune empty *destination* siblings — e.g. when a previous
|
# Prune empty *destination* siblings — e.g. when a previous
|
||||||
|
|
|
||||||
|
|
@ -302,8 +302,8 @@ class ListenBrainzManager:
|
||||||
# Fallback to first image
|
# Fallback to first image
|
||||||
if images:
|
if images:
|
||||||
return images[0].get('thumbnails', {}).get('small') or images[0].get('image')
|
return images[0].get('thumbnails', {}).get('small') or images[0].get('image')
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("cover-art fetch: %s", e)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -507,7 +507,7 @@ class ListeningStatsWorker:
|
||||||
if conn:
|
if conn:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _resolve_db_track_id(self, title, artist):
|
def _resolve_db_track_id(self, title, artist):
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ def lookup(normalized_album_key: str, artist_key: str) -> Optional[str]:
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
@ -142,7 +142,7 @@ def record(normalized_album_key: str, artist_key: str, release_mbid: str) -> boo
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -168,7 +168,7 @@ def clear_all() -> bool:
|
||||||
if conn is not None:
|
if conn is not None:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -922,8 +922,8 @@ class NavidromeClient(MediaServerClient):
|
||||||
if target_playlist:
|
if target_playlist:
|
||||||
self._make_request('deletePlaylist', {'id': target_playlist.id})
|
self._make_request('deletePlaylist', {'id': target_playlist.id})
|
||||||
logger.info(f"Deleted existing backup playlist '{target_name}'")
|
logger.info(f"Deleted existing backup playlist '{target_name}'")
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Target doesn't exist, which is fine
|
logger.debug("backup playlist precheck: %s", e)
|
||||||
|
|
||||||
# Create new playlist with copied tracks
|
# Create new playlist with copied tracks
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -224,8 +224,8 @@ def playlist_explorer_build_tree(deps: PlaylistExplorerDeps):
|
||||||
cursor.execute("SELECT title FROM albums WHERE artist_id = ?", (ar['id'],))
|
cursor.execute("SELECT title FROM albums WHERE artist_id = ?", (ar['id'],))
|
||||||
for alb_row in cursor.fetchall():
|
for alb_row in cursor.fetchall():
|
||||||
owned_titles.add((alb_row['title'] or '').strip().lower())
|
owned_titles.add((alb_row['title'] or '').strip().lower())
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Non-critical — owned badges just won't show
|
logger.debug("owned-titles lookup: %s", e)
|
||||||
|
|
||||||
# Build release list
|
# Build release list
|
||||||
releases = []
|
releases = []
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,9 @@ def build_runner(
|
||||||
def _on_progress(updates):
|
def _on_progress(updates):
|
||||||
try:
|
try:
|
||||||
get_queue().update_active_progress(queue_id=item.queue_id, **updates)
|
get_queue().update_active_progress(queue_id=item.queue_id, **updates)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
# Progress fan-out failures must never break a run.
|
# Progress fan-out failures must never break a run.
|
||||||
pass
|
logger.debug("reorganize progress fan-out: %s", e)
|
||||||
|
|
||||||
return reorganize_album(
|
return reorganize_album(
|
||||||
album_id=item.album_id,
|
album_id=item.album_id,
|
||||||
|
|
|
||||||
|
|
@ -399,7 +399,7 @@ class LibraryReorganizeJob(RepairJob):
|
||||||
if conn:
|
if conn:
|
||||||
try:
|
try:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _get_setting(self, context: JobContext, key: str, default):
|
def _get_setting(self, context: JobContext, key: str, default):
|
||||||
|
|
|
||||||
|
|
@ -167,8 +167,8 @@ class SeasonalDiscoveryService:
|
||||||
try:
|
try:
|
||||||
cursor.execute(f"ALTER TABLE {table} ADD COLUMN source TEXT NOT NULL DEFAULT 'spotify'")
|
cursor.execute(f"ALTER TABLE {table} ADD COLUMN source TEXT NOT NULL DEFAULT 'spotify'")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Column already exists
|
logger.debug("source column migration %s: %s", table, e)
|
||||||
|
|
||||||
logger.info("Seasonal discovery database schema initialized")
|
logger.info("Seasonal discovery database schema initialized")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -216,8 +216,8 @@ class SoulseekClient(DownloadSourcePlugin):
|
||||||
if session:
|
if session:
|
||||||
try:
|
try:
|
||||||
await session.close()
|
await session.close()
|
||||||
except:
|
except Exception as _e:
|
||||||
pass
|
logger.debug("aiohttp session close: %s", _e)
|
||||||
|
|
||||||
async def _make_direct_request(self, method: str, endpoint: str, **kwargs) -> Optional[Dict[str, Any]]:
|
async def _make_direct_request(self, method: str, endpoint: str, **kwargs) -> Optional[Dict[str, Any]]:
|
||||||
"""Make a direct request to slskd without /api/v0/ prefix (for endpoints that work directly)"""
|
"""Make a direct request to slskd without /api/v0/ prefix (for endpoints that work directly)"""
|
||||||
|
|
@ -273,8 +273,8 @@ class SoulseekClient(DownloadSourcePlugin):
|
||||||
if session:
|
if session:
|
||||||
try:
|
try:
|
||||||
await session.close()
|
await session.close()
|
||||||
except:
|
except Exception as _e:
|
||||||
pass
|
logger.debug("aiohttp direct session close: %s", _e)
|
||||||
|
|
||||||
def _process_search_responses(self, responses_data: List[Dict[str, Any]]) -> tuple[List[TrackResult], List[AlbumResult]]:
|
def _process_search_responses(self, responses_data: List[Dict[str, Any]]) -> tuple[List[TrackResult], List[AlbumResult]]:
|
||||||
"""Process search response data into TrackResult and AlbumResult objects"""
|
"""Process search response data into TrackResult and AlbumResult objects"""
|
||||||
|
|
@ -1621,8 +1621,8 @@ class SoulseekClient(DownloadSourcePlugin):
|
||||||
if resp.status in [200, 405]: # 405 means endpoint exists but wrong method
|
if resp.status in [200, 405]: # 405 means endpoint exists but wrong method
|
||||||
available_endpoints[f"direct_{endpoint}"] = f"Status: {resp.status}"
|
available_endpoints[f"direct_{endpoint}"] = f"Status: {resp.status}"
|
||||||
logger.info(f"[OK] Direct endpoint available: {simple_url} (Status: {resp.status})")
|
logger.info(f"[OK] Direct endpoint available: {simple_url} (Status: {resp.status})")
|
||||||
except:
|
except Exception as _e:
|
||||||
pass
|
logger.debug("direct endpoint probe %s: %s", endpoint, _e)
|
||||||
finally:
|
finally:
|
||||||
await session.close()
|
await session.close()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1636,8 +1636,8 @@ class SpotifyClient:
|
||||||
try:
|
try:
|
||||||
albums_list = cached.get('_albums', cached) if isinstance(cached, dict) else cached
|
albums_list = cached.get('_albums', cached) if isinstance(cached, dict) else cached
|
||||||
return [Album.from_spotify_album(ad) for ad in albums_list]
|
return [Album.from_spotify_album(ad) for ad in albums_list]
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Cache data incompatible, re-fetch
|
logger.debug("artist albums cache reuse: %s", e)
|
||||||
|
|
||||||
if self.is_spotify_authenticated():
|
if self.is_spotify_authenticated():
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -460,7 +460,7 @@ def process_watchlist_scan_automatically(automation_id=None, profile_id=None, de
|
||||||
# Clear one-time rescan cutoff after full scan cycle
|
# Clear one-time rescan cutoff after full scan cycle
|
||||||
try:
|
try:
|
||||||
scanner._clear_rescan_cutoff()
|
scanner._clear_rescan_cutoff()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — finally-block cleanup, logger may be torn down
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Always reset flag
|
# Always reset flag
|
||||||
|
|
|
||||||
|
|
@ -3107,8 +3107,8 @@ class WatchlistScanner:
|
||||||
release_date = datetime.strptime(release_date_str, "%Y-%m-%d")
|
release_date = datetime.strptime(release_date_str, "%Y-%m-%d")
|
||||||
days_old = (datetime.now() - release_date).days
|
days_old = (datetime.now() - release_date).days
|
||||||
is_new = days_old <= 30
|
is_new = days_old <= 30
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("new-release date parse: %s", e)
|
||||||
|
|
||||||
# Add each track to discovery pool
|
# Add each track to discovery pool
|
||||||
for track in tracks:
|
for track in tracks:
|
||||||
|
|
@ -3552,8 +3552,8 @@ class WatchlistScanner:
|
||||||
if release_date_str and len(release_date_str) >= 10:
|
if release_date_str and len(release_date_str) >= 10:
|
||||||
release_date = datetime.strptime(release_date_str[:10], "%Y-%m-%d")
|
release_date = datetime.strptime(release_date_str[:10], "%Y-%m-%d")
|
||||||
days_old = (datetime.now() - release_date).days
|
days_old = (datetime.now() - release_date).days
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("release-date parse: %s", e)
|
||||||
|
|
||||||
for track in album_data['tracks'].get('items', []):
|
for track in album_data['tracks'].get('items', []):
|
||||||
track_id = track.get('id')
|
track_id = track.get('id')
|
||||||
|
|
|
||||||
|
|
@ -421,8 +421,8 @@ class WebMetadataUpdateWorker:
|
||||||
if hasattr(album, 'genres') and album.genres:
|
if hasattr(album, 'genres') and album.genres:
|
||||||
album_genres.update(genre.tag if hasattr(genre, 'tag') else str(genre)
|
album_genres.update(genre.tag if hasattr(genre, 'tag') else str(genre)
|
||||||
for genre in album.genres)
|
for genre in album.genres)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Albums might not be accessible
|
logger.debug("artist album genre walk: %s", e)
|
||||||
|
|
||||||
# Combine all genres (prioritize Spotify genres)
|
# Combine all genres (prioritize Spotify genres)
|
||||||
all_genres = spotify_genres.union(album_genres)
|
all_genres = spotify_genres.union(album_genres)
|
||||||
|
|
|
||||||
|
|
@ -1190,8 +1190,8 @@ class YouTubeClient(DownloadSourcePlugin):
|
||||||
album_data = track_details.get('album', {})
|
album_data = track_details.get('album', {})
|
||||||
if album_data.get('artists'):
|
if album_data.get('artists'):
|
||||||
album_artist = album_data['artists'][0]
|
album_artist = album_data['artists'][0]
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("spotify album artist lookup: %s", e)
|
||||||
|
|
||||||
logger.debug(" Setting metadata tags...")
|
logger.debug(" Setting metadata tags...")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5219,8 +5219,8 @@ class MusicDatabase:
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
thumb_url=album_row[1] if album_row and len(album_row) > 1 else None
|
thumb_url=album_row[1] if album_row and len(album_row) > 1 else None
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Non-critical history logging
|
logger.debug("history logging: %s", e)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
@ -12137,5 +12137,5 @@ def close_database():
|
||||||
db_instance.close()
|
db_instance.close()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Ignore threading errors during shutdown
|
# Ignore threading errors during shutdown
|
||||||
pass
|
logger.debug("db instance close: %s", e)
|
||||||
_database_instances.clear()
|
_database_instances.clear()
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ line-length = 160
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# Start lenient — catch real bugs, not style nits
|
# Start lenient — catch real bugs, not style nits
|
||||||
# E: pycodestyle errors, F: pyflakes, UP: pyupgrade, B: bugbear
|
# E: pycodestyle errors, F: pyflakes, UP: pyupgrade, B: bugbear
|
||||||
select = ["F", "E9", "W6", "B"]
|
select = ["F", "E9", "W6", "B", "S110"]
|
||||||
# Ignore rules that will flag too much in an existing codebase
|
# Ignore rules that will flag too much in an existing codebase
|
||||||
ignore = [
|
ignore = [
|
||||||
"F401", # unused imports (too noisy initially)
|
"F401", # unused imports (too noisy initially)
|
||||||
|
|
@ -15,7 +15,7 @@ ignore = [
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
# Tests can use assert, magic values, etc.
|
# Tests can use assert, magic values, etc.
|
||||||
"tests/**" = ["B", "F"]
|
"tests/**" = ["B", "F", "S110"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
markers = [
|
markers = [
|
||||||
|
|
|
||||||
|
|
@ -387,8 +387,8 @@ def _set_profile_context():
|
||||||
session.pop('profile_id', None)
|
session.pop('profile_id', None)
|
||||||
from flask import jsonify as _jsonify
|
from flask import jsonify as _jsonify
|
||||||
return _jsonify({"error": "profile_required", "message": "Profile no longer exists"}), 401
|
return _jsonify({"error": "profile_required", "message": "Profile no longer exists"}), 401
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # DB error — don't block requests, use the session value
|
logger.debug("profile session validate: %s", e)
|
||||||
|
|
||||||
g.profile_id = pid
|
g.profile_id = pid
|
||||||
|
|
||||||
|
|
@ -515,8 +515,8 @@ def check_download_permission():
|
||||||
profile = get_database().get_profile(pid)
|
profile = get_database().get_profile(pid)
|
||||||
if profile and not profile.get('can_download', True):
|
if profile and not profile.get('can_download', True):
|
||||||
return jsonify({'success': False, 'error': 'Downloads are disabled for this profile.'}), 403
|
return jsonify({'success': False, 'error': 'Downloads are disabled for this profile.'}), 403
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # DB error — don't block
|
logger.debug("download permission check: %s", e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# --- Docker Helper Functions ---
|
# --- Docker Helper Functions ---
|
||||||
|
|
@ -1397,8 +1397,8 @@ def _register_automation_handlers():
|
||||||
'status': 'skipped',
|
'status': 'skipped',
|
||||||
'reason': f'All {len(tracks_json)} tracks unchanged since last sync',
|
'reason': f'All {len(tracks_json)} tracks unchanged since last sync',
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # If we can't read last status, just run the sync
|
logger.debug("mirror sync last-status read: %s", e)
|
||||||
|
|
||||||
_update_automation_progress(auto_id, progress=50,
|
_update_automation_progress(auto_id, progress=50,
|
||||||
phase=f'Syncing "{pl["name"]}"',
|
phase=f'Syncing "{pl["name"]}"',
|
||||||
|
|
@ -2974,13 +2974,13 @@ def _atexit_save_history():
|
||||||
try:
|
try:
|
||||||
from core.api_call_tracker import api_call_tracker
|
from core.api_call_tracker import api_call_tracker
|
||||||
api_call_tracker.save()
|
api_call_tracker.save()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — atexit handler, log handles may be closed
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _atexit_shutdown():
|
def _atexit_shutdown():
|
||||||
try:
|
try:
|
||||||
_shutdown_runtime_components()
|
_shutdown_runtime_components()
|
||||||
except Exception:
|
except Exception: # noqa: S110 — atexit handler, log handles may be closed
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4176,8 +4176,8 @@ def handle_settings():
|
||||||
# FIX: Re-instantiate the global tidal_client to pick up new settings
|
# FIX: Re-instantiate the global tidal_client to pick up new settings
|
||||||
try:
|
try:
|
||||||
tidal_client = TidalClient()
|
tidal_client = TidalClient()
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Keep existing tidal_client if re-init fails
|
logger.debug("tidal client re-init: %s", e)
|
||||||
# Reload enrichment worker clients for key-based services
|
# Reload enrichment worker clients for key-based services
|
||||||
if lastfm_worker:
|
if lastfm_worker:
|
||||||
lastfm_worker._init_client()
|
lastfm_worker._init_client()
|
||||||
|
|
@ -4330,8 +4330,8 @@ def hydrabase_connect():
|
||||||
if _hydrabase_ws:
|
if _hydrabase_ws:
|
||||||
try:
|
try:
|
||||||
_hydrabase_ws.close()
|
_hydrabase_ws.close()
|
||||||
except:
|
except Exception as _e:
|
||||||
pass
|
logger.debug("hydrabase connect-existing close: %s", _e)
|
||||||
ws = websocket.create_connection(
|
ws = websocket.create_connection(
|
||||||
url,
|
url,
|
||||||
header={"x-api-key": api_key},
|
header={"x-api-key": api_key},
|
||||||
|
|
@ -4356,8 +4356,8 @@ def hydrabase_disconnect():
|
||||||
if _hydrabase_ws:
|
if _hydrabase_ws:
|
||||||
try:
|
try:
|
||||||
_hydrabase_ws.close()
|
_hydrabase_ws.close()
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("hydrabase disconnect close: %s", e)
|
||||||
_hydrabase_ws = None
|
_hydrabase_ws = None
|
||||||
config_manager.set('hydrabase.auto_connect', False)
|
config_manager.set('hydrabase.auto_connect', False)
|
||||||
# Only disable dev mode if not using Hydrabase as a regular fallback source
|
# Only disable dev mode if not using Hydrabase as a regular fallback source
|
||||||
|
|
@ -4428,8 +4428,8 @@ def hydrabase_send():
|
||||||
with _hydrabase_lock:
|
with _hydrabase_lock:
|
||||||
try:
|
try:
|
||||||
_hydrabase_ws.close()
|
_hydrabase_ws.close()
|
||||||
except:
|
except Exception as _e:
|
||||||
pass
|
logger.debug("hydrabase send close: %s", _e)
|
||||||
_hydrabase_ws = None
|
_hydrabase_ws = None
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
@ -15761,8 +15761,8 @@ def backup_database_endpoint():
|
||||||
try:
|
try:
|
||||||
with open(meta_path, 'w') as mf:
|
with open(meta_path, 'w') as mf:
|
||||||
json.dump({"version": SOULSYNC_VERSION, "created": timestamp}, mf)
|
json.dump({"version": SOULSYNC_VERSION, "created": timestamp}, mf)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Non-critical — backup still works without metadata
|
logger.debug("backup meta sidecar write: %s", e)
|
||||||
# Rolling cleanup
|
# Rolling cleanup
|
||||||
existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime)
|
existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime)
|
||||||
# Filter out .meta.json files from the backup list
|
# Filter out .meta.json files from the backup list
|
||||||
|
|
@ -27002,8 +27002,8 @@ def get_seasonal_playlist(season_key):
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
track_dict['track_data_json'] = json.loads(track_dict['track_data_json'])
|
track_dict['track_data_json'] = json.loads(track_dict['track_data_json'])
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("track_data_json parse: %s", e)
|
||||||
tracks.append(track_dict)
|
tracks.append(track_dict)
|
||||||
else:
|
else:
|
||||||
# Try discovery_pool as fallback (filtered by source)
|
# Try discovery_pool as fallback (filtered by source)
|
||||||
|
|
@ -27029,8 +27029,8 @@ def get_seasonal_playlist(season_key):
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
track_dict['track_data_json'] = json.loads(track_dict['track_data_json'])
|
track_dict['track_data_json'] = json.loads(track_dict['track_data_json'])
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("discovery track_data_json parse: %s", e)
|
||||||
tracks.append(track_dict)
|
tracks.append(track_dict)
|
||||||
|
|
||||||
config = SEASONAL_CONFIG[season_key]
|
config = SEASONAL_CONFIG[season_key]
|
||||||
|
|
@ -32212,8 +32212,8 @@ def start_oauth_callback_servers():
|
||||||
self.send_header('Content-type', 'text/html')
|
self.send_header('Content-type', 'text/html')
|
||||||
self.end_headers()
|
self.end_headers()
|
||||||
self.wfile.write(f'<h1>Internal Server Error</h1><p>{str(e)}</p>'.encode())
|
self.wfile.write(f'<h1>Internal Server Error</h1><p>{str(e)}</p>'.encode())
|
||||||
except Exception:
|
except Exception as _e:
|
||||||
pass # Connection already broken, nothing more we can do
|
_oauth_logger.debug("oauth response write: %s", _e)
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
def log_message(self, format, *args):
|
||||||
pass # Suppress BaseHTTPRequestHandler access logs (we use our own logger)
|
pass # Suppress BaseHTTPRequestHandler access logs (we use our own logger)
|
||||||
|
|
@ -34193,8 +34193,8 @@ def _hydrabase_reconnect_loop():
|
||||||
if _hydrabase_ws is not None and _hydrabase_ws.connected:
|
if _hydrabase_ws is not None and _hydrabase_ws.connected:
|
||||||
_consecutive_failures = 0
|
_consecutive_failures = 0
|
||||||
continue
|
continue
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Socket in bad state — treat as disconnected
|
logger.debug("hydrabase socket check: %s", e)
|
||||||
|
|
||||||
# Disconnected with auto_connect enabled — try to reconnect
|
# Disconnected with auto_connect enabled — try to reconnect
|
||||||
# Back off: 30s, 60s, 120s, max 300s between attempts
|
# Back off: 30s, 60s, 120s, max 300s between attempts
|
||||||
|
|
@ -34208,8 +34208,8 @@ def _hydrabase_reconnect_loop():
|
||||||
if _hydrabase_ws:
|
if _hydrabase_ws:
|
||||||
try:
|
try:
|
||||||
_hydrabase_ws.close()
|
_hydrabase_ws.close()
|
||||||
except:
|
except Exception as e:
|
||||||
pass
|
logger.debug("hydrabase reconnect close: %s", e)
|
||||||
ws = websocket.create_connection(
|
ws = websocket.create_connection(
|
||||||
hydra_cfg['url'],
|
hydra_cfg['url'],
|
||||||
header={"x-api-key": hydra_cfg['api_key']},
|
header={"x-api-key": hydra_cfg['api_key']},
|
||||||
|
|
@ -34224,8 +34224,8 @@ def _hydrabase_reconnect_loop():
|
||||||
logger.error(f"[Hydrabase] Reconnect attempt failed ({_consecutive_failures}): {e}")
|
logger.error(f"[Hydrabase] Reconnect attempt failed ({_consecutive_failures}): {e}")
|
||||||
elif _consecutive_failures == 4:
|
elif _consecutive_failures == 4:
|
||||||
logger.error("[Hydrabase] Reconnect failing repeatedly — suppressing further logs until success")
|
logger.error("[Hydrabase] Reconnect failing repeatedly — suppressing further logs until success")
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass # Don't crash the monitor loop
|
logger.debug("hydrabase monitor loop: %s", e)
|
||||||
|
|
||||||
def _emit_service_status_loop():
|
def _emit_service_status_loop():
|
||||||
"""Background thread that pushes service status every 5 seconds."""
|
"""Background thread that pushes service status every 5 seconds."""
|
||||||
|
|
|
||||||
|
|
@ -3432,6 +3432,7 @@ const WHATS_NEW = {
|
||||||
'2.4.2': [
|
'2.4.2': [
|
||||||
// --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
|
// --- post-2.4.1 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
|
||||||
{ date: 'Unreleased — 2.4.2 dev cycle' },
|
{ date: 'Unreleased — 2.4.2 dev cycle' },
|
||||||
|
{ title: 'Internal: Stop Swallowing Exceptions Silently', desc: 'github issue #369 (johnbaumb): the codebase had ~300 `except Exception: pass` blocks — and another ~30 bare `except: pass` ones — across web_server.py, every metadata client, every download/import worker, the repair jobs, and most service modules. when one of those paths failed at runtime, the failure was completely invisible: no log line, no telemetry, nothing. you\'d see "downloads stopped working after a few hours" or "enrichment never finishes" and there was nothing to grep for in app.log because the exception had been thrown straight into the void. swept all of them. converted to `except Exception as e: logger.debug("<context>: %s", e)` so failures land in the log with enough context to grep. bare `except:` cases (which also swallow KeyboardInterrupt and SystemExit — actively wrong) got upgraded to `except Exception:` first so ctrl-c works correctly. ~14 cleanup-path sites (atexit handlers, finally-block conn.close calls) were intentionally left silent with explicit `# noqa: S110` comments — logging during shutdown can itself crash because file handles get torn down before the handler fires. and added ruff S110 to the lint config so this pattern fails CI going forward — drift fails at PR review instead of at runtime against a wedged worker thread. zero behavior change to any happy path; just made the failure paths inspectable. test suite (2188 tests) green throughout the sweep.' },
|
||||||
{ title: 'Fix: Repair Job Card "X Findings" Badge Was Misleading After Bulk-Fix', desc: 'discord report: duplicate detector card said "372 findings" and cover art filler said "60 findings", but clicking the findings tab pending filter showed 0 — read like a bug ("findings aren\'t being created"). actual cause: job-card badge displayed `last_run.findings_created` (historical "found in last scan") which doesn\'t reflect current state when those findings have since been bulk-fixed and moved to status="resolved". fix: api response now includes `pending_findings_count` per job (current pending count from a single sql aggregation). badge now shows "X pending" when pending count > 0 (urgent red color), or "X found in last scan" with a muted grey color when pending = 0 but the last scan did surface something. user can tell at a glance whether something needs review vs whether it\'s a historical reminder. 3 new tests pin the per-job pending count helper.', page: 'stats' },
|
{ title: 'Fix: Repair Job Card "X Findings" Badge Was Misleading After Bulk-Fix', desc: 'discord report: duplicate detector card said "372 findings" and cover art filler said "60 findings", but clicking the findings tab pending filter showed 0 — read like a bug ("findings aren\'t being created"). actual cause: job-card badge displayed `last_run.findings_created` (historical "found in last scan") which doesn\'t reflect current state when those findings have since been bulk-fixed and moved to status="resolved". fix: api response now includes `pending_findings_count` per job (current pending count from a single sql aggregation). badge now shows "X pending" when pending count > 0 (urgent red color), or "X found in last scan" with a muted grey color when pending = 0 but the last scan did surface something. user can tell at a glance whether something needs review vs whether it\'s a historical reminder. 3 new tests pin the per-job pending count helper.', page: 'stats' },
|
||||||
{ title: 'Fix: Downloads Stop After 2-3 Hours (slskd HTTP Timeout)', desc: 'github issue #499 (bafoed): big initial sync of spotify playlists worked for 2-3 hours then downloads silently stopped. 3 active tasks stuck in "searching" state, replaced every ~10 min by different ones, but slskd ui showed no actual searches happening. only fix was restarting the soulsync container — which would buy another 2-3 hours. root cause: `core/soulseek_client.py` constructed `aiohttp.ClientSession()` with no timeout at four sites. when slskd hung on a request (overloaded, transient network blip, internal stall), the http call blocked indefinitely — and the worker thread blocked with it. download executor only has `max_workers=3` for download workers. once 3 worker threads were wedged on hung calls, no further downloads could start. batch-level "stuck detection" (10-min) was correctly marking tasks `not_found` and trying to start replacements, but the executor pool was exhausted — replacements queued forever. fix: bounded `aiohttp.ClientTimeout` (total 120s, connect 15s, sock_read 60s) on every slskd `ClientSession` construction. legitimate metadata calls (search submission, status polls, download enqueue) finish in seconds — slskd doesn\'t stream files through these requests, so the timeout can\'t kill a real operation. when timeout fires, the request raises `asyncio.TimeoutError` which is now explicitly caught + logged + returns None to the caller (treats as a normal failure, same code path as a 5xx response). worker thread unblocks → executor pool stays healthy → downloads keep flowing. 3 new tests pin the timeout config + the `asyncio.TimeoutError` handler so future drift fails at the test boundary instead of at runtime against a wedged executor.', page: 'downloads' },
|
{ title: 'Fix: Downloads Stop After 2-3 Hours (slskd HTTP Timeout)', desc: 'github issue #499 (bafoed): big initial sync of spotify playlists worked for 2-3 hours then downloads silently stopped. 3 active tasks stuck in "searching" state, replaced every ~10 min by different ones, but slskd ui showed no actual searches happening. only fix was restarting the soulsync container — which would buy another 2-3 hours. root cause: `core/soulseek_client.py` constructed `aiohttp.ClientSession()` with no timeout at four sites. when slskd hung on a request (overloaded, transient network blip, internal stall), the http call blocked indefinitely — and the worker thread blocked with it. download executor only has `max_workers=3` for download workers. once 3 worker threads were wedged on hung calls, no further downloads could start. batch-level "stuck detection" (10-min) was correctly marking tasks `not_found` and trying to start replacements, but the executor pool was exhausted — replacements queued forever. fix: bounded `aiohttp.ClientTimeout` (total 120s, connect 15s, sock_read 60s) on every slskd `ClientSession` construction. legitimate metadata calls (search submission, status polls, download enqueue) finish in seconds — slskd doesn\'t stream files through these requests, so the timeout can\'t kill a real operation. when timeout fires, the request raises `asyncio.TimeoutError` which is now explicitly caught + logged + returns None to the caller (treats as a normal failure, same code path as a 5xx response). worker thread unblocks → executor pool stays healthy → downloads keep flowing. 3 new tests pin the timeout config + the `asyncio.TimeoutError` handler so future drift fails at the test boundary instead of at runtime against a wedged executor.', page: 'downloads' },
|
||||||
{ title: 'Fix: Library Reorganize Job Misclassified Album Tracks As Singles', desc: 'github issue #500 (bafoed): library reorganize repair job moved tracks like `Surf Curse/Surf Curse - Nothing Yet (2017)/01 - Christine F.flac` to single-template paths like `Surf Curse/Surf Curse - Christine F/Surf Curse - Christine F.flac`. root cause: the job used `is_album = (group_size > 1)` where `group_size` was the count of tracks for the same album currently sitting in the transfer folder being scanned — when only one track of an album was in transfer (rest already moved to library, or album tags varied across tracks like "Buds" vs "Buds (Bonus)"), each track became a 1-element group → all routed through single template. fix: rewrote the job to delegate to the per-album planner (`core.library_reorganize.preview_album_reorganize` / `reorganize_queue`) — the same planner the artist-detail "reorganize" modal uses. db-driven: the planner knows the album has multiple tracks regardless of how many sit in the transfer folder, so the album-vs-single classification is structurally correct. apply mode delegates to the existing reorganize queue → file move + post-processing + db update + sidecar handling all flow through one code path. only iterates albums for the ACTIVE media server (matches the artist-detail modal\'s scope) — multi-server users (plex + jellyfin etc) won\'t accidentally have the job touch the inactive server\'s files. albums missing a metadata source id get a single "needs enrichment first" finding instead of n per-track "no source" findings. dropped ~500 loc of tag-reading + transfer-walk + template logic that was duplicated against the per-album path. files in transfer with no db entry are now exclusively the orphan_file_detector\'s domain (clean separation). 12 tests pin the delegation contract.', page: 'library' },
|
{ title: 'Fix: Library Reorganize Job Misclassified Album Tracks As Singles', desc: 'github issue #500 (bafoed): library reorganize repair job moved tracks like `Surf Curse/Surf Curse - Nothing Yet (2017)/01 - Christine F.flac` to single-template paths like `Surf Curse/Surf Curse - Christine F/Surf Curse - Christine F.flac`. root cause: the job used `is_album = (group_size > 1)` where `group_size` was the count of tracks for the same album currently sitting in the transfer folder being scanned — when only one track of an album was in transfer (rest already moved to library, or album tags varied across tracks like "Buds" vs "Buds (Bonus)"), each track became a 1-element group → all routed through single template. fix: rewrote the job to delegate to the per-album planner (`core.library_reorganize.preview_album_reorganize` / `reorganize_queue`) — the same planner the artist-detail "reorganize" modal uses. db-driven: the planner knows the album has multiple tracks regardless of how many sit in the transfer folder, so the album-vs-single classification is structurally correct. apply mode delegates to the existing reorganize queue → file move + post-processing + db update + sidecar handling all flow through one code path. only iterates albums for the ACTIVE media server (matches the artist-detail modal\'s scope) — multi-server users (plex + jellyfin etc) won\'t accidentally have the job touch the inactive server\'s files. albums missing a metadata source id get a single "needs enrichment first" finding instead of n per-track "no source" findings. dropped ~500 loc of tag-reading + transfer-walk + template logic that was duplicated against the per-album path. files in transfer with no db entry are now exclusively the orphan_file_detector\'s domain (clean separation). 12 tests pin the delegation contract.', page: 'library' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue