fix: move iPod track deletion to background thread with progress bar, batch DB sync
This commit is contained in:
parent
3da5e15fe0
commit
63d72adae6
@ -436,24 +436,46 @@ class Nano7Database:
|
|||||||
return tracks
|
return tracks
|
||||||
|
|
||||||
def delete_track(self, pid: int) -> bool:
|
def delete_track(self, pid: int) -> bool:
|
||||||
"""Delete track by pid. Scans for matching file, deletes, re-syncs."""
|
"""Delete a single track by pid (scans, deletes, syncs)."""
|
||||||
deleted = False
|
return self.delete_tracks([pid]) > 0
|
||||||
for s in self._scan_ipod_files():
|
|
||||||
if s["pid"] == pid:
|
def delete_tracks(self, pids: list[int], progress_callback=None) -> int:
|
||||||
if os.path.exists(s["file_path"]):
|
"""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"])
|
os.remove(s["file_path"])
|
||||||
logger.info("Deleted: %s", s["file_path"])
|
logger.info("Deleted: %s", s["file_path"])
|
||||||
deleted = True
|
deleted += 1
|
||||||
break
|
pid_set.discard(s["pid"])
|
||||||
if deleted:
|
if progress_callback and i % 5 == 0:
|
||||||
self.sync_itunescdb()
|
progress_callback(
|
||||||
return True
|
min(50, int((i + 1) / max(len(scanned), 1) * 50)),
|
||||||
# Try deleting by file path if pid lookup fails
|
f"Deleting files... {deleted} removed",
|
||||||
# pid might be a negative hash; try all files
|
)
|
||||||
logger.warning("No file found for pid=%d", pid)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def remove_duplicates(self) -> list[dict]:
|
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)."""
|
"""Find and remove duplicate tracks (same title+artist+album)."""
|
||||||
all_tracks = self._scan_ipod_files()
|
all_tracks = self._scan_ipod_files()
|
||||||
seen: dict[tuple, list[dict]] = {}
|
seen: dict[tuple, list[dict]] = {}
|
||||||
@ -467,7 +489,6 @@ class Nano7Database:
|
|||||||
for key, items in seen.items():
|
for key, items in seen.items():
|
||||||
if len(items) <= 1:
|
if len(items) <= 1:
|
||||||
continue
|
continue
|
||||||
# Keep first, delete rest
|
|
||||||
for item in items[1:]:
|
for item in items[1:]:
|
||||||
fpath = item["file_path"]
|
fpath = item["file_path"]
|
||||||
if os.path.exists(fpath):
|
if os.path.exists(fpath):
|
||||||
@ -480,9 +501,19 @@ class Nano7Database:
|
|||||||
"file_path": fpath,
|
"file_path": fpath,
|
||||||
})
|
})
|
||||||
logger.info("Removed duplicate: %s", 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 removed:
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(55, "Regenerating iPod database...")
|
||||||
self.sync_itunescdb()
|
self.sync_itunescdb()
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(100, f"Removed {len(removed)} duplicate(s)")
|
||||||
|
|
||||||
logger.info("Removed %d duplicate(s)", len(removed))
|
logger.info("Removed %d duplicate(s)", len(removed))
|
||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|||||||
153
src/main.py
153
src/main.py
@ -55,6 +55,8 @@ class WorkerThread(QThread):
|
|||||||
"transfer": self._run_transfer,
|
"transfer": self._run_transfer,
|
||||||
"detect_devices": self._run_detect_devices,
|
"detect_devices": self._run_detect_devices,
|
||||||
"export_ipod": self._run_export_ipod,
|
"export_ipod": self._run_export_ipod,
|
||||||
|
"delete_tracks": self._run_delete_tracks,
|
||||||
|
"remove_duplicates": self._run_remove_duplicates,
|
||||||
}
|
}
|
||||||
handler = handlers.get(self.task_type)
|
handler = handlers.get(self.task_type)
|
||||||
if handler:
|
if handler:
|
||||||
@ -92,7 +94,7 @@ class WorkerThread(QThread):
|
|||||||
tracks = self.kwargs.get("tracks", [])
|
tracks = self.kwargs.get("tracks", [])
|
||||||
local_files = self.kwargs.get("local_files", [])
|
local_files = self.kwargs.get("local_files", [])
|
||||||
output_dir = self.kwargs.get("output_dir", "converted")
|
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)
|
quality = self.kwargs.get("quality", 256)
|
||||||
|
|
||||||
valid_tracks = [
|
valid_tracks = [
|
||||||
@ -223,6 +225,40 @@ class WorkerThread(QThread):
|
|||||||
exported = ipod.export_tracks(tracks, dest_dir, progress_callback=progress)
|
exported = ipod.export_tracks(tracks, dest_dir, progress_callback=progress)
|
||||||
self.finished_signal.emit(True, f"Exported {len(exported)} track(s)", exported)
|
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):
|
def stop(self):
|
||||||
self.is_running = False
|
self.is_running = False
|
||||||
self.wait()
|
self.wait()
|
||||||
@ -311,7 +347,7 @@ class MainWindow(QMainWindow):
|
|||||||
format_layout = QHBoxLayout()
|
format_layout = QHBoxLayout()
|
||||||
format_label = QLabel("Format:")
|
format_label = QLabel("Format:")
|
||||||
self.format_combo = QComboBox()
|
self.format_combo = QComboBox()
|
||||||
self.format_combo.addItems(["m4a", "mp3"])
|
self.format_combo.addItems(["mp3", "m4a"])
|
||||||
quality_label = QLabel("Quality (kbps):")
|
quality_label = QLabel("Quality (kbps):")
|
||||||
self.quality_spin = QSpinBox()
|
self.quality_spin = QSpinBox()
|
||||||
self.quality_spin.setRange(128, 320)
|
self.quality_spin.setRange(128, 320)
|
||||||
@ -555,6 +591,15 @@ class MainWindow(QMainWindow):
|
|||||||
self.export_status.setVisible(False)
|
self.export_status.setVisible(False)
|
||||||
layout.addWidget(self.export_status)
|
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):
|
def _setup_settings_tab(self, tab):
|
||||||
layout = QVBoxLayout(tab)
|
layout = QVBoxLayout(tab)
|
||||||
|
|
||||||
@ -1256,6 +1301,22 @@ class MainWindow(QMainWindow):
|
|||||||
if success:
|
if success:
|
||||||
self.library_status.setText(message)
|
self.library_status.setText(message)
|
||||||
self._scan_library()
|
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:
|
else:
|
||||||
self.library_status.setText(f"Error: {message}")
|
self.library_status.setText(f"Error: {message}")
|
||||||
QMessageBox.warning(self, "Conversion Error", message)
|
QMessageBox.warning(self, "Conversion Error", message)
|
||||||
@ -1508,23 +1569,52 @@ class MainWindow(QMainWindow):
|
|||||||
if reply != QMessageBox.StandardButton.Yes:
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
if self.worker_thread and self.worker_thread.isRunning():
|
||||||
from ipod_nano7_db import Nano7Database
|
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
|
||||||
db = Nano7Database(self.current_mount_point)
|
return
|
||||||
|
|
||||||
removed = 0
|
pids = []
|
||||||
for item in selected:
|
for item in selected:
|
||||||
track_data = item.data(Qt.ItemDataRole.UserRole)
|
track_data = item.data(Qt.ItemDataRole.UserRole)
|
||||||
if track_data and "pid" in track_data:
|
if track_data and "pid" in track_data:
|
||||||
success = db.delete_track(track_data["pid"])
|
pids.append(track_data["pid"])
|
||||||
if success:
|
|
||||||
removed += 1
|
|
||||||
|
|
||||||
|
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()
|
self._load_ipod_tracks()
|
||||||
QMessageBox.information(self, "Done", f"Successfully removed {removed} track(s) from iPod")
|
QMessageBox.information(self, "Done" if success else "Error", message)
|
||||||
except Exception as e:
|
self._cleanup_worker()
|
||||||
logger.error(f"Failed to remove tracks: {e}")
|
|
||||||
QMessageBox.warning(self, "Error", f"Failed to remove tracks: {e}")
|
|
||||||
|
|
||||||
def _on_export_ipod_clicked(self):
|
def _on_export_ipod_clicked(self):
|
||||||
if not self.current_mount_point:
|
if not self.current_mount_point:
|
||||||
@ -1629,26 +1719,31 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
QMessageBox.warning(self, "Error", "No iPod device mounted.")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
if self.worker_thread and self.worker_thread.isRunning():
|
||||||
from ipod_nano7_db import Nano7Database
|
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
|
||||||
db = Nano7Database(self.current_mount_point)
|
|
||||||
removed = db.remove_duplicates()
|
|
||||||
|
|
||||||
if not removed:
|
|
||||||
QMessageBox.information(self, "Scan Complete", "No duplicate tracks found.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
details = f"Found and removed {len(removed)} duplicate entry(ies):\n\n"
|
self._set_delete_buttons_enabled(False)
|
||||||
for r in removed[:15]:
|
self.delete_progress.setVisible(True)
|
||||||
details += f" {r['artist']} \u2014 {r['title']} ({r['album']})\n"
|
self.delete_progress.setValue(0)
|
||||||
if len(removed) > 15:
|
self.delete_status.setVisible(True)
|
||||||
details += f" ... and {len(removed) - 15} more\n"
|
self.delete_status.setText("Scanning for duplicates...")
|
||||||
|
|
||||||
QMessageBox.information(self, "Duplicates Removed", details)
|
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()
|
||||||
|
|
||||||
|
def _on_delete_duplicates_finished(self, success, message, result):
|
||||||
|
self._set_delete_buttons_enabled(True)
|
||||||
self._load_ipod_tracks()
|
self._load_ipod_tracks()
|
||||||
except Exception as e:
|
if success:
|
||||||
logger.error(f"Failed to remove duplicates: {e}")
|
self.delete_progress.setValue(100)
|
||||||
QMessageBox.warning(self, "Error", f"Failed to remove duplicates: {e}")
|
QMessageBox.information(self, "Duplicates" if success else "Error", message)
|
||||||
|
self._cleanup_worker()
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
if self.worker_thread and self.worker_thread.isRunning():
|
if self.worker_thread and self.worker_thread.isRunning():
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user