CCCPaste

music.py

#!/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()