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()