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:
parent
25bb8105ac
commit
3bfb0ebbfe
@ -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)
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user