#!/usr/bin/env python3 import os import shutil import unicodedata import re from pathlib import Path # Copy a directory (~/Masic/RythmBox) to a FAT32 formated # flash drive, named MUSIC, converting all filenames # This was written with GPT-5 Mini via duck.ai as a bash script, # Some manual changes and it was fed into Google Gemini # Gemini then made further optimizations and rewrote it in python # --- CONFIGURATION --- # Get current user home directory safely (equivalent to $(whoami)) HOME = Path.home() SOURCE_DIR = HOME / "Music/RythmBox" DEST_DIR = Path("/media") / HOME.name / "MUSIC" MAX_BYTES = 255 def sanitize_component(name): """ Makes a filename FAT32 safe: 1. Transliterates Unicode to ASCII (approximate). 2. Removes control characters and forbidden symbols. 3. Replaces spaces with underscores. 4. Truncates to 255 bytes. """ # 1. Normalize unicode and strip accents (e.g., 'Björk' -> 'Bjork') # NFKD decomposition separates characters from accents norm = unicodedata.normalize('NFKD', name) # Encode to ASCII bytes, ignoring errors (drops non-ascii), then decode back name = norm.encode('ASCII', 'ignore').decode('ASCII') # 2. Replace forbidden characters: < > : " / \ | ? * name = re.sub(r'[<>:"/\\|?*]', '-', name) # 3. Remove control characters name = re.sub(r'[\000-\037]', '', name) # 4. Replace spaces with underscores name = re.sub(r'\s+', '_', name) # 5. Trim leading/trailing dots and spaces (FAT32 dislike) name = name.strip('. ') # 6. Ensure not empty if not name: name = "_" # 7. Truncate to MAX_BYTES (FAT32 limit) # We must encode to bytes to check length, then decode back carefully encoded = name.encode('utf-8') if len(encoded) > MAX_BYTES: # Slice bytes and decode, ignoring partial characters at the end name = encoded[:MAX_BYTES].decode('utf-8', 'ignore') return name def main(): src_path = Path(SOURCE_DIR) dst_path = Path(DEST_DIR) if not src_path.exists(): print(f"Error: Source directory not found: {src_path}") return print(f"Source: {src_path}") print(f"Dest: {dst_path}") print("-" * 40) # Walk through the source directory for root, dirs, files in os.walk(src_path): # Calculate relative path from source root (e.g. "Artist/Album") rel_path = Path(root).relative_to(src_path) # Sanitize the directory path components safe_parts = [sanitize_component(p) for p in rel_path.parts if p != '.'] # Construct full destination directory path current_dst_dir = dst_path.joinpath(*safe_parts) # Create destination directory if it doesn't exist if not current_dst_dir.exists(): try: current_dst_dir.mkdir(parents=True, exist_ok=True) except OSError as e: print(f"Skipping dir '{rel_path}': {e}") continue # Process files in this directory for filename in files: safe_filename = sanitize_component(filename) src_file = Path(root) / filename dst_file = current_dst_dir / safe_filename # Check if copy is needed (doesn't exist or source is newer) should_copy = False if not dst_file.exists(): should_copy = True else: # Compare modification times if src_file.stat().st_mtime > dst_file.stat().st_mtime: should_copy = True if should_copy: try: print(f"Copying: {safe_filename}") # copy2 preserves metadata (timestamps) shutil.copy2(src_file, dst_file) except Exception as e: print(f"Failed to copy {filename}: {e}") print("-" * 40) print("Operation complete.") if __name__ == "__main__": main()