Files
moma/src/extractors/filename_extractor.py
T
sha 2e652ae58a feat: Add OpenScreen for directory input and validation
feat: Introduce poster rendering views with multiple engines

feat: Implement ASCII art poster renderer using PIL

feat: Create base class for poster renderers

feat: Add RichPixels renderer for high-quality terminal image display

feat: Implement Viu terminal image viewer renderer

feat: Add ProposedFilenameView for generating standardized filenames

feat: Create RenameConfirmScreen for renaming files with confirmation

feat: Implement SettingsScreen for configuring application settings

feat: Add custom PosterWidget for rendering poster images
2026-04-11 22:09:29 +03:00

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