feat: initial commit for export and import

This commit is contained in:
2026-06-17 11:47:28 +02:00
commit 8c6a25cdb0
5 changed files with 736 additions and 0 deletions

47
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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()