diff --git a/src/ipod_nano7_db.py b/src/ipod_nano7_db.py index 761cfa5..82174f2 100644 --- a/src/ipod_nano7_db.py +++ b/src/ipod_nano7_db.py @@ -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 diff --git a/src/main.py b/src/main.py index 019e057..468e53f 100644 --- a/src/main.py +++ b/src/main.py @@ -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():