mirror of
https://github.com/shadoll/moma.git
synced 2026-08-28 03:27:34 +00:00
This commit introduces a new module `logging_config.py` that implements a singleton pattern for logging configuration. The logger is initialized only once and can be configured based on an environment variable to log to a file or to the console. This centralizes logging setup and ensures consistent logging behavior throughout the application.
488 lines
20 KiB
Python
488 lines
20 KiB
Python
import re
|
|
import logging
|
|
from pathlib import Path
|
|
from collections import Counter
|
|
from ..constants import (
|
|
SOURCE_DICT, FRAME_CLASSES, MOVIE_DB_DICT, SPECIAL_EDITIONS, SKIP_WORDS,
|
|
NON_STANDARD_QUALITY_INDICATORS,
|
|
is_valid_year,
|
|
CYRILLIC_TO_ENGLISH
|
|
)
|
|
from ..cache import cached_method, Cache
|
|
from ..utils.pattern_utils import PatternExtractor
|
|
import langcodes
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FilenameExtractor:
|
|
"""Class to extract information from filename"""
|
|
|
|
def __init__(self, file_path: Path | str, use_cache: bool = True):
|
|
if isinstance(file_path, str):
|
|
self.file_path = Path(file_path)
|
|
self.file_name = file_path
|
|
else:
|
|
self.file_path = file_path
|
|
self.file_name = file_path.name
|
|
|
|
self.cache = Cache() if use_cache else None # Singleton cache for @cached_method decorator
|
|
self.settings = None # Will be set by Settings singleton if needed
|
|
|
|
# Initialize utility helper
|
|
self._pattern_extractor = PatternExtractor()
|
|
|
|
def _normalize_cyrillic(self, text: str) -> str:
|
|
"""Normalize Cyrillic characters to English equivalents for parsing"""
|
|
for cyr, eng in CYRILLIC_TO_ENGLISH.items():
|
|
text = text.replace(cyr, eng)
|
|
return text
|
|
|
|
def _get_frame_class_from_height(self, height: int) -> str | None:
|
|
"""Get frame class from video height using FRAME_CLASSES constant"""
|
|
for frame_class, info in FRAME_CLASSES.items():
|
|
if height == info['nominal_height']:
|
|
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
|
|
year_pos = -1
|
|
source_pos = -1
|
|
quality_pos = -1
|
|
paren_match = None
|
|
dot_match = None
|
|
|
|
# Find year position (either (YYYY) or .YYYY.)
|
|
paren_match = re.search(r'\((\d{4})\)', self.file_name)
|
|
if paren_match:
|
|
year_pos = paren_match.start()
|
|
else:
|
|
dot_match = re.search(r'\.(\d{4})\.', self.file_name)
|
|
if dot_match:
|
|
year_pos = dot_match.start()
|
|
else:
|
|
# Last resort: any 4-digit number
|
|
any_match = re.search(r'\b(\d{4})\b', self.file_name)
|
|
if any_match:
|
|
year = int(any_match.group(1))
|
|
# Basic sanity check using constants
|
|
if is_valid_year(year):
|
|
year_pos = any_match.start() # Cut before the year for plain years
|
|
|
|
# Find source position
|
|
source = self.extract_source()
|
|
if source:
|
|
for alias in SOURCE_DICT[source]:
|
|
match = re.search(r'\b' + re.escape(alias) + r'\b', self.file_name, re.IGNORECASE)
|
|
if match:
|
|
source_pos = match.start()
|
|
break
|
|
|
|
# Find quality bracket position (like [720p,ukr,eng])
|
|
quality_match = re.search(r'\[[^\]]*(?:720p|1080p|2160p|480p|SD|HD|HDR)[^\]]*\]', self.file_name)
|
|
if quality_match:
|
|
quality_pos = quality_match.start()
|
|
|
|
# Find the earliest position that's not at the beginning
|
|
positions = [pos for pos in [year_pos, source_pos, quality_pos] if pos > 0]
|
|
cut_pos = min(positions) if positions else -1
|
|
|
|
# Extract title (everything before the cut position)
|
|
if cut_pos > 0:
|
|
title = self.file_name[:cut_pos].strip()
|
|
else:
|
|
# No delimiters found after position 0, take everything before the last dot
|
|
title = self.file_name.rsplit('.', 1)[0].strip()
|
|
|
|
# If year is at the beginning, remove it
|
|
if year_pos == 0:
|
|
if paren_match and paren_match.start() == 0:
|
|
title = re.sub(r'^\(\d{4}\)\s*', '', title)
|
|
elif dot_match and dot_match.start() == 0:
|
|
title = re.sub(r'^\.\d{4}\.\s*', '', title)
|
|
|
|
# Remove common prefixes that are not part of the title
|
|
# Remove bracketed prefixes like [01.1], [1], etc.
|
|
title = re.sub(r'^\s*\[[^\]]+\]\s*', '', title)
|
|
|
|
# Remove order number prefixes like 01., 1., 1.1 followed by space/underscore
|
|
# Only remove if the number is multi-digit or has decimal (to avoid removing single digit titles)
|
|
match = re.match(r'^\s*(\d+(?:\.\d+)?)\.(?=\s|_)', title)
|
|
if match:
|
|
order = match.group(1)
|
|
if len(order) > 1 or '.' in order:
|
|
title = re.sub(r'^\s*(\d+(?:\.\d+)?)\.(?=\s|_)', '', title)
|
|
|
|
# Remove order like 1.9 where 1 is order, 9 is title
|
|
order = self.extract_order()
|
|
if order:
|
|
match = re.match(r'^' + re.escape(order) + r'\.(.+)', title)
|
|
if match:
|
|
title = match.group(1)
|
|
|
|
# Clean up any remaining leading separators
|
|
title = title.lstrip('_ \t')
|
|
|
|
# Clean up title: remove leading/trailing brackets and dots
|
|
title = title.strip('[](). ')
|
|
|
|
# Replace dots with spaces if they appear to be word separators
|
|
# Only replace dots that are surrounded by letters/digits (not at edges)
|
|
title = re.sub(r'(?<=[a-zA-Z0-9À-ÿ])\.(?=[a-zA-Z0-9À-ÿ])', ' ', title)
|
|
|
|
# Clean up multiple spaces
|
|
title = re.sub(r'\s+', ' ', title).strip()
|
|
|
|
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)
|
|
paren_match = re.search(r'\((\d{4})\)', self.file_name)
|
|
if paren_match:
|
|
return paren_match.group(1)
|
|
|
|
# Fallback: look for year in dots (like .1971.)
|
|
dot_match = re.search(r'\.(\d{4})\.', self.file_name)
|
|
if dot_match:
|
|
return dot_match.group(1)
|
|
|
|
# Last resort: any 4-digit number (but this is less reliable)
|
|
any_match = re.search(r'\b(\d{4})\b', self.file_name)
|
|
if any_match:
|
|
year = int(any_match.group(1))
|
|
# Basic sanity check using constants
|
|
if is_valid_year(year):
|
|
year_pos = any_match.start()
|
|
return str(year)
|
|
|
|
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)
|
|
|
|
for src, aliases in SOURCE_DICT.items():
|
|
for alias in aliases:
|
|
if alias.upper() in temp_name.upper():
|
|
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
|
|
# Patterns: [01], [01.1], 01., 1., 1.1 followed by space or underscore
|
|
|
|
# Check for bracketed patterns: [01], [01.1], etc.
|
|
bracket_match = re.match(r'^\[(\d+(?:\.\d+)?)\]', self.file_name)
|
|
if bracket_match:
|
|
return bracket_match.group(1)
|
|
|
|
# Check for dot patterns: 01., 1., 1.1 followed by title before (
|
|
dot_match = re.match(r'^(\d+(?:\.\d)*)\.?\s*', self.file_name)
|
|
if dot_match and '.' in dot_match.group(0):
|
|
order = dot_match.group(1)
|
|
if '.' in order:
|
|
parts = order.split('.')
|
|
if len(parts) > 1 and parts[-1] != '1':
|
|
order = parts[0]
|
|
return order
|
|
|
|
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
|
|
normalized_name = self._normalize_cyrillic(self.file_name)
|
|
|
|
# First check for specific numeric resolutions with p/i
|
|
match = re.search(r'(\d{3,4})([pi])', normalized_name, re.IGNORECASE)
|
|
if match:
|
|
height = int(match.group(1))
|
|
scan_type = match.group(2).lower()
|
|
frame_class = f"{height}{scan_type}"
|
|
if frame_class in FRAME_CLASSES:
|
|
return frame_class
|
|
# Fallback to height-based if not in constants
|
|
return self._get_frame_class_from_height(height)
|
|
|
|
# If no specific resolution found, check for non-standard quality indicators
|
|
for indicator in NON_STANDARD_QUALITY_INDICATORS:
|
|
if re.search(r'\b' + re.escape(indicator) + r'\b', self.file_name, re.IGNORECASE):
|
|
return None
|
|
|
|
return None
|
|
|
|
@cached_method()
|
|
def extract_hdr(self) -> str | None:
|
|
"""Extract HDR information from filename"""
|
|
# Check for SDR first - indicates no HDR
|
|
if re.search(r'\bSDR\b', self.file_name, re.IGNORECASE):
|
|
return None
|
|
|
|
# Check for HDR, but not NoHDR
|
|
if re.search(r'\bHDR\b', self.file_name, re.IGNORECASE) and not re.search(r'\bNoHDR\b', self.file_name, re.IGNORECASE):
|
|
return 'HDR'
|
|
|
|
return None
|
|
|
|
@cached_method()
|
|
def extract_movie_db(self) -> list[str] | None:
|
|
"""Extract movie database identifier from filename"""
|
|
# Use PatternExtractor utility to avoid code duplication
|
|
db_info = self._pattern_extractor.extract_movie_db_ids(self.file_name)
|
|
if db_info:
|
|
return [db_info['type'], db_info['id']]
|
|
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
|
|
special_info = []
|
|
|
|
for canonical_edition, variants in SPECIAL_EDITIONS.items():
|
|
for edition in variants:
|
|
# Check in brackets: [Theatrical Cut], [Director's Cut], etc.
|
|
bracket_pattern = r'\[([^\]]+)\]'
|
|
brackets = re.findall(bracket_pattern, self.file_name)
|
|
for bracket in brackets:
|
|
# Check if bracket contains comma-separated items
|
|
items = [item.strip() for item in bracket.split(',')]
|
|
for item in items:
|
|
if edition.lower() == item.lower().strip():
|
|
if canonical_edition not in special_info:
|
|
special_info.append(canonical_edition)
|
|
|
|
# Check as standalone text (case-insensitive)
|
|
if re.search(r'\b' + re.escape(edition) + r'\b', self.file_name, re.IGNORECASE):
|
|
if canonical_edition not in special_info:
|
|
special_info.append(canonical_edition)
|
|
|
|
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
|
|
# Skip subtitle indicators and focus on audio languages
|
|
|
|
langs = []
|
|
|
|
# First, look for languages inside brackets
|
|
bracket_pattern = r'\[([^\]]+)\]'
|
|
brackets = re.findall(bracket_pattern, self.file_name)
|
|
|
|
for bracket in brackets:
|
|
bracket_lower = bracket.lower()
|
|
|
|
# Skip brackets that contain movie database patterns
|
|
if any(db in bracket_lower for db in ['imdb', 'tmdb', 'tvdb']):
|
|
continue
|
|
|
|
# Parse items separated by commas or underscores
|
|
items = re.split(r'[,_]', bracket)
|
|
items = [item.strip() for item in items]
|
|
|
|
for item in items:
|
|
# Skip empty items or items that are clearly not languages
|
|
if not item or len(item) < 2:
|
|
continue
|
|
|
|
item_lower = item.lower()
|
|
|
|
# Skip subtitle indicators
|
|
if item_lower in ['sub', 'subs', 'subtitle']:
|
|
continue
|
|
|
|
# Check if item contains language codes (2-3 letter codes)
|
|
# Pattern: optional number + optional 'x' + language code
|
|
# Allow the language code to be at the end of the item
|
|
lang_match = re.search(r'(?:(\d+)x?)?([a-z]{2,3})$', item_lower)
|
|
if lang_match:
|
|
count = int(lang_match.group(1)) if lang_match.group(1) else 1
|
|
lang_code = lang_match.group(2)
|
|
|
|
# Skip if it's a quality/resolution indicator or other skip word
|
|
if lang_code in SKIP_WORDS:
|
|
continue
|
|
|
|
# Skip if the language code is not at the end or if there are extra letters after
|
|
# But allow prefixes like numbers and 'x'
|
|
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()
|
|
langs.extend([iso3_code] * count)
|
|
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
|
|
# Remove bracketed content first
|
|
text_without_brackets = re.sub(r'\[([^\]]+)\]', '', self.file_name)
|
|
|
|
# Split on dots, spaces, and underscores
|
|
parts = re.split(r'[.\s_]+', text_without_brackets)
|
|
|
|
for part in parts:
|
|
part = part.strip()
|
|
if not part or len(part) < 2:
|
|
continue
|
|
|
|
part_lower = part.lower()
|
|
|
|
# Check if this part is a 2-3 letter code
|
|
if not re.match(r'^[a-zA-Z]{2,3}$', part):
|
|
continue
|
|
|
|
# Skip title case 2-letter words to avoid false positives like "In" -> "ind"
|
|
if part.istitle() and len(part) == 2:
|
|
continue
|
|
|
|
# Skip known non-language words
|
|
if part_lower in SKIP_WORDS:
|
|
continue
|
|
|
|
# Try to validate with langcodes library
|
|
try:
|
|
lang_obj = langcodes.Language.get(part_lower)
|
|
iso3_code = lang_obj.to_alpha3()
|
|
langs.append(iso3_code)
|
|
except (LookupError, ValueError, AttributeError) as e:
|
|
# Not a valid language code, skip
|
|
logger.debug(f"Invalid language code '{part_lower}': {e}")
|
|
pass
|
|
|
|
if not langs:
|
|
return ''
|
|
|
|
# Count occurrences while preserving order of first appearance
|
|
lang_counts = {}
|
|
for lang in langs:
|
|
if lang not in lang_counts:
|
|
lang_counts[lang] = 0
|
|
lang_counts[lang] += 1
|
|
|
|
# Format like mediainfo: "2ukr,eng" preserving order
|
|
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_extension(self) -> str | None:
|
|
"""Extract file extension from filename"""
|
|
# Use pathlib to extract extension properly
|
|
ext = self.file_path.suffix
|
|
# Remove leading dot and return
|
|
return ext[1:] if ext else None
|
|
|
|
@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
|
|
|
|
tracks = []
|
|
|
|
# First, look for languages inside brackets
|
|
bracket_pattern = r'\[([^\]]+)\]'
|
|
brackets = re.findall(bracket_pattern, self.file_name)
|
|
|
|
for bracket in brackets:
|
|
bracket_lower = bracket.lower()
|
|
|
|
# Skip brackets that contain movie database patterns
|
|
if any(db in bracket_lower for db in ['imdb', 'tmdb', 'tvdb']):
|
|
continue
|
|
|
|
# Parse items separated by commas or underscores
|
|
items = re.split(r'[,_]', bracket)
|
|
items = [item.strip() for item in items]
|
|
|
|
for item in items:
|
|
# Skip empty items or items that are clearly not languages
|
|
if not item or len(item) < 2:
|
|
continue
|
|
|
|
item_lower = item.lower()
|
|
|
|
# Skip subtitle indicators
|
|
if item_lower in ['sub', 'subs', 'subtitle']:
|
|
continue
|
|
|
|
# Check if item contains language codes (2-3 letter codes)
|
|
# Pattern: optional number + optional 'x' + language code
|
|
# Allow the language code to be at the end of the item
|
|
lang_match = re.search(r'(?:(\d+)x?)?([a-z]{2,3})$', item_lower)
|
|
if lang_match:
|
|
count = int(lang_match.group(1)) if lang_match.group(1) else 1
|
|
lang_code = lang_match.group(2)
|
|
|
|
# Skip if it's a quality/resolution indicator or other skip word
|
|
if lang_code in SKIP_WORDS:
|
|
continue
|
|
|
|
# Skip if the language code is not at the end or if there are extra letters after
|
|
# But allow prefixes like numbers and 'x'
|
|
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 (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
|
|
# Remove bracketed content first
|
|
text_without_brackets = re.sub(r'\[([^\]]+)\]', '', self.file_name)
|
|
|
|
# Split on dots, spaces, and underscores
|
|
parts = re.split(r'[.\s_]+', text_without_brackets)
|
|
|
|
for part in parts:
|
|
part = part.strip()
|
|
if not part or len(part) < 2:
|
|
continue
|
|
|
|
part_lower = part.lower()
|
|
|
|
# Check if this part is a 2-3 letter code
|
|
if not re.match(r'^[a-zA-Z]{2,3}$', part):
|
|
continue
|
|
|
|
# Skip title case 2-letter words to avoid false positives like "In" -> "ind"
|
|
if part.istitle() and len(part) == 2:
|
|
continue
|
|
|
|
# Skip known non-language words
|
|
if part_lower in SKIP_WORDS:
|
|
continue
|
|
|
|
# Try to validate with langcodes library
|
|
try:
|
|
lang_obj = langcodes.Language.get(part_lower)
|
|
iso3_code = lang_obj.to_alpha3()
|
|
tracks.append({'language': iso3_code})
|
|
except (LookupError, ValueError, AttributeError) as e:
|
|
# Not a valid language code, skip
|
|
logger.debug(f"Invalid language code '{part_lower}': {e}")
|
|
pass
|
|
|
|
return tracks |