feat: modernize iPod tab with table view, search, and sorting, fix LibraryCache API

This commit is contained in:
Maksim Totmin 2026-06-01 22:11:36 +07:00
parent 597603f90e
commit 2d77b3ea3e
2 changed files with 621 additions and 113 deletions

View File

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

View File

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