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
This commit is contained in:
Maksim Totmin 2026-05-31 15:26:03 +07:00
parent 11e4d49e2b
commit 7477cfba66
3 changed files with 94 additions and 28 deletions

View File

@ -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")

View File

@ -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,
)

View File

@ -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: