From 2d77b3ea3e4600b3fdf35d84f532a54d8ab8197a Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Mon, 1 Jun 2026 22:11:36 +0700 Subject: [PATCH] feat: modernize iPod tab with table view, search, and sorting, fix LibraryCache API --- src/ipod_nano7_db.py | 94 ++++++- src/ui/ipod_tab.py | 640 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 621 insertions(+), 113 deletions(-) diff --git a/src/ipod_nano7_db.py b/src/ipod_nano7_db.py index ad18a43..cb78759 100644 --- a/src/ipod_nano7_db.py +++ b/src/ipod_nano7_db.py @@ -206,6 +206,16 @@ def _read_audio_tags(filepath: str) -> dict[str, Any]: ds = str(val[0]) if len(ds) >= 4: info["year"] = int(ds[:4]) + + # Compilation flag (TCMP frame) — only set when explicitly present + try: + id3 = ID3(filepath) + if "TCMP" in id3: + tcmp = id3["TCMP"] + if hasattr(tcmp, "text") and tcmp.text: + info["compilation"] = str(tcmp.text[0]) == "1" + except Exception: + pass except Exception: pass elif ext in (".m4a", ".aac", ".mp4", ".m4p"): @@ -228,6 +238,14 @@ def _read_audio_tags(filepath: str) -> dict[str, Any]: tr = t["trkn"] if isinstance(tr, list) and tr and isinstance(tr[0], tuple): info["track_number"] = tr[0][0] + + # Compilation flag (cpil atom) + if "cpil" in t: + cpil = t["cpil"] + if isinstance(cpil, bool): + info["compilation"] = cpil + elif isinstance(cpil, list) and len(cpil) > 0: + info["compilation"] = bool(cpil[0]) else: if audio and audio.tags: for tag_k, dest in [("title", "title"), ("artist", "artist"), @@ -239,6 +257,15 @@ def _read_audio_tags(filepath: str) -> dict[str, Any]: info[dest] = str(v[0]) if isinstance(v, list) and v else str(v) except (ValueError, TypeError): pass + + # Compilation flag (vorbis comment) + try: + if "compilation" in audio.tags: + comp = audio.tags["compilation"] + comp_val = str(comp[0]) if isinstance(comp, list) else str(comp) + info["compilation"] = comp_val == "1" + except (ValueError, TypeError): + pass except Exception: pass return info @@ -329,6 +356,7 @@ def _scanned_tracks_to_iop(mountpoint: str, sample_count=sample_count, gapless_track_flag=1, gapless_album_flag=1 if has_album else 0, + compilation_flag=s.get("compilation", False), play_count=s.get("play_count", 0), rating=s.get("rating", 0), last_played=s.get("last_played", 0), @@ -493,8 +521,10 @@ class Nano7Database: "artist": s.get("artist") or "Unknown Artist", "album": s.get("album") or "", "album_artist": s.get("album_artist") or "", + "genre": s.get("genre", ""), "duration_ms": s.get("duration_ms", 0), "track_number": s.get("track_number", 0), + "file_size": s.get("file_size", 0), "disc_number": s.get("disc_number", 0), "physical_order": 0, "date_modified": 0, @@ -748,34 +778,78 @@ class Nano7Database: """Merge iPod + local play stats into *scanned* track dicts in-place. Uses additive model (like iTunes): - ``new_value = max(ipod_base, local_count) + ipod_delta`` + ``new_value = max(ipod_base, local_cache, play_stats_store) + ipod_delta`` + + Includes a **validation gate** that refuses to produce all-zero + play counts when we have previously-recorded non-zero data in the + local PlayStatsStore — this prevents accidental data loss from + silent iTunesDB parse failures. """ base_stats, delta_stats = self.read_play_stats() local_play_stats = self._load_local_play_stats() + # Supplement local_play_stats with durable PlayStatsStore (SQLite) + from library_cache import get_library_cache + db_store = get_library_cache() + for s in scanned: rel = os.path.relpath(s["file_path"], self.music_base) location = f":iPod_Control:Music:{rel.replace(os.sep, ':')}" bs = base_stats.get(location, {}) dd = delta_stats.get(location, {}) ch = s.get("content_hash", "") + + # Three-tier fallback: iTunesDB → LibraryCache (SQLite) → old JSON cache local_pc = local_play_stats.get(ch, {}) + db_entry = db_store.get_by_content_hash(ch) if ch else None s["play_count"] = max( - bs.get("play_count", 0), local_pc.get("play_count", 0), + bs.get("play_count", 0), + (db_entry or {}).get("play_count", 0), + local_pc.get("play_count", 0), ) + dd.get("play_count", 0) s["skip_count"] = max( - bs.get("skip_count", 0), local_pc.get("skip_count", 0), + bs.get("skip_count", 0), + (db_entry or {}).get("skip_count", 0), + local_pc.get("skip_count", 0), ) + dd.get("skip_count", 0) dd_rating = dd.get("rating", 0) - s["rating"] = dd_rating if dd_rating >= 0 else bs.get("rating", 0) or local_pc.get("rating", 0) + store_rating = (db_entry or {}).get("rating", 0) + s["rating"] = dd_rating if dd_rating >= 0 else ( + bs.get("rating", 0) or store_rating or local_pc.get("rating", 0) + ) s["last_played"] = max( - bs.get("last_played", 0), local_pc.get("last_played", 0), + bs.get("last_played", 0), + (db_entry or {}).get("last_played", 0), + local_pc.get("last_played", 0), ) + # ── validation gate ──────────────────────────────────────────── + # If every scanned track would get play_count=0 but the local DB + # previously had non-zero values, restore them. + all_zero = all(s.get("play_count", 0) == 0 for s in scanned) + if all_zero: + restored = 0 + for s in scanned: + ch = s.get("content_hash", "") + if not ch: + continue + db_entry = db_store.get_by_content_hash(ch) if ch else None + stored_pc = (db_entry or {}).get("play_count", 0) + if stored_pc > 0: + s["play_count"] = stored_pc + restored += 1 + if restored: + logger.warning( + "Validation gate triggered — restored play_count for %d track(s) " + "from local database after merge produced all zeros", + restored, + ) + # ─────────────────────────────────────────────────────────────── + def _cleanup_play_counts_file(self) -> None: """Delete the iPod 'Play Counts' delta file after a full sync.""" pc_file = os.path.join( @@ -788,12 +862,6 @@ class Nano7Database: except OSError as e: logger.debug("Could not delete Play Counts file: %s", e) - def consume_play_deltas(self) -> None: - """Public entry point: delete the Play Counts file after consuming - its data during mount. Prevents double-counting on subsequent - ``read_play_stats()`` calls.""" - self._cleanup_play_counts_file() - def _sync_itunescdb_impl(self) -> None: scanned = self._scan_ipod_files() if not scanned: @@ -912,8 +980,8 @@ class Nano7Database: from library_cache import get_library_cache cache = get_library_cache() result: dict[str, dict[str, int]] = {} - for entry in cache._data.values(): - ch = entry.get("_content_hash", "") + for _path, entry in cache.iter_entries(): + ch = entry.get("content_hash", "") pc = entry.get("play_count", 0) rating = entry.get("rating", 0) lp = entry.get("last_played", 0) diff --git a/src/ui/ipod_tab.py b/src/ui/ipod_tab.py index 93ca611..224b432 100644 --- a/src/ui/ipod_tab.py +++ b/src/ui/ipod_tab.py @@ -1,15 +1,19 @@ import os import logging -from typing import List, Dict, Optional +from typing import List, Dict, Optional, Any from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QProgressBar, - QComboBox, QListWidget, QListWidgetItem, QMessageBox, - QFileDialog, + QComboBox, QMessageBox, + QFileDialog, QTableWidget, QTableWidgetItem, + QHeaderView, QMenu, QLineEdit, QFrame, +) +from PyQt6.QtCore import Qt, pyqtSignal, QUrl, QMimeData +from PyQt6.QtGui import ( + QDragEnterEvent, QDragMoveEvent, QDragLeaveEvent, QDropEvent, + QDrag, QPixmap, QPainter, QColor, ) -from PyQt6.QtCore import Qt, pyqtSignal, QUrl -from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDragLeaveEvent, QDropEvent from ipod_device import IPodDevice from device_monitor import DeviceMonitor @@ -18,6 +22,60 @@ from worker import WorkerThread logger = logging.getLogger(__name__) +class NumericTableItem(QTableWidgetItem): + def __lt__(self, other): + self_data = self.data(Qt.ItemDataRole.UserRole) or {} + other_data = other.data(Qt.ItemDataRole.UserRole) or {} + self_num = (self_data or {}).get("track_number", 0) or 0 + other_num = (other_data or {}).get("track_number", 0) or 0 + return self_num < other_num + + +class DraggableIPodTable(QTableWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setDragEnabled(True) + + def startDrag(self, supportedActions): + paths = [] + for item in self.selectedItems(): + row = item.row() + data_item = self.item(row, 0) + if data_item is None: + continue + track_data = data_item.data(Qt.ItemDataRole.UserRole) + if track_data and track_data.get("file_path"): + paths.append(track_data["file_path"]) + + if not paths: + return + + paths = list(set(paths)) + mime = QMimeData() + mime.setUrls([QUrl.fromLocalFile(p) for p in paths]) + + drag = QDrag(self) + drag.setMimeData(mime) + + count = len(paths) + label = f"{count} track{'s' if count != 1 else ''}" + pixmap = QPixmap(160, 28) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + bg = self.palette().color(self.palette().ColorRole.Highlight) + painter.setBrush(bg) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(0, 0, 160, 28, 6, 6) + painter.setPen(self.palette().color(self.palette().ColorRole.HighlightedText)) + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, label) + painter.end() + drag.setPixmap(pixmap) + drag.setHotSpot(pixmap.rect().center()) + + drag.exec(Qt.DropAction.CopyAction) + + class iPodTab(QWidget): device_mounted = pyqtSignal(str) @@ -35,93 +93,297 @@ class iPodTab(QWidget): def _setup_ui(self): layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + palette = self.palette() + hl = palette.color(palette.ColorRole.Highlight) + hl_name = hl.name() + hl_light = QColor(hl) + hl_light.setAlpha(60) + hl_light_name = hl_light.name() + text_color = palette.color(palette.ColorRole.WindowText).name() + dim_color = palette.color(palette.ColorRole.PlaceholderText).name() + mid_color = palette.color(palette.ColorRole.Midlight).name() if palette.color(palette.ColorRole.Midlight).isValid() else palette.color(palette.ColorRole.Window).darker(120).name() + base_color = palette.color(palette.ColorRole.Base).name() + window_color = palette.color(palette.ColorRole.Window).name() + + section_style = f""" + color: {dim_color}; + font-size: 11px; + font-weight: bold; + letter-spacing: 1px; + padding: 4px 0; + """ + + btn_style = f""" + QPushButton {{ + padding: 5px 12px; + border: 1px solid {mid_color}; + border-radius: 4px; + background: {base_color}; + color: {text_color}; + font-size: 12px; + }} + QPushButton:hover {{ + background: {hl_light_name}; + border-color: {hl_name}; + }} + QPushButton:disabled {{ + color: {dim_color}; + border-color: {mid_color}; + background: {window_color}; + }} + """ + + # ── Device Toolbar ────────────────────────────────── device_layout = QHBoxLayout() - device_label = QLabel("iPod Device:") + device_layout.setSpacing(6) + + device_label = QLabel("Device:") + device_label.setStyleSheet(f"color: {text_color}; font-size: 12px; font-weight: bold;") + device_layout.addWidget(device_label) + self.device_combo = QComboBox() self.device_combo.setPlaceholderText("No devices found") + self.device_combo.setMinimumWidth(200) self.device_combo.currentIndexChanged.connect(self._on_device_combo_changed) - refresh_button = QPushButton("Refresh") - refresh_button.clicked.connect(self._on_refresh_devices_clicked) - device_layout.addWidget(device_label) device_layout.addWidget(self.device_combo) - device_layout.addWidget(refresh_button) - layout.addLayout(device_layout) - mount_layout = QHBoxLayout() - self.mount_button = QPushButton("Mount") + self.refresh_button = QPushButton("\U0001F504 Refresh") + self.refresh_button.setStyleSheet(btn_style) + self.refresh_button.clicked.connect(self._on_refresh_devices_clicked) + device_layout.addWidget(self.refresh_button) + + device_layout.addStretch() + + self.mount_button = QPushButton("\U0001F50C Mount") + self.mount_button.setStyleSheet(btn_style) self.mount_button.setEnabled(False) self.mount_button.clicked.connect(self._on_mount_clicked) - self.eject_button = QPushButton("Eject") + device_layout.addWidget(self.mount_button) + + self.eject_button = QPushButton("\u23CF Eject") + self.eject_button.setStyleSheet(btn_style) self.eject_button.setEnabled(False) self.eject_button.clicked.connect(self._on_eject_clicked) - mount_layout.addWidget(self.mount_button) - mount_layout.addWidget(self.eject_button) - mount_layout.addStretch() - layout.addLayout(mount_layout) + device_layout.addWidget(self.eject_button) + layout.addLayout(device_layout) + + # ── Separator ─────────────────────────────────────── + sep1 = QFrame() + sep1.setFrameShape(QFrame.Shape.HLine) + sep1.setStyleSheet(f"color: {mid_color}; margin: 2px 0;") + sep1.setFixedHeight(1) + layout.addWidget(sep1) + + # ── Device Info Bar ───────────────────────────────── self.device_info_label = QLabel("No device selected") + self.device_info_label.setStyleSheet(f"color: {dim_color}; font-size: 12px; padding: 2px 0;") layout.addWidget(self.device_info_label) self.device_status_label = QLabel("Status: Not connected") + self.device_status_label.setStyleSheet(f"color: {dim_color}; font-size: 11px; padding: 0 0 4px 0;") layout.addWidget(self.device_status_label) - self.track_count_label = QLabel("Tracks on device: 0") - layout.addWidget(self.track_count_label) + # ── Search toolbar ────────────────────────────────── + search_layout = QHBoxLayout() + search_layout.setSpacing(8) - on_device_label = QLabel("On Device:") - layout.addWidget(on_device_label) + self.track_count_label = QLabel("Tracks: 0") + self.track_count_label.setStyleSheet(f"color: {text_color}; font-size: 12px; font-weight: bold;") + search_layout.addWidget(self.track_count_label) - self.transferred_list = QListWidget() - self.transferred_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) - layout.addWidget(self.transferred_list) + search_layout.addStretch() - self._drop_label = QLabel("\u2B07 Drop tracks here to transfer to iPod") + self.ipod_search_input = QLineEdit() + self.ipod_search_input.setPlaceholderText("\U0001F50D Search artists, albums, tracks...") + self.ipod_search_input.setFixedWidth(280) + self.ipod_search_input.setStyleSheet(f""" + QLineEdit {{ + padding: 5px 10px; + border: 1px solid {mid_color}; + border-radius: 4px; + background: {base_color}; + color: {text_color}; + font-size: 12px; + }} + QLineEdit:focus {{ + border-color: {hl_name}; + }} + """) + self.ipod_search_input.textChanged.connect(self._on_search_text_changed) + search_layout.addWidget(self.ipod_search_input) + + layout.addLayout(search_layout) + + # ── Track Table ───────────────────────────────────── + self.track_table = DraggableIPodTable() + self.track_table.setColumnCount(7) + self.track_table.setHorizontalHeaderLabels( + ["#", "Artist", "Title", "Album", "Duration", "Genre", "Size"] + ) + self.track_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) + self.track_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) + self.track_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) + self.track_table.setAlternatingRowColors(True) + self.track_table.verticalHeader().setVisible(False) + self.track_table.setSortingEnabled(True) + self.track_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.track_table.customContextMenuRequested.connect(self._on_table_context_menu) + self.track_table.cellDoubleClicked.connect(self._on_table_double_clicked) + + self.track_table.setStyleSheet(f""" + QTableWidget {{ + background: {base_color}; + alternate-background-color: {window_color}; + border: 1px solid {mid_color}; + border-radius: 4px; + gridline-color: {mid_color}; + font-size: 12px; + color: {text_color}; + selection-background-color: {hl_light_name}; + selection-color: {text_color}; + }} + QTableWidget::item {{ + padding: 4px 8px; + }} + QTableWidget::item:selected {{ + background: {hl_light_name}; + color: {text_color}; + }} + QHeaderView::section {{ + background: {window_color}; + color: {dim_color}; + font-size: 11px; + font-weight: bold; + padding: 6px 8px; + border: none; + border-bottom: 1px solid {mid_color}; + border-right: 1px solid {mid_color}; + }} + QHeaderView::section:hover {{ + color: {text_color}; + background: {hl_light_name}; + }} + QHeaderView::down-arrow {{ + image: none; + }} + QHeaderView::up-arrow {{ + image: none; + }} + """) + + header = self.track_table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) + + self.track_table.horizontalHeader().sortIndicatorChanged.connect(self._on_sort_changed) + layout.addWidget(self.track_table, stretch=1) + + # ── Drop Zone ─────────────────────────────────────── + self._drop_label = QLabel("\u2B07 Drop tracks here to transfer to iPod") self._drop_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self._drop_label.setFixedHeight(48) - hl = self.palette().color(self.palette().ColorRole.Highlight) + self._drop_label.setFixedHeight(44) self._drop_label.setStyleSheet( - f"background: {hl.name()}20; border: 2px dashed {hl.name()}; " - f"border-radius: 8px; font-weight: bold; color: {hl.name()};" + f"background: {hl_light_name}; border: 2px dashed {hl_name}; " + f"border-radius: 8px; font-weight: bold; color: {hl_name}; font-size: 13px;" ) self._drop_label.setVisible(False) layout.addWidget(self._drop_label) - remove_button = QPushButton("Remove Selected from iPod") - remove_button.clicked.connect(self._on_remove_tracks_clicked) - layout.addWidget(remove_button) + # ── Action Toolbar ────────────────────────────────── + action_layout = QHBoxLayout() + action_layout.setSpacing(6) - orphan_button = QPushButton("Scan for Orphaned Files") - orphan_button.clicked.connect(self._on_scan_orphans_clicked) - layout.addWidget(orphan_button) + self.remove_button = QPushButton("\U0001F5D1 Remove from iPod") + self.remove_button.setStyleSheet(btn_style) + self.remove_button.clicked.connect(self._on_remove_tracks_clicked) + action_layout.addWidget(self.remove_button) - dedup_button = QPushButton("Remove Duplicate Tracks") - dedup_button.clicked.connect(self._on_remove_duplicates_clicked) - layout.addWidget(dedup_button) - - self.export_button = QPushButton("\u2B07 Download to Computer...") + self.export_button = QPushButton("\u2B07 Download to Computer...") self.export_button.setEnabled(False) + self.export_button.setStyleSheet(btn_style) self.export_button.clicked.connect(self._on_export_ipod_clicked) - layout.addWidget(self.export_button) + action_layout.addWidget(self.export_button) + + action_layout.addStretch() + + self.orphan_button = QPushButton("\U0001F4C2 Scan Orphans") + self.orphan_button.setStyleSheet(btn_style) + self.orphan_button.clicked.connect(self._on_scan_orphans_clicked) + action_layout.addWidget(self.orphan_button) + + self.dedup_button = QPushButton("\U0001F501 Remove Duplicates") + self.dedup_button.setStyleSheet(btn_style) + self.dedup_button.clicked.connect(self._on_remove_duplicates_clicked) + action_layout.addWidget(self.dedup_button) + + layout.addLayout(action_layout) + + # ── Progress Layout ───────────────────────────────── + progress_layout = QVBoxLayout() + progress_layout.setSpacing(4) self.export_progress = QProgressBar() self.export_progress.setRange(0, 100) self.export_progress.setVisible(False) - layout.addWidget(self.export_progress) + self.export_progress.setFixedHeight(18) + self.export_progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {mid_color}; + border-radius: 4px; + background: {window_color}; + text-align: center; + font-size: 11px; + color: {text_color}; + }} + QProgressBar::chunk {{ + background: {hl_name}; + border-radius: 3px; + }} + """) + progress_layout.addWidget(self.export_progress) self.export_status = QLabel("") self.export_status.setVisible(False) - layout.addWidget(self.export_status) + self.export_status.setStyleSheet(f"color: {dim_color}; font-size: 11px;") + progress_layout.addWidget(self.export_status) self.delete_progress = QProgressBar() self.delete_progress.setRange(0, 100) self.delete_progress.setVisible(False) - layout.addWidget(self.delete_progress) + self.delete_progress.setFixedHeight(18) + self.delete_progress.setStyleSheet(f""" + QProgressBar {{ + border: 1px solid {mid_color}; + border-radius: 4px; + background: {window_color}; + text-align: center; + font-size: 11px; + color: {text_color}; + }} + QProgressBar::chunk {{ + background: {hl_name}; + border-radius: 3px; + }} + """) + progress_layout.addWidget(self.delete_progress) self.delete_status = QLabel("") self.delete_status.setVisible(False) - layout.addWidget(self.delete_status) + self.delete_status.setStyleSheet(f"color: {dim_color}; font-size: 11px;") + progress_layout.addWidget(self.delete_status) + layout.addLayout(progress_layout) + + # ── Drag / Drop ─────────────────────────────────────────── def dragEnterEvent(self, event: QDragEnterEvent): if event.mimeData().hasUrls(): self._drag_hover_active = True @@ -162,11 +424,11 @@ class iPodTab(QWidget): else: super().dropEvent(event) + # ── Device Discovery ────────────────────────────────────── def _on_refresh_devices_clicked(self): if self._is_worker_running(): return - self.device_combo.clear() self.device_info_label.setText("Detecting devices...") self.worker_thread = WorkerThread(task_type="detect_devices") @@ -205,7 +467,7 @@ class iPodTab(QWidget): self._set_device_not_found() def _start_mount_and_load(self, device: dict, mount_point=None, already_mounted=False): - self.device_status_label.setText("Mounting and loading tracks...") + self.device_status_label.setText("Status: Mounting and loading tracks...") self.mount_button.setEnabled(False) self.worker_thread = WorkerThread( @@ -241,29 +503,30 @@ class iPodTab(QWidget): total_space = device_info.get("total_space", 0) self.device_info_label.setText( - f"Device: {device['name']}\n" - f"Mount Point: {mount_point}\n" - f"Free Space: {free_space / 1024**2:.1f} MB / {total_space / 1024**2:.1f} MB" + f"Device: {device['name']} \u00B7 " + f"Mount: {mount_point} \u00B7 " + f"Free: {free_space / 1024**2:.1f} MB / {total_space / 1024**2:.1f} MB" ) self.device_status_label.setText("Status: Mounted and ready") + self.device_status_label.setStyleSheet( + f"color: {self.palette().color(self.palette().ColorRole.Highlight).name()}; " + f"font-size: 11px; font-weight: bold; padding: 0 0 4px 0;" + ) self.mount_button.setEnabled(False) self.eject_button.setEnabled(True) self.device_mounted.emit(mount_point) - self.transferred_list.clear() - for track in tracks: - display = f"{track['artist']} — {track['title']}" - if track.get("album"): - display += f" ({track['album']})" - item = QListWidgetItem(display) - item.setData(Qt.ItemDataRole.UserRole, track) - self.transferred_list.addItem(item) + self._populate_table(tracks) - self.track_count_label.setText(f"Tracks on device: {len(tracks)}") + self.track_count_label.setText(f"Tracks: {len(tracks)}") self.export_button.setEnabled(len(tracks) > 0) else: self.device_status_label.setText(f"Status: {message}") + self.device_status_label.setStyleSheet( + f"color: {self.palette().color(self.palette().ColorRole.PlaceholderText).name()}; " + f"font-size: 11px; padding: 0 0 4px 0;" + ) self.mount_button.setEnabled(True) if self.ipod_devices: self._set_device_unmounted(self.ipod_devices[0]) @@ -272,17 +535,71 @@ class iPodTab(QWidget): self._cleanup_worker() + def _populate_table(self, tracks: List[Dict]): + self.track_table.setSortingEnabled(False) + self.track_table.setRowCount(0) + + for track in tracks: + row = self.track_table.rowCount() + self.track_table.insertRow(row) + + track_num = track.get("track_number", 0) or 0 + duration_ms = track.get("duration_ms", 0) or 0 + file_size = track.get("file_size", 0) or 0 + play_count = track.get("play_count", 0) or 0 + + num_item = NumericTableItem(str(track_num) if track_num else "") + num_item.setData(Qt.ItemDataRole.UserRole, track) + + cells = [ + num_item, + QTableWidgetItem(track.get("artist", "Unknown")), + QTableWidgetItem(track.get("title", "Unknown")), + QTableWidgetItem(track.get("album", "") or ""), + QTableWidgetItem(self._format_duration(duration_ms)), + QTableWidgetItem(track.get("genre", "") or ""), + QTableWidgetItem(self._format_size(file_size)), + ] + + for col, item in enumerate(cells): + if col == 0: + continue + self.track_table.setItem(row, col, item) + + self.track_table.setItem(row, 0, num_item) + + self.track_table.setSortingEnabled(True) + self.ipod_search_input.clear() + + def _format_duration(self, duration_ms: int) -> str: + if duration_ms <= 0: + return "--:--" + total_s = int(duration_ms / 1000) + m, s = divmod(total_s, 60) + return f"{m}:{s:02d}" + + def _format_size(self, size_bytes: int) -> str: + if size_bytes <= 0: + return "" + if size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.0f} KB" + return f"{size_bytes / (1024 * 1024):.1f} MB" + + # ── Mount / Eject ───────────────────────────────────────── def _set_device_unmounted(self, device: dict): self.current_mount_point = None self.device_info_label.setText( - f"Device: {device['name']}\n" - f"Click 'Mount' to connect" + f"Device: {device['name']}\nClick 'Mount' to connect" ) self.device_status_label.setText("Status: Connected but not mounted") + self.device_status_label.setStyleSheet( + f"color: {self.palette().color(self.palette().ColorRole.PlaceholderText).name()}; " + f"font-size: 11px; padding: 0 0 4px 0;" + ) self.mount_button.setEnabled(True) self.eject_button.setEnabled(False) - self.track_count_label.setText("Tracks on device: 0") - self.transferred_list.clear() + self.track_count_label.setText("Tracks: 0") + self.track_table.setRowCount(0) self.export_button.setEnabled(False) self.export_progress.setVisible(False) self.export_status.setVisible(False) @@ -293,11 +610,15 @@ class iPodTab(QWidget): self.current_mount_point = None self.device_info_label.setText("No iPod devices found") self.device_status_label.setText("Status: Not connected") + self.device_status_label.setStyleSheet( + f"color: {self.palette().color(self.palette().ColorRole.PlaceholderText).name()}; " + f"font-size: 11px; padding: 0 0 4px 0;" + ) self.device_combo.clear() self.mount_button.setEnabled(False) self.eject_button.setEnabled(False) - self.track_count_label.setText("Tracks on device: 0") - self.transferred_list.clear() + self.track_count_label.setText("Tracks: 0") + self.track_table.setRowCount(0) self.export_button.setEnabled(False) self.export_progress.setVisible(False) self.export_status.setVisible(False) @@ -323,7 +644,7 @@ class iPodTab(QWidget): if reply != QMessageBox.StandardButton.Yes: return - self.device_status_label.setText("Ejecting...") + self.device_status_label.setText("Status: Ejecting...") self.eject_button.setEnabled(False) try: @@ -342,21 +663,131 @@ class iPodTab(QWidget): self.device_combo.setItemData(idx, self.ipod_devices[0], Qt.ItemDataRole.UserRole) self.device_info_label.setText("iPod ejected. You can now disconnect it.") - self.device_status_label.setText("Status: Ejected - safe to disconnect") + self.device_status_label.setText("Status: Ejected -- safe to disconnect") + self.device_status_label.setStyleSheet( + f"color: {self.palette().color(self.palette().ColorRole.PlaceholderText).name()}; " + f"font-size: 11px; padding: 0 0 4px 0;" + ) self.mount_button.setEnabled(False) self.eject_button.setEnabled(False) - self.track_count_label.setText("Tracks on device: 0") - self.transferred_list.clear() + self.track_count_label.setText("Tracks: 0") + self.track_table.setRowCount(0) self.device_unmounted.emit() else: self.device_status_label.setText("Status: Eject failed") + self.device_status_label.setStyleSheet( + f"color: {self.palette().color(self.palette().ColorRole.PlaceholderText).name()}; " + f"font-size: 11px; padding: 0 0 4px 0;" + ) self.eject_button.setEnabled(True) QMessageBox.warning(self, "Eject Error", "Failed to eject iPod safely.") except Exception as e: self.device_status_label.setText("Status: Eject error") + self.device_status_label.setStyleSheet( + f"color: {self.palette().color(self.palette().ColorRole.PlaceholderText).name()}; " + f"font-size: 11px; padding: 0 0 4px 0;" + ) self.eject_button.setEnabled(True) QMessageBox.warning(self, "Eject Error", f"Error ejecting iPod: {e}") + # ── Search / Filter ─────────────────────────────────────── + def _on_search_text_changed(self, text: str): + query = text.lower() + for row in range(self.track_table.rowCount()): + if not query: + self.track_table.setRowHidden(row, False) + continue + match = False + for col in (1, 2, 3): + item = self.track_table.item(row, col) + if item and query in item.text().lower(): + match = True + break + self.track_table.setRowHidden(row, not match) + + visible = 0 + for row in range(self.track_table.rowCount()): + if not self.track_table.isRowHidden(row): + visible += 1 + total = self.track_table.rowCount() + if query: + self.track_count_label.setText(f"Tracks: {visible} / {total}") + else: + self.track_count_label.setText(f"Tracks: {total}") + + def _on_sort_changed(self, _column: int, _order: int): + pass + + # ── Context Menu ────────────────────────────────────────── + def _on_table_context_menu(self, pos): + item = self.track_table.itemAt(pos) + if item is None: + return + row = item.row() + data_item = self.track_table.item(row, 0) + if data_item is None: + return + track_data = data_item.data(Qt.ItemDataRole.UserRole) + + menu = QMenu(self) + menu.setStyleSheet(f""" + QMenu {{ + background: {self.palette().color(self.palette().ColorRole.Window).name()}; + border: 1px solid {self.palette().color(self.palette().ColorRole.Midlight).name()}; + border-radius: 4px; + padding: 4px; + font-size: 12px; + color: {self.palette().color(self.palette().ColorRole.WindowText).name()}; + }} + QMenu::item {{ + padding: 6px 24px; + border-radius: 3px; + }} + QMenu::item:selected {{ + background: {self.palette().color(self.palette().ColorRole.Highlight).name()}; + color: {self.palette().color(self.palette().ColorRole.HighlightedText).name()}; + }} + """) + + play_action = menu.addAction("\u25B6 Play") + export_action = menu.addAction("\u2B07 Download to Computer...") + menu.addSeparator() + remove_action = menu.addAction("\U0001F5D1 Remove from iPod") + + action = menu.exec(self.track_table.viewport().mapToGlobal(pos)) + if action == play_action: + if track_data and track_data.get("file_path") and os.path.exists(track_data["file_path"]): + self._play_track(track_data) + elif action == export_action: + self._on_export_ipod_clicked() + elif action == remove_action: + self._on_remove_tracks_clicked() + + def _on_table_double_clicked(self, row: int): + data_item = self.track_table.item(row, 0) + if data_item is None: + return + track_data = data_item.data(Qt.ItemDataRole.UserRole) + if track_data and track_data.get("file_path") and os.path.exists(track_data["file_path"]): + self._play_track(track_data) + + def _play_track(self, track_data: dict): + from PyQt6.QtMultimedia import QMediaPlayer + path = track_data["file_path"] + parent = self.parent() + while parent is not None: + if hasattr(parent, "player") and hasattr(parent, "player_header"): + player: QMediaPlayer = parent.player + player.setSource(QUrl.fromLocalFile(path)) + player.play() + parent.player_header.set_playing(True) + parent.player_header.set_track_info( + f"{track_data.get('artist', 'Unknown')} \u2014 {track_data.get('title', 'Unknown')}" + ) + break + parent = parent.parent() + + # ── Load tracks ────────────────────────────────────────── def _load_ipod_tracks(self): if not self.current_mount_point: return @@ -366,28 +797,25 @@ class iPodTab(QWidget): db = Nano7Database(self.current_mount_point) tracks = db.get_all_tracks() - self.transferred_list.clear() - for track in tracks: - display = f"{track['artist']} — {track['title']}" - if track.get("album"): - display += f" ({track['album']})" - item = QListWidgetItem(display) - item.setData(Qt.ItemDataRole.UserRole, track) - self.transferred_list.addItem(item) + self._populate_table(tracks) - self.track_count_label.setText(f"Tracks on device: {len(tracks)}") + self.track_count_label.setText(f"Tracks: {len(tracks)}") self.export_button.setEnabled(len(tracks) > 0) except Exception as e: logger.error(f"Failed to load iPod tracks: {e}") - self.track_count_label.setText("Tracks on device: unknown") + self.track_count_label.setText("Tracks: unknown") + # ── Remove Tracks ───────────────────────────────────────── def _on_remove_tracks_clicked(self): - selected = self.transferred_list.selectedItems() - if not selected: + selected_rows = set() + for item in self.track_table.selectedItems(): + selected_rows.add(item.row()) + + if not selected_rows: QMessageBox.information(self, "Info", "Select tracks to remove") return - count = len(selected) + count = len(selected_rows) reply = QMessageBox.question( self, "Confirm Delete", f"Remove {count} track(s) from iPod?\nThis will permanently delete the files from the device.", @@ -401,9 +829,11 @@ class iPodTab(QWidget): return pids = [] - - for item in selected: - track_data = item.data(Qt.ItemDataRole.UserRole) + for row in selected_rows: + data_item = self.track_table.item(row, 0) + if data_item is None: + continue + track_data = data_item.data(Qt.ItemDataRole.UserRole) if track_data and "pid" in track_data: pids.append(track_data["pid"]) @@ -411,7 +841,7 @@ class iPodTab(QWidget): QMessageBox.warning(self, "Error", "No valid tracks selected") return - self._set_delete_buttons_enabled(False) + self._set_action_buttons_enabled(False) self.delete_progress.setVisible(True) self.delete_progress.setValue(0) self.delete_status.setVisible(True) @@ -426,31 +856,35 @@ class iPodTab(QWidget): self.worker_thread.finished_signal.connect(self._on_delete_finished) self.worker_thread.start() - def _set_delete_buttons_enabled(self, enabled: bool): - for w in self.findChildren(QPushButton): - if w.text() in ("Remove Selected from iPod", "Remove Duplicate Tracks", - "Scan for Orphaned Files", "\u2B07 Download to Computer..."): - w.setEnabled(enabled) + def _set_action_buttons_enabled(self, enabled: bool): + self.remove_button.setEnabled(enabled) + self.export_button.setEnabled(enabled) + self.orphan_button.setEnabled(enabled) + self.dedup_button.setEnabled(enabled) def _on_delete_progress(self, progress, status): self.delete_progress.setValue(progress) self.delete_status.setText(status) def _on_delete_finished(self, success, message, result): - self._set_delete_buttons_enabled(True) + self._set_action_buttons_enabled(True) if success: self.delete_progress.setValue(100) self._load_ipod_tracks() QMessageBox.information(self, "Done" if success else "Error", message) self._cleanup_worker() + # ── Export ───────────────────────────────────────────────── def _on_export_ipod_clicked(self): if not self.current_mount_point: QMessageBox.warning(self, "Error", "No iPod device mounted.") return - selected = self.transferred_list.selectedItems() - if not selected: + selected_rows = set() + for item in self.track_table.selectedItems(): + selected_rows.add(item.row()) + + if not selected_rows: QMessageBox.information(self, "Info", "Select tracks to download") return @@ -459,8 +893,11 @@ class iPodTab(QWidget): return tracks = [] - for item in selected: - data = item.data(Qt.ItemDataRole.UserRole) + for row in selected_rows: + data_item = self.track_table.item(row, 0) + if data_item is None: + continue + data = data_item.data(Qt.ItemDataRole.UserRole) if data and data.get("file_path") and os.path.exists(data["file_path"]): tracks.append(data) @@ -505,6 +942,7 @@ class iPodTab(QWidget): self._cleanup_worker() + # ── Orphans ──────────────────────────────────────────────── def _on_scan_orphans_clicked(self): if not self.current_mount_point: QMessageBox.warning(self, "Error", "No iPod device mounted.") @@ -525,7 +963,7 @@ class iPodTab(QWidget): details = f"Found {len(orphans)} orphaned file(s) occupying {total_mb:.1f} MB:\n\n" for o in orphans[:20]: size_kb = o["size"] / 1024 - details += f" {o['artist']} — {o['title']} ({size_kb:.0f} KB)\n" + details += f" {o['artist']} -- {o['title']} ({size_kb:.0f} KB)\n" if len(orphans) > 20: details += f" ... and {len(orphans) - 20} more\n" @@ -542,6 +980,7 @@ class iPodTab(QWidget): logger.error(f"Failed to scan for orphaned files: {e}") QMessageBox.warning(self, "Error", f"Failed to scan: {e}") + # ── Duplicates ───────────────────────────────────────────── def _on_remove_duplicates_clicked(self): if not self.current_mount_point: QMessageBox.warning(self, "Error", "No iPod device mounted.") @@ -551,7 +990,7 @@ class iPodTab(QWidget): QMessageBox.information(self, "Info", "Please wait for the current task to finish") return - self._set_delete_buttons_enabled(False) + self._set_action_buttons_enabled(False) self.delete_progress.setVisible(True) self.delete_progress.setValue(0) self.delete_status.setVisible(True) @@ -566,13 +1005,14 @@ class iPodTab(QWidget): self.worker_thread.start() def _on_delete_duplicates_finished(self, success, message, result): - self._set_delete_buttons_enabled(True) + self._set_action_buttons_enabled(True) self._load_ipod_tracks() if success: self.delete_progress.setValue(100) QMessageBox.information(self, "Duplicates" if success else "Error", message) self._cleanup_worker() + # ── Monitoring ───────────────────────────────────────────── def start_monitoring(self, monitor: DeviceMonitor): monitor.state_changed.connect(self._on_auto_state_changed)