465 lines
17 KiB
Python
465 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Fix artist name formatting for Let's Sing Karaoke channel.
|
|
|
|
This script specifically targets the "Last Name, First Name" format and converts it to
|
|
"First Name Last Name" format in ID3 tags. It only processes entries where there is exactly one comma
|
|
followed by exactly 2 words, to avoid affecting multi-artist entries.
|
|
|
|
Usage:
|
|
python fix_artist_name_format.py --preview # Show what would be changed
|
|
python fix_artist_name_format.py --apply # Actually make the changes
|
|
python fix_artist_name_format.py --external "D:\Karaoke\Karaoke\MP4\Let's Sing Karaoke" # Use external directory
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import argparse
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple, Optional
|
|
|
|
# Try to import mutagen for ID3 tag manipulation
|
|
try:
|
|
from mutagen.mp4 import MP4
|
|
MUTAGEN_AVAILABLE = True
|
|
except ImportError:
|
|
MUTAGEN_AVAILABLE = False
|
|
print("⚠️ mutagen not available - install with: pip install mutagen")
|
|
|
|
|
|
def is_lastname_firstname_format(artist_name: str) -> bool:
|
|
"""
|
|
Check if artist name is in "Last Name, First Name" format.
|
|
|
|
Args:
|
|
artist_name: The artist name to check
|
|
|
|
Returns:
|
|
True if the name matches "Last Name, First Name" format with exactly 2 words after comma
|
|
"""
|
|
if ',' not in artist_name:
|
|
return False
|
|
|
|
# Split by comma
|
|
parts = artist_name.split(',', 1)
|
|
if len(parts) != 2:
|
|
return False
|
|
|
|
last_name = parts[0].strip()
|
|
first_name_part = parts[1].strip()
|
|
|
|
# Check if there are exactly 2 words after the comma
|
|
words_after_comma = first_name_part.split()
|
|
if len(words_after_comma) != 2:
|
|
return False
|
|
|
|
# Additional check: make sure it's not a multi-artist entry
|
|
# If there are more than 2 words total in the artist name, it might be multi-artist
|
|
total_words = len(artist_name.split())
|
|
if total_words > 4: # Last, First Name (4 words max for single artist)
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def convert_to_firstname_lastname(artist_name: str) -> str:
|
|
"""
|
|
Convert "Last Name, First Name" to "First Name Last Name".
|
|
|
|
Args:
|
|
artist_name: Artist name in "Last Name, First Name" format
|
|
|
|
Returns:
|
|
Artist name in "First Name Last Name" format
|
|
"""
|
|
parts = artist_name.split(',', 1)
|
|
last_name = parts[0].strip()
|
|
first_name_part = parts[1].strip()
|
|
|
|
# Split the first name part into words
|
|
words = first_name_part.split()
|
|
if len(words) == 2:
|
|
first_name = words[0]
|
|
middle_name = words[1]
|
|
return f"{first_name} {middle_name} {last_name}"
|
|
else:
|
|
# Fallback - just reverse the parts
|
|
return f"{first_name_part} {last_name}"
|
|
|
|
|
|
def extract_artist_title_from_filename(filename: str) -> Tuple[str, str]:
|
|
"""
|
|
Extract artist and title from a filename.
|
|
|
|
Args:
|
|
filename: MP4 filename (without extension)
|
|
|
|
Returns:
|
|
Tuple of (artist, title)
|
|
"""
|
|
# Remove .mp4 extension
|
|
if filename.endswith('.mp4'):
|
|
filename = filename[:-4]
|
|
|
|
# Look for " - " separator
|
|
if " - " in filename:
|
|
parts = filename.split(" - ", 1)
|
|
return parts[0].strip(), parts[1].strip()
|
|
|
|
return "", filename
|
|
|
|
|
|
def update_id3_tags(file_path: str, new_artist: str, apply_changes: bool = False) -> bool:
|
|
"""
|
|
Update the ID3 tags in an MP4 file.
|
|
|
|
Args:
|
|
file_path: Path to the MP4 file
|
|
new_artist: New artist name to set
|
|
apply_changes: Whether to actually apply changes or just preview
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
if not MUTAGEN_AVAILABLE:
|
|
print(f"⚠️ mutagen not available - cannot update ID3 tags for {file_path}")
|
|
return False
|
|
|
|
try:
|
|
mp4 = MP4(file_path)
|
|
|
|
if apply_changes:
|
|
# Update the artist tag
|
|
mp4["\xa9ART"] = new_artist
|
|
mp4.save()
|
|
print(f"📝 Updated ID3 tag: {os.path.basename(file_path)} → Artist: '{new_artist}'")
|
|
else:
|
|
# Just preview what would be changed
|
|
current_artist = mp4.get("\xa9ART", ["Unknown"])[0] if "\xa9ART" in mp4 else "Unknown"
|
|
print(f"📝 Would update ID3 tag: {os.path.basename(file_path)} → Artist: '{current_artist}' → '{new_artist}'")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"❌ Failed to update ID3 tags for {file_path}: {e}")
|
|
return False
|
|
|
|
|
|
def scan_external_directory(directory_path: str) -> List[Dict]:
|
|
"""
|
|
Scan external directory for MP4 files with "Last Name, First Name" format in ID3 tags.
|
|
|
|
Args:
|
|
directory_path: Path to the external directory
|
|
|
|
Returns:
|
|
List of files that need ID3 tag updates
|
|
"""
|
|
if not os.path.exists(directory_path):
|
|
print(f"❌ Directory not found: {directory_path}")
|
|
return []
|
|
|
|
if not MUTAGEN_AVAILABLE:
|
|
print("❌ mutagen not available - cannot scan ID3 tags")
|
|
return []
|
|
|
|
files_to_update = []
|
|
|
|
# Scan for MP4 files
|
|
for file_path in Path(directory_path).glob("*.mp4"):
|
|
try:
|
|
mp4 = MP4(str(file_path))
|
|
current_artist = mp4.get("\xa9ART", ["Unknown"])[0] if "\xa9ART" in mp4 else "Unknown"
|
|
|
|
if current_artist and is_lastname_firstname_format(current_artist):
|
|
new_artist = convert_to_firstname_lastname(current_artist)
|
|
|
|
files_to_update.append({
|
|
'file_path': str(file_path),
|
|
'filename': file_path.name,
|
|
'old_artist': current_artist,
|
|
'new_artist': new_artist
|
|
})
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ Could not read ID3 tags from {file_path.name}: {e}")
|
|
|
|
return files_to_update
|
|
|
|
|
|
def update_tracking_file(tracking_file: str, channel_name: str = "Let's Sing Karaoke", apply_changes: bool = False) -> Tuple[int, List[Dict]]:
|
|
"""
|
|
Update the karaoke tracking file to fix artist name formatting.
|
|
|
|
Args:
|
|
tracking_file: Path to the tracking JSON file
|
|
channel_name: Channel name to target (default: Let's Sing Karaoke)
|
|
apply_changes: Whether to actually apply changes or just preview
|
|
|
|
Returns:
|
|
Tuple of (number of changes made, list of changed entries)
|
|
"""
|
|
if not os.path.exists(tracking_file):
|
|
print(f"❌ Tracking file not found: {tracking_file}")
|
|
return 0, []
|
|
|
|
# Load the tracking data
|
|
with open(tracking_file, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
changes_made = 0
|
|
changed_entries = []
|
|
|
|
# Process songs
|
|
for song_key, song_data in data.get('songs', {}).items():
|
|
if song_data.get('channel_name') != channel_name:
|
|
continue
|
|
|
|
artist = song_data.get('artist', '')
|
|
if not artist or not is_lastname_firstname_format(artist):
|
|
continue
|
|
|
|
# Convert the artist name
|
|
new_artist = convert_to_firstname_lastname(artist)
|
|
|
|
if apply_changes:
|
|
# Update the tracking data
|
|
song_data['artist'] = new_artist
|
|
|
|
# Update the video title if it exists and contains the old artist name
|
|
video_title = song_data.get('video_title', '')
|
|
if video_title and artist in video_title:
|
|
song_data['video_title'] = video_title.replace(artist, new_artist)
|
|
|
|
# Update the file path if it exists
|
|
file_path = song_data.get('file_path', '')
|
|
if file_path and artist in file_path:
|
|
song_data['file_path'] = file_path.replace(artist, new_artist)
|
|
|
|
changes_made += 1
|
|
changed_entries.append({
|
|
'song_key': song_key,
|
|
'old_artist': artist,
|
|
'new_artist': new_artist,
|
|
'title': song_data.get('title', ''),
|
|
'file_path': song_data.get('file_path', '')
|
|
})
|
|
|
|
print(f"🔄 {'Updated' if apply_changes else 'Would update'}: '{artist}' → '{new_artist}' ({song_data.get('title', '')})")
|
|
|
|
# Save the updated data
|
|
if apply_changes and changes_made > 0:
|
|
# Create backup
|
|
backup_file = f"{tracking_file}.backup"
|
|
shutil.copy2(tracking_file, backup_file)
|
|
print(f"💾 Created backup: {backup_file}")
|
|
|
|
# Save updated file
|
|
with open(tracking_file, 'w', encoding='utf-8') as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
print(f"💾 Updated tracking file: {tracking_file}")
|
|
|
|
return changes_made, changed_entries
|
|
|
|
|
|
def update_songlist_tracking(songlist_file: str, channel_name: str = "Let's Sing Karaoke", apply_changes: bool = False) -> Tuple[int, List[Dict]]:
|
|
"""
|
|
Update the songlist tracking file to fix artist name formatting.
|
|
|
|
Args:
|
|
songlist_file: Path to the songlist tracking JSON file
|
|
channel_name: Channel name to target (default: Let's Sing Karaoke)
|
|
apply_changes: Whether to actually apply changes or just preview
|
|
|
|
Returns:
|
|
Tuple of (number of changes made, list of changed entries)
|
|
"""
|
|
if not os.path.exists(songlist_file):
|
|
print(f"❌ Songlist tracking file not found: {songlist_file}")
|
|
return 0, []
|
|
|
|
# Load the songlist data
|
|
with open(songlist_file, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
|
|
changes_made = 0
|
|
changed_entries = []
|
|
|
|
# Process songlist entries
|
|
for song_key, song_data in data.items():
|
|
artist = song_data.get('artist', '')
|
|
if not artist or not is_lastname_firstname_format(artist):
|
|
continue
|
|
|
|
# Convert the artist name
|
|
new_artist = convert_to_firstname_lastname(artist)
|
|
|
|
if apply_changes:
|
|
# Update the songlist data
|
|
song_data['artist'] = new_artist
|
|
|
|
changes_made += 1
|
|
changed_entries.append({
|
|
'song_key': song_key,
|
|
'old_artist': artist,
|
|
'new_artist': new_artist,
|
|
'title': song_data.get('title', '')
|
|
})
|
|
|
|
print(f"🔄 {'Updated' if apply_changes else 'Would update'} songlist: '{artist}' → '{new_artist}' ({song_data.get('title', '')})")
|
|
|
|
# Save the updated data
|
|
if apply_changes and changes_made > 0:
|
|
# Create backup
|
|
backup_file = f"{songlist_file}.backup"
|
|
shutil.copy2(songlist_file, backup_file)
|
|
print(f"💾 Created backup: {backup_file}")
|
|
|
|
# Save updated file
|
|
with open(songlist_file, 'w', encoding='utf-8') as f:
|
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
print(f"💾 Updated songlist file: {songlist_file}")
|
|
|
|
return changes_made, changed_entries
|
|
|
|
|
|
def update_id3_tags_for_files(files_to_update: List[Dict], apply_changes: bool = False) -> int:
|
|
"""
|
|
Update ID3 tags for a list of files.
|
|
|
|
Args:
|
|
files_to_update: List of files to update
|
|
apply_changes: Whether to actually apply changes or just preview
|
|
|
|
Returns:
|
|
Number of files successfully updated
|
|
"""
|
|
updated_count = 0
|
|
|
|
for file_info in files_to_update:
|
|
file_path = file_info['file_path']
|
|
new_artist = file_info['new_artist']
|
|
|
|
if update_id3_tags(file_path, new_artist, apply_changes):
|
|
updated_count += 1
|
|
|
|
return updated_count
|
|
|
|
|
|
def main():
|
|
"""Main function to run the artist name fix script."""
|
|
parser = argparse.ArgumentParser(description="Fix artist name formatting in ID3 tags for Let's Sing Karaoke")
|
|
parser.add_argument('--preview', action='store_true', help='Show what would be changed without making changes')
|
|
parser.add_argument('--apply', action='store_true', help='Actually apply the changes')
|
|
parser.add_argument('--external', type=str, help='Path to external karaoke directory')
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Default to preview mode if no action specified
|
|
if not args.preview and not args.apply:
|
|
args.preview = True
|
|
|
|
print("🎤 Artist Name Format Fix Script (ID3 Tags Only)")
|
|
print("=" * 60)
|
|
print("This script will fix 'Last Name, First Name' format to 'First Name Last Name'")
|
|
print("Only targeting Let's Sing Karaoke channel to avoid affecting other channels.")
|
|
print("Focusing on ID3 tags only - filenames will not be changed.")
|
|
print()
|
|
|
|
if not MUTAGEN_AVAILABLE:
|
|
print("❌ mutagen library not available!")
|
|
print("Please install it with: pip install mutagen")
|
|
return
|
|
|
|
if args.preview:
|
|
print("🔍 PREVIEW MODE - No changes will be made")
|
|
else:
|
|
print("⚡ APPLY MODE - Changes will be made")
|
|
print()
|
|
|
|
# File paths
|
|
tracking_file = "data/karaoke_tracking.json"
|
|
songlist_file = "data/songlist_tracking.json"
|
|
|
|
# Process external directory if specified
|
|
if args.external:
|
|
print(f"📁 Scanning external directory: {args.external}")
|
|
external_files = scan_external_directory(args.external)
|
|
|
|
if external_files:
|
|
print(f"\n📋 Found {len(external_files)} files with 'Last Name, First Name' format in ID3 tags:")
|
|
for file_info in external_files:
|
|
print(f" • {file_info['filename']}: '{file_info['old_artist']}' → '{file_info['new_artist']}'")
|
|
|
|
if args.apply:
|
|
print(f"\n📝 Updating ID3 tags in external files...")
|
|
updated_count = update_id3_tags_for_files(external_files, apply_changes=True)
|
|
print(f"✅ Updated ID3 tags in {updated_count} external files")
|
|
else:
|
|
print(f"\n📝 Would update ID3 tags in {len(external_files)} external files")
|
|
else:
|
|
print("✅ No files with 'Last Name, First Name' format found in ID3 tags")
|
|
|
|
# Process tracking files (only if they exist in current project)
|
|
if os.path.exists(tracking_file):
|
|
print(f"\n📊 Processing karaoke tracking file...")
|
|
tracking_changes, tracking_entries = update_tracking_file(tracking_file, apply_changes=args.apply)
|
|
else:
|
|
print(f"\n⚠️ Tracking file not found: {tracking_file}")
|
|
tracking_changes = 0
|
|
|
|
if os.path.exists(songlist_file):
|
|
print(f"\n📊 Processing songlist tracking file...")
|
|
songlist_changes, songlist_entries = update_songlist_tracking(songlist_file, apply_changes=args.apply)
|
|
else:
|
|
print(f"\n⚠️ Songlist tracking file not found: {songlist_file}")
|
|
songlist_changes = 0
|
|
|
|
# Process local downloads directory ID3 tags
|
|
downloads_dir = "downloads"
|
|
local_id3_updates = 0
|
|
if os.path.exists(downloads_dir) and tracking_changes > 0:
|
|
print(f"\n📝 Processing ID3 tags in local downloads directory...")
|
|
# Scan local downloads for files that need ID3 tag updates
|
|
local_files = []
|
|
for entry in tracking_entries:
|
|
file_path = entry.get('file_path', '')
|
|
if file_path and os.path.exists(file_path.replace('\\', '/')):
|
|
local_files.append({
|
|
'file_path': file_path.replace('\\', '/'),
|
|
'filename': os.path.basename(file_path),
|
|
'old_artist': entry['old_artist'],
|
|
'new_artist': entry['new_artist']
|
|
})
|
|
|
|
if local_files:
|
|
local_id3_updates = update_id3_tags_for_files(local_files, apply_changes=args.apply)
|
|
|
|
total_changes = tracking_changes + songlist_changes
|
|
|
|
print("\n" + "=" * 60)
|
|
print("📋 Summary:")
|
|
print(f" • Tracking file changes: {tracking_changes}")
|
|
print(f" • Songlist file changes: {songlist_changes}")
|
|
print(f" • Local ID3 tag updates: {local_id3_updates}")
|
|
print(f" • Total changes: {total_changes}")
|
|
|
|
if args.external:
|
|
external_count = len(scan_external_directory(args.external)) if args.preview else len(external_files)
|
|
print(f" • External ID3 tag updates: {external_count}")
|
|
|
|
if total_changes > 0 or (args.external and external_count > 0):
|
|
if args.apply:
|
|
print("\n✅ Artist name formatting in ID3 tags has been fixed!")
|
|
print("💾 Backups have been created for all modified files.")
|
|
print("🔄 You may need to re-run your karaoke downloader to update any cached data.")
|
|
else:
|
|
print("\n🔍 Preview complete. Use --apply to make these changes.")
|
|
else:
|
|
print("\n✅ No changes needed! All artist names are already in the correct format.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |