mirror of
https://github.com/shadoll/moma.git
synced 2026-08-28 11:33:25 +00:00
added media catalog mode, impooved cache
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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('.')
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user