fix: move iPod track deletion to background thread with progress bar, batch DB sync

This commit is contained in:
Maksim Totmin 2026-05-31 22:43:59 +07:00
parent 3da5e15fe0
commit 63d72adae6
2 changed files with 178 additions and 52 deletions

View File

@ -436,24 +436,46 @@ class Nano7Database:
return tracks
def delete_track(self, pid: int) -> bool:
"""Delete track by pid. Scans for matching file, deletes, re-syncs."""
deleted = False
for s in self._scan_ipod_files():
if s["pid"] == pid:
if os.path.exists(s["file_path"]):
os.remove(s["file_path"])
logger.info("Deleted: %s", s["file_path"])
deleted = True
break
if deleted:
self.sync_itunescdb()
return True
# Try deleting by file path if pid lookup fails
# pid might be a negative hash; try all files
logger.warning("No file found for pid=%d", pid)
return False
"""Delete a single track by pid (scans, deletes, syncs)."""
return self.delete_tracks([pid]) > 0
def remove_duplicates(self) -> list[dict]:
def delete_tracks(self, pids: list[int], progress_callback=None) -> int:
"""Delete tracks by pid. Scans once, deletes all matching files,
then regenerates the database exactly once.
Args:
pids: List of track pids to delete.
progress_callback: Optional callable(percent, status) for progress.
Returns:
Number of files actually deleted.
"""
pid_set = set(pids)
scanned = self._scan_ipod_files()
deleted = 0
for i, s in enumerate(scanned):
if s["pid"] in pid_set and os.path.exists(s["file_path"]):
os.remove(s["file_path"])
logger.info("Deleted: %s", s["file_path"])
deleted += 1
pid_set.discard(s["pid"])
if progress_callback and i % 5 == 0:
progress_callback(
min(50, int((i + 1) / max(len(scanned), 1) * 50)),
f"Deleting files... {deleted} removed",
)
if deleted:
if progress_callback:
progress_callback(55, "Regenerating iPod database...")
self.sync_itunescdb()
if progress_callback:
progress_callback(100, f"Removed {deleted} track(s)")
return deleted
def remove_duplicates(self, progress_callback=None) -> list[dict]:
"""Find and remove duplicate tracks (same title+artist+album)."""
all_tracks = self._scan_ipod_files()
seen: dict[tuple, list[dict]] = {}
@ -467,7 +489,6 @@ class Nano7Database:
for key, items in seen.items():
if len(items) <= 1:
continue
# Keep first, delete rest
for item in items[1:]:
fpath = item["file_path"]
if os.path.exists(fpath):
@ -480,9 +501,19 @@ class Nano7Database:
"file_path": fpath,
})
logger.info("Removed duplicate: %s", fpath)
if progress_callback:
progress_callback(
min(50, int(len(removed) / max(len(all_tracks), 1) * 50)),
f"Removing duplicates... {len(removed)} found",
)
if removed:
if progress_callback:
progress_callback(55, "Regenerating iPod database...")
self.sync_itunescdb()
if progress_callback:
progress_callback(100, f"Removed {len(removed)} duplicate(s)")
logger.info("Removed %d duplicate(s)", len(removed))
return removed

View File

@ -55,6 +55,8 @@ class WorkerThread(QThread):
"transfer": self._run_transfer,
"detect_devices": self._run_detect_devices,
"export_ipod": self._run_export_ipod,
"delete_tracks": self._run_delete_tracks,
"remove_duplicates": self._run_remove_duplicates,
}
handler = handlers.get(self.task_type)
if handler:
@ -92,7 +94,7 @@ class WorkerThread(QThread):
tracks = self.kwargs.get("tracks", [])
local_files = self.kwargs.get("local_files", [])
output_dir = self.kwargs.get("output_dir", "converted")
fmt = self.kwargs.get("format", "m4a")
fmt = self.kwargs.get("format", "mp3")
quality = self.kwargs.get("quality", 256)
valid_tracks = [
@ -223,6 +225,40 @@ class WorkerThread(QThread):
exported = ipod.export_tracks(tracks, dest_dir, progress_callback=progress)
self.finished_signal.emit(True, f"Exported {len(exported)} track(s)", exported)
def _run_delete_tracks(self):
pids = self.kwargs.get("pids", [])
mount_point = self.kwargs.get("mount_point")
if not pids or not mount_point:
self.finished_signal.emit(False, "No tracks to delete or device not mounted", None)
return
from ipod_nano7_db import Nano7Database
db = Nano7Database(mount_point)
def progress(p, s):
self.progress_signal.emit(p, s)
removed = db.delete_tracks(pids, progress_callback=progress)
self.finished_signal.emit(True, f"Removed {removed} track(s)", removed)
def _run_remove_duplicates(self):
mount_point = self.kwargs.get("mount_point")
if not mount_point:
self.finished_signal.emit(False, "No iPod device mounted", None)
return
from ipod_nano7_db import Nano7Database
db = Nano7Database(mount_point)
def progress(p, s):
self.progress_signal.emit(p, s)
self.progress_signal.emit(0, "Scanning for duplicates...")
removed = db.remove_duplicates(progress_callback=progress)
self.finished_signal.emit(True, f"Removed {len(removed)} duplicate(s)", removed)
def stop(self):
self.is_running = False
self.wait()
@ -311,7 +347,7 @@ class MainWindow(QMainWindow):
format_layout = QHBoxLayout()
format_label = QLabel("Format:")
self.format_combo = QComboBox()
self.format_combo.addItems(["m4a", "mp3"])
self.format_combo.addItems(["mp3", "m4a"])
quality_label = QLabel("Quality (kbps):")
self.quality_spin = QSpinBox()
self.quality_spin.setRange(128, 320)
@ -555,6 +591,15 @@ class MainWindow(QMainWindow):
self.export_status.setVisible(False)
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_status = QLabel("")
self.delete_status.setVisible(False)
layout.addWidget(self.delete_status)
def _setup_settings_tab(self, tab):
layout = QVBoxLayout(tab)
@ -1256,6 +1301,22 @@ class MainWindow(QMainWindow):
if success:
self.library_status.setText(message)
self._scan_library()
if result:
converted_source_paths = set()
for track, _output_path in result:
src = getattr(track, "download_path", None)
if src:
converted_source_paths.add(os.path.normpath(src))
if converted_source_paths:
self.library_scanned_sources = [
s for s in self.library_scanned_sources
if os.path.normpath(s.get("path", "")) not in converted_source_paths
]
self.library_source_files = [
p for p in self.library_source_files
if os.path.normpath(p) not in converted_source_paths
]
self._refresh_library_ui()
else:
self.library_status.setText(f"Error: {message}")
QMessageBox.warning(self, "Conversion Error", message)
@ -1508,23 +1569,52 @@ class MainWindow(QMainWindow):
if reply != QMessageBox.StandardButton.Yes:
return
try:
from ipod_nano7_db import Nano7Database
db = Nano7Database(self.current_mount_point)
if self.worker_thread and self.worker_thread.isRunning():
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
return
removed = 0
for item in selected:
track_data = item.data(Qt.ItemDataRole.UserRole)
if track_data and "pid" in track_data:
success = db.delete_track(track_data["pid"])
if success:
removed += 1
pids = []
for item in selected:
track_data = item.data(Qt.ItemDataRole.UserRole)
if track_data and "pid" in track_data:
pids.append(track_data["pid"])
self._load_ipod_tracks()
QMessageBox.information(self, "Done", f"Successfully removed {removed} track(s) from iPod")
except Exception as e:
logger.error(f"Failed to remove tracks: {e}")
QMessageBox.warning(self, "Error", f"Failed to remove tracks: {e}")
if not pids:
QMessageBox.warning(self, "Error", "No valid tracks selected")
return
self._set_delete_buttons_enabled(False)
self.delete_progress.setVisible(True)
self.delete_progress.setValue(0)
self.delete_status.setVisible(True)
self.delete_status.setText(f"Removing {len(pids)} track(s)...")
self.worker_thread = WorkerThread(
task_type="delete_tracks",
pids=pids,
mount_point=self.current_mount_point,
)
self.worker_thread.progress_signal.connect(self._on_delete_progress)
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 _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)
if success:
self.delete_progress.setValue(100)
self._load_ipod_tracks()
QMessageBox.information(self, "Done" if success else "Error", message)
self._cleanup_worker()
def _on_export_ipod_clicked(self):
if not self.current_mount_point:
@ -1629,26 +1719,31 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, "Error", "No iPod device mounted.")
return
try:
from ipod_nano7_db import Nano7Database
db = Nano7Database(self.current_mount_point)
removed = db.remove_duplicates()
if self.worker_thread and self.worker_thread.isRunning():
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
return
if not removed:
QMessageBox.information(self, "Scan Complete", "No duplicate tracks found.")
return
self._set_delete_buttons_enabled(False)
self.delete_progress.setVisible(True)
self.delete_progress.setValue(0)
self.delete_status.setVisible(True)
self.delete_status.setText("Scanning for duplicates...")
details = f"Found and removed {len(removed)} duplicate entry(ies):\n\n"
for r in removed[:15]:
details += f" {r['artist']} \u2014 {r['title']} ({r['album']})\n"
if len(removed) > 15:
details += f" ... and {len(removed) - 15} more\n"
self.worker_thread = WorkerThread(
task_type="remove_duplicates",
mount_point=self.current_mount_point,
)
self.worker_thread.progress_signal.connect(self._on_delete_progress)
self.worker_thread.finished_signal.connect(self._on_delete_duplicates_finished)
self.worker_thread.start()
QMessageBox.information(self, "Duplicates Removed", details)
self._load_ipod_tracks()
except Exception as e:
logger.error(f"Failed to remove duplicates: {e}")
QMessageBox.warning(self, "Error", f"Failed to remove duplicates: {e}")
def _on_delete_duplicates_finished(self, success, message, result):
self._set_delete_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()
def closeEvent(self, event):
if self.worker_thread and self.worker_thread.isRunning():