From 7477cfba6656c73852affd7fcf38a33d4297f97a Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Sun, 31 May 2026 15:26:03 +0700 Subject: [PATCH] Replace Ready to Transfer list with sortable table (QTableWidget) - Replace QListWidget with QTableWidget in Library tab - 6 columns: #, Artist, Title, Duration, Genre, Size - Track numbers displayed without leading zeros (1, 2, 3...) - Dash shown when track number is unknown - Sort by track number within album by default - Clickable column headers for sorting by any field - Alternating row colors for readability - Row selection (SelectRows behavior) - Artist and Title columns stretch to fill space - Add genre field to TrackInfo dataclass - Include genre in metadata extract_tags() return value - Extract genre and track_number during library scan --- src/main.py | 120 +++++++++++++++++++++++++++++--------- src/metadata_handler.py | 1 + src/youtube_downloader.py | 1 + 3 files changed, 94 insertions(+), 28 deletions(-) diff --git a/src/main.py b/src/main.py index ad18a46..d64ef63 100644 --- a/src/main.py +++ b/src/main.py @@ -14,7 +14,8 @@ from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QLineEdit, QProgressBar, QFileDialog, QComboBox, QCheckBox, QTabWidget, QListWidget, QListWidgetItem, - QMessageBox, QSpinBox, QGroupBox, QHeaderView + QMessageBox, QSpinBox, QGroupBox, QHeaderView, + QTableWidget, QTableWidgetItem ) from PyQt6.QtCore import Qt, QThread, pyqtSignal @@ -326,9 +327,26 @@ class MainWindow(QMainWindow): self.ready_label = QLabel("Ready to Transfer:") layout.addWidget(self.ready_label) - self.library_ready_list = QListWidget() - self.library_ready_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) - layout.addWidget(self.library_ready_list, stretch=2) + self.library_ready_table = QTableWidget() + self.library_ready_table.setColumnCount(6) + self.library_ready_table.setHorizontalHeaderLabels( + ["#", "Artist", "Title", "Duration", "Genre", "Size"] + ) + self.library_ready_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.library_ready_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) + self.library_ready_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.library_ready_table.setAlternatingRowColors(True) + self.library_ready_table.setSortingEnabled(True) + + header = self.library_ready_table.horizontalHeader() + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) + + layout.addWidget(self.library_ready_table, stretch=2) self.source_label = QLabel("Source Files — Need Conversion:") layout.addWidget(self.source_label) @@ -487,11 +505,14 @@ class MainWindow(QMainWindow): if not output_dir or not os.path.isdir(output_dir): self.library_ready.clear() self._refresh_library_ui() + self._refresh_source_ui([]) return ready = [] sources = [] + handler = MetadataHandler() + for root, _dirs, files in os.walk(output_dir): for fname in sorted(files): fpath = os.path.join(root, fname) @@ -502,15 +523,18 @@ class MainWindow(QMainWindow): if ext in AUDIO_EXTS: size = os.path.getsize(fpath) try: - handler = MetadataHandler() info = handler.extract_tags(fpath) duration = info.duration if info else 0 artist = info.artist if info else "Unknown" title = info.title if info else fname + genre = info.genre if info else "" + track_num = info.track_number if info else 0 except Exception: duration = 0 artist = "Unknown" title = fname + genre = "" + track_num = 0 ready.append({ "path": fpath, @@ -518,22 +542,26 @@ class MainWindow(QMainWindow): "artist": artist, "duration_s": duration, "size": size, + "genre": genre, + "track_num": track_num, }) elif ext in SOURCE_EXTS: size = os.path.getsize(fpath) try: - handler = MetadataHandler() info = handler.extract_tags(fpath) title = info.title if info else fname artist = info.artist if info else "Unknown" + track_num = info.track_number if info else 0 except Exception: title = fname artist = "Unknown" + track_num = 0 sources.append({ "path": fpath, "title": title, "artist": artist, "size": size, + "track_num": track_num, }) self.library_ready = ready @@ -541,20 +569,36 @@ class MainWindow(QMainWindow): self._refresh_source_ui(sources) def _refresh_library_ui(self): - self.library_ready_list.clear() - for track in sorted(self.library_ready, key=lambda t: (t["artist"], t["title"])): - display = ( - f"{track['artist']} — {track['title']}" - f" {self._format_duration(track['duration_s'])}" - f" {self._format_size(track['size'])}" - ) - item = QListWidgetItem(display) - item.setData(Qt.ItemDataRole.UserRole, track) - self.library_ready_list.addItem(item) + self.library_ready_table.setRowCount(0) + self.library_ready_table.setSortingEnabled(False) + + for track in sorted( + self.library_ready, + key=lambda t: (t.get("track_num") or 0, t["artist"].lower(), t["title"].lower()) + ): + row = self.library_ready_table.rowCount() + self.library_ready_table.insertRow(row) + + track_num_str = str(track["track_num"]) if track.get("track_num") else "—" + + cells = [ + QTableWidgetItem(track_num_str), + QTableWidgetItem(track["artist"]), + QTableWidgetItem(track["title"]), + QTableWidgetItem(self._format_duration(track["duration_s"])), + QTableWidgetItem(track.get("genre", "") or ""), + QTableWidgetItem(self._format_size(track["size"])), + ] + + cells[0].setData(Qt.ItemDataRole.UserRole, track) + for col, item in enumerate(cells): + self.library_ready_table.setItem(row, col, item) + + self.library_ready_table.setSortingEnabled(True) count = len(self.library_ready) self.ready_label.setVisible(count > 0) - self.library_ready_list.setVisible(count > 0) + self.library_ready_table.setVisible(count > 0) if count: self.library_status.setText(f"{count} track(s) ready to transfer") self.library_transfer_btn.setEnabled(True) @@ -615,21 +659,29 @@ class MainWindow(QMainWindow): self.library_convert_btn.setEnabled(count > 0) def _get_selected_ready_tracks(self) -> List[Tuple[TrackInfo, str]]: - selected = self.library_ready_list.selectedItems() - if not selected: - selected = [self.library_ready_list.item(i) for i in range(self.library_ready_list.count())] + selected_rows = set() + for item in self.library_ready_table.selectedItems(): + selected_rows.add(item.row()) + + if not selected_rows: + selected_rows = set(range(self.library_ready_table.rowCount())) tracks = [] - for item in selected: - data = item.data(Qt.ItemDataRole.UserRole) + for row in sorted(selected_rows): + data_item = self.library_ready_table.item(row, 0) + if data_item is None: + continue + data = data_item.data(Qt.ItemDataRole.UserRole) if data: track_info = TrackInfo( video_id=f"local_{hash(data['path'])}", title=data['title'], artist=data['artist'], - album="", + album=data.get("album", ""), thumbnail_url="", duration=data.get('duration_s', 0), + track_number=data.get('track_num'), + genre=data.get('genre'), download_path=data['path'], ) tracks.append((track_info, data['path'])) @@ -677,11 +729,23 @@ class MainWindow(QMainWindow): def _on_library_remove_selected(self): """Delete selected files from disk and clean up empty directories.""" files_to_delete = [] - for lst in (self.library_ready_list, self.library_source_list): - for item in lst.selectedItems(): - data = item.data(Qt.ItemDataRole.UserRole) - if data and os.path.exists(data.get("path", "")): - files_to_delete.append(data) + + selected_rows = set() + for item in self.library_ready_table.selectedItems(): + selected_rows.add(item.row()) + + for row in selected_rows: + data_item = self.library_ready_table.item(row, 0) + if data_item is None: + continue + data = data_item.data(Qt.ItemDataRole.UserRole) + if data and os.path.exists(data.get("path", "")): + files_to_delete.append(data) + + for item in self.library_source_list.selectedItems(): + data = item.data(Qt.ItemDataRole.UserRole) + if data and os.path.exists(data.get("path", "")): + files_to_delete.append(data) if not files_to_delete: QMessageBox.information(self, "Info", "No files selected") diff --git a/src/metadata_handler.py b/src/metadata_handler.py index a8d5864..5303ca3 100644 --- a/src/metadata_handler.py +++ b/src/metadata_handler.py @@ -356,6 +356,7 @@ class MetadataHandler: thumbnail_url="", duration=duration, track_number=track_number if track_number else None, + genre=genre, download_path=file_path, ) diff --git a/src/youtube_downloader.py b/src/youtube_downloader.py index 1b200b9..0b063f3 100644 --- a/src/youtube_downloader.py +++ b/src/youtube_downloader.py @@ -32,6 +32,7 @@ class TrackInfo: playlist_title: Optional[str] = None playlist_index: Optional[int] = None download_path: Optional[str] = None + genre: Optional[str] = None class YouTubeDownloader: