YoutubeDownloader/app/ui/windows/download_list_window.py
2026-03-18 19:28:41 +01:00

564 lines
23 KiB
Python

from pathlib import Path
from typing import Optional
from PyQt6.QtCore import QSize, Qt, pyqtSignal
from PyQt6.QtGui import QDrag, QIcon
from PyQt6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QSizePolicy,
QStyle,
QVBoxLayout,
QWidget,
)
from app.ui.widgets.loading_bar import LoadingBar
from app.core.main_manager import MainManager, NotificationType
class AlbumDialog(QDialog):
def __init__(self, language_manager, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.language_manager = language_manager
self.setWindowTitle(self.language_manager.get_text("create_album"))
layout = QVBoxLayout(self)
form = QFormLayout()
self.name_input = QLineEdit(self)
form.addRow(self.language_manager.get_text("album_name_label"), self.name_input)
self.artist_input = QLineEdit(self)
form.addRow(self.language_manager.get_text("artist_label"), self.artist_input)
cover_row = QHBoxLayout()
self.cover_input = QLineEdit(self)
self.cover_input.setReadOnly(True)
cover_row.addWidget(self.cover_input, 1)
self.browse_button = QPushButton(self.language_manager.get_text("choose_cover"), self)
self.browse_button.clicked.connect(self._choose_cover)
cover_row.addWidget(self.browse_button)
form.addRow(self.language_manager.get_text("cover_label"), cover_row)
layout.addLayout(form)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _choose_cover(self) -> None:
cover_file, _ = QFileDialog.getOpenFileName(
self,
self.language_manager.get_text("choose_cover"),
str(Path.home()),
"Images (*.png *.jpg *.jpeg *.webp)",
)
if cover_file:
self.cover_input.setText(cover_file)
class TrackEditorDialog(QDialog):
def __init__(self, language_manager, track: dict, album_names: list[str], parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.language_manager = language_manager
self.setWindowTitle(self.language_manager.get_text("edit_track"))
layout = QVBoxLayout(self)
form = QFormLayout()
self.title_input = QLineEdit(str(track.get("mp3_title") or track.get("title", "")), self)
form.addRow(self.language_manager.get_text("mp3_title_label"), self.title_input)
self.artist_input = QLineEdit(str(track.get("artist", "")), self)
form.addRow(self.language_manager.get_text("artist_label"), self.artist_input)
self.album_combo = QComboBox(self)
self.album_combo.addItem(self.language_manager.get_text("without_album"), "")
for album_name in album_names:
self.album_combo.addItem(album_name, album_name)
current_album = str(track.get("album", ""))
album_idx = self.album_combo.findData(current_album)
self.album_combo.setCurrentIndex(album_idx if album_idx >= 0 else 0)
form.addRow(self.language_manager.get_text("assign_album_label"), self.album_combo)
layout.addLayout(form)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
class ExplorerGridWidget(QListWidget):
trackMoved = pyqtSignal(int, str)
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self._dragged_track_index: int = -1
self.setViewMode(QListWidget.ViewMode.IconMode)
self.setFlow(QListWidget.Flow.LeftToRight)
self.setWrapping(True)
self.setResizeMode(QListWidget.ResizeMode.Adjust)
self.setMovement(QListWidget.Movement.Static)
self.setUniformItemSizes(False)
self.setIconSize(QSize(128, 128))
self.setGridSize(QSize(180, 160))
self.setAcceptDrops(True)
self.viewport().setAcceptDrops(True)
def _item_type(self, item: Optional[QListWidgetItem]) -> str:
if item is None:
return ""
value = item.data(Qt.ItemDataRole.UserRole + 1)
return value if isinstance(value, str) else ""
def startDrag(self, supportedActions) -> None:
item = self.currentItem()
if self._item_type(item) != "track":
self._dragged_track_index = -1
return
track_index = item.data(Qt.ItemDataRole.UserRole + 2) if item else None
self._dragged_track_index = track_index if isinstance(track_index, int) else -1
if self._dragged_track_index < 0:
return
mime_data = self.mimeData([item])
if mime_data is None:
return
mime_data.setText(str(self._dragged_track_index))
drag = QDrag(self)
drag.setMimeData(mime_data)
if not item.icon().isNull():
drag.setPixmap(item.icon().pixmap(self.iconSize()))
drag.exec(Qt.DropAction.MoveAction)
def _drag_track_index_from_event(self, event) -> int:
mime = event.mimeData()
track_index_text = mime.text().strip() if mime and mime.hasText() else ""
return int(track_index_text) if track_index_text.isdigit() else -1
def dragEnterEvent(self, event) -> None:
if self._drag_track_index_from_event(event) >= 0:
event.acceptProposedAction()
return
event.ignore()
def dragMoveEvent(self, event) -> None:
if self._drag_track_index_from_event(event) < 0:
event.ignore()
return
target_pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
target_item = self.itemAt(target_pos)
if target_item is None or self._item_type(target_item) != "album":
event.ignore()
return
event.acceptProposedAction()
def dropEvent(self, event) -> None:
track_index = self._drag_track_index_from_event(event)
if track_index < 0:
event.ignore()
return
target_pos = event.position().toPoint() if hasattr(event, "position") else event.pos()
target_item = self.itemAt(target_pos)
if target_item is None:
event.ignore()
return
if self._item_type(target_item) != "album":
event.ignore()
return
album_name_data = target_item.data(Qt.ItemDataRole.UserRole + 3)
album_name = album_name_data if isinstance(album_name_data, str) else ""
self.trackMoved.emit(track_index, album_name)
event.acceptProposedAction()
class DownloadListWindow(QWidget):
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.main_manager = MainManager.get_instance()
self.language_manager = self.main_manager.get_language_manager()
self.alert_manager = self.main_manager.get_alert_manager()
self.youtube_manager = self.main_manager.get_youtube_manager()
self.metadata_manager = self.main_manager.get_metadata_manager()
self.observer_manager = self.main_manager.get_observer_manager()
self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language)
self.youtube_manager.list_changed.connect(self.refresh_list)
self.youtube_manager.started.connect(self.on_download_started)
self.youtube_manager.progress_text.connect(self.on_progress_text)
self.youtube_manager.progress_value.connect(self.on_progress_value)
self.youtube_manager.completed.connect(self.on_download_completed)
self.youtube_manager.failed.connect(self.on_download_failed)
self.youtube_manager.queue_finished.connect(self.on_queue_finished)
self.youtube_manager.downloading_state_changed.connect(self.set_downloading_state)
self.track_data: list[dict] = []
self.album_profiles: dict[str, dict[str, str]] = {}
self.current_album: str = ""
self.setup_ui()
self.refresh_list(self.youtube_manager.get_tracks())
def setup_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
layout.setSpacing(12)
layout.setContentsMargins(20, 20, 20, 20)
self.title_label = QLabel(self.language_manager.get_text("download_list_title"), self)
self.title_label.setWordWrap(True)
self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
layout.addWidget(self.title_label)
tools_row = QHBoxLayout()
self.create_album_button = QPushButton(self.language_manager.get_text("create_album"), self)
self.create_album_button.clicked.connect(self.create_album)
tools_row.addWidget(self.create_album_button)
self.edit_track_button = QPushButton(self.language_manager.get_text("edit_track"), self)
self.edit_track_button.clicked.connect(self.edit_selected_track)
tools_row.addWidget(self.edit_track_button)
tools_row.addStretch(1)
layout.addLayout(tools_row)
nav_row = QHBoxLayout()
self.back_to_root_button = QPushButton(self.language_manager.get_text("back_to_root"), self)
self.back_to_root_button.clicked.connect(self.go_to_root)
nav_row.addWidget(self.back_to_root_button)
self.path_label = QLabel("", self)
self.path_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
nav_row.addWidget(self.path_label, 1)
layout.addLayout(nav_row)
self.explorer_grid = ExplorerGridWidget(self)
self.explorer_grid.setSelectionMode(QAbstractItemView.SelectionMode.MultiSelection)
self.explorer_grid.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
self.explorer_grid.setDefaultDropAction(Qt.DropAction.MoveAction)
self.explorer_grid.setDragEnabled(True)
self.explorer_grid.setAcceptDrops(True)
self.explorer_grid.setDropIndicatorShown(True)
self.explorer_grid.setIconSize(QSize(64, 64))
self.explorer_grid.setGridSize(QSize(110, 100))
self.explorer_grid.itemDoubleClicked.connect(self.on_grid_item_double_clicked)
self.explorer_grid.trackMoved.connect(self.on_track_dropped)
layout.addWidget(self.explorer_grid, 1)
destination_layout = QHBoxLayout()
self.folder_input = QLineEdit(self)
self.folder_input.setReadOnly(True)
self.folder_input.setText(str(Path.home() / "Downloads"))
destination_layout.addWidget(self.folder_input, 1)
self.browse_button = QPushButton(self.language_manager.get_text("choose_folder"), self)
self.browse_button.clicked.connect(self.select_folder)
destination_layout.addWidget(self.browse_button)
layout.addLayout(destination_layout)
actions_layout = QHBoxLayout()
self.remove_button = QPushButton(self.language_manager.get_text("remove_selected"), self)
self.remove_button.clicked.connect(self.remove_selected)
actions_layout.addWidget(self.remove_button)
self.clear_button = QPushButton(self.language_manager.get_text("clear_list"), self)
self.clear_button.clicked.connect(self.clear_list)
actions_layout.addWidget(self.clear_button)
layout.addLayout(actions_layout)
download_layout = QHBoxLayout()
self.download_selected_button = QPushButton(self.language_manager.get_text("download_selected"), self)
self.download_selected_button.clicked.connect(self.download_selected)
download_layout.addWidget(self.download_selected_button)
self.download_all_button = QPushButton(self.language_manager.get_text("download_all"), self)
self.download_all_button.clicked.connect(self.download_all)
download_layout.addWidget(self.download_all_button)
layout.addLayout(download_layout)
self.loading_bar = None
self.status_label = QLabel("", self)
self.status_label.setWordWrap(True)
layout.addWidget(self.status_label)
def _show_loading_bar(self, label: str):
if self.loading_bar is not None:
self.loading_bar.deleteLater()
self.loading_bar = LoadingBar(label, self)
self.layout().addWidget(self.loading_bar)
self.loading_bar.set_label(label)
self.loading_bar.set_progress(0)
def refresh_list(self, tracks: list) -> None:
self.track_data = tracks
self._sync_albums_from_tracks()
self._render_grid()
def _sync_albums_from_tracks(self) -> None:
for track in self.track_data:
album_name = str(track.get("album", "")).strip()
if not album_name:
continue
if album_name not in self.album_profiles:
self.album_profiles[album_name] = {
"artist": str(track.get("artist", "")).strip(),
"cover_path": str(track.get("cover_path", "")).strip(),
}
def _add_album_item(self, album_name: str) -> None:
profile = self.album_profiles.get(album_name, {})
cover_path = str(profile.get("cover_path", "")).strip()
folder_icon: QIcon
if cover_path and Path(cover_path).exists():
folder_icon = QIcon(cover_path)
else:
folder_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon)
if folder_icon.isNull():
folder_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_DirIcon)
item = QListWidgetItem(folder_icon, album_name)
item.setData(Qt.ItemDataRole.UserRole + 1, "album")
item.setData(Qt.ItemDataRole.UserRole + 3, album_name)
item.setFlags((item.flags() | Qt.ItemFlag.ItemIsDropEnabled) & ~Qt.ItemFlag.ItemIsDragEnabled)
self.explorer_grid.addItem(item)
def _add_track_item(self, index: int, track: dict) -> None:
cover_path = str(track.get("cover_path", "")).strip()
if cover_path and Path(cover_path).exists():
file_icon = QIcon(cover_path)
else:
file_icon = self.style().standardIcon(QStyle.StandardPixmap.SP_FileIcon)
title = str(track.get("mp3_title") or track.get("title") or track.get("url", ""))
item = QListWidgetItem(file_icon, title)
item.setData(Qt.ItemDataRole.UserRole + 1, "track")
item.setData(Qt.ItemDataRole.UserRole + 2, index)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
self.explorer_grid.addItem(item)
def _render_grid(self) -> None:
selected_track_index = self._selected_track_index()
self.explorer_grid.blockSignals(True)
self.explorer_grid.clear()
if not self.current_album:
for album_name in sorted(self.album_profiles.keys()):
self._add_album_item(album_name)
for index, track in enumerate(self.track_data):
album_name = str(track.get("album", "")).strip()
if album_name:
continue
self._add_track_item(index, track)
self.path_label.setText(self.language_manager.get_text("root_path"))
self.back_to_root_button.setVisible(False)
else:
for index, track in enumerate(self.track_data):
album_name = str(track.get("album", "")).strip()
if album_name != self.current_album:
continue
self._add_track_item(index, track)
self.path_label.setText(f"{self.language_manager.get_text('albums_title')} / {self.current_album}")
self.back_to_root_button.setVisible(True)
if selected_track_index >= 0:
for row in range(self.explorer_grid.count()):
item = self.explorer_grid.item(row)
track_index = item.data(Qt.ItemDataRole.UserRole + 2)
if isinstance(track_index, int) and track_index == selected_track_index:
self.explorer_grid.setCurrentItem(item)
break
self.explorer_grid.blockSignals(False)
def _selected_track_index(self) -> int:
item = self.explorer_grid.currentItem()
if item is None:
return -1
track_index = item.data(Qt.ItemDataRole.UserRole + 2)
return track_index if isinstance(track_index, int) else -1
def on_grid_item_double_clicked(self, item: QListWidgetItem) -> None:
item_type = item.data(Qt.ItemDataRole.UserRole + 1)
if item_type == "album":
album_name = item.data(Qt.ItemDataRole.UserRole + 3)
if isinstance(album_name, str):
self.current_album = album_name
self._render_grid()
return
if item_type == "track":
self.edit_selected_track()
def go_to_root(self) -> None:
self.current_album = ""
self._render_grid()
def create_album(self) -> None:
dialog = AlbumDialog(self.language_manager, self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
album_name = dialog.name_input.text().strip()
if not album_name:
return
cover_path = dialog.cover_input.text().strip()
self.album_profiles[album_name] = {
"artist": dialog.artist_input.text().strip(),
"cover_path": cover_path,
}
# UI: demande au manager d'appliquer la cover à tous les morceaux de l'album
self.metadata_manager.apply_album_cover(album_name, cover_path)
self.current_album = ""
self._render_grid()
self.status_label.setText(self.language_manager.get_text("album_saved"))
def edit_selected_track(self) -> None:
track_index = self._selected_track_index()
if track_index < 0 or track_index >= len(self.track_data):
self.alert_manager.show_error("no_track_selected", self)
return
track = self.track_data[track_index]
dialog = TrackEditorDialog(self.language_manager, track, sorted(self.album_profiles.keys()), self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
album_name = str(dialog.album_combo.currentData() or "")
profile = self.album_profiles.get(album_name, {"artist": "", "cover_path": ""})
final_artist = dialog.artist_input.text().strip() or profile.get("artist", "")
final_cover = profile.get("cover_path", "") if album_name else str(track.get("cover_path", ""))
if self.metadata_manager.update_track_metadata(
track_index,
dialog.title_input.text(),
final_artist,
album_name,
final_cover,
):
self.status_label.setText(self.language_manager.get_text("metadata_saved"))
def on_track_dropped(self, track_index: int, album_name: str) -> None:
if track_index < 0 or track_index >= len(self.track_data):
return
profile = self.album_profiles.get(album_name, {"artist": "", "cover_path": ""})
if self.metadata_manager.update_track_metadata(
track_index,
str(self.track_data[track_index].get("mp3_title") or self.track_data[track_index].get("title", "")),
profile.get("artist", ""),
album_name,
profile.get("cover_path", ""),
):
self.status_label.setText(self.language_manager.get_text("metadata_saved"))
def select_folder(self) -> None:
selected_folder = QFileDialog.getExistingDirectory(
self,
self.language_manager.get_text("choose_output_folder"),
self.folder_input.text(),
)
if selected_folder:
self.folder_input.setText(selected_folder)
def remove_selected(self) -> None:
track_index = self._selected_track_index()
self.youtube_manager.remove_track(track_index)
def clear_list(self) -> None:
self.youtube_manager.clear_tracks()
self.status_label.setText(self.language_manager.get_text("list_cleared"))
def download_selected(self) -> None:
track_index = self._selected_track_index()
self.youtube_manager.download_track_at(track_index, self.folder_input.text().strip())
def download_all(self) -> None:
self.youtube_manager.download_all_tracks(self.folder_input.text().strip())
def on_download_started(self) -> None:
self._show_loading_bar(self.language_manager.get_text("downloading_audio"))
self.status_label.setText(self.language_manager.get_text("downloading_audio"))
def on_progress_text(self, text: str) -> None:
if self.loading_bar is not None:
self.loading_bar.set_label(text)
self.status_label.setText(text)
def on_progress_value(self, value: int) -> None:
if self.loading_bar is not None:
self.loading_bar.set_progress(value)
def on_download_completed(self, _title: str) -> None:
if self.loading_bar is not None:
self.loading_bar.set_progress(100)
def on_download_failed(self, error_key: str) -> None:
if self.loading_bar is not None:
self.loading_bar.set_label(self.language_manager.get_text(error_key))
self.loading_bar.set_progress(0)
self.loading_bar.deleteLater()
self.loading_bar = None
self.status_label.setText(self.language_manager.get_text(error_key))
self.alert_manager.show_error(error_key, self)
def on_queue_finished(self) -> None:
if self.loading_bar is not None:
self.loading_bar.set_label(self.language_manager.get_text("playlist_download_complete"))
self.loading_bar.set_progress(100)
self.loading_bar.deleteLater()
self.loading_bar = None
self.status_label.setText(self.language_manager.get_text("playlist_download_complete"))
self.alert_manager.show_success("playlist_download_complete", self)
def set_downloading_state(self, downloading: bool) -> None:
self.create_album_button.setEnabled(not downloading)
self.edit_track_button.setEnabled(not downloading)
self.browse_button.setEnabled(not downloading)
self.remove_button.setEnabled(not downloading)
self.clear_button.setEnabled(not downloading)
self.download_selected_button.setEnabled(not downloading)
self.download_all_button.setEnabled(not downloading)
self.explorer_grid.setEnabled(not downloading)
self.back_to_root_button.setEnabled(not downloading)
def update_language(self) -> None:
self.title_label.setText(self.language_manager.get_text("download_list_title"))
self.create_album_button.setText(self.language_manager.get_text("create_album"))
self.edit_track_button.setText(self.language_manager.get_text("edit_track"))
self.browse_button.setText(self.language_manager.get_text("choose_folder"))
self.remove_button.setText(self.language_manager.get_text("remove_selected"))
self.clear_button.setText(self.language_manager.get_text("clear_list"))
self.download_selected_button.setText(self.language_manager.get_text("download_selected"))
self.download_all_button.setText(self.language_manager.get_text("download_all"))
self.back_to_root_button.setText(self.language_manager.get_text("back_to_root"))
self._render_grid()