#!/usr/bin/env python3 """ mixxx_import.py - Importe les cues Mixxx (fichiers .mixxx JSON) dans la base de données, crée les pistes manquantes et importe une playlist .m3u. Version avec interface graphique corrigée. """ import sqlite3 import json import sys import os import argparse import threading from pathlib import Path import tkinter as tk from tkinter import filedialog, messagebox, ttk # ------------------------------------------------------------ # 1. Fonctions de base # ------------------------------------------------------------ AUDIO_EXTENSIONS = {".flac", ".mp3", ".opus", ".ogg", ".wav", ".m4a"} def default_db(): if sys.platform == "win32": return Path(os.environ["APPDATA"]) / "Mixxx" / "mixxxdb.sqlite" return Path.home() / ".mixxx" / "mixxxdb.sqlite" def ensure_track(con, file_path: Path) -> int: location = str(file_path.resolve()) row = con.execute("SELECT id FROM track_locations WHERE location = ?", (location,)).fetchone() if row: return row[0] filename = file_path.name directory = str(file_path.parent) filesize = file_path.stat().st_size cur = con.execute( "INSERT INTO track_locations (location, filename, directory, filesize, fs_deleted, needs_verification) VALUES (?,?,?,?,0,0)", (location, filename, directory, filesize) ) tl_id = cur.lastrowid title = file_path.stem con.execute( "INSERT INTO library (id, artist, title, album, genre, tracknumber, year, duration, samplerate, bitrate, channels, bpm, key, rating, comment) " "VALUES (?, '', ?, '', '', '', 0, 0, 0, 0, 0, 0, '', 0, '')", (tl_id, title) ) return tl_id def add_playlist(con, name, track_ids): cur = con.execute( "INSERT INTO Playlists (name, position, hidden, date_created, date_modified) VALUES (?, 0, 0, datetime('now'), datetime('now'))", (name,) ) playlist_id = cur.lastrowid for pos, tid in enumerate(track_ids): con.execute( "INSERT INTO PlaylistTracks (playlist_id, track_id, position, pl_datetime_added) VALUES (?, ?, ?, datetime('now'))", (playlist_id, tid, pos) ) return playlist_id def import_cues_v2(music_dir: Path, db_path: Path, dry_run=False, overwrite=False, m3u_file=None, log_callback=print): con = sqlite3.connect(db_path) try: log_callback("Scan des fichiers audio...") audio_files = {} for ext in AUDIO_EXTENSIONS: for f in music_dir.rglob(f"*{ext}"): if f.name not in audio_files: audio_files[f.name] = f.resolve() log_callback(f"{len(audio_files)} fichiers audio trouvés.") filename_to_trackid = {} for fname, fpath in audio_files.items(): try: tid = ensure_track(con, fpath) filename_to_trackid[fname] = tid except Exception as e: log_callback(f"[ERREUR] {fname} : {e}") mixxx_files = list(music_dir.rglob("*.mixxx")) log_callback(f"{len(mixxx_files)} fichier(s) .mixxx trouvé(s).") total = 0 for mf in mixxx_files: try: payload = json.loads(mf.read_text(encoding="utf-8")) except Exception as e: log_callback(f"[ERREUR] {mf.name} : {e}") continue if payload.get("version") != 1: log_callback(f"[IGNORÉ] {mf.name} : version inconnue") continue target = payload.get("filename", "") tid = filename_to_trackid.get(target) if tid is None: log_callback(f"[PAS TROUVÉ] {target}") continue cues = payload.get("cues", []) if not cues: continue if overwrite and not dry_run: con.execute("DELETE FROM cues WHERE track_id = ?", (tid,)) existing = set() if not overwrite: rows = con.execute( "SELECT type, position, length, hotcue, label FROM cues WHERE track_id = ?", (tid,) ).fetchall() existing = {(r[0], r[1], r[2] or 0.0, r[3], r[4] or "") for r in rows} inserted = 0 for c in cues: key = (c["type"], c["position"], c.get("length", 0.0) or 0.0, c["hotcue"], c.get("label", "") or "") if key in existing: continue if not dry_run: con.execute( "INSERT INTO cues (track_id, type, position, length, hotcue, label, color) VALUES (?,?,?,?,?,?,?)", (tid, c["type"], c["position"], c.get("length", 0.0), c["hotcue"], c.get("label", ""), c.get("color", 16744448)) ) inserted += 1 status = "[DRY]" if dry_run else "[OK]" log_callback(f"{status} {target} : {inserted} cues") total += inserted if m3u_file and m3u_file.exists(): log_callback(f"\nImport playlist : {m3u_file.name}") with open(m3u_file, "r", encoding="utf-8") as f: lines = [l.strip() for l in f if l.strip() and not l.startswith("#")] track_ids = [] m3u_dir = m3u_file.parent for rel in lines: abs_path = (m3u_dir / rel).resolve() if not abs_path.exists(): log_callback(f"[FICHIER ABSENT] {abs_path}") continue tid = ensure_track(con, abs_path) track_ids.append(tid) if track_ids and not dry_run: name = m3u_file.stem add_playlist(con, name, track_ids) log_callback(f"Playlist '{name}' créée ({len(track_ids)} pistes).") elif track_ids: log_callback(f"(dry-run) Playlist '{m3u_file.stem}' aurait {len(track_ids)} pistes.") if not dry_run: con.commit() log_callback(f"\n✅ IMPORT TERMINÉ : {total} cues importés.") finally: con.close() # ------------------------------------------------------------ # 2. Interface graphique Tkinter (CORRIGÉE) # ------------------------------------------------------------ class ImportGUI: def __init__(self): self.root = tk.Tk() self.root.title("Mixxx Import Cues") self.root.geometry("600x500") # Fenêtre plus grande self.root.resizable(True, True) # Variables self.folder_path = tk.StringVar() self.m3u_path = tk.StringVar() self.dry_run = tk.BooleanVar(value=False) self.overwrite = tk.BooleanVar(value=False) self.db_path = tk.StringVar(value=str(default_db())) # Style self.root.configure(bg="#f0f0f0") self.create_widgets() def create_widgets(self): # Frame principal avec padding main_frame = ttk.Frame(self.root, padding="20") main_frame.pack(fill="both", expand=True) # Titre title_label = ttk.Label(main_frame, text="Import des Cues Mixxx", font=("Arial", 14, "bold")) title_label.pack(pady=(0, 20)) # Dossier musique folder_frame = ttk.LabelFrame(main_frame, text="Dossier contenant les fichiers audio et .mixxx", padding="10") folder_frame.pack(fill="x", pady=(0, 10)) folder_entry_frame = ttk.Frame(folder_frame) folder_entry_frame.pack(fill="x") ttk.Entry(folder_entry_frame, textvariable=self.folder_path).pack(side="left", fill="x", expand=True, padx=(0, 5)) ttk.Button(folder_entry_frame, text="📁 Parcourir", command=self.browse_folder).pack(side="right") # Fichier .m3u m3u_frame = ttk.LabelFrame(main_frame, text="Fichier .m3u (optionnel)", padding="10") m3u_frame.pack(fill="x", pady=(0, 10)) m3u_entry_frame = ttk.Frame(m3u_frame) m3u_entry_frame.pack(fill="x") ttk.Entry(m3u_entry_frame, textvariable=self.m3u_path).pack(side="left", fill="x", expand=True, padx=(0, 5)) ttk.Button(m3u_entry_frame, text="📁 Parcourir", command=self.browse_m3u).pack(side="right") # Base de données db_frame = ttk.LabelFrame(main_frame, text="Base de données Mixxx", padding="10") db_frame.pack(fill="x", pady=(0, 10)) db_entry_frame = ttk.Frame(db_frame) db_entry_frame.pack(fill="x") ttk.Entry(db_entry_frame, textvariable=self.db_path).pack(side="left", fill="x", expand=True, padx=(0, 5)) ttk.Button(db_entry_frame, text="📁 Parcourir", command=self.browse_db).pack(side="right") # Options options_frame = ttk.LabelFrame(main_frame, text="Options", padding="10") options_frame.pack(fill="x", pady=(0, 10)) ttk.Checkbutton(options_frame, text="Dry-run (simulation sans écriture)", variable=self.dry_run).pack(anchor="w", pady=2) ttk.Checkbutton(options_frame, text="Écraser les cues existants", variable=self.overwrite).pack(anchor="w", pady=2) # Zone de log log_frame = ttk.LabelFrame(main_frame, text="Journal", padding="5") log_frame.pack(fill="both", expand=True, pady=(0, 10)) self.log_text = tk.Text(log_frame, height=8, width=70, state="disabled", bg="#1e1e1e", fg="#00ff00", font=("Courier", 9)) self.log_text.pack(fill="both", expand=True) scrollbar = ttk.Scrollbar(self.log_text, orient="vertical", command=self.log_text.yview) scrollbar.pack(side="right", fill="y") self.log_text.configure(yscrollcommand=scrollbar.set) # Frame pour les boutons button_frame = ttk.Frame(main_frame) button_frame.pack(fill="x", pady=(10, 0)) # Bouton LANCER (bien visible) self.run_btn = tk.Button( button_frame, text="🚀 LANCER L'IMPORT", command=self.start_import, bg="#4CAF50", # Vert fg="white", font=("Arial", 12, "bold"), padx=30, pady=10, relief="raised", cursor="hand2" ) self.run_btn.pack(side="left", expand=True, fill="x", padx=(0, 5)) # Bouton Quitter quit_btn = tk.Button( button_frame, text="❌ Quitter", command=self.root.quit, bg="#f44336", # Rouge fg="white", font=("Arial", 12), padx=20, pady=10, cursor="hand2" ) quit_btn.pack(side="right", expand=True, fill="x", padx=(5, 0)) def log(self, message): self.log_text.configure(state="normal") self.log_text.insert("end", message + "\n") self.log_text.see("end") self.log_text.configure(state="disabled") self.root.update_idletasks() def browse_folder(self): path = filedialog.askdirectory(title="Sélectionner le dossier de musique") if path: self.folder_path.set(path) def browse_m3u(self): path = filedialog.askopenfilename(title="Sélectionner un fichier .m3u", filetypes=[("M3U files", "*.m3u"), ("All files", "*.*")]) if path: self.m3u_path.set(path) def browse_db(self): path = filedialog.askopenfilename(title="Sélectionner mixxxdb.sqlite", filetypes=[("SQLite", "*.sqlite"), ("All files", "*.*")]) if path: self.db_path.set(path) def validate(self): music = Path(self.folder_path.get()) if not music.is_dir(): messagebox.showerror("Erreur", "Le dossier de musique n'existe pas.") return False db = Path(self.db_path.get()) if not db.exists(): messagebox.showerror("Erreur", "Le fichier de base de données est introuvable.") return False m3u = self.m3u_path.get().strip() if m3u and not Path(m3u).exists(): messagebox.showerror("Erreur", "Le fichier .m3u indiqué n'existe pas.") return False return True def start_import(self): if not self.validate(): return self.run_btn.configure(state="disabled", text="⏳ Import en cours...") self.log("=" * 50) self.log("DÉBUT DE L'IMPORT") self.log("=" * 50) music_dir = Path(self.folder_path.get()) db_path = Path(self.db_path.get()) dry = self.dry_run.get() overwrite = self.overwrite.get() m3u = Path(self.m3u_path.get()) if self.m3u_path.get().strip() else None def task(): try: import_cues_v2(music_dir, db_path, dry, overwrite, m3u, log_callback=self.log) except Exception as e: self.log(f"❌ ERREUR FATALE : {e}") finally: self.run_btn.configure(state="normal", text="🚀 LANCER L'IMPORT") if not dry: messagebox.showinfo("Terminé", "Import terminé avec succès !") threading.Thread(target=task, daemon=True).start() def run(self): self.root.mainloop() # ------------------------------------------------------------ # Point d'entrée # ------------------------------------------------------------ if __name__ == "__main__": if len(sys.argv) > 1: parser = argparse.ArgumentParser(description="Import cues Mixxx (console ou GUI)") parser.add_argument("music_dir", nargs="?", help="Dossier contenant musique + .mixxx") parser.add_argument("db_path", nargs="?", default=str(default_db()), help="Chemin vers mixxxdb.sqlite") parser.add_argument("--dry-run", action="store_true", help="Simulation") parser.add_argument("--overwrite", action="store_true", help="Écraser les cues existants") parser.add_argument("--m3u", help="Fichier .m3u à importer") parser.add_argument("--gui", action="store_true", help="Forcer l'interface graphique") args = parser.parse_args() if args.gui or not args.music_dir: ImportGUI().run() else: music_dir = Path(args.music_dir) db_path = Path(args.db_path) if not music_dir.is_dir(): print("Dossier introuvable") sys.exit(1) if not Path(db_path).exists(): print("DB introuvable") sys.exit(1) m3u = Path(args.m3u) if args.m3u else None import_cues_v2(music_dir, db_path, args.dry_run, args.overwrite, m3u, log_callback=print) else: ImportGUI().run()