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:
@@ -483,7 +483,7 @@ The application uses two complementary error handling mechanisms:
|
||||
# Base exception with HTTP status mapping
|
||||
AniWorldAPIException(message, status_code, error_code, details)
|
||||
├── AuthenticationError (401)
|
||||
├── AuthorizationError (403)
|
||||
├── AuthorizationError (403)
|
||||
├── ValidationError (422)
|
||||
├── NotFoundError (404)
|
||||
├── ConflictError (409)
|
||||
@@ -499,11 +499,13 @@ AniWorldAPIException(message, status_code, error_code, details)
|
||||
#### When to Use Each
|
||||
|
||||
**Use HTTPException for:**
|
||||
|
||||
- Simple parameter validation (missing fields, wrong type)
|
||||
- Direct HTTP-level errors (401, 403, 404 without business context)
|
||||
- Quick endpoint-specific failures
|
||||
|
||||
**Use Custom Exceptions for:**
|
||||
|
||||
- Service-layer business logic errors (AnimeServiceError, ConfigServiceError)
|
||||
- Errors needing rich context (details dict, error codes)
|
||||
- Errors that should be logged with specific categorization
|
||||
@@ -530,6 +532,7 @@ except AnimeServiceError as e:
|
||||
#### Global Exception Handlers
|
||||
|
||||
All custom exceptions are automatically handled by global middleware that:
|
||||
|
||||
- Converts exceptions to structured JSON responses
|
||||
- Logs errors with appropriate severity
|
||||
- Includes request ID for tracking
|
||||
|
||||
@@ -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)
|
||||
|
||||
**Example**:
|
||||
|
||||
```bash
|
||||
# If ENV var is set:
|
||||
ANIME_DIRECTORY=/env/path # This takes precedence
|
||||
@@ -204,11 +205,11 @@ Source: [src/server/models/config.py](../src/server/models/config.py#L15-L24)
|
||||
|
||||
**Notes:**
|
||||
|
||||
- Obtain a TMDB API key from https://www.themoviedb.org/settings/api
|
||||
- `auto_create` creates NFO files during the download process
|
||||
- `update_on_scan` refreshes metadata when scanning existing anime
|
||||
- Image downloads require valid `tmdb_api_key`
|
||||
- Larger image sizes (`w780`, `original`) consume more storage space
|
||||
- Obtain a TMDB API key from https://www.themoviedb.org/settings/api
|
||||
- `auto_create` creates NFO files during the download process
|
||||
- `update_on_scan` refreshes metadata when scanning existing anime
|
||||
- Image downloads require valid `tmdb_api_key`
|
||||
- Larger image sizes (`w780`, `original`) consume more storage space
|
||||
|
||||
Source: [src/server/models/config.py](../src/server/models/config.py#L109-L132)
|
||||
|
||||
@@ -241,11 +242,11 @@ Settings are resolved in this order (first match wins):
|
||||
|
||||
Master password must meet all criteria:
|
||||
|
||||
- Minimum 8 characters
|
||||
- At least one uppercase letter
|
||||
- At least one lowercase letter
|
||||
- At least one digit
|
||||
- At least one special character
|
||||
- Minimum 8 characters
|
||||
- At least one uppercase letter
|
||||
- At least one lowercase letter
|
||||
- At least one digit
|
||||
- At least one special character
|
||||
|
||||
Source: [src/server/services/auth_service.py](../src/server/services/auth_service.py#L97-L125)
|
||||
|
||||
@@ -356,6 +357,6 @@ Source: [src/server/api/config.py](../src/server/api/config.py#L67-L142)
|
||||
|
||||
## 10. Related Documentation
|
||||
|
||||
- [API.md](API.md) - Configuration API endpoints
|
||||
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment setup
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) - Configuration service architecture
|
||||
- [API.md](API.md) - Configuration API endpoints
|
||||
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment setup
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) - Configuration service architecture
|
||||
|
||||
@@ -267,57 +267,74 @@ For each task completed:
|
||||
|
||||
### Code Duplication Issues
|
||||
|
||||
#### Duplication 1: Validation Patterns
|
||||
#### Duplication 1: Validation Patterns ✅ RESOLVED
|
||||
|
||||
- **Files**: `src/server/api/anime.py` (2 locations)
|
||||
- **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
|
||||
|
||||
- **Files**: `src/core/SeriesApp.py`, `src/server/api/dependencies.py`
|
||||
- **What**: Logic for initializing NFOService with fallback to config file
|
||||
- **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
|
||||
- **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
|
||||
- **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)
|
||||
|
||||
#### Consideration 1: Configuration Precedence Documentation
|
||||
#### Consideration 1: Configuration Precedence Documentation ✅ RESOLVED
|
||||
|
||||
- **Question**: Should environment variables (`settings.py`) always override file-based config (`config.json`)?
|
||||
- **Current State**: Implicit, inconsistent precedence
|
||||
- **Needed**: Explicit precedence rules documented in `docs/CONFIGURATION.md`
|
||||
- **Impact**: Affects service initialization, testing, deployment
|
||||
- **Action**: Document explicit precedence: ENV vars > config.json > defaults
|
||||
- **Current State**: ✅ Explicit precedence implemented and documented
|
||||
- **Resolution**: Explicit precedence rules documented in `docs/CONFIGURATION.md`
|
||||
- **Decision**: ENV vars > config.json > defaults (enforced in code)
|
||||
- **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?
|
||||
- **Options**:
|
||||
1. Extend to all entities (AnimeSeries, Episode, etc.) for consistency
|
||||
2. Keep queue-only with clear documentation why
|
||||
- **Current State**: Only `DownloadQueueRepository` exists
|
||||
- **Impact**: Affects `src/server/api/anime.py`, `src/server/services/episode_service.py`, `src/server/services/series_service.py`
|
||||
- **Action**: Decide on approach and document in `docs/ARCHITECTURE.md`
|
||||
- **Decision**: Service Layer IS the Repository Layer (no separate repo layer needed)
|
||||
- **Current State**: ✅ Service layer provides CRUD for all entities
|
||||
- **Resolution**: Documented in `docs/ARCHITECTURE.md` section 4.1
|
||||
- **Action Taken**: Extended service methods and documented in Issue 7 resolution
|
||||
- **Status**: COMPLETED (January 24, 2026)
|
||||
|
||||
#### Consideration 3: Error Handling Standardization
|
||||
#### Consideration 3: Error Handling Standardization ✅ RESOLVED
|
||||
|
||||
- **Question**: Should all endpoints use custom exceptions with global exception handler?
|
||||
- **Options**:
|
||||
1. All custom exceptions (`ValidationError`, `NotFoundError`, etc.) with middleware handler
|
||||
2. Continue mixed approach with `HTTPException` and custom exceptions
|
||||
- **Current State**: Mixed patterns across endpoints
|
||||
- **Decision**: Dual pattern approach is intentional and correct
|
||||
- **Current State**: ✅ Documented pattern - HTTPException for simple cases, custom exceptions for business logic
|
||||
- **Resolution**: Documented in `docs/ARCHITECTURE.md` section 4.5
|
||||
- **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
|
||||
- **Action**: Decide on standard approach and implement globally
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from src.server.models.nfo import (
|
||||
NFOMissingSeries,
|
||||
)
|
||||
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__)
|
||||
|
||||
@@ -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)
|
||||
async def check_nfo(
|
||||
serie_id: str,
|
||||
@@ -135,17 +111,35 @@ async def check_nfo(
|
||||
|
||||
# Ensure folder name includes year if available
|
||||
serie_folder = serie.ensure_folder_with_year()
|
||||
folder_path = Path(settings.anime_directory) / serie_folder
|
||||
|
||||
# Check NFO
|
||||
has_nfo = await nfo_service.check_nfo_exists(serie_folder)
|
||||
nfo_path = None
|
||||
if has_nfo:
|
||||
nfo_path = str(
|
||||
Path(settings.anime_directory) / serie_folder / "tvshow.nfo"
|
||||
)
|
||||
nfo_path = str(folder_path / "tvshow.nfo")
|
||||
|
||||
# Check media files
|
||||
media_files = check_media_files(serie_folder)
|
||||
# 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
|
||||
)
|
||||
|
||||
# Get file paths
|
||||
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(
|
||||
serie_id=serie_id,
|
||||
@@ -229,7 +223,18 @@ async def create_nfo(
|
||||
)
|
||||
|
||||
# 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(
|
||||
serie_id=serie_id,
|
||||
@@ -313,7 +318,18 @@ async def update_nfo(
|
||||
)
|
||||
|
||||
# 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(
|
||||
serie_id=serie_id,
|
||||
@@ -449,7 +465,19 @@ async def get_media_status(
|
||||
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:
|
||||
raise
|
||||
@@ -521,7 +549,19 @@ async def download_media(
|
||||
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:
|
||||
raise
|
||||
@@ -678,7 +718,20 @@ async def get_missing_nfo(
|
||||
has_nfo = await nfo_service.check_nfo_exists(serie_folder)
|
||||
|
||||
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 = (
|
||||
media_files.has_poster
|
||||
or media_files.has_logo
|
||||
|
||||
@@ -234,6 +234,7 @@ class BackgroundLoaderService:
|
||||
|
||||
# Check database for series info
|
||||
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)
|
||||
if not series_db:
|
||||
@@ -244,19 +245,25 @@ class BackgroundLoaderService:
|
||||
# Check episodes
|
||||
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
|
||||
nfo_path = Path(anime_directory) / folder / "tvshow.nfo"
|
||||
missing["nfo"] = not nfo_path.exists() or not series_db.has_nfo
|
||||
missing["nfo"] = not media_status.get("nfo", False) or not series_db.has_nfo
|
||||
|
||||
# Check logo
|
||||
logo_path = Path(anime_directory) / folder / "logo.png"
|
||||
missing["logo"] = not logo_path.exists() or not series_db.logo_loaded
|
||||
missing["logo"] = not media_status.get("logo", False) or not series_db.logo_loaded
|
||||
|
||||
# Check images (poster and fanart)
|
||||
poster_path = Path(anime_directory) / folder / "poster.jpg"
|
||||
fanart_path = Path(anime_directory) / folder / "fanart.jpg"
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
229
src/server/utils/media.py
Normal file
229
src/server/utils/media.py
Normal 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
|
||||
Reference in New Issue
Block a user