From 3bfb0ebbfe3e21bfb82fda93fd60cf385f264eb7 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Mon, 1 Jun 2026 12:37:49 +0700 Subject: [PATCH] Fix UI freeze on iPod mount/track-load and add content_hash/sync-metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move mount_device, get_device_info, and track scanning to background WorkerThread (new 'mount_and_load' task type) so the UI stays responsive - Fix DeviceMonitor auto-poll to run detect_devices() in a background thread - Refactor _on_device_detection_finished and _on_mount_clicked to use the new worker; remove synchronous _set_device_mounted - Avoid double scan (get_track_count + get_all_tracks) — scan once in worker - Add content_hash fingerprint to _scan_ipod_files and library_cache for track matching - Add update_track_metadata / _write_databases_from_tracks with artwork override support in Nano7Database - Add 'Sync Metadata' button and context menu action in LibraryTab --- src/device_monitor.py | 37 +++++++--- src/ipod_nano7_db.py | 157 +++++++++++++++++++++++++++++++++++++++--- src/library_cache.py | 26 ++++++- src/ui/ipod_tab.py | 111 +++++++++++++++-------------- src/ui/library_tab.py | 92 ++++++++++++++++++++++++- src/worker.py | 88 +++++++++++++++++++++++ 6 files changed, 437 insertions(+), 74 deletions(-) diff --git a/src/device_monitor.py b/src/device_monitor.py index e9863c4..c87d467 100644 --- a/src/device_monitor.py +++ b/src/device_monitor.py @@ -1,13 +1,25 @@ import logging from typing import Set -from PyQt6.QtCore import QObject, QTimer, pyqtSignal +from PyQt6.QtCore import QObject, QTimer, QThread, pyqtSignal from ipod_device import IPodDevice logger = logging.getLogger(__name__) +class _DetectWorker(QThread): + finished = pyqtSignal(object) + + def run(self): + try: + devices = IPodDevice().detect_devices() + self.finished.emit(devices) + except Exception: + logger.exception("Device detection failed") + self.finished.emit([]) + + class DeviceMonitor(QObject): state_changed = pyqtSignal(list) @@ -17,6 +29,7 @@ class DeviceMonitor(QObject): self.timer = QTimer(self) self.timer.timeout.connect(self._poll) self._device_ids: Set[str] = set() + self._poll_worker = None def start(self, interval_ms: int = 3000): self._device_ids.clear() @@ -25,14 +38,20 @@ class DeviceMonitor(QObject): def stop(self): self.timer.stop() + if self._poll_worker and self._poll_worker.isRunning(): + self._poll_worker.quit() + self._poll_worker.wait(3000) self._device_ids.clear() def _poll(self): - try: - devices = IPodDevice().detect_devices() - current_ids = {d["id"] for d in devices} - if current_ids != self._device_ids: - self._device_ids = current_ids - self.state_changed.emit(devices) - except Exception: - logger.exception("DeviceMonitor poll failed") + if self._poll_worker and self._poll_worker.isRunning(): + return + self._poll_worker = _DetectWorker() + self._poll_worker.finished.connect(self._on_devices_detected) + self._poll_worker.start() + + def _on_devices_detected(self, devices): + current_ids = {d["id"] for d in devices} + if current_ids != self._device_ids: + self._device_ids = current_ids + self.state_changed.emit(devices) diff --git a/src/ipod_nano7_db.py b/src/ipod_nano7_db.py index 3984d20..e246583 100644 --- a/src/ipod_nano7_db.py +++ b/src/ipod_nano7_db.py @@ -132,12 +132,27 @@ MASTER_CONTAINER_PID = 203092939621887772 MUSIC_CONTAINER_PID = 4872745547565090077 +_REMOVE_ARTWORK = object() +"""Sentinel: used in artwork_overrides to remove artwork from a track.""" + + # ── Helpers ────────────────────────────────────────────────────────── def _to_mac_epoch(ts: int) -> int: return ts + MAC_EPOCH_OFFSET if ts > 0 else 0 +def content_hash(filepath: str, max_bytes: int = 65536) -> str: + """SHA1 of first max_bytes of audio data (fast content fingerprint).""" + h = hashlib.sha1() + try: + with open(filepath, "rb") as f: + h.update(f.read(max_bytes)) + except OSError: + return "" + return h.hexdigest() + + def _read_audio_tags(filepath: str) -> dict[str, Any]: """Extract title, artist, album, duration, etc. from an audio file.""" info: dict[str, Any] = {} @@ -374,6 +389,7 @@ class Nano7Database: tags["file_name"] = fname tags["file_size"] = os.path.getsize(fpath) tags["pid"] = self._hash_to_pid(fpath) + tags["content_hash"] = content_hash(fpath) results.append(tags) return results @@ -471,6 +487,7 @@ class Nano7Database: "year": s.get("year", 0), "ipod_path": None, "file_path": s["file_path"], + "content_hash": s.get("content_hash", ""), }) return tracks @@ -602,15 +619,127 @@ class Nano7Database: logger.exception("iTunesDB sync failed: %s", e) def _sync_itunescdb_impl(self) -> None: - # 1. Scan all audio files on iPod scanned = self._scan_ipod_files() if not scanned: logger.warning("No audio files found on iPod — writing empty databases") - # Write minimal empty databases self._write_empty_databases() return + self._write_databases_from_tracks(scanned) - # 2. Prepare artwork for all tracks + def find_track_by_content_hash(self, content_hash: str) -> dict | None: + """Find an iPod track by its content hash.""" + if not content_hash: + return None + for t in self.get_all_tracks(): + if t.get("content_hash") == content_hash: + return t + return None + + def find_track_by_metadata( + self, title: str, artist: str, album: str = "", + ) -> dict | None: + """Find an iPod track by title+artist+album (case-insensitive, fallback matching).""" + for t in self.get_all_tracks(): + if (t["title"].lower() == title.lower() and + t["artist"].lower() == artist.lower() and + (t.get("album") or "").lower() == album.lower()): + return t + return None + + def update_track_metadata( + self, + pid: int, + new_metadata: dict, + local_source_path: str | None = None, + ) -> bool: + """Update metadata for a single iPod track without touching audio files. + + Only updates the databases (iTunesDB + SQLite + ArtworkDB). + The audio file on the iPod is never modified — new metadata is + written directly into the database records. + + Args: + pid: The iPod track pid (from get_all_tracks). + new_metadata: Dict with optional keys: title, artist, album, + album_artist, genre, composer, year, track_number, + disc_number, comment. + local_source_path: If provided, artwork is extracted from this + local file and encoded into iPod-native formats. + + Returns: + True if the track was found and updated, False otherwise. + """ + scanned = self._scan_ipod_files() + if not scanned: + logger.warning("No tracks on iPod to update") + return False + + target_idx = None + for idx, s in enumerate(scanned): + if s["pid"] == pid: + target_idx = idx + break + + if target_idx is None: + logger.warning("Track with pid=%d not found on iPod", pid) + return False + + target = scanned[target_idx] + + text_fields = { + "title": "title", + "artist": "artist", + "album": "album", + "album_artist": "album_artist", + "genre": "genre", + "composer": "composer", + "comment": "comment", + } + for meta_key, scan_key in text_fields.items(): + if meta_key in new_metadata: + target[scan_key] = str(new_metadata[meta_key]) if new_metadata[meta_key] else "" + + if "year" in new_metadata: + target["year"] = int(new_metadata["year"] or 0) + if "track_number" in new_metadata: + target["track_number"] = int(new_metadata["track_number"] or 0) + if "disc_number" in new_metadata: + target["disc_number"] = int(new_metadata["disc_number"] or 0) + + artwork_overrides: dict[str, object] = {} + if local_source_path and os.path.exists(local_source_path): + try: + art_result = prepare_artwork_for_track(local_source_path) + if art_result is not None: + artwork_overrides[target["file_path"]] = art_result + else: + artwork_overrides[target["file_path"]] = _REMOVE_ARTWORK + except Exception as e: + logger.warning("Failed to extract artwork from %s: %s", + local_source_path, e) + + self._write_databases_from_tracks(scanned, artwork_overrides=artwork_overrides) + + logger.info("Updated metadata for track: %s", target.get("title", "?")) + return True + + def _write_databases_from_tracks( + self, + scanned: list[dict], + artwork_overrides: dict[str, object] | None = None, + ) -> None: + """Build artwork, convert to TrackInfo, write iTunesDB + SQLite. + + Args: + scanned: List of track dicts from _scan_ipod_files(). + artwork_overrides: Optional dict mapping iPod file_path → + (art_hash, {fmt_id: EncodedFormatPayload}) for tracks whose + artwork should come from a local file instead of the iPod file. + Use the ``_REMOVE_ARTWORK`` sentinel to explicitly remove + artwork for a track. Omit a key to keep existing iPod artwork. + """ + artwork_overrides = artwork_overrides or {} + # 1. Prepare artwork for all tracks artwork_map: dict[str, tuple[int, int, int]] = {} art_entries: list[ArtworkEntry] = [] art_hash_to_img_id: dict[str, int] = {} @@ -618,10 +747,16 @@ class Nano7Database: for idx, s in enumerate(scanned): fpath = s["file_path"] - try: - art_result = prepare_artwork_for_track(fpath) - except Exception: + override = artwork_overrides.get(fpath) + if override is None: + try: + art_result = prepare_artwork_for_track(fpath) + except Exception: + art_result = None + elif override is _REMOVE_ARTWORK: art_result = None + else: + art_result = override if art_result is not None: art_hash, formats = art_result art_count = len(formats) @@ -657,11 +792,11 @@ class Nano7Database: except Exception as e: logger.warning("ArtworkDB write failed: %s", e) - # 3. Convert to iOpenPod TrackInfo + # 2. Convert to iOpenPod TrackInfo tracks = _scanned_tracks_to_iop(self.mountpoint, scanned, artwork_map) logger.info("Built %d iOpenPod TrackInfo objects", len(tracks)) - # 4. Determine capabilities for this device + # 3. Determine capabilities for this device ipod_device = _import_iop("ipod_device") capabilities = None try: @@ -675,7 +810,7 @@ class Nano7Database: except Exception as e: logger.debug("Capabilities detection failed: %s", e) - # 5. Generate and write binary iTunesDB first (to get db_pid) + # 4. Generate and write binary iTunesDB first (to get db_pid) try: firewire_id = ipod_device.get_firewire_id(self.mountpoint) except Exception: @@ -695,7 +830,7 @@ class Nano7Database: return logger.info("iTunesDB written (%d tracks)", len(tracks)) - # 6. Extract db_pid from the binary DB for SQLite cross-reference + # 5. Extract db_pid from the binary DB for SQLite cross-reference db_pid = 0 try: ipod_dev = _import_iop("ipod_device") @@ -709,7 +844,7 @@ class Nano7Database: except Exception as e: logger.warning("Could not extract db_pid: %s", e) - # 7. Write SQLite databases (Nano 6G/7G require this) + # 6. Write SQLite databases (Nano 6G/7G require this) try: write_sqlite_databases = _import_iop("SQLiteDB_Writer").write_sqlite_databases sqlite_ok = write_sqlite_databases( diff --git a/src/library_cache.py b/src/library_cache.py index 46104d3..057fb00 100644 --- a/src/library_cache.py +++ b/src/library_cache.py @@ -5,6 +5,7 @@ Caches extracted tag data per file path with mtime-based invalidation. Stored as JSON in XDG cache directory. """ +import hashlib import json import logging import os @@ -17,11 +18,23 @@ logger = logging.getLogger(__name__) _CACHE_FILE = "library_cache.json" +def _content_hash(filepath: str, max_bytes: int = 65536) -> str: + """SHA1 of first max_bytes of audio data (fast content fingerprint).""" + h = hashlib.sha1() + try: + with open(filepath, "rb") as f: + h.update(f.read(max_bytes)) + except OSError: + return "" + return h.hexdigest() + + class LibraryCache: """JSON-based cache of audio file metadata. Keys are absolute file paths. Values are dicts of metadata - plus an internal ``_mtime`` field for change detection. + plus internal ``_mtime`` and ``_content_hash`` fields for + change detection and track matching. Cached entries are returned only when the file still exists and its ``os.path.getmtime`` matches the stored value. @@ -64,7 +77,15 @@ class LibraryCache: actual_mtime = os.path.getmtime(file_path) if actual_mtime != entry.get("_mtime", 0): return None - return {k: v for k, v in entry.items() if k != "_mtime"} + result = {} + for k, v in entry.items(): + if k == "_mtime": + continue + if k == "_content_hash": + result["content_hash"] = v + else: + result[k] = v + return result def put(self, file_path: str, data: Dict[str, Any]) -> None: entry = dict(data) @@ -72,6 +93,7 @@ class LibraryCache: entry["_mtime"] = os.path.getmtime(file_path) except OSError: return + entry["_content_hash"] = _content_hash(file_path) self._data[file_path] = entry def remove(self, file_path: str) -> None: diff --git a/src/ui/ipod_tab.py b/src/ui/ipod_tab.py index 54cc3f6..a084e17 100644 --- a/src/ui/ipod_tab.py +++ b/src/ui/ipod_tab.py @@ -132,21 +132,9 @@ class iPodTab(QWidget): if self.ipod_devices: device = self.ipod_devices[0] - device_id = device["id"] detected_mount = device.get("mount_point") already_mounted = device.get("mounted", False) - - if already_mounted and detected_mount: - self._set_device_mounted(device, detected_mount) - else: - ipod = IPodDevice() - mount_point = ipod.mount_device(device_id, detected_mount=detected_mount) - if mount_point: - device["mount_point"] = mount_point - device["mounted"] = True - self._set_device_mounted(device, mount_point) - else: - self._set_device_unmounted(device) + self._start_mount_and_load(device, detected_mount, already_mounted) else: self._set_device_not_found() else: @@ -154,27 +142,67 @@ class iPodTab(QWidget): self._cleanup_worker() - def _set_device_mounted(self, device: dict, mount_point: str): - self.current_mount_point = mount_point - ipod = IPodDevice(mount_point=mount_point) - device_info = ipod.get_device_info() - device["info"] = device_info - - free_space = device_info.get("free_space", 0) - 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" - ) - self.device_status_label.setText("Status: Mounted and ready") + def _start_mount_and_load(self, device: dict, mount_point=None, already_mounted=False): + self.device_status_label.setText("Mounting and loading tracks...") self.mount_button.setEnabled(False) - self.eject_button.setEnabled(True) - self.device_mounted.emit(mount_point) + self.worker_thread = WorkerThread( + task_type="mount_and_load", + device=device, + mount_point=mount_point, + already_mounted=already_mounted, + ) + self.worker_thread.progress_signal.connect(self._on_mount_load_progress) + self.worker_thread.finished_signal.connect(self._on_mount_load_finished) + self.worker_thread.start() - self._load_ipod_tracks() + def _on_mount_load_progress(self, progress, status): + self.device_info_label.setText(status) + + def _on_mount_load_finished(self, success, message, result): + if success: + device = result["device"] + mount_point = result["mount_point"] + device_info = result["device_info"] + tracks = result["tracks"] + + self.current_mount_point = mount_point + device["info"] = device_info + + free_space = device_info.get("free_space", 0) + 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" + ) + self.device_status_label.setText("Status: Mounted and ready") + 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.track_count_label.setText(f"Tracks on device: {len(tracks)}") + self.export_button.setEnabled(len(tracks) > 0) + else: + self.device_status_label.setText(f"Status: {message}") + self.mount_button.setEnabled(True) + if self.ipod_devices: + self._set_device_unmounted(self.ipod_devices[0]) + else: + self._set_device_not_found() + + self._cleanup_worker() def _set_device_unmounted(self, device: dict): self.current_mount_point = None @@ -213,27 +241,8 @@ class iPodTab(QWidget): return device = self.ipod_devices[0] - device_id = device["id"] detected_mount = device.get("mount_point") - - self.device_status_label.setText("Mounting...") - self.mount_button.setEnabled(False) - - try: - ipod = IPodDevice() - mount_point = ipod.mount_device(device_id, detected_mount=detected_mount) - if mount_point: - device["mount_point"] = mount_point - device["mounted"] = True - self._set_device_mounted(device, mount_point) - else: - self.device_status_label.setText("Status: Mount failed") - self.mount_button.setEnabled(True) - QMessageBox.warning(self, "Mount Error", "Failed to mount iPod. Check permissions and try again.") - except Exception as e: - self.device_status_label.setText("Status: Mount error") - self.mount_button.setEnabled(True) - QMessageBox.warning(self, "Mount Error", f"Error mounting iPod: {e}") + self._start_mount_and_load(device, detected_mount, already_mounted=False) def _on_eject_clicked(self): reply = QMessageBox.question( diff --git a/src/ui/library_tab.py b/src/ui/library_tab.py index 8e6469a..3d0c679 100644 --- a/src/ui/library_tab.py +++ b/src/ui/library_tab.py @@ -214,6 +214,12 @@ class LibraryTab(QWidget): self.library_transfer_btn.clicked.connect(self._on_library_transfer_clicked) toolbar.addWidget(self.library_transfer_btn) + self.library_sync_btn = QPushButton("\uD83D\uDD04 Sync Metadata") + self.library_sync_btn.setEnabled(False) + self.library_sync_btn.setToolTip("Sync metadata and artwork for all tracks on iPod") + self.library_sync_btn.clicked.connect(self._on_sync_metadata_clicked) + toolbar.addWidget(self.library_sync_btn) + self.library_remove_btn = QPushButton("\uD83D\uDDD1\uFE0F Remove") self.library_remove_btn.setEnabled(False) self.library_remove_btn.clicked.connect(self._on_library_remove_selected) @@ -233,6 +239,7 @@ class LibraryTab(QWidget): self.library_add_btn, self.library_convert_btn, self.library_transfer_btn, + self.library_sync_btn, self.library_remove_btn, self.library_edit_btn, refresh_btn, @@ -717,7 +724,9 @@ class LibraryTab(QWidget): has_ready = ready_count > 0 has_source = source_count > 0 - self.library_transfer_btn.setEnabled(has_ready and self._current_mount_point is not None) + has_mount = self._current_mount_point is not None + self.library_transfer_btn.setEnabled(has_ready and has_mount) + self.library_sync_btn.setEnabled(has_ready and has_mount) self.library_convert_btn.setEnabled(has_source) self.library_remove_btn.setEnabled(len(all_tracks) > 0) self.library_edit_btn.setEnabled(len(all_tracks) > 0) @@ -984,6 +993,8 @@ class LibraryTab(QWidget): play_action.triggered.connect(lambda: self._play_track_from_index(row)) if is_ready and self._current_mount_point is not None: + sync_action = menu.addAction("\uD83D\uDD04 Sync Metadata to iPod") + sync_action.triggered.connect(self._on_sync_metadata_clicked) transfer_action = menu.addAction("\uD83D\uDCE4 Transfer to iPod") transfer_action.triggered.connect(self._on_library_transfer_clicked) else: @@ -1129,6 +1140,84 @@ class LibraryTab(QWidget): self._cleanup_worker() + def _on_sync_metadata_clicked(self): + selected_tracks = self._get_selected_ready_tracks() + if not selected_tracks: + QMessageBox.warning(self, "Error", "No tracks selected.") + return + + if not self._current_mount_point: + QMessageBox.warning(self, "Error", "No iPod device mounted.") + return + + if self.worker_thread and self.worker_thread.isRunning(): + QMessageBox.information(self, "Info", "Please wait for the current task to finish") + return + + self.library_sync_btn.setEnabled(False) + self.library_status.setText("Matching tracks on iPod...") + self.library_progress.setValue(0) + + from ipod_nano7_db import Nano7Database, content_hash as compute_content_hash + + db = Nano7Database(self._current_mount_point) + ipod_tracks = db.get_all_tracks() + ipod_by_hash = {t.get("content_hash", ""): t for t in ipod_tracks if t.get("content_hash")} + + sync_batch = [] + errors = [] + for track_info, local_path in selected_tracks: + ch = compute_content_hash(local_path) + ipod_track = ipod_by_hash.get(ch) + if ipod_track is None: + ipod_track = db.find_track_by_metadata( + track_info.title, track_info.artist, track_info.album or "", + ) + if ipod_track is None: + errors.append(f"'{track_info.title}' — not found on iPod") + continue + + sync_batch.append({ + "ipod_pid": ipod_track["pid"], + "local_path": local_path, + "new_metadata": { + "title": track_info.title, + "artist": track_info.artist, + "album": track_info.album or "", + "track_number": track_info.track_number or 0, + "genre": track_info.genre or "", + }, + }) + + if not sync_batch: + msg = "No matching tracks found on iPod" + if errors: + msg += ":\n" + "\n".join(errors[:5]) + QMessageBox.warning(self, "Sync Error", msg) + return + + if errors: + logger.warning("Sync metadata skipped for %d unmatched track(s)", len(errors)) + + self.worker_thread = WorkerThread( + task_type="sync_metadata", + sync_batch=sync_batch, + mount_point=self._current_mount_point, + ) + self.worker_thread.progress_signal.connect(self._on_library_progress) + self.worker_thread.finished_signal.connect(self._on_sync_metadata_finished) + self.worker_thread.start() + + def _on_sync_metadata_finished(self, success, message, result): + self.library_sync_btn.setEnabled(True) + if success: + self.library_status.setText(message) + QMessageBox.information(self, "Sync Complete", message) + else: + self.library_status.setText(f"Error: {message}") + QMessageBox.warning(self, "Sync Error", message) + self._cleanup_worker() + def _cleanup_worker(self): if self.worker_thread: self.worker_thread.wait(3000) @@ -1168,6 +1257,7 @@ class LibraryTab(QWidget): def on_device_unmounted(self): self._current_mount_point = None self.library_transfer_btn.setEnabled(False) + self.library_sync_btn.setEnabled(False) self.library_progress.setVisible(False) self.library_status.setVisible(False) diff --git a/src/worker.py b/src/worker.py index e52ed30..f5046c5 100644 --- a/src/worker.py +++ b/src/worker.py @@ -39,9 +39,11 @@ class WorkerThread(QThread): "convert": self._run_convert, "transfer": self._run_transfer, "detect_devices": self._run_detect_devices, + "mount_and_load": self._run_mount_and_load, "export_ipod": self._run_export_ipod, "delete_tracks": self._run_delete_tracks, "remove_duplicates": self._run_remove_duplicates, + "sync_metadata": self._run_sync_metadata, } handler = handlers.get(self.task_type) if handler: @@ -168,6 +170,48 @@ class WorkerThread(QThread): self.finished_signal.emit(True, f"Found {len(devices)} iPod devices", devices) + def _run_mount_and_load(self): + import shutil + + device = self.kwargs.get("device", {}) + mount_point = self.kwargs.get("mount_point") + already_mounted = self.kwargs.get("already_mounted", False) + + if not already_mounted: + device_id = device.get("id") + detected_mount = device.get("mount_point") + ipod = IPodDevice() + mount_point = ipod.mount_device(device_id, detected_mount=detected_mount) + if not mount_point: + self.finished_signal.emit(False, "Failed to mount iPod", None) + return + device["mount_point"] = mount_point + device["mounted"] = True + + from ipod_nano7_db import Nano7Database + + usage = shutil.disk_usage(mount_point) + device_info = { + "mount_point": mount_point, + "total_space": usage.total, + "free_space": usage.free, + "used_space": usage.used, + } + + db = Nano7Database(mount_point) + tracks = db.get_all_tracks() + device_info["track_count"] = len(tracks) + + result = { + "device": device, + "mount_point": mount_point, + "device_info": device_info, + "tracks": tracks, + } + self.finished_signal.emit( + True, f"Loaded {len(tracks)} tracks from iPod", result + ) + def _run_export_ipod(self): tracks = self.kwargs.get("tracks", []) dest_dir = self.kwargs.get("dest_dir", "") @@ -221,6 +265,50 @@ class WorkerThread(QThread): removed = db.remove_duplicates(progress_callback=progress) self.finished_signal.emit(True, f"Removed {len(removed)} duplicate(s)", removed) + def _run_sync_metadata(self): + """Update metadata and artwork for tracks already on iPod (batch).""" + sync_batch = self.kwargs.get("sync_batch", []) + mount_point = self.kwargs.get("mount_point") + + if not sync_batch or not mount_point: + self.finished_signal.emit(False, "Missing parameters for sync", None) + return + + from ipod_nano7_db import Nano7Database + db = Nano7Database(mount_point) + total = len(sync_batch) + synced = 0 + errors = [] + + for i, item in enumerate(sync_batch): + if not self.is_running: + break + + self.progress_signal.emit( + int((i / total) * 100), + f"Syncing metadata: {item['new_metadata'].get('title', '?')}...", + ) + + try: + success = db.update_track_metadata( + pid=item["ipod_pid"], + new_metadata=item["new_metadata"], + local_source_path=item.get("local_path"), + ) + if success: + synced += 1 + else: + errors.append(item['new_metadata'].get('title', '?')) + except Exception as e: + logger.exception("Metadata sync failed for track: %s", e) + errors.append(f"{item['new_metadata'].get('title', '?')}: {e}") + + msg = f"Synced metadata for {synced}/{total} track(s)" + if errors: + msg += f" ({len(errors)} error(s))" + self.progress_signal.emit(100, msg) + self.finished_signal.emit(synced > 0, msg, {"synced": synced, "errors": errors}) + def stop(self): self.is_running = False self.wait()