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
+40 -3
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: