Initial commit: Add playlist downloader project with UI and core functionality

This commit is contained in:
2026-06-28 10:34:48 +02:00
commit 605dede363
49 changed files with 8300 additions and 0 deletions
+288
View File
@@ -0,0 +1,288 @@
"""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"
"blink182" -> "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