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:
parent
3bfb0ebbfe
commit
4c703db802
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user