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

View File

@ -14,7 +14,7 @@ from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QApplication, QMainWindow, QWidget, QVBoxLayout,
QSplitter, QStackedWidget, QSplitter, QStackedWidget,
) )
from PyQt6.QtCore import Qt, QUrl from PyQt6.QtCore import Qt, QUrl, QTimer
from PyQt6.QtGui import QPixmap from PyQt6.QtGui import QPixmap
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
@ -64,6 +64,8 @@ class MainWindow(QMainWindow):
self._shuffle_active: bool = False self._shuffle_active: bool = False
self._repeat_mode: str = "no_repeat" self._repeat_mode: str = "no_repeat"
self._auto_sync_worker = None
self._setup_player() self._setup_player()
self._setup_ui() self._setup_ui()
self.library_tab.set_playlist_manager(self.playlist_manager) self.library_tab.set_playlist_manager(self.playlist_manager)
@ -269,12 +271,33 @@ class MainWindow(QMainWindow):
def _on_device_mounted(self, mount_point: str): def _on_device_mounted(self, mount_point: str):
self.library_tab.on_device_mounted(mount_point) self.library_tab.on_device_mounted(mount_point)
self.library_tab.refresh_play_counts_from_cache()
self.statusBar().showMessage(f"iPod mounted at {mount_point}") self.statusBar().showMessage(f"iPod mounted at {mount_point}")
QTimer.singleShot(2000, lambda: self._start_auto_sync(mount_point))
def _on_device_unmounted(self): def _on_device_unmounted(self):
self.library_tab.on_device_unmounted() self.library_tab.on_device_unmounted()
self.statusBar().showMessage("iPod disconnected") self.statusBar().showMessage("iPod disconnected")
def _start_auto_sync(self, mount_point: str):
if self._auto_sync_worker is not None:
return
from worker import WorkerThread
self._auto_sync_worker = WorkerThread(
"auto_sync_playcounts", parent=self, mount_point=mount_point,
)
self._auto_sync_worker.progress_signal.connect(self._on_auto_sync_progress)
self._auto_sync_worker.finished_signal.connect(self._on_auto_sync_finished)
self._auto_sync_worker.start()
def _on_auto_sync_progress(self, pct: int, msg: str):
pass
def _on_auto_sync_finished(self, success: bool, msg: str, _result):
if success and msg.startswith("Auto-sync complete"):
self.library_tab.refresh_play_counts_from_cache()
self._auto_sync_worker = None
def _toggle_sidebar(self): def _toggle_sidebar(self):
visible = not self.sidebar.isVisible() visible = not self.sidebar.isVisible()
self.sidebar.setVisible(visible) self.sidebar.setVisible(visible)

View File

@ -118,6 +118,10 @@ def _import_iop(module_name: str):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# iOpenPod parsers/writers log ERROR for transient I/O (USB resettling)
logging.getLogger("iTunesDB_Parser").setLevel(logging.CRITICAL)
logging.getLogger("iTunesDB_Writer").setLevel(logging.CRITICAL)
# ── constants ──────────────────────────────────────────────────────── # ── constants ────────────────────────────────────────────────────────
FILETYPE_CODES = { FILETYPE_CODES = {
"mp3": 0x4D503320, "mp3": 0x4D503320,
@ -511,8 +515,8 @@ class Nano7Database:
location = f":iPod_Control:Music:{rel.replace(os.sep, ':')}" location = f":iPod_Control:Music:{rel.replace(os.sep, ':')}"
bs = base_stats.get(location, {}) bs = base_stats.get(location, {})
dd = delta_stats.get(location, {}) dd = delta_stats.get(location, {})
pc = bs.get("play_count", 0) + dd.get("play_count", 0) pc = bs.get("play_count", 0)
sc = bs.get("skip_count", 0) + dd.get("skip_count", 0) sc = bs.get("skip_count", 0)
dd_rt = dd.get("rating", 0) dd_rt = dd.get("rating", 0)
rating = dd_rt if dd_rt >= 0 else bs.get("rating", 0) rating = dd_rt if dd_rt >= 0 else bs.get("rating", 0)
tracks.append({ tracks.append({
@ -767,12 +771,17 @@ class Nano7Database:
# ── sync: regenerate everything via iOpenPod ────────────────────── # ── sync: regenerate everything via iOpenPod ──────────────────────
def sync_itunescdb(self) -> None: def sync_itunescdb(self) -> bool:
"""Regenerate ALL databases using iOpenPod's writers.""" """Regenerate ALL databases using iOpenPod's writers.
Returns True if the databases were written successfully.
"""
try: try:
self._sync_itunescdb_impl() self._sync_itunescdb_impl()
return True
except Exception as e: except Exception as e:
logger.exception("iTunesDB sync failed: %s", e) logger.exception("iTunesDB sync failed: %s", e)
return False
def _merge_play_stats(self, scanned: list[dict]) -> None: def _merge_play_stats(self, scanned: list[dict]) -> None:
"""Merge iPod + local play stats into *scanned* track dicts in-place. """Merge iPod + local play stats into *scanned* track dicts in-place.
@ -807,13 +816,13 @@ class Nano7Database:
bs.get("play_count", 0), bs.get("play_count", 0),
(db_entry or {}).get("play_count", 0), (db_entry or {}).get("play_count", 0),
local_pc.get("play_count", 0), local_pc.get("play_count", 0),
) + dd.get("play_count", 0) )
s["skip_count"] = max( s["skip_count"] = max(
bs.get("skip_count", 0), bs.get("skip_count", 0),
(db_entry or {}).get("skip_count", 0), (db_entry or {}).get("skip_count", 0),
local_pc.get("skip_count", 0), local_pc.get("skip_count", 0),
) + dd.get("skip_count", 0) )
dd_rating = dd.get("rating", 0) dd_rating = dd.get("rating", 0)
store_rating = (db_entry or {}).get("rating", 0) store_rating = (db_entry or {}).get("rating", 0)
@ -970,6 +979,41 @@ class Nano7Database:
) )
return base_stats, delta_stats return base_stats, delta_stats
def read_play_counts_delta(self) -> dict[str, dict[str, int]]:
"""Read Play Counts file using filesystem scan (no iTunesDB needed).
Positional mapping uses ``_scan_ipod_files()`` order (correct after
any sync written by this app). Returns ``{location: {play_count,
skip_count, rating}}`` or empty dict on failure.
"""
pc_path = os.path.join(
self.mountpoint, "iPod_Control", "iTunes", "Play Counts",
)
if not os.path.exists(pc_path):
return {}
pc_mod = _import_iop("iTunesDB_Parser.playcounts")
try:
entries = pc_mod.parse_playcounts(pc_path)
except Exception:
return {}
if not entries:
return {}
scanned = self._scan_ipod_files()
delta: dict[str, dict[str, int]] = {}
for i, entry in enumerate(entries):
if i >= len(scanned):
break
if entry.play_count == 0 and entry.skip_count == 0 and entry.rating < 0:
continue
rel = os.path.relpath(scanned[i]["file_path"], self.music_base)
loc = f":iPod_Control:Music:{rel.replace(os.sep, ':')}"
delta[loc] = {
"play_count": entry.play_count,
"skip_count": entry.skip_count,
"rating": entry.rating,
}
return delta
@staticmethod @staticmethod
def _load_local_play_stats() -> dict[str, dict[str, int]]: def _load_local_play_stats() -> dict[str, dict[str, int]]:
"""Read accumulated play stats from the local library cache. """Read accumulated play stats from the local library cache.

View File

@ -31,7 +31,7 @@ _DB_FILE = "library_cache.db"
_BACKUP_FILE = "library_cache.db.backup" _BACKUP_FILE = "library_cache.db.backup"
_OLD_JSON_FILE = "library_cache.json" _OLD_JSON_FILE = "library_cache.json"
_SCHEMA_VERSION = 1 _SCHEMA_VERSION = 2
_SCHEMA = """ _SCHEMA = """
CREATE TABLE IF NOT EXISTS library_cache ( CREATE TABLE IF NOT EXISTS library_cache (
@ -64,7 +64,11 @@ CREATE INDEX IF NOT EXISTS idx_lc_hash ON library_cache(content_hash);
CREATE INDEX IF NOT EXISTS idx_lc_artist ON library_cache(artist); CREATE INDEX IF NOT EXISTS idx_lc_artist ON library_cache(artist);
CREATE INDEX IF NOT EXISTS idx_lc_album ON library_cache(album); CREATE INDEX IF NOT EXISTS idx_lc_album ON library_cache(album);
PRAGMA user_version = {version}; CREATE TABLE IF NOT EXISTS playcounts_log (
firewire_id TEXT PRIMARY KEY,
last_synced_delta TEXT NOT NULL DEFAULT '{}'
);
""" """
@ -150,11 +154,18 @@ class LibraryCache:
self._init_db() self._init_db()
def _init_db(self) -> None: def _init_db(self) -> None:
self.conn.executescript( self._migrate_schema()
_SCHEMA.format(version=_SCHEMA_VERSION) self.conn.executescript(_SCHEMA)
) self.conn.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}")
self.conn.commit() self.conn.commit()
def _migrate_schema(self) -> None:
cur_ver = self.conn.execute("PRAGMA user_version").fetchone()[0]
if cur_ver >= 2:
return
if cur_ver == 1:
self.conn.execute("DROP TABLE IF EXISTS playcounts_log")
# ── public read API ──────────────────────────────────────────────── # ── public read API ────────────────────────────────────────────────
def get(self, file_path: str) -> dict[str, Any] | None: def get(self, file_path: str) -> dict[str, Any] | None:
@ -302,6 +313,37 @@ class LibraryCache:
self.conn.commit() self.conn.commit()
self._backup() self._backup()
# ── play-counts delta deduplication ──────────────────────────────────
def get_last_synced_delta(self, firewire_id: str) -> dict[str, dict[str, int]]:
"""Return the delta dict that was last committed for this iPod.
Returns an empty dict if no sync has happened yet.
"""
with self._lock:
row = self.conn.execute(
"SELECT last_synced_delta FROM playcounts_log WHERE firewire_id=?",
(firewire_id,),
).fetchone()
if row and row["last_synced_delta"]:
try:
return json.loads(row["last_synced_delta"])
except json.JSONDecodeError:
pass
return {}
def set_last_synced_delta(self, firewire_id: str,
delta: dict[str, dict[str, int]]) -> None:
"""Store the delta that was committed during the last sync."""
blob = json.dumps(delta, sort_keys=True, separators=(",", ":"))
with self._lock:
self.conn.execute(
"INSERT OR REPLACE INTO playcounts_log (firewire_id, last_synced_delta) "
"VALUES (?, ?)",
(firewire_id, blob),
)
self._backup()
# ── backup rotation ──────────────────────────────────────────────── # ── backup rotation ────────────────────────────────────────────────
def _backup(self) -> None: def _backup(self) -> None:

View File

@ -1023,9 +1023,45 @@ class iPodTab(QWidget):
pass pass
def _on_auto_state_changed(self, devices): 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(): if self._is_worker_running():
return 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): def _on_device_combo_changed(self, index):
device = self.device_combo.itemData(index, Qt.ItemDataRole.UserRole) device = self.device_combo.itemData(index, Qt.ItemDataRole.UserRole)

View File

@ -685,6 +685,52 @@ class LibraryTab(QWidget):
self._restore_playing_row() self._restore_playing_row()
self._build_album_grid() 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): def _restore_playing_row(self):
idx = self.current_playback_index idx = self.current_playback_index
if idx < 0: if idx < 0:

View File

@ -24,6 +24,10 @@ SOURCE_EXTS = {'.flac', '.wav', '.ogg', '.opus', '.wma', '.ape', '.aiff', '.alac
class WorkerThread(QThread): class WorkerThread(QThread):
"""Worker thread for background tasks""" """Worker thread for background tasks"""
_processed_fwids: set[str] = set()
_auto_synced_fwids: set[str] = set()
_pending_commit_fwids: set[str] = set()
progress_signal = pyqtSignal(int, str) progress_signal = pyqtSignal(int, str)
finished_signal = pyqtSignal(bool, str, object) finished_signal = pyqtSignal(bool, str, object)
@ -45,6 +49,7 @@ class WorkerThread(QThread):
"remove_duplicates": self._run_remove_duplicates, "remove_duplicates": self._run_remove_duplicates,
"sync_metadata": self._run_sync_metadata, "sync_metadata": self._run_sync_metadata,
"create_playlist": self._run_create_playlist, "create_playlist": self._run_create_playlist,
"auto_sync_playcounts": self._run_auto_sync_playcounts,
} }
handler = handlers.get(self.task_type) handler = handlers.get(self.task_type)
if handler: if handler:
@ -208,11 +213,11 @@ class WorkerThread(QThread):
from library_cache import get_library_cache, save_library_cache from library_cache import get_library_cache, save_library_cache
cache = get_library_cache() cache = get_library_cache()
# Read deltas from iPod Play Counts file without deleting it — ipod_base, _ipod_delta = db.read_play_stats()
# the file is cleaned up by _cleanup_play_counts_file() only after a fwid = db.firewire_id.hex()
# successful DB write in _sync_itunescdb_impl / sync_metadata_for. WorkerThread._processed_fwids.add(fwid)
ipod_base, ipod_delta = db.read_play_stats()
# ── build content_hash → iPod location map ──────────────────────
hash_to_location: dict[str, str] = {} hash_to_location: dict[str, str] = {}
for t in tracks: for t in tracks:
ch = t.get("content_hash") ch = t.get("content_hash")
@ -225,6 +230,7 @@ class WorkerThread(QThread):
loc = f":iPod_Control:Music:{rel.replace(os.sep, ':')}" loc = f":iPod_Control:Music:{rel.replace(os.sep, ':')}"
hash_to_location[ch] = loc hash_to_location[ch] = loc
# ── merge into local cache ──────────────────────────────────────
updated_cache = 0 updated_cache = 0
for file_path, entry in cache.iter_entries(): for file_path, entry in cache.iter_entries():
if not os.path.exists(file_path): if not os.path.exists(file_path):
@ -234,16 +240,14 @@ class WorkerThread(QThread):
continue continue
loc = hash_to_location[ch] loc = hash_to_location[ch]
bs = ipod_base.get(loc, {}) bs = ipod_base.get(loc, {})
dd = ipod_delta.get(loc, {})
new_play_count = max( new_play_count = max(
entry.get("play_count", 0), bs.get("play_count", 0), entry.get("play_count", 0), bs.get("play_count", 0),
) + dd.get("play_count", 0) )
new_skip_count = max( new_skip_count = max(
entry.get("skip_count", 0), bs.get("skip_count", 0), entry.get("skip_count", 0), bs.get("skip_count", 0),
) + dd.get("skip_count", 0) )
dd_rating = dd.get("rating", 0) new_rating = bs.get("rating", 0) or entry.get("rating", 0)
new_rating = dd_rating if dd_rating >= 0 else bs.get("rating", 0) or entry.get("rating", 0)
new_last_played = max( new_last_played = max(
entry.get("last_played", 0), bs.get("last_played", 0), entry.get("last_played", 0), bs.get("last_played", 0),
) )
@ -261,6 +265,14 @@ class WorkerThread(QThread):
save_library_cache() save_library_cache()
logger.info("Updated play counts for %d local tracks from iPod", updated_cache) logger.info("Updated play counts for %d local tracks from iPod", updated_cache)
# ── deferred iTunesDB commit on stable USB ──────────────────
if fwid in WorkerThread._pending_commit_fwids and os.access(mount_point, os.W_OK):
if db.sync_itunescdb():
WorkerThread._pending_commit_fwids.discard(fwid)
logger.info("Deferred iTunesDB commit for %s complete", fwid)
else:
logger.debug("Deferred commit for %s failed (read-only filesystem)", fwid)
self.progress_signal.emit(80, "Loading complete") self.progress_signal.emit(80, "Loading complete")
result = { result = {
@ -273,6 +285,105 @@ class WorkerThread(QThread):
True, f"Loaded {len(tracks)} tracks from iPod", result True, f"Loaded {len(tracks)} tracks from iPod", result
) )
def _run_auto_sync_playcounts(self):
mount_point = self.kwargs.get("mount_point")
if not mount_point:
self.finished_signal.emit(False, "No mount point for auto-sync", None)
return
ipod_ctrl = os.path.join(mount_point, "iPod_Control")
if not os.path.exists(ipod_ctrl):
self.finished_signal.emit(True, "Auto-sync skipped (device gone)", None)
return
from ipod_nano7_db import Nano7Database
db = Nano7Database(mount_point)
fwid = db.firewire_id.hex()
if fwid in WorkerThread._auto_synced_fwids:
self.finished_signal.emit(True, "Auto-sync already done", None)
return
ipod_base, ipod_delta = db.read_play_stats()
if not ipod_base and not ipod_delta:
import time
time.sleep(3)
ipod_base, ipod_delta = db.read_play_stats()
if not ipod_base and not ipod_delta:
ipod_delta = db.read_play_counts_delta()
if not ipod_delta:
self.finished_signal.emit(True, "Auto-sync: no delta", None)
return
from library_cache import get_library_cache, save_library_cache
cache = get_library_cache()
stored = cache.get_last_synced_delta(fwid)
effective: dict[str, dict[str, int]] = {}
for loc, dd in ipod_delta.items():
sd = stored.get(loc, {})
eff_pc = max(0, dd.get("play_count", 0) - sd.get("play_count", 0))
eff_sc = max(0, dd.get("skip_count", 0) - sd.get("skip_count", 0))
if eff_pc > 0 or eff_sc > 0:
effective[loc] = {"play_count": eff_pc, "skip_count": eff_sc}
dd_rating = dd.get("rating", 0)
if dd_rating > 0:
effective.setdefault(loc, {})["rating"] = dd_rating
cache.set_last_synced_delta(fwid, ipod_delta)
if effective:
tracks = db.get_all_tracks()
hash_to_loc: dict[str, str] = {}
loc_to_hash: dict[str, str] = {}
for t in tracks:
ch = t.get("content_hash", "")
fpath = t.get("file_path", "")
if ch and fpath:
rel = os.path.relpath(
fpath, os.path.join(db.mountpoint, "iPod_Control", "Music"),
)
loc = f":iPod_Control:Music:{rel.replace(os.sep, ':')}"
loc_to_hash[loc] = ch
hash_to_loc[ch] = loc
self.progress_signal.emit(40, "Merging play counts...")
updated = 0
for file_path, entry in cache.iter_entries():
ch = entry.get("content_hash", "")
if ch not in hash_to_loc:
continue
loc = hash_to_loc[ch]
eff = effective.get(loc)
if not eff:
continue
bs = ipod_base.get(loc, {})
new_pc = max(entry.get("play_count", 0), bs.get("play_count", 0)) + eff.get(
"play_count", 0,
)
new_sc = max(entry.get("skip_count", 0), bs.get("skip_count", 0)) + eff.get(
"skip_count", 0,
)
new_rt = eff.get("rating", 0) or bs.get("rating", 0) or entry.get("rating", 0)
new_lp = max(entry.get("last_played", 0), bs.get("last_played", 0))
cache.update_play_stats(
file_path,
play_count=new_pc,
rating=new_rt,
last_played=new_lp,
skip_count=new_sc,
)
updated += 1
if updated:
save_library_cache()
logger.info("Auto-sync: applied %d effective deltas", updated)
self.progress_signal.emit(100, "Auto-sync complete")
WorkerThread._pending_commit_fwids.add(fwid)
WorkerThread._auto_synced_fwids.add(fwid)
self.finished_signal.emit(True, "Auto-sync complete", mount_point)
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", "")