import json import os from pathlib import Path from typing import List, Dict, Any, Optional from mutagen.mp4 import MP4 from karaoke_downloader.data_path_manager import get_data_path_manager class SongListGenerator: """Utility class for generating song lists from MP4 files with ID3 tags.""" def __init__(self, songlist_path: str = None): if songlist_path is None: songlist_path = str(get_data_path_manager().get_songlist_path()) self.songlist_path = Path(songlist_path) self.songlist_path.parent.mkdir(parents=True, exist_ok=True) def read_existing_songlist(self) -> List[Dict[str, Any]]: """Read existing song list from JSON file.""" if self.songlist_path.exists(): try: with open(self.songlist_path, 'r', encoding='utf-8') as f: return json.load(f) except (json.JSONDecodeError, IOError) as e: print(f"⚠️ Warning: Could not read existing songlist: {e}") return [] return [] def save_songlist(self, songlist: List[Dict[str, Any]]) -> None: """Save song list to JSON file.""" try: with open(self.songlist_path, 'w', encoding='utf-8') as f: json.dump(songlist, f, indent=2, ensure_ascii=False) print(f"✅ Song list saved to {self.songlist_path}") except IOError as e: print(f"❌ Error saving song list: {e}") raise def extract_id3_tags(self, mp4_path: Path) -> Optional[Dict[str, str]]: """Extract ID3 tags from MP4 file.""" try: mp4 = MP4(str(mp4_path)) # Extract artist and title from ID3 tags artist = mp4.get("\xa9ART", ["Unknown Artist"])[0] if "\xa9ART" in mp4 else "Unknown Artist" title = mp4.get("\xa9nam", ["Unknown Title"])[0] if "\xa9nam" in mp4 else "Unknown Title" return { "artist": artist, "title": title } except Exception as e: print(f"⚠️ Warning: Could not extract ID3 tags from {mp4_path.name}: {e}") return None def scan_directory_for_mp4_files(self, directory_path: str) -> List[Path]: """Scan directory for MP4 files.""" directory = Path(directory_path) if not directory.exists(): raise FileNotFoundError(f"Directory not found: {directory_path}") if not directory.is_dir(): raise ValueError(f"Path is not a directory: {directory_path}") mp4_files = list(directory.glob("*.mp4")) if not mp4_files: print(f"⚠️ No MP4 files found in {directory_path}") return [] print(f"📁 Found {len(mp4_files)} MP4 files in {directory.name}") return sorted(mp4_files) def generate_songlist_from_directory(self, directory_path: str, append: bool = True) -> Dict[str, Any]: """Generate a song list from MP4 files in a directory.""" directory = Path(directory_path) directory_name = directory.name # Scan for MP4 files mp4_files = self.scan_directory_for_mp4_files(directory_path) if not mp4_files: return {} # Extract ID3 tags and create songs list songs = [] for index, mp4_file in enumerate(mp4_files, start=1): id3_tags = self.extract_id3_tags(mp4_file) if id3_tags: song = { "position": index, "title": id3_tags["title"], "artist": id3_tags["artist"] } songs.append(song) print(f" {index:3d}. {id3_tags['artist']} - {id3_tags['title']}") if not songs: print("❌ No valid ID3 tags found in any MP4 files") return {} # Create the song list entry songlist_entry = { "title": directory_name, "songs": songs } # Handle appending to existing song list if append: existing_songlist = self.read_existing_songlist() # Check if a playlist with this title already exists existing_index = None for i, entry in enumerate(existing_songlist): if entry.get("title") == directory_name: existing_index = i break if existing_index is not None: # Replace existing entry print(f"🔄 Replacing existing playlist: {directory_name}") existing_songlist[existing_index] = songlist_entry else: # Add new entry to the beginning of the list print(f"➕ Adding new playlist: {directory_name}") existing_songlist.insert(0, songlist_entry) self.save_songlist(existing_songlist) else: # Create new song list with just this entry print(f"📝 Creating new song list with playlist: {directory_name}") self.save_songlist([songlist_entry]) return songlist_entry def generate_songlist_from_multiple_directories(self, directory_paths: List[str], append: bool = True) -> List[Dict[str, Any]]: """Generate song lists from multiple directories.""" results = [] errors = [] # Read existing song list once at the beginning existing_songlist = self.read_existing_songlist() if append else [] for directory_path in directory_paths: try: print(f"\n📂 Processing directory: {directory_path}") directory = Path(directory_path) directory_name = directory.name # Scan for MP4 files mp4_files = self.scan_directory_for_mp4_files(directory_path) if not mp4_files: continue # Extract ID3 tags and create songs list songs = [] for index, mp4_file in enumerate(mp4_files, start=1): id3_tags = self.extract_id3_tags(mp4_file) if id3_tags: song = { "position": index, "title": id3_tags["title"], "artist": id3_tags["artist"] } songs.append(song) print(f" {index:3d}. {id3_tags['artist']} - {id3_tags['title']}") if not songs: print("❌ No valid ID3 tags found in any MP4 files") continue # Create the song list entry songlist_entry = { "title": directory_name, "songs": songs } # Check if a playlist with this title already exists existing_index = None for i, entry in enumerate(existing_songlist): if entry.get("title") == directory_name: existing_index = i break if existing_index is not None: # Replace existing entry print(f"🔄 Replacing existing playlist: {directory_name}") existing_songlist[existing_index] = songlist_entry else: # Add new entry to the beginning of the list print(f"➕ Adding new playlist: {directory_name}") existing_songlist.insert(0, songlist_entry) results.append(songlist_entry) except Exception as e: error_msg = f"Error processing {directory_path}: {e}" print(f"❌ {error_msg}") errors.append(error_msg) # Save the final song list if results: if append: # Save the updated existing song list self.save_songlist(existing_songlist) else: # Create new song list with just the results self.save_songlist(results) # If there were any errors, raise an exception if errors: raise Exception(f"Failed to process {len(errors)} directories: {'; '.join(errors)}") return results def main(): """CLI entry point for song list generation.""" import argparse import sys parser = argparse.ArgumentParser( description="Generate song lists from MP4 files with ID3 tags", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python -m karaoke_downloader.songlist_generator /path/to/mp4/directory python -m karaoke_downloader.songlist_generator /path/to/dir1 /path/to/dir2 --no-append python -m karaoke_downloader.songlist_generator /path/to/dir --songlist-path custom_songlist.json """ ) parser.add_argument( "directories", nargs="+", help="Directory paths containing MP4 files with ID3 tags" ) parser.add_argument( "--no-append", action="store_true", help="Create a new song list instead of appending to existing one" ) parser.add_argument( "--songlist-path", default=None, help="Path to the song list JSON file (default: songList.json in the data directory)" ) args = parser.parse_args() try: generator = SongListGenerator(args.songlist_path) generator.generate_songlist_from_multiple_directories( args.directories, append=not args.no_append ) print("\n✅ Song list generation completed successfully!") except Exception as e: print(f"\n❌ Error: {e}") sys.exit(1) if __name__ == "__main__": main()