Compare commits
5 Commits
288b03cbb4
...
4f61ded92a
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f61ded92a | |||
| d6082b5cf6 | |||
| e76cd3a708 | |||
| 08f7f7453c | |||
| 023ddd182f |
@@ -1 +1 @@
|
|||||||
v1.4.14
|
v1.4.15
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aniworld-web",
|
"name": "aniworld-web",
|
||||||
"version": "1.4.14",
|
"version": "1.4.15",
|
||||||
"description": "Aniworld Anime Download Manager - Web Frontend",
|
"description": "Aniworld Anime Download Manager - Web Frontend",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -279,30 +279,15 @@ async def update_directory(
|
|||||||
|
|
||||||
config_service.save_config(app_config)
|
config_service.save_config(app_config)
|
||||||
|
|
||||||
# Sync series from data files to database
|
# Series are now loaded directly from database, no sync needed
|
||||||
sync_count = 0
|
logger.info(
|
||||||
try:
|
"Directory updated successfully",
|
||||||
import structlog
|
directory=directory
|
||||||
|
)
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
|
||||||
logger = structlog.get_logger(__name__)
|
|
||||||
sync_count = await sync_legacy_series_to_db(directory, logger)
|
|
||||||
logger.info(
|
|
||||||
"Directory updated: synced series from data files",
|
|
||||||
directory=directory,
|
|
||||||
count=sync_count
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
# Log but don't fail the directory update if sync fails
|
|
||||||
import structlog
|
|
||||||
structlog.get_logger(__name__).warning(
|
|
||||||
"Failed to sync series after directory update",
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
|
|
||||||
response: Dict[str, Any] = {
|
response: Dict[str, Any] = {
|
||||||
"message": "Anime directory updated successfully",
|
"message": "Anime directory updated successfully",
|
||||||
"synced_series": sync_count
|
"synced_series": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -422,3 +422,32 @@ class TMDBClient:
|
|||||||
if expired_keys:
|
if expired_keys:
|
||||||
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
logger.debug("Removed %d expired negative cache entries", len(expired_keys))
|
||||||
return len(expired_keys)
|
return len(expired_keys)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tmdb_client() -> TMDBClient:
|
||||||
|
"""Factory function to create a TMDBClient with settings configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TMDBClient instance configured with settings.tmdb_api_key
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If TMDB API key is not configured
|
||||||
|
"""
|
||||||
|
from src.config.settings import settings
|
||||||
|
|
||||||
|
if not settings.tmdb_api_key:
|
||||||
|
raise ValueError("TMDB API key is not configured")
|
||||||
|
|
||||||
|
return TMDBClient(api_key=settings.tmdb_api_key)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tmdb_image_base_url(tmdb_id: int) -> str:
|
||||||
|
"""Get the base URL for TMDB images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tmdb_id: TMDB show ID (used for account-specific URLs)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Base URL string for TMDB images
|
||||||
|
"""
|
||||||
|
return "https://image.tmdb.org/t/p/"
|
||||||
|
|||||||
@@ -1618,139 +1618,3 @@ class AnimeService:
|
|||||||
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
def get_anime_service(series_app: SeriesApp) -> AnimeService:
|
||||||
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
"""Factory used for creating AnimeService with a SeriesApp instance."""
|
||||||
return AnimeService(series_app)
|
return AnimeService(series_app)
|
||||||
|
|
||||||
|
|
||||||
async def sync_legacy_series_to_db(
|
|
||||||
anime_directory: str,
|
|
||||||
log_instance=None # pylint: disable=unused-argument
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
One-time legacy sync: import any series from 'data' files
|
|
||||||
not already in the database.
|
|
||||||
|
|
||||||
Deprecated: Series are now loaded directly from the database.
|
|
||||||
This function remains for backwards compatibility with legacy
|
|
||||||
file-based data during migration.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
anime_directory: Path to the anime directory with data files
|
|
||||||
log_instance: Optional logger instance (unused, kept for API
|
|
||||||
compatibility). This function always uses structlog internally.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of new series added to the database
|
|
||||||
"""
|
|
||||||
# Always use structlog for structured logging with keyword arguments
|
|
||||||
log = structlog.get_logger(__name__)
|
|
||||||
|
|
||||||
log.warning(
|
|
||||||
"sync_legacy_series_to_db is deprecated. "
|
|
||||||
"Series are now loaded directly from database."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from src.server.database.connection import get_db_session
|
|
||||||
from src.server.database.service import AnimeSeriesService, EpisodeService
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Starting data file to database sync",
|
|
||||||
directory=anime_directory
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get all series from data files using SeriesApp
|
|
||||||
series_app = SeriesApp(anime_directory)
|
|
||||||
all_series = await asyncio.to_thread(
|
|
||||||
series_app.get_all_series_from_data_files
|
|
||||||
)
|
|
||||||
|
|
||||||
if not all_series:
|
|
||||||
log.info("No series found in data files to sync")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Found series in data files, syncing to database",
|
|
||||||
count=len(all_series)
|
|
||||||
)
|
|
||||||
|
|
||||||
async with get_db_session() as db:
|
|
||||||
added_count = 0
|
|
||||||
skipped_count = 0
|
|
||||||
for serie in all_series:
|
|
||||||
# Handle series with empty name - use folder as fallback
|
|
||||||
if not serie.name or not serie.name.strip():
|
|
||||||
if serie.folder and serie.folder.strip():
|
|
||||||
serie.name = serie.folder.strip()
|
|
||||||
log.debug(
|
|
||||||
"Using folder as name fallback",
|
|
||||||
key=serie.key,
|
|
||||||
folder=serie.folder
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
log.warning(
|
|
||||||
"Skipping series with empty name and folder",
|
|
||||||
key=serie.key
|
|
||||||
)
|
|
||||||
skipped_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if series already exists in DB
|
|
||||||
existing = await AnimeSeriesService.get_by_key(db, serie.key)
|
|
||||||
if existing:
|
|
||||||
log.debug(
|
|
||||||
"Series already exists in database",
|
|
||||||
name=serie.name,
|
|
||||||
key=serie.key
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create new series in database
|
|
||||||
anime_series = await AnimeSeriesService.create(
|
|
||||||
db=db,
|
|
||||||
key=serie.key,
|
|
||||||
name=serie.name,
|
|
||||||
site=serie.site,
|
|
||||||
folder=serie.folder,
|
|
||||||
year=serie.year if hasattr(serie, 'year') else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create Episode records for each episode in episodeDict
|
|
||||||
if serie.episodeDict:
|
|
||||||
for season, episode_numbers in serie.episodeDict.items():
|
|
||||||
for episode_number in episode_numbers:
|
|
||||||
await EpisodeService.create(
|
|
||||||
db=db,
|
|
||||||
series_id=anime_series.id,
|
|
||||||
season=season,
|
|
||||||
episode_number=episode_number,
|
|
||||||
)
|
|
||||||
|
|
||||||
added_count += 1
|
|
||||||
log.debug(
|
|
||||||
"Added series to database",
|
|
||||||
name=serie.name,
|
|
||||||
key=serie.key
|
|
||||||
)
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
|
||||||
log.warning(
|
|
||||||
"Failed to add series to database",
|
|
||||||
key=serie.key,
|
|
||||||
name=serie.name,
|
|
||||||
error=str(e)
|
|
||||||
)
|
|
||||||
skipped_count += 1
|
|
||||||
|
|
||||||
log.info(
|
|
||||||
"Data file sync complete",
|
|
||||||
added=added_count,
|
|
||||||
skipped=len(all_series) - added_count
|
|
||||||
)
|
|
||||||
return added_count
|
|
||||||
|
|
||||||
except Exception as e: # pylint: disable=broad-except
|
|
||||||
log.warning(
|
|
||||||
"Failed to sync series to database",
|
|
||||||
error=str(e),
|
|
||||||
exc_info=True
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import structlog
|
|||||||
|
|
||||||
from src.config.settings import settings
|
from src.config.settings import settings
|
||||||
from src.server.database.service import AnimeSeriesService
|
from src.server.database.service import AnimeSeriesService
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
|
||||||
from src.server.services.setup_service import SetupService
|
from src.server.services.setup_service import SetupService
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
@@ -237,14 +236,15 @@ async def _sync_anime_folders(progress_service=None) -> int:
|
|||||||
metadata={"step_id": "series_sync"}
|
metadata={"step_id": "series_sync"}
|
||||||
)
|
)
|
||||||
|
|
||||||
sync_count = await sync_legacy_series_to_db(settings.anime_directory)
|
# Legacy sync removed - series are loaded directly from database via _load_series_into_memory
|
||||||
logger.info("Data file sync complete. Added %d series.", sync_count)
|
sync_count = 0
|
||||||
|
logger.info("Data file sync skipped - series loaded directly from database")
|
||||||
|
|
||||||
if progress_service:
|
if progress_service:
|
||||||
await progress_service.update_progress(
|
await progress_service.update_progress(
|
||||||
progress_id="series_sync",
|
progress_id="series_sync",
|
||||||
current=75,
|
current=75,
|
||||||
message=f"Synced {sync_count} series from data files",
|
message=f"Series loaded directly from database",
|
||||||
metadata={"step_id": "series_sync"}
|
metadata={"step_id": "series_sync"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -478,13 +478,8 @@ async def _execute_nfo_scan(progress_service=None) -> None:
|
|||||||
key=data.get('key'),
|
key=data.get('key'),
|
||||||
folder=data.get('folder'),
|
folder=data.get('folder'),
|
||||||
)
|
)
|
||||||
elif event_data.get('type') == 'nfo_scan_completed':
|
# Note: nfo_scan_completed event is NOT handled here because
|
||||||
stats = event_data.get('statistics', {})
|
# perform_nfo_scan_phase handles completion after _execute_nfo_scan returns
|
||||||
if progress_service:
|
|
||||||
await progress_service.complete_progress(
|
|
||||||
progress_id="nfo_scan",
|
|
||||||
message=f"NFO scan complete: {stats.get('created', 0)} created, {stats.get('updated', 0)} updated",
|
|
||||||
)
|
|
||||||
|
|
||||||
nfo_service.subscribe_to_scan_events(nfo_event_handler)
|
nfo_service.subscribe_to_scan_events(nfo_event_handler)
|
||||||
|
|
||||||
@@ -546,16 +541,25 @@ async def perform_nfo_scan_if_needed(progress_service=None):
|
|||||||
|
|
||||||
# Execute the NFO scan
|
# Execute the NFO scan
|
||||||
try:
|
try:
|
||||||
|
# Ensure any newly created series are loaded from DB into SeriesApp memory
|
||||||
|
await _load_series_into_memory(progress_service=None)
|
||||||
await _execute_nfo_scan(progress_service)
|
await _execute_nfo_scan(progress_service)
|
||||||
await _mark_nfo_scan_completed()
|
await _mark_nfo_scan_completed()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to complete NFO scan: %s", e, exc_info=True)
|
logger.error("Failed to complete NFO scan: %s", e, exc_info=True)
|
||||||
if progress_service:
|
if progress_service:
|
||||||
await progress_service.fail_progress(
|
try:
|
||||||
progress_id="nfo_scan",
|
await progress_service.fail_progress(
|
||||||
error_message=f"NFO scan failed: {str(e)}",
|
progress_id="nfo_scan",
|
||||||
metadata={"step_id": "nfo_scan"}
|
error_message=f"NFO scan failed: {str(e)}",
|
||||||
)
|
metadata={"step_id": "nfo_scan"}
|
||||||
|
)
|
||||||
|
except Exception as fail_err:
|
||||||
|
logger.warning(
|
||||||
|
"Could not fail progress 'nfo_scan': %s",
|
||||||
|
fail_err,
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def perform_nfo_scan_phase(progress_service=None):
|
async def perform_nfo_scan_phase(progress_service=None):
|
||||||
@@ -613,6 +617,9 @@ async def perform_nfo_scan_phase(progress_service=None):
|
|||||||
|
|
||||||
# Execute the NFO scan
|
# Execute the NFO scan
|
||||||
try:
|
try:
|
||||||
|
# Ensure any newly created series (e.g., from resolving unresolved folders)
|
||||||
|
# are loaded from DB into SeriesApp memory before scanning
|
||||||
|
await _load_series_into_memory(progress_service=None)
|
||||||
await _execute_nfo_scan(progress_service)
|
await _execute_nfo_scan(progress_service)
|
||||||
await _mark_nfo_scan_completed()
|
await _mark_nfo_scan_completed()
|
||||||
|
|
||||||
@@ -627,11 +634,18 @@ async def perform_nfo_scan_phase(progress_service=None):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to complete NFO scan phase: %s", e, exc_info=True)
|
logger.error("Failed to complete NFO scan phase: %s", e, exc_info=True)
|
||||||
if progress_service:
|
if progress_service:
|
||||||
await progress_service.fail_progress(
|
try:
|
||||||
progress_id="nfo_scan",
|
await progress_service.fail_progress(
|
||||||
error_message=f"NFO scan failed: {str(e)}",
|
progress_id="nfo_scan",
|
||||||
metadata={"step_id": "nfo_scan", "phase": "nfo"}
|
error_message=f"NFO scan failed: {str(e)}",
|
||||||
)
|
metadata={"step_id": "nfo_scan", "phase": "nfo"}
|
||||||
|
)
|
||||||
|
except Exception as fail_err:
|
||||||
|
logger.warning(
|
||||||
|
"Could not fail progress 'nfo_scan': %s",
|
||||||
|
fail_err,
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _check_media_scan_status() -> bool:
|
async def _check_media_scan_status() -> bool:
|
||||||
|
|||||||
@@ -326,6 +326,22 @@ class NfoScanService:
|
|||||||
|
|
||||||
nfo_exists = os.path.isfile(nfo_path)
|
nfo_exists = os.path.isfile(nfo_path)
|
||||||
|
|
||||||
|
# If tmdb_id is missing, try to look it up by series name
|
||||||
|
if not series_data.get("tmdb_id"):
|
||||||
|
logger.debug("No tmdb_id for %s — attempting TMDB lookup", key)
|
||||||
|
name = series_data.get("name", "")
|
||||||
|
found_tmdb_id = await self._lookup_tmdb_id_by_name(name)
|
||||||
|
if found_tmdb_id:
|
||||||
|
series_data["tmdb_id"] = found_tmdb_id
|
||||||
|
await self._save_tmdb_id(key, found_tmdb_id)
|
||||||
|
logger.info("Found and saved tmdb_id %s for %s", found_tmdb_id, key)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Could not resolve tmdb_id for %s (%s)",
|
||||||
|
key,
|
||||||
|
name,
|
||||||
|
)
|
||||||
|
|
||||||
if not nfo_exists:
|
if not nfo_exists:
|
||||||
# Create new NFO
|
# Create new NFO
|
||||||
logger.info("Creating NFO for series: %s (%s)", key, folder)
|
logger.info("Creating NFO for series: %s (%s)", key, folder)
|
||||||
@@ -526,6 +542,53 @@ class NfoScanService:
|
|||||||
logger.info("Regenerated NFO for %s", key)
|
logger.info("Regenerated NFO for %s", key)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
async def _save_tmdb_id(self, key: str, tmdb_id: int) -> None:
|
||||||
|
"""Save tmdb_id to the database for a series.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Series key (primary identifier)
|
||||||
|
tmdb_id: TMDB series ID to save
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.server.database.connection import get_db_session
|
||||||
|
from src.server.database.service import AnimeSeriesService
|
||||||
|
|
||||||
|
async with get_db_session() as db:
|
||||||
|
series = await AnimeSeriesService.get_by_key(db, key)
|
||||||
|
if series:
|
||||||
|
series.tmdb_id = tmdb_id
|
||||||
|
await db.flush()
|
||||||
|
logger.debug("Saved tmdb_id %s for series: %s", tmdb_id, key)
|
||||||
|
else:
|
||||||
|
logger.warning("Series not found for tmdb_id save: %s", key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to save tmdb_id for %s: %s", key, exc)
|
||||||
|
|
||||||
|
async def _lookup_tmdb_id_by_name(self, name: str) -> Optional[int]:
|
||||||
|
"""Look up a TMDB series ID by series name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Series name to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TMDB series ID or None if not found.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||||
|
|
||||||
|
client = get_tmdb_client()
|
||||||
|
results = await client.search_tv_show(name)
|
||||||
|
if results and results.get("results"):
|
||||||
|
first_result = results["results"][0]
|
||||||
|
return first_result.get("id")
|
||||||
|
return None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("TMDB lookup failed for %s: %s", name, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
async def _fetch_tmdb_data(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
async def _fetch_tmdb_data(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
|
||||||
"""Fetch series metadata from TMDB API.
|
"""Fetch series metadata from TMDB API.
|
||||||
|
|
||||||
@@ -539,7 +602,7 @@ class NfoScanService:
|
|||||||
from src.server.nfo.tmdb_client import get_tmdb_client
|
from src.server.nfo.tmdb_client import get_tmdb_client
|
||||||
|
|
||||||
client = get_tmdb_client()
|
client = get_tmdb_client()
|
||||||
data = await client.get_series_details(tmdb_id)
|
data = await client.get_tv_show_details(tmdb_id)
|
||||||
return data
|
return data
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("TMDB fetch failed for TMDB ID %s: %s", tmdb_id, exc)
|
logger.warning("TMDB fetch failed for TMDB ID %s: %s", tmdb_id, exc)
|
||||||
|
|||||||
@@ -110,81 +110,6 @@ class TestGetAllSeriesFromDataFiles:
|
|||||||
assert len(result) == 0
|
assert len(result) == 0
|
||||||
|
|
||||||
|
|
||||||
class TestSyncSeriesToDatabase:
|
|
||||||
"""Test sync_legacy_series_to_db function from anime_service."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_with_empty_directory(self):
|
|
||||||
"""Test sync with empty anime directory."""
|
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
with patch('src.server.SeriesApp.Loaders'), \
|
|
||||||
patch('src.server.SeriesApp.SerieScanner'):
|
|
||||||
count = await sync_legacy_series_to_db(tmp_dir)
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
# Function should complete successfully with no series
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_adds_new_series_to_database(self):
|
|
||||||
"""Test that sync adds new series to database.
|
|
||||||
|
|
||||||
This is a more realistic test that verifies series data is loaded
|
|
||||||
from files and the sync function attempts to add them to the DB.
|
|
||||||
The actual DB interaction is tested in test_add_to_db_creates_record.
|
|
||||||
"""
|
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
|
||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
||||||
# Create test data files
|
|
||||||
_create_test_data_file(
|
|
||||||
tmp_dir,
|
|
||||||
folder="Sync Test Anime",
|
|
||||||
key="sync-test-anime",
|
|
||||||
name="Sync Test Anime",
|
|
||||||
episodes={1: [1, 2]}
|
|
||||||
)
|
|
||||||
|
|
||||||
# First verify that we can load the series from files
|
|
||||||
with patch('src.server.SeriesApp.Loaders'), \
|
|
||||||
patch('src.server.SeriesApp.SerieScanner'):
|
|
||||||
app = SeriesApp(tmp_dir)
|
|
||||||
series = app.get_all_series_from_data_files()
|
|
||||||
assert len(series) == 1
|
|
||||||
assert series[0].key == "sync-test-anime"
|
|
||||||
|
|
||||||
# Now test that the sync function loads series and handles DB
|
|
||||||
# gracefully (even if DB operations fail, it should not crash)
|
|
||||||
with patch('src.server.SeriesApp.Loaders'), \
|
|
||||||
patch('src.server.SeriesApp.SerieScanner'):
|
|
||||||
# The function should return 0 because DB isn't available
|
|
||||||
# but should not crash
|
|
||||||
count = await sync_legacy_series_to_db(tmp_dir)
|
|
||||||
|
|
||||||
# Since no real DB, it will fail gracefully
|
|
||||||
# Function returns 0 when DB operations fail
|
|
||||||
assert isinstance(count, int)
|
|
||||||
assert count == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_handles_exceptions_gracefully(self):
|
|
||||||
"""Test that sync handles exceptions without crashing."""
|
|
||||||
from src.server.services.anime_service import sync_legacy_series_to_db
|
|
||||||
|
|
||||||
# Make SeriesApp raise an exception during initialization
|
|
||||||
with patch('src.server.SeriesApp.Loaders'), \
|
|
||||||
patch('src.server.SeriesApp.SerieScanner'), \
|
|
||||||
patch(
|
|
||||||
'src.server.SeriesApp.SerieList',
|
|
||||||
side_effect=Exception("Test error")
|
|
||||||
):
|
|
||||||
count = await sync_legacy_series_to_db("/fake/path")
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
# Function should complete without crashing
|
|
||||||
|
|
||||||
|
|
||||||
class TestEndToEndSync:
|
class TestEndToEndSync:
|
||||||
"""End-to-end tests for the sync functionality."""
|
"""End-to-end tests for the sync functionality."""
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.server.services.anime_service import (
|
from src.server.services.anime_service import AnimeService, AnimeServiceError
|
||||||
AnimeService,
|
|
||||||
AnimeServiceError,
|
|
||||||
sync_legacy_series_to_db,
|
|
||||||
)
|
|
||||||
from src.server.services.progress_service import ProgressService
|
from src.server.services.progress_service import ProgressService
|
||||||
|
|
||||||
|
|
||||||
@@ -1302,142 +1298,3 @@ class TestGetNFOStatisticsSelfManaged:
|
|||||||
assert result["with_tmdb_id"] == 40
|
assert result["with_tmdb_id"] == 40
|
||||||
|
|
||||||
|
|
||||||
class TestSyncSeriesFromDataFiles:
|
|
||||||
"""Test module-level sync_legacy_series_to_db function."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_adds_new_series(self, tmp_path):
|
|
||||||
"""Should create series for data files not in DB."""
|
|
||||||
mock_serie = MagicMock()
|
|
||||||
mock_serie.key = "new-series"
|
|
||||||
mock_serie.name = "New Series"
|
|
||||||
mock_serie.site = "aniworld.to"
|
|
||||||
mock_serie.folder = "New Series"
|
|
||||||
mock_serie.episodeDict = {1: [1]}
|
|
||||||
|
|
||||||
mock_session = AsyncMock()
|
|
||||||
mock_ctx = AsyncMock()
|
|
||||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
|
||||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.anime_service.SeriesApp"
|
|
||||||
) as MockApp, patch(
|
|
||||||
"src.server.database.connection.get_db_session",
|
|
||||||
return_value=mock_ctx,
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=None,
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.create",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=MagicMock(id=1),
|
|
||||||
) as mock_create, patch(
|
|
||||||
"src.server.database.service.EpisodeService.create",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
):
|
|
||||||
mock_app_instance = MagicMock()
|
|
||||||
mock_app_instance.get_all_series_from_data_files.return_value = [
|
|
||||||
mock_serie
|
|
||||||
]
|
|
||||||
MockApp.return_value = mock_app_instance
|
|
||||||
|
|
||||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
|
||||||
|
|
||||||
assert count == 1
|
|
||||||
mock_create.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_skips_existing(self, tmp_path):
|
|
||||||
"""Already-existing series should be skipped."""
|
|
||||||
mock_serie = MagicMock()
|
|
||||||
mock_serie.key = "exists"
|
|
||||||
mock_serie.name = "Exists"
|
|
||||||
mock_serie.site = "x"
|
|
||||||
mock_serie.folder = "Exists"
|
|
||||||
mock_serie.episodeDict = {}
|
|
||||||
|
|
||||||
mock_session = AsyncMock()
|
|
||||||
mock_ctx = AsyncMock()
|
|
||||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
|
||||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.anime_service.SeriesApp"
|
|
||||||
) as MockApp, patch(
|
|
||||||
"src.server.database.connection.get_db_session",
|
|
||||||
return_value=mock_ctx,
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=MagicMock(),
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.create",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
) as mock_create:
|
|
||||||
mock_app_instance = MagicMock()
|
|
||||||
mock_app_instance.get_all_series_from_data_files.return_value = [
|
|
||||||
mock_serie
|
|
||||||
]
|
|
||||||
MockApp.return_value = mock_app_instance
|
|
||||||
|
|
||||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
mock_create.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_no_data_files(self, tmp_path):
|
|
||||||
"""Empty directory should return 0."""
|
|
||||||
with patch(
|
|
||||||
"src.server.services.anime_service.SeriesApp"
|
|
||||||
) as MockApp:
|
|
||||||
mock_app_instance = MagicMock()
|
|
||||||
mock_app_instance.get_all_series_from_data_files.return_value = []
|
|
||||||
MockApp.return_value = mock_app_instance
|
|
||||||
|
|
||||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
|
||||||
|
|
||||||
assert count == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sync_handles_empty_name(self, tmp_path):
|
|
||||||
"""Series with empty name should use folder as fallback."""
|
|
||||||
mock_serie = MagicMock()
|
|
||||||
mock_serie.key = "no-name"
|
|
||||||
mock_serie.name = ""
|
|
||||||
mock_serie.site = "x"
|
|
||||||
mock_serie.folder = "FallbackFolder"
|
|
||||||
mock_serie.episodeDict = {}
|
|
||||||
|
|
||||||
mock_session = AsyncMock()
|
|
||||||
mock_ctx = AsyncMock()
|
|
||||||
mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
|
|
||||||
mock_ctx.__aexit__ = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"src.server.services.anime_service.SeriesApp"
|
|
||||||
) as MockApp, patch(
|
|
||||||
"src.server.database.connection.get_db_session",
|
|
||||||
return_value=mock_ctx,
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.get_by_key",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=None,
|
|
||||||
), patch(
|
|
||||||
"src.server.database.service.AnimeSeriesService.create",
|
|
||||||
new_callable=AsyncMock,
|
|
||||||
return_value=MagicMock(id=1),
|
|
||||||
) as mock_create:
|
|
||||||
mock_app_instance = MagicMock()
|
|
||||||
mock_app_instance.get_all_series_from_data_files.return_value = [
|
|
||||||
mock_serie
|
|
||||||
]
|
|
||||||
MockApp.return_value = mock_app_instance
|
|
||||||
|
|
||||||
count = await sync_legacy_series_to_db(str(tmp_path))
|
|
||||||
|
|
||||||
assert count == 1
|
|
||||||
# The name should have been set to folder
|
|
||||||
assert mock_serie.name == "FallbackFolder"
|
|
||||||
|
|||||||
@@ -161,14 +161,11 @@ class TestSyncAnimeFolders:
|
|||||||
async def test_sync_anime_folders_without_progress(self):
|
async def test_sync_anime_folders_without_progress(self):
|
||||||
"""Test syncing anime folders without progress service."""
|
"""Test syncing anime folders without progress service."""
|
||||||
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
||||||
patch('src.server.services.initialization_service.os.path.isdir', return_value=True), \
|
patch('src.server.services.initialization_service.os.path.isdir', return_value=True):
|
||||||
patch('src.server.services.initialization_service.sync_legacy_series_to_db',
|
|
||||||
new_callable=AsyncMock, return_value=42) as mock_sync:
|
|
||||||
mock_settings.anime_directory = "/path/to/anime"
|
mock_settings.anime_directory = "/path/to/anime"
|
||||||
result = await _sync_anime_folders()
|
result = await _sync_anime_folders()
|
||||||
|
|
||||||
assert result == 42
|
assert result == 0
|
||||||
mock_sync.assert_called_once()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_sync_anime_folders_with_progress(self):
|
async def test_sync_anime_folders_with_progress(self):
|
||||||
@@ -176,13 +173,11 @@ class TestSyncAnimeFolders:
|
|||||||
mock_progress = AsyncMock()
|
mock_progress = AsyncMock()
|
||||||
|
|
||||||
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
with patch('src.server.services.initialization_service.settings') as mock_settings, \
|
||||||
patch('src.server.services.initialization_service.os.path.isdir', return_value=True), \
|
patch('src.server.services.initialization_service.os.path.isdir', return_value=True):
|
||||||
patch('src.server.services.initialization_service.sync_legacy_series_to_db',
|
|
||||||
new_callable=AsyncMock, return_value=10) as mock_sync:
|
|
||||||
mock_settings.anime_directory = "/path/to/anime"
|
mock_settings.anime_directory = "/path/to/anime"
|
||||||
result = await _sync_anime_folders(progress_service=mock_progress)
|
result = await _sync_anime_folders(progress_service=mock_progress)
|
||||||
|
|
||||||
assert result == 10
|
assert result == 0
|
||||||
# Verify progress updates were called
|
# Verify progress updates were called
|
||||||
assert mock_progress.update_progress.call_count == 2
|
assert mock_progress.update_progress.call_count == 2
|
||||||
mock_progress.update_progress.assert_any_call(
|
mock_progress.update_progress.assert_any_call(
|
||||||
@@ -194,7 +189,7 @@ class TestSyncAnimeFolders:
|
|||||||
mock_progress.update_progress.assert_any_call(
|
mock_progress.update_progress.assert_any_call(
|
||||||
progress_id="series_sync",
|
progress_id="series_sync",
|
||||||
current=75,
|
current=75,
|
||||||
message="Synced 10 series from data files",
|
message="Series loaded directly from database",
|
||||||
metadata={"step_id": "series_sync"}
|
metadata={"step_id": "series_sync"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -537,6 +532,8 @@ class TestPerformNFOScan:
|
|||||||
new_callable=AsyncMock, return_value=False), \
|
new_callable=AsyncMock, return_value=False), \
|
||||||
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
||||||
new_callable=AsyncMock, return_value=True), \
|
new_callable=AsyncMock, return_value=True), \
|
||||||
|
patch('src.server.services.initialization_service._load_series_into_memory',
|
||||||
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._execute_nfo_scan',
|
patch('src.server.services.initialization_service._execute_nfo_scan',
|
||||||
new_callable=AsyncMock), \
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
||||||
@@ -554,6 +551,8 @@ class TestPerformNFOScan:
|
|||||||
new_callable=AsyncMock, return_value=False), \
|
new_callable=AsyncMock, return_value=False), \
|
||||||
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
patch('src.server.services.initialization_service._is_nfo_scan_configured',
|
||||||
new_callable=AsyncMock, return_value=True), \
|
new_callable=AsyncMock, return_value=True), \
|
||||||
|
patch('src.server.services.initialization_service._load_series_into_memory',
|
||||||
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._execute_nfo_scan',
|
patch('src.server.services.initialization_service._execute_nfo_scan',
|
||||||
new_callable=AsyncMock), \
|
new_callable=AsyncMock), \
|
||||||
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
patch('src.server.services.initialization_service._mark_nfo_scan_completed',
|
||||||
|
|||||||
Reference in New Issue
Block a user