"""Utility functions for text processing and file handling.""" import re import unicodedata from typing import Optional def remove_wrong_ed_suffix(text: str) -> str: """Remove erroneous 'ed' suffixes added to words that shouldn't have them. Examples: "Enter Sandmaned" → "Enter Sandman" "Bohemian Rhapsodyed" → "Bohemian Rhapsody" "Don't Stop Me Nowed" → "Don't Stop Me Now" """ if not text or len(text) < 3: return text # Words that commonly get corrupted with 'ed' # Pattern: word ends with vowel+consonant, gets 'ed' appended corruptions = { 'sandmaned': 'sandman', 'nowed': 'now', 'suned': 'sun', 'dusted': 'dust', 'loveed': 'love', 'rhapsodyed': 'rhapsody', 'queened': 'queen', 'championsed': 'champions', 'youed': 'you', 'symphonyed': 'symphony', 'goed': 'go', 'zombieed': 'zombie', 'beed': 'be', 'lifeed': 'life', 'youthed': 'youth', } result = text for corrupted, correct in corruptions.items(): # Case-insensitive replacement while preserving case of the correct word pattern = re.compile(re.escape(corrupted), re.IGNORECASE) def replace_preserving_case(match): # If original text was uppercase, return uppercase matched = match.group(0) if matched.isupper(): return correct.upper() elif matched[0].isupper(): return correct.capitalize() else: return correct result = pattern.sub(replace_preserving_case, result) return result def sanitize_filename(text: str) -> str: """Remove/replace invalid filename characters.""" if not text: return "" text = str(text) # Replace forward/backward slashes with unicode lookalike (for cases like "AC/DC") # ⁄ (U+2044 FRACTION SLASH) looks like "/" but is a valid filename character text = text.replace("/", "⁄").replace("\\", "⁄") # Remove other invalid filename characters text = re.sub(r'[*?"<>|]', '', text) # Remove leading/trailing dots and spaces text = text.strip(". ") # Collapse multiple spaces text = re.sub(r'\s+', ' ', text) return text def normalize_text(text: str) -> str: """Normalize text for comparison (lowercase, remove special chars).""" if not text: return "" text = unicodedata.normalize('NFKD', text) text = text.lower() text = text.replace("-", "").replace(" ", "").replace("'", "") return text def capitalize_artist(artist: str) -> str: """Capitalize artist name properly.""" if not artist or artist.lower() in ["unknown", "unknown artist"]: return artist words = artist.split() result = [] for word in words: if "-" in word: parts = [p.capitalize() for p in word.split("-")] result.append("-".join(parts)) else: result.append(word.capitalize()) return " ".join(result) def extract_year(date_string: str) -> Optional[str]: """Extract year from a date string.""" if not date_string: return None match = re.search(r"\b(\d{4})\b", str(date_string)) return match.group(1) if match else None def clean_title_for_filename(title: str) -> str: """Remove common suffixes from track titles for cleaner filenames. Examples: "There Is a Light That Never Goes Out - 2011 Remaster" → "There Is a Light That Never Goes Out" "Song - Remastered 1" → "Song" "Song - Remastered I" → "Song" "Ain't No Rest For The Wicked - Original Version" → "Ain't No Rest For The Wicked" "Song - Lyrics" → "Song" "Track (Live)" → "Track" "Don't Stop Me Nowed 2011" → "Don't Stop Me Nowed" """ if not title: return "" # List of patterns to remove (case-insensitive) patterns_to_remove = [ # Remastered with year in both orders r'\s*[-–]\s*(re-mastered|remastered|re-master|remaster)\s+\d{4}', # "- Remastered 2009" # Year-based remasters (2011 Remaster, etc.) r'\s*[-–]\s*\d{4}\s+(re-mastered|remastered|re-master|remaster)', r'\s*\(\d{4}\s+(re-mastered|remastered|re-master|remaster)\)', # Generic remaster/remastered without year r'\s*[-–]\s*(re-mastered|remastered|re-master|remaster)(\s+\d+)?', # Remastered, Remastered 1, etc. r'\s*[-–]\s*(re-mastered|remastered|re-master|remaster)\s+([ivxlcdm]+)?', # Remastered I, II, etc. (roman numerals) r'\s*[-–]\s*(re-master|remaster)(ed)?\s+(edition|version)', # Original/Version suffixes r'\s*[-–]\s*(original\s+)?(version|edit|cut)', r'\s*[-–]\s*original\s+(mix|remix)', # Generic versions r'\s*[-–]\s*(lyrics|lyric)', r'\s*[-–]\s*(official\s+)?(audio|video)', r'\s*[-–]\s*(live|acoustic|remix|cover)', r'\s*[-–]\s*(feat\.|featuring).*', # Parentheses versions r'\s*\((lyrics|lyric|live|acoustic|remix|cover|original|version|remaster|remastered)\)', r'\s*\(.*remaster.*\)', # Simple year at the end (2011, 2020, etc.) - with or without dash/parentheses r'\s*[-–]\s*\d{4}\s*$', # " - 2011" at end r'\s*\(\d{4}\)\s*$', # " (2011)" at end r'\s+\d{4}\s*$', # " 2011" at end ] cleaned = title for pattern in patterns_to_remove: cleaned = re.sub(pattern, '', cleaned, flags=re.IGNORECASE) # Clean up extra spaces and trailing dashes cleaned = re.sub(r'\s+', ' ', cleaned).strip() cleaned = re.sub(r'\s*[-–]\s*$', '', cleaned).strip() return cleaned def clean_track_name(text: str) -> str: """Remove unwanted parenthetical content from track names (dates, Remaster, Lyrics, Official, etc).""" if not text: return "" # Remove common patterns in parentheses patterns = [ r'\s*\(\s*\d{4}\s+[Rr]emaster(?:ed)?\s*\)', # (2005 Remaster) r'\s*\(\s*\d{4}\s+[Rr]emaster(?:ed)?\s+[Vv]ersion\s*\)', # (2005 Remastered Version) r'\s*\(\s*[Ll]yrics\s*\)', # (Lyrics) r'\s*\(\s*[Oo]fficial\s+[Vv]ideo\s*\)', # (Official Video) r'\s*\(\s*[Oo]fficial\s+[Aa]udio\s*\)', # (Official Audio) r'\s*\(\s*[Oo]fficial\s*\)', # (Official) r'\s*\(\s*[Ll]ive\s+(?:at\s+)?.*?\)', # (Live) or (Live at Madison Square Garden) r'\s*\(\s*[Ll]ive\s*\)', # (Live) r'\s*\(\s*[Aa]lternate\s+[Vv]ersion\s*\)', # (Alternate Version) r'\s*\(\s*[Aa]lternative\s+[Vv]ersion\s*\)', # (Alternative Version) r'\s*\(\s*[Ff]eature(?:ing)?\s+.*?\)', # (Feature Remix, Feat. Artist, etc) r'\s*\(\s*[Rr]emix\s*\)', # (Remix) r'\s*\(\s*[Dd]eep\s+[Hh]ouse\s*\)', # (Deep House) r'\s*\(\s*[Cc]lean\s*\)', # (Clean) r'\s*\(\s*[Ee]xplicit\s*\)', # (Explicit) r'\s*-\s*[Ll]yrics\s*$', # "- Lyrics" at the end ] result = text for pattern in patterns: result = re.sub(pattern, '', result, flags=re.IGNORECASE) # Clean up multiple spaces result = re.sub(r'\s+', ' ', result).strip() return result # Database of official artist names with their common variations ARTIST_NAME_CORRECTIONS = { # Format: normalized_key -> official_name "blink182": "Blink-182", "blink 182": "Blink-182", "blink-182": "Blink-182", "linkinpark": "Linkin Park", "linkin park": "Linkin Park", "linkin-park": "Linkin Park", "thebeatles": "The Beatles", "the beatles": "The Beatles", "beatles": "The Beatles", "acdc": "AC/DC", "ac/dc": "AC/DC", "ac dc": "AC/DC", "pinkfloyd": "Pink Floyd", "pink floyd": "Pink Floyd", "pink-floyd": "Pink Floyd", "ledzeppelin": "Led Zeppelin", "led zeppelin": "Led Zeppelin", "led-zeppelin": "Led Zeppelin", "thewho": "The Who", "the who": "The Who", "metallica": "Metallica", "gunsnroses": "Guns N' Roses", "guns n roses": "Guns N' Roses", "guns n' roses": "Guns N' Roses", "guns n roses": "Guns N' Roses", "guns-n-roses": "Guns N' Roses", "redhotchilipeppers": "Red Hot Chili Peppers", "red hot chili peppers": "Red Hot Chili Peppers", "rhcp": "Red Hot Chili Peppers", "greenday": "Green Day", "green day": "Green Day", "green-day": "Green Day", "thepinkfloyd": "Pink Floyd", "rollingstone": "The Rolling Stones", "rolling stone": "The Rolling Stones", "rolling stones": "The Rolling Stones", "therollingstone": "The Rolling Stones", "therollingstones": "The Rolling Stones", "foofighter": "Foo Fighters", "foo fighter": "Foo Fighters", "foo fighters": "Foo Fighters", "thirty second to mars": "Thirty Seconds to Mars", "thirty seconds to mars": "Thirty Seconds to Mars", "30 seconds to mars": "Thirty Seconds to Mars", "30secondstomars": "Thirty Seconds to Mars", "thirtysecondstomars": "Thirty Seconds to Mars", "jared leto": "Thirty Seconds to Mars", } def normalize_artist_name(artist: str) -> str: """ Normalize artist name to official name using correction database. Examples: "blink182" -> "Blink-182" "blink‐182" -> "Blink-182" (Unicode hyphen from MusicBrainz) "linkin park" -> "Linkin Park" "guns n roses" -> "Guns N' Roses" """ if not artist: return artist # Normalize all kinds of hyphens/dashes to standard ASCII hyphen artist = artist.replace('\u2010', '-') # Unicode hyphen ‐ artist = artist.replace('\u2011', '-') # Non-breaking hyphen ‑ artist = artist.replace('\u2012', '-') # Figure dash ‒ artist = artist.replace('\u2013', '-') # En dash – artist = artist.replace('\u2014', '-') # Em dash — # Create a normalized key for lookup (remove spaces, hyphens, apostrophes) normalized_key = artist.lower().replace(" ", "").replace("-", "").replace("'", "") # Check if we have a correction for this artist if normalized_key in ARTIST_NAME_CORRECTIONS: return ARTIST_NAME_CORRECTIONS[normalized_key] # If no correction found, return the original artist name return artist