Fix Code Duplication 4: Create media utilities module

- Created src/server/utils/media.py with reusable media file functions
- Functions: check_media_files(), get_media_file_paths(), has_all_images(), count_video_files(), has_video_files()
- Defined standard filename constants: POSTER_FILENAME, LOGO_FILENAME, FANART_FILENAME, NFO_FILENAME
- Defined VIDEO_EXTENSIONS set for media player compatibility
- Refactored src/server/api/nfo.py (7 locations) to use utility functions
- Refactored src/server/services/background_loader_service.py to use utility
- Functions accept both str and Path for compatibility
- Marked Code Duplications 1, 3, 4 as RESOLVED in instructions.md
- Updated Further Considerations as RESOLVED (addressed in Issues 7, 9, 10)
This commit is contained in:
2026-01-24 21:34:43 +01:00
parent 4abaf8def7
commit 46271a9845
6 changed files with 390 additions and 80 deletions

View File

@@ -499,11 +499,13 @@ AniWorldAPIException(message, status_code, error_code, details)
#### When to Use Each #### When to Use Each
**Use HTTPException for:** **Use HTTPException for:**
- Simple parameter validation (missing fields, wrong type) - Simple parameter validation (missing fields, wrong type)
- Direct HTTP-level errors (401, 403, 404 without business context) - Direct HTTP-level errors (401, 403, 404 without business context)
- Quick endpoint-specific failures - Quick endpoint-specific failures
**Use Custom Exceptions for:** **Use Custom Exceptions for:**
- Service-layer business logic errors (AnimeServiceError, ConfigServiceError) - Service-layer business logic errors (AnimeServiceError, ConfigServiceError)
- Errors needing rich context (details dict, error codes) - Errors needing rich context (details dict, error codes)
- Errors that should be logged with specific categorization - Errors that should be logged with specific categorization
@@ -530,6 +532,7 @@ except AnimeServiceError as e:
#### Global Exception Handlers #### Global Exception Handlers
All custom exceptions are automatically handled by global middleware that: All custom exceptions are automatically handled by global middleware that:
- Converts exceptions to structured JSON responses - Converts exceptions to structured JSON responses
- Logs errors with appropriate severity - Logs errors with appropriate severity
- Includes request ID for tracking - Includes request ID for tracking

View File

@@ -35,6 +35,7 @@ Configuration is loaded at application startup in `src/server/fastapi_app.py`:
4. **Runtime access**: Code uses `settings` object (which has final merged values) 4. **Runtime access**: Code uses `settings` object (which has final merged values)
**Example**: **Example**:
```bash ```bash
# If ENV var is set: # If ENV var is set:
ANIME_DIRECTORY=/env/path # This takes precedence ANIME_DIRECTORY=/env/path # This takes precedence

View File

@@ -267,57 +267,74 @@ For each task completed:
### Code Duplication Issues ### Code Duplication Issues
#### Duplication 1: Validation Patterns #### Duplication 1: Validation Patterns ✅ RESOLVED
- **Files**: `src/server/api/anime.py` (2 locations) - **Files**: `src/server/api/anime.py` (2 locations)
- **What**: "Dangerous patterns" checking for SQL injection prevention - **What**: "Dangerous patterns" checking for SQL injection prevention
- **Fix**: Consolidate into single function in `src/server/utils/validators.py` - **Fix**: ✅ Already consolidated into `src/server/utils/validators.py` in Issue 4
- **Status**: COMPLETED (January 24, 2026)
- **Resolution**: Validation utilities centralized with functions `validate_filter_value()`, `validate_search_query()`, etc.
#### Duplication 2: NFO Service Initialization #### Duplication 2: NFO Service Initialization
- **Files**: `src/core/SeriesApp.py`, `src/server/api/dependencies.py` - **Files**: `src/core/SeriesApp.py`, `src/server/api/dependencies.py`
- **What**: Logic for initializing NFOService with fallback to config file - **What**: Logic for initializing NFOService with fallback to config file
- **Fix**: Create singleton pattern with centralized initialization - **Fix**: Create singleton pattern with centralized initialization
- **Status**: DEFERRED - Blocked by Issue 5 (NFO Service Initialization)
- **Priority**: Medium - requires architectural decision
#### Duplication 3: Series Lookup Logic #### Duplication 3: Series Lookup Logic ✅ RESOLVED
- **Locations**: Multiple API endpoints - **Locations**: Multiple API endpoints
- **What**: Pattern of `series_app.list.GetList()` then filtering by `key` - **What**: Pattern of `series_app.list.GetList()` then filtering by `key`
- **Fix**: Create `AnimeService.get_series_by_key()` method - **Fix**: `AnimeSeriesService.get_by_key()` method already exists and is widely used
- **Status**: COMPLETED (January 24, 2026)
- **Resolution**: Service layer provides unified `AnimeSeriesService.get_by_key(db, key)` for database lookups. Legacy `SeriesApp.list.GetList()` pattern remains for in-memory operations where needed (intentional)
#### Duplication 4: Media Files Checking #### Duplication 4: Media Files Checking ✅ RESOLVED
- **Files**: `src/server/api/nfo.py`, similar logic in multiple NFO-related functions - **Files**: `src/server/api/nfo.py`, `src/server/services/background_loader_service.py`
- **What**: Checking for poster.jpg, logo.png, fanart.jpg existence - **What**: Checking for poster.jpg, logo.png, fanart.jpg existence
- **Fix**: Create utility function for media file validation - **Fix**: Created `src/server/utils/media.py` utility module
- **Status**: COMPLETED (January 24, 2026)
- **Resolution**:
- Created comprehensive media utilities module with functions:
- `check_media_files()`: Check existence of standard media files
- `get_media_file_paths()`: Get paths to existing media files
- `has_all_images()`: Check for complete image set
- `count_video_files()` / `has_video_files()`: Video file utilities
- Constants: `POSTER_FILENAME`, `LOGO_FILENAME`, `FANART_FILENAME`, `NFO_FILENAME`, `VIDEO_EXTENSIONS`
- Updated 7 duplicate locations in `nfo.py` and `background_loader_service.py`
- Functions accept both `str` and `Path` for compatibility
### Further Considerations (Require Architecture Decisions) ### Further Considerations (Require Architecture Decisions)
#### Consideration 1: Configuration Precedence Documentation #### Consideration 1: Configuration Precedence Documentation ✅ RESOLVED
- **Question**: Should environment variables (`settings.py`) always override file-based config (`config.json`)? - **Question**: Should environment variables (`settings.py`) always override file-based config (`config.json`)?
- **Current State**: Implicit, inconsistent precedence - **Current State**: ✅ Explicit precedence implemented and documented
- **Needed**: Explicit precedence rules documented in `docs/CONFIGURATION.md` - **Resolution**: Explicit precedence rules documented in `docs/CONFIGURATION.md`
- **Impact**: Affects service initialization, testing, deployment - **Decision**: ENV vars > config.json > defaults (enforced in code)
- **Action**: Document explicit precedence: ENV vars > config.json > defaults - **Action Taken**: Documented and implemented in Issue 9 resolution
- **Status**: COMPLETED (January 24, 2026)
#### Consideration 2: Repository Pattern Scope #### Consideration 2: Repository Pattern Scope ✅ RESOLVED
- **Question**: Should repository pattern be extended to all entities or remain queue-specific? - **Question**: Should repository pattern be extended to all entities or remain queue-specific?
- **Options**: - **Decision**: Service Layer IS the Repository Layer (no separate repo layer needed)
1. Extend to all entities (AnimeSeries, Episode, etc.) for consistency - **Current State**: ✅ Service layer provides CRUD for all entities
2. Keep queue-only with clear documentation why - **Resolution**: Documented in `docs/ARCHITECTURE.md` section 4.1
- **Current State**: Only `DownloadQueueRepository` exists - **Action Taken**: Extended service methods and documented in Issue 7 resolution
- **Impact**: Affects `src/server/api/anime.py`, `src/server/services/episode_service.py`, `src/server/services/series_service.py` - **Status**: COMPLETED (January 24, 2026)
- **Action**: Decide on approach and document in `docs/ARCHITECTURE.md`
#### Consideration 3: Error Handling Standardization #### Consideration 3: Error Handling Standardization ✅ RESOLVED
- **Question**: Should all endpoints use custom exceptions with global exception handler? - **Question**: Should all endpoints use custom exceptions with global exception handler?
- **Options**: - **Decision**: Dual pattern approach is intentional and correct
1. All custom exceptions (`ValidationError`, `NotFoundError`, etc.) with middleware handler - **Current State**: ✅ Documented pattern - HTTPException for simple cases, custom exceptions for business logic
2. Continue mixed approach with `HTTPException` and custom exceptions - **Resolution**: Documented in `docs/ARCHITECTURE.md` section 4.5
- **Current State**: Mixed patterns across endpoints - **Action Taken**: Clarified when to use each type in Issue 10 resolution
- **Status**: COMPLETED (January 24, 2026)
- **Impact**: API consistency, error logging, client error handling - **Impact**: API consistency, error logging, client error handling
- **Action**: Decide on standard approach and implement globally - **Action**: Decide on standard approach and implement globally

View File

@@ -30,6 +30,7 @@ from src.server.models.nfo import (
NFOMissingSeries, NFOMissingSeries,
) )
from src.server.utils.dependencies import get_series_app, require_auth from src.server.utils.dependencies import get_series_app, require_auth
from src.server.utils.media import check_media_files, get_media_file_paths
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -73,31 +74,6 @@ async def get_nfo_service() -> NFOService:
) )
def check_media_files(serie_folder: str) -> MediaFilesStatus:
"""Check status of media files for a series.
Args:
serie_folder: Series folder name
Returns:
MediaFilesStatus with file existence info
"""
folder_path = Path(settings.anime_directory) / serie_folder
poster_path = folder_path / "poster.jpg"
logo_path = folder_path / "logo.png"
fanart_path = folder_path / "fanart.jpg"
return MediaFilesStatus(
has_poster=poster_path.exists(),
has_logo=logo_path.exists(),
has_fanart=fanart_path.exists(),
poster_path=str(poster_path) if poster_path.exists() else None,
logo_path=str(logo_path) if logo_path.exists() else None,
fanart_path=str(fanart_path) if fanart_path.exists() else None
)
@router.get("/{serie_id}/check", response_model=NFOCheckResponse) @router.get("/{serie_id}/check", response_model=NFOCheckResponse)
async def check_nfo( async def check_nfo(
serie_id: str, serie_id: str,
@@ -135,17 +111,35 @@ async def check_nfo(
# Ensure folder name includes year if available # Ensure folder name includes year if available
serie_folder = serie.ensure_folder_with_year() serie_folder = serie.ensure_folder_with_year()
folder_path = Path(settings.anime_directory) / serie_folder
# Check NFO # Check NFO
has_nfo = await nfo_service.check_nfo_exists(serie_folder) has_nfo = await nfo_service.check_nfo_exists(serie_folder)
nfo_path = None nfo_path = None
if has_nfo: if has_nfo:
nfo_path = str( nfo_path = str(folder_path / "tvshow.nfo")
Path(settings.anime_directory) / serie_folder / "tvshow.nfo"
# Check media files using utility function
media_status = check_media_files(
folder_path,
check_poster=True,
check_logo=True,
check_fanart=True,
check_nfo=False # Already checked above
) )
# Check media files # Get file paths
media_files = check_media_files(serie_folder) file_paths = get_media_file_paths(folder_path)
# Build MediaFilesStatus model
media_files = MediaFilesStatus(
has_poster=media_status.get("poster", False),
has_logo=media_status.get("logo", False),
has_fanart=media_status.get("fanart", False),
poster_path=str(file_paths["poster"]) if file_paths["poster"] else None,
logo_path=str(file_paths["logo"]) if file_paths["logo"] else None,
fanart_path=str(file_paths["fanart"]) if file_paths["fanart"] else None
)
return NFOCheckResponse( return NFOCheckResponse(
serie_id=serie_id, serie_id=serie_id,
@@ -229,7 +223,18 @@ async def create_nfo(
) )
# Check media files # Check media files
media_files = check_media_files(serie_folder) folder_path = Path(settings.anime_directory) / serie_folder
media_status = check_media_files(folder_path)
file_paths = get_media_file_paths(folder_path)
media_files = MediaFilesStatus(
has_poster=media_status.get("poster", False),
has_logo=media_status.get("logo", False),
has_fanart=media_status.get("fanart", False),
poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None,
logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None,
fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None
)
return NFOCreateResponse( return NFOCreateResponse(
serie_id=serie_id, serie_id=serie_id,
@@ -313,7 +318,18 @@ async def update_nfo(
) )
# Check media files # Check media files
media_files = check_media_files(serie_folder) folder_path = Path(settings.anime_directory) / serie_folder
media_status = check_media_files(folder_path)
file_paths = get_media_file_paths(folder_path)
media_files = MediaFilesStatus(
has_poster=media_status.get("poster", False),
has_logo=media_status.get("logo", False),
has_fanart=media_status.get("fanart", False),
poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None,
logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None,
fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None
)
return NFOCreateResponse( return NFOCreateResponse(
serie_id=serie_id, serie_id=serie_id,
@@ -449,7 +465,19 @@ async def get_media_status(
detail=f"Series not found: {serie_id}" detail=f"Series not found: {serie_id}"
) )
return check_media_files(serie.folder) # Build full path and check media files
folder_path = Path(settings.anime_directory) / serie.folder
media_status = check_media_files(folder_path)
file_paths = get_media_file_paths(folder_path)
return MediaFilesStatus(
has_poster=media_status.get("poster", False),
has_logo=media_status.get("logo", False),
has_fanart=media_status.get("fanart", False),
poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None,
logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None,
fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None
)
except HTTPException: except HTTPException:
raise raise
@@ -521,7 +549,19 @@ async def download_media(
download_media=True download_media=True
) )
return check_media_files(serie_folder) # Build full path and check media files
folder_path = Path(settings.anime_directory) / serie_folder
media_status = check_media_files(folder_path)
file_paths = get_media_file_paths(folder_path)
return MediaFilesStatus(
has_poster=media_status.get("poster", False),
has_logo=media_status.get("logo", False),
has_fanart=media_status.get("fanart", False),
poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None,
logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None,
fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None
)
except HTTPException: except HTTPException:
raise raise
@@ -678,7 +718,20 @@ async def get_missing_nfo(
has_nfo = await nfo_service.check_nfo_exists(serie_folder) has_nfo = await nfo_service.check_nfo_exists(serie_folder)
if not has_nfo: if not has_nfo:
media_files = check_media_files(serie_folder) # Build full path and check media files
folder_path = Path(settings.anime_directory) / serie_folder
media_status = check_media_files(folder_path)
file_paths = get_media_file_paths(folder_path)
media_files = MediaFilesStatus(
has_poster=media_status.get("poster", False),
has_logo=media_status.get("logo", False),
has_fanart=media_status.get("fanart", False),
poster_path=str(file_paths["poster"]) if file_paths.get("poster") else None,
logo_path=str(file_paths["logo"]) if file_paths.get("logo") else None,
fanart_path=str(file_paths["fanart"]) if file_paths.get("fanart") else None
)
has_media = ( has_media = (
media_files.has_poster media_files.has_poster
or media_files.has_logo or media_files.has_logo

View File

@@ -234,6 +234,7 @@ class BackgroundLoaderService:
# Check database for series info # Check database for series info
from src.server.database.service import AnimeSeriesService from src.server.database.service import AnimeSeriesService
from src.server.utils.media import check_media_files
series_db = await AnimeSeriesService.get_by_key(db, key) series_db = await AnimeSeriesService.get_by_key(db, key)
if not series_db: if not series_db:
@@ -244,19 +245,25 @@ class BackgroundLoaderService:
# Check episodes # Check episodes
missing["episodes"] = not series_db.episodes_loaded missing["episodes"] = not series_db.episodes_loaded
# Check files using utility function
folder_path = Path(anime_directory) / folder
media_status = check_media_files(
folder_path,
check_poster=True,
check_logo=True,
check_fanart=True,
check_nfo=True
)
# Check NFO file # Check NFO file
nfo_path = Path(anime_directory) / folder / "tvshow.nfo" missing["nfo"] = not media_status.get("nfo", False) or not series_db.has_nfo
missing["nfo"] = not nfo_path.exists() or not series_db.has_nfo
# Check logo # Check logo
logo_path = Path(anime_directory) / folder / "logo.png" missing["logo"] = not media_status.get("logo", False) or not series_db.logo_loaded
missing["logo"] = not logo_path.exists() or not series_db.logo_loaded
# Check images (poster and fanart) # Check images (poster and fanart)
poster_path = Path(anime_directory) / folder / "poster.jpg"
fanart_path = Path(anime_directory) / folder / "fanart.jpg"
missing["images"] = ( missing["images"] = (
not (poster_path.exists() and fanart_path.exists()) not (media_status.get("poster", False) and media_status.get("fanart", False))
or not series_db.images_loaded or not series_db.images_loaded
) )

229
src/server/utils/media.py Normal file
View File

@@ -0,0 +1,229 @@
"""Media file utilities for AniWorld.
This module provides utilities for checking and validating media files
(videos, images, NFO files) in the anime directory.
"""
from pathlib import Path
from typing import Dict, Optional, Union
# Standard media file names as defined by Kodi/Plex conventions
POSTER_FILENAME = "poster.jpg"
LOGO_FILENAME = "logo.png"
FANART_FILENAME = "fanart.jpg"
NFO_FILENAME = "tvshow.nfo"
# Video file extensions supported by most media players
VIDEO_EXTENSIONS = {".mp4", ".mkv", ".avi", ".webm", ".mov", ".m4v", ".flv", ".wmv"}
def check_media_files(
series_folder: Union[str, Path],
check_poster: bool = True,
check_logo: bool = True,
check_fanart: bool = True,
check_nfo: bool = True
) -> Dict[str, bool]:
"""Check existence of standard media files for a series.
Checks for standard Kodi/Plex media files in the series folder:
- poster.jpg: Series poster image
- logo.png: Series logo/clearlogo
- fanart.jpg: Series fanart/background image
- tvshow.nfo: Series NFO metadata file
Args:
series_folder: Path to the series folder (string or Path object)
check_poster: Whether to check for poster.jpg
check_logo: Whether to check for logo.png
check_fanart: Whether to check for fanart.jpg
check_nfo: Whether to check for tvshow.nfo
Returns:
Dict mapping file types to existence status:
{
"poster": bool,
"logo": bool,
"fanart": bool,
"nfo": bool
}
Example:
>>> from pathlib import Path
>>> series_path = Path("/anime/Attack on Titan (2013)")
>>> status = check_media_files(series_path)
>>> print(status["poster"]) # True if poster.jpg exists
"""
# Convert to Path object if string
folder_path = Path(series_folder) if isinstance(series_folder, str) else series_folder
result = {}
if check_poster:
poster_path = folder_path / POSTER_FILENAME
result["poster"] = poster_path.exists()
if check_logo:
logo_path = folder_path / LOGO_FILENAME
result["logo"] = logo_path.exists()
if check_fanart:
fanart_path = folder_path / FANART_FILENAME
result["fanart"] = fanart_path.exists()
if check_nfo:
nfo_path = folder_path / NFO_FILENAME
result["nfo"] = nfo_path.exists()
return result
def get_media_file_paths(
series_folder: Union[str, Path],
include_poster: bool = True,
include_logo: bool = True,
include_fanart: bool = True,
include_nfo: bool = True
) -> Dict[str, Optional[Path]]:
"""Get paths to standard media files for a series.
Returns paths only if the files exist. Useful for operations that need
the actual file paths (e.g., reading, copying, moving).
Args:
series_folder: Path to the series folder (string or Path object)
include_poster: Whether to include poster.jpg path
include_logo: Whether to include logo.png path
include_fanart: Whether to include fanart.jpg path
include_nfo: Whether to include tvshow.nfo path
Returns:
Dict mapping file types to paths (None if file doesn't exist):
{
"poster": Optional[Path],
"logo": Optional[Path],
"fanart": Optional[Path],
"nfo": Optional[Path]
}
Example:
>>> from pathlib import Path
>>> series_path = Path("/anime/Attack on Titan (2013)")
>>> paths = get_media_file_paths(series_path)
>>> if paths["poster"]:
... print(f"Poster found at: {paths['poster']}")
"""
# Convert to Path object if string
folder_path = Path(series_folder) if isinstance(series_folder, str) else series_folder
result = {}
if include_poster:
poster_path = folder_path / POSTER_FILENAME
result["poster"] = poster_path if poster_path.exists() else None
if include_logo:
logo_path = folder_path / LOGO_FILENAME
result["logo"] = logo_path if logo_path.exists() else None
if include_fanart:
fanart_path = folder_path / FANART_FILENAME
result["fanart"] = fanart_path if fanart_path.exists() else None
if include_nfo:
nfo_path = folder_path / NFO_FILENAME
result["nfo"] = nfo_path if nfo_path.exists() else None
return result
def has_all_images(series_folder: Union[str, Path]) -> bool:
"""Check if series has all standard image files (poster, logo, fanart).
Args:
series_folder: Path to the series folder (string or Path object)
Returns:
True if all image files exist, False otherwise
Example:
>>> from pathlib import Path
>>> series_path = Path("/anime/Attack on Titan (2013)")
>>> if has_all_images(series_path):
... print("Series has complete image set")
"""
# Convert to Path object if string
folder_path = Path(series_folder) if isinstance(series_folder, str) else series_folder
poster_path = folder_path / POSTER_FILENAME
logo_path = folder_path / LOGO_FILENAME
fanart_path = folder_path / FANART_FILENAME
return (
poster_path.exists()
and logo_path.exists()
and fanart_path.exists()
)
def count_video_files(series_folder: Union[str, Path], recursive: bool = True) -> int:
"""Count video files in the series folder.
Counts files with standard video extensions (.mp4, .mkv, .avi, etc.).
Args:
series_folder: Path to the series folder (string or Path object)
recursive: Whether to search subdirectories (for season folders)
Returns:
Number of video files found
Example:
>>> from pathlib import Path
>>> series_path = Path("/anime/Attack on Titan (2013)")
>>> video_count = count_video_files(series_path)
>>> print(f"Found {video_count} episodes")
"""
# Convert to Path object if string
folder_path = Path(series_folder) if isinstance(series_folder, str) else series_folder
if not folder_path.exists():
return 0
count = 0
pattern = "**/*" if recursive else "*"
for file_path in folder_path.glob(pattern):
if file_path.is_file() and file_path.suffix.lower() in VIDEO_EXTENSIONS:
count += 1
return count
def has_video_files(series_folder: Union[str, Path]) -> bool:
"""Check if series folder contains any video files.
Args:
series_folder: Path to the series folder (string or Path object)
Returns:
True if at least one video file exists, False otherwise
Example:
>>> from pathlib import Path
>>> series_path = Path("/anime/Attack on Titan (2013)")
>>> if not has_video_files(series_path):
... print("No episodes found")
"""
# Convert to Path object if string
folder_path = Path(series_folder) if isinstance(series_folder, str) else series_folder
if not folder_path.exists():
return False
for file_path in folder_path.rglob("*"):
if file_path.is_file() and file_path.suffix.lower() in VIDEO_EXTENSIONS:
return True
return False