mirror of
https://github.com/shadoll/moma.git
synced 2026-08-28 11:33:25 +00:00
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
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
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
|
||||
Reference in New Issue
Block a user