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