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])
|
ds = str(val[0])
|
||||||
if len(ds) >= 4:
|
if len(ds) >= 4:
|
||||||
info["year"] = int(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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
elif ext in (".m4a", ".aac", ".mp4", ".m4p"):
|
elif ext in (".m4a", ".aac", ".mp4", ".m4p"):
|
||||||
@ -228,6 +238,14 @@ def _read_audio_tags(filepath: str) -> dict[str, Any]:
|
|||||||
tr = t["trkn"]
|
tr = t["trkn"]
|
||||||
if isinstance(tr, list) and tr and isinstance(tr[0], tuple):
|
if isinstance(tr, list) and tr and isinstance(tr[0], tuple):
|
||||||
info["track_number"] = tr[0][0]
|
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:
|
else:
|
||||||
if audio and audio.tags:
|
if audio and audio.tags:
|
||||||
for tag_k, dest in [("title", "title"), ("artist", "artist"),
|
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)
|
info[dest] = str(v[0]) if isinstance(v, list) and v else str(v)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
pass
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return info
|
return info
|
||||||
@ -329,6 +356,7 @@ def _scanned_tracks_to_iop(mountpoint: str,
|
|||||||
sample_count=sample_count,
|
sample_count=sample_count,
|
||||||
gapless_track_flag=1,
|
gapless_track_flag=1,
|
||||||
gapless_album_flag=1 if has_album else 0,
|
gapless_album_flag=1 if has_album else 0,
|
||||||
|
compilation_flag=s.get("compilation", False),
|
||||||
play_count=s.get("play_count", 0),
|
play_count=s.get("play_count", 0),
|
||||||
rating=s.get("rating", 0),
|
rating=s.get("rating", 0),
|
||||||
last_played=s.get("last_played", 0),
|
last_played=s.get("last_played", 0),
|
||||||
@ -493,8 +521,10 @@ class Nano7Database:
|
|||||||
"artist": s.get("artist") or "Unknown Artist",
|
"artist": s.get("artist") or "Unknown Artist",
|
||||||
"album": s.get("album") or "",
|
"album": s.get("album") or "",
|
||||||
"album_artist": s.get("album_artist") or "",
|
"album_artist": s.get("album_artist") or "",
|
||||||
|
"genre": s.get("genre", ""),
|
||||||
"duration_ms": s.get("duration_ms", 0),
|
"duration_ms": s.get("duration_ms", 0),
|
||||||
"track_number": s.get("track_number", 0),
|
"track_number": s.get("track_number", 0),
|
||||||
|
"file_size": s.get("file_size", 0),
|
||||||
"disc_number": s.get("disc_number", 0),
|
"disc_number": s.get("disc_number", 0),
|
||||||
"physical_order": 0,
|
"physical_order": 0,
|
||||||
"date_modified": 0,
|
"date_modified": 0,
|
||||||
@ -748,34 +778,78 @@ class Nano7Database:
|
|||||||
"""Merge iPod + local play stats into *scanned* track dicts in-place.
|
"""Merge iPod + local play stats into *scanned* track dicts in-place.
|
||||||
|
|
||||||
Uses additive model (like iTunes):
|
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()
|
base_stats, delta_stats = self.read_play_stats()
|
||||||
local_play_stats = self._load_local_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:
|
for s in scanned:
|
||||||
rel = os.path.relpath(s["file_path"], self.music_base)
|
rel = os.path.relpath(s["file_path"], self.music_base)
|
||||||
location = f":iPod_Control:Music:{rel.replace(os.sep, ':')}"
|
location = f":iPod_Control:Music:{rel.replace(os.sep, ':')}"
|
||||||
bs = base_stats.get(location, {})
|
bs = base_stats.get(location, {})
|
||||||
dd = delta_stats.get(location, {})
|
dd = delta_stats.get(location, {})
|
||||||
ch = s.get("content_hash", "")
|
ch = s.get("content_hash", "")
|
||||||
|
|
||||||
|
# Three-tier fallback: iTunesDB → LibraryCache (SQLite) → old JSON cache
|
||||||
local_pc = local_play_stats.get(ch, {})
|
local_pc = local_play_stats.get(ch, {})
|
||||||
|
db_entry = db_store.get_by_content_hash(ch) if ch else None
|
||||||
|
|
||||||
s["play_count"] = max(
|
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)
|
) + dd.get("play_count", 0)
|
||||||
|
|
||||||
s["skip_count"] = max(
|
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.get("skip_count", 0)
|
||||||
|
|
||||||
dd_rating = dd.get("rating", 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(
|
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:
|
def _cleanup_play_counts_file(self) -> None:
|
||||||
"""Delete the iPod 'Play Counts' delta file after a full sync."""
|
"""Delete the iPod 'Play Counts' delta file after a full sync."""
|
||||||
pc_file = os.path.join(
|
pc_file = os.path.join(
|
||||||
@ -788,12 +862,6 @@ class Nano7Database:
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.debug("Could not delete Play Counts file: %s", 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:
|
def _sync_itunescdb_impl(self) -> None:
|
||||||
scanned = self._scan_ipod_files()
|
scanned = self._scan_ipod_files()
|
||||||
if not scanned:
|
if not scanned:
|
||||||
@ -912,8 +980,8 @@ class Nano7Database:
|
|||||||
from library_cache import get_library_cache
|
from library_cache import get_library_cache
|
||||||
cache = get_library_cache()
|
cache = get_library_cache()
|
||||||
result: dict[str, dict[str, int]] = {}
|
result: dict[str, dict[str, int]] = {}
|
||||||
for entry in cache._data.values():
|
for _path, entry in cache.iter_entries():
|
||||||
ch = entry.get("_content_hash", "")
|
ch = entry.get("content_hash", "")
|
||||||
pc = entry.get("play_count", 0)
|
pc = entry.get("play_count", 0)
|
||||||
rating = entry.get("rating", 0)
|
rating = entry.get("rating", 0)
|
||||||
lp = entry.get("last_played", 0)
|
lp = entry.get("last_played", 0)
|
||||||
|
|||||||
@ -1,15 +1,19 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional, Any
|
||||||
|
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QWidget, QVBoxLayout, QHBoxLayout,
|
QWidget, QVBoxLayout, QHBoxLayout,
|
||||||
QPushButton, QLabel, QProgressBar,
|
QPushButton, QLabel, QProgressBar,
|
||||||
QComboBox, QListWidget, QListWidgetItem, QMessageBox,
|
QComboBox, QMessageBox,
|
||||||
QFileDialog,
|
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 ipod_device import IPodDevice
|
||||||
from device_monitor import DeviceMonitor
|
from device_monitor import DeviceMonitor
|
||||||
@ -18,6 +22,60 @@ from worker import WorkerThread
|
|||||||
logger = logging.getLogger(__name__)
|
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):
|
class iPodTab(QWidget):
|
||||||
|
|
||||||
device_mounted = pyqtSignal(str)
|
device_mounted = pyqtSignal(str)
|
||||||
@ -35,93 +93,297 @@ class iPodTab(QWidget):
|
|||||||
|
|
||||||
def _setup_ui(self):
|
def _setup_ui(self):
|
||||||
layout = QVBoxLayout(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_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 = QComboBox()
|
||||||
self.device_combo.setPlaceholderText("No devices found")
|
self.device_combo.setPlaceholderText("No devices found")
|
||||||
|
self.device_combo.setMinimumWidth(200)
|
||||||
self.device_combo.currentIndexChanged.connect(self._on_device_combo_changed)
|
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(self.device_combo)
|
||||||
device_layout.addWidget(refresh_button)
|
|
||||||
layout.addLayout(device_layout)
|
|
||||||
|
|
||||||
mount_layout = QHBoxLayout()
|
self.refresh_button = QPushButton("\U0001F504 Refresh")
|
||||||
self.mount_button = QPushButton("Mount")
|
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.setEnabled(False)
|
||||||
self.mount_button.clicked.connect(self._on_mount_clicked)
|
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.setEnabled(False)
|
||||||
self.eject_button.clicked.connect(self._on_eject_clicked)
|
self.eject_button.clicked.connect(self._on_eject_clicked)
|
||||||
mount_layout.addWidget(self.mount_button)
|
device_layout.addWidget(self.eject_button)
|
||||||
mount_layout.addWidget(self.eject_button)
|
|
||||||
mount_layout.addStretch()
|
|
||||||
layout.addLayout(mount_layout)
|
|
||||||
|
|
||||||
|
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 = 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)
|
layout.addWidget(self.device_info_label)
|
||||||
|
|
||||||
self.device_status_label = QLabel("Status: Not connected")
|
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)
|
layout.addWidget(self.device_status_label)
|
||||||
|
|
||||||
self.track_count_label = QLabel("Tracks on device: 0")
|
# ── Search toolbar ──────────────────────────────────
|
||||||
layout.addWidget(self.track_count_label)
|
search_layout = QHBoxLayout()
|
||||||
|
search_layout.setSpacing(8)
|
||||||
|
|
||||||
on_device_label = QLabel("On Device:")
|
self.track_count_label = QLabel("Tracks: 0")
|
||||||
layout.addWidget(on_device_label)
|
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()
|
search_layout.addStretch()
|
||||||
self.transferred_list.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection)
|
|
||||||
layout.addWidget(self.transferred_list)
|
|
||||||
|
|
||||||
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.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self._drop_label.setFixedHeight(48)
|
self._drop_label.setFixedHeight(44)
|
||||||
hl = self.palette().color(self.palette().ColorRole.Highlight)
|
|
||||||
self._drop_label.setStyleSheet(
|
self._drop_label.setStyleSheet(
|
||||||
f"background: {hl.name()}20; border: 2px dashed {hl.name()}; "
|
f"background: {hl_light_name}; border: 2px dashed {hl_name}; "
|
||||||
f"border-radius: 8px; font-weight: bold; color: {hl.name()};"
|
f"border-radius: 8px; font-weight: bold; color: {hl_name}; font-size: 13px;"
|
||||||
)
|
)
|
||||||
self._drop_label.setVisible(False)
|
self._drop_label.setVisible(False)
|
||||||
layout.addWidget(self._drop_label)
|
layout.addWidget(self._drop_label)
|
||||||
|
|
||||||
remove_button = QPushButton("Remove Selected from iPod")
|
# ── Action Toolbar ──────────────────────────────────
|
||||||
remove_button.clicked.connect(self._on_remove_tracks_clicked)
|
action_layout = QHBoxLayout()
|
||||||
layout.addWidget(remove_button)
|
action_layout.setSpacing(6)
|
||||||
|
|
||||||
orphan_button = QPushButton("Scan for Orphaned Files")
|
self.remove_button = QPushButton("\U0001F5D1 Remove from iPod")
|
||||||
orphan_button.clicked.connect(self._on_scan_orphans_clicked)
|
self.remove_button.setStyleSheet(btn_style)
|
||||||
layout.addWidget(orphan_button)
|
self.remove_button.clicked.connect(self._on_remove_tracks_clicked)
|
||||||
|
action_layout.addWidget(self.remove_button)
|
||||||
|
|
||||||
dedup_button = QPushButton("Remove Duplicate Tracks")
|
self.export_button = QPushButton("\u2B07 Download to Computer...")
|
||||||
dedup_button.clicked.connect(self._on_remove_duplicates_clicked)
|
|
||||||
layout.addWidget(dedup_button)
|
|
||||||
|
|
||||||
self.export_button = QPushButton("\u2B07 Download to Computer...")
|
|
||||||
self.export_button.setEnabled(False)
|
self.export_button.setEnabled(False)
|
||||||
|
self.export_button.setStyleSheet(btn_style)
|
||||||
self.export_button.clicked.connect(self._on_export_ipod_clicked)
|
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 = QProgressBar()
|
||||||
self.export_progress.setRange(0, 100)
|
self.export_progress.setRange(0, 100)
|
||||||
self.export_progress.setVisible(False)
|
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 = QLabel("")
|
||||||
self.export_status.setVisible(False)
|
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 = QProgressBar()
|
||||||
self.delete_progress.setRange(0, 100)
|
self.delete_progress.setRange(0, 100)
|
||||||
self.delete_progress.setVisible(False)
|
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 = QLabel("")
|
||||||
self.delete_status.setVisible(False)
|
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):
|
def dragEnterEvent(self, event: QDragEnterEvent):
|
||||||
if event.mimeData().hasUrls():
|
if event.mimeData().hasUrls():
|
||||||
self._drag_hover_active = True
|
self._drag_hover_active = True
|
||||||
@ -162,11 +424,11 @@ class iPodTab(QWidget):
|
|||||||
else:
|
else:
|
||||||
super().dropEvent(event)
|
super().dropEvent(event)
|
||||||
|
|
||||||
|
# ── Device Discovery ──────────────────────────────────────
|
||||||
def _on_refresh_devices_clicked(self):
|
def _on_refresh_devices_clicked(self):
|
||||||
if self._is_worker_running():
|
if self._is_worker_running():
|
||||||
return
|
return
|
||||||
|
|
||||||
self.device_combo.clear()
|
|
||||||
self.device_info_label.setText("Detecting devices...")
|
self.device_info_label.setText("Detecting devices...")
|
||||||
|
|
||||||
self.worker_thread = WorkerThread(task_type="detect_devices")
|
self.worker_thread = WorkerThread(task_type="detect_devices")
|
||||||
@ -205,7 +467,7 @@ class iPodTab(QWidget):
|
|||||||
self._set_device_not_found()
|
self._set_device_not_found()
|
||||||
|
|
||||||
def _start_mount_and_load(self, device: dict, mount_point=None, already_mounted=False):
|
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.mount_button.setEnabled(False)
|
||||||
|
|
||||||
self.worker_thread = WorkerThread(
|
self.worker_thread = WorkerThread(
|
||||||
@ -241,29 +503,30 @@ class iPodTab(QWidget):
|
|||||||
total_space = device_info.get("total_space", 0)
|
total_space = device_info.get("total_space", 0)
|
||||||
|
|
||||||
self.device_info_label.setText(
|
self.device_info_label.setText(
|
||||||
f"Device: {device['name']}\n"
|
f"Device: {device['name']} \u00B7 "
|
||||||
f"Mount Point: {mount_point}\n"
|
f"Mount: {mount_point} \u00B7 "
|
||||||
f"Free Space: {free_space / 1024**2:.1f} MB / {total_space / 1024**2:.1f} MB"
|
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.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.mount_button.setEnabled(False)
|
||||||
self.eject_button.setEnabled(True)
|
self.eject_button.setEnabled(True)
|
||||||
|
|
||||||
self.device_mounted.emit(mount_point)
|
self.device_mounted.emit(mount_point)
|
||||||
|
|
||||||
self.transferred_list.clear()
|
self._populate_table(tracks)
|
||||||
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.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)
|
self.export_button.setEnabled(len(tracks) > 0)
|
||||||
else:
|
else:
|
||||||
self.device_status_label.setText(f"Status: {message}")
|
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)
|
self.mount_button.setEnabled(True)
|
||||||
if self.ipod_devices:
|
if self.ipod_devices:
|
||||||
self._set_device_unmounted(self.ipod_devices[0])
|
self._set_device_unmounted(self.ipod_devices[0])
|
||||||
@ -272,17 +535,71 @@ class iPodTab(QWidget):
|
|||||||
|
|
||||||
self._cleanup_worker()
|
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):
|
def _set_device_unmounted(self, device: dict):
|
||||||
self.current_mount_point = None
|
self.current_mount_point = None
|
||||||
self.device_info_label.setText(
|
self.device_info_label.setText(
|
||||||
f"Device: {device['name']}\n"
|
f"Device: {device['name']}\nClick 'Mount' to connect"
|
||||||
f"Click 'Mount' to connect"
|
|
||||||
)
|
)
|
||||||
self.device_status_label.setText("Status: Connected but not mounted")
|
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.mount_button.setEnabled(True)
|
||||||
self.eject_button.setEnabled(False)
|
self.eject_button.setEnabled(False)
|
||||||
self.track_count_label.setText("Tracks on device: 0")
|
self.track_count_label.setText("Tracks: 0")
|
||||||
self.transferred_list.clear()
|
self.track_table.setRowCount(0)
|
||||||
self.export_button.setEnabled(False)
|
self.export_button.setEnabled(False)
|
||||||
self.export_progress.setVisible(False)
|
self.export_progress.setVisible(False)
|
||||||
self.export_status.setVisible(False)
|
self.export_status.setVisible(False)
|
||||||
@ -293,11 +610,15 @@ class iPodTab(QWidget):
|
|||||||
self.current_mount_point = None
|
self.current_mount_point = None
|
||||||
self.device_info_label.setText("No iPod devices found")
|
self.device_info_label.setText("No iPod devices found")
|
||||||
self.device_status_label.setText("Status: Not connected")
|
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.device_combo.clear()
|
||||||
self.mount_button.setEnabled(False)
|
self.mount_button.setEnabled(False)
|
||||||
self.eject_button.setEnabled(False)
|
self.eject_button.setEnabled(False)
|
||||||
self.track_count_label.setText("Tracks on device: 0")
|
self.track_count_label.setText("Tracks: 0")
|
||||||
self.transferred_list.clear()
|
self.track_table.setRowCount(0)
|
||||||
self.export_button.setEnabled(False)
|
self.export_button.setEnabled(False)
|
||||||
self.export_progress.setVisible(False)
|
self.export_progress.setVisible(False)
|
||||||
self.export_status.setVisible(False)
|
self.export_status.setVisible(False)
|
||||||
@ -323,7 +644,7 @@ class iPodTab(QWidget):
|
|||||||
if reply != QMessageBox.StandardButton.Yes:
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.device_status_label.setText("Ejecting...")
|
self.device_status_label.setText("Status: Ejecting...")
|
||||||
self.eject_button.setEnabled(False)
|
self.eject_button.setEnabled(False)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -342,21 +663,131 @@ class iPodTab(QWidget):
|
|||||||
self.device_combo.setItemData(idx, self.ipod_devices[0], Qt.ItemDataRole.UserRole)
|
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_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.mount_button.setEnabled(False)
|
||||||
self.eject_button.setEnabled(False)
|
self.eject_button.setEnabled(False)
|
||||||
self.track_count_label.setText("Tracks on device: 0")
|
self.track_count_label.setText("Tracks: 0")
|
||||||
self.transferred_list.clear()
|
self.track_table.setRowCount(0)
|
||||||
self.device_unmounted.emit()
|
self.device_unmounted.emit()
|
||||||
else:
|
else:
|
||||||
self.device_status_label.setText("Status: Eject failed")
|
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)
|
self.eject_button.setEnabled(True)
|
||||||
QMessageBox.warning(self, "Eject Error", "Failed to eject iPod safely.")
|
QMessageBox.warning(self, "Eject Error", "Failed to eject iPod safely.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.device_status_label.setText("Status: Eject error")
|
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)
|
self.eject_button.setEnabled(True)
|
||||||
QMessageBox.warning(self, "Eject Error", f"Error ejecting iPod: {e}")
|
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):
|
def _load_ipod_tracks(self):
|
||||||
if not self.current_mount_point:
|
if not self.current_mount_point:
|
||||||
return
|
return
|
||||||
@ -366,28 +797,25 @@ class iPodTab(QWidget):
|
|||||||
db = Nano7Database(self.current_mount_point)
|
db = Nano7Database(self.current_mount_point)
|
||||||
tracks = db.get_all_tracks()
|
tracks = db.get_all_tracks()
|
||||||
|
|
||||||
self.transferred_list.clear()
|
self._populate_table(tracks)
|
||||||
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.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)
|
self.export_button.setEnabled(len(tracks) > 0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load iPod tracks: {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):
|
def _on_remove_tracks_clicked(self):
|
||||||
selected = self.transferred_list.selectedItems()
|
selected_rows = set()
|
||||||
if not selected:
|
for item in self.track_table.selectedItems():
|
||||||
|
selected_rows.add(item.row())
|
||||||
|
|
||||||
|
if not selected_rows:
|
||||||
QMessageBox.information(self, "Info", "Select tracks to remove")
|
QMessageBox.information(self, "Info", "Select tracks to remove")
|
||||||
return
|
return
|
||||||
|
|
||||||
count = len(selected)
|
count = len(selected_rows)
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "Confirm Delete",
|
self, "Confirm Delete",
|
||||||
f"Remove {count} track(s) from iPod?\nThis will permanently delete the files from the device.",
|
f"Remove {count} track(s) from iPod?\nThis will permanently delete the files from the device.",
|
||||||
@ -401,9 +829,11 @@ class iPodTab(QWidget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
pids = []
|
pids = []
|
||||||
|
for row in selected_rows:
|
||||||
for item in selected:
|
data_item = self.track_table.item(row, 0)
|
||||||
track_data = item.data(Qt.ItemDataRole.UserRole)
|
if data_item is None:
|
||||||
|
continue
|
||||||
|
track_data = data_item.data(Qt.ItemDataRole.UserRole)
|
||||||
if track_data and "pid" in track_data:
|
if track_data and "pid" in track_data:
|
||||||
pids.append(track_data["pid"])
|
pids.append(track_data["pid"])
|
||||||
|
|
||||||
@ -411,7 +841,7 @@ class iPodTab(QWidget):
|
|||||||
QMessageBox.warning(self, "Error", "No valid tracks selected")
|
QMessageBox.warning(self, "Error", "No valid tracks selected")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._set_delete_buttons_enabled(False)
|
self._set_action_buttons_enabled(False)
|
||||||
self.delete_progress.setVisible(True)
|
self.delete_progress.setVisible(True)
|
||||||
self.delete_progress.setValue(0)
|
self.delete_progress.setValue(0)
|
||||||
self.delete_status.setVisible(True)
|
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.finished_signal.connect(self._on_delete_finished)
|
||||||
self.worker_thread.start()
|
self.worker_thread.start()
|
||||||
|
|
||||||
def _set_delete_buttons_enabled(self, enabled: bool):
|
def _set_action_buttons_enabled(self, enabled: bool):
|
||||||
for w in self.findChildren(QPushButton):
|
self.remove_button.setEnabled(enabled)
|
||||||
if w.text() in ("Remove Selected from iPod", "Remove Duplicate Tracks",
|
self.export_button.setEnabled(enabled)
|
||||||
"Scan for Orphaned Files", "\u2B07 Download to Computer..."):
|
self.orphan_button.setEnabled(enabled)
|
||||||
w.setEnabled(enabled)
|
self.dedup_button.setEnabled(enabled)
|
||||||
|
|
||||||
def _on_delete_progress(self, progress, status):
|
def _on_delete_progress(self, progress, status):
|
||||||
self.delete_progress.setValue(progress)
|
self.delete_progress.setValue(progress)
|
||||||
self.delete_status.setText(status)
|
self.delete_status.setText(status)
|
||||||
|
|
||||||
def _on_delete_finished(self, success, message, result):
|
def _on_delete_finished(self, success, message, result):
|
||||||
self._set_delete_buttons_enabled(True)
|
self._set_action_buttons_enabled(True)
|
||||||
if success:
|
if success:
|
||||||
self.delete_progress.setValue(100)
|
self.delete_progress.setValue(100)
|
||||||
self._load_ipod_tracks()
|
self._load_ipod_tracks()
|
||||||
QMessageBox.information(self, "Done" if success else "Error", message)
|
QMessageBox.information(self, "Done" if success else "Error", message)
|
||||||
self._cleanup_worker()
|
self._cleanup_worker()
|
||||||
|
|
||||||
|
# ── Export ─────────────────────────────────────────────────
|
||||||
def _on_export_ipod_clicked(self):
|
def _on_export_ipod_clicked(self):
|
||||||
if not self.current_mount_point:
|
if not self.current_mount_point:
|
||||||
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
||||||
return
|
return
|
||||||
|
|
||||||
selected = self.transferred_list.selectedItems()
|
selected_rows = set()
|
||||||
if not selected:
|
for item in self.track_table.selectedItems():
|
||||||
|
selected_rows.add(item.row())
|
||||||
|
|
||||||
|
if not selected_rows:
|
||||||
QMessageBox.information(self, "Info", "Select tracks to download")
|
QMessageBox.information(self, "Info", "Select tracks to download")
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -459,8 +893,11 @@ class iPodTab(QWidget):
|
|||||||
return
|
return
|
||||||
|
|
||||||
tracks = []
|
tracks = []
|
||||||
for item in selected:
|
for row in selected_rows:
|
||||||
data = item.data(Qt.ItemDataRole.UserRole)
|
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"]):
|
if data and data.get("file_path") and os.path.exists(data["file_path"]):
|
||||||
tracks.append(data)
|
tracks.append(data)
|
||||||
|
|
||||||
@ -505,6 +942,7 @@ class iPodTab(QWidget):
|
|||||||
|
|
||||||
self._cleanup_worker()
|
self._cleanup_worker()
|
||||||
|
|
||||||
|
# ── Orphans ────────────────────────────────────────────────
|
||||||
def _on_scan_orphans_clicked(self):
|
def _on_scan_orphans_clicked(self):
|
||||||
if not self.current_mount_point:
|
if not self.current_mount_point:
|
||||||
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
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"
|
details = f"Found {len(orphans)} orphaned file(s) occupying {total_mb:.1f} MB:\n\n"
|
||||||
for o in orphans[:20]:
|
for o in orphans[:20]:
|
||||||
size_kb = o["size"] / 1024
|
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:
|
if len(orphans) > 20:
|
||||||
details += f" ... and {len(orphans) - 20} more\n"
|
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}")
|
logger.error(f"Failed to scan for orphaned files: {e}")
|
||||||
QMessageBox.warning(self, "Error", f"Failed to scan: {e}")
|
QMessageBox.warning(self, "Error", f"Failed to scan: {e}")
|
||||||
|
|
||||||
|
# ── Duplicates ─────────────────────────────────────────────
|
||||||
def _on_remove_duplicates_clicked(self):
|
def _on_remove_duplicates_clicked(self):
|
||||||
if not self.current_mount_point:
|
if not self.current_mount_point:
|
||||||
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
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")
|
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._set_delete_buttons_enabled(False)
|
self._set_action_buttons_enabled(False)
|
||||||
self.delete_progress.setVisible(True)
|
self.delete_progress.setVisible(True)
|
||||||
self.delete_progress.setValue(0)
|
self.delete_progress.setValue(0)
|
||||||
self.delete_status.setVisible(True)
|
self.delete_status.setVisible(True)
|
||||||
@ -566,13 +1005,14 @@ class iPodTab(QWidget):
|
|||||||
self.worker_thread.start()
|
self.worker_thread.start()
|
||||||
|
|
||||||
def _on_delete_duplicates_finished(self, success, message, result):
|
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()
|
self._load_ipod_tracks()
|
||||||
if success:
|
if success:
|
||||||
self.delete_progress.setValue(100)
|
self.delete_progress.setValue(100)
|
||||||
QMessageBox.information(self, "Duplicates" if success else "Error", message)
|
QMessageBox.information(self, "Duplicates" if success else "Error", message)
|
||||||
self._cleanup_worker()
|
self._cleanup_worker()
|
||||||
|
|
||||||
|
# ── Monitoring ─────────────────────────────────────────────
|
||||||
def start_monitoring(self, monitor: DeviceMonitor):
|
def start_monitoring(self, monitor: DeviceMonitor):
|
||||||
monitor.state_changed.connect(self._on_auto_state_changed)
|
monitor.state_changed.connect(self._on_auto_state_changed)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user