"""Copies practitioner MP3s to audio_phone/ with phone-friendly names + ID3 tags + cover. For each module: - rename to M{N}-{idx:02d}-{tema}.mp3 - tag: title, album="NLP Practitioner - Modul N - Tema", artist, track, year, genre - embed PNG cover (generated with Pillow) showing module + theme Run: .venv/Scripts/python.exe rename_phone_practitioner.py """ from __future__ import annotations import json import shutil import sys from pathlib import Path from PIL import Image, ImageDraw, ImageFont from mutagen.id3 import APIC, ID3, ID3NoHeaderError, TALB, TIT2, TPE1, TPE2, TRCK, TYER, TCON from mutagen.mp3 import MP3 ROOT = Path(__file__).parent PRACT = ROOT / "nlp-practitioner" AUDIO_SRC = PRACT / "audio" AUDIO_DST = PRACT / "audio_phone" COVERS_DIR = PRACT / "audio_phone" / "_covers" MANIFEST = PRACT / "manifest.json" THEMES = { 1: {"slug": "PACING_RAPORT", "title_full": "Pacing si Raport", "filename_phrase": "Pacing si Raport"}, 2: {"slug": "SUBMODALITATI", "title_full": "Submodalitati si VAK", "filename_phrase": "Submodalitati"}, 3: {"slug": "OBIECTIVE", "title_full": "Setarea si Atingerea Obiectivelor", "filename_phrase": "Obiective"}, 4: {"slug": "NIVELURI_NEUROLOGICE", "title_full": "Niveluri Neurologice si Metamodel", "filename_phrase": "Niveluri Neurologice"}, 5: {"slug": "MILTON_MODEL", "title_full": "Modelul Milton, Hipnoza si Asertivitate","filename_phrase": "Modelul Milton"}, 6: {"slug": "RECADRARE_PARTI", "title_full": "Recadrare si Parti Interioare", "filename_phrase": "Recadrare si Parti"}, 7: {"slug": "PARTI_INTERIOARE", "title_full": "Parti Interioare si Triunghi Dramatic", "filename_phrase": "Parti Interioare"}, 8: {"slug": "INTEGRARE_MICROSTRATEGII", "title_full": "Integrare, Linia Timpului, Microstrategii","filename_phrase": "Microstrategii"}, } ARTIST = "cursnlp.ro" ALBUM_ARTIST = "Horia Radu si Marius Mutu" YEAR = "2024" GENRE = "Speech" COURSE_PREFIX = "NLP Practitioner" def find_font(size: int) -> ImageFont.FreeTypeFont: candidates = [ "C:/Windows/Fonts/segoeuib.ttf", "C:/Windows/Fonts/arialbd.ttf", "C:/Windows/Fonts/arial.ttf", "C:/Windows/Fonts/segoeui.ttf", ] for c in candidates: if Path(c).exists(): return ImageFont.truetype(c, size) return ImageFont.load_default() def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.ImageDraw) -> list[str]: words = text.split() lines: list[str] = [] cur = "" for w in words: trial = (cur + " " + w).strip() bbox = draw.textbbox((0, 0), trial, font=font) if bbox[2] - bbox[0] <= max_width: cur = trial else: if cur: lines.append(cur) cur = w if cur: lines.append(cur) return lines # Per-module accent colors (calm, distinct, readable on white text) PALETTE = { 1: ("#1f4e79", "#2e75b6"), # blue 2: ("#5b2c6f", "#9b59b6"), # purple 3: ("#922b21", "#cd6155"), # red 4: ("#1e8449", "#52be80"), # green 5: ("#7e5109", "#d68910"), # amber/gold 6: ("#0e6655", "#48c9b0"), # teal 7: ("#4a235a", "#7d3c98"), # deep purple 8: ("#212f3c", "#566573"), # slate } def make_cover(module_num: int, theme_full: str, out_path: Path) -> Path: """Generate 1000x1000 PNG cover with gradient + module + theme text.""" size = 1000 img = Image.new("RGB", (size, size), "#ffffff") draw = ImageDraw.Draw(img) c1, c2 = PALETTE.get(module_num, ("#222", "#666")) def hex_to_rgb(h: str) -> tuple[int, int, int]: h = h.lstrip("#") return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) r1, g1, b1 = hex_to_rgb(c1) r2, g2, b2 = hex_to_rgb(c2) for y in range(size): t = y / (size - 1) r = int(r1 + (r2 - r1) * t) g = int(g1 + (g2 - g1) * t) b = int(b1 + (b2 - b1) * t) draw.line([(0, y), (size, y)], fill=(r, g, b)) # Course label (small, top) f_small = find_font(36) label = COURSE_PREFIX + " - 2024" bbox = draw.textbbox((0, 0), label, font=f_small) draw.text(((size - (bbox[2] - bbox[0])) / 2, 60), label, font=f_small, fill="#ffffff") # Big module number f_huge = find_font(280) mod_text = f"M{module_num}" bbox = draw.textbbox((0, 0), mod_text, font=f_huge) draw.text(((size - (bbox[2] - bbox[0])) / 2, 160), mod_text, font=f_huge, fill="#ffffff") # "MODUL N" word f_med = find_font(54) sub = f"MODUL {module_num}" bbox = draw.textbbox((0, 0), sub, font=f_med) draw.text(((size - (bbox[2] - bbox[0])) / 2, 470), sub, font=f_med, fill="#ffffff") # Theme (wrapped) f_theme = find_font(64) lines = wrap_text(theme_full, f_theme, max_width=size - 120, draw=draw) y = 600 for line in lines: bbox = draw.textbbox((0, 0), line, font=f_theme) draw.text(((size - (bbox[2] - bbox[0])) / 2, y), line, font=f_theme, fill="#ffffff") y += int((bbox[3] - bbox[1]) * 1.4) # Footer f_foot = find_font(28) foot = ARTIST bbox = draw.textbbox((0, 0), foot, font=f_foot) draw.text(((size - (bbox[2] - bbox[0])) / 2, size - 70), foot, font=f_foot, fill="#ffffff") out_path.parent.mkdir(parents=True, exist_ok=True) img.save(out_path, "PNG", optimize=True) return out_path def safe_filename_part(s: str) -> str: bad = '<>:"/\\|?*' for ch in bad: s = s.replace(ch, "") return s.strip() def tag_mp3(mp3_path: Path, *, title: str, album: str, track_num: int, track_total: int, cover_png: Path) -> None: try: tags = ID3(mp3_path) tags.delete() except ID3NoHeaderError: tags = ID3() tags.add(TIT2(encoding=3, text=title)) tags.add(TALB(encoding=3, text=album)) tags.add(TPE1(encoding=3, text=ARTIST)) tags.add(TPE2(encoding=3, text=ALBUM_ARTIST)) tags.add(TRCK(encoding=3, text=f"{track_num}/{track_total}")) tags.add(TYER(encoding=3, text=YEAR)) tags.add(TCON(encoding=3, text=GENRE)) with open(cover_png, "rb") as f: img_bytes = f.read() tags.add(APIC(encoding=3, mime="image/png", type=3, desc="Cover", data=img_bytes)) tags.save(mp3_path, v2_version=3) def main() -> int: if not MANIFEST.exists(): print(f"ERROR: manifest not found: {MANIFEST}") return 1 with MANIFEST.open(encoding="utf-8") as f: manifest = json.load(f) AUDIO_DST.mkdir(parents=True, exist_ok=True) COVERS_DIR.mkdir(parents=True, exist_ok=True) total_copied = 0 total_skipped = 0 total_errors = 0 for mod in manifest["modules"]: name = mod["name"] # "Modul N" try: mod_num = int(name.split()[-1]) except Exception: print(f"SKIP module without numeric suffix: {name}") continue theme = THEMES.get(mod_num) if theme is None: print(f"SKIP module {mod_num}: no theme defined") continue audio_lectures = [lec for lec in mod.get("lectures", []) if lec.get("type") == "audio"] if not audio_lectures: continue cover_path = COVERS_DIR / f"M{mod_num}.png" if not cover_path.exists(): make_cover(mod_num, theme["title_full"], cover_path) print(f" cover: {cover_path.name}") album = f"{COURSE_PREFIX} - Modul {mod_num} - {theme['title_full']}" track_total = len(audio_lectures) out_dir = AUDIO_DST / f"M{mod_num} - {theme['filename_phrase']}" out_dir.mkdir(parents=True, exist_ok=True) for idx, lec in enumerate(audio_lectures, start=1): src_path_str = lec.get("audio_path", "") src = ROOT / src_path_str if not src.exists(): print(f" MISSING src: {src}") total_errors += 1 continue phrase = safe_filename_part(theme["filename_phrase"]) new_name = f"M{mod_num}-{idx:02d}-{phrase}.mp3" dst = out_dir / new_name if dst.exists() and dst.stat().st_size == src.stat().st_size: print(f" skip (already copied): {new_name}") total_skipped += 1 else: shutil.copy2(src, dst) print(f" copied: {new_name}") total_copied += 1 title_short = lec.get("title", new_name) track_title = f"M{mod_num} {title_short} - {theme['filename_phrase']}" try: tag_mp3( dst, title=track_title, album=album, track_num=idx, track_total=track_total, cover_png=cover_path, ) except Exception as e: print(f" TAG ERROR for {new_name}: {e}") total_errors += 1 print(f"\nDONE. copied={total_copied} skipped={total_skipped} errors={total_errors}") print(f"Output: {AUDIO_DST}") return 0 if total_errors == 0 else 2 if __name__ == "__main__": sys.exit(main())