mirror of
https://github.com/shadoll/moma.git
synced 2026-08-28 11:33:25 +00:00
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:
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
...
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user