From 46271a98454a0972d622e4412eb22a532d425d4b Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 24 Jan 2026 21:34:43 +0100 Subject: [PATCH] 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) --- docs/ARCHITECTURE.md | 5 +- docs/CONFIGURATION.md | 27 ++- docs/instructions.md | 65 +++-- src/server/api/nfo.py | 123 +++++++--- .../services/background_loader_service.py | 21 +- src/server/utils/media.py | 229 ++++++++++++++++++ 6 files changed, 390 insertions(+), 80 deletions(-) create mode 100644 src/server/utils/media.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2fcbe17..a7023fa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9a50283..910086e 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 diff --git a/docs/instructions.md b/docs/instructions.md index 7ddb59b..e5e784b 100644 --- a/docs/instructions.md +++ b/docs/instructions.md @@ -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 diff --git a/src/server/api/nfo.py b/src/server/api/nfo.py index 90a1d95..5ebd117 100644 --- a/src/server/api/nfo.py +++ b/src/server/api/nfo.py @@ -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 diff --git a/src/server/services/background_loader_service.py b/src/server/services/background_loader_service.py index 8197ff0..1093336 100644 --- a/src/server/services/background_loader_service.py +++ b/src/server/services/background_loader_service.py @@ -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 ) diff --git a/src/server/utils/media.py b/src/server/utils/media.py new file mode 100644 index 0000000..1ad205a --- /dev/null +++ b/src/server/utils/media.py @@ -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