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, sample_count=sample_count,
gapless_track_flag=1, gapless_track_flag=1,
gapless_album_flag=1 if has_album else 0, 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) tracks.append(t)
return tracks return tracks
@ -469,10 +474,18 @@ class Nano7Database:
return len(self._scan_ipod_files()) return len(self._scan_ipod_files())
def get_all_tracks(self) -> list[dict]: 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() scanned = self._scan_ipod_files()
base_stats, delta_stats = self.read_play_stats()
tracks = [] tracks = []
for s in scanned: 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({ tracks.append({
"pid": s["pid"], "pid": s["pid"],
"title": s.get("title") or os.path.splitext(s["file_name"])[0], "title": s.get("title") or os.path.splitext(s["file_name"])[0],
@ -488,6 +501,10 @@ class Nano7Database:
"ipod_path": None, "ipod_path": None,
"file_path": s["file_path"], "file_path": s["file_path"],
"content_hash": s.get("content_hash", ""), "content_hash": s.get("content_hash", ""),
"play_count": pc,
"rating": rating,
"last_played": bs.get("last_played", 0),
"skip_count": sc,
}) })
return tracks return tracks
@ -618,13 +635,65 @@ class Nano7Database:
except Exception as e: except Exception as e:
logger.exception("iTunesDB sync failed: %s", 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: def _sync_itunescdb_impl(self) -> None:
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")
self._write_empty_databases() self._write_empty_databases()
return return
self._merge_play_stats(scanned)
self._write_databases_from_tracks(scanned) self._write_databases_from_tracks(scanned)
self._cleanup_play_counts_file()
def find_track_by_content_hash(self, content_hash: str) -> dict | None: def find_track_by_content_hash(self, content_hash: str) -> dict | None:
"""Find an iPod track by its content hash.""" """Find an iPod track by its content hash."""
@ -646,6 +715,107 @@ class Nano7Database:
return t return t
return None 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( def update_track_metadata(
self, self,
pid: int, pid: int,
@ -718,7 +888,9 @@ class Nano7Database:
logger.warning("Failed to extract artwork from %s: %s", logger.warning("Failed to extract artwork from %s: %s",
local_source_path, e) local_source_path, e)
self._merge_play_stats(scanned)
self._write_databases_from_tracks(scanned, artwork_overrides=artwork_overrides) 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", "?")) logger.info("Updated metadata for track: %s", target.get("title", "?"))
return True return True
@ -889,3 +1061,4 @@ class Nano7Database:
db_pid=random.getrandbits(64), db_pid=random.getrandbits(64),
capabilities=capabilities, firewire_id=fwid, backup=True, 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 download_path: Optional[str] = None
genre: Optional[str] = None genre: Optional[str] = None
cover_path: 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 os
import sys import sys
import time
import logging import logging
from typing import List, Dict, Optional, Tuple, Any from typing import List, Dict, Optional, Tuple, Any
from pathlib import Path from pathlib import Path
@ -22,7 +23,7 @@ from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from track_info import TrackInfo from track_info import TrackInfo
from metadata_handler import MetadataHandler from metadata_handler import MetadataHandler
from ui.metadata_editor import MetadataEditorDialog 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 artwork.cache import cover_cache
from config_loader import ConfigLoader from config_loader import ConfigLoader
from worker import WorkerThread from worker import WorkerThread
@ -74,6 +75,7 @@ class LibraryTab(QWidget):
self._saved_position_ms: int = 0 self._saved_position_ms: int = 0
self._saved_playing: bool = False self._saved_playing: bool = False
self._pending_seek_ms: int = 0 self._pending_seek_ms: int = 0
self._halfway_passed: set[int] = set()
self._library_toolbar_buttons: list = [] self._library_toolbar_buttons: list = []
@ -163,9 +165,9 @@ class LibraryTab(QWidget):
self._setup_library_toolbar(layout) self._setup_library_toolbar(layout)
self.library_table = QTableWidget() self.library_table = QTableWidget()
self.library_table.setColumnCount(7) self.library_table.setColumnCount(8)
self.library_table.setHorizontalHeaderLabels( 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.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.library_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) self.library_table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
@ -184,6 +186,7 @@ class LibraryTab(QWidget):
header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(5, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(7, QHeaderView.ResizeMode.ResizeToContents)
layout.addWidget(self.library_table, stretch=1) layout.addWidget(self.library_table, stretch=1)
@ -281,6 +284,7 @@ class LibraryTab(QWidget):
old_index = self.current_playback_index old_index = self.current_playback_index
self._clear_playing_row(old_index) self._clear_playing_row(old_index)
self._halfway_passed.discard(old_index)
self.current_playback_index = index self.current_playback_index = index
self.player.setSource(QUrl.fromLocalFile(track_data["path"])) self.player.setSource(QUrl.fromLocalFile(track_data["path"]))
@ -407,6 +411,8 @@ class LibraryTab(QWidget):
if duration > 0 and not self.position_slider.isSliderDown(): if duration > 0 and not self.position_slider.isSliderDown():
self.position_slider.setValue(int(position / duration * 1000)) self.position_slider.setValue(int(position / duration * 1000))
self.time_label_start.setText(self._format_time_ms(position)) 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): def _on_media_duration_changed(self, duration: int):
self.time_label_end.setText(self._format_time_ms(duration)) self.time_label_end.setText(self._format_time_ms(duration))
@ -415,9 +421,11 @@ class LibraryTab(QWidget):
def _on_media_status_changed(self, status): def _on_media_status_changed(self, status):
if status == QMediaPlayer.MediaStatus.EndOfMedia: if status == QMediaPlayer.MediaStatus.EndOfMedia:
self._increment_play_count()
self._stop_eq_animation() self._stop_eq_animation()
self._on_next_clicked() self._on_next_clicked()
elif status == QMediaPlayer.MediaStatus.NoMedia: elif status == QMediaPlayer.MediaStatus.NoMedia:
self._halfway_passed.discard(self.current_playback_index)
self._stop_eq_animation() self._stop_eq_animation()
self.play_btn.setText("\u25B6") self.play_btn.setText("\u25B6")
self._is_playing = False self._is_playing = False
@ -426,6 +434,34 @@ class LibraryTab(QWidget):
self.player.setPosition(self._pending_seek_ms) self.player.setPosition(self._pending_seek_ms)
self._pending_seek_ms = 0 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): def _start_eq_animation(self):
if not self._eq_timer or self._eq_running: if not self._eq_timer or self._eq_running:
return return
@ -702,6 +738,7 @@ class LibraryTab(QWidget):
QTableWidgetItem(duration_str), QTableWidgetItem(duration_str),
QTableWidgetItem(track.get("genre", "") or ""), QTableWidgetItem(track.get("genre", "") or ""),
QTableWidgetItem(self._format_size(track["size"])), QTableWidgetItem(self._format_size(track["size"])),
QTableWidgetItem(str(track.get("play_count", "0") or "0") if is_ready else ""),
] ]
if not is_ready: if not is_ready:

View File

@ -202,6 +202,58 @@ class WorkerThread(QThread):
tracks = db.get_all_tracks() tracks = db.get_all_tracks()
device_info["track_count"] = len(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 = { result = {
"device": device, "device": device,
"mount_point": mount_point, "mount_point": mount_point,