fix: reliable play count sync with auto-sync + deferred iTunesDB commit

Stops double-counting when iPod Nano 7G regenerates Play Counts
file after a DB write (firmware quirk, new mtime with stale data).

Architecture:
- Mount: only max(cache, base) — never reads Play Counts delta
- 2s after mount → auto-sync worker: reads delta, subtracts stored
  (persistent guard), applies effective to cache, defers DB write
- iTunesDB commit deferred to next stable mount (checks W_OK first)
- read_play_stats() uses 3s retry + read_play_counts_delta() fallback
  (filesystem-based PC parsing, independent of iTunesDB I/O errors)

Changes:
- ipod_nano7_db.py: sync_itunescdb() returns bool; new
  read_play_counts_delta() method; get_all_tracks() no longer adds
  delta; _merge_play_stats() drops delta addition; suppress iOpenPod
  ERROR logs to CRITICAL
- library_cache.py: schema v2 with playcounts_log table +
  get/set_last_synced_delta, migration from v1
- worker.py: session guards (_processed_fwids, _auto_synced_fwids,
  _pending_commit_fwids); auto_sync_playcounts task; deferred commit
  in mount
- app.py: QTimer.singleShot(2000) after mount for auto-sync
- ipod_tab.py: _on_auto_state_changed rewritten (no unmount, mp guard)
- library_tab.py: refresh_play_counts_from_cache() method
This commit is contained in:
Maksim Totmin
2026-06-02 00:54:18 +07:00
parent d08125c7b3
commit 54cd87654f
6 changed files with 324 additions and 22 deletions
+37 -1
View File
@@ -1023,9 +1023,45 @@ class iPodTab(QWidget):
pass
def _on_auto_state_changed(self, devices):
"""Automatic device detection callback from the monitor timer.
Updates the device combo silently and only mounts a device that
is not already active. Idempotent Play Counts processing in
the worker prevents doublecounting even if the mount handler
runs multiple times due to USB glitches.
"""
if self._is_worker_running():
return
self._on_device_detection_finished(True, f"Found {len(devices)} devices", devices)
self._refresh_device_combo(devices)
if not devices:
return
mp = devices[0].get("mount_point", "")
if mp and mp == self.current_mount_point:
return
self._start_mount_and_load(
devices[0],
mount_point=mp,
already_mounted=devices[0].get("mounted", False),
)
def _refresh_device_combo(self, devices):
"""Update the device combo box without triggering remount."""
self.device_combo.blockSignals(True)
self.device_combo.clear()
for device in devices:
mounted = device.get("mounted", False)
suffix = " [mounted]" if mounted else " [not mounted]"
self.device_combo.addItem(device.get("name", "Unknown Device") + suffix)
idx = self.device_combo.count() - 1
self.device_combo.setItemData(idx, device, Qt.ItemDataRole.UserRole)
if devices:
self.device_combo.setCurrentIndex(0)
self.device_combo.blockSignals(False)
def _on_device_combo_changed(self, index):
device = self.device_combo.itemData(index, Qt.ItemDataRole.UserRole)
+46
View File
@@ -685,6 +685,52 @@ class LibraryTab(QWidget):
self._restore_playing_row()
self._build_album_grid()
def refresh_play_counts_from_cache(self):
"""Reload play-count values from the persistent cache without a
full library rescan.
Called after ``mount_and_load`` completes — the cache already
holds merged iPod + local play stats, but the table was
rendered before the mount and displays stale values.
"""
cache = get_library_cache()
updated = 0
ready_index = {t["path"]: i for i, t in enumerate(self.library_ready)}
for row in range(self.library_table.rowCount()):
item_0 = self.library_table.item(row, 0)
if item_0 is None:
continue
data = item_0.data(Qt.ItemDataRole.UserRole)
if data is None:
continue
_is_ready, track = data
if not _is_ready:
continue
path = track.get("path", "")
if not path:
continue
entry = cache.get(path)
if entry is None:
continue
new_pc = entry.get("play_count", 0)
old_pc = track.get("play_count", 0)
if new_pc != old_pc:
track["play_count"] = new_pc
pc_cell = self.library_table.item(row, 7)
if pc_cell:
pc_cell.setText(str(new_pc))
idx = ready_index.get(path)
if idx is not None:
self.library_ready[idx]["play_count"] = new_pc
updated += 1
if updated:
logger.debug("Refreshed play counts for %d tracks from cache", updated)
def _restore_playing_row(self):
idx = self.current_playback_index
if idx < 0: