Files
mixxx/mixxx_import.py

375 lines
14 KiB
Python
Executable File

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