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:
sha
2025-12-31 03:13:26 +00:00
parent b50b9bc165
commit c5fbd367fc
20 changed files with 3036 additions and 76 deletions
+21
View File
@@ -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',
]
+348
View File
@@ -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"
+332
View File
@@ -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
+350
View File
@@ -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)