Fix UI freeze on iPod mount/track-load and add content_hash/sync-metadata

- 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
This commit is contained in:
Maksim Totmin 2026-06-01 12:37:49 +07:00
parent 25bb8105ac
commit 3bfb0ebbfe
6 changed files with 437 additions and 74 deletions

View File

@ -1,13 +1,25 @@
import logging import logging
from typing import Set from typing import Set
from PyQt6.QtCore import QObject, QTimer, pyqtSignal from PyQt6.QtCore import QObject, QTimer, QThread, pyqtSignal
from ipod_device import IPodDevice from ipod_device import IPodDevice
logger = logging.getLogger(__name__) 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): class DeviceMonitor(QObject):
state_changed = pyqtSignal(list) state_changed = pyqtSignal(list)
@ -17,6 +29,7 @@ class DeviceMonitor(QObject):
self.timer = QTimer(self) self.timer = QTimer(self)
self.timer.timeout.connect(self._poll) self.timer.timeout.connect(self._poll)
self._device_ids: Set[str] = set() self._device_ids: Set[str] = set()
self._poll_worker = None
def start(self, interval_ms: int = 3000): def start(self, interval_ms: int = 3000):
self._device_ids.clear() self._device_ids.clear()
@ -25,14 +38,20 @@ class DeviceMonitor(QObject):
def stop(self): def stop(self):
self.timer.stop() 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() self._device_ids.clear()
def _poll(self): def _poll(self):
try: if self._poll_worker and self._poll_worker.isRunning():
devices = IPodDevice().detect_devices() return
current_ids = {d["id"] for d in devices} self._poll_worker = _DetectWorker()
if current_ids != self._device_ids: self._poll_worker.finished.connect(self._on_devices_detected)
self._device_ids = current_ids self._poll_worker.start()
self.state_changed.emit(devices)
except Exception: def _on_devices_detected(self, devices):
logger.exception("DeviceMonitor poll failed") current_ids = {d["id"] for d in devices}
if current_ids != self._device_ids:
self._device_ids = current_ids
self.state_changed.emit(devices)

View File

@ -132,12 +132,27 @@ MASTER_CONTAINER_PID = 203092939621887772
MUSIC_CONTAINER_PID = 4872745547565090077 MUSIC_CONTAINER_PID = 4872745547565090077
_REMOVE_ARTWORK = object()
"""Sentinel: used in artwork_overrides to remove artwork from a track."""
# ── Helpers ────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────
def _to_mac_epoch(ts: int) -> int: def _to_mac_epoch(ts: int) -> int:
return ts + MAC_EPOCH_OFFSET if ts > 0 else 0 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]: def _read_audio_tags(filepath: str) -> dict[str, Any]:
"""Extract title, artist, album, duration, etc. from an audio file.""" """Extract title, artist, album, duration, etc. from an audio file."""
info: dict[str, Any] = {} info: dict[str, Any] = {}
@ -374,6 +389,7 @@ class Nano7Database:
tags["file_name"] = fname tags["file_name"] = fname
tags["file_size"] = os.path.getsize(fpath) tags["file_size"] = os.path.getsize(fpath)
tags["pid"] = self._hash_to_pid(fpath) tags["pid"] = self._hash_to_pid(fpath)
tags["content_hash"] = content_hash(fpath)
results.append(tags) results.append(tags)
return results return results
@ -471,6 +487,7 @@ class Nano7Database:
"year": s.get("year", 0), "year": s.get("year", 0),
"ipod_path": None, "ipod_path": None,
"file_path": s["file_path"], "file_path": s["file_path"],
"content_hash": s.get("content_hash", ""),
}) })
return tracks return tracks
@ -602,15 +619,127 @@ class Nano7Database:
logger.exception("iTunesDB sync failed: %s", e) logger.exception("iTunesDB sync failed: %s", e)
def _sync_itunescdb_impl(self) -> None: def _sync_itunescdb_impl(self) -> None:
# 1. Scan all audio files on iPod
scanned = self._scan_ipod_files() scanned = self._scan_ipod_files()
if not scanned: if not scanned:
logger.warning("No audio files found on iPod — writing empty databases") logger.warning("No audio files found on iPod — writing empty databases")
# Write minimal empty databases
self._write_empty_databases() self._write_empty_databases()
return 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]] = {} artwork_map: dict[str, tuple[int, int, int]] = {}
art_entries: list[ArtworkEntry] = [] art_entries: list[ArtworkEntry] = []
art_hash_to_img_id: dict[str, int] = {} art_hash_to_img_id: dict[str, int] = {}
@ -618,10 +747,16 @@ class Nano7Database:
for idx, s in enumerate(scanned): for idx, s in enumerate(scanned):
fpath = s["file_path"] fpath = s["file_path"]
try: override = artwork_overrides.get(fpath)
art_result = prepare_artwork_for_track(fpath) if override is None:
except Exception: try:
art_result = prepare_artwork_for_track(fpath)
except Exception:
art_result = None
elif override is _REMOVE_ARTWORK:
art_result = None art_result = None
else:
art_result = override
if art_result is not None: if art_result is not None:
art_hash, formats = art_result art_hash, formats = art_result
art_count = len(formats) art_count = len(formats)
@ -657,11 +792,11 @@ class Nano7Database:
except Exception as e: except Exception as e:
logger.warning("ArtworkDB write failed: %s", 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) tracks = _scanned_tracks_to_iop(self.mountpoint, scanned, artwork_map)
logger.info("Built %d iOpenPod TrackInfo objects", len(tracks)) 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") ipod_device = _import_iop("ipod_device")
capabilities = None capabilities = None
try: try:
@ -675,7 +810,7 @@ class Nano7Database:
except Exception as e: except Exception as e:
logger.debug("Capabilities detection failed: %s", 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: try:
firewire_id = ipod_device.get_firewire_id(self.mountpoint) firewire_id = ipod_device.get_firewire_id(self.mountpoint)
except Exception: except Exception:
@ -695,7 +830,7 @@ class Nano7Database:
return return
logger.info("iTunesDB written (%d tracks)", len(tracks)) 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 db_pid = 0
try: try:
ipod_dev = _import_iop("ipod_device") ipod_dev = _import_iop("ipod_device")
@ -709,7 +844,7 @@ class Nano7Database:
except Exception as e: except Exception as e:
logger.warning("Could not extract db_pid: %s", 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: try:
write_sqlite_databases = _import_iop("SQLiteDB_Writer").write_sqlite_databases write_sqlite_databases = _import_iop("SQLiteDB_Writer").write_sqlite_databases
sqlite_ok = write_sqlite_databases( sqlite_ok = write_sqlite_databases(

View File

@ -5,6 +5,7 @@ Caches extracted tag data per file path with mtime-based invalidation.
Stored as JSON in XDG cache directory. Stored as JSON in XDG cache directory.
""" """
import hashlib
import json import json
import logging import logging
import os import os
@ -17,11 +18,23 @@ logger = logging.getLogger(__name__)
_CACHE_FILE = "library_cache.json" _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: class LibraryCache:
"""JSON-based cache of audio file metadata. """JSON-based cache of audio file metadata.
Keys are absolute file paths. Values are dicts of 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 Cached entries are returned only when the file still exists
and its ``os.path.getmtime`` matches the stored value. and its ``os.path.getmtime`` matches the stored value.
@ -64,7 +77,15 @@ class LibraryCache:
actual_mtime = os.path.getmtime(file_path) actual_mtime = os.path.getmtime(file_path)
if actual_mtime != entry.get("_mtime", 0): if actual_mtime != entry.get("_mtime", 0):
return None 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: def put(self, file_path: str, data: Dict[str, Any]) -> None:
entry = dict(data) entry = dict(data)
@ -72,6 +93,7 @@ class LibraryCache:
entry["_mtime"] = os.path.getmtime(file_path) entry["_mtime"] = os.path.getmtime(file_path)
except OSError: except OSError:
return return
entry["_content_hash"] = _content_hash(file_path)
self._data[file_path] = entry self._data[file_path] = entry
def remove(self, file_path: str) -> None: def remove(self, file_path: str) -> None:

View File

@ -132,21 +132,9 @@ class iPodTab(QWidget):
if self.ipod_devices: if self.ipod_devices:
device = self.ipod_devices[0] device = self.ipod_devices[0]
device_id = device["id"]
detected_mount = device.get("mount_point") detected_mount = device.get("mount_point")
already_mounted = device.get("mounted", False) already_mounted = device.get("mounted", False)
self._start_mount_and_load(device, detected_mount, already_mounted)
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)
else: else:
self._set_device_not_found() self._set_device_not_found()
else: else:
@ -154,27 +142,67 @@ class iPodTab(QWidget):
self._cleanup_worker() self._cleanup_worker()
def _set_device_mounted(self, device: dict, mount_point: str): def _start_mount_and_load(self, device: dict, mount_point=None, already_mounted=False):
self.current_mount_point = mount_point self.device_status_label.setText("Mounting and loading tracks...")
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")
self.mount_button.setEnabled(False) 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): def _set_device_unmounted(self, device: dict):
self.current_mount_point = None self.current_mount_point = None
@ -213,27 +241,8 @@ class iPodTab(QWidget):
return return
device = self.ipod_devices[0] device = self.ipod_devices[0]
device_id = device["id"]
detected_mount = device.get("mount_point") detected_mount = device.get("mount_point")
self._start_mount_and_load(device, detected_mount, already_mounted=False)
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}")
def _on_eject_clicked(self): def _on_eject_clicked(self):
reply = QMessageBox.question( reply = QMessageBox.question(

View File

@ -214,6 +214,12 @@ class LibraryTab(QWidget):
self.library_transfer_btn.clicked.connect(self._on_library_transfer_clicked) self.library_transfer_btn.clicked.connect(self._on_library_transfer_clicked)
toolbar.addWidget(self.library_transfer_btn) 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 = QPushButton("\uD83D\uDDD1\uFE0F Remove")
self.library_remove_btn.setEnabled(False) self.library_remove_btn.setEnabled(False)
self.library_remove_btn.clicked.connect(self._on_library_remove_selected) self.library_remove_btn.clicked.connect(self._on_library_remove_selected)
@ -233,6 +239,7 @@ class LibraryTab(QWidget):
self.library_add_btn, self.library_add_btn,
self.library_convert_btn, self.library_convert_btn,
self.library_transfer_btn, self.library_transfer_btn,
self.library_sync_btn,
self.library_remove_btn, self.library_remove_btn,
self.library_edit_btn, self.library_edit_btn,
refresh_btn, refresh_btn,
@ -717,7 +724,9 @@ class LibraryTab(QWidget):
has_ready = ready_count > 0 has_ready = ready_count > 0
has_source = source_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_convert_btn.setEnabled(has_source)
self.library_remove_btn.setEnabled(len(all_tracks) > 0) self.library_remove_btn.setEnabled(len(all_tracks) > 0)
self.library_edit_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)) play_action.triggered.connect(lambda: self._play_track_from_index(row))
if is_ready and self._current_mount_point is not None: 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 = menu.addAction("\uD83D\uDCE4 Transfer to iPod")
transfer_action.triggered.connect(self._on_library_transfer_clicked) transfer_action.triggered.connect(self._on_library_transfer_clicked)
else: else:
@ -1129,6 +1140,84 @@ class LibraryTab(QWidget):
self._cleanup_worker() 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): def _cleanup_worker(self):
if self.worker_thread: if self.worker_thread:
self.worker_thread.wait(3000) self.worker_thread.wait(3000)
@ -1168,6 +1257,7 @@ class LibraryTab(QWidget):
def on_device_unmounted(self): def on_device_unmounted(self):
self._current_mount_point = None self._current_mount_point = None
self.library_transfer_btn.setEnabled(False) self.library_transfer_btn.setEnabled(False)
self.library_sync_btn.setEnabled(False)
self.library_progress.setVisible(False) self.library_progress.setVisible(False)
self.library_status.setVisible(False) self.library_status.setVisible(False)

View File

@ -39,9 +39,11 @@ class WorkerThread(QThread):
"convert": self._run_convert, "convert": self._run_convert,
"transfer": self._run_transfer, "transfer": self._run_transfer,
"detect_devices": self._run_detect_devices, "detect_devices": self._run_detect_devices,
"mount_and_load": self._run_mount_and_load,
"export_ipod": self._run_export_ipod, "export_ipod": self._run_export_ipod,
"delete_tracks": self._run_delete_tracks, "delete_tracks": self._run_delete_tracks,
"remove_duplicates": self._run_remove_duplicates, "remove_duplicates": self._run_remove_duplicates,
"sync_metadata": self._run_sync_metadata,
} }
handler = handlers.get(self.task_type) handler = handlers.get(self.task_type)
if handler: if handler:
@ -168,6 +170,48 @@ class WorkerThread(QThread):
self.finished_signal.emit(True, f"Found {len(devices)} iPod devices", devices) 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): def _run_export_ipod(self):
tracks = self.kwargs.get("tracks", []) tracks = self.kwargs.get("tracks", [])
dest_dir = self.kwargs.get("dest_dir", "") dest_dir = self.kwargs.get("dest_dir", "")
@ -221,6 +265,50 @@ class WorkerThread(QThread):
removed = db.remove_duplicates(progress_callback=progress) removed = db.remove_duplicates(progress_callback=progress)
self.finished_signal.emit(True, f"Removed {len(removed)} duplicate(s)", removed) 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): def stop(self):
self.is_running = False self.is_running = False
self.wait() self.wait()