Initial commit: Add playlist downloader project with UI and core functionality

This commit is contained in:
2026-06-28 10:34:48 +02:00
commit 605dede363
49 changed files with 8300 additions and 0 deletions
View File
+164
View File
@@ -0,0 +1,164 @@
"""
Application principale - Interface graphique customtkinter.
"""
import fcntl
import os
import queue
import sys
import threading
import customtkinter as ctk
from src.ui.styles import COLORS, MAX_PER_TICK, get_font
from src.ui.tabs.create_tab import CreatePlaylistTab
from src.ui.tabs.download_tab import DownloadTab
from src.ui.tabs.download_worker import DownloadWorker
class App(ctk.CTk):
"""Application principale avec onglets."""
def __init__(self):
super().__init__()
self.title("🎵 dl_yt Playlist Manager & Downloader")
self.geometry("1400x950")
self.resizable(True, True)
self._q = queue.Queue()
self._download_worker = None
self._log_count = 0
self._build_ui()
self._poll()
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _build_ui(self):
"""Construire l'interface."""
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=1)
# En-tête
hdr = ctk.CTkFrame(self, corner_radius=10)
hdr.grid(row=0, column=0, padx=15, pady=(15, 5), sticky="ew")
ctk.CTkLabel(
hdr,
text="🎵 dl_yt — Playlist Manager & Downloader",
font=get_font("FONT_TITLE"),
).pack(pady=10)
# Onglets
self._tabview = ctk.CTkTabview(self, corner_radius=10)
self._tabview.grid(row=1, column=0, padx=15, pady=5, sticky="nsew")
# Onglet de création
self._create_tab = CreatePlaylistTab(self, self._tabview)
# Onglet de téléchargement
self._download_tab = DownloadTab(self, self._tabview, self._q)
def _start_download(self):
"""Démarrer le téléchargement."""
if self._download_tab._running:
return
self._download_worker = DownloadWorker(self._download_tab, self._q)
self._download_tab._running = True
self._download_tab._stop_event.clear()
self._download_tab._start_btn.configure(state="disabled")
self._download_tab._stop_btn.configure(state="normal")
self._download_tab._stats = {"downloaded": 0, "exists": 0, "skipped": 0, "failed": 0}
self._download_tab._done = 0
self._download_tab._overall_bar.set(0)
for v in self._download_tab._stat_vars.values():
v.set(v.get().split(":")[0] + ": 0")
for r in self._download_tab._rows:
r.clear()
threading.Thread(target=self._download_worker.run, daemon=True).start()
def _poll(self):
"""Boucle d'événements GUI."""
MAX_PER_TICK_VAL = MAX_PER_TICK
processed = 0
try:
while processed < MAX_PER_TICK_VAL:
msg = self._q.get_nowait()
kind = msg[0]
processed += 1
if kind == "track_start":
_, slot, label = msg
if slot < len(self._download_tab._rows):
self._download_tab._rows[slot].set_track(label)
elif kind == "track_pct":
_, slot, pct, stage = msg
if slot < len(self._download_tab._rows):
self._download_tab._rows[slot].update(pct, stage)
elif kind == "track_done":
_, slot, result = msg
if slot < len(self._download_tab._rows):
self._download_tab._rows[slot].finish(result)
elif kind == "total_update":
_, dl, ex, sk, fa, done, total = msg
self._download_tab._total_lbl.configure(text=f"{done} / {total}")
self._download_tab._overall_bar.set(done / total if total else 0)
icons = [
("downloaded", "⬇️ Downloaded"),
("exists", "⏭️ Exists"),
("skipped", "⏭️ Skipped"),
("failed", "❌ Failed"),
]
for (key, icon), val in zip(icons, [dl, ex, sk, fa]):
self._download_tab._stat_vars[key].set(f"{icon}: {val}")
elif kind == "cache_update":
_, count = msg
self._download_tab._cache_entries_var.set(f"💾 Cache: {count}")
elif kind == "log":
self._download_tab._log_msg_download(msg[1])
elif kind == "finished":
self._download_tab._running = False
self._download_tab._start_btn.configure(state="normal")
self._download_tab._stop_btn.configure(state="disabled")
except queue.Empty:
pass
finally:
self.after(20, self._poll)
def _on_close(self):
"""Gestion de la fermeture."""
if self._download_tab._running:
self._download_tab._stop()
if self._download_worker:
self._download_worker.stop()
self.destroy()
def main():
"""Point d'entrée principal."""
lock_path = "/tmp/dl_yt_gui.lock"
lock_file = open(lock_path, "w")
try:
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except IOError:
print("❌ dl_yt GUI is already running!")
sys.exit(1)
try:
app = App()
app.mainloop()
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
os.unlink(lock_path)
if __name__ == "__main__":
main()
+192
View File
@@ -0,0 +1,192 @@
"""
Application principale PyQt6 - Interface graphique.
"""
import sys
import queue
import threading
from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTabWidget,
QProgressBar, QTextEdit
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QObject
from PyQt6.QtGui import QFont
from src.ui.tabs.create_tab_qt import CreatePlaylistTab
from src.ui.tabs.download_tab_qt import DownloadTab
class LogEmitter(QObject):
"""Émetteur thread-safe pour les logs."""
message = pyqtSignal(str)
class App(QMainWindow):
"""Application principale avec onglets."""
def __init__(self):
super().__init__()
self.setWindowTitle("🎵 dl_yt Playlist Manager & Downloader")
self.setGeometry(100, 100, 1400, 950)
# Set window icon
from PyQt6.QtGui import QIcon
from pathlib import Path
icon_path = Path(__file__).parent.parent.parent / "music.png"
if icon_path.exists():
self.setWindowIcon(QIcon(str(icon_path)))
self._q = queue.Queue()
self._log_emitter = LogEmitter()
self._log_emitter.message.connect(self._add_log)
self._log_count = 0
self._build_ui()
self._poll()
def _build_ui(self):
"""Construire l'interface."""
central_widget = QWidget()
self.setCentralWidget(central_widget)
layout = QVBoxLayout()
layout.setContentsMargins(15, 15, 15, 15)
layout.setSpacing(10)
# En-tête
header_label = QLabel("🎵 dl_yt — Playlist Manager & Downloader")
header_font = QFont()
header_font.setPointSize(18)
header_font.setBold(True)
header_label.setFont(header_font)
header_label.setStyleSheet("color: #2196F3; padding: 10px;")
layout.addWidget(header_label)
# Onglets
self._tabs = QTabWidget()
self._tabs.setStyleSheet("""
QTabWidget::pane {
border: 1px solid #444444;
}
QTabBar::tab {
background-color: #2a2a2a;
color: #cccccc;
padding: 8px 20px;
border: 1px solid #444444;
margin-right: 2px;
}
QTabBar::tab:selected {
background-color: #2196F3;
color: white;
}
QTabBar::tab:hover {
background-color: #3a3a3a;
}
""")
# Tab: Create Playlist
self._create_tab = CreatePlaylistTab()
self._tabs.addTab(self._create_tab, " Create Playlist")
# Tab: Download
self._download_tab = DownloadTab(log_emitter=self._log_emitter)
self._tabs.addTab(self._download_tab, "⬇️ Download")
layout.addWidget(self._tabs, 1)
# Logs
log_label = QLabel("Logs:")
log_font = QFont()
log_font.setPointSize(10)
log_font.setBold(True)
log_label.setFont(log_font)
layout.addWidget(log_label)
self._log_text = QTextEdit()
self._log_text.setReadOnly(True)
self._log_text.setMaximumHeight(150)
self._log_text.setStyleSheet("""
QTextEdit {
background-color: #1a1a1a;
color: #cccccc;
border: 1px solid #444444;
border-radius: 4px;
font-family: Courier;
font-size: 9pt;
}
""")
layout.addWidget(self._log_text)
central_widget.setLayout(layout)
# Style global
self.setStyleSheet("""
QMainWindow {
background-color: #121212;
}
QWidget {
background-color: #121212;
color: #ffffff;
}
QLabel {
color: #ffffff;
}
QLineEdit {
background-color: #2a2a2a;
color: #ffffff;
border: 1px solid #444444;
border-radius: 4px;
padding: 5px;
}
QLineEdit:focus {
border: 2px solid #2196F3;
}
""")
def _poll(self):
"""Vérifier les logs en attente."""
try:
while True:
msg = self._q.get_nowait()
self._add_log(msg)
except queue.Empty:
pass
# Poll again in 100ms
QTimer.singleShot(100, self._poll)
def _add_log(self, msg: str):
"""Ajouter un log."""
self._log_count += 1
if self._log_count > 1000: # Limiter à 1000 lignes
self._log_text.clear()
self._log_count = 0
self._log_text.append(msg)
# Scroll to bottom
scrollbar = self._log_text.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
def closeEvent(self, event):
"""Gérer la fermeture."""
# Cleanup if needed
event.accept()
def main():
"""Point d'entrée."""
# IMPORTANT: Créer QApplication EN PREMIER
from PyQt6.QtWidgets import QApplication
app_instance = QApplication.instance()
if app_instance is None:
app_instance = QApplication(sys.argv)
# MAINTENANT créer l'App (qui contient des QWidgets)
app_qt = App()
app_qt.show()
sys.exit(app_instance.exec())
if __name__ == "__main__":
main()
View File
+59
View File
@@ -0,0 +1,59 @@
"""
Composant Button réutilisable - Bouton customtkinter standardisé.
"""
import customtkinter as ctk
from src.ui.styles import COLORS
class CustomButton(ctk.CTkButton):
"""Bouton standardisé pour l'application."""
def __init__(
self,
parent,
text: str,
command=None,
size: str = "medium",
button_type: str = "normal",
**kwargs
):
"""
Créer un bouton customisé.
Args:
parent: Widget parent
text: Texte du bouton
command: Fonction à exécuter
size: 'small', 'medium', 'large' (défaut: medium)
button_type: 'normal', 'success', 'danger', 'neutral' (défaut: normal)
**kwargs: Arguments additionnels pour CTkButton
"""
# Configuration de taille
size_config = {
"small": {"width": 100, "height": 32, "font": ctk.CTkFont(size=11)},
"medium": {"width": 130, "height": 38, "font": ctk.CTkFont(size=12)},
"large": {"width": 150, "height": 45, "font": ctk.CTkFont(size=16, weight="bold")},
}
config = size_config.get(size, size_config["medium"])
# Configuration de couleur
color_config = {
"normal": {"fg_color": "#1f6aa5", "hover_color": "#144a7a"},
"success": {"fg_color": COLORS["success"], "hover_color": COLORS["success_hover"]},
"danger": {"fg_color": COLORS["danger"], "hover_color": COLORS["danger_hover"]},
"neutral": {"fg_color": COLORS["neutral"], "hover_color": "#444"},
}
colors = color_config.get(button_type, color_config["normal"])
# Fusionner les configurations
final_config = {**config, **colors, **kwargs}
super().__init__(
parent,
text=text,
command=command,
**final_config
)
+126
View File
@@ -0,0 +1,126 @@
"""
DeleteModal pour PyQt6 - Modale de confirmation de suppression personnalisée.
"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
class DeleteModal(QDialog):
"""Modale de confirmation de suppression personnalisée et jolie."""
confirmed = pyqtSignal()
cancelled = pyqtSignal()
def __init__(self, parent, title: str = "Confirm", message: str = "Are you sure?"):
"""
Créer une modale de confirmation.
Args:
parent: Widget parent
title: Titre
message: Message de confirmation
"""
super().__init__(parent)
self.setWindowTitle(title)
self.setModal(True)
self.setFixedSize(450, 200)
self.result_value = None
self.setStyleSheet("""
QDialog {
background-color: #212121;
border-radius: 8px;
}
QLabel {
color: #ffffff;
}
""")
# Layout principal
layout = QVBoxLayout()
layout.setContentsMargins(30, 30, 30, 30)
layout.setSpacing(20)
# Message
msg_label = QLabel(message)
msg_font = QFont()
msg_font.setPointSize(11)
msg_label.setFont(msg_font)
msg_label.setStyleSheet("color: #cccccc;")
layout.addWidget(msg_label)
layout.addStretch()
# Boutons
btn_layout = QHBoxLayout()
btn_layout.setSpacing(10)
# Bouton Cancel
cancel_btn = QPushButton("Cancel")
cancel_btn.setFixedSize(120, 40)
cancel_btn.setStyleSheet("""
QPushButton {
background-color: #757575;
color: white;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}
QPushButton:hover {
background-color: #616161;
}
QPushButton:pressed {
background-color: #4a4a4a;
}
""")
cancel_btn.clicked.connect(self._on_cancel)
btn_layout.addWidget(cancel_btn)
btn_layout.addStretch()
# Bouton Delete/Clear
delete_btn = QPushButton("Delete")
delete_btn.setFixedSize(120, 40)
delete_btn.setStyleSheet("""
QPushButton {
background-color: #f44336;
color: white;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}
QPushButton:hover {
background-color: #d32f2f;
}
QPushButton:pressed {
background-color: #c62828;
}
""")
delete_btn.clicked.connect(self._on_confirmed)
btn_layout.addWidget(delete_btn)
layout.addLayout(btn_layout)
self.setLayout(layout)
# Centre la modal
self.move(parent.x() + parent.width() // 2 - 225,
parent.y() + parent.height() // 2 - 100)
def _on_confirmed(self):
"""Action si confirmation."""
self.result_value = True
self.confirmed.emit()
self.accept()
def _on_cancel(self):
"""Action si annulation."""
self.result_value = False
self.cancelled.emit()
self.reject()
+116
View File
@@ -0,0 +1,116 @@
"""
Composant LoadingModal - Modal de chargement avec animation.
"""
import customtkinter as ctk
from src.ui.styles import get_font
class LoadingModal(ctk.CTkToplevel):
"""Modal de chargement avec animation."""
def __init__(self, parent, title: str = "Loading", message: str = "Please wait...", determinate: bool = False):
"""
Créer une modal de chargement.
Args:
parent: Widget parent
title: Titre
message: Message de chargement
determinate: Si True, barre de progression déterminée (0→1). Sinon, indéterminée
"""
super().__init__(parent)
self.title(title)
self.geometry("400x200")
self.resizable(False, False)
self.transient(parent)
# Note: grab_set() removed to prevent UI freeze during long operations
# try:
# self.grab_set()
# except Exception:
# pass
# Centre la modal
self.update_idletasks()
parent_x = parent.winfo_x()
parent_y = parent.winfo_y()
parent_w = parent.winfo_width()
parent_h = parent.winfo_height()
x = parent_x + (parent_w // 2) - 200
y = parent_y + (parent_h // 2) - 100
self.geometry(f"+{x}+{y}")
# Apparence
self.configure(fg_color="#212121")
# Layout
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=1)
# Titre
ctk.CTkLabel(
self,
text=title,
font=get_font("FONT_SECTION"),
text_color="#ffffff",
).grid(row=0, column=0, padx=20, pady=(20, 10))
# Message
msg_frame = ctk.CTkFrame(self, fg_color="transparent")
msg_frame.grid(row=1, column=0, padx=20, pady=10, sticky="nsew")
msg_frame.grid_columnconfigure(0, weight=1)
msg_frame.grid_rowconfigure(1, weight=1)
self._msg_label = ctk.CTkLabel(
msg_frame,
text=message,
font=ctk.CTkFont(size=12),
text_color="#cccccc",
)
self._msg_label.grid(row=0, column=0, sticky="ew", pady=(0, 10))
# Progress bar (déterminée ou indéterminée)
self._progress = ctk.CTkProgressBar(
msg_frame,
height=6,
)
self._progress.grid(row=1, column=0, sticky="ew")
if determinate:
self._progress.configure(mode="determinate")
self._progress.set(0)
else:
self._progress.configure(mode="indeterminate", indeterminate_speed=1.0)
self._progress.start()
# Prevent closing
self.protocol("WM_DELETE_WINDOW", lambda: None)
def update_message(self, message: str):
"""Mettre à jour le message."""
try:
self._msg_label.configure(text=message)
self.update_idletasks()
except Exception:
pass
def set_progress(self, value: float):
"""Définir la progression (0.0 à 1.0) si en mode déterminé."""
try:
v = 0.0 if value is None else max(0.0, min(1.0, float(value)))
# Si la barre est en mode déterminée, set() mettra à jour la progression
self._progress.set(v)
self.update_idletasks()
except Exception:
pass
def close(self):
"""Fermer la modal."""
try:
# Arrêter l'animation si en mode indéterminé
try:
self._progress.stop()
except Exception:
pass
self.destroy()
except Exception:
pass
+138
View File
@@ -0,0 +1,138 @@
"""
LoadingModal pour PyQt6 - Modal de chargement avec barre de progression.
"""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QProgressBar
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont
class LoadingModal(QDialog):
"""Modal de chargement avec barre de progression déterminée ou indéterminée."""
def __init__(self, parent, title: str = "Loading", message: str = "Please wait...", determinate: bool = False):
"""
Créer une modal de chargement.
Args:
parent: Widget parent
title: Titre
message: Message de chargement
determinate: Si True, barre de progression déterminée (0→100). Sinon, indéterminée
"""
super().__init__(parent)
self.setWindowTitle(title)
self.setModal(False)
self.setFixedSize(500, 250)
self.setStyleSheet("""
QDialog {
background-color: #212121;
border-radius: 8px;
}
QLabel {
color: #ffffff;
}
""")
# Layout
layout = QVBoxLayout()
layout.setContentsMargins(30, 30, 30, 30)
layout.setSpacing(15)
# Titre
title_label = QLabel(title)
title_font = QFont()
title_font.setPointSize(16)
title_font.setBold(True)
title_label.setFont(title_font)
layout.addWidget(title_label)
# Message
self._msg_label = QLabel(message)
msg_font = QFont()
msg_font.setPointSize(11)
self._msg_label.setFont(msg_font)
layout.addWidget(self._msg_label)
# Progress bar
self._progress = QProgressBar()
self._progress.setStyleSheet("""
QProgressBar {
background-color: #3a3a3a;
border: none;
border-radius: 5px;
height: 8px;
}
QProgressBar::chunk {
background-color: #2196F3;
border-radius: 5px;
}
""")
if determinate:
self._progress.setMinimum(0)
self._progress.setMaximum(100)
self._progress.setValue(0)
self._determinate = True
else:
self._progress.setMinimum(0)
self._progress.setMaximum(0)
self._determinate = False
self._animation_timer = QTimer()
self._animation_timer.timeout.connect(self._animate_progress)
self._animation_timer.start(50)
layout.addWidget(self._progress)
layout.addStretch()
self.setLayout(layout)
# Centre la modal
self.move(parent.x() + parent.width() // 2 - 250,
parent.y() + parent.height() // 2 - 125)
def update_message(self, message: str):
"""Mettre à jour le message."""
try:
self._msg_label.setText(message)
self.repaint()
except Exception:
pass
def set_progress(self, value: float):
"""Définir la progression (0.0 à 1.0) si en mode déterminé."""
try:
if self._determinate:
percent = max(0, min(100, int(value * 100)))
self._progress.setValue(percent)
self.repaint()
except Exception:
pass
def _animate_progress(self):
"""Animation pour la barre indéterminée."""
try:
if not self._determinate:
current = self._progress.value()
self._progress.setValue((current + 5) % 100)
except Exception:
pass
def close(self):
"""Fermer la modal."""
try:
if hasattr(self, '_animation_timer'):
self._animation_timer.stop()
self.hide()
self.deleteLater()
except Exception:
pass
def closeEvent(self, event):
"""Gérer la fermeture."""
try:
if hasattr(self, '_animation_timer'):
self._animation_timer.stop()
except Exception:
pass
event.accept()
+191
View File
@@ -0,0 +1,191 @@
"""
Composant Modal - Fenêtre de dialogue personnalisée avec thème sombre.
"""
import customtkinter as ctk
from src.ui.styles import COLORS, get_font
class Modal(ctk.CTkToplevel):
"""Modal dialog avec thème sombre et design moderne."""
def __init__(
self,
parent,
title: str = "Confirmation",
message: str = "Are you sure?",
btn_yes_text: str = "Oui",
btn_no_text: str = "Non",
modal_type: str = "info",
):
"""
Créer une modal personnalisée.
Args:
parent: Widget parent
title: Titre de la modal
message: Message à afficher
btn_yes_text: Texte du bouton confirmer
btn_no_text: Texte du bouton annuler
modal_type: 'info', 'warning', 'danger'
"""
super().__init__(parent)
self.result = None
# Configuration de la fenêtre
self.title(title)
self.geometry("500x250")
self.resizable(False, False)
self.transient(parent)
# Centre la modal sur la fenêtre parent
self.update_idletasks()
parent_x = parent.winfo_x()
parent_y = parent.winfo_y()
parent_w = parent.winfo_width()
parent_h = parent.winfo_height()
x = parent_x + (parent_w // 2) - 250
y = parent_y + (parent_h // 2) - 125
self.geometry(f"+{x}+{y}")
# Faire le grab après que la fenêtre soit visible
self.grab_set()
# Apparence
self._set_appearance()
# Layout
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(1, weight=1)
# Icône et titre
icon_color = self._get_icon_color(modal_type)
icon_text = self._get_icon_text(modal_type)
header_frame = ctk.CTkFrame(self, fg_color="transparent")
header_frame.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="ew")
header_frame.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(
header_frame,
text=icon_text,
font=ctk.CTkFont(size=28),
text_color=icon_color,
).grid(row=0, column=0, padx=(0, 10), sticky="w")
ctk.CTkLabel(
header_frame,
text=title,
font=get_font("FONT_SECTION"),
text_color="#ffffff",
).grid(row=0, column=1, sticky="w")
# Message
msg_frame = ctk.CTkFrame(self, fg_color="transparent")
msg_frame.grid(row=1, column=0, padx=20, pady=15, sticky="nsew")
ctk.CTkLabel(
msg_frame,
text=message,
font=ctk.CTkFont(size=12),
text_color="#cccccc",
wraplength=460,
justify="left",
).pack(fill="both", expand=True)
# Boutons
btn_frame = ctk.CTkFrame(self, fg_color="transparent")
btn_frame.grid(row=2, column=0, padx=20, pady=(10, 20), sticky="ew")
btn_frame.grid_columnconfigure((0, 1), weight=1)
ctk.CTkButton(
btn_frame,
text=btn_yes_text,
height=40,
font=ctk.CTkFont(size=12, weight="bold"),
fg_color=COLORS["success"],
hover_color=COLORS["success_hover"],
command=self._on_yes,
).grid(row=0, column=0, padx=(0, 5), sticky="ew")
ctk.CTkButton(
btn_frame,
text=btn_no_text,
height=40,
font=ctk.CTkFont(size=12, weight="bold"),
fg_color=COLORS["danger"],
hover_color=COLORS["danger_hover"],
command=self._on_no,
).grid(row=0, column=1, padx=(5, 0), sticky="ew")
# Focus sur le bouton "Non" par défaut
self.after(100, lambda: self.focus_set())
def _set_appearance(self):
"""Configurer l'apparence avec le thème sombre."""
ctk.set_appearance_mode("dark")
self.configure(fg_color="#212121")
@staticmethod
def _get_icon_text(modal_type: str) -> str:
"""Obtenir l'icône basée sur le type."""
icons = {
"info": "",
"warning": "⚠️",
"danger": "🗑️",
}
return icons.get(modal_type, "")
@staticmethod
def _get_icon_color(modal_type: str) -> str:
"""Obtenir la couleur basée sur le type."""
colors = {
"info": "#1f6aa5",
"warning": "#ffb700",
"danger": "#e74c3c",
}
return colors.get(modal_type, "#1f6aa5")
def _on_yes(self):
"""Action du bouton Oui."""
self.result = True
self.destroy()
def _on_no(self):
"""Action du bouton Non."""
self.result = False
self.destroy()
def ask(self) -> bool:
"""Afficher la modal et retourner le résultat."""
self.wait_window()
return self.result if self.result is not None else False
def ask_confirmation(
parent,
title: str = "Confirmation",
message: str = "Are you sure?",
modal_type: str = "info",
) -> bool:
"""
Fonction helper pour montrer une modal et récupérer la réponse.
Args:
parent: Widget parent
title: Titre
message: Message
modal_type: 'info', 'warning', 'danger'
Returns:
True si Oui, False si Non
"""
modal = Modal(
parent,
title=title,
message=message,
btn_yes_text="Oui",
btn_no_text="Non",
modal_type=modal_type,
)
return modal.ask()
+595
View File
@@ -0,0 +1,595 @@
"""Volume Normalization Modal component for PyQt6."""
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QRadioButton,
QLabel, QLineEdit, QDoubleSpinBox, QPushButton, QButtonGroup, QComboBox
)
from PyQt6.QtCore import pyqtSignal, Qt
from PyQt6.QtGui import QFont, QColor, QKeyEvent
from src.core.config import NormalizationSettings
class CustomLineEdit(QLineEdit):
"""Custom QLineEdit that handles arrow keys for value adjustment."""
value_changed = pyqtSignal(float)
def __init__(self, parent=None, step=0.5):
super().__init__(parent)
self.step = step
# Allow focus even when read-only
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def keyPressEvent(self, event: QKeyEvent):
"""Handle arrow keys to adjust value."""
if event.key() == Qt.Key.Key_Up:
try:
current = float(self.text())
self.setValue(current + self.step)
event.accept()
except ValueError:
super().keyPressEvent(event)
elif event.key() == Qt.Key.Key_Down:
try:
current = float(self.text())
self.setValue(current - self.step)
event.accept()
except ValueError:
super().keyPressEvent(event)
else:
super().keyPressEvent(event)
def setValue(self, value):
"""Set value and emit signal."""
# Temporarily disable read-only to set text
was_readonly = self.isReadOnly()
self.setReadOnly(False)
self.setText(str(value))
self.setReadOnly(was_readonly)
self.value_changed.emit(value)
class CustomDoubleSpinBox(QDoubleSpinBox):
"""Custom QDoubleSpinBox that properly handles arrow keys."""
def keyPressEvent(self, event: QKeyEvent):
"""Handle key press events for arrow keys."""
if event.key() == Qt.Key.Key_Up:
# Increase value
self.setValue(self.value() + self.singleStep())
event.accept()
elif event.key() == Qt.Key.Key_Down:
# Decrease value
self.setValue(self.value() - self.singleStep())
event.accept()
else:
super().keyPressEvent(event)
class NormalizationModal(QDialog):
"""Modal dialog for configuring volume normalization settings."""
settings_applied = pyqtSignal(dict) # Emitted when Apply is clicked
def __init__(self, parent, config):
"""Initialize modal with current settings from config.
Args:
parent: Parent widget
config: Config module instance
"""
super().__init__(parent)
self.config = config
self.current_settings = {}
# Set modal properties
self.setWindowTitle("Volume Normalization Settings")
self.setModal(True)
self.setFixedSize(500, 280) # Smaller default size
self.setStyleSheet("""
QDialog {
background-color: #212121;
color: #ffffff;
}
QGroupBox {
color: #ffffff;
border: 1px solid #444444;
border-radius: 4px;
margin-top: 8px;
padding-top: 8px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 3px 0 3px;
}
QRadioButton {
color: #ffffff;
spacing: 5px;
}
QRadioButton::indicator {
width: 18px;
height: 18px;
}
QLineEdit, QDoubleSpinBox {
background-color: #333333;
color: #ffffff;
border: 1px solid #555555;
border-radius: 3px;
padding: 4px;
}
QPushButton {
border-radius: 3px;
padding: 6px 16px;
font-weight: bold;
}
QPushButton#applyBtn {
background-color: #2196F3;
color: #ffffff;
}
QPushButton#applyBtn:hover {
background-color: #1976D2;
}
QPushButton#applyBtn:disabled {
background-color: #757575;
}
QPushButton#cancelBtn {
background-color: #757575;
color: #ffffff;
}
QPushButton#cancelBtn:hover {
background-color: #616161;
}
""")
self._build_ui()
self._load_current_settings()
def _build_ui(self):
"""Build the modal UI."""
layout = QVBoxLayout()
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
# Presets group
presets_group = QGroupBox("Presets")
presets_layout = QVBoxLayout()
self.preset_combo = QComboBox()
self.presets = ['Quiet', 'Normal', 'Loud', 'Very Loud', 'Custom']
self.preset_combo.setStyleSheet("""
QComboBox {
min-height: 40px;
max-height: 40px;
font-family: "Franklin Gothic Medium";
font-size: 16px;
padding: 1px 1px 1px 1px;
background-color: #333333;
color: #ffffff;
border: 1px solid #555555;
border-radius: 4px;
}
QComboBox::drop-down {
width: 40px;
border: 0px;
background-color: #2196F3;
border-radius: 3px;
margin-right: 2px;
}
QComboBox::down-arrow {
image: url(resources/arrow_down.png);
width: 32px;
height: 32px;
}
QComboBox QAbstractItemView {
background-color: #333333;
color: #ffffff;
border: 1px solid #555555;
selection-background-color: #2196F3;
padding: 4px;
font-family: "Franklin Gothic Medium";
font-size: 16px;
}
""")
# Add items without arrow indicator (now using PNG)
for preset in self.presets:
self.preset_combo.addItem(preset)
self.preset_combo.currentTextChanged.connect(self._on_preset_selected)
presets_layout.addWidget(self.preset_combo)
presets_group.setLayout(presets_layout)
layout.addWidget(presets_group)
# Parameters display group
params_display_group = QGroupBox("Current Parameters")
params_display_layout = QVBoxLayout()
self.params_display = QLabel("I=-14 TP=-1.5 LRA=11")
self.params_display.setFont(QFont("Arial", 11))
self.params_display.setStyleSheet("color: #ffffff;")
params_display_layout.addWidget(self.params_display)
params_display_group.setLayout(params_display_layout)
layout.addWidget(params_display_group)
# Custom parameters group
params_group = QGroupBox("Custom Parameters")
params_layout = QVBoxLayout()
params_layout.setSpacing(14) # Reduced from 20 to 14
# I parameter
i_label = QLabel("Integrated Loudness (I): -23 to 0 LUFS")
i_label.setStyleSheet("color: #cccccc; font-size: 10px;")
params_layout.addWidget(i_label)
i_input_layout = QHBoxLayout()
i_input_layout.setSpacing(8)
i_minus_btn = QPushButton("")
i_minus_btn.setMaximumWidth(45)
i_minus_btn.setMinimumHeight(36)
i_minus_btn.setStyleSheet("""
QPushButton {
background-color: #f44336;
color: #ffffff;
border: none;
border-radius: 3px;
font-weight: bold;
font-size: 16px;
}
QPushButton:hover {
background-color: #d32f2f;
}
QPushButton:pressed {
background-color: #c62828;
}
""")
i_minus_btn.clicked.connect(lambda: self._adjust_parameter('I', -0.5))
self.i_input = CustomLineEdit(step=0.5)
self.i_input.setText("-14.0")
self.i_input.setReadOnly(True)
self.i_input.setMinimumHeight(36)
self.i_input.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.i_input.setStyleSheet("""
QLineEdit {
background-color: #333333;
color: #ffffff;
border: 1px solid #555555;
border-radius: 3px;
padding: 6px;
font-size: 12px;
}
""")
i_plus_btn = QPushButton("+")
i_plus_btn.setMaximumWidth(45)
i_plus_btn.setMinimumHeight(36)
i_plus_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: #ffffff;
border: none;
border-radius: 3px;
font-weight: bold;
font-size: 16px;
}
QPushButton:hover {
background-color: #45a049;
}
QPushButton:pressed {
background-color: #3d8b40;
}
""")
i_plus_btn.clicked.connect(lambda: self._adjust_parameter('I', 0.5))
i_input_layout.addWidget(i_minus_btn)
i_input_layout.addWidget(self.i_input, 1)
i_input_layout.addWidget(i_plus_btn)
params_layout.addLayout(i_input_layout)
# TP parameter
tp_label = QLabel("True Peak (TP): -5 to 0 dBFS")
tp_label.setStyleSheet("color: #cccccc; font-size: 10px;")
params_layout.addWidget(tp_label)
tp_input_layout = QHBoxLayout()
tp_input_layout.setSpacing(8)
tp_minus_btn = QPushButton("")
tp_minus_btn.setMaximumWidth(45)
tp_minus_btn.setMinimumHeight(36)
tp_minus_btn.setStyleSheet("""
QPushButton {
background-color: #f44336;
color: #ffffff;
border: none;
border-radius: 3px;
font-weight: bold;
font-size: 16px;
}
QPushButton:hover {
background-color: #d32f2f;
}
QPushButton:pressed {
background-color: #c62828;
}
""")
tp_minus_btn.clicked.connect(lambda: self._adjust_parameter('TP', -0.1))
self.tp_input = CustomLineEdit(step=0.1)
self.tp_input.setText("-1.5")
self.tp_input.setReadOnly(True)
self.tp_input.setMinimumHeight(36)
self.tp_input.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.tp_input.setStyleSheet("""
QLineEdit {
background-color: #333333;
color: #ffffff;
border: 1px solid #555555;
border-radius: 3px;
padding: 6px;
font-size: 12px;
}
""")
tp_plus_btn = QPushButton("+")
tp_plus_btn.setMaximumWidth(45)
tp_plus_btn.setMinimumHeight(36)
tp_plus_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: #ffffff;
border: none;
border-radius: 3px;
font-weight: bold;
font-size: 16px;
}
QPushButton:hover {
background-color: #45a049;
}
QPushButton:pressed {
background-color: #3d8b40;
}
""")
tp_plus_btn.clicked.connect(lambda: self._adjust_parameter('TP', 0.1))
tp_input_layout.addWidget(tp_minus_btn)
tp_input_layout.addWidget(self.tp_input, 1)
tp_input_layout.addWidget(tp_plus_btn)
params_layout.addLayout(tp_input_layout)
# LRA parameter
lra_label = QLabel("Loudness Range (LRA): 1 to 20 LU")
lra_label.setStyleSheet("color: #cccccc; font-size: 10px;")
params_layout.addWidget(lra_label)
lra_input_layout = QHBoxLayout()
lra_input_layout.setSpacing(8)
lra_minus_btn = QPushButton("")
lra_minus_btn.setMaximumWidth(45)
lra_minus_btn.setMinimumHeight(36)
lra_minus_btn.setStyleSheet("""
QPushButton {
background-color: #f44336;
color: #ffffff;
border: none;
border-radius: 3px;
font-weight: bold;
font-size: 16px;
}
QPushButton:hover {
background-color: #d32f2f;
}
QPushButton:pressed {
background-color: #c62828;
}
""")
lra_minus_btn.clicked.connect(lambda: self._adjust_parameter('LRA', -0.5))
self.lra_input = CustomLineEdit(step=0.5)
self.lra_input.setText("11.0")
self.lra_input.setReadOnly(True)
self.lra_input.setMinimumHeight(36)
self.lra_input.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.lra_input.setStyleSheet("""
QLineEdit {
background-color: #333333;
color: #ffffff;
border: 1px solid #555555;
border-radius: 3px;
padding: 6px;
font-size: 12px;
}
""")
lra_plus_btn = QPushButton("+")
lra_plus_btn.setMaximumWidth(45)
lra_plus_btn.setMinimumHeight(36)
lra_plus_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: #ffffff;
border: none;
border-radius: 3px;
font-weight: bold;
font-size: 16px;
}
QPushButton:hover {
background-color: #45a049;
}
QPushButton:pressed {
background-color: #3d8b40;
}
""")
lra_plus_btn.clicked.connect(lambda: self._adjust_parameter('LRA', 0.5))
lra_input_layout.addWidget(lra_minus_btn)
lra_input_layout.addWidget(self.lra_input, 1)
lra_input_layout.addWidget(lra_plus_btn)
params_layout.addLayout(lra_input_layout)
params_group.setLayout(params_layout)
self.params_group = params_group # Store reference to hide/show later
layout.addWidget(params_group)
params_group.setMaximumHeight(0) # Hide by default (collapse instead of hide)
# Error display area
self.error_label = QLabel("")
self.error_label.setStyleSheet("color: #ff6b6b; background-color: #3d2626; padding: 8px; border-radius: 3px;")
self.error_label.setVisible(False)
layout.addWidget(self.error_label)
layout.addStretch()
# Buttons
button_layout = QHBoxLayout()
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setObjectName("cancelBtn")
cancel_btn.clicked.connect(self._on_cancel)
button_layout.addWidget(cancel_btn)
self.apply_btn = QPushButton("Apply")
self.apply_btn.setObjectName("applyBtn")
self.apply_btn.clicked.connect(self._on_apply)
button_layout.addWidget(self.apply_btn)
layout.addLayout(button_layout)
self.setLayout(layout)
def _load_current_settings(self):
"""Load saved settings from Config module."""
self.current_settings = NormalizationSettings.load_settings()
preset = self.current_settings.get('current_preset', 'Normal')
params = self.current_settings.get('custom_parameters', {})
# Update input fields first
self._update_parameter_fields(params)
self._update_params_display(params)
# Select preset in combo box (this will trigger _on_preset_selected)
if preset in self.presets:
self.preset_combo.setCurrentText(preset)
else:
self.preset_combo.setCurrentText('Normal')
# Ensure visibility is set correctly after UI is built
self._on_preset_selected(self.preset_combo.currentText())
def _on_preset_selected(self, preset_name: str):
"""Handle preset selection - update fields and enable/disable editing."""
if preset_name != 'Custom':
params = NormalizationSettings.get_preset_params(preset_name)
self._update_parameter_fields(params)
self._enable_custom_editing(False)
self.params_group.setMaximumHeight(0) # Hide custom parameters
# Resize modal to smaller size
self.setFixedSize(500, 280)
else:
self._enable_custom_editing(True)
self.params_group.setMaximumHeight(16777215) # Show custom parameters (max height)
# Resize modal to larger size
self.setFixedSize(500, 600)
# Set focus to first spinbox when Custom is selected
self.i_input.setFocus()
self._update_params_display(self._get_current_params())
self._validate_parameters()
def _on_parameter_changed(self, param_name: str, value):
"""Handle parameter input changes - validate and update UI."""
self._validate_parameters()
self._update_params_display(self._get_current_params())
def _adjust_parameter(self, param_name: str, delta: float):
"""Adjust parameter value by delta amount."""
if param_name == 'I':
current = float(self.i_input.text())
new_value = max(-23, min(0, current + delta))
self.i_input.setText(str(new_value))
elif param_name == 'TP':
current = float(self.tp_input.text())
new_value = max(-5, min(0, current + delta))
self.tp_input.setText(str(new_value))
elif param_name == 'LRA':
current = float(self.lra_input.text())
new_value = max(1, min(20, current + delta))
self.lra_input.setText(str(new_value))
self._validate_parameters()
self._update_params_display(self._get_current_params())
def _get_current_params(self) -> dict:
"""Get current parameter values from input fields."""
try:
i_val = float(self.i_input.text())
tp_val = float(self.tp_input.text())
lra_val = float(self.lra_input.text())
except ValueError:
# Return current values if parsing fails
i_val = -14
tp_val = -1.5
lra_val = 11
return {
'I': i_val,
'TP': tp_val,
'LRA': lra_val,
}
def _validate_parameters(self) -> tuple[bool, str]:
"""Validate all parameters against FFmpeg specifications."""
params = self._get_current_params()
valid, error_msg = NormalizationSettings.validate_parameters(params)
if not valid:
self.error_label.setText(error_msg)
self.error_label.setVisible(True)
self.apply_btn.setEnabled(False)
else:
self.error_label.setVisible(False)
self.apply_btn.setEnabled(True)
return valid, error_msg
def _update_parameter_fields(self, params: dict):
"""Update input fields with parameter values."""
self.i_input.setText(str(params.get('I', -14)))
self.tp_input.setText(str(params.get('TP', -1.5)))
self.lra_input.setText(str(params.get('LRA', 11)))
def _update_params_display(self, params: dict):
"""Update the parameters display label."""
i_val = params.get('I', -14)
tp_val = params.get('TP', -1.5)
lra_val = params.get('LRA', 11)
self.params_display.setText(f"I={i_val} TP={tp_val} LRA={lra_val}")
def _enable_custom_editing(self, enabled: bool):
"""Enable/disable custom parameter input fields."""
# When enabled, allow arrow keys to work; when disabled, make read-only
self.i_input.setReadOnly(not enabled)
self.tp_input.setReadOnly(not enabled)
self.lra_input.setReadOnly(not enabled)
def _on_apply(self):
"""Save settings and close modal."""
valid, error_msg = self._validate_parameters()
if not valid:
return
preset = self.preset_combo.currentText()
params = self._get_current_params()
# Save to config
success = NormalizationSettings.save_settings(preset, params)
if not success:
self.error_label.setText("Failed to save settings. Please try again.")
self.error_label.setVisible(True)
return
# Emit signal and close
self.settings_applied.emit({
'current_preset': preset,
'custom_parameters': params
})
self.accept()
def _on_cancel(self):
"""Close modal without saving."""
self.reject()
def get_current_settings(self) -> dict:
"""Return current settings as dict."""
preset = self.preset_combo.currentText()
return {
'current_preset': preset,
'custom_parameters': self._get_current_params()
}
+77
View File
@@ -0,0 +1,77 @@
"""
Composant PlaylistTrackItem - Ligne affichant une chanson avec options.
"""
import customtkinter as ctk
from src.ui.styles import COLORS
class PlaylistTrackItem(ctk.CTkFrame):
"""Une ligne représentant une chanson dans la playlist."""
def __init__(self, parent, track_number: int, artist: str, title: str, on_delete=None):
"""
Créer une ligne de chanson.
Args:
parent: Widget parent
track_number: Numéro de la chanson
artist: Nom de l'artiste
title: Titre de la chanson
on_delete: Callback quand on clique sur le bouton delete
"""
super().__init__(parent, fg_color="#2a2a2a", corner_radius=6, border_width=1, border_color="#444444")
self.track_number = track_number
self.artist = artist
self.title = title
self.on_delete = on_delete
# Layout
self.grid_columnconfigure(1, weight=1)
# Numéro
ctk.CTkLabel(
self,
text=f"{track_number}.",
font=ctk.CTkFont(size=12, weight="bold"),
text_color="#888888",
width=40,
).grid(row=0, column=0, padx=(10, 5), pady=10, sticky="w")
# Info chanson
info_frame = ctk.CTkFrame(self, fg_color="transparent")
info_frame.grid(row=0, column=1, padx=10, pady=10, sticky="ew")
info_frame.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(
info_frame,
text=artist,
font=ctk.CTkFont(size=12, weight="bold"),
text_color="#ffffff",
).grid(row=0, column=0, sticky="w")
ctk.CTkLabel(
info_frame,
text=title,
font=ctk.CTkFont(size=11),
text_color="#cccccc",
).grid(row=1, column=0, sticky="w")
# Bouton delete
delete_btn = ctk.CTkButton(
self,
text="",
font=ctk.CTkFont(size=14, weight="bold"),
width=40,
height=40,
fg_color="#7a2a2a",
hover_color="#5a1d1d",
text_color="#ffffff",
command=self._on_delete_click,
)
delete_btn.grid(row=0, column=2, padx=(5, 10), pady=10, sticky="e")
def _on_delete_click(self):
"""Appeler le callback de suppression."""
if self.on_delete:
self.on_delete(self.track_number - 1) # Index 0-based
@@ -0,0 +1,81 @@
"""
PlaylistTrackItem pour PyQt6 - Ligne affichant une chanson avec options.
"""
from PyQt6.QtWidgets import QWidget, QHBoxLayout, QLabel, QPushButton
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QFont
class PlaylistTrackItem(QWidget):
"""Une ligne représentant une chanson dans la playlist."""
delete_clicked = pyqtSignal(int)
def __init__(self, parent, track_number: int, artist: str, title: str, index: int, on_delete=None):
"""
Créer une ligne de chanson.
"""
super().__init__(parent)
self.track_number = track_number
self.artist = artist
self.title = title
self.index = index
self.on_delete = on_delete
# Style simple
self.setStyleSheet("""
QWidget {
background-color: #2a2a2a;
border: none;
border-bottom: 1px solid #3a3a3a;
}
QWidget:hover {
background-color: #323232;
}
""")
self.setFixedHeight(50)
# Layout
layout = QHBoxLayout()
layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(0)
# Texte contenant: "1. Artist — Title"
text_html = f"<b>{track_number}. {artist}</b> — <span style='color: #999999;'>{title}</span>"
text_label = QLabel(text_html)
text_label.setStyleSheet("color: #ffffff;")
text_font = QFont()
text_font.setPointSize(10)
text_label.setFont(text_font)
layout.addWidget(text_label, 1)
# Bouton delete
delete_btn = QPushButton("🗑")
delete_btn.setFixedSize(38, 38)
delete_btn.setStyleSheet("""
QPushButton {
background-color: #f44336;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
}
QPushButton:hover {
background-color: #d32f2f;
}
QPushButton:pressed {
background-color: #c62828;
}
""")
delete_btn.clicked.connect(self._on_delete_clicked)
layout.addWidget(delete_btn)
self.setLayout(layout)
def _on_delete_clicked(self):
"""Appeler le callback de suppression."""
if self.on_delete:
self.on_delete(self.index)
self.delete_clicked.emit(self.index)
+74
View File
@@ -0,0 +1,74 @@
"""
Composant TrackRow - Représente une ligne de piste en cours de téléchargement.
"""
import customtkinter as ctk
from src.ui.styles import COLORS, get_font
class TrackRow:
"""Une ligne: label + progress bar + stage badge dans le panneau actif."""
def __init__(self, parent, row: int):
self.label_var = ctk.StringVar(value="")
self.stage_var = ctk.StringVar(value="")
self.pct_var = ctk.StringVar(value="")
self.label = ctk.CTkLabel(
parent, textvariable=self.label_var, anchor="w", width=300
)
self.label.grid(row=row, column=0, padx=(10, 5), pady=3, sticky="w")
self.bar = ctk.CTkProgressBar(parent, width=280, height=14)
self.bar.set(0)
self.bar.grid(row=row, column=1, padx=5, pady=3)
self.pct_lbl = ctk.CTkLabel(parent, textvariable=self.pct_var, width=40)
self.pct_lbl.grid(row=row, column=2, padx=2, pady=3)
self.stage_lbl = ctk.CTkLabel(
parent,
textvariable=self.stage_var,
width=140,
anchor="w",
text_color=COLORS["text_dim"],
)
self.stage_lbl.grid(row=row, column=3, padx=(2, 10), pady=3, sticky="w")
self.clear()
def clear(self):
"""Réinitialiser la ligne."""
self.label_var.set(" ")
self.stage_var.set("")
self.pct_var.set("")
self.bar.set(0)
self.bar.configure(progress_color="#1f6aa5")
def set_track(self, label: str):
"""Définir un nouveau titre à télécharger."""
self.label_var.set(f"{label[:42]}")
self.stage_var.set("starting…")
self.pct_var.set("0%")
self.bar.set(0)
self.bar.configure(progress_color="#1f6aa5")
def update(self, pct: int, stage: str):
"""Mettre à jour la progression et l'étape."""
self.bar.set(pct / 100)
self.pct_var.set(f"{pct}%")
self.stage_var.set(stage)
def finish(self, result: str):
"""Marquer la piste comme terminée."""
if "done" in result or "cached" in result:
color = COLORS["success"] if "done" in result else "#888800"
else:
color = "#aa2222" # red for any failure/skip/stop
self.bar.configure(progress_color=color)
self.bar.set(1.0)
self.pct_var.set("100%")
self.stage_var.set(result)
cur = self.label_var.get()
prefix = "" if "done" in result or "cached" in result else ""
self.label_var.set(cur.replace("", prefix))
@@ -0,0 +1,169 @@
"""
Version optimisée avec virtualisation pour très longues listes (1000+).
À utiliser si nombre d'items > 1000.
"""
from PyQt6.QtWidgets import QListView, QAbstractItemView
from PyQt6.QtCore import QAbstractListModel, Qt, QModelIndex, pyqtSignal
class PlaylistModel(QAbstractListModel):
"""Modèle virtuel pour playlist - virtualisation complète."""
data_changed = pyqtSignal()
def __init__(self, tracks=None):
super().__init__()
self._tracks = tracks or []
def rowCount(self, parent=QModelIndex()):
"""Nombre d'items dans la liste."""
if parent.isValid():
return 0
return len(self._tracks)
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
"""Obtenir les données pour un item."""
if not index.isValid():
return None
if index.row() >= len(self._tracks):
return None
track = self._tracks[index.row()]
if role == Qt.ItemDataRole.DisplayRole:
artist = track.get("artist", "")
title = track.get("title", "")
return f"{index.row() + 1}. {artist} - {title}"
if role == Qt.ItemDataRole.UserRole: # Données brutes
return track
return None
def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
"""Modifier les données d'un item."""
if not index.isValid():
return False
if index.row() >= len(self._tracks):
return False
self._tracks[index.row()] = value
self.dataChanged.emit(index, index)
return True
def insertRows(self, row, count, parent=QModelIndex()):
"""Insérer des lignes."""
if row < 0 or row > len(self._tracks):
return False
self.beginInsertRows(parent, row, row + count - 1)
for i in range(count):
self._tracks.insert(row + i, {})
self.endInsertRows()
return True
def removeRows(self, row, count, parent=QModelIndex()):
"""Supprimer des lignes."""
if row < 0 or row + count > len(self._tracks):
return False
self.beginRemoveRows(parent, row, row + count - 1)
for _ in range(count):
self._tracks.pop(row)
self.endRemoveRows()
return True
def add_track(self, track):
"""Ajouter une chanson."""
self.insertRows(len(self._tracks), 1)
self.setData(self.index(len(self._tracks) - 1), track)
def remove_track(self, index):
"""Supprimer une chanson."""
self.removeRows(index, 1)
def get_all_tracks(self):
"""Obtenir toutes les chansons."""
return self._tracks.copy()
def set_all_tracks(self, tracks):
"""Définir toutes les chansons."""
self.beginResetModel()
self._tracks = tracks or []
self.endResetModel()
def clear(self):
"""Vider la liste."""
if self._tracks:
self.removeRows(0, len(self._tracks))
class VirtualizedPlaylistView(QListView):
"""Vue virtuelle optimisée pour les listes très longues."""
track_deleted = pyqtSignal(int)
def __init__(self, parent=None):
super().__init__(parent)
self.setStyleSheet("""
QListView {
background-color: #1a1a1a;
border: 1px solid #444444;
border-radius: 4px;
}
QListView::item {
padding: 8px;
margin: 2px 0px;
background-color: #2a2a2a;
border-radius: 4px;
border: 1px solid #444444;
}
QListView::item:selected {
background-color: #2196F3;
color: white;
}
QListView::item:hover {
background-color: #3a3a3a;
}
""")
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setUniformItemSizes(True) # Important pour performance!
self.setSpacing(4)
# Buffer items visibles seulement
self.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
def keyPressEvent(self, event):
"""Gérer les touches."""
if event.key() == Qt.Key.Key_Delete:
index = self.currentIndex()
if index.isValid():
self.model().removeRows(index.row(), 1)
self.track_deleted.emit(index.row())
else:
super().keyPressEvent(event)
# Comparaison performance
"""
Virtualisation: Avant vs Après pour 5000 items
AVANT (Batch rendering):
- Créer 5000 widgets = ~5-10s
- Affichage: ~20s freeze
- RAM: 300MB+
- Scroll: Saccadé
APRÈS (Virtualisation):
- Créer modèle vide = instant
- Affichage: 50ms (visible items seulement)
- RAM: 10MB
- Scroll: Fluide 60fps
SPEEDUP: 200x+ plus rapide!
"""
+55
View File
@@ -0,0 +1,55 @@
"""
Thème et styles pour l'interface graphique.
"""
import customtkinter as ctk
# ── Configuration du thème ──────────────────────────────────────────────────
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")
# ── Couleurs ────────────────────────────────────────────────────────────────
COLORS = {
"success": "#2a9d2a",
"success_hover": "#1d5a1d",
"danger": "#7a2a2a",
"danger_hover": "#5a1d1d",
"neutral": "#555",
"text_dim": "#aaaaaa",
}
# ── Stages poids ────────────────────────────────────────────────────────────
STAGE_WEIGHTS = {
"artist": 5,
"download": 60,
"mb": 20,
"meta": 5,
"norm": 10,
}
# ── Configuration des workers ───────────────────────────────────────────────
MAX_WORKERS = 3
MAX_PER_TICK = 25
# ── Fonts (créées de manière lazy pour éviter tkinter issues) ──────────────
_fonts = {}
def get_font(name):
"""Obtenir une font créée de manière lazy."""
global _fonts
if name not in _fonts:
if name == "FONT_TITLE":
_fonts[name] = ctk.CTkFont(size=20, weight="bold")
elif name == "FONT_SECTION":
_fonts[name] = ctk.CTkFont(size=14, weight="bold")
elif name == "FONT_LABEL":
_fonts[name] = ctk.CTkFont(size=12, weight="bold")
elif name == "FONT_MONO":
_fonts[name] = ctk.CTkFont(family="monospace", size=10)
return _fonts[name]
# Accès direct pour compatibilité
FONT_TITLE = property(lambda self: get_font("FONT_TITLE"))
FONT_SECTION = property(lambda self: get_font("FONT_SECTION"))
FONT_LABEL = property(lambda self: get_font("FONT_LABEL"))
FONT_MONO = property(lambda self: get_font("FONT_MONO"))
View File
+470
View File
@@ -0,0 +1,470 @@
"""
Onglet: Créer/Éditer une playlist.
"""
import json
import threading
from pathlib import Path
from tkinter import filedialog
import customtkinter as ctk
from src.ui.styles import COLORS, get_font
from src.ui.components.button import CustomButton
from src.ui.components.modal import ask_confirmation
from src.ui.components.playlist_track_item import PlaylistTrackItem
from src.ui.components.loading_modal import LoadingModal
class CreatePlaylistTab:
"""Tab pour créer et éditer des playlists."""
def __init__(self, parent, tabview):
self.tab = tabview.add(" Create Playlist")
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(3, weight=1)
# État interne
self._playlist_tracks = []
self._log_queue = None # Sera défini par l'app
self._pending_loading_modal = None # Modal to close after full render
self._render_job = None # after() job id for batch rendering
self._render_generation = 0 # increments to cancel stale renders
self._build_ui()
def _build_ui(self):
"""Construire l'interface de l'onglet."""
# Titre
ctk.CTkLabel(
self.tab,
text="Create or edit a playlist",
font=get_font("FONT_SECTION"),
).grid(row=0, column=0, columnspan=3, padx=10, pady=(10, 5), sticky="w")
# Ligne d'entrée
input_frame = ctk.CTkFrame(self.tab, fg_color="transparent")
input_frame.grid(row=1, column=0, columnspan=3, padx=10, pady=5, sticky="ew")
input_frame.grid_columnconfigure(2, weight=1)
ctk.CTkLabel(input_frame, text="Artist:").grid(
row=0, column=0, padx=5, sticky="w"
)
self._create_artist_var = ctk.StringVar()
ctk.CTkEntry(
input_frame,
textvariable=self._create_artist_var,
placeholder_text="e.g., Queen",
).grid(row=0, column=1, padx=5, sticky="ew")
ctk.CTkLabel(input_frame, text="Title:").grid(
row=0, column=2, padx=5, sticky="w"
)
self._create_title_var = ctk.StringVar()
ctk.CTkEntry(
input_frame,
textvariable=self._create_title_var,
placeholder_text="e.g., Bohemian Rhapsody",
).grid(row=0, column=3, padx=5, sticky="ew")
self._btn_add_song = CustomButton(
input_frame, text=" Add Song", command=self._add_to_playlist, size="medium"
)
self._btn_add_song.grid(row=0, column=4, padx=5, sticky="w")
# Liste des chansons
ctk.CTkLabel(
self.tab, text="Songs in playlist", font=get_font("FONT_LABEL")
).grid(row=2, column=0, columnspan=3, padx=10, pady=(10, 5), sticky="w")
list_frame = ctk.CTkFrame(self.tab)
list_frame.grid(row=3, column=0, columnspan=3, padx=10, pady=5, sticky="nsew")
list_frame.grid_columnconfigure(0, weight=1)
list_frame.grid_rowconfigure(0, weight=1)
# Scrollable frame pour les chansons
self._scrollable_frame = ctk.CTkScrollableFrame(
list_frame,
fg_color="transparent",
)
self._scrollable_frame.grid(row=0, column=0, sticky="nsew")
self._scrollable_frame.grid_columnconfigure(0, weight=1)
# Frame pour les items
self._tracks_frame = ctk.CTkFrame(
self._scrollable_frame,
fg_color="transparent",
)
self._tracks_frame.pack(fill="both", expand=True, padx=5, pady=5)
self._tracks_frame.grid_columnconfigure(0, weight=1)
# Boutons de contrôle
btn_frame = ctk.CTkFrame(self.tab, fg_color="transparent")
btn_frame.grid(
row=4, column=0, columnspan=3, padx=10, pady=(5, 10), sticky="ew"
)
self._btn_remove_last = CustomButton(
btn_frame,
text="🗑 Remove Last",
command=self._remove_last_from_playlist,
size="medium",
button_type="danger",
)
self._btn_remove_last.pack(side="left", padx=5)
self._btn_import = CustomButton(
btn_frame,
text="📥 Import JSON",
command=self._import_playlist,
size="medium",
)
self._btn_import.pack(side="left", padx=5)
self._btn_save = CustomButton(
btn_frame,
text="💾 Save Playlist",
command=self._save_playlist,
size="medium",
button_type="success",
)
self._btn_save.pack(side="left", padx=5)
self._btn_clear = CustomButton(
btn_frame,
text="🗑 Clear All",
command=self._clear_playlist,
size="medium",
button_type="neutral",
)
self._btn_clear.pack(side="left", padx=5)
def _set_controls_enabled(self, enabled: bool):
"""Activer/désactiver les contrôles de l'onglet."""
state = "normal" if enabled else "disabled"
for btn in [getattr(self, "_btn_add_song", None),
getattr(self, "_btn_remove_last", None),
getattr(self, "_btn_import", None),
getattr(self, "_btn_save", None),
getattr(self, "_btn_clear", None)]:
if btn is not None:
try:
btn.configure(state=state)
except Exception:
pass
def _add_to_playlist(self):
"""Ajouter une chanson à la playlist."""
artist = self._create_artist_var.get().strip()
title = self._create_title_var.get().strip()
if not artist or not title:
self._log_msg("⚠️ Please enter both artist and title")
return
self._playlist_tracks.append({"artist": artist, "title": title})
self._update_playlist_display()
self._create_artist_var.set("")
self._create_title_var.set("")
self._log_msg(f"✅ Added: {artist} - {title}")
def _remove_last_from_playlist(self):
"""Supprimer la dernière chanson."""
if self._playlist_tracks:
removed = self._playlist_tracks.pop()
self._update_playlist_display()
self._log_msg(f"🗑 Removed: {removed['artist']} - {removed['title']}")
else:
self._log_msg("⚠️ Playlist is empty")
def _clear_playlist(self):
"""Effacer toute la playlist."""
if not self._playlist_tracks:
self._log_msg("⚠️ Playlist is already empty")
return
if ask_confirmation(
self.tab,
title="Clear Playlist",
message=f"Delete all {len(self._playlist_tracks)} songs?",
modal_type="danger",
):
# Cancel any pending batch renders
if getattr(self, "_render_job", None):
try:
self.tab.after_cancel(self._render_job)
except Exception:
pass
self._render_job = None
self._render_generation += 1
# Disable controls during destruction
self._set_controls_enabled(False)
# Clear data immediately
self._playlist_tracks.clear()
# Gradually destroy UI items to keep UI responsive
children = list(self._tracks_frame.winfo_children())
total_widgets = len(children)
# Show blocking modal during destruction with determinate progress
try:
loading = LoadingModal(
self.tab.master.master,
title="Clearing playlist",
message=f"Removing {total_widgets} items...",
determinate=True
)
except Exception as e:
self._log_msg(f"⚠️ Could not show loading modal: {e}")
loading = None
batch = 100
delay_ms = 10
def destroy_batch(idx=0):
end = min(idx + batch, len(children))
for i in range(idx, end):
try:
children[i].destroy()
except Exception:
pass
# Mettre à jour la progression si modal en cours
if loading:
progress = end / total_widgets if total_widgets > 0 else 0
loading.set_progress(progress)
loading.update_message(f"Removing items: {end}/{total_widgets}")
# Force UI update
try:
loading.update()
except Exception:
pass
if end < len(children):
self._render_job = self.tab.after(delay_ms, lambda: destroy_batch(end))
else:
self._render_job = None
# Final UI update to empty state
self._update_playlist_display()
if loading:
try:
loading.close()
except Exception:
pass
self._log_msg("🗑 Playlist cleared")
self._set_controls_enabled(True)
destroy_batch(0)
def _update_playlist_display(self):
"""Mettre à jour l'affichage de la playlist de manière progressive."""
# Annuler tout rendu différé en cours et démarrer une nouvelle génération
if getattr(self, "_render_job", None):
try:
self.tab.after_cancel(self._render_job)
except Exception:
pass
self._render_job = None
self._render_generation += 1
gen = self._render_generation
# Nettoyer immédiatement pour feedback visuel
for widget in self._tracks_frame.winfo_children():
widget.destroy()
if not self._playlist_tracks:
# Afficher le message vide
empty_frame = ctk.CTkFrame(self._tracks_frame, fg_color="transparent")
empty_frame.pack(pady=40)
ctk.CTkLabel(
empty_frame,
text="🎵 No songs yet",
font=ctk.CTkFont(size=13, weight="bold"),
text_color="#555555",
).pack()
ctk.CTkLabel(
empty_frame,
text="Add one to get started!",
font=ctk.CTkFont(size=11),
text_color="#444444",
).pack()
# Close pending modal if any
if getattr(self, "_pending_loading_modal", None):
try:
self._pending_loading_modal.close()
except Exception:
pass
self._pending_loading_modal = None
return
# Copier les données pour éviter les race conditions
tracks_copy = [(i, dict(t)) for i, t in enumerate(self._playlist_tracks)]
# Rendre par batch pour ne pas bloquer l'UI
self._render_batch(tracks_copy, 0, batch_size=50, gen=gen)
def _render_batch(self, tracks_copy, start_idx, batch_size=10, gen=None):
"""Rendre un batch d'items."""
# Abort if a newer render generation has started
if gen is not None and gen != self._render_generation:
return
end_idx = min(start_idx + batch_size, len(tracks_copy))
# Créer les items du batch actuel
for idx in range(start_idx, end_idx):
track_idx, track = tracks_copy[idx]
artist = track.get("artist", "")
title = track.get("title", "")
item = PlaylistTrackItem(
self._tracks_frame,
track_number=track_idx + 1,
artist=artist,
title=title,
on_delete=self._delete_track_at_index,
)
item.pack(fill="x", pady=4)
# Mettre à jour la progression si modal en cours
if getattr(self, "_pending_loading_modal", None):
total = getattr(self, "_pending_loading_modal_total", len(tracks_copy))
progress = end_idx / total if total > 0 else 0
items_display = end_idx if total > 0 else 0
self._pending_loading_modal.set_progress(progress)
self._pending_loading_modal.update_message(
f"Rendering items: {items_display}/{total}"
)
# Rendre le prochain batch après un petit délai si nécessaire
if end_idx < len(tracks_copy):
self._render_job = self.tab.after(100, lambda: self._render_batch(tracks_copy, end_idx, batch_size, gen))
else:
# Tous les items sont rendus, ajouter le total
total_frame = ctk.CTkFrame(self._tracks_frame, fg_color="transparent")
total_frame.pack(fill="x", pady=(15, 0))
ctk.CTkLabel(
total_frame,
text=f"Total: {len(tracks_copy)} songs",
font=ctk.CTkFont(size=11, weight="bold"),
text_color="#888888",
).pack(anchor="w", padx=5)
self._render_job = None
# Close pending loading modal if present after full render
if getattr(self, "_pending_loading_modal", None):
try:
self._pending_loading_modal.close()
except Exception:
pass
self._pending_loading_modal = None
def _delete_track_at_index(self, index: int):
"""Supprimer une chanson avec confirmation."""
if index < 0 or index >= len(self._playlist_tracks):
return
track = self._playlist_tracks[index]
artist = track.get("artist", "Unknown")
title = track.get("title", "Unknown")
if ask_confirmation(
self.tab,
title="Remove Song",
message=f"Remove '{artist} - {title}' from playlist?",
modal_type="warning",
):
self._playlist_tracks.pop(index)
self._update_playlist_display()
self._log_msg(f"🗑 Removed: {artist} - {title}")
def _import_playlist(self):
"""Importer une playlist depuis un fichier JSON."""
filepath = filedialog.askopenfilename(
filetypes=[("JSON", "*.json"), ("All", "*")]
)
if not filepath:
return
# Show blocking loading modal during import with determinate progress bar
loading = LoadingModal(
self.tab.master.master,
title="Importing playlist",
message="Reading JSON...",
determinate=True
)
self.tab.update_idletasks()
def import_async():
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
self._playlist_tracks = data
# Keep modal until UI finished rendering
self._pending_loading_modal = loading
self._pending_loading_modal_total = len(self._playlist_tracks)
# Schedule GUI update in the main thread
self.tab.after(0, self._update_playlist_display)
self._log_msg(
f"📥 Imported {len(self._playlist_tracks)} songs from {Path(filepath).name}"
)
else:
self._log_msg("❌ Invalid JSON format - expected a list of songs")
self.tab.after(0, loading.close)
except Exception as e:
self._log_msg(f"❌ Error importing playlist: {e}")
self.tab.after(0, loading.close)
# Run import in background thread to avoid blocking GUI
threading.Thread(target=import_async, daemon=True).start()
def _save_playlist(self):
"""Sauvegarder la playlist en JSON."""
if not self._playlist_tracks:
self._log_msg("⚠️ Playlist is empty - nothing to save")
return
filepath = filedialog.asksaveasfilename(
defaultextension=".json", filetypes=[("JSON", "*.json"), ("All", "*")]
)
if not filepath:
return
try:
data = []
for track in self._playlist_tracks:
data.append(
{
"position": len(data) + 1,
"artist": track.get("artist", ""),
"title": track.get("title", ""),
"album": track.get("album", ""),
"year": track.get("year", ""),
"genre": track.get("genre", "Rock"),
}
)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
self._log_msg(
f"💾 Saved {len(self._playlist_tracks)} songs to {Path(filepath).name}"
)
except Exception as e:
self._log_msg(f"❌ Error saving playlist: {e}")
def _log_msg(self, text: str):
"""Logger un message - afficher temporairement dans une popup ou print."""
import sys
print(f"[Create Playlist] {text}", file=sys.stderr)
def get_tracks(self):
"""Obtenir les pistes de la playlist."""
return self._playlist_tracks
def set_tracks(self, tracks):
"""Définir les pistes de la playlist."""
self._playlist_tracks = tracks
self._update_playlist_display()
+539
View File
@@ -0,0 +1,539 @@
"""
Create Playlist Tab pour PyQt6 - Tableau avec modale fonctionnelle.
"""
import json
import threading
from pathlib import Path
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QTableWidget, QTableWidgetItem, QMessageBox, QFileDialog, QHeaderView, QDialog
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QFont
from src.ui.components.loading_modal_qt import LoadingModal
from src.ui.components.delete_modal_qt import DeleteModal
class CreatePlaylistTab(QWidget):
"""Tab pour créer et éditer des playlists."""
log_signal = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._playlist_tracks = []
self._render_job = None
self._render_generation = 0
self._pending_loading_modal = None
self._pending_loading_modal_total = 0
self._build_ui()
self.log_signal.connect(self._on_log)
def _build_ui(self):
"""Construire l'interface de l'onglet."""
layout = QVBoxLayout()
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
# Titre
title_label = QLabel("Create or edit a playlist")
title_font = QFont()
title_font.setPointSize(14)
title_font.setBold(True)
title_label.setFont(title_font)
layout.addWidget(title_label)
# Ligne d'entrée
input_layout = QHBoxLayout()
input_layout.setSpacing(10)
input_layout.setContentsMargins(0, 0, 0, 0)
artist_label = QLabel("🎤 Artist:")
artist_label.setStyleSheet("color: #ffffff; font-weight: bold; font-size: 11px;")
input_layout.addWidget(artist_label)
self._artist_input = QLineEdit()
self._artist_input.setPlaceholderText("e.g., Queen")
self._artist_input.setMinimumHeight(40)
self._artist_input.setStyleSheet("""
QLineEdit {
background-color: #2a2a2a;
color: #ffffff;
border: 2px solid #3f3f3f;
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
}
QLineEdit:focus {
border: 2px solid #2196F3;
}
""")
input_layout.addWidget(self._artist_input, 1)
title_label = QLabel("🎵 Title:")
title_label.setStyleSheet("color: #ffffff; font-weight: bold; font-size: 11px;")
input_layout.addWidget(title_label)
self._title_input = QLineEdit()
self._title_input.setPlaceholderText("e.g., Bohemian Rhapsody")
self._title_input.setMinimumHeight(40)
self._title_input.setStyleSheet("""
QLineEdit {
background-color: #2a2a2a;
color: #ffffff;
border: 2px solid #3f3f3f;
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
}
QLineEdit:focus {
border: 2px solid #2196F3;
}
""")
input_layout.addWidget(self._title_input, 1)
add_btn = QPushButton(" Add Song")
add_btn.setFixedWidth(140)
add_btn.setMinimumHeight(40)
add_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
padding: 8px 15px;
}
QPushButton:hover {
background-color: #388E3C;
}
QPushButton:pressed {
background-color: #2E7D32;
}
""")
add_btn.clicked.connect(self._add_to_playlist)
input_layout.addWidget(add_btn)
layout.addLayout(input_layout)
# Label "Songs in playlist"
songs_label = QLabel("Songs in playlist")
songs_font = QFont()
songs_font.setPointSize(11)
songs_font.setBold(True)
songs_label.setFont(songs_font)
layout.addWidget(songs_label)
# Tableau
self._table = QTableWidget()
self._table.setColumnCount(3)
self._table.setHorizontalHeaderLabels(["Artist", "Title", "Action"])
self._table.setStyleSheet("""
QTableWidget {
background-color: #1a1a1a;
border: 1px solid #444444;
gridline-color: #3a3a3a;
}
QTableWidget::item {
padding: 5px;
color: #ffffff;
}
QTableWidget::item:selected {
background-color: #2196F3;
}
QHeaderView::section {
background-color: #2a2a2a;
color: #ffffff;
padding: 8px;
border: none;
border-right: 1px solid #3a3a3a;
font-weight: bold;
}
QScrollBar:vertical {
background-color: #2a2a2a;
width: 12px;
}
QScrollBar::handle:vertical {
background-color: #4a4a4a;
border-radius: 6px;
}
QScrollBar::handle:vertical:hover {
background-color: #5a5a5a;
}
""")
header = self._table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
self._table.setRowCount(0)
layout.addWidget(self._table, 1)
# Boutons de contrôle
btn_layout = QHBoxLayout()
btn_layout.setSpacing(8)
btn_layout.setContentsMargins(0, 10, 0, 0)
self._btn_import = QPushButton("📥 Import JSON")
self._btn_import.setMinimumHeight(45)
self._btn_import.setStyleSheet(self._get_button_style("primary"))
self._btn_import.clicked.connect(self._import_playlist)
btn_layout.addWidget(self._btn_import, 1)
self._btn_clear = QPushButton("🗑 Clear All")
self._btn_clear.setMinimumHeight(45)
self._btn_clear.setStyleSheet(self._get_button_style("neutral"))
self._btn_clear.clicked.connect(self._clear_playlist)
btn_layout.addWidget(self._btn_clear, 1)
self._btn_save = QPushButton("💾 Save Playlist")
self._btn_save.setMinimumHeight(45)
self._btn_save.setStyleSheet(self._get_button_style("success"))
self._btn_save.clicked.connect(self._save_playlist)
btn_layout.addWidget(self._btn_save, 1)
layout.addLayout(btn_layout)
self.setLayout(layout)
def _get_button_style(self, button_type: str) -> str:
"""Obtenir le style CSS pour un bouton."""
colors = {
"primary": ("#2196F3", "#1976D2"),
"success": ("#4CAF50", "#388E3C"),
"danger": ("#f44336", "#d32f2f"),
"neutral": ("#757575", "#616161"),
}
normal, hover = colors.get(button_type, colors["primary"])
return f"""
QPushButton {{
background-color: {normal};
color: white;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
padding: 10px 15px;
}}
QPushButton:hover {{
background-color: {hover};
}}
QPushButton:pressed {{
background-color: {hover};
opacity: 0.9;
}}
QPushButton:disabled {{
background-color: #999999;
opacity: 0.6;
}}
"""
def _set_controls_enabled(self, enabled: bool):
"""Activer/désactiver les contrôles."""
for btn in [self._btn_import, self._btn_save, self._btn_clear]:
btn.setEnabled(enabled)
def _add_to_playlist(self):
"""Ajouter une chanson à la playlist."""
artist = self._artist_input.text().strip()
title = self._title_input.text().strip()
if not artist or not title:
self._log_msg("⚠️ Please enter both artist and title")
return
self._playlist_tracks.append({"artist": artist, "title": title})
self._update_table()
self._artist_input.clear()
self._title_input.clear()
self._log_msg(f"✅ Added: {artist} - {title}")
def _clear_playlist(self):
"""Effacer toute la playlist."""
if not self._playlist_tracks:
self._log_msg("⚠️ Playlist is already empty")
return
# Utiliser la DeleteModal au lieu de QMessageBox
delete_modal = DeleteModal(
self.window(),
title="Clear Playlist",
message=f"Delete all {len(self._playlist_tracks)} songs?"
)
if not delete_modal.exec():
return
if self._render_job:
self._render_job = None
self._render_generation += 1
self._set_controls_enabled(False)
# Afficher la modale AVANT de clear
loading = None
try:
loading = LoadingModal(
self.window(),
title="Clearing playlist",
message="Removing items...",
determinate=True
)
loading.show()
except Exception as e:
self._log_msg(f"⚠️ Could not show loading modal: {e}")
total_rows = self._table.rowCount()
def destroy_batch(idx=0):
batch_size = 50
end = min(idx + batch_size, total_rows)
for i in range(end - idx):
self._table.removeRow(0)
if loading and total_rows > 0:
progress = end / total_rows
percent = int(progress * 100)
loading.set_progress(progress)
loading.update_message(f"Removing items: {end}/{total_rows} ({percent}%)")
if end < total_rows:
QTimer.singleShot(10, lambda: destroy_batch(end))
else:
# Maintenant que la UI est clearée, vider les données
self._playlist_tracks.clear()
self._render_job = None
if loading:
loading.set_progress(1.0)
loading.update_message("Done!")
QTimer.singleShot(500, loading.close)
self._log_msg("🗑 Playlist cleared")
self._set_controls_enabled(True)
destroy_batch(0)
def _update_table(self):
"""Mettre à jour le tableau avec les chansons."""
self._table.setRowCount(0)
self._render_generation += 1
gen = self._render_generation
self._pending_loading_modal_total = len(self._playlist_tracks)
# Ne créer une modale que si pas déjà une active
if self._pending_loading_modal is None and len(self._playlist_tracks) > 50:
try:
loading = LoadingModal(
self.window(),
title="Loading playlist",
message="Rendering songs...",
determinate=True
)
loading.show()
self._pending_loading_modal = loading
except Exception:
self._pending_loading_modal = None
self._render_table_batch(0, gen)
def _render_table_batch(self, start_idx, gen, batch_size=50):
"""Rendre un batch de lignes dans la table."""
if gen != self._render_generation:
return
end_idx = min(start_idx + batch_size, len(self._playlist_tracks))
for idx in range(start_idx, end_idx):
track = self._playlist_tracks[idx]
row = self._table.rowCount()
self._table.insertRow(row)
self._table.setRowHeight(row, 45)
# Colonne Artist
artist_item = QTableWidgetItem(track.get("artist", ""))
artist_item.setFont(QFont("Arial", 10, QFont.Weight.Bold))
self._table.setItem(row, 0, artist_item)
# Colonne Title
title_item = QTableWidgetItem(track.get("title", ""))
self._table.setItem(row, 1, title_item)
# Colonne Action (bouton supprimer centré)
delete_btn = QPushButton("🗑")
delete_btn.setFixedSize(35, 35)
delete_btn.setStyleSheet("""
QPushButton {
background-color: #f44336;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}
QPushButton:hover {
background-color: #d32f2f;
}
QPushButton:pressed {
background-color: #c62828;
}
""")
delete_btn.clicked.connect(lambda checked, i=idx: self._delete_track_at_index(i))
# Centrer le bouton dans la cellule
btn_container = QWidget()
btn_layout = QHBoxLayout()
btn_layout.setContentsMargins(0, 0, 0, 0)
btn_layout.addStretch()
btn_layout.addWidget(delete_btn)
btn_layout.addStretch()
btn_container.setLayout(btn_layout)
self._table.setCellWidget(row, 2, btn_container)
if self._pending_loading_modal:
total = self._pending_loading_modal_total
progress = end_idx / total if total > 0 else 0
percent = int(progress * 100)
self._pending_loading_modal.set_progress(progress)
self._pending_loading_modal.update_message(f"Rendering: {end_idx}/{total} ({percent}%)")
if end_idx < len(self._playlist_tracks):
self._render_job = QTimer.singleShot(50, lambda: self._render_table_batch(end_idx, gen))
else:
self._render_job = None
if self._pending_loading_modal:
try:
self._pending_loading_modal.set_progress(1.0)
self._pending_loading_modal.update_message("Done!")
QTimer.singleShot(500, self._pending_loading_modal.close)
except Exception:
pass
self._pending_loading_modal = None
def _delete_track_at_index(self, index: int):
"""Supprimer une chanson."""
if index < 0 or index >= len(self._playlist_tracks):
return
track = self._playlist_tracks[index]
artist = track.get("artist", "Unknown")
title = track.get("title", "Unknown")
# Utiliser la DeleteModal
delete_modal = DeleteModal(
self.window(),
title="Remove Song",
message=f"Remove '{artist} - {title}' from playlist?"
)
if delete_modal.exec() != QDialog.Accepted:
return
self._playlist_tracks.pop(index)
self._update_table()
self._log_msg(f"🗑 Removed: {artist} - {title}")
def _import_playlist(self):
"""Importer une playlist depuis un fichier JSON."""
filepath, _ = QFileDialog.getOpenFileName(
self,
"Import Playlist",
"",
"JSON Files (*.json);;All Files (*)"
)
if not filepath:
return
loading = LoadingModal(
self.window(),
title="Importing playlist",
message="Reading JSON...",
determinate=True
)
loading.show()
def import_async():
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
self._playlist_tracks = data
self._pending_loading_modal = loading
self._pending_loading_modal_total = len(self._playlist_tracks)
self.log_signal.emit(
f"📥 Imported {len(self._playlist_tracks)} songs from {Path(filepath).name}"
)
loading.update_message(f"Rendering: 0/{len(self._playlist_tracks)} (0%)")
loading.setWindowTitle("Rendering playlist")
QTimer.singleShot(0, self._update_table)
else:
self.log_signal.emit("❌ Invalid JSON format - expected a list of songs")
loading.close()
except Exception as e:
self.log_signal.emit(f"❌ Error importing playlist: {e}")
loading.close()
thread = threading.Thread(target=import_async, daemon=True)
thread.start()
def _save_playlist(self):
"""Sauvegarder la playlist en JSON."""
if not self._playlist_tracks:
self._log_msg("⚠️ Playlist is empty - nothing to save")
return
filepath, _ = QFileDialog.getSaveFileName(
self,
"Save Playlist",
"",
"JSON Files (*.json);;All Files (*)"
)
if not filepath:
return
try:
data = []
for track in self._playlist_tracks:
data.append({
"position": len(data) + 1,
"artist": track.get("artist", ""),
"title": track.get("title", ""),
"album": track.get("album", ""),
"year": track.get("year", ""),
"genre": track.get("genre", "Rock"),
})
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
self._log_msg(f"💾 Saved {len(self._playlist_tracks)} songs to {Path(filepath).name}")
except Exception as e:
self._log_msg(f"❌ Error saving playlist: {e}")
def _log_msg(self, text: str):
"""Logger un message."""
self.log_signal.emit(text)
def _on_log(self, text: str):
"""Traiter un log (thread-safe)."""
import sys
print(f"[Create Playlist] {text}", file=sys.stderr)
def get_tracks(self):
"""Obtenir les pistes de la playlist."""
return self._playlist_tracks
def set_tracks(self, tracks):
"""Définir les pistes de la playlist."""
self._playlist_tracks = tracks
self._update_table()
+318
View File
@@ -0,0 +1,318 @@
"""
Onglet: Télécharger une playlist.
"""
import json
import queue
import subprocess
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from tkinter import filedialog
import customtkinter as ctk
from src.core.config import Config
from src.core.downloader import YouTubeDownloader
from src.core.playlist_parser import PlaylistParser
from src.utils.utils import (
clean_track_name,
normalize_artist_name,
sanitize_filename,
clean_title_for_filename,
remove_wrong_ed_suffix,
)
from src.ui.components.track_row import TrackRow
from src.ui.components.button import CustomButton
from src.ui.components.modal import ask_confirmation
from src.ui.components.loading_modal import LoadingModal
from src.ui.styles import COLORS, MAX_WORKERS, get_font
class DownloadTab:
"""Tab pour télécharger les playlists."""
def __init__(self, parent, tabview, queue_obj):
self.tab = tabview.add("⬇️ Download")
self.tab.grid_columnconfigure(0, weight=1)
self.tab.grid_rowconfigure(3, weight=1) # Row du log s'étire
self.tab.grid_rowconfigure(4, minsize=0) # Espace au-dessus des boutons
self._q = queue_obj
self._running = False
self._stop_event = threading.Event()
self._executor = None
self._stats = {"downloaded": 0, "exists": 0, "skipped": 0, "failed": 0}
self._total = 0
self._done = 0
self._log_count = 0
self._build_ui()
def _build_ui(self):
"""Construire l'interface de l'onglet."""
# Configuration
cfg = ctk.CTkFrame(self.tab, corner_radius=10)
cfg.grid(row=0, column=0, padx=15, pady=5, sticky="ew")
cfg.grid_columnconfigure(1, weight=1)
ctk.CTkLabel(cfg, text="Playlist JSON:").grid(
row=0, column=0, padx=10, pady=6, sticky="w"
)
self._pl_var = ctk.StringVar(value=str(Config.PLAYLIST_FILE))
ctk.CTkEntry(cfg, textvariable=self._pl_var).grid(
row=0, column=1, padx=5, pady=6, sticky="ew"
)
CustomButton(cfg, text="Browse", command=self._browse_playlist, size="small").grid(
row=0, column=2, padx=(0, 10), pady=6
)
ctk.CTkLabel(cfg, text="Output dir:").grid(
row=1, column=0, padx=10, pady=6, sticky="w"
)
self._out_var = ctk.StringVar(value=str(Config.OUTPUT_DIR))
ctk.CTkEntry(cfg, textvariable=self._out_var).grid(
row=1, column=1, padx=5, pady=6, sticky="ew"
)
CustomButton(cfg, text="Browse", command=self._browse_output, size="small").grid(
row=1, column=2, padx=(0, 10), pady=6
)
ctk.CTkLabel(cfg, text="Workers:").grid(
row=2, column=0, padx=10, pady=6, sticky="w"
)
self._workers_var = ctk.StringVar(value=str(MAX_WORKERS))
ctk.CTkOptionMenu(
cfg, values=["1", "2", "3", "4", "5"], variable=self._workers_var, width=60
).grid(row=2, column=1, padx=5, pady=6, sticky="w")
# Progression globale
ov = ctk.CTkFrame(self.tab, corner_radius=10)
ov.grid(row=1, column=0, padx=15, pady=5, sticky="ew")
ov.grid_columnconfigure(0, weight=1)
top_row = ctk.CTkFrame(ov, fg_color="transparent")
top_row.pack(fill="x", padx=10, pady=(8, 2))
ctk.CTkLabel(top_row, text="Overall", font=get_font("FONT_LABEL")).pack(side="left")
self._total_lbl = ctk.CTkLabel(top_row, text="0 / 0")
self._total_lbl.pack(side="right")
self._overall_bar = ctk.CTkProgressBar(ov, height=18)
self._overall_bar.set(0)
self._overall_bar.pack(fill="x", padx=10, pady=(2, 4))
stats_row = ctk.CTkFrame(ov, fg_color="transparent")
stats_row.pack(fill="x", padx=10, pady=(0, 8))
self._stat_vars = {}
self._cache_entries_var = ctk.StringVar(value="💾 Cache: 0")
ctk.CTkLabel(stats_row, textvariable=self._cache_entries_var, width=100).pack(
side="left", padx=6
)
for key, icon in [
("downloaded", "⬇️ Downloaded"),
("exists", "⏭️ Exists"),
("skipped", "⏭️ Skipped"),
("failed", "❌ Failed"),
]:
v = ctk.StringVar(value=f"{icon}: 0")
self._stat_vars[key] = v
ctk.CTkLabel(stats_row, textvariable=v, width=140).pack(
side="left", padx=6
)
# Téléchargements actifs
self._at_frame = ctk.CTkFrame(self.tab, corner_radius=10)
self._at_frame.grid(row=2, column=0, padx=15, pady=5, sticky="ew")
ctk.CTkLabel(
self._at_frame,
text="Active downloads",
font=get_font("FONT_LABEL"),
).grid(row=0, column=0, columnspan=4, padx=10, pady=(8, 4), sticky="w")
self._rows = []
for i in range(MAX_WORKERS):
self._rows.append(TrackRow(self._at_frame, row=i + 1))
# Log
log_frame = ctk.CTkFrame(self.tab, corner_radius=10)
log_frame.grid(row=3, column=0, padx=15, pady=5, sticky="nsew")
log_frame.grid_columnconfigure(0, weight=1)
log_frame.grid_rowconfigure(1, weight=1)
ctk.CTkLabel(log_frame, text="Log", font=get_font("FONT_LABEL")).grid(
row=0, column=0, padx=10, pady=(8, 2), sticky="w"
)
self._log = ctk.CTkTextbox(
log_frame, height=140, state="disabled", font=get_font("FONT_MONO")
)
self._log.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="nsew")
# Boutons
btn = ctk.CTkFrame(self.tab, fg_color="transparent")
btn.grid(row=5, column=0, padx=15, pady=(5, 15), sticky="w")
self._start_btn = CustomButton(
btn,
text="▶ Start",
command=self._start,
size="large",
button_type="success",
)
self._start_btn.pack(side="left", padx=8)
self._stop_btn = CustomButton(
btn,
text="⏹ Stop",
command=self._stop,
size="large",
button_type="danger",
)
self._stop_btn.pack(side="left", padx=8)
self._stop_btn.configure(state="disabled")
CustomButton(
btn,
text="🗑 Reset folder",
command=self._reset_folder,
size="large",
button_type="neutral",
).pack(side="left", padx=8)
def _browse_playlist(self):
p = filedialog.askopenfilename(
filetypes=[("JSON", "*.json"), ("All", "*")]
)
if p:
self._pl_var.set(p)
def _browse_output(self):
p = filedialog.askdirectory()
if p:
self._out_var.set(p)
def _reset_folder(self):
folder = Path(self._out_var.get())
count = len(list(folder.glob("*.mp3")))
if count == 0:
self._log_msg_download("🗑 Folder is already empty")
return
if not ask_confirmation(
self.tab,
title="Reset folder",
message=f"Delete {count} MP3 files from:\n{folder}?",
modal_type="danger",
):
return
removed = 0
for f in folder.glob("*.mp3"):
f.unlink()
removed += 1
for f in folder.glob("*.webm"):
f.unlink()
self._log_msg_download(f"🗑 Removed {removed} MP3 files")
def _start(self):
if self._running:
return
# Afficher la modal de loading
loading = LoadingModal(
self.tab.master.master, # Remonter à l'app root
title="Starting download",
message="Loading playlist and initializing...",
)
workers = int(self._workers_var.get())
if len(self._rows) != workers:
for r in self._rows:
r.label.grid_forget()
r.bar.grid_forget()
r.pct_lbl.grid_forget()
r.stage_lbl.grid_forget()
self._rows.clear()
for i in range(workers):
self._rows.append(TrackRow(self._at_frame, row=i + 1))
self._running = True
self._stop_event.clear()
self._start_btn.configure(state="disabled")
self._stop_btn.configure(state="normal")
self._stats = {"downloaded": 0, "exists": 0, "skipped": 0, "failed": 0}
self._done = 0
self._overall_bar.set(0)
for v in self._stat_vars.values():
v.set(v.get().split(":")[0] + ": 0")
for r in self._rows:
r.clear()
# Démarrer le téléchargement dans un thread et fermer la loading modal après
def run_with_loading():
try:
threading.Thread(target=self._run_download, daemon=True).start()
finally:
self.tab.after(500, loading.close)
threading.Thread(target=run_with_loading, daemon=True).start()
def _kill_child_processes(self):
"""Arrêter tous les sous-processus yt-dlp et ffmpeg."""
try:
subprocess.run(
["pkill", "-9", "-P", str(subprocess.os.getpid())],
capture_output=True,
)
except Exception:
pass
try:
subprocess.run(["pkill", "-9", "-f", "yt_dlp"], capture_output=True)
subprocess.run(["pkill", "-9", "-f", "ffmpeg"], capture_output=True)
except Exception:
pass
try:
folder = Path(self._out_var.get())
for f in list(folder.glob("*.webm")) + list(folder.glob("*.part")) + list(folder.glob("*_norm.mp3")):
f.unlink()
except Exception:
pass
def _stop(self):
self._stop_event.set()
self._running = False
self._kill_child_processes()
self._log_msg_download("⏹ Stopped child processes killed")
self._stop_btn.configure(state="disabled")
self._start_btn.configure(state="normal")
def _log_msg_download(self, text: str):
"""Logger un message dans l'onglet de téléchargement."""
self._log.configure(state="normal")
self._log_count += 1
if self._log_count % 100 == 0:
content = self._log.get("1.0", "end").splitlines()
if len(content) > 300:
self._log.delete("1.0", f"{len(content)-300}.0")
self._log.insert("end", text + "\n")
if self._log_count % 2 == 0:
self._log.see("end")
self._log.configure(state="disabled")
def is_running(self):
"""Vérifier si un téléchargement est en cours."""
return self._running
def get_stats(self):
"""Obtenir les statistiques."""
return self._stats
def get_total(self):
"""Obtenir le nombre total de pistes."""
return self._total
def get_done(self):
"""Obtenir le nombre de pistes terminées."""
return self._done
def _run_download(self):
"""Orchestrer le téléchargement."""
from src.ui.tabs.download_worker import DownloadWorker
worker = DownloadWorker(self, self._q)
worker.run()
+812
View File
@@ -0,0 +1,812 @@
"""
Download Tab pour PyQt6 - Gestion des téléchargements de playlists.
"""
import queue
import subprocess
import threading
from pathlib import Path
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QProgressBar, QTextEdit, QFileDialog, QMessageBox, QFrame, QSpinBox
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal
from PyQt6.QtGui import QFont
from src.core.config import Config
from src.ui.components.loading_modal_qt import LoadingModal
from src.ui.components.delete_modal_qt import DeleteModal
from src.ui.components.normalization_modal_qt import NormalizationModal
from src.utils.utils import clean_track_name, normalize_artist_name
class StringVar:
"""Wrapper compatible avec tkinter StringVar pour QLineEdit."""
def __init__(self, line_edit, initial_value=""):
self.line_edit = line_edit
if initial_value:
self.line_edit.setText(initial_value)
def get(self):
"""Retourner la valeur du QLineEdit."""
return self.line_edit.text()
def set(self, value):
"""Définir la valeur du QLineEdit."""
self.line_edit.setText(str(value))
class SpinBoxVar:
"""Wrapper compatible avec tkinter StringVar pour QSpinBox."""
def __init__(self, spinbox, initial_value=4):
self.spinbox = spinbox
self.spinbox.setValue(int(initial_value))
def get(self):
"""Retourner la valeur du QSpinBox comme string."""
return str(self.spinbox.value())
def set(self, value):
"""Définir la valeur du QSpinBox."""
self.spinbox.setValue(int(value))
class TrackRowQt:
"""Représentation d'une ligne de track en cours de téléchargement."""
def __init__(self, parent_layout: QVBoxLayout):
# Créer un widget conteneur pour cette row
self.widget = QWidget()
self.widget.setMinimumHeight(28)
self.widget.setMaximumHeight(28)
self.layout = QHBoxLayout()
self.layout.setSpacing(10)
self.layout.setContentsMargins(5, 0, 5, 0)
# Label pour le nom de la chanson (visible à gauche)
self.label = QLabel("")
self.label.setStyleSheet("color: #cccccc; font-size: 13px; font-weight: bold;")
self.label.setMinimumWidth(280)
self.label.setMaximumWidth(320)
self.label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self.layout.addWidget(self.label)
# Barre de progression (prend le reste de l'espace)
self.bar = QProgressBar()
self.bar.setMaximumHeight(18)
self.bar.setMinimumHeight(18)
self.bar.setStyleSheet("""
QProgressBar {
background-color: #2a2a2a;
border: 1px solid #444444;
border-radius: 6px;
text-align: center;
}
QProgressBar::chunk {
background-color: #2196F3;
border-radius: 5px;
}
""")
self.bar.setValue(0)
self.bar.setFormat("%p%") # Display percentage inside the bar
self.layout.addWidget(self.bar, 1)
# Étape/Résultat (statut final)
self.stage_lbl = QLabel("")
self.stage_lbl.setStyleSheet("color: #888888; font-size: 12px; padding-left: 10px;")
self.stage_lbl.setMinimumWidth(120)
self.stage_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.layout.addWidget(self.stage_lbl)
self.widget.setLayout(self.layout)
parent_layout.addWidget(self.widget)
def clear(self):
"""Réinitialiser la ligne."""
self.label.setText("")
self.bar.setValue(0)
self.stage_lbl.setText("")
def update_track(self, label: str):
"""Mettre à jour le label du track au démarrage."""
self.label.setText(label)
self.bar.setValue(0)
self.stage_lbl.setText("")
def update_progress(self, pct: int, stage: str):
"""Mettre à jour la progression - le label reste affiché."""
self.bar.setValue(pct)
self.stage_lbl.setText(stage)
def set_done(self, result: str):
"""Marquer comme terminé - le label reste affiché."""
self.bar.setValue(100)
self.stage_lbl.setText(result)
class DownloadTab(QWidget):
"""Tab pour télécharger les playlists."""
log_signal = pyqtSignal(str)
progress_signal = pyqtSignal(str, int, int, int, int, int, int) # type, dl, exists, skipped, failed, done, total
track_signal = pyqtSignal(int, str, int, str) # slot, label, pct, stage
cache_signal = pyqtSignal(int) # cache_entries
def __init__(self, parent=None, log_emitter=None):
super().__init__(parent)
self._q = queue.Queue()
self._running = False
self._stop_event = threading.Event()
self._stats = {"downloaded": 0, "exists": 0, "skipped": 0, "failed": 0}
self._total = 0
self._done = 0
self._log_count = 0
self._rows = []
self._max_workers = 4
self._log_emitter = log_emitter
self._slot_labels = {} # Cache pour tracker les labels par slot
# Normalization modal
self.normalization_modal = NormalizationModal(self, Config)
# Signaux
self.log_signal.connect(self._on_log)
self.progress_signal.connect(self._on_progress)
self.track_signal.connect(self._on_track_update)
self.cache_signal.connect(self._on_cache_update)
self.normalization_modal.settings_applied.connect(self._on_normalization_settings_applied)
self._build_ui()
self._poll_queue()
def _build_ui(self):
"""Construire l'interface de l'onglet."""
layout = QVBoxLayout()
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(8)
# Configuration
cfg_frame = QFrame()
cfg_frame.setStyleSheet("""
QFrame {
background-color: #1a1a1a;
border-radius: 6px;
}
""")
cfg_layout = QVBoxLayout()
cfg_layout.setContentsMargins(15, 10, 15, 10)
cfg_layout.setSpacing(8)
# Playlist
pl_row = QHBoxLayout()
pl_row.setSpacing(10)
pl_label = QLabel("Playlist JSON:")
pl_label.setStyleSheet("color: #ffffff; font-weight: bold; font-size: 11px;")
pl_row.addWidget(pl_label)
self._pl_input = QLineEdit()
self._pl_input.setText(str(Config.PLAYLIST_FILE))
self._pl_input.setStyleSheet("""
QLineEdit {
background-color: #2a2a2a;
color: #ffffff;
border: 1px solid #3f3f3f;
border-radius: 4px;
padding: 6px;
font-size: 11px;
}
""")
self._pl_var = StringVar(self._pl_input)
pl_row.addWidget(self._pl_input, 1)
browse_pl_btn = QPushButton("Browse")
browse_pl_btn.setMaximumWidth(80)
browse_pl_btn.setStyleSheet("""
QPushButton {
background-color: #2196F3;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
font-weight: bold;
font-size: 11px;
}
QPushButton:hover {
background-color: #1976D2;
}
QPushButton:pressed {
background-color: #1565C0;
}
""")
browse_pl_btn.clicked.connect(self._browse_playlist)
pl_row.addWidget(browse_pl_btn)
cfg_layout.addLayout(pl_row)
# Output dir
out_row = QHBoxLayout()
out_row.setSpacing(10)
out_label = QLabel("Output dir:")
out_label.setStyleSheet("color: #ffffff; font-weight: bold; font-size: 11px;")
out_row.addWidget(out_label)
self._out_input = QLineEdit()
self._out_input.setText(str(Config.OUTPUT_DIR))
self._out_input.setStyleSheet("""
QLineEdit {
background-color: #2a2a2a;
color: #ffffff;
border: 1px solid #3f3f3f;
border-radius: 4px;
padding: 6px;
font-size: 11px;
}
""")
self._out_var = StringVar(self._out_input)
out_row.addWidget(self._out_input, 1)
browse_out_btn = QPushButton("Browse")
browse_out_btn.setMaximumWidth(80)
browse_out_btn.setStyleSheet("""
QPushButton {
background-color: #2196F3;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
font-weight: bold;
font-size: 11px;
}
QPushButton:hover {
background-color: #1976D2;
}
QPushButton:pressed {
background-color: #1565C0;
}
""")
browse_out_btn.clicked.connect(self._browse_output)
out_row.addWidget(browse_out_btn)
cfg_layout.addLayout(out_row)
# Workers
workers_row = QHBoxLayout()
workers_row.setSpacing(10)
workers_label = QLabel("Workers:")
workers_label.setStyleSheet("color: #ffffff; font-weight: bold; font-size: 11px;")
workers_row.addWidget(workers_label)
self._workers_input = QSpinBox()
self._workers_input.setMinimum(1)
self._workers_input.setMaximum(8)
self._workers_input.setValue(4)
self._workers_input.setFixedWidth(60)
self._workers_input.setStyleSheet("""
QSpinBox {
background-color: #2a2a2a;
color: #ffffff;
border: 1px solid #3f3f3f;
border-radius: 4px;
padding: 3px 2px;
font-size: 12px;
font-weight: bold;
}
QSpinBox:focus {
border: 1px solid #2196F3;
background-color: #333333;
}
QSpinBox::up-button {
subcontrol-origin: padding;
subcontrol-position: right top;
width: 16px;
padding-right: 2px;
}
QSpinBox::up-button:hover {
background-color: #2196F3;
}
QSpinBox::down-button {
subcontrol-origin: padding;
subcontrol-position: right bottom;
width: 16px;
padding-right: 2px;
}
QSpinBox::down-button:hover {
background-color: #2196F3;
}
""")
self._workers_var = SpinBoxVar(self._workers_input)
workers_row.addWidget(self._workers_input)
workers_row.addStretch()
cfg_layout.addLayout(workers_row)
cfg_frame.setLayout(cfg_layout)
layout.addWidget(cfg_frame)
# Progression globale
progress_frame = QFrame()
progress_frame.setStyleSheet("""
QFrame {
background-color: #1a1a1a;
border-radius: 6px;
}
""")
progress_layout = QVBoxLayout()
progress_layout.setContentsMargins(15, 10, 15, 10)
progress_layout.setSpacing(8)
top_row = QHBoxLayout()
overall_label = QLabel("Overall Progress")
overall_label.setStyleSheet("color: #2196F3; font-weight: bold; font-size: 12px;")
top_row.addWidget(overall_label)
self._total_lbl = QLabel("0 / 0")
self._total_lbl.setStyleSheet("color: #cccccc; font-size: 11px;")
self._total_lbl.setAlignment(Qt.AlignmentFlag.AlignRight)
top_row.addWidget(self._total_lbl)
progress_layout.addLayout(top_row)
self._overall_bar = QProgressBar()
self._overall_bar.setMaximumHeight(20)
self._overall_bar.setStyleSheet("""
QProgressBar {
background-color: #2a2a2a;
border: 1px solid #444444;
border-radius: 6px;
}
QProgressBar::chunk {
background-color: #4CAF50;
border-radius: 5px;
}
""")
self._overall_bar.setValue(0)
progress_layout.addWidget(self._overall_bar)
stats_row = QHBoxLayout()
stats_row.setSpacing(10)
self._cache_lbl = QLabel("💾 Cache: 0")
self._cache_lbl.setStyleSheet("color: #cccccc; font-size: 10px;")
stats_row.addWidget(self._cache_lbl)
self._stat_labels = {}
for key, icon in [("downloaded", "⬇️ Downloaded"), ("exists", "⏭️ Exists"),
("skipped", "⏭️ Skipped"), ("failed", "❌ Failed")]:
lbl = QLabel(f"{icon}: 0")
lbl.setStyleSheet("color: #cccccc; font-size: 10px;")
self._stat_labels[key] = lbl
stats_row.addWidget(lbl)
stats_row.addStretch()
progress_layout.addLayout(stats_row)
progress_frame.setLayout(progress_layout)
layout.addWidget(progress_frame)
# Téléchargements actifs
active_frame = QFrame()
active_frame.setStyleSheet("""
QFrame {
background-color: #1a1a1a;
border-radius: 6px;
}
""")
active_layout = QVBoxLayout()
active_layout.setContentsMargins(15, 10, 15, 10)
active_layout.setSpacing(10)
active_label = QLabel("Active Downloads")
active_label.setStyleSheet("color: #2196F3; font-weight: bold; font-size: 12px;")
active_layout.addWidget(active_label)
# Créer un widget conteneur pour les rows
rows_container = QWidget()
rows_container.setStyleSheet("background-color: transparent;")
self._rows_layout = QVBoxLayout()
self._rows_layout.setSpacing(4)
self._rows_layout.setContentsMargins(0, 0, 0, 0)
rows_container.setLayout(self._rows_layout)
active_layout.addWidget(rows_container)
active_frame.setLayout(active_layout)
layout.addWidget(active_frame)
# Boutons de contrôle
btn_layout = QHBoxLayout()
btn_layout.setSpacing(10)
self._start_btn = QPushButton("▶ Start")
self._start_btn.setMinimumHeight(40)
self._start_btn.setMinimumWidth(120)
self._start_btn.setStyleSheet("""
QPushButton {
background-color: #4CAF50;
color: white;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}
QPushButton:hover {
background-color: #45a049;
}
QPushButton:pressed {
background-color: #3d8b40;
}
QPushButton:disabled {
background-color: #888888;
}
""")
self._start_btn.clicked.connect(self._start)
btn_layout.addWidget(self._start_btn)
self._stop_btn = QPushButton("⏹ Stop")
self._stop_btn.setMinimumHeight(40)
self._stop_btn.setMinimumWidth(120)
self._stop_btn.setStyleSheet("""
QPushButton {
background-color: #f44336;
color: white;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}
QPushButton:hover {
background-color: #d32f2f;
}
QPushButton:pressed {
background-color: #c62828;
}
QPushButton:disabled {
background-color: #888888;
}
""")
self._stop_btn.clicked.connect(self._stop)
self._stop_btn.setEnabled(False)
btn_layout.addWidget(self._stop_btn)
reset_btn = QPushButton("🗑 Reset Folder")
reset_btn.setMinimumHeight(40)
reset_btn.setMinimumWidth(140)
reset_btn.setStyleSheet("""
QPushButton {
background-color: #757575;
color: white;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}
QPushButton:hover {
background-color: #616161;
}
QPushButton:pressed {
background-color: #4a4a4a;
}
""")
reset_btn.clicked.connect(self._reset_folder)
btn_layout.addWidget(reset_btn)
# Normalization Settings button
norm_btn = QPushButton("🔊 Normalization Settings")
norm_btn.setMinimumHeight(40)
norm_btn.setMinimumWidth(180)
norm_btn.setStyleSheet("""
QPushButton {
background-color: #FF9800;
color: white;
border: none;
border-radius: 6px;
font-weight: bold;
font-size: 12px;
}
QPushButton:hover {
background-color: #F57C00;
}
QPushButton:pressed {
background-color: #E65100;
}
""")
norm_btn.clicked.connect(self._open_normalization_modal)
btn_layout.addWidget(norm_btn)
btn_layout.addStretch()
layout.addLayout(btn_layout)
self.setLayout(layout)
def _create_track_rows(self, num_rows: int):
"""Créer ou adapter le nombre de lignes de track selon les workers."""
# Supprimer les rows existantes
for row in self._rows:
row.widget.deleteLater()
self._rows.clear()
# Créer le nombre exact de rows requis
for i in range(num_rows):
self._rows.append(TrackRowQt(self._rows_layout))
# Calculer la hauteur totale nécessaire
# Chaque row fait 28px + 4px de spacing
row_height = 28
spacing = 4
total_height = (row_height * num_rows) + (spacing * (num_rows - 1))
# Ajouter un peu de padding
total_height += 10
def _browse_playlist(self):
"""Parcourir et sélectionner le fichier playlist."""
path, _ = QFileDialog.getOpenFileName(
self, "Select Playlist JSON", "",
"JSON Files (*.json);;All Files (*)"
)
if path:
self._pl_input.setText(path)
def _browse_output(self):
"""Parcourir et sélectionner le répertoire de sortie."""
path = QFileDialog.getExistingDirectory(self, "Select Output Directory")
if path:
self._out_input.setText(path)
def _reset_folder(self):
"""Réinitialiser le dossier en supprimant les MP3."""
folder = Path(self._out_var.get())
count = len(list(folder.glob("*.mp3")))
if count == 0:
self._log_msg_download("🗑 Folder is already empty")
return
dlg = DeleteModal(
self,
title="Reset Folder",
message=f"Delete {count} MP3 files from:\n{folder}?"
)
dlg.exec()
if dlg.result_value:
removed = 0
for f in folder.glob("*.mp3"):
f.unlink()
removed += 1
for f in folder.glob("*.webm"):
f.unlink()
self._log_msg_download(f"🗑 Removed {removed} MP3 files")
def _start(self):
"""Démarrer le téléchargement."""
if self._running:
return
loading = LoadingModal(
self,
title="Starting Download",
message="Loading playlist and initializing..."
)
try:
workers = int(self._workers_var.get())
if workers < 1 or workers > 8:
workers = 4
except ValueError:
workers = 4
self._max_workers = workers
# Créer/adapter les lignes de track selon le nombre de workers
self._create_track_rows(workers)
self._running = True
self._stop_event.clear()
self._start_btn.setEnabled(False)
self._stop_btn.setEnabled(True)
self._stats = {"downloaded": 0, "exists": 0, "skipped": 0, "failed": 0}
self._slot_labels = {} # Réinitialiser le cache de labels
self._done = 0
self._overall_bar.setValue(0)
self._total_lbl.setText("0 / 0")
for lbl in self._stat_labels.values():
lbl.setText(lbl.text().split(":")[0] + ": 0")
for row in self._rows:
row.clear()
def run_with_loading():
try:
threading.Thread(target=self._run_download, daemon=True).start()
finally:
QTimer.singleShot(500, loading.close)
threading.Thread(target=run_with_loading, daemon=True).start()
def _stop(self):
"""Arrêter le téléchargement."""
self._stop_event.set()
self._running = False
self._kill_child_processes()
self._log_msg_download("⏹ Stopped child processes killed")
self._stop_btn.setEnabled(False)
self._start_btn.setEnabled(True)
def _kill_child_processes(self):
"""Arrêter tous les sous-processus yt-dlp et ffmpeg (mais pas l'application)."""
import platform
import subprocess
system = platform.system()
if system == 'Windows':
# Windows: tuer seulement ffmpeg et yt-dlp, pas python.exe
try:
subprocess.run(["taskkill", "/F", "/IM", "ffmpeg.exe"], capture_output=True)
except Exception:
pass
try:
# Tuer les processus yt-dlp spécifiquement (pas tous les python)
subprocess.run(["taskkill", "/F", "/FI", "WINDOWTITLE eq yt-dlp*"], capture_output=True)
except Exception:
pass
else:
# Linux/macOS: tuer seulement ffmpeg et yt-dlp
try:
subprocess.run(["pkill", "-9", "-f", "ffmpeg"], capture_output=True)
except Exception:
pass
try:
subprocess.run(["pkill", "-9", "-f", "yt_dlp"], capture_output=True)
except Exception:
pass
# Nettoyer les fichiers temporaires
try:
folder = Path(self._out_var.get())
for f in list(folder.glob("*.webm")) + list(folder.glob("*.part")) + list(folder.glob("*_norm.mp3")):
try:
f.unlink()
except Exception:
pass
except Exception:
pass
def _run_download(self):
"""Orchestrer le téléchargement."""
from src.ui.tabs.download_worker import DownloadWorker
worker = DownloadWorker(self, self._q, max_workers=self._max_workers)
worker._stop_event = self._stop_event # Partager le stop event du tab
worker.run()
def _poll_queue(self):
"""Récupérer les messages de la file d'attente."""
try:
while True:
msg = self._q.get_nowait()
if msg[0] == "log":
self.log_signal.emit(msg[1])
elif msg[0] == "download_progress":
# Track download progress (bytes downloaded, total bytes)
if not hasattr(self, '_total_download_bytes'):
self._total_download_bytes = 0
self._downloaded_bytes = 0
self._downloaded_bytes = msg[1]
self._total_download_bytes = max(self._total_download_bytes, msg[2])
# Update progress bar immediately
self._update_overall_progress()
elif msg[0] == "total_update":
self.progress_signal.emit("update", msg[1], msg[2], msg[3], msg[4], msg[5], msg[6])
elif msg[0] == "track_start":
slot = msg[1]
label = msg[2]
# Tracker le label pour ce slot
self._slot_labels[slot] = label
self.track_signal.emit(slot, label, 0, "")
elif msg[0] == "track_pct":
slot = msg[1]
pct = msg[2]
# Ignorer track_pct à 100% car track_done arrive juste après
if pct == 100:
continue
# Réutiliser le label stocké pour ce slot
label = self._slot_labels.get(slot, "")
self.track_signal.emit(slot, label, pct, msg[3])
elif msg[0] == "track_done":
slot = msg[1]
# Réutiliser le label stocké pour ce slot
label = self._slot_labels.get(slot, "")
self.track_signal.emit(slot, label, 100, msg[2])
elif msg[0] == "cache_update":
self.cache_signal.emit(msg[1])
elif msg[0] == "finished":
self._running = False
self._start_btn.setEnabled(True)
self._stop_btn.setEnabled(False)
except queue.Empty:
pass
QTimer.singleShot(100, self._poll_queue)
def _on_log(self, text: str):
"""Traiter un message de log."""
self._log_msg_download(text)
def _update_overall_progress(self):
"""Mettre à jour la barre de progression globale en temps réel."""
if self._total > 0:
# Simple progress: done / total
pct = int((self._done / self._total) * 100)
self._overall_bar.setValue(min(pct, 100))
def _on_progress(self, cmd: str, dl: int, exists: int, skipped: int, failed: int, done: int, total: int):
"""Mettre à jour la progression globale."""
if cmd == "update":
self._stats["downloaded"] = dl
self._stats["exists"] = exists
self._stats["skipped"] = skipped
self._stats["failed"] = failed
self._done = done
self._total = total
self._stat_labels["downloaded"].setText(f"⬇️ Downloaded: {dl}")
self._stat_labels["exists"].setText(f"⏭️ Exists: {exists}")
self._stat_labels["skipped"].setText(f"⏭️ Skipped: {skipped}")
self._stat_labels["failed"].setText(f"❌ Failed: {failed}")
self._total_lbl.setText(f"{done} / {total}")
# Update progress bar
self._update_overall_progress()
def _on_track_update(self, slot: int, label: str, pct: int, stage: str):
"""Mettre à jour un track en cours."""
if slot < len(self._rows):
row = self._rows[slot]
# Mettre à jour la progression
if pct == 0:
# Nouveau track au démarrage - toujours mettre à jour le label
row.label.setText(label)
row.bar.setValue(0)
row.stage_lbl.setText("")
elif pct == 100:
# Track terminé - ne pas afficher "100%" (la barre suffit)
row.bar.setValue(100)
row.stage_lbl.setText(stage)
else:
# Progression en cours
row.bar.setValue(pct)
row.stage_lbl.setText(stage)
def _on_cache_update(self, entries: int):
"""Mettre à jour le cache."""
self._cache_lbl.setText(f"💾 Cache: {entries}")
def _log_msg_download(self, text: str):
"""Logger un message dans la zone globale."""
if self._log_emitter:
self._log_emitter.message.emit(text)
else:
# Fallback si pas de log_emitter
print(f"[Download] {text}")
def is_running(self):
"""Vérifier si un téléchargement est en cours."""
return self._running
def get_stats(self):
"""Obtenir les statistiques."""
return self._stats
def get_total(self):
"""Obtenir le nombre total de pistes."""
return self._total
def get_done(self):
"""Obtenir le nombre de pistes terminées."""
return self._done
def _open_normalization_modal(self):
"""Open the normalization settings modal."""
self.normalization_modal.exec()
def _on_normalization_settings_applied(self, settings: dict):
"""Handle settings applied from modal."""
# Settings are automatically saved by the modal
# This is called when the user clicks Apply
pass
+544
View File
@@ -0,0 +1,544 @@
"""
Worker pour gérer les téléchargements dans des threads séparés.
"""
import queue
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from src.core.config import Config
from src.core.downloader import YouTubeDownloader
from src.core.playlist_parser import PlaylistParser
from src.utils.utils import (
clean_track_name,
normalize_artist_name,
sanitize_filename,
clean_title_for_filename,
remove_wrong_ed_suffix,
)
class DownloadWorker:
"""Gère l'orchestration et l'exécution des téléchargements."""
def __init__(self, download_tab, queue_obj, max_workers=4):
self.tab = download_tab
self._q = queue_obj
self._stop_event = threading.Event()
self.max_workers = max_workers
self._file_cache_lock = threading.Lock() # Synchronize file cache access
def run(self):
"""Lancer le processus de téléchargement."""
downloader = None
parser = None
try:
try:
folder = Path(self.tab._out_var.get())
for f in folder.glob("*_norm.mp3"):
f.unlink()
except Exception:
pass
parser = PlaylistParser(self.tab._pl_var.get())
parser.load()
tracks = parser.deduplicate()
try:
parser.save()
except Exception as se:
self._q.put(
("log", f"⚠️ Could not auto-save corrected titles: {se}")
)
self.tab._total = len(tracks)
self._q.put(("log", f"📂 {self.tab._total} tracks loaded"))
self._q.put(
(
"total_update",
0,
0,
0,
0,
0,
self.tab._total,
)
)
output_dir = Path(self.tab._out_var.get())
downloader = YouTubeDownloader(Config)
downloader.output_dir = output_dir
self._q.put(("log", "✓ Downloader ready"))
artist_cache = set()
file_cache = {}
file_cache_normalized = {}
try:
if output_dir.exists():
for f in output_dir.glob("*.mp3"):
file_cache[f.name] = True
parts = f.stem.split(" - ", 1)
if len(parts) == 2:
artist_part = clean_track_name(parts[0]).lower()
title_part = clean_track_name(parts[1]).lower()
norm_key = f"{artist_part}|{title_part}"
file_cache_normalized[norm_key] = f.name
extracted_artist = clean_track_name(parts[0])
artist_cache.add(extracted_artist.lower())
self._preload_caches_from_files(downloader, output_dir)
self._q.put(("log", f"📂 {len(file_cache)} files found on disk"))
except Exception as e:
self._q.put(("log", f"⚠️ Could not scan folder: {e}"))
workers = self.max_workers
slot_pool = queue.Queue()
for i in range(workers):
slot_pool.put(i)
with ThreadPoolExecutor(max_workers=workers) as ex:
futures = {
ex.submit(
self._worker,
downloader,
track,
file_cache,
file_cache_normalized,
artist_cache,
slot_pool,
self.tab._stats,
parser,
self._file_cache_lock,
): track
for track in tracks
}
for f in as_completed(futures):
if self._stop_event.is_set():
for pending in futures:
pending.cancel()
break
f.result()
except Exception as e:
self._q.put(("log", f"❌ Error: {e}"))
finally:
if downloader:
cache_stats = downloader.get_cache_stats()
self._q.put(("log", ""))
self._q.put(
("log", "━━━━━━━━━━━━━━━━━━ CACHE STATISTICS ━━━━━━━━━━━━━━━━━━")
)
self._q.put(
(
"log",
f"📂 Album-Year cache: {cache_stats['total_album_entries']} entries, {cache_stats['album_year_hits']} hits",
)
)
self._q.put(
(
"log",
f"📂 Artist-Genre cache: {cache_stats['total_artist_entries']} entries, {cache_stats['artist_genre_hits']} hits",
)
)
self._q.put(
(
"log",
f"📂 Artist-Album cache: {cache_stats['total_artist_album_entries']} entries, {cache_stats['artist_album_hits']} hits",
)
)
total_hits = (
cache_stats["artist_album_hits"]
+ cache_stats["artist_genre_hits"]
+ cache_stats["album_year_hits"]
)
total_entries = (
cache_stats["total_album_entries"]
+ cache_stats["total_artist_entries"]
+ cache_stats["total_artist_album_entries"]
)
self._q.put(("log", f"💾 TOTAL: {total_entries} entries, {total_hits} hits"))
self._q.put(
("log", "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
)
self._q.put(("log", "✅ Done!"))
self._q.put(("finished",))
def _worker(
self,
downloader: YouTubeDownloader,
track: dict,
file_cache: dict,
file_cache_normalized: dict,
artist_cache: set,
slot_pool: queue.Queue,
stats: dict,
parser: PlaylistParser,
file_cache_lock: threading.Lock,
):
"""Traiter un seul track."""
artist = sanitize_filename(track.get("artist", ""))
title = remove_wrong_ed_suffix(
clean_title_for_filename(
sanitize_filename(track.get("title", "Unknown"))
)
)
album = sanitize_filename(track.get("album", ""))
year = track.get("year", "")
genre = track.get("genre", "Rock")
position = track.get("position", 0)
# Create label from JSON data immediately
display_title = clean_track_name(title)
display_artist = (
clean_track_name(normalize_artist_name(artist)) if artist else ""
)
label = f"{display_artist} - {display_title}" if display_artist else display_title
slot = slot_pool.get()
self._q.put(("track_start", slot, label))
_last_pct = [-1]
def pct(p, s, force=False):
p = min(int(p), 99)
if force or p >= _last_pct[0] + 2 or p == 99:
_last_pct[0] = p
self._q.put(("track_pct", slot, p, s))
result = "failed"
result_key = "failed"
try:
if self._stop_event.is_set():
result = "⏹ stopped"
result_key = "skipped"
return
if not artist or not artist.strip():
pct(2, "resolving artist…")
self._q.put(("log", f" 🔍 [{slot}] Resolving artist for: {title}"))
artist = downloader._resolve_artist_from_title(title, album)
if not artist:
self._q.put(
("log", f" ❌ [{slot}] Could not resolve artist for: {title}")
)
result = "no artist"
result_key = "skipped"
return
self._q.put(
("log", f" ✓ [{slot}] Artist resolved → {artist}")
)
track["artist"] = artist
try:
parser.save()
self._q.put(
("log", f" 💾 [{slot}] JSON updated with artist: {artist}")
)
except Exception as je:
self._q.put(("log", f" ⚠️ [{slot}] Could not save JSON: {je}"))
# Update label after artist resolution
display_artist = clean_track_name(normalize_artist_name(artist))
label = f"{display_artist} - {display_title}" if display_artist else display_title
self._q.put(("track_start", slot, label))
artist = normalize_artist_name(artist)
pct(5, "artist ✓")
clean_a = clean_track_name(artist)
clean_t = clean_track_name(title)
if clean_a:
filename = f"{clean_a} - {clean_t}.mp3"
with file_cache_lock:
if filename in file_cache:
self._q.put(("log", f" ⏭️ [{slot}] FILE EXISTS {label}"))
result = "✓ exists"
result_key = "exists"
return
norm_key = f"{clean_a.lower()}|{clean_t.lower()}"
if norm_key in file_cache_normalized:
existing_file = file_cache_normalized[norm_key]
self._q.put(
("log", f" ⏭️ [{slot}] FILE EXISTS (fuzzy match) {existing_file}")
)
result = "✓ exists"
result_key = "exists"
return
artist_key = clean_a.lower()
if artist_key in artist_cache:
self._q.put(
("log", f" 💾 [{slot}] Artist in cache → faster metadata lookup")
)
pct(5, "cache ✓")
if self._stop_event.is_set():
result = "⏹ stopped"
result_key = "skipped"
return
self._q.put(
("log", f" ⬇️ [{slot}] Searching YT: {artist} {display_title}")
)
dl_start = 5
def hook(d):
if self._stop_event.is_set():
return
if d["status"] == "downloading":
tot = (
d.get("total_bytes")
or d.get("total_bytes_estimate")
or 0
)
done = d.get("downloaded_bytes", 0)
if tot > 0:
# Calculate download progress as percentage of total bytes
# Download phase is 5% to 65% of track progress (60% of total)
download_pct = int((done / tot) * 60)
track_pct = dl_start + download_pct
pct(track_pct, f"downloading… {done // 1024 // 1024}MB / {tot // 1024 // 1024}MB")
elif d["status"] == "finished":
pct(65, "processing…")
# Capture print output from downloader
import sys
from io import StringIO
old_stdout = sys.stdout
string_buffer = StringIO()
sys.stdout = string_buffer
try:
filepath = downloader.download_best_match(
artist, title, album, progress_hook=hook
)
finally:
captured_output = string_buffer.getvalue()
sys.stdout = old_stdout
# Send captured output to logs
if captured_output:
for line in captured_output.strip().split('\n'):
if line.strip():
self._q.put(("log", f" [{slot}] {line}"))
if not filepath:
self._q.put(
("log", f" ❌ [{slot}] No YT match: {artist} {display_title}")
)
result = "❌ failed"
result_key = "failed"
return
from pathlib import Path as _Path
filename = _Path(filepath).name
self._q.put(("log", f" ✓ [{slot}] Downloaded → {filename}"))
with file_cache_lock:
file_cache[filename] = True
artist_cache.add(clean_a.lower())
pct(65, "downloaded ✓", force=True)
if self._stop_event.is_set():
result = "⏹ stopped"
result_key = "skipped"
return
pct(65, "MusicBrainz…", force=True)
cache_stats_before = downloader.get_cache_stats()
enriched = downloader.enrich_metadata(
artist, title, album, year, genre, position
)
cache_stats_after = downloader.get_cache_stats()
if (
cache_stats_after["artist_album_hits"]
> cache_stats_before["artist_album_hits"]
):
self._q.put(("log", f" 💾 [{slot}] Artist-Album cache HIT"))
elif (
cache_stats_after["artist_genre_hits"]
> cache_stats_before["artist_genre_hits"]
):
self._q.put(("log", f" 💾 [{slot}] Artist-Genre cache HIT"))
elif (
cache_stats_after["album_year_hits"]
> cache_stats_before["album_year_hits"]
):
self._q.put(("log", f" 💾 [{slot}] Album-Year cache HIT"))
total_cache_entries = downloader.get_total_cache_entries()
self._q.put(("cache_update", total_cache_entries))
mb_album = enriched.get("album", "?")
mb_year = enriched.get("year", "?")
mb_genre = enriched.get("genre", "?")
mb_track = enriched.get("tracknumber", "")
mb_total = enriched.get("total_tracks", "")
track_str = (
f"{mb_track}/{mb_total}" if mb_track and mb_total else mb_track or "?"
)
self._q.put(
(
"log",
f" 🎵 [{slot}] MB → album:{mb_album} year:{mb_year} "
f"genre:{mb_genre} track:{track_str}",
)
)
pct(85, "MusicBrainz ✓", force=True)
if self._stop_event.is_set():
result = "⏹ stopped"
result_key = "skipped"
return
pct(85, "writing tags…", force=True)
downloader.add_metadata(
filepath,
enriched["artist"],
enriched["title"],
enriched["album"],
enriched["year"],
enriched["genre"],
position,
enriched.get("tracknumber", ""),
enriched.get("total_tracks", ""),
)
pct(90, "tags ✓", force=True)
pct(90, "normalizing…", force=True)
try:
success = downloader.normalize_volume(filepath, timeout=60)
if not success:
self._q.put(
("log", f" ⚠️ [{slot}] Normalize timed out or failed")
)
except Exception as ne:
self._q.put(("log", f" ⚠️ [{slot}] Normalize skipped: {ne}"))
pct(99, "done ✓", force=True)
result = "✅ done"
result_key = "downloaded"
self._q.put(("log", f" ✅ [{slot}] DONE {label}"))
except Exception as e:
self._q.put(("log", f" ❌ [{slot}] EXCEPTION {label}{e}"))
finally:
try:
self._q.put(("track_pct", slot, 100, result))
self._q.put(("track_done", slot, result))
self._update_stats(result_key, stats)
except Exception as fe:
pass
finally:
slot_pool.put(slot)
def _update_stats(self, key: str, stats: dict):
"""Mettre à jour les statistiques."""
stats[key] = stats.get(key, 0) + 1
self.tab._done += 1
self._q.put(
(
"total_update",
stats.get("downloaded", 0),
stats.get("exists", 0),
stats.get("skipped", 0),
stats.get("failed", 0),
self.tab._done,
self.tab._total,
)
)
def _preload_caches_from_files(self, downloader: YouTubeDownloader, folder: Path):
"""Pré-remplir les caches depuis les métadonnées des MP3."""
try:
from mutagen.easyid3 import EasyID3
from mutagen.id3 import ID3
mp3_files = list(folder.glob("*.mp3"))
self._q.put(
("log", f"🔍 Scanning {len(mp3_files)} MP3 files for metadata...")
)
loaded = 0
skipped = 0
errors = 0
for mp3_file in mp3_files:
try:
try:
audio = EasyID3(str(mp3_file))
except Exception:
audio = ID3(str(mp3_file))
artist_raw = ""
genre = "Rock"
year = ""
album_raw = ""
if isinstance(audio, dict):
artist_raw = str(audio.get("TPE1", "")) or ""
genre = str(audio.get("TCON", ["Rock"])[0]) or "Rock"
year = str(audio.get("TDRC", "")) or ""
album_raw = str(audio.get("TALB", "")) or ""
else:
artist_raw = (audio.get("artist", [""])[0] or "").strip()
genre = (audio.get("genre", ["Rock"])[0] or "Rock").strip()
year = (audio.get("date", [""])[0] or "").strip()
album_raw = (audio.get("album", [""])[0] or "").strip()
if not artist_raw or not artist_raw.strip():
skipped += 1
continue
artist = sanitize_filename(artist_raw.strip())
album = sanitize_filename(album_raw.strip()) if album_raw else ""
artist_lower = artist.lower()
album_lower = album.lower() if album else ""
downloader._artist_genre_cache[artist_lower] = genre
if album_lower:
downloader._album_year_cache[album_lower] = year
artist_album_key = f"{artist_lower}|{album_lower}"
downloader._artist_album_cache[artist_album_key] = {
"year": year,
"genre": genre,
}
loaded += 1
except Exception as e:
self._q.put(
(
"log",
f" ⚠️ Could not read {mp3_file.name}: {type(e).__name__}: {str(e)[:50]}",
)
)
errors += 1
summary = f"💾 Pre-loaded {loaded} metadata entries"
if skipped > 0:
summary += f" ({skipped} no artist tag)"
if errors > 0:
summary += f" ({errors} read errors)"
self._q.put(("log", summary))
if loaded == 0 and len(mp3_files) > 0:
self._q.put(
("log", "⚠️ No metadata loaded! Check if MP3 files have ID3 tags.")
)
except ImportError:
self._q.put(
("log", "⚠️ mutagen not installed - cannot pre-load metadata caches")
)
except Exception as e:
self._q.put(
("log", f"⚠️ Could not pre-load caches: {type(e).__name__}: {e}")
)
def stop(self):
"""Arrêter le worker."""
self._stop_event.set()