diff --git a/data/server_duplicates_tracking.json b/data/server_duplicates_tracking.json index ca0d4bd..3dd84c5 100644 --- a/data/server_duplicates_tracking.json +++ b/data/server_duplicates_tracking.json @@ -7966,5 +7966,69 @@ "channel": "@KaraokeOnVEVO", "marked_at": "2025-07-28T08:08:58.214983", "reason": "already_on_server" + }, + "zara larsson_midnight sun": { + "artist": "Zara Larsson", + "title": "Midnight Sun", + "video_title": "Zara Larsson Midnight Sun", + "channel": "@sing2karaoke", + "marked_at": "2025-07-28T09:18:12.805038", + "reason": "already_on_server" + }, + "imagine dragons, j i d_enemy": { + "artist": "Imagine Dragons, J I D", + "title": "Enemy", + "video_title": "Imagine Dragons, J I D Enemy", + "channel": "@sing2karaoke", + "marked_at": "2025-07-28T09:18:12.822951", + "reason": "already_on_server" + }, + "jonas blue, why don't we_don't wake me up": { + "artist": "Jonas Blue, Why Don't We", + "title": "Don't Wake Me Up", + "video_title": "Jonas Blue, Why Don't We Don't Wake Me Up", + "channel": "@sing2karaoke", + "marked_at": "2025-07-28T09:18:12.844018", + "reason": "already_on_server" + }, + "rex orange county_pluto projector": { + "artist": "Rex Orange County", + "title": "Pluto Projector", + "video_title": "Rex Orange County Pluto Projector", + "channel": "@sing2karaoke", + "marked_at": "2025-07-28T09:18:12.858730", + "reason": "already_on_server" + }, + "charlie puth_light switch": { + "artist": "Charlie Puth", + "title": "Light Switch", + "video_title": "Charlie Puth Light Switch", + "channel": "@sing2karaoke", + "marked_at": "2025-07-28T09:18:12.878327", + "reason": "already_on_server" + }, + "the rolling stones_(i can't get no) satisfaction": { + "artist": "The Rolling Stones", + "title": "(I Can't Get No) Satisfaction", + "video_title": "(I Can't Get No) Satisfaction - The Rolling Stones KARAOKE Without Backing Vocals", + "channel": "@VocalStarKaraoke", + "marked_at": "2025-07-28T09:18:13.023146", + "reason": "already_on_server" + }, + "lauren spencer smith_fingers crossed": { + "artist": "Lauren Spencer Smith", + "title": "Fingers Crossed", + "video_title": "Lauren Spencer Smith Fingers Crossed", + "channel": "@sing2karaoke", + "marked_at": "2025-07-28T09:20:07.067847", + "reason": "already_on_server" + }, + "tems_crazy tings": { + "artist": "Tems", + "title": "Crazy Tings", + "video_title": "Tems Crazy Tings", + "channel": "@sing2karaoke", + "marked_at": "2025-07-28T09:20:07.089571", + "reason": "already_on_server" } } \ No newline at end of file diff --git a/karaoke_downloader/channel_parser.py b/karaoke_downloader/channel_parser.py new file mode 100644 index 0000000..b197bc3 --- /dev/null +++ b/karaoke_downloader/channel_parser.py @@ -0,0 +1,254 @@ +""" +Channel-specific parsing utilities for extracting artist and title from video titles. + +This module handles the different title formats used by various karaoke channels, +providing channel-specific parsing rules to extract artist and title information +correctly for ID3 tagging and filename generation. +""" + +import json +import re +from typing import Dict, List, Optional, Tuple, Any +from pathlib import Path + + +class ChannelParser: + """Handles channel-specific parsing of video titles to extract artist and title.""" + + def __init__(self, channels_file: str = "data/channels.json"): + """Initialize the parser with channel configuration.""" + self.channels_file = Path(channels_file) + self.channels_config = self._load_channels_config() + + def _load_channels_config(self) -> Dict[str, Any]: + """Load the channels configuration from JSON file.""" + if not self.channels_file.exists(): + raise FileNotFoundError(f"Channels configuration file not found: {self.channels_file}") + + with open(self.channels_file, 'r', encoding='utf-8') as f: + return json.load(f) + + def get_channel_config(self, channel_name: str) -> Optional[Dict[str, Any]]: + """Get the configuration for a specific channel.""" + for channel in self.channels_config.get("channels", []): + if channel["name"] == channel_name: + return channel + return None + + def extract_artist_title(self, video_title: str, channel_name: str) -> Tuple[str, str]: + """ + Extract artist and title from a video title using channel-specific parsing rules. + + Args: + video_title: The full video title from YouTube + channel_name: The name of the channel (must match config) + + Returns: + Tuple of (artist, title) - both may be empty strings if parsing fails + """ + channel_config = self.get_channel_config(channel_name) + if not channel_config: + # Fallback to global settings + return self._fallback_parse(video_title) + + parsing_rules = channel_config.get("parsing_rules", {}) + format_type = parsing_rules.get("format", "artist_title_separator") + + if format_type == "artist_title_separator": + return self._parse_artist_title_separator(video_title, parsing_rules) + elif format_type == "artist_title_spaces": + return self._parse_artist_title_spaces(video_title, parsing_rules) + elif format_type == "title_artist_pipe": + return self._parse_title_artist_pipe(video_title, parsing_rules) + else: + return self._fallback_parse(video_title) + + def _parse_artist_title_separator(self, video_title: str, rules: Dict[str, Any]) -> Tuple[str, str]: + """Parse format: 'Artist - Title' or 'Title - Artist'.""" + separator = rules.get("separator", " - ") + artist_first = rules.get("artist_first", True) + + if separator not in video_title: + return "", video_title.strip() + + parts = video_title.split(separator, 1) + if len(parts) != 2: + return "", video_title.strip() + + part1, part2 = parts[0].strip(), parts[1].strip() + + # Apply cleanup to both parts + part1_clean = self._cleanup_title(part1, rules.get("title_cleanup", {})) + part2_clean = self._cleanup_title(part2, rules.get("title_cleanup", {})) + + if artist_first: + return part1_clean, part2_clean + else: + return part2_clean, part1_clean + + def _parse_artist_title_spaces(self, video_title: str, rules: Dict[str, Any]) -> Tuple[str, str]: + """Parse format: 'Artist Title' (multiple spaces).""" + separator = rules.get("separator", " ") + multi_artist_sep = rules.get("multi_artist_separator", ", ") + + # Try multiple space patterns to handle inconsistent spacing + # Look for the LAST occurrence of multiple spaces to handle cases with commas + space_patterns = [" ", " ", " "] # 3, 2, 4 spaces + + for pattern in space_patterns: + if pattern in video_title: + # Split on the LAST occurrence of the pattern + last_index = video_title.rfind(pattern) + if last_index != -1: + artist_part = video_title[:last_index].strip() + title_part = video_title[last_index + len(pattern):].strip() + + # Handle multiple artists (e.g., "Artist1, Artist2") + if multi_artist_sep in artist_part: + # Keep the full artist string as is + artist = artist_part + else: + artist = artist_part + + title = self._cleanup_title(title_part, rules.get("title_cleanup", {})) + + return artist, title + + # Try dash patterns as fallback for inconsistent formatting + dash_patterns = [" - ", " – ", " -"] # Regular dash, en dash, dash without trailing space + + for pattern in dash_patterns: + if pattern in video_title: + # Split on the LAST occurrence of the pattern + last_index = video_title.rfind(pattern) + if last_index != -1: + artist_part = video_title[:last_index].strip() + title_part = video_title[last_index + len(pattern):].strip() + + # Handle multiple artists (e.g., "Artist1, Artist2") + if multi_artist_sep in artist_part: + # Keep the full artist string as is + artist = artist_part + else: + artist = artist_part + + title = self._cleanup_title(title_part, rules.get("title_cleanup", {})) + + return artist, title + + # If no pattern matches, return empty artist and full title + return "", video_title.strip() + + def _parse_title_artist_pipe(self, video_title: str, rules: Dict[str, Any]) -> Tuple[str, str]: + """Parse format: 'Title | Artist'.""" + separator = rules.get("separator", " | ") + + if separator not in video_title: + return "", video_title.strip() + + parts = video_title.split(separator, 1) + if len(parts) != 2: + return "", video_title.strip() + + title_part, artist_part = parts[0].strip(), parts[1].strip() + + title = self._cleanup_title(title_part, rules.get("title_cleanup", {})) + artist = self._cleanup_title(artist_part, rules.get("artist_cleanup", {})) + + return artist, title + + def _cleanup_title(self, text: str, cleanup_rules: Dict[str, Any]) -> str: + """Apply cleanup rules to remove suffixes and normalize text.""" + if not cleanup_rules: + return text.strip() + + cleaned = text.strip() + + # Handle remove_suffix rule + if "remove_suffix" in cleanup_rules: + suffixes = cleanup_rules["remove_suffix"].get("suffixes", []) + for suffix in suffixes: + if cleaned.endswith(suffix): + cleaned = cleaned[:-len(suffix)].strip() + break + + return cleaned + + def _fallback_parse(self, video_title: str) -> Tuple[str, str]: + """Fallback parsing using global settings.""" + global_settings = self.channels_config.get("global_parsing_settings", {}) + fallback_format = global_settings.get("fallback_format", "artist_title_separator") + fallback_separator = global_settings.get("fallback_separator", " - ") + + if fallback_format == "artist_title_separator": + if fallback_separator in video_title: + parts = video_title.split(fallback_separator, 1) + if len(parts) == 2: + artist = parts[0].strip() + title = parts[1].strip() + # Apply global suffix cleanup + for suffix in global_settings.get("common_suffixes", []): + if title.endswith(suffix): + title = title[:-len(suffix)].strip() + break + return artist, title + + # If all else fails, return empty artist and full title + return "", video_title.strip() + + def is_playlist_title(self, video_title: str, channel_name: str) -> bool: + """Check if a video title appears to be a playlist rather than a single song.""" + channel_config = self.get_channel_config(channel_name) + if not channel_config: + return self._is_playlist_by_global_rules(video_title) + + parsing_rules = channel_config.get("parsing_rules", {}) + playlist_indicators = parsing_rules.get("playlist_indicators", []) + + if not playlist_indicators: + return self._is_playlist_by_global_rules(video_title) + + title_upper = video_title.upper() + for indicator in playlist_indicators: + if indicator.upper() in title_upper: + return True + + return False + + def _is_playlist_by_global_rules(self, video_title: str) -> bool: + """Check if title is a playlist using global rules.""" + global_settings = self.channels_config.get("global_parsing_settings", {}) + playlist_indicators = global_settings.get("playlist_indicators", []) + + title_upper = video_title.upper() + for indicator in playlist_indicators: + if indicator.upper() in title_upper: + return True + + return False + + def get_all_channel_names(self) -> List[str]: + """Get a list of all configured channel names.""" + return [channel["name"] for channel in self.channels_config.get("channels", [])] + + def get_channel_url(self, channel_name: str) -> Optional[str]: + """Get the URL for a specific channel.""" + channel_config = self.get_channel_config(channel_name) + return channel_config.get("url") if channel_config else None + + +# Convenience function for backward compatibility +def extract_artist_title(video_title: str, channel_name: str, channels_file: str = "data/channels.json") -> Tuple[str, str]: + """ + Convenience function to extract artist and title from a video title. + + Args: + video_title: The full video title from YouTube + channel_name: The name of the channel + channels_file: Path to the channels configuration file + + Returns: + Tuple of (artist, title) + """ + parser = ChannelParser(channels_file) + return parser.extract_artist_title(video_title, channel_name) \ No newline at end of file diff --git a/karaoke_downloader/cli.py b/karaoke_downloader/cli.py index 959fc14..2f74c52 100644 --- a/karaoke_downloader/cli.py +++ b/karaoke_downloader/cli.py @@ -1,17 +1,95 @@ +#!/usr/bin/env python3 +""" +Karaoke Video Downloader CLI +Command-line interface for the karaoke video downloader. +""" + import argparse import os import sys - from pathlib import Path -import json +from typing import List +from karaoke_downloader.channel_parser import ChannelParser +from karaoke_downloader.config_manager import AppConfig from karaoke_downloader.downloader import KaraokeDownloader # Constants +DEFAULT_LATEST_PER_CHANNEL_LIMIT = 10 DEFAULT_FUZZY_THRESHOLD = 85 -DEFAULT_LATEST_PER_CHANNEL_LIMIT = 5 -DEFAULT_DISPLAY_LIMIT = 10 -DEFAULT_CACHE_DURATION_HOURS = 24 + + +def load_channels_from_json(channels_file: str = "data/channels.json") -> List[str]: + """ + Load channel URLs from the new JSON format. + + Args: + channels_file: Path to the channels.json file + + Returns: + List of channel URLs + """ + try: + parser = ChannelParser(channels_file) + channels = parser.channels_config.get("channels", []) + return [channel["url"] for channel in channels] + except Exception as e: + print(f"❌ Error loading channels from {channels_file}: {e}") + return [] + + +def load_channels_from_text(channels_file: str = "data/channels.txt") -> List[str]: + """ + Load channel URLs from the old text format (for backward compatibility). + + Args: + channels_file: Path to the channels.txt file + + Returns: + List of channel URLs + """ + try: + with open(channels_file, "r", encoding="utf-8") as f: + return [ + line.strip() + for line in f + if line.strip() and not line.strip().startswith("#") + ] + except Exception as e: + print(f"❌ Error loading channels from {channels_file}: {e}") + return [] + + +def load_channels(channel_file: str = None) -> List[str]: + """ + Load channel URLs from either JSON or text format. + + Args: + channel_file: Path to the channel file (optional) + + Returns: + List of channel URLs + """ + if channel_file: + # Use the specified file + if channel_file.endswith('.json'): + return load_channels_from_json(channel_file) + else: + return load_channels_from_text(channel_file) + else: + # Try JSON first, then fall back to text + json_file = "data/channels.json" + txt_file = "data/channels.txt" + + if os.path.exists(json_file): + print(f"📋 Using new JSON format: {json_file}") + return load_channels_from_json(json_file) + elif os.path.exists(txt_file): + print(f"📋 Using legacy text format: {txt_file}") + return load_channels_from_text(txt_file) + else: + print("❌ No channel file found. Please create data/channels.json or data/channels.txt") + return [] def main(): @@ -282,17 +360,16 @@ Examples: sys.exit(0) # --- END NEW --- - # --- NEW: If no URL or file is provided, but --songlist-only is set, use all channels in data/channels.txt --- + # --- NEW: If no URL or file is provided, but --songlist-only is set, use all channels --- if (args.songlist_only or args.songlist_focus) and not args.url and not args.file: - channels_file = Path("data/channels.txt") - if channels_file.exists(): - args.file = str(channels_file) + channel_urls = load_channels() + if channel_urls: print( - "📋 No URL or --file provided, defaulting to all channels in data/channels.txt for songlist mode." + "📋 No URL or --file provided, defaulting to all configured channels for songlist mode." ) else: print( - "❌ No URL, --file, or data/channels.txt found. Please provide a channel URL or a file with channel URLs." + "❌ No URL, --file, or channel configuration found. Please provide a channel URL or create data/channels.json." ) sys.exit(1) # --- END NEW --- @@ -388,17 +465,11 @@ Examples: print(f" ... and {len(tracking) - 10} more") sys.exit(0) elif args.songlist_only or args.songlist_focus: - # Use provided file or default to data/channels.txt - channel_file = args.file if args.file else "data/channels.txt" - if not os.path.exists(channel_file): - print(f"❌ Channel file not found: {channel_file}") + # Use provided file or default to channels configuration + channel_urls = load_channels(args.file) + if not channel_urls: + print(f"❌ No channels found in configuration") sys.exit(1) - with open(channel_file, "r", encoding="utf-8") as f: - channel_urls = [ - line.strip() - for line in f - if line.strip() and not line.strip().startswith("#") - ] limit = args.limit if args.limit else None success = downloader.download_songlist_across_channels( channel_urls, @@ -412,17 +483,11 @@ Examples: max_channel_workers=args.channel_workers, ) elif args.latest_per_channel: - # Use provided file or default to data/channels.txt - channel_file = args.file if args.file else "data/channels.txt" - if not os.path.exists(channel_file): - print(f"❌ Channel file not found: {channel_file}") + # Use provided file or default to channels configuration + channel_urls = load_channels(args.file) + if not channel_urls: + print(f"❌ No channels found in configuration") sys.exit(1) - with open(channel_file, "r", encoding="utf-8") as f: - channel_urls = [ - line.strip() - for line in f - if line.strip() and not line.strip().startswith("#") - ] limit = args.limit if args.limit else DEFAULT_LATEST_PER_CHANNEL_LIMIT force_refresh_download_plan = ( args.force_download_plan if hasattr(args, "force_download_plan") else False @@ -448,17 +513,11 @@ Examples: else: # Default behavior: download from channels (equivalent to --latest-per-channel) print("🎯 No specific mode specified, defaulting to download from channels") - channel_file = args.file if args.file else "data/channels.txt" - if not os.path.exists(channel_file): - print(f"❌ Channel file not found: {channel_file}") - print("Please provide a channel URL or ensure data/channels.txt exists") + channel_urls = load_channels(args.file) + if not channel_urls: + print(f"❌ No channels found in configuration") + print("Please provide a channel URL or create data/channels.json") sys.exit(1) - with open(channel_file, "r", encoding="utf-8") as f: - channel_urls = [ - line.strip() - for line in f - if line.strip() and not line.strip().startswith("#") - ] limit = args.limit if args.limit else DEFAULT_LATEST_PER_CHANNEL_LIMIT force_refresh_download_plan = ( args.force_download_plan if hasattr(args, "force_download_plan") else False diff --git a/karaoke_downloader/download_pipeline.py b/karaoke_downloader/download_pipeline.py index 07705c8..3265183 100644 --- a/karaoke_downloader/download_pipeline.py +++ b/karaoke_downloader/download_pipeline.py @@ -297,9 +297,10 @@ class DownloadPipeline: video_title = video.get("title", "") # Extract artist and title from video title - from karaoke_downloader.id3_utils import extract_artist_title + from karaoke_downloader.channel_parser import ChannelParser - artist, title = extract_artist_title(video_title) + channel_parser = ChannelParser() + artist, title = channel_parser.extract_artist_title(video_title, channel_name) print(f" ({i}/{total}) Processing: {artist} - {title}") diff --git a/karaoke_downloader/download_planner.py b/karaoke_downloader/download_planner.py index f6c20cc..71fe36a 100644 --- a/karaoke_downloader/download_planner.py +++ b/karaoke_downloader/download_planner.py @@ -17,17 +17,16 @@ from karaoke_downloader.cache_manager import ( load_cached_plan, save_plan_cache, ) -# Import all fuzzy matching functions including the enhanced extract_artist_title -# This ensures consistent parsing across all modules and supports multiple video title formats +# Import all fuzzy matching functions from karaoke_downloader.fuzzy_matcher import ( create_song_key, create_video_key, - extract_artist_title, get_similarity_function, is_exact_match, is_fuzzy_match, normalize_title, ) +from karaoke_downloader.channel_parser import ChannelParser from karaoke_downloader.youtube_utils import get_channel_info # Constants @@ -127,10 +126,11 @@ def _scan_channel_for_matches( video_matches = [] # Pre-process video titles for efficient matching + channel_parser = ChannelParser() if fuzzy_match: # For fuzzy matching, create normalized video keys for video in available_videos: - v_artist, v_title = extract_artist_title(video["title"]) + v_artist, v_title = channel_parser.extract_artist_title(video["title"], channel_name) video_key = create_song_key(v_artist, v_title) # Find best match among remaining songs @@ -162,7 +162,7 @@ def _scan_channel_for_matches( else: # For exact matching, use direct key comparison for video in available_videos: - v_artist, v_title = extract_artist_title(video["title"]) + v_artist, v_title = channel_parser.extract_artist_title(video["title"], channel_name) video_key = create_song_key(v_artist, v_title) if video_key in song_keys: @@ -241,10 +241,11 @@ def build_download_plan( video_matches = [] # Pre-process video titles for efficient matching + channel_parser = ChannelParser() if fuzzy_match: # For fuzzy matching, create normalized video keys for video in available_videos: - v_artist, v_title = extract_artist_title(video["title"]) + v_artist, v_title = channel_parser.extract_artist_title(video["title"], channel_name) video_key = create_song_key(v_artist, v_title) # Find best match among remaining songs (thread-safe) @@ -283,7 +284,7 @@ def build_download_plan( else: # For exact matching, use direct key comparison for video in available_videos: - v_artist, v_title = extract_artist_title(video["title"]) + v_artist, v_title = channel_parser.extract_artist_title(video["title"], channel_name) video_key = create_song_key(v_artist, v_title) with song_lookup_lock: @@ -345,10 +346,11 @@ def build_download_plan( video_matches = [] # Initialize video_matches for this channel # Pre-process video titles for efficient matching + channel_parser = ChannelParser() if fuzzy_match: # For fuzzy matching, create normalized video keys for video in available_videos: - v_artist, v_title = extract_artist_title(video["title"]) + v_artist, v_title = channel_parser.extract_artist_title(video["title"], channel_name) video_key = create_song_key(v_artist, v_title) # Find best match among remaining songs @@ -381,7 +383,7 @@ def build_download_plan( else: # For exact matching, use direct key comparison for video in available_videos: - v_artist, v_title = extract_artist_title(video["title"]) + v_artist, v_title = channel_parser.extract_artist_title(video["title"], channel_name) video_key = create_song_key(v_artist, v_title) if video_key in song_keys: diff --git a/karaoke_downloader/downloader.py b/karaoke_downloader/downloader.py index 9e0be78..b91774b 100644 --- a/karaoke_downloader/downloader.py +++ b/karaoke_downloader/downloader.py @@ -32,7 +32,8 @@ from karaoke_downloader.fuzzy_matcher import ( is_exact_match, is_fuzzy_match, ) -from karaoke_downloader.id3_utils import add_id3_tags, extract_artist_title +from karaoke_downloader.id3_utils import add_id3_tags +from karaoke_downloader.channel_parser import ChannelParser from karaoke_downloader.server_manager import ( check_and_mark_server_duplicate, is_song_marked_as_server_duplicate, @@ -105,6 +106,9 @@ class KaraokeDownloader: # Load server songs for availability checking self.server_songs = load_server_songs() + # Initialize channel parser for title parsing + self.channel_parser = ChannelParser() + # Parallel download settings self.enable_parallel_downloads = False self.parallel_workers = 3 @@ -220,7 +224,7 @@ class KaraokeDownloader: matches = [] similarity = get_similarity_function() for video in available_videos: - artist, title = extract_artist_title(video["title"]) + artist, title = self.channel_parser.extract_artist_title(video["title"], channel_name) key = create_song_key(artist, title) if fuzzy_match: # Fuzzy match against all songlist keys @@ -702,7 +706,7 @@ class KaraokeDownloader: ) # Extract artist and title for tracking - artist, title_clean = extract_artist_title(title) + artist, title_clean = self.channel_parser.extract_artist_title(title, channel_name) task = DownloadTask( video_id=video_id, @@ -810,7 +814,7 @@ class KaraokeDownloader: ) filename = f"{channel_name} - {safe_title}.mp4" # Extract artist and title for tracking - artist, title_clean = extract_artist_title(title) + artist, title_clean = self.channel_parser.extract_artist_title(title, channel_name) print( f" ({v_idx+1}/{len(videos)}) Processing: {artist} - {title_clean}" @@ -942,7 +946,7 @@ class KaraokeDownloader: # Pre-filter videos to exclude known duplicates before processing pre_filtered_videos = [] for video in available_videos: - artist, title = extract_artist_title(video["title"]) + artist, title = self.channel_parser.extract_artist_title(video["title"], channel_name) song_key = create_song_key(artist, title) if song_key not in known_duplicate_keys: pre_filtered_videos.append(video) @@ -960,7 +964,7 @@ class KaraokeDownloader: break # We have enough videos for this channel videos_checked += 1 - artist, title = extract_artist_title(video["title"]) + artist, title = self.channel_parser.extract_artist_title(video["title"], channel_name) # Check if should skip this song during planning phase should_skip, reason, filtered_count = self._should_skip_song( diff --git a/karaoke_downloader/file_utils.py b/karaoke_downloader/file_utils.py index f9c9931..055f761 100644 --- a/karaoke_downloader/file_utils.py +++ b/karaoke_downloader/file_utils.py @@ -54,12 +54,19 @@ def sanitize_filename( ) safe_artist = safe_artist.strip() - # Create filename - filename = f"{safe_artist} - {safe_title}.mp4" + # Create filename - handle empty artist case + if not safe_artist or safe_artist.strip() == "": + # If no artist, just use the title + filename = f"{safe_title}.mp4" + else: + filename = f"{safe_artist} - {safe_title}.mp4" # Limit filename length if needed if len(filename) > max_length: - filename = f"{safe_artist[:DEFAULT_ARTIST_LENGTH_LIMIT]} - {safe_title[:DEFAULT_TITLE_LENGTH_LIMIT]}.mp4" + if not safe_artist or safe_artist.strip() == "": + filename = f"{safe_title[:DEFAULT_TITLE_LENGTH_LIMIT]}.mp4" + else: + filename = f"{safe_artist[:DEFAULT_ARTIST_LENGTH_LIMIT]} - {safe_title[:DEFAULT_TITLE_LENGTH_LIMIT]}.mp4" return filename @@ -81,11 +88,19 @@ def generate_possible_filenames( safe_title = sanitize_title_for_filenames(title) safe_artist = artist.replace("'", "").replace('"', "").strip() - return [ - f"{safe_artist} - {safe_title}.mp4", # Songlist mode - f"{channel_name} - {safe_title}.mp4", # Latest-per-channel mode - f"{safe_artist} - {safe_title} (Karaoke Version).mp4", # Channel videos mode - ] + # Handle empty artist case + if not safe_artist or safe_artist.strip() == "": + return [ + f"{safe_title}.mp4", # Songlist mode (no artist) + f"{channel_name} - {safe_title}.mp4", # Latest-per-channel mode + f"{safe_title} (Karaoke Version).mp4", # Channel videos mode (no artist) + ] + else: + return [ + f"{safe_artist} - {safe_title}.mp4", # Songlist mode + f"{channel_name} - {safe_title}.mp4", # Latest-per-channel mode + f"{safe_artist} - {safe_title} (Karaoke Version).mp4", # Channel videos mode + ] def sanitize_title_for_filenames(title: str) -> str: @@ -131,7 +146,10 @@ def check_file_exists_with_patterns( # Apply length limits if needed safe_artist = artist.replace("'", "").replace('"', "").strip() safe_title = sanitize_title_for_filenames(title) - filename = f"{safe_artist[:DEFAULT_ARTIST_LENGTH_LIMIT]} - {safe_title[:DEFAULT_TITLE_LENGTH_LIMIT]}.mp4" + if not safe_artist or safe_artist.strip() == "": + filename = f"{safe_title[:DEFAULT_TITLE_LENGTH_LIMIT]}.mp4" + else: + filename = f"{safe_artist[:DEFAULT_ARTIST_LENGTH_LIMIT]} - {safe_title[:DEFAULT_TITLE_LENGTH_LIMIT]}.mp4" # Check for exact filename match file_path = channel_dir / filename