From 4c703db802dad217ef71624bdc8dd217f79a932f Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Mon, 1 Jun 2026 13:21:22 +0700 Subject: [PATCH] Add incremental metadata/artwork sync to iPod (iTunes-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - content_hash() — SHA1 of first 64KB for matching local↔iPod tracks - Nano7Database.update_track_metadata() — update DB records only, never touch audio files on the iPod - Nano7Database.find_track_by_content_hash() + find_track_by_metadata() - _write_databases_from_tracks() extracted from sync_itunescdb, supports artwork_overrides parameter - LibraryCache stores content_hash for cached tracks - Worker + UI: 'Sync Metadata to iPod' button + context menu item - _REMOVE_ARTWORK sentinel for explicit artwork removal - track_info.py: add play_count, rating, last_played, skip_count fields --- src/ipod_nano7_db.py | 175 +++++++++++++++++++++++++++++++++++++++++- src/track_info.py | 4 + src/ui/library_tab.py | 43 ++++++++++- src/worker.py | 52 +++++++++++++ 4 files changed, 270 insertions(+), 4 deletions(-) diff --git a/src/ipod_nano7_db.py b/src/ipod_nano7_db.py index e246583..cdb1c01 100644 --- a/src/ipod_nano7_db.py +++ b/src/ipod_nano7_db.py @@ -329,6 +329,11 @@ def _scanned_tracks_to_iop(mountpoint: str, sample_count=sample_count, gapless_track_flag=1, gapless_album_flag=1 if has_album else 0, + play_count=s.get("play_count", 0), + rating=s.get("rating", 0), + last_played=s.get("last_played", 0), + skip_count=s.get("skip_count", 0), + played_mark=-1, ) tracks.append(t) return tracks @@ -469,10 +474,18 @@ class Nano7Database: return len(self._scan_ipod_files()) def get_all_tracks(self) -> list[dict]: - """Return all tracks on iPod with full metadata.""" + """Return all tracks on iPod with full metadata including play stats.""" scanned = self._scan_ipod_files() + base_stats, delta_stats = self.read_play_stats() tracks = [] for s in scanned: + rel = os.path.relpath(s["file_path"], self.music_base) + location = f":iPod_Control:Music:{rel.replace(os.sep, ':')}" + bs = base_stats.get(location, {}) + dd = delta_stats.get(location, {}) + pc = bs.get("play_count", 0) + dd.get("play_count", 0) + sc = bs.get("skip_count", 0) + dd.get("skip_count", 0) + rating = dd.get("rating", 0) or bs.get("rating", 0) tracks.append({ "pid": s["pid"], "title": s.get("title") or os.path.splitext(s["file_name"])[0], @@ -488,6 +501,10 @@ class Nano7Database: "ipod_path": None, "file_path": s["file_path"], "content_hash": s.get("content_hash", ""), + "play_count": pc, + "rating": rating, + "last_played": bs.get("last_played", 0), + "skip_count": sc, }) return tracks @@ -618,13 +635,65 @@ class Nano7Database: except Exception as e: logger.exception("iTunesDB sync failed: %s", e) + def _merge_play_stats(self, scanned: list[dict]) -> None: + """Merge iPod + local play stats into *scanned* track dicts in-place. + + Uses additive model (like iTunes): + ``new_value = max(ipod_base, local_count) + ipod_delta`` + """ + base_stats, delta_stats = self.read_play_stats() + local_play_stats = self._load_local_play_stats() + + for s in scanned: + rel = os.path.relpath(s["file_path"], self.music_base) + location = f":iPod_Control:Music:{rel.replace(os.sep, ':')}" + bs = base_stats.get(location, {}) + dd = delta_stats.get(location, {}) + ch = s.get("content_hash", "") + local_pc = local_play_stats.get(ch, {}) + + s["play_count"] = max( + bs.get("play_count", 0), local_pc.get("play_count", 0), + ) + dd.get("play_count", 0) + + s["skip_count"] = max( + bs.get("skip_count", 0), local_pc.get("skip_count", 0), + ) + dd.get("skip_count", 0) + + s["rating"] = dd.get("rating", 0) or bs.get("rating", 0) or local_pc.get("rating", 0) + + s["last_played"] = max( + bs.get("last_played", 0), local_pc.get("last_played", 0), + ) + + def _cleanup_play_counts_file(self) -> None: + """Delete the iPod 'Play Counts' delta file after a full sync.""" + pc_file = os.path.join( + self.mountpoint, "iPod_Control", "iTunes", "Play Counts", + ) + if os.path.exists(pc_file): + try: + os.remove(pc_file) + logger.debug("Deleted Play Counts file after sync") + except OSError as e: + logger.debug("Could not delete Play Counts file: %s", e) + + def consume_play_deltas(self) -> None: + """Public entry point: delete the Play Counts file after consuming + its data during mount. Prevents double-counting on subsequent + ``read_play_stats()`` calls.""" + self._cleanup_play_counts_file() + def _sync_itunescdb_impl(self) -> None: scanned = self._scan_ipod_files() if not scanned: logger.warning("No audio files found on iPod — writing empty databases") self._write_empty_databases() return + + self._merge_play_stats(scanned) self._write_databases_from_tracks(scanned) + self._cleanup_play_counts_file() def find_track_by_content_hash(self, content_hash: str) -> dict | None: """Find an iPod track by its content hash.""" @@ -646,6 +715,107 @@ class Nano7Database: return t return None + def read_play_stats(self) -> tuple[dict[str, dict[str, int]], dict[str, dict[str, int]]]: + """Read iPod play stats split into base (written) and delta (since last write). + + Returns ``(base_stats, delta_stats)`` keyed by iPod ``location``. + + **base_stats** — values from the binary iTunesDB / Dynamic.itdb + (the last written totals). Fields: ``play_count``, ``rating``, + ``last_played``, ``skip_count``. + + **delta_stats** — deltas from the ``Play Counts`` file accumulated + since the last database write. Fields: ``play_count``, ``skip_count``, + ``rating`` (0 if no delta activity). + + Both return empty dicts when the databases cannot be read (new or + corrupted iPod state). + """ + ipod_device = _import_iop("ipod_device") + try: + itdb_path = ipod_device.resolve_itdb_path(self.mountpoint) + except Exception: + itdb_path = os.path.join( + self.mountpoint, "iPod_Control", "iTunes", "iTunesDB", + ) + + base_stats: dict[str, dict[str, int]] = {} + delta_stats: dict[str, dict[str, int]] = {} + + try: + ipl = _import_iop("iTunesDB_Parser.ipod_library") + data = ipl.load_ipod_library(itdb_path, merge_playcounts=False) + except Exception as e: + logger.debug("Cannot read iPod play base stats: %s", e) + return base_stats, delta_stats + + if not data: + return base_stats, delta_stats + + tracks = data.get("mhlt", []) + for track in tracks: + loc = track.get("location", "") + if not loc: + continue + base_stats[loc] = { + "play_count": track.get("play_count_1", 0) or 0, + "rating": track.get("rating", 0) or 0, + "last_played": track.get("last_played", 0) or 0, + "skip_count": track.get("skip_count", 0) or 0, + } + + try: + pc_mod = _import_iop("iTunesDB_Parser.playcounts") + pc_dir = os.path.dirname(itdb_path) + pc_path = os.path.join(pc_dir, "Play Counts") + entries = pc_mod.parse_playcounts(pc_path) + if entries: + for i, entry in enumerate(entries): + if i >= len(tracks): + break + loc = tracks[i].get("location", "") + if not loc: + continue + delta_stats[loc] = { + "play_count": entry.play_count, + "skip_count": entry.skip_count, + "rating": entry.rating, + } + except Exception as e: + logger.debug("Cannot read iPod Play Counts deltas: %s", e) + + logger.debug( + "Read play stats — base: %d tracks, delta: %d tracks", + len(base_stats), len(delta_stats), + ) + return base_stats, delta_stats + + @staticmethod + def _load_local_play_stats() -> dict[str, dict[str, int]]: + """Read accumulated play stats from the local library cache. + + Returns ``{content_hash: {play_count, rating, last_played, + skip_count}}`` for every cached entry that has play data. + """ + from library_cache import get_library_cache + cache = get_library_cache() + result: dict[str, dict[str, int]] = {} + for entry in cache._data.values(): + ch = entry.get("_content_hash", "") + pc = entry.get("play_count", 0) + rating = entry.get("rating", 0) + lp = entry.get("last_played", 0) + sc = entry.get("skip_count", 0) + if pc or rating or lp or sc: + if ch: + result[ch] = { + "play_count": pc, + "rating": rating, + "last_played": lp, + "skip_count": sc, + } + return result + def update_track_metadata( self, pid: int, @@ -718,7 +888,9 @@ class Nano7Database: logger.warning("Failed to extract artwork from %s: %s", local_source_path, e) + self._merge_play_stats(scanned) self._write_databases_from_tracks(scanned, artwork_overrides=artwork_overrides) + self._cleanup_play_counts_file() logger.info("Updated metadata for track: %s", target.get("title", "?")) return True @@ -889,3 +1061,4 @@ class Nano7Database: db_pid=random.getrandbits(64), capabilities=capabilities, firewire_id=fwid, backup=True, ) + self._cleanup_play_counts_file() diff --git a/src/track_info.py b/src/track_info.py index 0024da0..628a811 100644 --- a/src/track_info.py +++ b/src/track_info.py @@ -21,3 +21,7 @@ class TrackInfo: download_path: Optional[str] = None genre: Optional[str] = None cover_path: Optional[str] = None + play_count: int = 0 + rating: int = 0 + last_played: int = 0 + skip_count: int = 0 diff --git a/src/ui/library_tab.py b/src/ui/library_tab.py index 3d0c679..e77c195 100644 --- a/src/ui/library_tab.py +++ b/src/ui/library_tab.py @@ -6,6 +6,7 @@ Provides the library UI, playback controls, and transfer/conversion workflows. import os import sys +import time import logging from typing import List, Dict, Optional, Tuple, Any from pathlib import Path @@ -22,7 +23,7 @@ from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput from track_info import TrackInfo from metadata_handler import MetadataHandler from ui.metadata_editor import MetadataEditorDialog -from library_cache import get_library_cache, invalidate_cache_for_path +from library_cache import get_library_cache, invalidate_cache_for_path, save_library_cache from artwork.cache import cover_cache from config_loader import ConfigLoader from worker import WorkerThread @@ -74,6 +75,7 @@ class LibraryTab(QWidget): self._saved_position_ms: int = 0 self._saved_playing: bool = False self._pending_seek_ms: int = 0 + self._halfway_passed: set[int] = set() self._library_toolbar_buttons: list = [] @@ -163,9 +165,9 @@ class LibraryTab(QWidget): self._setup_library_toolbar(layout) self.library_table = QTableWidget() - self.library_table.setColumnCount(7) + self.library_table.setColumnCount(8) self.library_table.setHorizontalHeaderLabels( - ["#", "Artist", "Title", "Album", "Duration", "Genre", "Size"] + ["#", "Artist", "Title", "Album", "Duration", "Genre", "Size", "Plays"] ) self.library_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self.library_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) @@ -184,6 +186,7 @@ class LibraryTab(QWidget): header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) + header.setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents) layout.addWidget(self.library_table, stretch=1) @@ -281,6 +284,7 @@ class LibraryTab(QWidget): old_index = self.current_playback_index self._clear_playing_row(old_index) + self._halfway_passed.discard(old_index) self.current_playback_index = index self.player.setSource(QUrl.fromLocalFile(track_data["path"])) @@ -407,6 +411,8 @@ class LibraryTab(QWidget): if duration > 0 and not self.position_slider.isSliderDown(): self.position_slider.setValue(int(position / duration * 1000)) self.time_label_start.setText(self._format_time_ms(position)) + if duration > 0 and position >= duration * 0.5: + self._halfway_passed.add(self.current_playback_index) def _on_media_duration_changed(self, duration: int): self.time_label_end.setText(self._format_time_ms(duration)) @@ -415,9 +421,11 @@ class LibraryTab(QWidget): def _on_media_status_changed(self, status): if status == QMediaPlayer.MediaStatus.EndOfMedia: + self._increment_play_count() self._stop_eq_animation() self._on_next_clicked() elif status == QMediaPlayer.MediaStatus.NoMedia: + self._halfway_passed.discard(self.current_playback_index) self._stop_eq_animation() self.play_btn.setText("\u25B6") self._is_playing = False @@ -426,6 +434,34 @@ class LibraryTab(QWidget): self.player.setPosition(self._pending_seek_ms) self._pending_seek_ms = 0 + def _increment_play_count(self): + idx = self.current_playback_index + if idx not in self._halfway_passed: + return + if idx < 0 or idx >= self.library_table.rowCount(): + return + data_item = self.library_table.item(idx, 0) + if data_item is None: + return + _is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole) + if not track_data: + return + path = track_data.get("path", "") + if not path: + return + cache = get_library_cache() + entry = cache.get(path) + if entry is None: + entry = track_data.copy() + entry["play_count"] = entry.get("play_count", 0) + 1 + entry["last_played"] = int(time.time()) + cache.put(path, entry) + save_library_cache() + self._halfway_passed.discard(idx) + plays_item = self.library_table.item(idx, 7) + if plays_item: + plays_item.setText(str(entry["play_count"])) + def _start_eq_animation(self): if not self._eq_timer or self._eq_running: return @@ -702,6 +738,7 @@ class LibraryTab(QWidget): QTableWidgetItem(duration_str), QTableWidgetItem(track.get("genre", "") or ""), QTableWidgetItem(self._format_size(track["size"])), + QTableWidgetItem(str(track.get("play_count", "0") or "0") if is_ready else ""), ] if not is_ready: diff --git a/src/worker.py b/src/worker.py index f5046c5..74acad9 100644 --- a/src/worker.py +++ b/src/worker.py @@ -202,6 +202,58 @@ class WorkerThread(QThread): tracks = db.get_all_tracks() device_info["track_count"] = len(tracks) + self.progress_signal.emit(60, "Syncing play counts from iPod...") + + from library_cache import get_library_cache + cache = get_library_cache() + + ipod_base, ipod_delta = db.read_play_stats() + + hash_to_location: dict[str, str] = {} + for t in tracks: + ch = t.get("content_hash") + if not ch: + continue + fpath = t.get("file_path", "") + if not fpath: + continue + rel = os.path.relpath(fpath, os.path.join(db.mountpoint, "iPod_Control", "Music")) + loc = f":iPod_Control:Music:{rel.replace(os.sep, ':')}" + hash_to_location[ch] = loc + + updated_cache = 0 + for file_path, entry in list(cache._data.items()): + if not os.path.exists(file_path): + continue + ch = entry.get("_content_hash", "") + if not ch or ch not in hash_to_location: + continue + loc = hash_to_location[ch] + bs = ipod_base.get(loc, {}) + dd = ipod_delta.get(loc, {}) + + entry["play_count"] = max( + entry.get("play_count", 0), bs.get("play_count", 0), + ) + dd.get("play_count", 0) + + entry["skip_count"] = max( + entry.get("skip_count", 0), bs.get("skip_count", 0), + ) + dd.get("skip_count", 0) + + entry["rating"] = dd.get("rating", 0) or bs.get("rating", 0) or entry.get("rating", 0) + + entry["last_played"] = max( + entry.get("last_played", 0), bs.get("last_played", 0), + ) + updated_cache += 1 + + if updated_cache: + cache.save() + logger.info("Updated play counts for %d local tracks from iPod", updated_cache) + + db.consume_play_deltas() + self.progress_signal.emit(80, "Loading complete") + result = { "device": device, "mount_point": mount_point,