feat: initial commit for export and import
This commit is contained in:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# === Python ===
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.spec # spé PyInstaller (peut être conservé si personnalisé)
|
||||||
|
*.manifest
|
||||||
|
|
||||||
|
# === Environnements virtuels ===
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
|
||||||
|
# === Fichiers de test / temporaires ===
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
test_*.db
|
||||||
|
test_*.sqlite
|
||||||
|
|
||||||
|
# === Mixxx ===
|
||||||
|
# Ignorer la base de données si jamais elle se retrouve dans le projet
|
||||||
|
mixxxdb.sqlite
|
||||||
|
mixxxdb.sqlite.bak
|
||||||
|
|
||||||
|
# Ignorer tous les fichiers .mixxx exportés (sauf exemples)
|
||||||
|
*.mixxx
|
||||||
|
!example.mixxx # autoriser explicitement un fichier exemple
|
||||||
|
|
||||||
|
# === Playlists exportées ===
|
||||||
|
*.m3u
|
||||||
|
!example.m3u
|
||||||
|
|
||||||
|
# === Système ===
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# === IDE ===
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# === Logs ===
|
||||||
|
*.log
|
||||||
93
mixxx_export.py
Executable file
93
mixxx_export.py
Executable file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Exporte tous les cue points de Mixxx vers des fichiers .mixxx (JSON)
|
||||||
|
placés à côté de chaque fichier audio.
|
||||||
|
|
||||||
|
Usage : python3 mixxx_export.py [chemin_db]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_DB = Path.home() / ".mixxx" / "mixxxdb.sqlite"
|
||||||
|
|
||||||
|
def export_cues(db_path: Path, output_dir: Path = None):
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
rows = con.execute("""
|
||||||
|
SELECT
|
||||||
|
tl.location AS audio_path,
|
||||||
|
tl.filename AS filename,
|
||||||
|
c.type AS type,
|
||||||
|
c.position AS position,
|
||||||
|
c.length AS length,
|
||||||
|
c.hotcue AS hotcue,
|
||||||
|
c.label AS label,
|
||||||
|
c.color AS color
|
||||||
|
FROM cues c
|
||||||
|
JOIN library l ON l.id = c.track_id
|
||||||
|
JOIN track_locations tl ON tl.id = l.location
|
||||||
|
WHERE tl.fs_deleted = 0
|
||||||
|
ORDER BY tl.location, c.hotcue, c.type
|
||||||
|
""").fetchall()
|
||||||
|
|
||||||
|
by_file = {}
|
||||||
|
for r in rows:
|
||||||
|
path = r["audio_path"]
|
||||||
|
by_file.setdefault(path, []).append({
|
||||||
|
"type": r["type"],
|
||||||
|
"position": r["position"],
|
||||||
|
"length": r["length"],
|
||||||
|
"hotcue": r["hotcue"],
|
||||||
|
"label": r["label"] or "",
|
||||||
|
"color": r["color"],
|
||||||
|
})
|
||||||
|
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
exported = 0
|
||||||
|
for audio_path, cues in by_file.items():
|
||||||
|
audio_file = Path(audio_path)
|
||||||
|
if not audio_file.exists():
|
||||||
|
print(f"[SKIP] fichier introuvable : {audio_path}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Déterminer où écrire le .mixxx
|
||||||
|
if output_dir:
|
||||||
|
out = output_dir / (audio_file.stem + audio_file.suffix + ".mixxx")
|
||||||
|
else:
|
||||||
|
out = audio_file.with_suffix(audio_file.suffix + ".mixxx")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"version": 1,
|
||||||
|
"filename": audio_file.name,
|
||||||
|
"cues": cues,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
|
||||||
|
print(f"[OK] {out.name}")
|
||||||
|
exported += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERR] {out.name} : {e}")
|
||||||
|
|
||||||
|
print(f"\n{exported} fichier(s) .mixxx exporté(s).")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("db_path", nargs="?", default=str(DEFAULT_DB), help="Chemin vers mixxxdb.sqlite")
|
||||||
|
parser.add_argument("--output-dir", help="Dossier de sortie pour les .mixxx (par défaut : à côté de l'audio)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
db_path = Path(args.db_path)
|
||||||
|
if not db_path.exists():
|
||||||
|
print(f"DB introuvable : {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
out_dir = Path(args.output_dir) if args.output_dir else None
|
||||||
|
export_cues(db_path, out_dir)
|
||||||
375
mixxx_import.py
Executable file
375
mixxx_import.py
Executable file
@@ -0,0 +1,375 @@
|
|||||||
|
#!/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()
|
||||||
38
mixxx_import.spec
Normal file
38
mixxx_import.spec
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(
|
||||||
|
['mixxx_import.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=[],
|
||||||
|
noarchive=False,
|
||||||
|
optimize=0,
|
||||||
|
)
|
||||||
|
pyz = PYZ(a.pure)
|
||||||
|
|
||||||
|
exe = EXE(
|
||||||
|
pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='mixxx_import',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=False,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
argv_emulation=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None,
|
||||||
|
)
|
||||||
183
test_export_import.py
Executable file
183
test_export_import.py
Executable file
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test d'intégration : export + import des cues Mixxx via les modules mixxx_export et mixxx_import.
|
||||||
|
Nécessite que mixxx_export.py et mixxx_import_gui.py (ou mixxx_import.py) soient dans le même répertoire,
|
||||||
|
ou ajustez les imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 1. Créer une base Mixxx temporaire avec les bonnes tables
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
def create_temp_db(db_path: Path):
|
||||||
|
con = sqlite3.connect(db_path)
|
||||||
|
con.executescript("""
|
||||||
|
CREATE TABLE track_locations (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
location TEXT,
|
||||||
|
filename TEXT,
|
||||||
|
directory TEXT,
|
||||||
|
filesize INTEGER,
|
||||||
|
fs_deleted INTEGER DEFAULT 0,
|
||||||
|
needs_verification INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE library (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
artist TEXT,
|
||||||
|
title TEXT,
|
||||||
|
album TEXT,
|
||||||
|
genre TEXT,
|
||||||
|
tracknumber INTEGER,
|
||||||
|
year INTEGER,
|
||||||
|
duration REAL,
|
||||||
|
samplerate INTEGER,
|
||||||
|
bitrate INTEGER,
|
||||||
|
channels INTEGER,
|
||||||
|
bpm REAL,
|
||||||
|
key TEXT,
|
||||||
|
rating INTEGER,
|
||||||
|
comment TEXT,
|
||||||
|
location INTEGER REFERENCES track_locations(id) -- clé manquante dans l'exemple précédent
|
||||||
|
);
|
||||||
|
CREATE TABLE cues (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
track_id INTEGER,
|
||||||
|
type INTEGER,
|
||||||
|
position REAL,
|
||||||
|
length REAL,
|
||||||
|
hotcue INTEGER,
|
||||||
|
label TEXT,
|
||||||
|
color INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS Playlists (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
position INTEGER,
|
||||||
|
hidden INTEGER DEFAULT 0,
|
||||||
|
date_created TEXT,
|
||||||
|
date_modified TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS PlaylistTracks (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
playlist_id INTEGER,
|
||||||
|
track_id INTEGER,
|
||||||
|
position INTEGER,
|
||||||
|
pl_datetime_added TEXT
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
con.commit()
|
||||||
|
return con
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 2. Insérer des données de test
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
def populate_test_data(con, audio_file_path: Path):
|
||||||
|
# Une piste "test.flac"
|
||||||
|
tl_id = 1
|
||||||
|
con.execute("""
|
||||||
|
INSERT INTO track_locations (id, location, filename, directory, filesize)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
""", (tl_id, str(audio_file_path), audio_file_path.name, str(audio_file_path.parent), 12345))
|
||||||
|
con.execute("""
|
||||||
|
INSERT INTO library (id, artist, title, album, genre, year, duration, location)
|
||||||
|
VALUES (?, 'Test', 'Ma Chanson', '', '', 2025, 200, ?)
|
||||||
|
""", (tl_id, tl_id)) # location pointe vers track_locations.id
|
||||||
|
# Deux hot cues
|
||||||
|
con.execute("INSERT INTO cues (track_id, type, position, length, hotcue, label, color) VALUES (?,1,10.5,0,1,'Intro',16744448)", (tl_id,))
|
||||||
|
con.execute("INSERT INTO cues (track_id, type, position, length, hotcue, label, color) VALUES (?,1,45.0,0,2,'Drop',0xFF8000)", (tl_id,))
|
||||||
|
# Une boucle (type=4)
|
||||||
|
con.execute("INSERT INTO cues (track_id, type, position, length, hotcue, label, color) VALUES (?,4,60.0,15.0,-1,'',0)", (tl_id,))
|
||||||
|
con.commit()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 3. Fonctions de test
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
def run_export(db_path: Path, output_dir: Path):
|
||||||
|
# Utiliser le module mixxx_export directement
|
||||||
|
import mixxx_export
|
||||||
|
mixxx_export.export_cues(db_path, output_dir)
|
||||||
|
|
||||||
|
def run_import(music_dir: Path, db_path: Path, dry_run=False):
|
||||||
|
# Utiliser le module mixxx_import (v2)
|
||||||
|
import mixxx_import
|
||||||
|
mixxx_import.import_cues_v2(music_dir, db_path, dry_run=dry_run, log_callback=print)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tmp = Path(tmpdir)
|
||||||
|
db_path = tmp / "test_mixxxdb.sqlite"
|
||||||
|
audio_dir = tmp / "music"
|
||||||
|
audio_dir.mkdir()
|
||||||
|
audio_file = audio_dir / "test.flac"
|
||||||
|
audio_file.write_text("fake audio") # fichier factice
|
||||||
|
|
||||||
|
# 1. Créer DB et peupler
|
||||||
|
con = create_temp_db(db_path)
|
||||||
|
populate_test_data(con, audio_file)
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
# 2. Exporter les cues dans un sous-dossier
|
||||||
|
export_dir = tmp / "export"
|
||||||
|
export_dir.mkdir()
|
||||||
|
run_export(db_path, export_dir)
|
||||||
|
expected_json = export_dir / "test.flac.mixxx"
|
||||||
|
assert expected_json.exists(), "Le fichier .mixxx n'a pas été créé"
|
||||||
|
|
||||||
|
with open(expected_json, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
assert data["version"] == 1
|
||||||
|
assert data["filename"] == "test.flac"
|
||||||
|
assert len(data["cues"]) == 3
|
||||||
|
print("[PASS] Export OK")
|
||||||
|
|
||||||
|
# 3. Préparer l'import : copier le .mixxx dans le dossier audio
|
||||||
|
import_audio_dir = tmp / "import"
|
||||||
|
import_audio_dir.mkdir()
|
||||||
|
import_audio_file = import_audio_dir / "test.flac"
|
||||||
|
import_audio_file.write_text("fake audio") # recréer le fichier audio
|
||||||
|
import_json = import_audio_dir / "test.flac.mixxx"
|
||||||
|
import_json.write_text(expected_json.read_text())
|
||||||
|
|
||||||
|
# Créer une nouvelle DB vierge pour l'import (même structure)
|
||||||
|
fresh_db = tmp / "fresh_mixxxdb.sqlite"
|
||||||
|
create_temp_db(fresh_db).close()
|
||||||
|
|
||||||
|
# 4. Importer
|
||||||
|
run_import(import_audio_dir, fresh_db, dry_run=False)
|
||||||
|
|
||||||
|
# 5. Vérifier que les cues ont été insérés
|
||||||
|
con = sqlite3.connect(fresh_db)
|
||||||
|
con.row_factory = sqlite3.Row
|
||||||
|
# Vérifier la piste
|
||||||
|
tl = con.execute("SELECT * FROM track_locations WHERE filename='test.flac'").fetchone()
|
||||||
|
assert tl is not None, "Piste non importée"
|
||||||
|
track_id = tl["id"]
|
||||||
|
# Vérifier les cues
|
||||||
|
cues = con.execute("SELECT * FROM cues WHERE track_id=?", (track_id,)).fetchall()
|
||||||
|
assert len(cues) == 3, f"Nombre de cues incorrect : {len(cues)} au lieu de 3"
|
||||||
|
# Vérifier les types
|
||||||
|
types = {c["type"] for c in cues}
|
||||||
|
assert types == {1, 4}, f"Types de cues inattendus: {types}"
|
||||||
|
print("[PASS] Import OK")
|
||||||
|
|
||||||
|
# 6. Test dry-run
|
||||||
|
# Créer une autre DB vide pour simuler
|
||||||
|
dry_db = tmp / "dry_db.sqlite"
|
||||||
|
create_temp_db(dry_db).close()
|
||||||
|
run_import(import_audio_dir, dry_db, dry_run=True)
|
||||||
|
con = sqlite3.connect(dry_db)
|
||||||
|
cues_dry = con.execute("SELECT count(*) FROM cues").fetchone()[0]
|
||||||
|
assert cues_dry == 0, "Le dry-run a écrit des données !"
|
||||||
|
print("[PASS] Dry-run OK")
|
||||||
|
|
||||||
|
print("\n✅ Tous les tests ont réussi !")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user