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,
|
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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user