added media catalog mode, impooved cache

This commit is contained in:
sha
2025-12-29 19:47:55 +00:00
parent eedc32bf31
commit 50de7e1d4a
20 changed files with 900 additions and 106 deletions
+40 -14
View File
@@ -10,14 +10,40 @@ from .default_extractor import DefaultExtractor
class MediaExtractor:
"""Class to extract various metadata from media files using specialized extractors"""
def __init__(self, file_path: Path):
@classmethod
def create(cls, file_path: Path, cache=None, ttl_seconds: int = 21600):
"""Factory method that returns cached object if available, else creates new."""
if cache:
cache_key = f"extractor_{file_path}"
cached_obj = cache.get_object(cache_key)
if cached_obj:
print(f"Loaded MediaExtractor object from cache for {file_path.name}")
return cached_obj
# Create new instance
instance = cls(file_path, cache, ttl_seconds)
# Cache the object
if cache:
cache_key = f"extractor_{file_path}"
cache.set_object(cache_key, instance, ttl_seconds)
print(f"Cached MediaExtractor object for {file_path.name}")
return instance
def __init__(self, file_path: Path, cache=None, ttl_seconds: int = 21600):
self.file_path = file_path
self.cache = cache
self.ttl_seconds = ttl_seconds
self.cache_key = f"file_data_{file_path}"
self.filename_extractor = FilenameExtractor(file_path)
self.metadata_extractor = MetadataExtractor(file_path)
self.mediainfo_extractor = MediaInfoExtractor(file_path)
self.fileinfo_extractor = FileInfoExtractor(file_path)
self.tmdb_extractor = TMDBExtractor(file_path)
self.tmdb_extractor = TMDBExtractor(file_path, cache, ttl_seconds)
self.default_extractor = DefaultExtractor()
# Extractor mapping
self._extractors = {
"Metadata": self.metadata_extractor,
@@ -164,9 +190,16 @@ class MediaExtractor:
],
},
}
# No caching logic here - handled in create() method
def get(self, key: str, source: str | None = None):
"""Get extracted data by key, optionally from specific source"""
print(f"Extracting real data for key '{key}' in {self.file_path.name}")
return self._get_uncached(key, source)
def _get_uncached(self, key: str, source: str | None = None):
"""Original get logic without caching"""
if source:
# Specific source requested - find the extractor and call the method directly
for extractor_name, extractor in self._extractors.items():
@@ -174,27 +207,20 @@ class MediaExtractor:
method = f"extract_{key}"
if hasattr(extractor, method):
val = getattr(extractor, method)()
# Apply condition if specified
if key in self._data and "condition" in self._data[key]:
condition = self._data[key]["condition"]
return val if condition(val) else None
return val
return val if val is not None else None
return None
# Fallback mode - try sources in order
if key in self._data:
data = self._data[key]
sources = data["sources"]
condition = data.get("condition", lambda x: x is not None)
sources = self._data[key]["sources"]
else:
# Try extractors in order for unconfigured keys
sources = [(name, f"extract_{key}") for name in ["MediaInfo", "Metadata", "Filename", "FileInfo"]]
condition = lambda x: x is not None
# Try each source in order until a valid value is found
for src, method in sources:
if src in self._extractors and hasattr(self._extractors[src], method):
val = getattr(self._extractors[src], method)()
if condition(val):
if val is not None:
return val
return None
+7
View File
@@ -1,6 +1,7 @@
from pathlib import Path
import logging
import os
from ..decorators import cached_method
# Set up logging conditionally
if os.getenv('FORMATTER_LOG', '0') == '1':
@@ -19,24 +20,30 @@ class FileInfoExtractor:
self._modification_time = file_path.stat().st_mtime
self._file_name = file_path.name
self._file_path = str(file_path)
self._cache = {} # Internal cache for method results
logging.info(f"FileInfoExtractor: file_name={self._file_name!r}, file_path={self._file_path!r}")
@cached_method()
def extract_size(self) -> int:
"""Extract file size in bytes"""
return self._size
@cached_method()
def extract_modification_time(self) -> float:
"""Extract file modification time"""
return self._modification_time
@cached_method()
def extract_file_name(self) -> str:
"""Extract file name"""
return self._file_name
@cached_method()
def extract_file_path(self) -> str:
"""Extract full file path as string"""
return self._file_path
@cached_method()
def extract_extension(self) -> str:
"""Extract file extension without the dot"""
return self.file_path.suffix.lower().lstrip('.')
+11
View File
@@ -2,6 +2,7 @@ import re
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
@@ -34,6 +35,7 @@ class FilenameExtractor:
return frame_class
return None
@cached_method()
def extract_title(self) -> str | None:
"""Extract movie title from filename"""
# Find positions of year, source, and quality brackets
@@ -120,6 +122,7 @@ class FilenameExtractor:
return title if title else None
@cached_method()
def extract_year(self) -> str | None:
"""Extract year from filename"""
# First try to find year in parentheses (most common and reliable)
@@ -144,6 +147,7 @@ class FilenameExtractor:
return None
@cached_method()
def extract_source(self) -> str | None:
"""Extract video source from filename"""
temp_name = re.sub(r'\s*\(\d{4}\)\s*|\s*\d{4}\s*|\.\d{4}\.', ' ', self.file_name)
@@ -154,6 +158,7 @@ class FilenameExtractor:
return src
return None
@cached_method()
def extract_order(self) -> str | None:
"""Extract collection order number from filename (at the beginning)"""
# Look for order patterns at the start of filename
@@ -176,6 +181,7 @@ class FilenameExtractor:
return None
@cached_method()
def extract_frame_class(self) -> str | None:
"""Extract frame class from filename (480p, 720p, 1080p, 2160p, etc.)"""
# Normalize Cyrillic characters for resolution parsing
@@ -200,6 +206,7 @@ class FilenameExtractor:
return None
@cached_method()
def extract_hdr(self) -> str | None:
"""Extract HDR information from filename"""
# Check for SDR first - indicates no HDR
@@ -212,6 +219,7 @@ class FilenameExtractor:
return None
@cached_method()
def extract_movie_db(self) -> list[str] | None:
"""Extract movie database identifier from filename"""
# Look for patterns at the end of filename in brackets or braces
@@ -233,6 +241,7 @@ class FilenameExtractor:
return None
@cached_method()
def extract_special_info(self) -> list[str] | None:
"""Extract special edition information from filename"""
# Look for special edition indicators in brackets or as standalone text
@@ -258,6 +267,7 @@ class FilenameExtractor:
return special_info if special_info else None
@cached_method()
def extract_audio_langs(self) -> str:
"""Extract audio languages from filename"""
# Look for language patterns in brackets and outside brackets
@@ -389,6 +399,7 @@ class FilenameExtractor:
audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()]
return ','.join(audio_langs)
@cached_method()
def extract_audio_tracks(self) -> list[dict]:
"""Extract audio track data from filename (simplified version with only language)"""
# Similar to extract_audio_langs but returns list of dicts
+15
View File
@@ -2,6 +2,7 @@ from pathlib import Path
from pymediainfo import MediaInfo
from collections import Counter
from ..constants import FRAME_CLASSES, MEDIA_TYPES
from ..decorators import cached_method
import langcodes
@@ -10,6 +11,7 @@ class MediaInfoExtractor:
def __init__(self, file_path: Path):
self.file_path = file_path
self._cache = {} # Internal cache for method results
try:
self.media_info = MediaInfo.parse(file_path)
self.video_tracks = [t for t in self.media_info.tracks if t.track_type == 'Video']
@@ -54,6 +56,7 @@ class MediaInfoExtractor:
return closest
return None
@cached_method()
def extract_duration(self) -> float | None:
"""Extract duration from media info in seconds"""
if self.media_info:
@@ -62,6 +65,7 @@ class MediaInfoExtractor:
return getattr(track, 'duration', 0) / 1000 if getattr(track, 'duration', None) else None
return None
@cached_method()
def extract_frame_class(self) -> str | None:
"""Extract frame class from media info (480p, 720p, 1080p, etc.)"""
if not self.video_tracks:
@@ -106,6 +110,7 @@ class MediaInfoExtractor:
return f"{closest_height}{scan_type}"
return None
@cached_method()
def extract_resolution(self) -> tuple[int, int] | None:
"""Extract actual video resolution as (width, height) tuple from media info"""
if not self.video_tracks:
@@ -116,6 +121,7 @@ class MediaInfoExtractor:
return width, height
return None
@cached_method()
def extract_aspect_ratio(self) -> str | None:
"""Extract video aspect ratio from media info"""
if not self.video_tracks:
@@ -125,6 +131,7 @@ class MediaInfoExtractor:
return str(aspect_ratio)
return None
@cached_method()
def extract_hdr(self) -> str | None:
"""Extract HDR info from media info"""
if not self.video_tracks:
@@ -134,6 +141,7 @@ class MediaInfoExtractor:
return 'HDR'
return None
@cached_method()
def extract_audio_langs(self) -> str | None:
"""Extract audio languages from media info"""
if not self.audio_tracks:
@@ -154,6 +162,7 @@ class MediaInfoExtractor:
audio_langs = [f"{count}{lang}" if count > 1 else lang for lang, count in lang_counts.items()]
return ','.join(audio_langs)
@cached_method()
def extract_video_tracks(self) -> list[dict]:
"""Extract video track data"""
tracks = []
@@ -169,6 +178,7 @@ class MediaInfoExtractor:
tracks.append(track_data)
return tracks
@cached_method()
def extract_audio_tracks(self) -> list[dict]:
"""Extract audio track data"""
tracks = []
@@ -182,6 +192,7 @@ class MediaInfoExtractor:
tracks.append(track_data)
return tracks
@cached_method()
def extract_subtitle_tracks(self) -> list[dict]:
"""Extract subtitle track data"""
tracks = []
@@ -193,6 +204,7 @@ class MediaInfoExtractor:
tracks.append(track_data)
return tracks
@cached_method()
def is_3d(self) -> bool:
"""Check if the video is 3D"""
if not self.video_tracks:
@@ -205,6 +217,7 @@ class MediaInfoExtractor:
return True
return False
@cached_method()
def extract_anamorphic(self) -> str | None:
"""Extract anamorphic info for 3D videos"""
if not self.video_tracks:
@@ -214,6 +227,7 @@ class MediaInfoExtractor:
return 'Anamorphic:Yes'
return None
@cached_method()
def extract_extension(self) -> str | None:
"""Extract file extension based on container format"""
if not self.media_info:
@@ -233,6 +247,7 @@ class MediaInfoExtractor:
return exts[0] if exts else None
return None
@cached_method()
def extract_3d_layout(self) -> str | None:
"""Extract 3D stereoscopic layout from MediaInfo"""
if not self.is_3d():
+6 -1
View File
@@ -1,6 +1,7 @@
import mutagen
from pathlib import Path
from ..constants import MEDIA_TYPES
from ..decorators import cached_method
class MetadataExtractor:
@@ -8,36 +9,40 @@ class MetadataExtractor:
def __init__(self, file_path: Path):
self.file_path = file_path
self._cache = {} # Internal cache for method results
try:
self.info = mutagen.File(file_path) # type: ignore
except Exception:
self.info = None
@cached_method()
def extract_title(self) -> str | None:
"""Extract title from metadata"""
if self.info:
return getattr(self.info, 'title', None) or getattr(self.info, 'get', lambda x, default=None: default)('title', [None])[0] # type: ignore
return None
@cached_method()
def extract_duration(self) -> float | None:
"""Extract duration from metadata"""
if self.info:
return getattr(self.info, 'length', None)
return None
@cached_method()
def extract_artist(self) -> str | None:
"""Extract artist from metadata"""
if self.info:
return getattr(self.info, 'artist', None) or getattr(self.info, 'get', lambda x, default=None: default)('artist', [None])[0] # type: ignore
return None
@cached_method()
def extract_meta_type(self) -> str:
"""Extract meta type from metadata"""
if self.info:
return type(self.info).__name__
return self._detect_by_mime()
def _detect_by_mime(self) -> str:
"""Detect meta type by MIME"""
try:
+74 -44
View File
@@ -11,53 +11,22 @@ from ..secrets import TMDB_API_KEY, TMDB_ACCESS_TOKEN
class TMDBExtractor:
"""Class to extract TMDB movie information"""
CACHE_DIR = Path.home() / ".cache" / "renamer" / "tmdb"
CACHE_DURATION = 5 * 24 * 60 * 60 # 5 days in seconds
def __init__(self, file_path: Path):
def __init__(self, file_path: Path, cache=None, ttl_seconds: int = 21600):
self.file_path = file_path
self.cache = cache
self.ttl_seconds = ttl_seconds
self._movie_db_info = None
def _get_cache_file_path(self, cache_key: str) -> Path:
"""Get the cache file path for a given cache key"""
# Create a hash of the cache key for the filename
key_hash = hashlib.md5(cache_key.encode('utf-8')).hexdigest()
return self.CACHE_DIR / f"{key_hash}.json"
def _is_cache_valid(self, cache_key: str) -> bool:
"""Check if cache entry is still valid"""
cache_file = self._get_cache_file_path(cache_key)
if not cache_file.exists():
return False
try:
# Check file modification time
stat = cache_file.stat()
return time.time() - stat.st_mtime < self.CACHE_DURATION
except OSError:
return False
def _get_cached_data(self, cache_key: str) -> Optional[Dict[str, Any]]:
"""Get data from cache if valid"""
if not self._is_cache_valid(cache_key):
return None
cache_file = self._get_cache_file_path(cache_key)
try:
with open(cache_file, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
return None
if self.cache:
return self.cache.get(f"tmdb_{cache_key}")
return None
def _set_cached_data(self, cache_key: str, data: Dict[str, Any]):
"""Store data in cache"""
try:
self.CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file = self._get_cache_file_path(cache_key)
with open(cache_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except OSError:
pass # Silently fail if we can't save cache
if self.cache:
self.cache.set(f"tmdb_{cache_key}", data, self.ttl_seconds)
def _make_tmdb_request(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Make a request to TMDB API"""
@@ -230,9 +199,70 @@ class TMDBExtractor:
return f"https://www.themoviedb.org/movie/{movie_id}"
return None
def extract_movie_db(self) -> Optional[Tuple[str, str]]:
"""Extract TMDB database info as (name, id) tuple"""
movie_id = self.extract_tmdb_id()
if movie_id:
return ("tmdb", movie_id)
def extract_duration(self) -> Optional[str]:
"""Extract TMDB runtime in minutes"""
movie_info = self._get_movie_info()
if movie_info and movie_info.get('runtime'):
return str(movie_info['runtime'])
return None
def extract_popularity(self) -> Optional[str]:
"""Extract TMDB popularity"""
movie_info = self._get_movie_info()
if movie_info:
return str(movie_info.get('popularity', ''))
return None
def extract_vote_average(self) -> Optional[str]:
"""Extract TMDB vote average"""
movie_info = self._get_movie_info()
if movie_info:
return str(movie_info.get('vote_average', ''))
return None
def extract_overview(self) -> Optional[str]:
"""Extract TMDB overview"""
movie_info = self._get_movie_info()
if movie_info:
return movie_info.get('overview')
return None
def extract_genres(self) -> Optional[str]:
"""Extract TMDB genres as codes"""
movie_info = self._get_movie_info()
if movie_info and movie_info.get('genres'):
return ', '.join(genre['name'] for genre in movie_info['genres'])
return None
def extract_poster_path(self) -> Optional[str]:
"""Extract TMDB poster path"""
movie_info = self._get_movie_info()
if movie_info:
return movie_info.get('poster_path')
return None
def extract_poster_image_path(self) -> Optional[str]:
"""Download and cache poster image, return local path"""
poster_path = self.extract_poster_path()
if not poster_path or not self.cache:
return None
cache_key = f"poster_{poster_path}"
cached_path = self.cache.get_image(cache_key)
if cached_path:
return str(cached_path)
# Download poster
base_url = "https://image.tmdb.org/t/p/w500" # Medium size
url = f"{base_url}{poster_path}"
try:
response = requests.get(url, timeout=10)
response.raise_for_status()
image_data = response.content
# 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:
return None