Add rename service and utility modules for file renaming operations

- Implemented RenameService for handling file renaming with features like name validation, proposed name generation, conflict detection, and atomic rename operations.
- Created utility modules for language code extraction, regex pattern matching, and frame class matching to centralize common functionalities.
- Added comprehensive logging for error handling and debugging across all new modules.
This commit is contained in:
sha
2025-12-31 03:13:26 +00:00
parent b50b9bc165
commit c5fbd367fc
20 changed files with 3036 additions and 76 deletions
+25
View File
@@ -0,0 +1,25 @@
"""Extractors package - provides metadata extraction from media files.
This package contains various extractor classes that extract metadata from
different sources (filename, MediaInfo, file system, TMDB API, etc.).
All extractors should implement the DataExtractor protocol defined in base.py.
"""
from .base import DataExtractor
from .default_extractor import DefaultExtractor
from .filename_extractor import FilenameExtractor
from .fileinfo_extractor import FileInfoExtractor
from .mediainfo_extractor import MediaInfoExtractor
from .metadata_extractor import MetadataExtractor
from .tmdb_extractor import TMDBExtractor
__all__ = [
'DataExtractor',
'DefaultExtractor',
'FilenameExtractor',
'FileInfoExtractor',
'MediaInfoExtractor',
'MetadataExtractor',
'TMDBExtractor',
]
+218
View File
@@ -0,0 +1,218 @@
"""Base classes and protocols for extractors.
This module defines the DataExtractor Protocol that all extractors should implement.
The protocol ensures a consistent interface across all extractor types.
"""
from pathlib import Path
from typing import Protocol, Optional
class DataExtractor(Protocol):
"""Protocol defining the standard interface for all extractors.
All extractor classes should implement this protocol to ensure consistent
behavior across the application. The protocol defines methods for extracting
various metadata from media files.
Attributes:
file_path: Path to the file being analyzed
Example:
class MyExtractor:
def __init__(self, file_path: Path):
self.file_path = file_path
def extract_title(self) -> Optional[str]:
# Implementation here
return "Movie Title"
"""
file_path: Path
def extract_title(self) -> Optional[str]:
"""Extract the title of the media file.
Returns:
The extracted title or None if not available
"""
...
def extract_year(self) -> Optional[str]:
"""Extract the release year.
Returns:
The year as a string (e.g., "2024") or None if not available
"""
...
def extract_source(self) -> Optional[str]:
"""Extract the source/release type (e.g., BluRay, WEB-DL, HDTV).
Returns:
The source type or None if not available
"""
...
def extract_order(self) -> Optional[str]:
"""Extract ordering information (e.g., episode number, disc number).
Returns:
The order information or None if not available
"""
...
def extract_resolution(self) -> Optional[str]:
"""Extract the video resolution (e.g., 1080p, 2160p, 720p).
Returns:
The resolution or None if not available
"""
...
def extract_hdr(self) -> Optional[str]:
"""Extract HDR information (e.g., HDR10, Dolby Vision).
Returns:
The HDR format or None if not available
"""
...
def extract_movie_db(self) -> Optional[str]:
"""Extract movie database IDs (e.g., TMDB, IMDB).
Returns:
Database identifiers or None if not available
"""
...
def extract_special_info(self) -> Optional[str]:
"""Extract special information (e.g., REPACK, PROPER, Director's Cut).
Returns:
Special release information or None if not available
"""
...
def extract_audio_langs(self) -> Optional[str]:
"""Extract audio language codes.
Returns:
Comma-separated language codes or None if not available
"""
...
def extract_meta_type(self) -> Optional[str]:
"""Extract metadata type/format information.
Returns:
The metadata type or None if not available
"""
...
def extract_size(self) -> Optional[int]:
"""Extract the file size in bytes.
Returns:
File size in bytes or None if not available
"""
...
def extract_modification_time(self) -> Optional[float]:
"""Extract the file modification timestamp.
Returns:
Unix timestamp of last modification or None if not available
"""
...
def extract_file_name(self) -> Optional[str]:
"""Extract the file name without path.
Returns:
The file name or None if not available
"""
...
def extract_file_path(self) -> Optional[str]:
"""Extract the full file path as string.
Returns:
The full file path or None if not available
"""
...
def extract_frame_class(self) -> Optional[str]:
"""Extract the frame class/aspect ratio classification.
Returns:
Frame class (e.g., "Widescreen", "Ultra-Widescreen") or None
"""
...
def extract_video_tracks(self) -> list[dict]:
"""Extract video track information.
Returns:
List of dictionaries containing video track metadata.
Returns empty list if no tracks available.
"""
...
def extract_audio_tracks(self) -> list[dict]:
"""Extract audio track information.
Returns:
List of dictionaries containing audio track metadata.
Returns empty list if no tracks available.
"""
...
def extract_subtitle_tracks(self) -> list[dict]:
"""Extract subtitle track information.
Returns:
List of dictionaries containing subtitle track metadata.
Returns empty list if no tracks available.
"""
...
def extract_anamorphic(self) -> Optional[str]:
"""Extract anamorphic encoding information.
Returns:
Anamorphic status or None if not available
"""
...
def extract_extension(self) -> Optional[str]:
"""Extract the file extension.
Returns:
File extension (without dot) or None if not available
"""
...
def extract_tmdb_url(self) -> Optional[str]:
"""Extract TMDB URL if available.
Returns:
Full TMDB URL or None if not available
"""
...
def extract_tmdb_id(self) -> Optional[str]:
"""Extract TMDB ID if available.
Returns:
TMDB ID as string or None if not available
"""
...
def extract_original_title(self) -> Optional[str]:
"""Extract the original title (non-localized).
Returns:
The original title or None if not available
"""
...
+14 -7
View File
@@ -1,10 +1,13 @@
import re
import logging
from pathlib import Path
from collections import Counter
from ..constants import SOURCE_DICT, FRAME_CLASSES, MOVIE_DB_DICT, SPECIAL_EDITIONS
from ..decorators import cached_method
import langcodes
logger = logging.getLogger(__name__)
class FilenameExtractor:
"""Class to extract information from filename"""
@@ -324,8 +327,9 @@ class FilenameExtractor:
lang_obj = langcodes.Language.get(lang_code)
iso3_code = lang_obj.to_alpha3()
langs.extend([iso3_code] * count)
except:
except (LookupError, ValueError, AttributeError) as e:
# Skip invalid language codes
logger.debug(f"Invalid language code '{lang_code}': {e}")
pass
# Second, look for standalone language codes outside brackets
@@ -375,14 +379,15 @@ class FilenameExtractor:
if part_lower not in skip_words and part_lower in known_language_codes:
lang_code = part_lower
# Convert to 3-letter ISO code
try:
lang_obj = langcodes.Language.get(lang_code)
iso3_code = lang_obj.to_alpha3()
langs.append(iso3_code)
except:
except (LookupError, ValueError, AttributeError) as e:
# Skip invalid language codes
logger.debug(f"Invalid language code '{lang_code}': {e}")
pass
if not langs:
@@ -449,14 +454,15 @@ class FilenameExtractor:
prefix = item_lower[:-len(lang_code)]
if not re.match(r'^(?:\d+x?)?$', prefix):
continue
# Convert to 3-letter ISO code
try:
lang_obj = langcodes.Language.get(lang_code)
iso3_code = lang_obj.to_alpha3()
tracks.append({'language': iso3_code})
except:
except (LookupError, ValueError, AttributeError) as e:
# Skip invalid language codes
logger.debug(f"Invalid language code '{lang_code}': {e}")
pass
# Second, look for standalone language codes outside brackets
@@ -506,14 +512,15 @@ class FilenameExtractor:
if part_lower not in skip_words and part_lower in known_language_codes:
lang_code = part_lower
# Convert to 3-letter ISO code
try:
lang_obj = langcodes.Language.get(lang_code)
iso3_code = lang_obj.to_alpha3()
tracks.append({'language': iso3_code})
except:
except (LookupError, ValueError, AttributeError) as e:
# Skip invalid language codes
logger.debug(f"Invalid language code '{lang_code}': {e}")
pass
return tracks
+7 -2
View File
@@ -4,6 +4,9 @@ from collections import Counter
from ..constants import FRAME_CLASSES, MEDIA_TYPES
from ..decorators import cached_method
import langcodes
import logging
logger = logging.getLogger(__name__)
class MediaInfoExtractor:
@@ -17,7 +20,8 @@ class MediaInfoExtractor:
self.video_tracks = [t for t in self.media_info.tracks if t.track_type == 'Video']
self.audio_tracks = [t for t in self.media_info.tracks if t.track_type == 'Audio']
self.sub_tracks = [t for t in self.media_info.tracks if t.track_type == 'Text']
except Exception:
except Exception as e:
logger.warning(f"Failed to parse media info for {file_path}: {e}")
self.media_info = None
self.video_tracks = []
self.audio_tracks = []
@@ -165,8 +169,9 @@ class MediaInfoExtractor:
lang_obj = langcodes.Language.get(lang_code.lower())
alpha3 = lang_obj.to_alpha3()
langs.append(alpha3)
except:
except (LookupError, ValueError, AttributeError) as e:
# If conversion fails, use the original code
logger.debug(f"Invalid language code '{lang_code}': {e}")
langs.append(lang_code.lower()[:3])
lang_counts = Counter(langs)
+7 -2
View File
@@ -1,8 +1,11 @@
import mutagen
import logging
from pathlib import Path
from ..constants import MEDIA_TYPES
from ..decorators import cached_method
logger = logging.getLogger(__name__)
class MetadataExtractor:
"""Class to extract information from file metadata"""
@@ -12,7 +15,8 @@ class MetadataExtractor:
self._cache = {} # Internal cache for method results
try:
self.info = mutagen.File(file_path) # type: ignore
except Exception:
except Exception as e:
logger.debug(f"Failed to read metadata from {file_path}: {e}")
self.info = None
@cached_method()
@@ -52,5 +56,6 @@ class MetadataExtractor:
if info['mime'] == mime:
return info['meta_type']
return 'Unknown'
except Exception:
except Exception as e:
logger.debug(f"Failed to detect MIME type for {self.file_path}: {e}")
return 'Unknown'
+4 -2
View File
@@ -50,7 +50,8 @@ class TMDBExtractor:
response = requests.get(url, headers=headers, params=params, timeout=10)
response.raise_for_status()
return response.json()
except (requests.RequestException, ValueError):
except (requests.RequestException, ValueError) as e:
logging.warning(f"TMDB API request failed for {url}: {e}")
return None
def _search_movie_by_title_year(self, title: str, year: Optional[str] = None) -> Optional[Dict[str, Any]]:
@@ -279,5 +280,6 @@ class TMDBExtractor:
# Cache image
local_path = self.cache.set_image(cache_key, image_data, self.ttl_seconds)
return str(local_path) if local_path else None
except requests.RequestException:
except requests.RequestException as e:
logging.warning(f"Failed to download poster from {poster_url}: {e}")
return None