Add sync_single_series_after_scan with NFO metadata and WebSocket updates
- Implement sync_single_series_after_scan to persist scanned series to database - Enhanced _broadcast_series_updated to include full NFO metadata (nfo_created_at, nfo_updated_at, tmdb_id, tvdb_id) - Add immediate episode scanning in add_series endpoint when background loader isn't running - Implement updateSingleSeries in frontend to handle series_updated WebSocket events - Add SERIES_UPDATED event constant to WebSocket event definitions - Update background loader to use sync_single_series_after_scan method - Simplified background loader initialization in FastAPI app - Add comprehensive tests for series update WebSocket payload and episode counting logic - Import reorganization: move get_background_loader_service to dependencies module
This commit is contained in:
@@ -15,12 +15,10 @@ from src.server.exceptions import (
|
||||
ValidationError,
|
||||
)
|
||||
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||
from src.server.services.background_loader_service import (
|
||||
BackgroundLoaderService,
|
||||
get_background_loader_service,
|
||||
)
|
||||
from src.server.services.background_loader_service import BackgroundLoaderService
|
||||
from src.server.utils.dependencies import (
|
||||
get_anime_service,
|
||||
get_background_loader_service,
|
||||
get_optional_database_session,
|
||||
get_series_app,
|
||||
require_auth,
|
||||
@@ -641,6 +639,7 @@ async def add_series(
|
||||
request: AddSeriesRequest,
|
||||
_auth: dict = Depends(require_auth),
|
||||
series_app: Any = Depends(get_series_app),
|
||||
anime_service: AnimeService = Depends(get_anime_service),
|
||||
db: Optional[AsyncSession] = Depends(get_optional_database_session),
|
||||
background_loader: BackgroundLoaderService = Depends(get_background_loader_service),
|
||||
) -> dict:
|
||||
@@ -831,8 +830,44 @@ async def add_series(
|
||||
key,
|
||||
e
|
||||
)
|
||||
|
||||
# Step F: Scan missing episodes immediately if background loader is not running
|
||||
# Uses existing SerieScanner and AnimeService sync to avoid duplicates
|
||||
try:
|
||||
loader_running = (
|
||||
background_loader.worker_task is not None
|
||||
and not background_loader.worker_task.done()
|
||||
)
|
||||
if (
|
||||
not loader_running
|
||||
and series_app
|
||||
and hasattr(series_app, "serie_scanner")
|
||||
):
|
||||
missing_episodes = series_app.serie_scanner.scan_single_series(
|
||||
key=key,
|
||||
folder=folder
|
||||
)
|
||||
total_missing = sum(
|
||||
len(eps) for eps in missing_episodes.values()
|
||||
)
|
||||
logger.info(
|
||||
"Scanned %d missing episodes for %s",
|
||||
total_missing,
|
||||
key
|
||||
)
|
||||
|
||||
# Persist scan results to database (includes episodes)
|
||||
# scan_single_series updates serie_scanner.keyDict with episodeDict
|
||||
# sync_single_series_after_scan retrieves from there and saves to DB
|
||||
await anime_service.sync_single_series_after_scan(key)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to scan missing episodes for %s: %s",
|
||||
key,
|
||||
e
|
||||
)
|
||||
|
||||
# Step F: Return immediate response (202 Accepted)
|
||||
# Step G: Return immediate response (202 Accepted)
|
||||
response = {
|
||||
"status": "success",
|
||||
"message": f"Series added successfully: {name}. Data will be loaded in background.",
|
||||
|
||||
@@ -251,17 +251,9 @@ async def lifespan(_application: FastAPI):
|
||||
logger.info("Download service initialized and queue restored")
|
||||
|
||||
# Initialize background loader service
|
||||
from src.server.services.background_loader_service import (
|
||||
init_background_loader_service,
|
||||
)
|
||||
from src.server.utils.dependencies import get_series_app
|
||||
from src.server.utils.dependencies import get_background_loader_service
|
||||
|
||||
series_app_instance = get_series_app()
|
||||
background_loader = init_background_loader_service(
|
||||
websocket_service=ws_service,
|
||||
anime_service=anime_service,
|
||||
series_app=series_app_instance
|
||||
)
|
||||
background_loader = get_background_loader_service()
|
||||
await background_loader.start()
|
||||
initialized['background_loader'] = True
|
||||
logger.info("Background loader service started")
|
||||
|
||||
@@ -658,6 +658,57 @@ class AnimeService:
|
||||
logger.exception("rescan failed")
|
||||
raise AnimeServiceError("Rescan failed") from exc
|
||||
|
||||
async def sync_single_series_after_scan(self, series_key: str) -> None:
|
||||
"""Persist a single scanned series and refresh cached state.
|
||||
|
||||
Reuses the same save/reload/cache invalidation flow as `rescan`
|
||||
to keep the database, in-memory list, and UI in sync.
|
||||
|
||||
Args:
|
||||
series_key: Series key to persist and refresh.
|
||||
"""
|
||||
# Get serie from scanner's keyDict, not series_app.list.keyDict
|
||||
# scan_single_series updates serie_scanner.keyDict with episodeDict
|
||||
if not hasattr(self._app, "serie_scanner") or not hasattr(self._app.serie_scanner, "keyDict"):
|
||||
logger.warning(
|
||||
"Serie scanner not available for single-series sync: %s",
|
||||
series_key,
|
||||
)
|
||||
return
|
||||
|
||||
serie = self._app.serie_scanner.keyDict.get(series_key)
|
||||
if not serie:
|
||||
logger.warning(
|
||||
"Series not found in scanner keyDict for single-series sync: %s",
|
||||
series_key,
|
||||
)
|
||||
return
|
||||
|
||||
total_episodes = sum(len(eps) for eps in (serie.episodeDict or {}).values())
|
||||
logger.info(
|
||||
"Syncing series %s with %d missing episodes. episodeDict: %s",
|
||||
series_key,
|
||||
total_episodes,
|
||||
serie.episodeDict
|
||||
)
|
||||
|
||||
await self._save_scan_results_to_db([serie])
|
||||
await self._load_series_from_db()
|
||||
|
||||
try:
|
||||
self._cached_list_missing.cache_clear()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
try:
|
||||
await self._broadcast_series_updated(series_key)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(
|
||||
"Failed to broadcast series update for %s: %s",
|
||||
series_key,
|
||||
exc,
|
||||
)
|
||||
|
||||
async def _save_scan_results_to_db(self, series_list: list) -> int:
|
||||
"""
|
||||
Save scan results to the database.
|
||||
@@ -684,13 +735,27 @@ class AnimeService:
|
||||
db, serie.key
|
||||
)
|
||||
|
||||
total_episodes = sum(len(eps) for eps in (serie.episodeDict or {}).values())
|
||||
|
||||
if existing:
|
||||
# Update existing series
|
||||
logger.info(
|
||||
"Updating existing series %s with %d episodes. episodeDict: %s",
|
||||
serie.key,
|
||||
total_episodes,
|
||||
serie.episodeDict
|
||||
)
|
||||
await self._update_series_in_db(
|
||||
serie, existing, db
|
||||
)
|
||||
else:
|
||||
# Create new series
|
||||
logger.info(
|
||||
"Creating new series %s with %d episodes. episodeDict: %s",
|
||||
serie.key,
|
||||
total_episodes,
|
||||
serie.episodeDict
|
||||
)
|
||||
await self._create_series_in_db(serie, db)
|
||||
|
||||
saved_count += 1
|
||||
@@ -936,17 +1001,79 @@ class AnimeService:
|
||||
return episodes_added
|
||||
|
||||
async def _broadcast_series_updated(self, series_key: str) -> None:
|
||||
"""Broadcast series update event to WebSocket clients."""
|
||||
"""Broadcast series update event to WebSocket clients with full data."""
|
||||
if not self._websocket_service:
|
||||
return
|
||||
|
||||
# Get updated series data to send to frontend
|
||||
series_data = None
|
||||
if hasattr(self._app, 'list') and hasattr(self._app.list, 'keyDict'):
|
||||
serie = self._app.list.keyDict.get(series_key)
|
||||
if serie:
|
||||
# Convert episode dict keys to strings for JSON
|
||||
missing_episodes = {str(k): v for k, v in (serie.episodeDict or {}).items()}
|
||||
total_missing = sum(len(eps) for eps in missing_episodes.values())
|
||||
|
||||
# Fetch NFO metadata from database
|
||||
has_nfo = False
|
||||
nfo_created_at = None
|
||||
nfo_updated_at = None
|
||||
tmdb_id = None
|
||||
tvdb_id = None
|
||||
|
||||
try:
|
||||
from src.server.database.connection import get_db_session
|
||||
from src.server.database.service import AnimeSeriesService
|
||||
|
||||
async with get_db_session() as db:
|
||||
db_series = await AnimeSeriesService.get_by_key(db, series_key)
|
||||
if db_series:
|
||||
has_nfo = db_series.has_nfo or False
|
||||
nfo_created_at = (
|
||||
db_series.nfo_created_at.isoformat()
|
||||
if db_series.nfo_created_at else None
|
||||
)
|
||||
nfo_updated_at = (
|
||||
db_series.nfo_updated_at.isoformat()
|
||||
if db_series.nfo_updated_at else None
|
||||
)
|
||||
tmdb_id = db_series.tmdb_id
|
||||
tvdb_id = db_series.tvdb_id
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not fetch NFO data for %s: %s",
|
||||
series_key,
|
||||
str(e)
|
||||
)
|
||||
|
||||
series_data = {
|
||||
"key": serie.key,
|
||||
"name": serie.name,
|
||||
"folder": serie.folder,
|
||||
"site": serie.site,
|
||||
"missing_episodes": missing_episodes,
|
||||
"has_missing": total_missing > 0,
|
||||
"has_nfo": has_nfo,
|
||||
"nfo_created_at": nfo_created_at,
|
||||
"nfo_updated_at": nfo_updated_at,
|
||||
"tmdb_id": tmdb_id,
|
||||
"tvdb_id": tvdb_id,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"type": "series_updated",
|
||||
"key": series_key,
|
||||
"message": "Episodes updated",
|
||||
"data": series_data,
|
||||
"message": "Series episodes updated",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
logger.info(
|
||||
"Broadcasting series update for %s with %d missing episodes",
|
||||
series_key,
|
||||
sum(len(eps) for eps in (series_data.get("missing_episodes", {}).values())) if series_data else 0
|
||||
)
|
||||
|
||||
await self._websocket_service.broadcast(payload)
|
||||
|
||||
async def add_series_to_db(
|
||||
|
||||
@@ -626,9 +626,10 @@ class BackgroundLoaderService:
|
||||
)
|
||||
|
||||
# Notify anime_service to sync episodes to database
|
||||
# Use sync_single_series_after_scan which gets data from serie_scanner.keyDict
|
||||
if self.anime_service:
|
||||
logger.debug(f"Calling anime_service.sync_episodes_to_db for {task.key}")
|
||||
await self.anime_service.sync_episodes_to_db(task.key)
|
||||
logger.debug(f"Calling anime_service.sync_single_series_after_scan for {task.key}")
|
||||
await self.anime_service.sync_single_series_after_scan(task.key)
|
||||
else:
|
||||
logger.warning(f"anime_service not available, episodes will not be synced to DB for {task.key}")
|
||||
else:
|
||||
|
||||
@@ -338,6 +338,18 @@ AniWorld.SeriesManager = (function() {
|
||||
const canBeSelected = hasMissingEpisodes;
|
||||
const hasNfo = serie.has_nfo || false;
|
||||
const isLoading = serie.loading_status && serie.loading_status !== 'completed' && serie.loading_status !== 'failed';
|
||||
|
||||
// Debug logging for troubleshooting
|
||||
if (serie.key === 'so-im-a-spider-so-what') {
|
||||
console.log('[createSerieCard] Spider series:', {
|
||||
key: serie.key,
|
||||
missing_episodes: serie.missing_episodes,
|
||||
missing_episodes_type: typeof serie.missing_episodes,
|
||||
episodeDict: serie.episodeDict,
|
||||
hasMissingEpisodes: hasMissingEpisodes,
|
||||
has_missing: serie.has_missing
|
||||
});
|
||||
}
|
||||
|
||||
return '<div class="series-card ' + (isSelected ? 'selected' : '') + ' ' +
|
||||
(hasMissingEpisodes ? 'has-missing' : 'complete') + ' ' +
|
||||
@@ -455,6 +467,76 @@ AniWorld.SeriesManager = (function() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single series from WebSocket data
|
||||
* @param {Object} updatedData - Updated series data from WebSocket
|
||||
*/
|
||||
function updateSingleSeries(updatedData) {
|
||||
if (!updatedData || !updatedData.key) {
|
||||
console.warn('Invalid series update data:', updatedData);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[updateSingleSeries] Received data:', updatedData);
|
||||
console.log('[updateSingleSeries] missing_episodes type:', typeof updatedData.missing_episodes);
|
||||
console.log('[updateSingleSeries] missing_episodes value:', updatedData.missing_episodes);
|
||||
|
||||
// Count total missing episodes from the episode dictionary
|
||||
const episodeDict = updatedData.missing_episodes || {};
|
||||
console.log('[updateSingleSeries] episodeDict:', episodeDict);
|
||||
|
||||
const totalMissing = Object.values(episodeDict).reduce(
|
||||
function(sum, episodes) {
|
||||
return sum + (Array.isArray(episodes) ? episodes.length : 0);
|
||||
},
|
||||
0
|
||||
);
|
||||
|
||||
console.log('[updateSingleSeries] totalMissing calculated:', totalMissing);
|
||||
|
||||
// Transform WebSocket data to match our internal format
|
||||
const transformedSerie = {
|
||||
key: updatedData.key,
|
||||
name: updatedData.name,
|
||||
site: updatedData.site || 'aniworld.to',
|
||||
folder: updatedData.folder,
|
||||
episodeDict: episodeDict,
|
||||
missing_episodes: totalMissing,
|
||||
has_missing: updatedData.has_missing || totalMissing > 0,
|
||||
has_nfo: updatedData.has_nfo || false,
|
||||
nfo_created_at: updatedData.nfo_created_at || null,
|
||||
nfo_updated_at: updatedData.nfo_updated_at || null,
|
||||
tmdb_id: updatedData.tmdb_id || null,
|
||||
tvdb_id: updatedData.tvdb_id || null,
|
||||
loading_status: updatedData.loading_status || 'completed',
|
||||
episodes_loaded: updatedData.episodes_loaded !== false,
|
||||
nfo_loaded: updatedData.nfo_loaded !== false,
|
||||
logo_loaded: updatedData.logo_loaded !== false,
|
||||
images_loaded: updatedData.images_loaded !== false
|
||||
};
|
||||
|
||||
console.log('[updateSingleSeries] Transformed serie:', transformedSerie);
|
||||
|
||||
// Find existing series in our data
|
||||
const existingIndex = seriesData.findIndex(function(s) {
|
||||
return s.key === updatedData.key;
|
||||
});
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing series
|
||||
seriesData[existingIndex] = transformedSerie;
|
||||
console.log('Updated existing series:', updatedData.key, transformedSerie);
|
||||
} else {
|
||||
// Add new series
|
||||
seriesData.push(transformedSerie);
|
||||
console.log('Added new series:', updatedData.key, transformedSerie);
|
||||
}
|
||||
|
||||
// Reapply filters and re-render
|
||||
applyFiltersAndSort();
|
||||
renderSeries();
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
@@ -464,6 +546,7 @@ AniWorld.SeriesManager = (function() {
|
||||
getSeriesData: getSeriesData,
|
||||
getFilteredSeriesData: getFilteredSeriesData,
|
||||
findByKey: findByKey,
|
||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus
|
||||
updateSeriesLoadingStatus: updateSeriesLoadingStatus,
|
||||
updateSingleSeries: updateSingleSeries
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -133,6 +133,22 @@ AniWorld.IndexSocketHandler = (function() {
|
||||
AniWorld.ScanManager.updateProcessStatus('download', false, true);
|
||||
});
|
||||
|
||||
// Series events
|
||||
socket.on(WS_EVENTS.SERIES_UPDATED, function(data) {
|
||||
console.log('Series updated:', data);
|
||||
|
||||
// Use the data directly to update the series instead of full refresh
|
||||
if (data && data.data && AniWorld.SeriesManager && AniWorld.SeriesManager.updateSingleSeries) {
|
||||
AniWorld.SeriesManager.updateSingleSeries(data.data);
|
||||
} else {
|
||||
// Fallback to full reload if data is incomplete
|
||||
console.warn('Incomplete series update data, falling back to full reload');
|
||||
if (AniWorld.SeriesManager && AniWorld.SeriesManager.loadSeries) {
|
||||
AniWorld.SeriesManager.loadSeries();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Series loading events
|
||||
socket.on(WS_EVENTS.SERIES_LOADING_UPDATE, function(data) {
|
||||
console.log('Series loading update:', data);
|
||||
|
||||
@@ -100,7 +100,8 @@ AniWorld.Constants = (function() {
|
||||
SCAN_ERROR: 'scan_error',
|
||||
SCAN_FAILED: 'scan_failed',
|
||||
|
||||
// Series loading events
|
||||
// Series events
|
||||
SERIES_UPDATED: 'series_updated',
|
||||
SERIES_LOADING_UPDATE: 'series_loading_update',
|
||||
|
||||
// Scheduled scan events
|
||||
|
||||
Reference in New Issue
Block a user