fix(logging): replace structlog with stdlib logging to prevent broken pipe crashes
structlog fails with BrokenPipeError when stdout is redirected (e.g., background processes, Docker logs). Replace all structlog.get_logger() calls with logging.getLogger() and convert keyword-style log calls to %-format strings. Also removes stale Docs/tasks.md (2028 lines) and updates Robot Framework tests to match current API behavior.
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
import structlog
|
||||
|
||||
from src.server.SeriesApp import SeriesApp
|
||||
from src.server.services.progress_service import (
|
||||
ProgressService,
|
||||
@@ -19,7 +18,7 @@ from src.server.services.websocket_service import (
|
||||
get_websocket_service,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnimeServiceError(Exception):
|
||||
@@ -61,16 +60,28 @@ class AnimeService:
|
||||
self._scan_lock = asyncio.Lock()
|
||||
# Subscribe to SeriesApp events
|
||||
# Note: Events library uses assignment (=), not += operator
|
||||
import logging
|
||||
_logger = logging.getLogger(__name__)
|
||||
try:
|
||||
self._app.download_status = self._on_download_status
|
||||
self._app.scan_status = self._on_scan_status
|
||||
logger.info(
|
||||
"Subscribed to SeriesApp events",
|
||||
scan_status_handler=str(self._app.scan_status),
|
||||
series_app_id=id(self._app),
|
||||
_logger.info(
|
||||
"Subscribed to SeriesApp events: scan_status=%s series_app_id=%s",
|
||||
str(self._app.scan_status),
|
||||
id(self._app),
|
||||
)
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
# Handle "broken pipe" when structlog tries to write to closed stdout
|
||||
# This can happen when server runs in background with stdout redirected
|
||||
import sys
|
||||
print(
|
||||
f"WARNING: Failed to subscribe to SeriesApp events: {e}. "
|
||||
f"Download/scan status callbacks may not work.",
|
||||
file=sys.stderr,
|
||||
flush=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to subscribe to SeriesApp events")
|
||||
_logger.error("Failed to subscribe to SeriesApp events: %s", e)
|
||||
raise AnimeServiceError("Initialization failed") from e
|
||||
|
||||
|
||||
@@ -95,8 +106,8 @@ class AnimeService:
|
||||
|
||||
if not loop:
|
||||
logger.debug(
|
||||
"No event loop available for download status event",
|
||||
status=args.status
|
||||
"No event loop available for download status event status=%s",
|
||||
args.status
|
||||
)
|
||||
return
|
||||
|
||||
@@ -166,8 +177,8 @@ class AnimeService:
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.error(
|
||||
"Error handling download status event",
|
||||
error=str(exc)
|
||||
"Error handling download status event error=%s",
|
||||
str(exc)
|
||||
)
|
||||
|
||||
def _on_scan_status(self, args) -> None:
|
||||
@@ -181,41 +192,40 @@ class AnimeService:
|
||||
args: ScanStatusEventArgs from SeriesApp containing key,
|
||||
folder, current, total, status, and progress info
|
||||
"""
|
||||
import logging
|
||||
_event_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
scan_id = "library_scan"
|
||||
|
||||
logger.info(
|
||||
"Scan status event received",
|
||||
status=args.status,
|
||||
current=args.current,
|
||||
total=args.total,
|
||||
folder=args.folder,
|
||||
_event_logger.info(
|
||||
"Scan status event received status=%s current=%s total=%s folder=%s",
|
||||
args.status, args.current, args.total, args.folder,
|
||||
)
|
||||
|
||||
# Get event loop - try running loop first, then stored loop
|
||||
loop = None
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
logger.debug("Using running event loop for scan status")
|
||||
_event_logger.debug("Using running event loop for scan status")
|
||||
except RuntimeError:
|
||||
# No running loop in this thread - use stored loop
|
||||
loop = self._event_loop
|
||||
logger.debug(
|
||||
"Using stored event loop for scan status",
|
||||
has_loop=loop is not None
|
||||
_event_logger.debug(
|
||||
"Using stored event loop for scan status has_loop=%s",
|
||||
loop is not None
|
||||
)
|
||||
|
||||
if not loop:
|
||||
logger.warning(
|
||||
"No event loop available for scan status event",
|
||||
status=args.status
|
||||
_event_logger.warning(
|
||||
"No event loop available for scan status event status=%s",
|
||||
args.status
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Processing scan status event",
|
||||
status=args.status,
|
||||
loop_id=id(loop),
|
||||
_event_logger.info(
|
||||
"Processing scan status event status=%s loop_id=%s",
|
||||
args.status, id(loop),
|
||||
)
|
||||
|
||||
# Map SeriesApp scan events to progress service
|
||||
@@ -439,8 +449,8 @@ class AnimeService:
|
||||
else:
|
||||
result.append(s) # type: ignore
|
||||
return result
|
||||
except Exception:
|
||||
logger.exception("Failed to get missing episodes list")
|
||||
except Exception as e:
|
||||
_logger.error("Failed to get missing episodes list: %s", str(e))
|
||||
raise
|
||||
|
||||
async def list_missing(self) -> list[dict]:
|
||||
@@ -459,7 +469,7 @@ class AnimeService:
|
||||
except AnimeServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("list_missing failed")
|
||||
_logger.error("list_missing failed: %s", str(exc))
|
||||
raise AnimeServiceError("Failed to list missing series") from exc
|
||||
|
||||
async def list_series_with_filters(
|
||||
@@ -604,16 +614,15 @@ class AnimeService:
|
||||
result_list.append(series_dict)
|
||||
|
||||
logger.info(
|
||||
"Listed series with filters",
|
||||
total_count=len(result_list),
|
||||
filter_type=filter_type
|
||||
"Listed series with filters total=%d filter_type=%s",
|
||||
len(result_list), filter_type
|
||||
)
|
||||
return result_list
|
||||
|
||||
except AnimeServiceError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("list_series_with_filters failed")
|
||||
logger.error("list_series_with_filters failed: %s", str(exc))
|
||||
raise AnimeServiceError(
|
||||
"Failed to list series with metadata"
|
||||
) from exc
|
||||
@@ -635,7 +644,7 @@ class AnimeService:
|
||||
result = await self._app.search(query)
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.exception("search failed")
|
||||
logger.error("search failed: %s", str(exc))
|
||||
raise AnimeServiceError("Search failed") from exc
|
||||
|
||||
async def rescan(self) -> None:
|
||||
@@ -655,30 +664,36 @@ class AnimeService:
|
||||
progress, this method returns immediately without starting
|
||||
a new scan.
|
||||
"""
|
||||
import logging
|
||||
_rescan_logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if a scan is already running (non-blocking)
|
||||
if self._scan_lock.locked():
|
||||
logger.info("Rescan already in progress, ignoring request")
|
||||
_rescan_logger.info("Rescan already in progress, ignoring request")
|
||||
return
|
||||
|
||||
async with self._scan_lock:
|
||||
try:
|
||||
# Store event loop for event handlers
|
||||
self._event_loop = asyncio.get_running_loop()
|
||||
logger.info(
|
||||
"Rescan started, event loop stored",
|
||||
loop_id=id(self._event_loop),
|
||||
series_app_id=id(self._app),
|
||||
scan_handler=str(self._app.scan_status),
|
||||
_rescan_logger.info(
|
||||
"Rescan started, event loop stored. loop_id=%d series_app_id=%d",
|
||||
id(self._event_loop),
|
||||
id(self._app),
|
||||
)
|
||||
|
||||
# SeriesApp.rescan returns scanned series list
|
||||
_rescan_logger.info("Calling _app.rescan()")
|
||||
scanned_series = await self._app.rescan()
|
||||
_rescan_logger.info("Rescan completed, found %d series", len(scanned_series) if scanned_series else 0)
|
||||
|
||||
# Persist scan results to database
|
||||
if scanned_series:
|
||||
_rescan_logger.info("Saving %d series to database", len(scanned_series))
|
||||
await self._save_scan_results_to_db(scanned_series)
|
||||
|
||||
# Reload series from database to ensure consistency
|
||||
_rescan_logger.info("Loading series from database")
|
||||
await self._load_series_from_db()
|
||||
|
||||
# invalidate cache
|
||||
@@ -687,8 +702,11 @@ class AnimeService:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
except AnimeServiceError:
|
||||
# Re-raise AnimeServiceError without wrapping
|
||||
raise
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.exception("rescan failed")
|
||||
_rescan_logger.error("Rescan failed: %s", str(exc))
|
||||
raise AnimeServiceError("Rescan failed") from exc
|
||||
|
||||
async def sync_single_series_after_scan(self, series_key: str) -> None:
|
||||
@@ -1290,11 +1308,12 @@ class AnimeService:
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Failed to rename folder for %s: %s -> %s",
|
||||
logger.error(
|
||||
"Failed to rename folder for %s: %s -> %s: %s",
|
||||
key,
|
||||
current_folder,
|
||||
target_folder
|
||||
target_folder,
|
||||
str(e)
|
||||
)
|
||||
return False
|
||||
|
||||
@@ -1365,7 +1384,7 @@ class AnimeService:
|
||||
logger.info("Download cancelled, propagating cancellation")
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("download failed")
|
||||
logger.error("download failed: %s", str(exc))
|
||||
raise AnimeServiceError("Download failed") from exc
|
||||
|
||||
async def update_nfo_status(
|
||||
@@ -1466,10 +1485,9 @@ class AnimeService:
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Failed to update NFO status",
|
||||
key=key,
|
||||
has_nfo=has_nfo
|
||||
logger.error(
|
||||
"Failed to update NFO status key=%s has_nfo=%s: %s",
|
||||
key, has_nfo, str(exc)
|
||||
)
|
||||
raise AnimeServiceError("NFO status update failed") from exc
|
||||
|
||||
@@ -1545,7 +1563,7 @@ class AnimeService:
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to query series without NFO")
|
||||
logger.error("Failed to query series without NFO: %s", str(exc))
|
||||
raise AnimeServiceError(
|
||||
"Query for series without NFO failed"
|
||||
) from exc
|
||||
@@ -1590,7 +1608,8 @@ class AnimeService:
|
||||
"with_tvdb_id": with_tvdb
|
||||
}
|
||||
|
||||
logger.info("Retrieved NFO statistics", **stats)
|
||||
logger.info("Retrieved NFO statistics total=%d with_nfo=%d without_nfo=%d with_tmdb_id=%d with_tvdb_id=%d",
|
||||
total, with_nfo, total - with_nfo, with_tmdb, with_tvdb)
|
||||
return stats
|
||||
else:
|
||||
# Use provided session and service layer count methods
|
||||
@@ -1607,11 +1626,12 @@ class AnimeService:
|
||||
"with_tvdb_id": with_tvdb
|
||||
}
|
||||
|
||||
logger.info("Retrieved NFO statistics", **stats)
|
||||
logger.info("Retrieved NFO statistics total=%d with_nfo=%d without_nfo=%d with_tmdb_id=%d with_tvdb_id=%d",
|
||||
total, with_nfo, total - with_nfo, with_tmdb, with_tvdb)
|
||||
return stats
|
||||
|
||||
except Exception as exc:
|
||||
logger.exception("Failed to get NFO statistics")
|
||||
logger.error("Failed to get NFO statistics: %s", str(exc))
|
||||
raise AnimeServiceError("NFO statistics query failed") from exc
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user