mirror of
https://github.com/shadoll/moma.git
synced 2026-08-28 03:27:34 +00:00
Add rename service and utility modules for file renaming operations
- Implemented RenameService for handling file renaming with features like name validation, proposed name generation, conflict detection, and atomic rename operations. - Created utility modules for language code extraction, regex pattern matching, and frame class matching to centralize common functionalities. - Added comprehensive logging for error handling and debugging across all new modules.
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""Utils package - shared utility functions for the Renamer application.
|
||||
|
||||
This package contains utility modules that provide common functionality
|
||||
used across multiple parts of the application. This eliminates code
|
||||
duplication and provides a single source of truth for shared logic.
|
||||
|
||||
Modules:
|
||||
- language_utils: Language code extraction and conversion
|
||||
- pattern_utils: Regex pattern matching and extraction
|
||||
- frame_utils: Frame class/aspect ratio matching
|
||||
"""
|
||||
|
||||
from .language_utils import LanguageCodeExtractor
|
||||
from .pattern_utils import PatternExtractor
|
||||
from .frame_utils import FrameClassMatcher
|
||||
|
||||
__all__ = [
|
||||
'LanguageCodeExtractor',
|
||||
'PatternExtractor',
|
||||
'FrameClassMatcher',
|
||||
]
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Frame class and aspect ratio matching utilities.
|
||||
|
||||
This module provides centralized logic for determining frame class
|
||||
(resolution classification) based on video dimensions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from renamer.constants import FRAME_CLASSES
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FrameClassMatcher:
|
||||
"""Shared frame class matching logic.
|
||||
|
||||
This class centralizes the logic for determining frame class
|
||||
(e.g., "1080p", "720p") from video dimensions.
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.match_by_dimensions(1920, 1080, scan_type='p')
|
||||
'1080p'
|
||||
"""
|
||||
|
||||
# Tolerance for matching dimensions (pixels)
|
||||
HEIGHT_TOLERANCE_LARGE = 50 # For initial height matching
|
||||
HEIGHT_TOLERANCE_SMALL = 20 # For closest match
|
||||
WIDTH_TOLERANCE = 5 # For width matching
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the frame class matcher."""
|
||||
pass
|
||||
|
||||
def match_by_dimensions(
|
||||
self,
|
||||
width: int,
|
||||
height: int,
|
||||
scan_type: str = 'p'
|
||||
) -> Optional[str]:
|
||||
"""Match frame class by width and height dimensions.
|
||||
|
||||
Uses a multi-step matching algorithm:
|
||||
1. Try width-based matching with typical widths
|
||||
2. Fall back to effective height calculation
|
||||
3. Try exact height match
|
||||
4. Find closest standard height
|
||||
5. Return custom frame class if no match
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
scan_type: 'p' for progressive, 'i' for interlaced
|
||||
|
||||
Returns:
|
||||
Frame class string (e.g., "1080p") or None if invalid input
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.match_by_dimensions(1920, 1080, 'p')
|
||||
'1080p'
|
||||
>>> matcher.match_by_dimensions(1280, 720, 'p')
|
||||
'720p'
|
||||
"""
|
||||
if not width or not height:
|
||||
return None
|
||||
|
||||
# Calculate effective height for aspect ratio consideration
|
||||
aspect_ratio = 16 / 9
|
||||
if height > width:
|
||||
# Portrait mode - unlikely for video but handle it
|
||||
effective_height = height / aspect_ratio
|
||||
else:
|
||||
effective_height = height
|
||||
|
||||
# Step 1: Try to match width to typical widths
|
||||
width_match = self._match_by_width_and_aspect(
|
||||
width, height, scan_type
|
||||
)
|
||||
if width_match:
|
||||
return width_match
|
||||
|
||||
# Step 2: Try exact match with standard frame classes
|
||||
frame_class = f"{int(round(effective_height))}{scan_type}"
|
||||
if frame_class in FRAME_CLASSES:
|
||||
return frame_class
|
||||
|
||||
# Step 3: Find closest standard height match
|
||||
closest_match = self._match_by_closest_height(
|
||||
effective_height, scan_type
|
||||
)
|
||||
if closest_match:
|
||||
return closest_match
|
||||
|
||||
# Step 4: Return custom frame class for non-standard resolutions
|
||||
return frame_class
|
||||
|
||||
def match_by_height(self, height: int) -> Optional[str]:
|
||||
"""Get frame class from video height only.
|
||||
|
||||
Tries exact match first, then finds closest match within tolerance.
|
||||
|
||||
Args:
|
||||
height: Video height in pixels
|
||||
|
||||
Returns:
|
||||
Frame class string or None if no match within tolerance
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.match_by_height(1080)
|
||||
'1080p'
|
||||
>>> matcher.match_by_height(1078) # Close to 1080
|
||||
'1080p'
|
||||
"""
|
||||
if not height:
|
||||
return None
|
||||
|
||||
# Try exact match first
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
if height == info['nominal_height']:
|
||||
return frame_class
|
||||
|
||||
# Find closest match
|
||||
closest = None
|
||||
min_diff = float('inf')
|
||||
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
diff = abs(height - info['nominal_height'])
|
||||
if diff < min_diff:
|
||||
min_diff = diff
|
||||
closest = frame_class
|
||||
|
||||
# Only return if difference is within tolerance
|
||||
if min_diff <= self.HEIGHT_TOLERANCE_LARGE:
|
||||
return closest
|
||||
|
||||
return None
|
||||
|
||||
def _match_by_width_and_aspect(
|
||||
self,
|
||||
width: int,
|
||||
height: int,
|
||||
scan_type: str
|
||||
) -> Optional[str]:
|
||||
"""Match frame class by width and aspect ratio.
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
scan_type: 'p' or 'i'
|
||||
|
||||
Returns:
|
||||
Frame class string or None if no match
|
||||
"""
|
||||
width_matches = []
|
||||
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
# Only consider frame classes with matching scan type
|
||||
if not frame_class.endswith(scan_type):
|
||||
continue
|
||||
|
||||
# Check if width matches any typical width for this frame class
|
||||
for typical_width in info['typical_widths']:
|
||||
if abs(width - typical_width) <= self.WIDTH_TOLERANCE:
|
||||
# Calculate height difference for this match
|
||||
height_diff = abs(height - info['nominal_height'])
|
||||
width_matches.append((frame_class, height_diff))
|
||||
|
||||
if width_matches:
|
||||
# Choose the frame class with smallest height difference
|
||||
width_matches.sort(key=lambda x: x[1])
|
||||
return width_matches[0][0]
|
||||
|
||||
return None
|
||||
|
||||
def _match_by_closest_height(
|
||||
self,
|
||||
height: float,
|
||||
scan_type: str
|
||||
) -> Optional[str]:
|
||||
"""Find closest standard frame class by height.
|
||||
|
||||
Args:
|
||||
height: Effective video height in pixels (can be float)
|
||||
scan_type: 'p' or 'i'
|
||||
|
||||
Returns:
|
||||
Frame class string or None if no match within tolerance
|
||||
"""
|
||||
closest_class = None
|
||||
min_diff = float('inf')
|
||||
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
# Only consider frame classes with matching scan type
|
||||
if not frame_class.endswith(scan_type):
|
||||
continue
|
||||
|
||||
diff = abs(height - info['nominal_height'])
|
||||
if diff < min_diff:
|
||||
min_diff = diff
|
||||
closest_class = frame_class
|
||||
|
||||
# Only return if within tolerance
|
||||
if closest_class and min_diff <= self.HEIGHT_TOLERANCE_SMALL:
|
||||
return closest_class
|
||||
|
||||
return None
|
||||
|
||||
def get_nominal_height(self, frame_class: str) -> Optional[int]:
|
||||
"""Get the nominal height for a frame class.
|
||||
|
||||
Args:
|
||||
frame_class: Frame class string (e.g., "1080p")
|
||||
|
||||
Returns:
|
||||
Nominal height in pixels or None if not found
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.get_nominal_height("1080p")
|
||||
1080
|
||||
"""
|
||||
if frame_class in FRAME_CLASSES:
|
||||
return FRAME_CLASSES[frame_class]['nominal_height']
|
||||
return None
|
||||
|
||||
def get_typical_widths(self, frame_class: str) -> list[int]:
|
||||
"""Get typical widths for a frame class.
|
||||
|
||||
Args:
|
||||
frame_class: Frame class string (e.g., "1080p")
|
||||
|
||||
Returns:
|
||||
List of typical widths in pixels
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.get_typical_widths("1080p")
|
||||
[1920, 1440, 1280]
|
||||
"""
|
||||
if frame_class in FRAME_CLASSES:
|
||||
return FRAME_CLASSES[frame_class]['typical_widths']
|
||||
return []
|
||||
|
||||
def is_standard_resolution(self, width: int, height: int) -> bool:
|
||||
"""Check if dimensions match a standard resolution.
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
|
||||
Returns:
|
||||
True if dimensions are close to a standard resolution
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.is_standard_resolution(1920, 1080)
|
||||
True
|
||||
>>> matcher.is_standard_resolution(1234, 567)
|
||||
False
|
||||
"""
|
||||
# Try to match with either scan type
|
||||
match_p = self.match_by_dimensions(width, height, 'p')
|
||||
match_i = self.match_by_dimensions(width, height, 'i')
|
||||
|
||||
# If we got a match that exists in FRAME_CLASSES, it's standard
|
||||
if match_p and match_p in FRAME_CLASSES:
|
||||
return True
|
||||
if match_i and match_i in FRAME_CLASSES:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def detect_scan_type(self, interlaced: Optional[str]) -> str:
|
||||
"""Detect scan type from interlaced flag.
|
||||
|
||||
Args:
|
||||
interlaced: Interlaced flag (e.g., "Yes", "No", None)
|
||||
|
||||
Returns:
|
||||
'i' for interlaced, 'p' for progressive
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.detect_scan_type("Yes")
|
||||
'i'
|
||||
>>> matcher.detect_scan_type("No")
|
||||
'p'
|
||||
"""
|
||||
if interlaced and str(interlaced).lower() in ['yes', 'true', '1']:
|
||||
return 'i'
|
||||
return 'p'
|
||||
|
||||
def calculate_aspect_ratio(self, width: int, height: int) -> Optional[float]:
|
||||
"""Calculate aspect ratio from dimensions.
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
|
||||
Returns:
|
||||
Aspect ratio as float (e.g., 1.777 for 16:9) or None if invalid
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> ratio = matcher.calculate_aspect_ratio(1920, 1080)
|
||||
>>> round(ratio, 2)
|
||||
1.78
|
||||
"""
|
||||
if not width or not height or height == 0:
|
||||
return None
|
||||
return width / height
|
||||
|
||||
def format_aspect_ratio(self, ratio: float) -> str:
|
||||
"""Format aspect ratio as a string.
|
||||
|
||||
Args:
|
||||
ratio: Aspect ratio as float
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., "16:9", "21:9")
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.format_aspect_ratio(1.777)
|
||||
'16:9'
|
||||
>>> matcher.format_aspect_ratio(2.35)
|
||||
'21:9'
|
||||
"""
|
||||
# Common aspect ratios
|
||||
common_ratios = {
|
||||
1.33: "4:3",
|
||||
1.78: "16:9",
|
||||
1.85: "1.85:1",
|
||||
2.35: "21:9",
|
||||
2.39: "2.39:1",
|
||||
}
|
||||
|
||||
# Find closest match
|
||||
closest = min(common_ratios.keys(), key=lambda x: abs(x - ratio))
|
||||
if abs(closest - ratio) < 0.05: # Within 5% tolerance
|
||||
return common_ratios[closest]
|
||||
|
||||
# Return as decimal if no match
|
||||
return f"{ratio:.2f}:1"
|
||||
@@ -0,0 +1,332 @@
|
||||
"""Language code extraction and conversion utilities.
|
||||
|
||||
This module provides centralized logic for extracting and converting language codes
|
||||
from filenames and metadata. This eliminates the ~150+ lines of duplicated code
|
||||
between FilenameExtractor and MediaInfoExtractor.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
import langcodes
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LanguageCodeExtractor:
|
||||
"""Shared language code extraction logic.
|
||||
|
||||
This class centralizes all language code detection and conversion logic,
|
||||
eliminating duplication across multiple extractors.
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> langs = extractor.extract_from_brackets("[2xUKR_ENG]")
|
||||
>>> print(langs) # ['ukr', 'ukr', 'eng']
|
||||
"""
|
||||
|
||||
# Comprehensive set of known ISO 639-1/639-2/639-3 language codes
|
||||
KNOWN_CODES = {
|
||||
# Most common codes
|
||||
'eng', 'ukr', 'rus', 'fra', 'deu', 'spa', 'ita', 'por', 'nor', 'swe',
|
||||
'dan', 'fin', 'pol', 'cze', 'hun', 'tur', 'ara', 'heb', 'hin', 'jpn',
|
||||
'kor', 'chi', 'tha', 'vie', 'und',
|
||||
|
||||
# European languages
|
||||
'dut', 'nld', 'bel', 'bul', 'hrv', 'ces', 'est', 'ell', 'ind',
|
||||
'lav', 'lit', 'mkd', 'ron', 'slk', 'slv', 'srp', 'zho',
|
||||
|
||||
# South Asian languages
|
||||
'arb', 'ben', 'mar', 'tam', 'tel', 'urd', 'guj', 'kan', 'mal', 'ori',
|
||||
'pan', 'asm', 'mai', 'bho', 'nep', 'sin', 'san', 'tib', 'mon',
|
||||
|
||||
# Central Asian languages
|
||||
'kaz', 'uzb', 'kir', 'tuk', 'aze', 'kat', 'hye', 'geo',
|
||||
|
||||
# Balkan languages
|
||||
'sqi', 'bos', 'alb', 'mol',
|
||||
|
||||
# Nordic languages
|
||||
'isl', 'fao',
|
||||
|
||||
# Other Asian languages
|
||||
'per', 'kur', 'pus', 'div', 'lao', 'khm', 'mya', 'msa',
|
||||
'yue', 'wuu', 'nan', 'hak', 'gan', 'hsn',
|
||||
|
||||
# Various other codes
|
||||
'awa', 'mag',
|
||||
}
|
||||
|
||||
# Language codes that are allowed in title case (to avoid false positives)
|
||||
ALLOWED_TITLE_CASE = {
|
||||
'ukr', 'nor', 'eng', 'rus', 'fra', 'deu', 'spa', 'ita', 'por', 'swe',
|
||||
'dan', 'fin', 'pol', 'cze', 'hun', 'tur', 'ara', 'heb', 'hin', 'jpn',
|
||||
'kor', 'chi', 'tha', 'vie', 'und'
|
||||
}
|
||||
|
||||
# Words to skip (common English words, file extensions, quality indicators)
|
||||
SKIP_WORDS = {
|
||||
# Common English words
|
||||
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had',
|
||||
'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his',
|
||||
'how', 'its', 'may', 'new', 'now', 'old', 'see', 'two', 'way', 'who',
|
||||
'boy', 'did', 'let', 'put', 'say', 'she', 'too', 'use',
|
||||
|
||||
# File extensions
|
||||
'avi', 'mkv', 'mp4', 'mpg', 'mov', 'wmv', 'flv', 'webm', 'm4v',
|
||||
'm2ts', 'ts', 'vob', 'iso', 'img',
|
||||
|
||||
# Quality/resolution indicators
|
||||
'sd', 'hd', 'lq', 'qhd', 'uhd', 'p', 'i', 'hdr', 'sdr', '4k', '8k',
|
||||
'2160p', '1080p', '720p', '480p', '360p', '240p', '144p',
|
||||
|
||||
# Source/encoding indicators
|
||||
'web', 'dl', 'rip', 'bluray', 'dvd', 'hdtv', 'bdrip', 'dvdrip',
|
||||
'xvid', 'divx', 'h264', 'h265', 'x264', 'x265', 'hevc', 'avc',
|
||||
|
||||
# Audio codecs
|
||||
'ma', 'atmos', 'dts', 'aac', 'ac3', 'mp3', 'flac', 'wav', 'wma',
|
||||
'ogg', 'opus',
|
||||
|
||||
# Subtitle indicator
|
||||
'sub', 'subs', 'subtitle',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the language code extractor."""
|
||||
pass
|
||||
|
||||
def extract_from_brackets(self, text: str) -> list[str]:
|
||||
"""Extract language codes from bracketed content.
|
||||
|
||||
Handles patterns like:
|
||||
- [UKR_ENG] → ['ukr', 'eng']
|
||||
- [2xUKR_ENG] → ['ukr', 'ukr', 'eng']
|
||||
- [4xUKR,ENG] → ['ukr', 'ukr', 'ukr', 'ukr', 'eng']
|
||||
|
||||
Args:
|
||||
text: Text containing bracketed language codes
|
||||
|
||||
Returns:
|
||||
List of ISO 639-3 language codes (3-letter)
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor.extract_from_brackets("[2xUKR_ENG]")
|
||||
['ukr', 'ukr', 'eng']
|
||||
"""
|
||||
langs = []
|
||||
|
||||
# Find all bracketed content
|
||||
bracket_pattern = r'\[([^\]]+)\]'
|
||||
brackets = re.findall(bracket_pattern, text)
|
||||
|
||||
for bracket in brackets:
|
||||
bracket_lower = bracket.lower()
|
||||
|
||||
# Skip brackets containing 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 too short
|
||||
if not item or len(item) < 2:
|
||||
continue
|
||||
|
||||
item_lower = item.lower()
|
||||
|
||||
# Skip subtitle indicators
|
||||
if item_lower in self.SKIP_WORDS:
|
||||
continue
|
||||
|
||||
# Pattern: optional number + optional 'x' + language code
|
||||
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 quality/resolution indicators
|
||||
if lang_code in self.SKIP_WORDS:
|
||||
continue
|
||||
|
||||
# Validate prefix (only digits and 'x' allowed)
|
||||
prefix = item_lower[:-len(lang_code)]
|
||||
if not re.match(r'^(?:\d+x?)?$', prefix):
|
||||
continue
|
||||
|
||||
# Convert to ISO 639-3 code
|
||||
iso3_code = self._convert_to_iso3(lang_code)
|
||||
if iso3_code:
|
||||
langs.extend([iso3_code] * count)
|
||||
|
||||
return langs
|
||||
|
||||
def extract_standalone(self, text: str) -> list[str]:
|
||||
"""Extract standalone language codes from text.
|
||||
|
||||
Looks for language codes outside of brackets in various formats:
|
||||
- Uppercase: ENG, UKR, NOR
|
||||
- Title case: Ukr, Nor, Eng
|
||||
- Lowercase: ukr, nor, eng
|
||||
- Dot-separated: .ukr. .eng.
|
||||
|
||||
Args:
|
||||
text: Text to extract language codes from
|
||||
|
||||
Returns:
|
||||
List of ISO 639-3 language codes (3-letter)
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor.extract_standalone("Movie.2024.UKR.ENG.1080p.mkv")
|
||||
['ukr', 'eng']
|
||||
"""
|
||||
langs = []
|
||||
|
||||
# Remove bracketed content first
|
||||
text_without_brackets = re.sub(r'\[([^\]]+)\]', '', text)
|
||||
|
||||
# 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 is a 2-3 letter code
|
||||
if re.match(r'^[a-zA-Z]{2,3}$', part):
|
||||
# Skip title case 2-letter words to avoid false positives
|
||||
if part.istitle() and len(part) == 2:
|
||||
continue
|
||||
|
||||
# For title case, only allow known language codes
|
||||
if part.istitle() and part_lower not in self.ALLOWED_TITLE_CASE:
|
||||
continue
|
||||
|
||||
# Skip common words and non-language codes
|
||||
if part_lower in self.SKIP_WORDS:
|
||||
continue
|
||||
|
||||
# Check if it's a known language code
|
||||
if part_lower in self.KNOWN_CODES:
|
||||
iso3_code = self._convert_to_iso3(part_lower)
|
||||
if iso3_code:
|
||||
langs.append(iso3_code)
|
||||
|
||||
return langs
|
||||
|
||||
def extract_all(self, text: str) -> list[str]:
|
||||
"""Extract all language codes from text (both bracketed and standalone).
|
||||
|
||||
Args:
|
||||
text: Text to extract language codes from
|
||||
|
||||
Returns:
|
||||
List of ISO 639-3 language codes (3-letter), duplicates removed
|
||||
while preserving order
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor.extract_all("Movie [UKR_ENG] 2024.rus.mkv")
|
||||
['ukr', 'eng', 'rus']
|
||||
"""
|
||||
# Extract from both sources
|
||||
bracketed = self.extract_from_brackets(text)
|
||||
standalone = self.extract_standalone(text)
|
||||
|
||||
# Combine while removing duplicates but preserving order
|
||||
seen = set()
|
||||
result = []
|
||||
|
||||
for lang in bracketed + standalone:
|
||||
if lang not in seen:
|
||||
seen.add(lang)
|
||||
result.append(lang)
|
||||
|
||||
return result
|
||||
|
||||
def format_lang_counts(self, langs: list[str]) -> str:
|
||||
"""Format language list with counts like MediaInfo.
|
||||
|
||||
Formats like: "2ukr,eng" for 2 Ukrainian tracks and 1 English track.
|
||||
|
||||
Args:
|
||||
langs: List of language codes (can have duplicates)
|
||||
|
||||
Returns:
|
||||
Formatted string with counts
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor.format_lang_counts(['ukr', 'ukr', 'eng'])
|
||||
'2ukr,eng'
|
||||
"""
|
||||
if not langs:
|
||||
return ''
|
||||
|
||||
# Count occurrences while preserving order of first appearance
|
||||
lang_counts = {}
|
||||
lang_order = []
|
||||
|
||||
for lang in langs:
|
||||
if lang not in lang_counts:
|
||||
lang_counts[lang] = 0
|
||||
lang_order.append(lang)
|
||||
lang_counts[lang] += 1
|
||||
|
||||
# Format with counts
|
||||
formatted = []
|
||||
for lang in lang_order:
|
||||
count = lang_counts[lang]
|
||||
formatted.append(f"{count}{lang}" if count > 1 else lang)
|
||||
|
||||
return ','.join(formatted)
|
||||
|
||||
def _convert_to_iso3(self, lang_code: str) -> Optional[str]:
|
||||
"""Convert a language code to ISO 639-3 (3-letter code).
|
||||
|
||||
Args:
|
||||
lang_code: 2 or 3 letter language code
|
||||
|
||||
Returns:
|
||||
ISO 639-3 code or None if invalid
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor._convert_to_iso3('en')
|
||||
'eng'
|
||||
>>> extractor._convert_to_iso3('ukr')
|
||||
'ukr'
|
||||
"""
|
||||
try:
|
||||
lang_obj = langcodes.Language.get(lang_code)
|
||||
return lang_obj.to_alpha3()
|
||||
except (LookupError, ValueError, AttributeError) as e:
|
||||
logger.debug(f"Invalid language code '{lang_code}': {e}")
|
||||
return None
|
||||
|
||||
def is_valid_code(self, code: str) -> bool:
|
||||
"""Check if a code is a valid language code.
|
||||
|
||||
Args:
|
||||
code: The code to check
|
||||
|
||||
Returns:
|
||||
True if valid language code
|
||||
|
||||
Example:
|
||||
>>> extractor = LanguageCodeExtractor()
|
||||
>>> extractor.is_valid_code('eng')
|
||||
True
|
||||
>>> extractor.is_valid_code('xyz')
|
||||
False
|
||||
"""
|
||||
return self._convert_to_iso3(code) is not None
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Pattern extraction utilities.
|
||||
|
||||
This module provides centralized regex pattern matching and extraction logic
|
||||
for common patterns found in media filenames.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional, Dict
|
||||
from datetime import datetime
|
||||
|
||||
from renamer.constants import MOVIE_DB_DICT
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PatternExtractor:
|
||||
"""Shared regex pattern extraction logic.
|
||||
|
||||
This class centralizes pattern matching for:
|
||||
- Movie database IDs (TMDB, IMDB, etc.)
|
||||
- Year detection and validation
|
||||
- Quality indicators
|
||||
- Source indicators
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> db_info = extractor.extract_movie_db_ids("[tmdbid-12345]")
|
||||
>>> print(db_info) # {'type': 'tmdb', 'id': '12345'}
|
||||
"""
|
||||
|
||||
# Year validation constants
|
||||
CURRENT_YEAR = datetime.now().year
|
||||
YEAR_FUTURE_BUFFER = 10 # Allow up to 10 years in the future
|
||||
MIN_VALID_YEAR = 1900
|
||||
|
||||
# Common quality indicators
|
||||
QUALITY_PATTERNS = {
|
||||
'2160p', '1080p', '720p', '480p', '360p', '240p', '144p',
|
||||
'4K', '8K', 'SD', 'HD', 'UHD', 'QHD', 'LQ'
|
||||
}
|
||||
|
||||
# Source indicators
|
||||
SOURCE_PATTERNS = {
|
||||
'BluRay', 'BDRip', 'BRRip', 'DVDRip', 'WEB-DL', 'WEBRip',
|
||||
'HDTV', 'PDTV', 'HDRip', 'CAM', 'TS', 'TC', 'R5', 'DVD'
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the pattern extractor."""
|
||||
self.max_valid_year = self.CURRENT_YEAR + self.YEAR_FUTURE_BUFFER
|
||||
|
||||
def extract_movie_db_ids(self, text: str) -> Optional[dict[str, str]]:
|
||||
"""Extract movie database IDs from text.
|
||||
|
||||
Supports patterns like:
|
||||
- [tmdbid-123456]
|
||||
- {imdb-tt1234567}
|
||||
- [imdbid-tt123]
|
||||
|
||||
Args:
|
||||
text: Text to search for database IDs
|
||||
|
||||
Returns:
|
||||
Dictionary with 'type' and 'id' keys, or None if not found
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.extract_movie_db_ids("[tmdbid-12345]")
|
||||
{'type': 'tmdb', 'id': '12345'}
|
||||
"""
|
||||
# Match patterns like [tmdbid-123456] or {imdb-tt1234567}
|
||||
pattern = r'[\[\{]([a-zA-Z]+(?:id)?)[-\s]*([a-zA-Z0-9]+)[\]\}]'
|
||||
matches = re.findall(pattern, text)
|
||||
|
||||
if matches:
|
||||
# Take the last match (closest to end of filename)
|
||||
db_type, db_id = matches[-1]
|
||||
|
||||
# Normalize database type
|
||||
db_type_lower = db_type.lower()
|
||||
|
||||
for db_key, db_info in MOVIE_DB_DICT.items():
|
||||
if any(db_type_lower.startswith(pattern.rstrip('-'))
|
||||
for pattern in db_info['patterns']):
|
||||
return {'type': db_key, 'id': db_id}
|
||||
|
||||
return None
|
||||
|
||||
def extract_year(self, text: str, validate: bool = True) -> Optional[str]:
|
||||
"""Extract year from text with optional validation.
|
||||
|
||||
Looks for 4-digit years in parentheses or standalone.
|
||||
Validates that the year is within a reasonable range.
|
||||
|
||||
Args:
|
||||
text: Text to extract year from
|
||||
validate: If True, validate year is within MIN_VALID_YEAR and max_valid_year
|
||||
|
||||
Returns:
|
||||
Year as string (e.g., "2024") or None if not found/invalid
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.extract_year("Movie Title (2024)")
|
||||
'2024'
|
||||
>>> extractor.extract_year("Movie (1899)") # Too old
|
||||
None
|
||||
"""
|
||||
# Look for year in parentheses first (most common)
|
||||
year_pattern = r'\((\d{4})\)'
|
||||
match = re.search(year_pattern, text)
|
||||
|
||||
if match:
|
||||
year = match.group(1)
|
||||
if validate:
|
||||
year_int = int(year)
|
||||
if self.MIN_VALID_YEAR <= year_int <= self.max_valid_year:
|
||||
return year
|
||||
else:
|
||||
logger.debug(f"Year {year} outside valid range "
|
||||
f"{self.MIN_VALID_YEAR}-{self.max_valid_year}")
|
||||
return None
|
||||
return year
|
||||
|
||||
# Fall back to standalone 4-digit number
|
||||
standalone_pattern = r'\b(\d{4})\b'
|
||||
matches = re.findall(standalone_pattern, text)
|
||||
|
||||
for potential_year in matches:
|
||||
if validate:
|
||||
year_int = int(potential_year)
|
||||
if self.MIN_VALID_YEAR <= year_int <= self.max_valid_year:
|
||||
return potential_year
|
||||
else:
|
||||
return potential_year
|
||||
|
||||
return None
|
||||
|
||||
def find_year_position(self, text: str) -> Optional[int]:
|
||||
"""Find the position of the year in text.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
Character index of the year, or None if not found
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.find_year_position("Movie (2024) 1080p")
|
||||
6 # Position of '(' before year
|
||||
"""
|
||||
year_pattern = r'\((\d{4})\)'
|
||||
match = re.search(year_pattern, text)
|
||||
|
||||
if match:
|
||||
year = match.group(1)
|
||||
year_int = int(year)
|
||||
if self.MIN_VALID_YEAR <= year_int <= self.max_valid_year:
|
||||
return match.start()
|
||||
|
||||
return None
|
||||
|
||||
def extract_quality(self, text: str) -> Optional[str]:
|
||||
"""Extract quality indicator from text.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
Quality string (e.g., "1080p") or None
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.extract_quality("Movie.1080p.BluRay")
|
||||
'1080p'
|
||||
"""
|
||||
text_upper = text.upper()
|
||||
|
||||
for quality in self.QUALITY_PATTERNS:
|
||||
# Case-insensitive search
|
||||
pattern = r'\b' + re.escape(quality) + r'\b'
|
||||
if re.search(pattern, text_upper, re.IGNORECASE):
|
||||
return quality
|
||||
|
||||
return None
|
||||
|
||||
def find_quality_position(self, text: str) -> Optional[int]:
|
||||
"""Find the position of quality indicator in text.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
Character index of quality indicator, or None if not found
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.find_quality_position("Movie 1080p BluRay")
|
||||
6
|
||||
"""
|
||||
for quality in self.QUALITY_PATTERNS:
|
||||
pattern = r'\b' + re.escape(quality) + r'\b'
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.start()
|
||||
|
||||
return None
|
||||
|
||||
def extract_source(self, text: str) -> Optional[str]:
|
||||
"""Extract source indicator from text.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
Source string (e.g., "BluRay") or None
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.extract_source("Movie.BluRay.1080p")
|
||||
'BluRay'
|
||||
"""
|
||||
for source in self.SOURCE_PATTERNS:
|
||||
pattern = r'\b' + re.escape(source) + r'\b'
|
||||
if re.search(pattern, text, re.IGNORECASE):
|
||||
return source
|
||||
|
||||
return None
|
||||
|
||||
def find_source_position(self, text: str) -> Optional[int]:
|
||||
"""Find the position of source indicator in text.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
Character index of source indicator, or None if not found
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.find_source_position("Movie BluRay 1080p")
|
||||
6
|
||||
"""
|
||||
for source in self.SOURCE_PATTERNS:
|
||||
pattern = r'\b' + re.escape(source) + r'\b'
|
||||
match = re.search(pattern, text, re.IGNORECASE)
|
||||
if match:
|
||||
return match.start()
|
||||
|
||||
return None
|
||||
|
||||
def extract_bracketed_content(self, text: str) -> list[str]:
|
||||
"""Extract all content from square brackets.
|
||||
|
||||
Args:
|
||||
text: Text to search
|
||||
|
||||
Returns:
|
||||
List of strings found in brackets
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.extract_bracketed_content("[UKR] Movie [ENG]")
|
||||
['UKR', 'ENG']
|
||||
"""
|
||||
bracket_pattern = r'\[([^\]]+)\]'
|
||||
return re.findall(bracket_pattern, text)
|
||||
|
||||
def remove_bracketed_content(self, text: str) -> str:
|
||||
"""Remove all bracketed content from text.
|
||||
|
||||
Args:
|
||||
text: Text to clean
|
||||
|
||||
Returns:
|
||||
Text with brackets and their content removed
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.remove_bracketed_content("[UKR] Movie [ENG]")
|
||||
' Movie '
|
||||
"""
|
||||
return re.sub(r'\[([^\]]+)\]', '', text)
|
||||
|
||||
def split_on_delimiters(self, text: str) -> list[str]:
|
||||
"""Split text on common delimiters (dots, spaces, underscores).
|
||||
|
||||
Args:
|
||||
text: Text to split
|
||||
|
||||
Returns:
|
||||
List of parts
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.split_on_delimiters("Movie.Title.2024")
|
||||
['Movie', 'Title', '2024']
|
||||
"""
|
||||
return re.split(r'[.\s_]+', text)
|
||||
|
||||
def sanitize_for_regex(self, text: str) -> str:
|
||||
"""Escape special regex characters in text.
|
||||
|
||||
Args:
|
||||
text: Text to sanitize
|
||||
|
||||
Returns:
|
||||
Escaped text safe for use in regex patterns
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.sanitize_for_regex("Movie (2024)")
|
||||
'Movie \\(2024\\)'
|
||||
"""
|
||||
return re.escape(text)
|
||||
|
||||
def is_quality_indicator(self, text: str) -> bool:
|
||||
"""Check if text is a quality indicator.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text is a known quality indicator
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.is_quality_indicator("1080p")
|
||||
True
|
||||
"""
|
||||
return text.upper() in self.QUALITY_PATTERNS
|
||||
|
||||
def is_source_indicator(self, text: str) -> bool:
|
||||
"""Check if text is a source indicator.
|
||||
|
||||
Args:
|
||||
text: Text to check
|
||||
|
||||
Returns:
|
||||
True if text is a known source indicator
|
||||
|
||||
Example:
|
||||
>>> extractor = PatternExtractor()
|
||||
>>> extractor.is_source_indicator("BluRay")
|
||||
True
|
||||
"""
|
||||
return any(source.lower() == text.lower() for source in self.SOURCE_PATTERNS)
|
||||
Reference in New Issue
Block a user