Compare commits

..

4 Commits

Author SHA1 Message Date
6c9605e896 chore: bump version 2026-05-26 08:58:37 +02:00
3947f6d266 refactor(scheduler): replace structlog with std logging and add extensive diagnostics
- Switch scheduler_service from structlog to standard logging for consistency
- Add detailed lifecycle logging in SchedulerService (start, stop, rescan)
- Add debug logging in fastapi_app scheduler initialization
- Fix test_add_series_episodes to mock EpisodeService.get_by_series
2026-05-26 07:51:22 +02:00
a3176f5ac1 chore: bump version 2026-05-25 21:31:46 +02:00
9a81b04b65 fix: downloaded episodes no longer appear as missing
Use the database as the authoritative source for missing-episode lists so
that episodes marked is_downloaded=True are never shown as missing, even
when the in-memory state is stale.

Key changes:
- EpisodeService.get_by_series() gains only_missing flag
- AnimeService uses DB-backed episodeDict and preserves downloaded episodes
  during sync, skipping them when adding/removing missing episodes
- DownloadService broadcasts series_updated after marking an episode downloaded
  so the frontend reflects the change immediately
- Frontend filters out series with zero missing episodes client-side and
  fixes renderSeries to respect the active filter
- Unit tests updated to assert the broadcast is sent
2026-05-25 21:30:31 +02:00
10 changed files with 252 additions and 68 deletions

View File

@@ -1 +1 @@
v1.1.13 v1.1.15

View File

@@ -1,6 +1,6 @@
{ {
"name": "aniworld-web", "name": "aniworld-web",
"version": "1.1.13", "version": "1.1.15",
"description": "Aniworld Anime Download Manager - Web Frontend", "description": "Aniworld Anime Download Manager - Web Frontend",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -541,6 +541,7 @@ class EpisodeService:
db: AsyncSession, db: AsyncSession,
series_id: int, series_id: int,
season: Optional[int] = None, season: Optional[int] = None,
only_missing: bool = False,
) -> List[Episode]: ) -> List[Episode]:
"""Get episodes for a series. """Get episodes for a series.
@@ -548,6 +549,9 @@ class EpisodeService:
db: Database session db: Database session
series_id: Foreign key to AnimeSeries series_id: Foreign key to AnimeSeries
season: Optional season filter season: Optional season filter
only_missing: If True, only return episodes where
is_downloaded is False (i.e., missing episodes).
Default False returns all episodes.
Returns: Returns:
List of Episode instances List of Episode instances
@@ -557,6 +561,9 @@ class EpisodeService:
if season is not None: if season is not None:
query = query.where(Episode.season == season) query = query.where(Episode.season == season)
if only_missing:
query = query.where(Episode.is_downloaded == False)
query = query.order_by(Episode.season, Episode.episode_number) query = query.order_by(Episode.season, Episode.episode_number)
result = await db.execute(query) result = await db.execute(query)
return list(result.scalars().all()) return list(result.scalars().all())

View File

@@ -121,7 +121,7 @@ async def _run_startup_health_checks(logger) -> dict:
import asyncio import asyncio
import shutil import shutil
import socket import socket
from typing import Dict, Any from typing import Any, Dict
checks: Dict[str, Any] = { checks: Dict[str, Any] = {
"ffmpeg": {"status": "unknown", "message": None}, "ffmpeg": {"status": "unknown", "message": None},
@@ -400,13 +400,15 @@ async def lifespan(_application: FastAPI):
# Initialize and start scheduler service # Initialize and start scheduler service
try: try:
logger.info("Initializing scheduler service...")
from src.server.services.scheduler_service import ( from src.server.services.scheduler_service import (
get_scheduler_service, get_scheduler_service,
) )
scheduler_service = get_scheduler_service() scheduler_service = get_scheduler_service()
logger.info("Scheduler service instance obtained, starting...")
await scheduler_service.start() await scheduler_service.start()
initialized['scheduler'] = True initialized['scheduler'] = True
logger.info("Scheduler service started") logger.info("Scheduler service started successfully")
except Exception as e: except Exception as e:
logger.warning("Failed to start scheduler service: %s", e) logger.warning("Failed to start scheduler service: %s", e)
# Continue - scheduler is optional # Continue - scheduler is optional

View File

@@ -498,13 +498,19 @@ class AnimeService:
logger.info("No series found in SeriesApp") logger.info("No series found in SeriesApp")
return [] return []
# Build NFO metadata map and filter data from database # Build NFO metadata map, episode dict, and filter data from database.
nfo_map = {} # Using DB as authoritative source for episodeDict ensures that
series_with_no_episodes = set() # episodes marked is_downloaded=True are never shown as missing,
# even if the in-memory state is stale.
nfo_map: dict = {}
db_episode_dict_map: dict[str, dict[int, list[int]]] = {}
series_with_no_episodes: set = set()
async with get_db_session() as db: async with get_db_session() as db:
# Get all series NFO metadata using service layer # Single query: load all series with their episodes eagerly
db_series_list = await AnimeSeriesService.get_all(db) db_series_list = await AnimeSeriesService.get_all(
db, with_episodes=True
)
for db_series in db_series_list: for db_series in db_series_list:
nfo_created = ( nfo_created = (
@@ -524,6 +530,20 @@ class AnimeService:
"series_id": db_series.id, "series_id": db_series.id,
} }
# Build episodeDict from DB, skipping is_downloaded=True
# episodes so they are never shown as missing in the UI.
ep_dict: dict[int, list[int]] = {}
if db_series.episodes:
for ep in db_series.episodes:
if ep.is_downloaded:
continue
if ep.season not in ep_dict:
ep_dict[ep.season] = []
ep_dict[ep.season].append(ep.episode_number)
for s in ep_dict:
ep_dict[s].sort()
db_episode_dict_map[db_series.folder] = ep_dict
# If filter is "missing_episodes", get series with any missing episodes # If filter is "missing_episodes", get series with any missing episodes
if filter_type == "missing_episodes": if filter_type == "missing_episodes":
series_missing = ( series_missing = (
@@ -545,7 +565,12 @@ class AnimeService:
name = getattr(serie, "name", "") name = getattr(serie, "name", "")
site = getattr(serie, "site", "") site = getattr(serie, "site", "")
folder = getattr(serie, "folder", "") folder = getattr(serie, "folder", "")
episode_dict = getattr(serie, "episodeDict", {}) or {} # Use DB-backed episodeDict (is_downloaded=True already filtered out)
# with in-memory episodeDict as fallback if the series isn't in DB yet.
episode_dict = db_episode_dict_map.get(
folder,
getattr(serie, "episodeDict", {}) or {}
)
# Apply filter if specified # Apply filter if specified
if filter_type == "missing_episodes": if filter_type == "missing_episodes":
@@ -815,18 +840,24 @@ class AnimeService:
- Adds new missing episodes that are not in the database - Adds new missing episodes that are not in the database
- Removes episodes from database that are no longer missing - Removes episodes from database that are no longer missing
(i.e., the file has been added to the filesystem) (i.e., the file has been added to the filesystem)
- Preserves episodes marked as downloaded (is_downloaded=True)
so download history is not lost
""" """
from src.server.database.service import AnimeSeriesService, EpisodeService from src.server.database.service import AnimeSeriesService, EpisodeService
# Get existing episodes from database # Get existing episodes from database (all episodes, including downloaded)
existing_episodes = await EpisodeService.get_by_series(db, existing.id) existing_episodes = await EpisodeService.get_by_series(db, existing.id)
# Build dict of existing episodes: {season: {ep_num: episode_id}} # Build dict of existing episodes: {season: {ep_num: episode_id}}
# and track which ones are already downloaded
existing_dict: dict[int, dict[int, int]] = {} existing_dict: dict[int, dict[int, int]] = {}
downloaded_set: set[tuple[int, int]] = set()
for ep in existing_episodes: for ep in existing_episodes:
if ep.season not in existing_dict: if ep.season not in existing_dict:
existing_dict[ep.season] = {} existing_dict[ep.season] = {}
existing_dict[ep.season][ep.episode_number] = ep.id existing_dict[ep.season][ep.episode_number] = ep.id
if ep.is_downloaded:
downloaded_set.add((ep.season, ep.episode_number))
# Get new missing episodes from scan # Get new missing episodes from scan
new_dict = serie.episodeDict or {} new_dict = serie.episodeDict or {}
@@ -857,9 +888,22 @@ class AnimeService:
# Remove episodes from database that are no longer missing # Remove episodes from database that are no longer missing
# (i.e., the episode file now exists on the filesystem) # (i.e., the episode file now exists on the filesystem)
# BUT: preserve episodes that are already downloaded (is_downloaded=True)
# so we don't lose download history
for season, eps_dict in existing_dict.items(): for season, eps_dict in existing_dict.items():
for ep_num, episode_id in eps_dict.items(): for ep_num, episode_id in eps_dict.items():
if (season, ep_num) not in new_missing_set: if (season, ep_num) not in new_missing_set:
# Skip already-downloaded episodes — they should stay in DB
# with is_downloaded=True to preserve download history
if (season, ep_num) in downloaded_set:
logger.debug(
"Preserving downloaded episode in database: "
"%s S%02dE%02d",
serie.key,
season,
ep_num
)
continue
await EpisodeService.delete(db, episode_id) await EpisodeService.delete(db, episode_id)
logger.info( logger.info(
"Removed episode from database (no longer missing): " "Removed episode from database (no longer missing): "
@@ -889,6 +933,10 @@ class AnimeService:
This method is called during initialization and after rescans This method is called during initialization and after rescans
to ensure the in-memory series list is in sync with the database. to ensure the in-memory series list is in sync with the database.
Only episodes where is_downloaded=False are loaded into the
in-memory episodeDict, so downloaded episodes are not shown
as missing.
""" """
from src.core.entities.series import Serie from src.core.entities.series import Serie
from src.server.database.connection import get_db_session from src.server.database.connection import get_db_session
@@ -903,9 +951,14 @@ class AnimeService:
series_list = [] series_list = []
for anime_series in anime_series_list: for anime_series in anime_series_list:
# Build episode_dict from episodes relationship # Build episode_dict from episodes relationship
# Only include episodes that are NOT downloaded (is_downloaded=False)
# so the missing-episode list stays accurate
episode_dict: dict[int, list[int]] = {} episode_dict: dict[int, list[int]] = {}
if anime_series.episodes: if anime_series.episodes:
for episode in anime_series.episodes: for episode in anime_series.episodes:
# Skip downloaded episodes — they are not missing
if episode.is_downloaded:
continue
season = episode.season season = episode.season
if season not in episode_dict: if season not in episode_dict:
episode_dict[season] = [] episode_dict[season] = []
@@ -963,23 +1016,39 @@ class AnimeService:
logger.warning("Series not found in database: %s", series_key) logger.warning("Series not found in database: %s", series_key)
return 0 return 0
# Get existing episodes from database # Get existing episodes from database (all, including downloaded)
existing_episodes = await EpisodeService.get_by_series(db, series_db.id) existing_episodes = await EpisodeService.get_by_series(db, series_db.id)
# Build dict of existing episodes: {season: {ep_num: episode_id}} # Build dict of existing episodes: {season: {ep_num: episode_id}}
# and track which ones are already downloaded
existing_dict: dict[int, dict[int, int]] = {} existing_dict: dict[int, dict[int, int]] = {}
downloaded_set: set[tuple[int, int]] = set()
for ep in existing_episodes: for ep in existing_episodes:
if ep.season not in existing_dict: if ep.season not in existing_dict:
existing_dict[ep.season] = {} existing_dict[ep.season] = {}
existing_dict[ep.season][ep.episode_number] = ep.id existing_dict[ep.season][ep.episode_number] = ep.id
if ep.is_downloaded:
downloaded_set.add((ep.season, ep.episode_number))
# Get new missing episodes from in-memory serie # Get new missing episodes from in-memory serie
new_dict = serie.episodeDict or {} new_dict = serie.episodeDict or {}
# Add new missing episodes that are not in the database # Add new missing episodes that are not in the database
# Skip episodes that are already downloaded (is_downloaded=True)
# so we don't re-add them as missing after they've been downloaded
for season, episode_numbers in new_dict.items(): for season, episode_numbers in new_dict.items():
existing_season_eps = existing_dict.get(season, {}) existing_season_eps = existing_dict.get(season, {})
for ep_num in episode_numbers: for ep_num in episode_numbers:
# Skip if already downloaded — don't re-add as missing
if (season, ep_num) in downloaded_set:
logger.debug(
"Skipping already-downloaded episode: "
"%s S%02dE%02d",
series_key,
season,
ep_num,
)
continue
if ep_num not in existing_season_eps: if ep_num not in existing_season_eps:
await EpisodeService.create( await EpisodeService.create(
db=db, db=db,
@@ -1015,20 +1084,23 @@ class AnimeService:
if hasattr(self._app, 'list') and hasattr(self._app.list, 'keyDict'): if hasattr(self._app, 'list') and hasattr(self._app.list, 'keyDict'):
serie = self._app.list.keyDict.get(series_key) serie = self._app.list.keyDict.get(series_key)
if serie: if serie:
# Convert episode dict keys to strings for JSON # Fetch NFO metadata and episodes from database.
missing_episodes = {str(k): v for k, v in (serie.episodeDict or {}).items()} # Using DB as the authoritative source for missing_episodes
total_missing = sum(len(eps) for eps in missing_episodes.values()) # ensures that episodes marked is_downloaded=True are never
# broadcast as missing, even if in-memory state is stale.
# Fetch NFO metadata from database
has_nfo = False has_nfo = False
nfo_created_at = None nfo_created_at = None
nfo_updated_at = None nfo_updated_at = None
tmdb_id = None tmdb_id = None
tvdb_id = None tvdb_id = None
missing_episodes: dict[str, list] = {}
try: try:
from src.server.database.connection import get_db_session from src.server.database.connection import get_db_session
from src.server.database.service import AnimeSeriesService from src.server.database.service import (
AnimeSeriesService,
EpisodeService,
)
async with get_db_session() as db: async with get_db_session() as db:
db_series = await AnimeSeriesService.get_by_key(db, series_key) db_series = await AnimeSeriesService.get_by_key(db, series_key)
@@ -1044,12 +1116,31 @@ class AnimeService:
) )
tmdb_id = db_series.tmdb_id tmdb_id = db_series.tmdb_id
tvdb_id = db_series.tvdb_id tvdb_id = db_series.tvdb_id
# Build missing_episodes from DB, skipping is_downloaded=True
db_episodes = await EpisodeService.get_by_series(
db, db_series.id, only_missing=True
)
for ep in db_episodes:
key_str = str(ep.season)
if key_str not in missing_episodes:
missing_episodes[key_str] = []
missing_episodes[key_str].append(ep.episode_number)
for s in missing_episodes:
missing_episodes[s].sort()
except Exception as e: except Exception as e:
logger.warning( logger.warning(
"Could not fetch NFO data for %s: %s", "Could not fetch series data for %s from DB: %s",
series_key, series_key,
str(e) str(e)
) )
# Fallback to in-memory state
missing_episodes = {
str(k): v
for k, v in (serie.episodeDict or {}).items()
}
total_missing = sum(len(eps) for eps in missing_episodes.values())
series_data = { series_data = {
"key": serie.key, "key": serie.key,

View File

@@ -275,7 +275,7 @@ class DownloadService:
""" """
try: try:
from src.server.database.connection import get_db_session from src.server.database.connection import get_db_session
from src.server.database.service import EpisodeService, AnimeSeriesService from src.server.database.service import AnimeSeriesService, EpisodeService
logger.info( logger.info(
"Attempting to mark episode as downloaded in DB: " "Attempting to mark episode as downloaded in DB: "
@@ -362,6 +362,31 @@ class DownloadService:
except Exception: except Exception:
pass pass
# Broadcast real-time update to frontend so the series card
# immediately reflects the new downloaded state (no longer
# shows the episode as missing) without waiting for a full
# reload on DOWNLOAD_COMPLETED.
try:
await self._anime_service._broadcast_series_updated(
series_key
)
logger.debug(
"Broadcast series_updated after marking "
"%s S%02dE%02d as downloaded",
series_key,
season,
episode,
)
except Exception as broadcast_exc:
logger.warning(
"Failed to broadcast series update after marking "
"%s S%02dE%02d as downloaded: %s",
series_key,
season,
episode,
broadcast_exc,
)
return True return True
except Exception as e: except Exception as e:
logger.error( logger.error(

View File

@@ -10,10 +10,10 @@ cron time), the job is triggered immediately within a grace period.
""" """
from __future__ import annotations from __future__ import annotations
import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import List, Optional from typing import List, Optional
import structlog
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
@@ -21,7 +21,7 @@ from apscheduler.triggers.cron import CronTrigger
from src.server.models.config import SchedulerConfig from src.server.models.config import SchedulerConfig
from src.server.services.config_service import ConfigServiceError, get_config_service from src.server.services.config_service import ConfigServiceError, get_config_service
logger = structlog.get_logger(__name__) logger = logging.getLogger(__name__)
_JOB_ID = "scheduled_rescan" _JOB_ID = "scheduled_rescan"
@@ -69,15 +69,18 @@ class SchedulerService:
SchedulerServiceError: If the scheduler is already running or SchedulerServiceError: If the scheduler is already running or
config cannot be loaded. config cannot be loaded.
""" """
logger.info("SchedulerService.start() called")
if self._is_running: if self._is_running:
logger.warning("Scheduler start called but already running")
raise SchedulerServiceError("Scheduler is already running") raise SchedulerServiceError("Scheduler is already running")
try: try:
config_service = get_config_service() config_service = get_config_service()
config = config_service.load_config() config = config_service.load_config()
self._config = config.scheduler self._config = config.scheduler
logger.info("Scheduler config loaded successfully")
except ConfigServiceError as exc: except ConfigServiceError as exc:
logger.error("Failed to load scheduler configuration", error=str(exc)) logger.error("Failed to load scheduler configuration: %s", exc)
raise SchedulerServiceError(f"Failed to load config: {exc}") from exc raise SchedulerServiceError(f"Failed to load config: {exc}") from exc
jobstores = { jobstores = {
@@ -90,6 +93,15 @@ class SchedulerService:
self._is_running = True self._is_running = True
return return
logger.info(
"Scheduler config loaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
self._config.enabled,
self._config.schedule_time,
self._config.schedule_days,
self._config.auto_download_after_rescan,
self._config.folder_scan_enabled,
)
trigger = self._build_cron_trigger() trigger = self._build_cron_trigger()
if trigger is None: if trigger is None:
logger.warning( logger.warning(
@@ -105,9 +117,9 @@ class SchedulerService:
coalesce=True, coalesce=True,
) )
logger.info( logger.info(
"Scheduler started with cron trigger", "Scheduler started with cron trigger: time=%s days=%s",
schedule_time=self._config.schedule_time, self._config.schedule_time,
schedule_days=self._config.schedule_days, self._config.schedule_days,
) )
self._scheduler.start() self._scheduler.start()
@@ -121,12 +133,13 @@ class SchedulerService:
if job: if job:
next_run = job.next_run_time next_run = job.next_run_time
logger.info( logger.info(
"Scheduler next run", "Scheduler next run: %s",
next_run=next_run.isoformat() if next_run else None, next_run.isoformat() if next_run else None,
) )
async def stop(self) -> None: async def stop(self) -> None:
"""Stop the APScheduler gracefully.""" """Stop the APScheduler gracefully."""
logger.info("SchedulerService.stop() called")
if not self._is_running: if not self._is_running:
logger.debug("Scheduler stop called but not running") logger.debug("Scheduler stop called but not running")
return return
@@ -134,8 +147,11 @@ class SchedulerService:
if self._scheduler and self._scheduler.running: if self._scheduler and self._scheduler.running:
self._scheduler.shutdown(wait=False) self._scheduler.shutdown(wait=False)
logger.info("Scheduler stopped") logger.info("Scheduler stopped")
else:
logger.info("Scheduler stop: scheduler was not running")
self._is_running = False self._is_running = False
logger.info("SchedulerService stopped successfully")
async def trigger_rescan(self) -> bool: async def trigger_rescan(self) -> bool:
"""Manually trigger a library rescan. """Manually trigger a library rescan.
@@ -168,12 +184,12 @@ class SchedulerService:
""" """
self._config = config self._config = config
logger.info( logger.info(
"Scheduler config reloaded", "Scheduler config reloaded: enabled=%s time=%s days=%s auto_download=%s folder_scan=%s",
enabled=config.enabled, config.enabled,
schedule_time=config.schedule_time, config.schedule_time,
schedule_days=config.schedule_days, config.schedule_days,
auto_download=config.auto_download_after_rescan, config.auto_download_after_rescan,
folder_scan=config.folder_scan_enabled, config.folder_scan_enabled,
) )
if not self._scheduler or not self._scheduler.running: if not self._scheduler or not self._scheduler.running:
@@ -194,9 +210,9 @@ class SchedulerService:
if self._scheduler.get_job(_JOB_ID): if self._scheduler.get_job(_JOB_ID):
self._scheduler.reschedule_job(_JOB_ID, trigger=trigger) self._scheduler.reschedule_job(_JOB_ID, trigger=trigger)
logger.info( logger.info(
"Scheduler rescheduled with cron trigger", "Scheduler rescheduled with cron trigger: time=%s days=%s",
schedule_time=config.schedule_time, config.schedule_time,
schedule_days=config.schedule_days, config.schedule_days,
) )
else: else:
self._scheduler.add_job( self._scheduler.add_job(
@@ -208,9 +224,9 @@ class SchedulerService:
coalesce=True, coalesce=True,
) )
logger.info( logger.info(
"Scheduler job added with cron trigger", "Scheduler job added with cron trigger: time=%s days=%s",
schedule_time=config.schedule_time, config.schedule_time,
schedule_days=config.schedule_days, config.schedule_days,
) )
def get_status(self) -> dict: def get_status(self) -> dict:
@@ -264,10 +280,10 @@ class SchedulerService:
day_of_week=day_of_week, day_of_week=day_of_week,
) )
logger.debug( logger.debug(
"CronTrigger built", "CronTrigger built: hour=%s minute=%s day_of_week=%s",
hour=hour_str, hour_str,
minute=minute_str, minute_str,
day_of_week=day_of_week, day_of_week,
) )
return trigger return trigger
@@ -281,7 +297,7 @@ class SchedulerService:
ws_service = get_websocket_service() ws_service = get_websocket_service()
await ws_service.manager.broadcast({"type": event_type, "data": data}) await ws_service.manager.broadcast({"type": event_type, "data": data})
except Exception as exc: # pylint: disable=broad-exception-caught except Exception as exc: # pylint: disable=broad-exception-caught
logger.warning("WebSocket broadcast failed", event=event_type, error=str(exc)) logger.warning("WebSocket broadcast failed: event=%s error=%s", event_type, exc)
async def _auto_download_missing(self) -> None: async def _auto_download_missing(self) -> None:
"""Queue and start downloads for all series with missing episodes.""" """Queue and start downloads for all series with missing episodes."""
@@ -299,9 +315,9 @@ class SchedulerService:
elapsed = now - self._last_auto_download_time elapsed = now - self._last_auto_download_time
if elapsed < timedelta(seconds=self._auto_download_cooldown_seconds): if elapsed < timedelta(seconds=self._auto_download_cooldown_seconds):
logger.debug( logger.debug(
"Auto-download skipped: cooldown active", "Auto-download skipped: cooldown active (elapsed=%.1fs cooldown=%ds)",
elapsed_seconds=elapsed.total_seconds(), elapsed.total_seconds(),
cooldown_seconds=self._auto_download_cooldown_seconds, self._auto_download_cooldown_seconds,
) )
return return
@@ -334,30 +350,31 @@ class SchedulerService:
) )
queued_count += len(episodes) queued_count += len(episodes)
logger.info( logger.info(
"Auto-download queued episodes", "Auto-download queued episodes for series=%s count=%d",
series=series.get("key"), series.get("key"),
count=len(episodes), len(episodes),
) )
if queued_count: if queued_count:
await download_service.start_queue_processing() await download_service.start_queue_processing()
logger.info("Auto-download queue processing started", queued=queued_count) logger.info("Auto-download queue processing started: queued=%d", queued_count)
await self._broadcast("auto_download_started", {"queued_count": queued_count}) await self._broadcast("auto_download_started", {"queued_count": queued_count})
logger.info("Auto-download completed", queued_count=queued_count) logger.info("Auto-download completed: queued_count=%d", queued_count)
# Update cooldown timestamp after successful auto-download # Update cooldown timestamp after successful auto-download
self._last_auto_download_time = datetime.now(timezone.utc) self._last_auto_download_time = datetime.now(timezone.utc)
async def _perform_rescan(self) -> None: async def _perform_rescan(self) -> None:
"""Execute a library rescan and optionally trigger auto-download.""" """Execute a library rescan and optionally trigger auto-download."""
logger.info("Scheduler _perform_rescan entered", scan_in_progress=self._scan_in_progress) logger.info("Scheduler _perform_rescan entered: scan_in_progress=%s", self._scan_in_progress)
if self._scan_in_progress: if self._scan_in_progress:
logger.warning("Skipping rescan: previous scan still in progress") logger.warning("Skipping rescan: previous scan still in progress")
return return
self._scan_in_progress = True self._scan_in_progress = True
scan_start = datetime.now(timezone.utc) scan_start = datetime.now(timezone.utc)
logger.info("Scheduled rescan started at %s", scan_start.isoformat())
try: try:
logger.info("Starting scheduled library rescan") logger.info("Starting scheduled library rescan")
@@ -365,18 +382,20 @@ class SchedulerService:
from src.server.utils.dependencies import get_anime_service # noqa: PLC0415 from src.server.utils.dependencies import get_anime_service # noqa: PLC0415
anime_service = get_anime_service() anime_service = get_anime_service()
logger.info("Anime service obtained for rescan")
await self._broadcast( await self._broadcast(
"scheduled_rescan_started", "scheduled_rescan_started",
{"timestamp": scan_start.isoformat()}, {"timestamp": scan_start.isoformat()},
) )
logger.info("Calling anime_service.rescan()...")
await anime_service.rescan() await anime_service.rescan()
self._last_scan_time = datetime.now(timezone.utc) self._last_scan_time = datetime.now(timezone.utc)
duration = (self._last_scan_time - scan_start).total_seconds() duration = (self._last_scan_time - scan_start).total_seconds()
logger.info("Scheduled library rescan completed", duration_seconds=duration) logger.info("Scheduled library rescan completed: duration=%.2fs", duration)
await self._broadcast( await self._broadcast(
"scheduled_rescan_completed", "scheduled_rescan_completed",
@@ -393,8 +412,8 @@ class SchedulerService:
await self._auto_download_missing() await self._auto_download_missing()
except Exception as dl_exc: # pylint: disable=broad-exception-caught except Exception as dl_exc: # pylint: disable=broad-exception-caught
logger.error( logger.error(
"Auto-download after rescan failed", "Auto-download after rescan failed: %s",
error=str(dl_exc), dl_exc,
exc_info=True, exc_info=True,
) )
await self._broadcast( await self._broadcast(
@@ -413,10 +432,11 @@ class SchedulerService:
folder_scan_service = FolderScanService() folder_scan_service = FolderScanService()
await folder_scan_service.run_folder_scan() await folder_scan_service.run_folder_scan()
logger.info("Folder scan completed successfully")
except Exception as fs_exc: # pylint: disable=broad-exception-caught except Exception as fs_exc: # pylint: disable=broad-exception-caught
logger.error( logger.error(
"Folder scan failed", "Folder scan failed: %s",
error=str(fs_exc), fs_exc,
exc_info=True, exc_info=True,
) )
await self._broadcast( await self._broadcast(
@@ -426,7 +446,7 @@ class SchedulerService:
logger.debug("Folder scan is disabled — skipping") logger.debug("Folder scan is disabled — skipping")
except Exception as exc: # pylint: disable=broad-exception-caught except Exception as exc: # pylint: disable=broad-exception-caught
logger.error("Scheduled rescan failed", error=str(exc), exc_info=True) logger.error("Scheduled rescan failed: %s", exc, exc_info=True)
await self._broadcast( await self._broadcast(
"scheduled_rescan_error", "scheduled_rescan_error",
{"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()}, {"error": str(exc), "timestamp": datetime.now(timezone.utc).isoformat()},
@@ -434,6 +454,7 @@ class SchedulerService:
finally: finally:
self._scan_in_progress = False self._scan_in_progress = False
logger.info("Scheduled rescan finished: scan_in_progress reset to False")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -446,9 +467,14 @@ class SchedulerService:
async def _run_rescan_job() -> None: async def _run_rescan_job() -> None:
"""Module-level job entry point — delegates to the current service.""" """Module-level job entry point — delegates to the current service."""
logger.info("=" * 60)
logger.info("APScheduler triggered _run_rescan_job") logger.info("APScheduler triggered _run_rescan_job")
logger.info("Getting scheduler service singleton...")
svc = get_scheduler_service() svc = get_scheduler_service()
logger.info("Scheduler service obtained, calling _perform_rescan()")
await svc._perform_rescan() await svc._perform_rescan()
logger.info("_run_rescan_job completed")
logger.info("=" * 60)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -462,7 +488,10 @@ def get_scheduler_service() -> SchedulerService:
"""Return the singleton SchedulerService instance.""" """Return the singleton SchedulerService instance."""
global _scheduler_service global _scheduler_service
if _scheduler_service is None: if _scheduler_service is None:
logger.info("Creating new SchedulerService singleton")
_scheduler_service = SchedulerService() _scheduler_service = SchedulerService()
else:
logger.debug("Returning existing SchedulerService singleton")
return _scheduler_service return _scheduler_service

View File

@@ -203,6 +203,17 @@ AniWorld.SeriesManager = (function() {
function applyFiltersAndSort() { function applyFiltersAndSort() {
let filtered = seriesData.slice(); let filtered = seriesData.slice();
// Apply client-side filter so that real-time WebSocket updates
// (e.g. an episode being marked downloaded) are immediately
// reflected without a full server reload.
if (filterMode === 'missing_episodes') {
filtered = filtered.filter(function(s) {
return s.missing_episodes > 0;
});
}
// 'no_episodes' filter state is maintained server-side;
// don't try to replicate it client-side here.
// Sort based on the current sorting mode // Sort based on the current sorting mode
filtered.sort(function(a, b) { filtered.sort(function(a, b) {
if (sortAlphabetical) { if (sortAlphabetical) {
@@ -233,8 +244,12 @@ AniWorld.SeriesManager = (function() {
*/ */
function renderSeries() { function renderSeries() {
const grid = document.getElementById('series-grid'); const grid = document.getElementById('series-grid');
const dataToRender = filteredSeriesData.length > 0 ? filteredSeriesData : // Always use filteredSeriesData — applyFiltersAndSort() is always
(seriesData.length > 0 ? seriesData : []); // called before renderSeries(), so filteredSeriesData is current.
// The old fallback to seriesData was incorrect: when a filter is
// active and filteredSeriesData is empty it must show the empty-state
// message, not fall through to unfiltered seriesData.
const dataToRender = filteredSeriesData;
if (dataToRender.length === 0) { if (dataToRender.length === 0) {
let message; let message;

View File

@@ -392,6 +392,7 @@ class TestAddSeriesWithEpisodes:
nfo_created_at=datetime(2024, 1, 1, 12, 0, 0), nfo_created_at=datetime(2024, 1, 1, 12, 0, 0),
nfo_updated_at=datetime(2024, 1, 2, 12, 0, 0) nfo_updated_at=datetime(2024, 1, 2, 12, 0, 0)
) )
mock_db_series.id = 1
# Create service with mocked WebSocket # Create service with mocked WebSocket
anime_service = AnimeService(mock_series_app) anime_service = AnimeService(mock_series_app)
@@ -403,12 +404,21 @@ class TestAddSeriesWithEpisodes:
mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session) mock_db_session.__aenter__ = AsyncMock(return_value=mock_db_session)
mock_db_session.__aexit__ = AsyncMock() mock_db_session.__aexit__ = AsyncMock()
# Mock episodes that match the in-memory episodeDict
mock_episodes = [
MagicMock(season=1, episode_number=1),
MagicMock(season=1, episode_number=2),
MagicMock(season=1, episode_number=3),
]
with patch('src.server.database.connection.get_db_session', return_value=mock_db_session): with patch('src.server.database.connection.get_db_session', return_value=mock_db_session):
with patch('src.server.database.service.AnimeSeriesService') as MockAnimeSeriesService: with patch('src.server.database.service.AnimeSeriesService') as MockAnimeSeriesService:
MockAnimeSeriesService.get_by_key = AsyncMock(return_value=mock_db_series) MockAnimeSeriesService.get_by_key = AsyncMock(return_value=mock_db_series)
with patch('src.server.database.service.EpisodeService') as MockEpisodeService:
MockEpisodeService.get_by_series = AsyncMock(return_value=mock_episodes)
# Act # Act
await anime_service._broadcast_series_updated(key) await anime_service._broadcast_series_updated(key)
# Assert # Assert
mock_websocket.broadcast.assert_called_once() mock_websocket.broadcast.assert_called_once()

View File

@@ -101,6 +101,7 @@ def mock_anime_service():
service = MagicMock(spec=AnimeService) service = MagicMock(spec=AnimeService)
service.download = AsyncMock(return_value=True) service.download = AsyncMock(return_value=True)
service._directory = "/mock/anime/directory" service._directory = "/mock/anime/directory"
service._broadcast_series_updated = AsyncMock(return_value=None)
return service return service
@@ -848,6 +849,10 @@ class TestRemoveEpisodeFromMissingList:
assert serie.episodeDict[1] == [1, 3] assert serie.episodeDict[1] == [1, 3]
# Cache was cleared # Cache was cleared
download_service._anime_service._cached_list_missing.cache_clear.assert_called() download_service._anime_service._cached_list_missing.cache_clear.assert_called()
# Broadcast was sent so frontend gets real-time update
download_service._anime_service._broadcast_series_updated.assert_awaited_once_with(
"test-series"
)
assert result is True assert result is True
@pytest.mark.asyncio @pytest.mark.asyncio