Add incremental metadata/artwork sync to iPod (iTunes-style)

- 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
This commit is contained in:
Maksim Totmin 2026-06-01 13:21:22 +07:00
parent 3bfb0ebbfe
commit 4c703db802
4 changed files with 270 additions and 4 deletions

View File

@ -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()

View File

@ -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

View File

@ -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:

View File

@ -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,