feat: modernize iPod tab with table view, search, and sorting, fix LibraryCache API
This commit is contained in:
parent
597603f90e
commit
2d77b3ea3e
@ -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)
|
||||
|
||||
@ -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.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)
|
||||
|
||||
dedup_button = QPushButton("Remove Duplicate Tracks")
|
||||
dedup_button.clicked.connect(self._on_remove_duplicates_clicked)
|
||||
layout.addWidget(dedup_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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user