Compare commits

...

13 Commits

Author SHA1 Message Date
Maksim Totmin
54cd87654f fix: reliable play count sync with auto-sync + deferred iTunesDB commit
Stops double-counting when iPod Nano 7G regenerates Play Counts
file after a DB write (firmware quirk, new mtime with stale data).

Architecture:
- Mount: only max(cache, base) — never reads Play Counts delta
- 2s after mount → auto-sync worker: reads delta, subtracts stored
  (persistent guard), applies effective to cache, defers DB write
- iTunesDB commit deferred to next stable mount (checks W_OK first)
- read_play_stats() uses 3s retry + read_play_counts_delta() fallback
  (filesystem-based PC parsing, independent of iTunesDB I/O errors)

Changes:
- ipod_nano7_db.py: sync_itunescdb() returns bool; new
  read_play_counts_delta() method; get_all_tracks() no longer adds
  delta; _merge_play_stats() drops delta addition; suppress iOpenPod
  ERROR logs to CRITICAL
- library_cache.py: schema v2 with playcounts_log table +
  get/set_last_synced_delta, migration from v1
- worker.py: session guards (_processed_fwids, _auto_synced_fwids,
  _pending_commit_fwids); auto_sync_playcounts task; deferred commit
  in mount
- app.py: QTimer.singleShot(2000) after mount for auto-sync
- ipod_tab.py: _on_auto_state_changed rewritten (no unmount, mp guard)
- library_tab.py: refresh_play_counts_from_cache() method
2026-06-02 00:54:18 +07:00
Maksim Totmin
d08125c7b3 fix: use correct case for Location key in read_play_stats 2026-06-01 22:32:17 +07:00
Maksim Totmin
ba7144c88f feat: migrate LibraryCache to SQLite, add SessionStore + splash overlay
- Rewrite LibraryCache from JSON (cache dir) to SQLite (data dir with WAL)
- Each track gets a stable UUID so play stats survive file renames/re-encodes
- Thread-safe via PRAGMA busy_timeout + RLock
- Automatic backup rotation (.backup, .bak) on every write
- One-shot migration from old ~/.cache/library_cache.json
- Add SessionStore (~/.local/share/session.json) for playback state
- Add SplashOverlay with spinning animation on first (empty-DB) launch
- _scan_library() now accepts on_progress callback for the overlay
- Fix _mtime → mtime SQLite column name lookup in recently-added filter
- PlaylistManager path migrated to ~/.local/share/ with auto-migration
- Metadata sync: remove dead consume_play_deltas(), use library API
2026-06-01 22:12:56 +07:00
Maksim Totmin
2d77b3ea3e feat: modernize iPod tab with table view, search, and sorting, fix LibraryCache API 2026-06-01 22:11:36 +07:00
Maksim Totmin
597603f90e feat: add ready audio files (m4a/mp3/aac) to library with structured copy
- Expand Add Files dialog to accept all supported audio formats
- Copy ready files to output_dir/Artist/Album/{track} - Title.ext
- Fallback to output_dir root when metadata is missing
- Handle filename collisions with numeric suffix
2026-06-01 21:37:41 +07:00
Maksim Totmin
b7367e63c4 fix: correct playlist creation on iPod (real db_track_id, metadata fallback, Settings hover fix) 2026-06-01 21:05:39 +07:00
Maksim Totmin
8861e55f32 Add shuffle and repeat controls with end-of-playlist fix 2026-06-01 20:32:56 +07:00
Maksim Totmin
aaaf354c9d fix: restore JumpStyle seek slider and fix fractional truncation bug
- Add JumpStyle(QProxyStyle) to player_header.py so clicking the seek
  bar jumps to the clicked position (lost during player_header extraction)
- Fix _on_slider_user_action: int(frac) always truncated to 0
  (e.g. 500/1000=0.5 → int(0.5)=0), now emits raw value 0-1000
- Fix _on_user_seek_frac: properly converts slider value (0-1000) to
  playback position with bounds clamping
2026-06-01 20:09:52 +07:00
Maksim Totmin
a66446120c refactor: remove dead code — unused tab_switch_requested signal, scope concept in hotkeys, redundant ipod_tab_index 2026-06-01 20:04:58 +07:00
Maksim Totmin
b68b86929b feat: drag-and-drop tracks onto playlist (sidebar) and iPod (tab + nav icon) 2026-06-01 20:00:56 +07:00
Maksim Totmin
bfd146a4a0 feat: add star rating to metadata editor with write support to audio tags
- ClickableStarRating widget — 5 stars with hover preview, toggle to unrate
- Rating field in MetadataEditorDialog (0–5 stars), saved to LibraryCache (0–100)
- MetadataHandler writes rating to MP3 (POPM), M4A (\xa9rt), FLAC/Ogg (RATING)
2026-06-01 19:59:56 +07:00
Maksim Totmin
309ba994a5 fix: rating no longer reset to -1 during mount/sync from iPod Play Counts
The Play Counts parser uses rating=-1 as sentinel for 'no change',
but the merge logic used  which treats -1 as truthy, overwriting
real ratings with -1 in all three paths: mount-time sync (worker.py),
DB regeneration (_merge_play_stats), and track listing (get_all_tracks).
2026-06-01 19:45:32 +07:00
Maksim Totmin
a36312c9d7 fix: QThread crash on iPod connect and device detection — proper wait() lifecycle
Root cause: QThread C++ destructor ran while underlying OS thread was
still terminating. The pattern of checking isRunning() and using
short timeouts (wait(3000/5000)) left a window where the Python
reference was dropped and Shiboken destroyed the C++ object before
the thread fully exited.

Fixes:
- Remove all self.finished.connect(self.deleteLater) — unsafe,
  deleteLater from within run() or from worker thread races with
  thread termination
- _cleanup_worker: always call wait() (no timeout) before nulling
  the Python reference — guarantees OS thread is fully dead
- Add _is_worker_running() helper with try/except RuntimeError
  guard to safely check stale C++ objects
- DeviceMonitor._poll/stop/wait: use wait() with no timeout
- closeEvent: guard isRunning() with try/except RuntimeError
2026-06-01 19:25:30 +07:00
19 changed files with 2849 additions and 353 deletions

View File

@ -6,6 +6,7 @@ Provides a GUI for converting, managing and transferring music to iPod Nano devi
import os
import sys
import random
import logging
from typing import Optional
@ -13,7 +14,7 @@ from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout,
QSplitter, QStackedWidget,
)
from PyQt6.QtCore import Qt, QUrl
from PyQt6.QtCore import Qt, QUrl, QTimer
from PyQt6.QtGui import QPixmap
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
@ -21,11 +22,14 @@ from config_loader import ConfigLoader
from device_monitor import DeviceMonitor
from hotkeys import HotkeyManager
from playlist_manager import PlaylistManager
from session_store import SessionStore
from library_cache import get_library_cache
from ui.library_tab import LibraryTab
from ui.ipod_tab import iPodTab
from ui.settings_tab import SettingsTab
from ui.sidebar_widget import SIDEBAR_DEFAULT_WIDTH
from ui.player_header import PlayerHeader
from ui.splash_overlay import SplashOverlay
from artwork.cache import cover_cache
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
@ -44,10 +48,12 @@ class MainWindow(QMainWindow):
self.config_loader = ConfigLoader()
self.hotkey_manager: Optional[HotkeyManager] = None
self.device_monitor: Optional[DeviceMonitor] = None
self.ipod_tab_index = 1
self.playlist_manager = PlaylistManager()
self.session_store = SessionStore()
self._sidebar_visible = True
self._pending_playlist_id: str = ""
self._pending_playlist_tracks: list = []
self._saved_volume: int = 80
self._saved_track_path: str = ""
@ -55,6 +61,11 @@ class MainWindow(QMainWindow):
self._saved_playing: bool = False
self._pending_seek_ms: int = 0
self._shuffle_active: bool = False
self._repeat_mode: str = "no_repeat"
self._auto_sync_worker = None
self._setup_player()
self._setup_ui()
self.library_tab.set_playlist_manager(self.playlist_manager)
@ -63,10 +74,18 @@ class MainWindow(QMainWindow):
self._setup_hotkeys()
self._wire_signals()
self.library_tab._scan_library()
cache = get_library_cache()
splash = SplashOverlay(self)
if len(cache) == 0:
splash.show()
QApplication.processEvents()
self.library_tab._scan_library(on_progress=splash.set_progress)
if splash.isVisible():
splash.close()
self._resume_playback_if_saved()
self._restore_library_state()
self.ipod_tab._on_refresh_devices_clicked()
self._start_device_monitor()
def _setup_player(self):
@ -90,6 +109,8 @@ class MainWindow(QMainWindow):
self.player_header.prev_requested.connect(self._on_prev)
self.player_header.play_pause_requested.connect(self._on_play_pause)
self.player_header.next_requested.connect(self._on_next)
self.player_header.shuffle_requested.connect(self._on_shuffle_toggle)
self.player_header.repeat_requested.connect(self._on_repeat_cycle)
self.player_header.position_changed_by_user.connect(self._on_user_seek_frac)
self.player_header.volume_changed.connect(self._on_volume_changed)
main_layout.addWidget(self.player_header)
@ -135,6 +156,12 @@ class MainWindow(QMainWindow):
)
self.settings_tab.auto_detect_changed.connect(self._on_auto_detect_toggled)
self.sidebar.tracks_dropped_on_playlist.connect(self._on_tracks_dropped_on_playlist)
self.sidebar.tracks_dropped_for_nav_transfer.connect(self._on_tracks_dropped_for_nav_transfer)
self.sidebar.playlist_dropped_for_nav_transfer.connect(self._on_playlist_dropped_for_nav_transfer)
self.ipod_tab.tracks_dropped_for_transfer.connect(self._on_tracks_dropped_for_transfer)
self.library_tab.transfer_finished.connect(self._on_transfer_finished_for_playlist)
def _on_sidebar_item_selected(self, kind: str, value: str):
if kind == "tab":
tab_index = {"library": 0, "ipod": 1, "settings": 2}.get(value, 0)
@ -175,18 +202,102 @@ class MainWindow(QMainWindow):
self.library_tab.clear_filter()
self.sidebar.select_library_music()
def _on_tracks_dropped_on_playlist(self, playlist_id: str, paths: list):
self.playlist_manager.add_tracks(playlist_id, paths)
playlist = self.playlist_manager.get_by_id(playlist_id)
if playlist:
self._on_playlist_selected(playlist_id)
self.statusBar().showMessage(f"Added {len(paths)} track(s) to playlist", 5000)
def _on_tracks_dropped_for_transfer(self, paths: list):
self.library_tab.transfer_tracks_by_paths(paths)
def _on_tracks_dropped_for_nav_transfer(self, paths: list):
self.stack.setCurrentIndex(0)
self.library_tab.transfer_tracks_by_paths(paths)
def _on_playlist_dropped_for_nav_transfer(self, playlist_id: str):
playlist = self.playlist_manager.get_by_id(playlist_id)
if not playlist or not playlist.track_paths:
return
self.stack.setCurrentIndex(0)
self._pending_playlist_id = playlist_id
self._pending_playlist_tracks = list(playlist.track_paths)
self.library_tab.transfer_tracks_by_paths(playlist.track_paths)
def _on_transfer_finished_for_playlist(self):
playlist_id = self._pending_playlist_id
track_paths = self._pending_playlist_tracks
self._pending_playlist_id = ""
self._pending_playlist_tracks = []
if not playlist_id or not track_paths:
return
mount_point = getattr(self.ipod_tab, "current_mount_point", None)
if not mount_point:
self.statusBar().showMessage("Cannot create playlist: iPod not mounted", 5000)
return
playlist = self.playlist_manager.get_by_id(playlist_id)
if not playlist:
return
self.statusBar().showMessage(f"Creating playlist '{playlist.name}' on iPod...")
from worker import WorkerThread
self._pl_worker = WorkerThread(
task_type="create_playlist",
mount_point=mount_point,
playlist_name=playlist.name,
track_paths=track_paths,
)
self._pl_worker.finished_signal.connect(self._on_playlist_creation_finished)
self._pl_worker.start()
def _on_playlist_creation_finished(self, success, message, result):
self.statusBar().showMessage(message, 8000)
try:
self._pl_worker.quit()
self._pl_worker.wait()
except Exception:
pass
self._pl_worker = None
def _load_playlists_into_sidebar(self):
for pl in self.playlist_manager.get_all():
self.sidebar.add_playlist_item(pl.id, pl.name)
def _on_device_mounted(self, mount_point: str):
self.library_tab.on_device_mounted(mount_point)
self.library_tab.refresh_play_counts_from_cache()
self.statusBar().showMessage(f"iPod mounted at {mount_point}")
QTimer.singleShot(2000, lambda: self._start_auto_sync(mount_point))
def _on_device_unmounted(self):
self.library_tab.on_device_unmounted()
self.statusBar().showMessage("iPod disconnected")
def _start_auto_sync(self, mount_point: str):
if self._auto_sync_worker is not None:
return
from worker import WorkerThread
self._auto_sync_worker = WorkerThread(
"auto_sync_playcounts", parent=self, mount_point=mount_point,
)
self._auto_sync_worker.progress_signal.connect(self._on_auto_sync_progress)
self._auto_sync_worker.finished_signal.connect(self._on_auto_sync_finished)
self._auto_sync_worker.start()
def _on_auto_sync_progress(self, pct: int, msg: str):
pass
def _on_auto_sync_finished(self, success: bool, msg: str, _result):
if success and msg.startswith("Auto-sync complete"):
self.library_tab.refresh_play_counts_from_cache()
self._auto_sync_worker = None
def _toggle_sidebar(self):
visible = not self.sidebar.isVisible()
self.sidebar.setVisible(visible)
@ -219,29 +330,43 @@ class MainWindow(QMainWindow):
hide_buttons = config.get_boolean("Advanced", "hide_library_buttons", fallback=False)
self.library_tab.set_buttons_visible(not hide_buttons)
self._saved_volume = config.get_int("Playback", "last_volume", fallback=80)
self._saved_track_path = config.get("Playback", "last_track_path", fallback="")
self._saved_position_ms = config.get_int("Playback", "last_position_ms", fallback=0)
self._saved_playing = config.get_boolean("Playback", "last_playing", fallback=False)
# Migrate Playback section → SessionStore if needed
self.session_store.migrate_from_config(config)
self._saved_volume = self.session_store.get_int("last_volume", 80)
self._saved_track_path = self.session_store.get_str("last_track_path", "")
self._saved_position_ms = self.session_store.get_int("last_position_ms", 0)
self._saved_playing = self.session_store.get_bool("last_playing", False)
self._shuffle_active = self.session_store.get_bool("shuffle_active", False)
self._repeat_mode = self.session_store.get_str("repeat_mode", "no_repeat")
self.player_header.set_shuffle_active(self._shuffle_active)
self.player_header.set_repeat_mode(self._repeat_mode)
self.audio_output.setVolume(self._saved_volume / 100.0)
self.player_header.set_volume(self._saved_volume)
def _save_settings(self):
config = self.config_loader
session = self.session_store
self.settings_tab.save_settings(config)
config.set("Playback", "last_volume", str(self.player_header.volume_slider.value()))
session.set("last_volume", self.player_header.volume_slider.value())
session.set("shuffle_active", self._shuffle_active)
session.set("repeat_mode", self._repeat_mode)
if self.current_track_path and os.path.exists(self.current_track_path):
config.set("Playback", "last_track_path", self.current_track_path)
session.set("last_track_path", self.current_track_path)
position = self.player.position()
duration = self.player.duration()
if duration > 0 and position > duration - 2000:
position = 0
config.set("Playback", "last_position_ms", str(position))
config.set("Playback", "last_playing", str(self._is_playing_now).lower())
session.set("last_position_ms", position)
session.set("last_playing", self._is_playing_now)
else:
config.set("Playback", "last_track_path", "")
config.set("Playback", "last_position_ms", "0")
session.set("last_track_path", "")
session.set("last_position_ms", 0)
session.save()
state = self.library_tab.get_state()
config.set("Library", "view_mode", state.get("view_mode", "table"))
config.set("Library", "filtered_album", state.get("filtered_album", ""))
@ -270,7 +395,7 @@ class MainWindow(QMainWindow):
"seek_backward": self._seek_backward,
"toggle_sidebar": self._toggle_sidebar,
"tab_library": lambda: self.stack.setCurrentIndex(0),
"tab_ipod": lambda: self.stack.setCurrentIndex(self.ipod_tab_index),
"tab_ipod": lambda: self.stack.setCurrentIndex(1),
"tab_settings": lambda: self.stack.setCurrentIndex(2),
"search_focus": self.library_tab._focus_search,
"select_all": self.library_tab._select_all_current,
@ -337,10 +462,41 @@ class MainWindow(QMainWindow):
idx = max(0, self.library_tab.current_playback_index - 1)
self.library_tab.play_track_at_index(idx)
def _get_next_index(self):
playable = self.library_tab.playable_row_indices()
if not playable:
return None
current = self.library_tab.current_playback_index
if self._shuffle_active:
candidates = [r for r in playable if r != current]
if not candidates:
candidates = playable
return random.choice(candidates)
try:
pos = playable.index(current)
except ValueError:
pos = -1
if pos >= 0 and pos < len(playable) - 1:
return playable[pos + 1]
if self._repeat_mode == "repeat_all" and playable:
return playable[0]
return None
def _stop_playback(self):
self.player.stop()
self.library_tab.on_playing_changed(False)
self.player_header.set_playing(False)
def _on_next(self):
rows = self.library_tab.library_table.rowCount()
idx = min(rows - 1, self.library_tab.current_playback_index + 1)
self.library_tab.play_track_at_index(idx)
idx = self._get_next_index()
if idx is not None:
self.library_tab.play_track_at_index(idx)
def _seek_forward(self):
if self.player.duration() <= 0:
@ -363,8 +519,13 @@ class MainWindow(QMainWindow):
if self.audio_output:
self.audio_output.setVolume(value / 100.0)
def _on_user_seek_frac(self, frac: int):
self.player.setPosition(int(frac * self.player.duration()))
def _on_user_seek_frac(self, value: int):
duration = self.player.duration()
if duration <= 0:
return
new_pos = int(value / 1000.0 * duration)
new_pos = max(0, min(duration - 1, new_pos))
self.player.setPosition(new_pos)
def _on_player_position(self, position: int):
duration = self.player.duration()
@ -375,10 +536,29 @@ class MainWindow(QMainWindow):
pos = self.player.position()
self.player_header.set_position(pos, duration)
def _on_shuffle_toggle(self):
self._shuffle_active = not self._shuffle_active
self.player_header.set_shuffle_active(self._shuffle_active)
def _on_repeat_cycle(self):
cycle = {"no_repeat": "repeat_all", "repeat_all": "repeat_one", "repeat_one": "no_repeat"}
self._repeat_mode = cycle[self._repeat_mode]
self.player_header.set_repeat_mode(self._repeat_mode)
def _on_media_status(self, status):
if status == QMediaPlayer.MediaStatus.EndOfMedia:
self.library_tab.on_track_ended()
self._on_next()
if self._repeat_mode == "repeat_one":
self.player.setPosition(0)
self.player.play()
return
next_idx = self._get_next_index()
if next_idx is not None:
self.library_tab.play_track_at_index(next_idx)
else:
self._stop_playback()
elif status == QMediaPlayer.MediaStatus.NoMedia:
self.library_tab.on_playing_changed(False)
self.player_header.reset_info()
@ -427,7 +607,17 @@ class MainWindow(QMainWindow):
def closeEvent(self, event):
if self.device_monitor:
self.device_monitor.stop()
self.ipod_tab.stop_monitoring(self.device_monitor)
for tab in (self.ipod_tab, self.library_tab):
if hasattr(tab, 'worker_thread') and tab.worker_thread:
try:
if tab.worker_thread.isRunning():
tab.worker_thread.stop()
except RuntimeError:
tab.worker_thread = None
self._save_settings()
from PyQt6.QtWidgets import QApplication
QApplication.processEvents()
event.accept()

View File

@ -10,22 +10,11 @@ import hashlib
import logging
import os
from xdg_base import xdg_cache_path
logger = logging.getLogger(__name__)
def xdg_cache_path(subdir: str) -> str:
"""Return path inside XDG cache directory for neo-pod-desktop.
Creates the directory structure if missing.
"""
base = os.environ.get(
"XDG_CACHE_HOME",
os.path.join(os.path.expanduser("~"), ".cache"),
)
path = os.path.join(base, "neo-pod-desktop", subdir)
os.makedirs(path, exist_ok=True)
return path
class CoverCache:
"""Manages a local cache of cover art images for the player UI.

View File

@ -163,17 +163,12 @@ class ConfigLoader:
if not self.config.has_option('Hotkeys', key):
self.config.set('Hotkeys', key, value)
# Playback section
# Playback section is DEPRECATED — session state moved to
# session.json in ~/.local/share/. The defaults below are
# kept only for backward-compatible reads during migration.
# All new code should use SessionStore instead.
if not self.config.has_section('Playback'):
self.config.add_section('Playback')
if not self.config.has_option('Playback', 'last_track_path'):
self.config.set('Playback', 'last_track_path', '')
if not self.config.has_option('Playback', 'last_position_ms'):
self.config.set('Playback', 'last_position_ms', '0')
if not self.config.has_option('Playback', 'last_volume'):
self.config.set('Playback', 'last_volume', '80')
if not self.config.has_option('Playback', 'last_playing'):
self.config.set('Playback', 'last_playing', 'false')
def get(self, section: str, option: str, fallback: Any = None) -> Any:
"""

View File

@ -11,6 +11,9 @@ logger = logging.getLogger(__name__)
class _DetectWorker(QThread):
finished = pyqtSignal(object)
def __init__(self, parent=None):
super().__init__(parent)
def run(self):
try:
devices = IPodDevice().detect_devices()
@ -38,19 +41,29 @@ class DeviceMonitor(QObject):
def stop(self):
self.timer.stop()
if self._poll_worker and self._poll_worker.isRunning():
if self._poll_worker:
self._poll_worker.quit()
self._poll_worker.wait(3000)
self._poll_worker.wait()
self._poll_worker = None
self._device_ids.clear()
def _poll(self):
if self._poll_worker and self._poll_worker.isRunning():
return
self._poll_worker = _DetectWorker()
self._poll_worker.finished.connect(self._on_devices_detected)
self._poll_worker.start()
if self._poll_worker:
try:
if self._poll_worker.isRunning():
return
except RuntimeError:
self._poll_worker = None
worker = _DetectWorker()
worker.finished.connect(self._on_devices_detected)
worker.start()
self._poll_worker = worker
def _on_devices_detected(self, devices):
if self._poll_worker:
self._poll_worker.quit()
self._poll_worker.wait()
self._poll_worker = None
current_ids = {d["id"] for d in devices}
if current_ids != self._device_ids:
self._device_ids = current_ids

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
Hotkey Manager for neo-pod-desktop
Provides configurable keyboard shortcuts with scope awareness.
Provides configurable keyboard shortcuts.
"""
import logging
@ -73,28 +73,27 @@ def _is_editable_focused() -> bool:
# ---------------------------------------------------------------------------
# Default shortcut definitions
# (action_name, default_key_string, display_label, scope)
# scope: "global" — always active; "tab" — handler decides based on current tab
# (action_name, default_key_string, display_label)
# ---------------------------------------------------------------------------
DEFAULTS = [
("play_pause", "Space", "Play / Pause", "global"),
("prev_track", "Ctrl+Left", "Previous Track", "global"),
("next_track", "Ctrl+Right", "Next Track", "global"),
("volume_up", "Ctrl+Up", "Volume Up", "global"),
("volume_down", "Ctrl+Down", "Volume Down", "global"),
("seek_forward", "Right", "Seek Forward 5s", "global"),
("seek_backward", "Left", "Seek Backward 5s", "global"),
("tab_library", "Ctrl+1", "Switch to Library", "global"),
("tab_ipod", "Ctrl+2", "Switch to iPod", "global"),
("tab_settings", "Ctrl+3", "Switch to Settings", "global"),
("search_focus", "Ctrl+F", "Focus Search", "global"),
("select_all", "Ctrl+A", "Select All", "global"),
("library_refresh", "F5", "Refresh Library", "global"),
("ipod_refresh", "Ctrl+R", "Refresh Devices", "global"),
("delete_selected", "Delete", "Delete Selected", "global"),
("edit_metadata", "F2", "Edit Metadata", "global"),
("toggle_sidebar", "Ctrl+B", "Toggle Sidebar", "global"),
("library_add_files", "Ctrl+O", "Library: Add Files", "global"),
("play_pause", "Space", "Play / Pause"),
("prev_track", "Ctrl+Left", "Previous Track"),
("next_track", "Ctrl+Right", "Next Track"),
("volume_up", "Ctrl+Up", "Volume Up"),
("volume_down", "Ctrl+Down", "Volume Down"),
("seek_forward", "Right", "Seek Forward 5s"),
("seek_backward", "Left", "Seek Backward 5s"),
("tab_library", "Ctrl+1", "Switch to Library"),
("tab_ipod", "Ctrl+2", "Switch to iPod"),
("tab_settings", "Ctrl+3", "Switch to Settings"),
("search_focus", "Ctrl+F", "Focus Search"),
("select_all", "Ctrl+A", "Select All"),
("library_refresh", "F5", "Refresh Library"),
("ipod_refresh", "Ctrl+R", "Refresh Devices"),
("delete_selected", "Delete", "Delete Selected"),
("edit_metadata", "F2", "Edit Metadata"),
("toggle_sidebar", "Ctrl+B", "Toggle Sidebar"),
("library_add_files", "Ctrl+O", "Library: Add Files"),
]
@ -173,17 +172,15 @@ class HotkeyManager:
self._parent = parent
self._config_loader = config_loader
# action_name → (QShortcut, callback, default_seq, scope)
# action_name → (QShortcut, callback, default_seq)
self._shortcuts: Dict[str, QShortcut] = {}
self._callbacks: Dict[str, Callable[[], None]] = {}
self._defaults: Dict[str, QKeySequence] = {}
self._labels: Dict[str, str] = {}
self._scopes: Dict[str, str] = {}
for name, key_str, label, scope in DEFAULTS:
for name, key_str, label in DEFAULTS:
self._defaults[name] = _build_seq(key_str)
self._labels[name] = label
self._scopes[name] = scope
# ------------------------------------------------------------------
# Registration
@ -201,7 +198,6 @@ class HotkeyManager:
seq = _build_seq(saved_key) if saved_key else self._defaults.get(action_name, QSeq())
self._callbacks[action_name] = callback
scope = self._scopes.get(action_name, "global")
context = Qt.ShortcutContext.WindowShortcut
sc = QShortcut(seq, self._parent, context=context)

View File

@ -32,7 +32,7 @@ import sqlite3
import struct
import subprocess
import time
from typing import Any, Optional
from typing import Any, Callable, Optional
from mutagen import File as MutagenFile
from mutagen.easyid3 import EasyID3
@ -118,6 +118,10 @@ def _import_iop(module_name: str):
logger = logging.getLogger(__name__)
# iOpenPod parsers/writers log ERROR for transient I/O (USB resettling)
logging.getLogger("iTunesDB_Parser").setLevel(logging.CRITICAL)
logging.getLogger("iTunesDB_Writer").setLevel(logging.CRITICAL)
# ── constants ────────────────────────────────────────────────────────
FILETYPE_CODES = {
"mp3": 0x4D503320,
@ -206,6 +210,16 @@ def _read_audio_tags(filepath: str) -> dict[str, Any]:
ds = str(val[0])
if len(ds) >= 4:
info["year"] = int(ds[:4])
# Compilation flag (TCMP frame) — only set when explicitly present
try:
id3 = ID3(filepath)
if "TCMP" in id3:
tcmp = id3["TCMP"]
if hasattr(tcmp, "text") and tcmp.text:
info["compilation"] = str(tcmp.text[0]) == "1"
except Exception:
pass
except Exception:
pass
elif ext in (".m4a", ".aac", ".mp4", ".m4p"):
@ -228,6 +242,14 @@ def _read_audio_tags(filepath: str) -> dict[str, Any]:
tr = t["trkn"]
if isinstance(tr, list) and tr and isinstance(tr[0], tuple):
info["track_number"] = tr[0][0]
# Compilation flag (cpil atom)
if "cpil" in t:
cpil = t["cpil"]
if isinstance(cpil, bool):
info["compilation"] = cpil
elif isinstance(cpil, list) and len(cpil) > 0:
info["compilation"] = bool(cpil[0])
else:
if audio and audio.tags:
for tag_k, dest in [("title", "title"), ("artist", "artist"),
@ -239,6 +261,15 @@ def _read_audio_tags(filepath: str) -> dict[str, Any]:
info[dest] = str(v[0]) if isinstance(v, list) and v else str(v)
except (ValueError, TypeError):
pass
# Compilation flag (vorbis comment)
try:
if "compilation" in audio.tags:
comp = audio.tags["compilation"]
comp_val = str(comp[0]) if isinstance(comp, list) else str(comp)
info["compilation"] = comp_val == "1"
except (ValueError, TypeError):
pass
except Exception:
pass
return info
@ -329,6 +360,7 @@ def _scanned_tracks_to_iop(mountpoint: str,
sample_count=sample_count,
gapless_track_flag=1,
gapless_album_flag=1 if has_album else 0,
compilation_flag=s.get("compilation", False),
play_count=s.get("play_count", 0),
rating=s.get("rating", 0),
last_played=s.get("last_played", 0),
@ -483,17 +515,20 @@ class Nano7Database:
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)
pc = bs.get("play_count", 0)
sc = bs.get("skip_count", 0)
dd_rt = dd.get("rating", 0)
rating = dd_rt if dd_rt >= 0 else bs.get("rating", 0)
tracks.append({
"pid": s["pid"],
"title": s.get("title") or os.path.splitext(s["file_name"])[0],
"artist": s.get("artist") or "Unknown Artist",
"album": s.get("album") or "",
"album_artist": s.get("album_artist") or "",
"genre": s.get("genre", ""),
"duration_ms": s.get("duration_ms", 0),
"track_number": s.get("track_number", 0),
"file_size": s.get("file_size", 0),
"disc_number": s.get("disc_number", 0),
"physical_order": 0,
"date_modified": 0,
@ -626,46 +661,204 @@ class Nano7Database:
deleted += 1
return deleted
# ── playlist creation ────────────────────────────────────────────
def create_playlist(self, name: str, track_paths: list[str]) -> bool:
"""Create a user playlist on the iPod.
Two-pass matching: content hash first, then title+artist+album
fallback. Only tracks already present on the iPod will be
included in the playlist.
Args:
name: Playlist name.
track_paths: Local file paths of the tracks to include.
Returns:
True if the playlist was created, False otherwise.
"""
scanned = self._scan_ipod_files()
if not scanned:
logger.warning("No tracks on iPod — cannot create playlist")
return False
playlist_created = False
self._merge_play_stats(scanned)
def _build_pl_cb(scanned, tracks):
"""Build playlist using real db_track_id from TrackInfo."""
nonlocal playlist_created
try:
IopPlaylistInfo = _import_iop(
"iTunesDB_Writer.mhyp_writer"
).PlaylistInfo
except Exception as e:
logger.exception("Failed to import PlaylistInfo: %s", e)
return None
# Pass 1: content_hash → db_track_id
hash_to_db_id: dict[str, int] = {}
for i, s in enumerate(scanned):
ch = s.get("content_hash", "")
if ch:
hash_to_db_id[ch] = tracks[i].db_track_id
found_ids: list[int] = []
unmatched: list[str] = []
for path in track_paths:
if not os.path.exists(path):
continue
ch = content_hash(path)
db_id = hash_to_db_id.get(ch)
if db_id is not None:
found_ids.append(db_id)
else:
unmatched.append(path)
hash_matched_count = len(found_ids)
# Pass 2: metadata fallback
if unmatched:
scanned_meta = []
for i, s in enumerate(scanned):
scanned_meta.append((
s.get("title", "").lower(),
s.get("artist", "").lower(),
(s.get("album") or "").lower(),
tracks[i].db_track_id,
))
found_set = set(found_ids)
for path in unmatched:
tags = _read_audio_tags(path)
title = (tags.get("title") or "").lower()
artist = (tags.get("artist") or "").lower()
album = (tags.get("album") or "").lower()
if not title or not artist:
continue
for s_title, s_artist, s_album, db_id in scanned_meta:
if (db_id not in found_set
and s_title == title
and s_artist == artist
and s_album == album):
found_ids.append(db_id)
found_set.add(db_id)
break
if not found_ids:
logger.warning(
"No tracks from playlist '%s' found on iPod",
name,
)
return None
matched_by_hash = hash_matched_count
matched_by_meta = len(found_ids) - hash_matched_count
logger.info(
"Playlist '%s': %d track(s) matched (%d by hash, %d by metadata)",
name, len(found_ids), matched_by_hash, matched_by_meta,
)
playlist_created = True
return [IopPlaylistInfo(name=name, track_ids=found_ids)]
db_ok = self._write_databases_from_tracks(
scanned, build_extra_playlists=_build_pl_cb,
)
self._cleanup_play_counts_file()
return db_ok and playlist_created
# ── sync: regenerate everything via iOpenPod ──────────────────────
def sync_itunescdb(self) -> None:
"""Regenerate ALL databases using iOpenPod's writers."""
def sync_itunescdb(self) -> bool:
"""Regenerate ALL databases using iOpenPod's writers.
Returns True if the databases were written successfully.
"""
try:
self._sync_itunescdb_impl()
return True
except Exception as e:
logger.exception("iTunesDB sync failed: %s", e)
return False
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``
``new_value = max(ipod_base, local_cache, play_stats_store) + ipod_delta``
Includes a **validation gate** that refuses to produce all-zero
play counts when we have previously-recorded non-zero data in the
local PlayStatsStore this prevents accidental data loss from
silent iTunesDB parse failures.
"""
base_stats, delta_stats = self.read_play_stats()
local_play_stats = self._load_local_play_stats()
# Supplement local_play_stats with durable PlayStatsStore (SQLite)
from library_cache import get_library_cache
db_store = get_library_cache()
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", "")
# Three-tier fallback: iTunesDB → LibraryCache (SQLite) → old JSON cache
local_pc = local_play_stats.get(ch, {})
db_entry = db_store.get_by_content_hash(ch) if ch else None
s["play_count"] = max(
bs.get("play_count", 0), local_pc.get("play_count", 0),
) + dd.get("play_count", 0)
bs.get("play_count", 0),
(db_entry or {}).get("play_count", 0),
local_pc.get("play_count", 0),
)
s["skip_count"] = max(
bs.get("skip_count", 0), local_pc.get("skip_count", 0),
) + dd.get("skip_count", 0)
bs.get("skip_count", 0),
(db_entry or {}).get("skip_count", 0),
local_pc.get("skip_count", 0),
)
s["rating"] = dd.get("rating", 0) or bs.get("rating", 0) or local_pc.get("rating", 0)
dd_rating = dd.get("rating", 0)
store_rating = (db_entry or {}).get("rating", 0)
s["rating"] = dd_rating if dd_rating >= 0 else (
bs.get("rating", 0) or store_rating or local_pc.get("rating", 0)
)
s["last_played"] = max(
bs.get("last_played", 0), local_pc.get("last_played", 0),
bs.get("last_played", 0),
(db_entry or {}).get("last_played", 0),
local_pc.get("last_played", 0),
)
# ── validation gate ────────────────────────────────────────────
# If every scanned track would get play_count=0 but the local DB
# previously had non-zero values, restore them.
all_zero = all(s.get("play_count", 0) == 0 for s in scanned)
if all_zero:
restored = 0
for s in scanned:
ch = s.get("content_hash", "")
if not ch:
continue
db_entry = db_store.get_by_content_hash(ch) if ch else None
stored_pc = (db_entry or {}).get("play_count", 0)
if stored_pc > 0:
s["play_count"] = stored_pc
restored += 1
if restored:
logger.warning(
"Validation gate triggered — restored play_count for %d track(s) "
"from local database after merge produced all zeros",
restored,
)
# ───────────────────────────────────────────────────────────────
def _cleanup_play_counts_file(self) -> None:
"""Delete the iPod 'Play Counts' delta file after a full sync."""
pc_file = os.path.join(
@ -678,12 +871,6 @@ class Nano7Database:
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:
@ -692,7 +879,9 @@ class Nano7Database:
return
self._merge_play_stats(scanned)
self._write_databases_from_tracks(scanned)
if not self._write_databases_from_tracks(scanned):
logger.error("iTunesDB sync failed — play counts not cleaned up")
return
self._cleanup_play_counts_file()
def find_track_by_content_hash(self, content_hash: str) -> dict | None:
@ -754,7 +943,7 @@ class Nano7Database:
tracks = data.get("mhlt", [])
for track in tracks:
loc = track.get("location", "")
loc = track.get("Location") or track.get("location", "")
if not loc:
continue
base_stats[loc] = {
@ -773,7 +962,7 @@ class Nano7Database:
for i, entry in enumerate(entries):
if i >= len(tracks):
break
loc = tracks[i].get("location", "")
loc = tracks[i].get("Location") or tracks[i].get("location", "")
if not loc:
continue
delta_stats[loc] = {
@ -790,6 +979,41 @@ class Nano7Database:
)
return base_stats, delta_stats
def read_play_counts_delta(self) -> dict[str, dict[str, int]]:
"""Read Play Counts file using filesystem scan (no iTunesDB needed).
Positional mapping uses ``_scan_ipod_files()`` order (correct after
any sync written by this app). Returns ``{location: {play_count,
skip_count, rating}}`` or empty dict on failure.
"""
pc_path = os.path.join(
self.mountpoint, "iPod_Control", "iTunes", "Play Counts",
)
if not os.path.exists(pc_path):
return {}
pc_mod = _import_iop("iTunesDB_Parser.playcounts")
try:
entries = pc_mod.parse_playcounts(pc_path)
except Exception:
return {}
if not entries:
return {}
scanned = self._scan_ipod_files()
delta: dict[str, dict[str, int]] = {}
for i, entry in enumerate(entries):
if i >= len(scanned):
break
if entry.play_count == 0 and entry.skip_count == 0 and entry.rating < 0:
continue
rel = os.path.relpath(scanned[i]["file_path"], self.music_base)
loc = f":iPod_Control:Music:{rel.replace(os.sep, ':')}"
delta[loc] = {
"play_count": entry.play_count,
"skip_count": entry.skip_count,
"rating": entry.rating,
}
return delta
@staticmethod
def _load_local_play_stats() -> dict[str, dict[str, int]]:
"""Read accumulated play stats from the local library cache.
@ -800,8 +1024,8 @@ class Nano7Database:
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", "")
for _path, entry in cache.iter_entries():
ch = entry.get("content_hash", "")
pc = entry.get("play_count", 0)
rating = entry.get("rating", 0)
lp = entry.get("last_played", 0)
@ -899,7 +1123,9 @@ class Nano7Database:
self,
scanned: list[dict],
artwork_overrides: dict[str, object] | None = None,
) -> None:
extra_playlists: list | None = None,
build_extra_playlists: Callable | None = None,
) -> bool:
"""Build artwork, convert to TrackInfo, write iTunesDB + SQLite.
Args:
@ -909,6 +1135,15 @@ class Nano7Database:
artwork should come from a local file instead of the iPod file.
Use the ``_REMOVE_ARTWORK`` sentinel to explicitly remove
artwork for a track. Omit a key to keep existing iPod artwork.
extra_playlists: Pre-built list of PlaylistInfo (ignored when
build_extra_playlists is provided).
build_extra_playlists: Optional callable(scanned, tracks) list
of PlaylistInfo. Called after TrackInfo conversion and
db_track_id generation, so the callback can use real
db_track_id values from TrackInfo objects.
Returns:
True if databases were written successfully.
"""
artwork_overrides = artwork_overrides or {}
# 1. Prepare artwork for all tracks
@ -968,6 +1203,20 @@ class Nano7Database:
tracks = _scanned_tracks_to_iop(self.mountpoint, scanned, artwork_map)
logger.info("Built %d iOpenPod TrackInfo objects", len(tracks))
# 2b. Pre-generate db_track_id for all tracks so callbacks can use them
generate_db_track_id = _import_iop("iTunesDB_Writer.mhit_writer").generate_db_track_id
for track in tracks:
if track.db_track_id == 0:
track.db_track_id = generate_db_track_id()
# 2c. Build extra playlists via callback if provided
if build_extra_playlists is not None:
result = build_extra_playlists(scanned, tracks)
if result is not None:
extra_playlists = result
else:
extra_playlists = None
# 3. Determine capabilities for this device
ipod_device = _import_iop("ipod_device")
capabilities = None
@ -996,10 +1245,11 @@ class Nano7Database:
backup=True,
capabilities=capabilities,
master_playlist_name="iPod",
playlists=extra_playlists or None,
)
if not ok:
logger.error("iTunesDB write failed")
return
return False
logger.info("iTunesDB written (%d tracks)", len(tracks))
# 5. Extract db_pid from the binary DB for SQLite cross-reference
@ -1027,13 +1277,18 @@ class Nano7Database:
capabilities=capabilities,
firewire_id=firewire_id,
backup=True,
playlists=extra_playlists or None,
)
if sqlite_ok:
logger.info("SQLite databases written")
else:
logger.error("SQLite database write failed")
return False
except Exception as e:
logger.exception("SQLite write error: %s", e)
return False
return True
def _write_empty_databases(self) -> None:
"""Write minimal empty databases when no tracks exist."""

View File

@ -1,25 +1,83 @@
#!/usr/bin/env python3
"""
Library metadata cache for neo-pod-desktop.
Caches extracted tag data per file path with mtime-based invalidation.
Stored as JSON in XDG cache directory.
Stored as SQLite-format database in ``~/.local/share/neo-pod-desktop/library_cache.db``.
Each row is keyed by its **file path** and also carries a stable **UUID** so
play-count / rating / last-played data survives file renames, moves, and
re-encodings. The database is thread-safe (WAL + busy_timeout) and
automatically rotated to a backup file on every write.
Migration from the old JSON file (``~/.cache/neo-pod-desktop/library_cache.json``)
happens automatically on the first access.
"""
import hashlib
import json
import logging
import os
from typing import Any, Dict, Optional
import sqlite3
import threading
import time
import uuid
from typing import Any, Iterator
from artwork.cache import xdg_cache_path
from xdg_base import xdg_data_path
logger = logging.getLogger(__name__)
_CACHE_FILE = "library_cache.json"
_DB_FILE = "library_cache.db"
_BACKUP_FILE = "library_cache.db.backup"
_OLD_JSON_FILE = "library_cache.json"
_SCHEMA_VERSION = 2
_SCHEMA = """
CREATE TABLE IF NOT EXISTS library_cache (
path TEXT PRIMARY KEY,
uuid TEXT UNIQUE NOT NULL,
title TEXT DEFAULT '',
artist TEXT DEFAULT '',
album TEXT DEFAULT '',
album_artist TEXT DEFAULT '',
genre TEXT DEFAULT '',
duration_s REAL DEFAULT 0,
size INTEGER DEFAULT 0,
track_num INTEGER DEFAULT 0,
source_type TEXT DEFAULT '',
bitrate INTEGER DEFAULT 0,
sample_rate INTEGER DEFAULT 44100,
cover_path TEXT DEFAULT '',
thumbnail_url TEXT DEFAULT '',
play_count INTEGER DEFAULT 0,
rating INTEGER DEFAULT 0,
last_played INTEGER DEFAULT 0,
skip_count INTEGER DEFAULT 0,
mtime REAL NOT NULL DEFAULT 0,
content_hash TEXT NOT NULL DEFAULT '',
updated_at INTEGER DEFAULT (cast(strftime('%s','now') as int))
);
CREATE INDEX IF NOT EXISTS idx_lc_uuid ON library_cache(uuid);
CREATE INDEX IF NOT EXISTS idx_lc_hash ON library_cache(content_hash);
CREATE INDEX IF NOT EXISTS idx_lc_artist ON library_cache(artist);
CREATE INDEX IF NOT EXISTS idx_lc_album ON library_cache(album);
CREATE TABLE IF NOT EXISTS playcounts_log (
firewire_id TEXT PRIMARY KEY,
last_synced_delta TEXT NOT NULL DEFAULT '{}'
);
"""
# ---------------------------------------------------------------------------
# Fast content fingerprint
# ---------------------------------------------------------------------------
def _content_hash(filepath: str, max_bytes: int = 65536) -> str:
"""SHA1 of first max_bytes of audio data (fast content fingerprint)."""
"""SHA1 of first *max_bytes* of audio data."""
h = hashlib.sha1()
try:
with open(filepath, "rb") as f:
@ -29,96 +87,19 @@ def _content_hash(filepath: str, max_bytes: int = 65536) -> str:
return h.hexdigest()
class LibraryCache:
"""JSON-based cache of audio file metadata.
# ---------------------------------------------------------------------------
# Singleton helpers (kept for backward compatibility)
# ---------------------------------------------------------------------------
Keys are absolute file paths. Values are dicts of metadata
plus internal ``_mtime`` and ``_content_hash`` fields for
change detection and track matching.
Cached entries are returned only when the file still exists
and its ``os.path.getmtime`` matches the stored value.
"""
def __init__(self, cache_path: Optional[str] = None):
if cache_path is None:
cache_path = os.path.join(xdg_cache_path(""), _CACHE_FILE)
self._path = cache_path
self._data: Dict[str, Dict[str, Any]] = {}
def load(self) -> None:
self._data = {}
if not os.path.exists(self._path):
return
try:
with open(self._path, "r", encoding="utf-8") as fh:
self._data = json.load(fh)
except (json.JSONDecodeError, OSError) as exc:
logger.warning("Failed to load library cache: %s", exc)
self._data = {}
def save(self) -> None:
self._data = {k: v for k, v in self._data.items()
if os.path.exists(k)}
os.makedirs(os.path.dirname(self._path), exist_ok=True)
try:
with open(self._path, "w", encoding="utf-8") as fh:
json.dump(self._data, fh, ensure_ascii=False)
except OSError as exc:
logger.warning("Failed to save library cache: %s", exc)
def get(self, file_path: str) -> Optional[Dict[str, Any]]:
entry = self._data.get(file_path)
if entry is None:
return None
if not os.path.isfile(file_path):
self._data.pop(file_path, None)
return None
actual_mtime = os.path.getmtime(file_path)
if actual_mtime != entry.get("_mtime", 0):
return None
result = {}
for k, v in entry.items():
if k == "_mtime":
continue
if k == "_content_hash":
result["content_hash"] = v
else:
result[k] = v
return result
def put(self, file_path: str, data: Dict[str, Any]) -> None:
entry = dict(data)
try:
entry["_mtime"] = os.path.getmtime(file_path)
except OSError:
return
entry["_content_hash"] = _content_hash(file_path)
self._data[file_path] = entry
def remove(self, file_path: str) -> None:
self._data.pop(file_path, None)
def clear(self) -> None:
self._data.clear()
if os.path.exists(self._path):
try:
os.remove(self._path)
except OSError:
pass
def __len__(self) -> int:
return len(self._data)
_library_cache: "LibraryCache | None" = None
_migrated_flag = False
_library_cache: Optional[LibraryCache] = None
def get_library_cache() -> LibraryCache:
def get_library_cache() -> "LibraryCache":
global _library_cache
if _library_cache is None:
_library_cache = LibraryCache()
_library_cache.load()
_library_cache._migrate_old()
return _library_cache
@ -131,3 +112,385 @@ def save_library_cache() -> None:
def invalidate_cache_for_path(file_path: str) -> None:
cache = get_library_cache()
cache.remove(file_path)
# ---------------------------------------------------------------------------
# SQLite-backed cache
# ---------------------------------------------------------------------------
class LibraryCache:
"""Persistent cache backed by a single SQLite database.
Public API (unchanged from the JSON version):
get(path) dict | None
put(path, data) None
remove(path) None
clear() None
save() None
__len__() int
New public API:
iter_entries() Iterator[(path, dict)]
get_by_content_hash(chash) dict | None
get_by_uuid(uid) dict | None
update_play_count(path) int (increment + return)
"""
def __init__(self, db_path: str | None = None):
if db_path is None:
db_path = os.path.join(xdg_data_path(), _DB_FILE)
self._path = db_path
self._lock = threading.RLock()
self.conn = sqlite3.connect(
db_path, check_same_thread=False,
detect_types=sqlite3.PARSE_DECLTYPES,
)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self.conn.execute("PRAGMA busy_timeout=5000")
self.conn.execute("PRAGMA synchronous=NORMAL")
self.conn.execute("PRAGMA foreign_keys=OFF")
self._init_db()
def _init_db(self) -> None:
self._migrate_schema()
self.conn.executescript(_SCHEMA)
self.conn.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}")
self.conn.commit()
def _migrate_schema(self) -> None:
cur_ver = self.conn.execute("PRAGMA user_version").fetchone()[0]
if cur_ver >= 2:
return
if cur_ver == 1:
self.conn.execute("DROP TABLE IF EXISTS playcounts_log")
# ── public read API ────────────────────────────────────────────────
def get(self, file_path: str) -> dict[str, Any] | None:
"""Return cached data for *file_path*, or None if missing or stale."""
with self._lock:
row = self.conn.execute(
"SELECT * FROM library_cache WHERE path=?",
(file_path,),
).fetchone()
if row is None:
return None
if not os.path.isfile(file_path):
return None
actual_mtime = os.path.getmtime(file_path)
if actual_mtime != row["mtime"]:
return None
return self._row_to_dict(row)
def get_by_content_hash(self, content_hash: str) -> dict[str, Any] | None:
"""Look up a track by its content fingerprint."""
with self._lock:
row = self.conn.execute(
"SELECT * FROM library_cache WHERE content_hash=?",
(content_hash,),
).fetchone()
if row is None:
return None
return self._row_to_dict(row)
def get_by_uuid(self, track_uuid: str) -> dict[str, Any] | None:
"""Look up a track by its stable UUID."""
with self._lock:
row = self.conn.execute(
"SELECT * FROM library_cache WHERE uuid=?",
(track_uuid,),
).fetchone()
if row is None:
return None
return self._row_to_dict(row)
def iter_entries(self) -> Iterator[tuple[str, dict[str, Any]]]:
"""Yield (file_path, data_dict) for every row in the database."""
with self._lock:
rows = self.conn.execute(
"SELECT * FROM library_cache"
).fetchall()
for row in rows:
yield (row["path"], self._row_to_dict(row))
def __len__(self) -> int:
with self._lock:
return self.conn.execute(
"SELECT COUNT(*) FROM library_cache"
).fetchone()[0]
# ── public write API ───────────────────────────────────────────────
def put(self, file_path: str, data: dict[str, Any]) -> None:
"""Insert or replace a cache entry for *file_path*."""
try:
mtime = os.path.getmtime(file_path)
except OSError:
return
track_uuid = data.get("_track_uuid") or data.get("uuid") or str(uuid.uuid4())
ch = data.get("_content_hash") or data.get("content_hash") or _content_hash(file_path)
with self._lock:
self.conn.execute(
"""INSERT OR REPLACE INTO library_cache
(path, uuid, title, artist, album, album_artist,
genre, duration_s, size, track_num, source_type,
bitrate, sample_rate, cover_path, thumbnail_url,
play_count, rating, last_played, skip_count,
mtime, content_hash, updated_at)
VALUES (?,?,?,?,?,?,
?,?,?,?,?,
?,?,?,?,
?,?,?,?,
?,?,cast(strftime('%s','now') as int))""",
(
file_path,
track_uuid,
data.get("title", ""),
data.get("artist", ""),
data.get("album", ""),
data.get("album_artist", ""),
data.get("genre", ""),
data.get("duration_s", 0) or data.get("duration", 0),
data.get("size", 0),
data.get("track_num", 0) or data.get("track_number", 0),
data.get("source_type", ""),
data.get("bitrate", 0),
data.get("sample_rate", 44100),
data.get("cover_path", ""),
data.get("thumbnail_url", ""),
data.get("play_count", 0),
data.get("rating", 0),
data.get("last_played", 0),
data.get("skip_count", 0),
mtime,
ch,
),
)
self._backup()
def update_play_stats(self, file_path: str,
play_count: int | None = None,
rating: int | None = None,
last_played: int | None = None,
skip_count: int | None = None) -> None:
"""Update only the play-stat fields for *file_path*."""
fields = []
vals: list[Any] = []
for k, v in [("play_count", play_count), ("rating", rating),
("last_played", last_played), ("skip_count", skip_count)]:
if v is not None:
fields.append(f"{k}=?")
vals.append(v)
if not fields:
return
fields.append("updated_at=cast(strftime('%s','now') as int)")
with self._lock:
self.conn.execute(
f"UPDATE library_cache SET {', '.join(fields)} WHERE path=?",
(*vals, file_path),
)
self._backup()
def remove(self, file_path: str) -> None:
with self._lock:
self.conn.execute(
"DELETE FROM library_cache WHERE path=?", (file_path,),
)
self._backup()
def clear(self) -> None:
with self._lock:
self.conn.execute("DELETE FROM library_cache")
self._backup()
def save(self) -> None:
"""Explicit commit + backup."""
with self._lock:
self.conn.commit()
self._backup()
# ── play-counts delta deduplication ──────────────────────────────────
def get_last_synced_delta(self, firewire_id: str) -> dict[str, dict[str, int]]:
"""Return the delta dict that was last committed for this iPod.
Returns an empty dict if no sync has happened yet.
"""
with self._lock:
row = self.conn.execute(
"SELECT last_synced_delta FROM playcounts_log WHERE firewire_id=?",
(firewire_id,),
).fetchone()
if row and row["last_synced_delta"]:
try:
return json.loads(row["last_synced_delta"])
except json.JSONDecodeError:
pass
return {}
def set_last_synced_delta(self, firewire_id: str,
delta: dict[str, dict[str, int]]) -> None:
"""Store the delta that was committed during the last sync."""
blob = json.dumps(delta, sort_keys=True, separators=(",", ":"))
with self._lock:
self.conn.execute(
"INSERT OR REPLACE INTO playcounts_log (firewire_id, last_synced_delta) "
"VALUES (?, ?)",
(firewire_id, blob),
)
self._backup()
# ── backup rotation ────────────────────────────────────────────────
def _backup(self) -> None:
"""Rotate database backup files after each write.
Commits the current transaction first so the backup connection
can checkpoint the WAL without blocking.
Keeps two historical copies:
library_cache.db current
library_cache.db.backup previous version
library_cache.db.bak version before that
"""
# Commit so the second connection can safely checkpoint.
self.conn.commit()
db = self._path
b1 = db + ".backup"
b2 = db + ".bak"
try:
bak_conn = sqlite3.connect(db)
bak_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
bak_conn.close()
except Exception as exc:
logger.debug("Checkpoint failed: %s", exc)
if os.path.exists(b1):
try:
os.replace(b1, b2)
except OSError:
pass
try:
import shutil
if os.path.exists(db):
shutil.copy2(db, b1)
except OSError as exc:
logger.debug("Backup rotation failed: %s", exc)
# ── internal helpers ───────────────────────────────────────────────
@staticmethod
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
keys = row.keys()
d = dict(zip(keys, row))
d.pop("updated_at", None)
alias = {
"duration_s": "duration",
"track_num": "track_number",
}
for old_k, new_k in alias.items():
if old_k in d and new_k not in d:
d[new_k] = d[old_k]
return d
# ── JSON migration legacy→SQLite ──────────────────────────────────
def _migrate_old(self) -> None:
"""One-shot migration from the old JSON cache file."""
global _migrated_flag
if _migrated_flag:
return
old_dir = xdg_data_path()
old_path = os.path.join(
os.path.dirname(old_dir), # parent: neo-pod-desktop
".cache", "neo-pod-desktop", _OLD_JSON_FILE,
)
# also try the actual XDG cache dir
from xdg_base import xdg_cache_path
alt_path = os.path.join(xdg_cache_path(), _OLD_JSON_FILE)
for candidate in (old_path, alt_path):
if os.path.exists(candidate):
self._do_migrate_json(candidate)
break
_migrated_flag = True
def _do_migrate_json(self, json_path: str) -> None:
"""Read old JSON and insert all entries."""
try:
with open(json_path, "r", encoding="utf-8") as f:
old_data = json.load(f)
except (json.JSONDecodeError, OSError) as exc:
logger.warning("JSON migration source corrupt: %s", exc)
return
count = 0
for file_path, entry in old_data.items():
if not isinstance(entry, dict):
continue
mtime = entry.get("_mtime", 0)
if not mtime:
try:
mtime = os.path.getmtime(file_path)
except OSError:
continue
entry["_track_uuid"] = entry.get("_track_uuid") or str(uuid.uuid4())
entry["_content_hash"] = entry.get("_content_hash") or _content_hash(file_path)
with self._lock:
self.conn.execute(
"""INSERT OR IGNORE INTO library_cache
(path, uuid, title, artist, album, album_artist,
genre, duration_s, size, track_num, source_type,
bitrate, sample_rate, cover_path, thumbnail_url,
play_count, rating, last_played, skip_count,
mtime, content_hash, updated_at)
VALUES (?,?,?,?,?,?,
?,?,?,?,?,
?,?,?,?,
?,?,?,?,
?,?,cast(strftime('%s','now') as int))""",
(
file_path,
entry.get("_track_uuid", ""),
entry.get("title", ""),
entry.get("artist", ""),
entry.get("album", ""),
entry.get("album_artist", ""),
entry.get("genre", ""),
entry.get("duration_s", 0) or entry.get("duration", 0),
entry.get("size", 0),
entry.get("track_num", 0) or entry.get("track_number", 0),
entry.get("source_type", ""),
entry.get("bitrate", 0),
entry.get("sample_rate", 44100),
entry.get("cover_path", ""),
entry.get("thumbnail_url", ""),
entry.get("play_count", 0),
entry.get("rating", 0),
entry.get("last_played", 0),
entry.get("skip_count", 0),
mtime,
entry.get("_content_hash", ""),
),
)
count += 1
self.conn.commit()
self._backup()
# Rename old file so we don't re-migrate
try:
os.rename(json_path, json_path + ".migrated")
logger.info(
"Migrated %d entries from %s → SQLite",
count, json_path,
)
except OSError:
pass

View File

@ -17,7 +17,7 @@ import requests
from PIL import Image
from mutagen import File as MutagenFile
from mutagen.mp4 import MP4, MP4Cover
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK
from mutagen.id3 import ID3, APIC, POPM, TIT2, TPE1, TALB, TRCK
from mutagen.easyid3 import EasyID3
from track_info import TrackInfo
@ -552,7 +552,8 @@ class MetadataHandler:
file_path: Path to audio file
tags: Dict of tag_name -> value
Supported keys: title, artist, album, album_artist, genre,
date, track_number, track_total, disc_number, disc_total, comment
date, track_number, track_total, disc_number, disc_total,
comment, rating (05 stars)
new_cover_path: Path to new cover image, None to keep existing,
empty string '' to remove cover art
@ -637,6 +638,13 @@ class MetadataHandler:
elif 'disk' in audio:
del audio['disk']
if 'rating' in tags:
val = tags['rating'] * 20 # stars → 0-100
if val > 0:
audio['\xa9rt'] = [val]
elif '\xa9rt' in audio:
del audio['\xa9rt']
if new_cover_path == '':
if 'covr' in audio:
del audio['covr']
@ -704,6 +712,16 @@ class MetadataHandler:
elif 'discnumber' in audio:
del audio['discnumber']
if 'rating' in tags:
stars = tags['rating']
popm_val = [0, 64, 128, 192, 224, 255][min(stars, 5)]
audio = ID3(file_path)
audio.delall("POPM")
if popm_val > 0:
audio.add(POPM(email="", rating=popm_val))
audio.save(file_path)
audio = EasyID3(file_path)
audio.save()
if new_cover_path is not None:
@ -798,6 +816,16 @@ class MetadataHandler:
except (KeyError, TypeError):
pass
if 'rating' in tags:
val = str(tags['rating'] * 20) # stars → "0" … "100"
if int(val) > 0:
audio.tags['RATING'] = val
elif 'RATING' in audio.tags:
try:
del audio.tags['RATING']
except (KeyError, TypeError):
pass
if new_cover_path is not None:
self._clear_cover_vorbis(audio)
if new_cover_path != '':

View File

@ -5,11 +5,11 @@ import time
import logging
from typing import List, Dict, Optional
from xdg_base import xdg_data_path
logger = logging.getLogger(__name__)
_PLAYLIST_FILE = os.path.join(
os.path.expanduser("~"), ".config", "neo-pod-desktop", "playlists.json"
)
_PLAYLIST_FILE = os.path.join(xdg_data_path(), "playlists.json")
class Playlist:
@ -47,8 +47,22 @@ class PlaylistManager:
def __init__(self, filepath: str = _PLAYLIST_FILE):
self._filepath = filepath
self._playlists: List[Playlist] = []
self._migrate_old()
self._load()
def _migrate_old(self):
"""Migrate from old ~/.config/ location to ~/.local/share/."""
old = os.path.join(
os.path.expanduser("~"), ".config", "neo-pod-desktop", "playlists.json",
)
if os.path.exists(old) and not os.path.exists(self._filepath):
try:
os.makedirs(os.path.dirname(self._filepath), exist_ok=True)
os.rename(old, self._filepath)
logger.info("Migrated playlists from %s%s", old, self._filepath)
except OSError as exc:
logger.warning("Playlist migration failed: %s", exc)
def _load(self):
try:
os.makedirs(os.path.dirname(self._filepath), exist_ok=True)

88
src/session_store.py Normal file
View File

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
Session state store persistent playback state.
Stored as JSON in ``~/.local/share/neo-pod-desktop/session.json``.
Replaces the ``[Playback]`` section of config.ini session state
(user's last track, position, volume, playback mode) is user data,
not configuration.
"""
import json
import logging
import os
from typing import Any
from xdg_base import xdg_data_path
logger = logging.getLogger(__name__)
_SESSION_FILE = "session.json"
class SessionStore:
def __init__(self, path: str | None = None):
self._path = path or os.path.join(xdg_data_path(), _SESSION_FILE)
self._data: dict[str, Any] = {}
self._load()
# ── load / save ────────────────────────────────────────────────────
def _load(self) -> None:
if not os.path.exists(self._path):
return
try:
with open(self._path, "r", encoding="utf-8") as f:
self._data = json.load(f)
except (json.JSONDecodeError, OSError) as exc:
logger.warning("Failed to load session: %s", exc)
self._data = {}
def save(self) -> None:
tmp = self._path + ".tmp"
try:
os.makedirs(os.path.dirname(self._path), exist_ok=True)
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self._data, f, ensure_ascii=False)
os.replace(tmp, self._path)
except OSError as exc:
logger.warning("Failed to save session: %s", exc)
# ── typed accessors ────────────────────────────────────────────────
def get_str(self, key: str, fallback: str = "") -> str:
return str(self._data.get(key, fallback))
def get_int(self, key: str, fallback: int = 0) -> int:
return int(self._data.get(key, fallback))
def get_bool(self, key: str, fallback: bool = False) -> bool:
v = self._data.get(key, fallback)
if isinstance(v, bool):
return v
return str(v).lower() in ("true", "1", "yes")
def set(self, key: str, value: Any) -> None:
self._data[key] = value
# ── migrations ─────────────────────────────────────────────────────
def migrate_from_config(self, config_loader: Any) -> None:
"""Import ``[Playback]`` section from the old config loader."""
keys = [
"last_track_path", "last_position_ms", "last_volume",
"last_playing", "shuffle_active", "repeat_mode",
]
changed = False
for k in keys:
if k not in self._data:
v = config_loader.get("Playback", k, fallback="")
self.set(k, v)
changed = True
if changed:
self.save()
# Wipe old Playback section so it isn't read again.
if config_loader.config.has_section("Playback"):
config_loader.config.remove_section("Playback")
config_loader.save()
logger.info("Migrated playback state from config.ini to session.json")

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,8 @@ import os
import sys
import time
import logging
from typing import List, Dict, Optional, Tuple, Any
import shutil
from typing import List, Dict, Optional, Tuple, Any, Callable
from pathlib import Path
from PyQt6.QtWidgets import (
@ -17,8 +18,8 @@ from PyQt6.QtWidgets import (
QTableWidget, QTableWidgetItem, QSlider, QMenu, QInputDialog,
QStackedWidget, QListWidget, QListWidgetItem, QListView,
)
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSize
from PyQt6.QtGui import QPixmap, QColor, QIcon, QPainter, QPainterPath
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, QSize, QUrl, QMimeData
from PyQt6.QtGui import QPixmap, QColor, QIcon, QPainter, QPainterPath, QDrag
from track_info import TrackInfo
from metadata_handler import MetadataHandler
@ -41,6 +42,51 @@ EQ_FRAMES = [
"\u2585\u2586\u2583", "\u2586\u2584\u2581",
]
class DraggableLibraryTable(QTableWidget):
"""QTableWidget subclass that supports dragging tracks as file URLs."""
def __init__(self, parent=None):
super().__init__(parent)
self.setDragEnabled(True)
def startDrag(self, supportedActions):
paths = []
for item in self.selectedItems():
data_item = self.item(item.row(), 0)
if data_item is None:
continue
_is_ready, track_data = data_item.data(Qt.ItemDataRole.UserRole)
if track_data and track_data.get("path"):
paths.append(track_data["path"])
if not paths:
return
paths = list(set(paths))
mime = QMimeData()
mime.setUrls([QUrl.fromLocalFile(p) for p in paths])
drag = QDrag(self)
drag.setMimeData(mime)
count = len(paths)
label = f"{count} track{'s' if count != 1 else ''}"
pixmap = QPixmap(160, 28)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
bg = self.palette().color(self.palette().ColorRole.Highlight)
painter.setBrush(bg)
painter.setPen(Qt.PenStyle.NoPen)
painter.drawRoundedRect(0, 0, 160, 28, 6, 6)
painter.setPen(self.palette().color(self.palette().ColorRole.HighlightedText))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, label)
painter.end()
drag.setPixmap(pixmap)
drag.setHotSpot(pixmap.rect().center())
drag.exec(Qt.DropAction.CopyAction)
class NumericTableItem(QTableWidgetItem):
def __lt__(self, other):
_, self_data = self.data(Qt.ItemDataRole.UserRole) or (False, {})
@ -54,7 +100,6 @@ class LibraryTab(QWidget):
"""Library tab widget with library table, filtering, and transfer workflows."""
transfer_finished = pyqtSignal()
tab_switch_requested = pyqtSignal(int)
play_requested = pyqtSignal(dict, int, bool)
def __init__(self, config_loader: ConfigLoader, parent=None):
@ -95,7 +140,7 @@ class LibraryTab(QWidget):
self._setup_library_toolbar(layout)
self.library_table = QTableWidget()
self.library_table = DraggableLibraryTable()
self.library_table.setColumnCount(8)
self.library_table.setHorizontalHeaderLabels(
["#", "Artist", "Title", "Album", "Duration", "Genre", "Size", "Plays"]
@ -212,6 +257,13 @@ class LibraryTab(QWidget):
layout.addLayout(toolbar)
def playable_row_indices(self) -> list[int]:
indices = []
for row in range(self.library_table.rowCount()):
if not self.library_table.isRowHidden(row):
indices.append(row)
return indices
def play_track_at_index(self, index: int, start_position_ms: int = 0, auto_play: bool = True):
if index < 0 or index >= self.library_table.rowCount():
return
@ -398,7 +450,8 @@ class LibraryTab(QWidget):
return f"{size_bytes / 1024:.0f} KB"
return f"{size_bytes / (1024 * 1024):.1f} MB"
def _scan_library(self, force: bool = False):
def _scan_library(self, force: bool = False,
on_progress: Callable[[int, int], None] | None = None):
output_dir = self.get_output_dir()
if not output_dir or not os.path.isdir(output_dir):
self.library_ready.clear()
@ -406,10 +459,23 @@ class LibraryTab(QWidget):
self._refresh_library_ui()
return
# Pre-count total scannable files so the overlay can show progress.
total = 0
if on_progress:
for root, _dirs, files in os.walk(output_dir):
for fname in files:
fpath = os.path.join(root, fname)
ext = os.path.splitext(fname)[1].lower()
if os.path.isfile(fpath) and (ext in AUDIO_EXTS or ext in SOURCE_EXTS):
total += 1
if total == 0:
total = 1
ready = []
sources = []
cache_hits = 0
cache_misses = 0
processed = 0
cache = get_library_cache()
handler = MetadataHandler()
@ -421,6 +487,7 @@ class LibraryTab(QWidget):
if not os.path.isfile(fpath):
continue
processed += 1
cached = None if force else cache.get(fpath)
if cached is not None:
@ -431,18 +498,19 @@ class LibraryTab(QWidget):
elif ext in SOURCE_EXTS:
cached.pop("_mtime", None)
sources.append(cached)
continue
else:
cache_misses += 1
if ext in AUDIO_EXTS:
track_data = self._extract_ready_track(fpath, fname, handler)
ready.append(track_data)
cache.put(fpath, track_data)
elif ext in SOURCE_EXTS:
track_data = self._extract_source_track(fpath, fname, handler)
sources.append(track_data)
cache.put(fpath, track_data)
cache_misses += 1
if ext in AUDIO_EXTS:
track_data = self._extract_ready_track(fpath, fname, handler)
ready.append(track_data)
cache.put(fpath, track_data)
elif ext in SOURCE_EXTS:
track_data = self._extract_source_track(fpath, fname, handler)
sources.append(track_data)
cache.put(fpath, track_data)
if on_progress:
on_progress(processed, total)
cache.save()
@ -617,6 +685,52 @@ class LibraryTab(QWidget):
self._restore_playing_row()
self._build_album_grid()
def refresh_play_counts_from_cache(self):
"""Reload play-count values from the persistent cache without a
full library rescan.
Called after ``mount_and_load`` completes the cache already
holds merged iPod + local play stats, but the table was
rendered before the mount and displays stale values.
"""
cache = get_library_cache()
updated = 0
ready_index = {t["path"]: i for i, t in enumerate(self.library_ready)}
for row in range(self.library_table.rowCount()):
item_0 = self.library_table.item(row, 0)
if item_0 is None:
continue
data = item_0.data(Qt.ItemDataRole.UserRole)
if data is None:
continue
_is_ready, track = data
if not _is_ready:
continue
path = track.get("path", "")
if not path:
continue
entry = cache.get(path)
if entry is None:
continue
new_pc = entry.get("play_count", 0)
old_pc = track.get("play_count", 0)
if new_pc != old_pc:
track["play_count"] = new_pc
pc_cell = self.library_table.item(row, 7)
if pc_cell:
pc_cell.setText(str(new_pc))
idx = ready_index.get(path)
if idx is not None:
self.library_ready[idx]["play_count"] = new_pc
updated += 1
if updated:
logger.debug("Refreshed play counts for %d tracks from cache", updated)
def _restore_playing_row(self):
idx = self.current_playback_index
if idx < 0:
@ -702,20 +816,77 @@ class LibraryTab(QWidget):
self._scan_library(force=True)
def _on_library_add_files(self):
source_filter = "Audio Files (*.flac *.wav *.ogg *.opus *.wma *.ape *.aiff *.alac)"
all_exts = sorted(SOURCE_EXTS | AUDIO_EXTS)
source_filter = f"Audio Files (*{' *'.join(all_exts)})"
files, _ = QFileDialog.getOpenFileNames(
self, "Select Audio Files", "", source_filter
)
new_files = []
new_source_files = []
copied_ready = []
for f in files:
ext = os.path.splitext(f)[1].lower()
if ext in SOURCE_EXTS and os.path.exists(f) and f not in self.library_source_files:
self.library_source_files.append(f)
new_files.append(f)
new_source_files.append(f)
elif ext in AUDIO_EXTS and os.path.exists(f):
output_dir = self.get_output_dir()
if output_dir:
dest = self._copy_to_library(f, output_dir)
if dest:
copied_ready.append(dest)
if new_files:
if new_source_files or copied_ready:
self._scan_library()
def _copy_to_library(self, src: str, output_dir: str) -> str:
handler = MetadataHandler()
ext = os.path.splitext(src)[1].lower()
try:
info = handler.extract_tags(src)
artist = info.artist if info and info.artist else None
album = info.album if info and info.album else None
title = info.title if info and info.title else None
track_num = info.track_number if info else 0
except Exception:
artist = album = title = None
track_num = 0
if title:
safe_title = self._sanitize_filename(title)
safe_artist = self._sanitize_filename(artist) if artist else "_Unknown"
safe_album = self._sanitize_filename(album) if album else "_Unknown"
fname = f"{track_num:02d} - {safe_title}{ext}" if track_num > 0 else f"{safe_title}{ext}"
dest_dir = os.path.join(output_dir, safe_artist, safe_album)
else:
fname = os.path.basename(src)
dest_dir = output_dir
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, fname)
if os.path.normpath(src) == os.path.normpath(dest):
return dest
if not os.path.exists(dest):
shutil.copy2(src, dest)
return dest
base, ext = os.path.splitext(fname)
for i in range(1, 100):
dest = os.path.join(dest_dir, f"{base}_{i}{ext}")
if not os.path.exists(dest):
shutil.copy2(src, dest)
return dest
return None
@staticmethod
def _sanitize_filename(filename: str) -> str:
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
filename = filename.replace(char, '_')
if len(filename) > 100:
filename = filename[:97] + '...'
return filename.strip()
def _incremental_update_after_edit(self, file_paths: list):
cache = get_library_cache()
handler = MetadataHandler()
@ -957,7 +1128,7 @@ class LibraryTab(QWidget):
)
return
if self.worker_thread and self.worker_thread.isRunning():
if self._is_worker_running():
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
return
@ -1025,7 +1196,7 @@ class LibraryTab(QWidget):
QMessageBox.warning(self, "Error", "No iPod device mounted. Go to iPod tab and mount first.")
return
if self.worker_thread and self.worker_thread.isRunning():
if self._is_worker_running():
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
return
@ -1060,6 +1231,72 @@ class LibraryTab(QWidget):
self._cleanup_worker()
def transfer_tracks_by_paths(self, paths: List[str]):
"""Transfer tracks by file paths (used for drag-drop onto iPod tab)."""
ready_by_path = {}
for t in self.library_ready:
p = t.get("path", "")
if p:
ready_by_path[p] = t
handler = MetadataHandler()
tracks = []
for path in paths:
if not os.path.exists(path):
continue
if path in ready_by_path:
d = ready_by_path[path]
ti = TrackInfo(
source_id=f"local_{hash(path)}",
title=d["title"], artist=d["artist"],
album=d.get("album", ""), thumbnail_url="",
duration=d.get("duration_s", 0),
track_number=d.get("track_num"),
genre=d.get("genre", ""), download_path=path,
)
tracks.append((ti, path))
else:
try:
info = handler.extract_tags(path)
ti = TrackInfo(
source_id=f"local_{hash(path)}",
title=info.title if info else os.path.basename(path),
artist=info.artist if info else "Unknown",
album=info.album if info else "", thumbnail_url="",
duration=info.duration if info else 0,
track_number=info.track_number if info else 0,
genre=info.genre if info else "", download_path=path,
)
tracks.append((ti, path))
except Exception as e:
logger.warning(f"Skipping {path}: {e}")
continue
if not tracks:
QMessageBox.warning(self, "Error", "No valid tracks to transfer")
return
if not self._current_mount_point:
QMessageBox.warning(self, "Error", "No iPod device mounted. Go to iPod tab and mount first.")
return
if self._is_worker_running():
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
return
self.library_transfer_btn.setEnabled(False)
self.library_status.setText("Transferring...")
self.library_progress.setValue(0)
self.worker_thread = WorkerThread(
task_type="transfer",
tracks=tracks,
mount_point=self._current_mount_point,
)
self.worker_thread.progress_signal.connect(self._on_library_transfer_progress)
self.worker_thread.finished_signal.connect(self._on_library_transfer_finished)
self.worker_thread.start()
def _on_sync_metadata_clicked(self):
selected_tracks = self._get_selected_ready_tracks()
if not selected_tracks:
@ -1070,7 +1307,7 @@ class LibraryTab(QWidget):
QMessageBox.warning(self, "Error", "No iPod device mounted.")
return
if self.worker_thread and self.worker_thread.isRunning():
if self._is_worker_running():
QMessageBox.information(self, "Info", "Please wait for the current task to finish")
return
@ -1140,10 +1377,18 @@ class LibraryTab(QWidget):
def _cleanup_worker(self):
if self.worker_thread:
self.worker_thread.wait(3000)
self.worker_thread.deleteLater()
self.worker_thread.quit()
self.worker_thread.wait()
self.worker_thread = None
def _is_worker_running(self):
if self.worker_thread:
try:
return self.worker_thread.isRunning()
except RuntimeError:
self.worker_thread = None
return False
def on_device_mounted(self, mount_point: str):
self._current_mount_point = mount_point
self.library_progress.setVisible(True)
@ -1201,7 +1446,7 @@ class LibraryTab(QWidget):
entry = cache.get(path) if path else None
visible = False
if entry and isinstance(entry, dict):
added = entry.get("_mtime", 0) or entry.get("added_at", 0) or 0
added = entry.get("mtime", 0) or entry.get("added_at", 0) or 0
visible = added >= cutoff
self.library_table.setRowHidden(row, not visible)

View File

@ -31,6 +31,8 @@ from PyQt6.QtGui import QPixmap, QDragEnterEvent, QDropEvent
from metadata_handler import MetadataHandler
from artwork.cache import cover_cache
from services.itunes_search import search_itunes
from library_cache import get_library_cache, save_library_cache
from .star_rating import ClickableStarRating
COVER_SIZE = 250
@ -72,6 +74,8 @@ class MetadataEditorDialog(QDialog):
self._original_tags = dict(first_tags)
self._existing_cover_data = self._extract_cover_from_path(self.file_paths[0])
self._load_ratings_from_cache()
if self._batch_mode:
all_tags = [first_tags]
all_different = False
@ -84,6 +88,31 @@ class MetadataEditorDialog(QDialog):
if tags.get(key) != self._original_tags.get(key):
self._mixed_fields.add(key)
def _load_ratings_from_cache(self):
"""Load rating from LibraryCache for all selected files.
Stores the rating (05 stars) in ``self._original_tags['rating']``
(only when cache has a value; filetag rating takes precedence if
cache is empty). In batch mode, marks ``rating`` as mixed when
values differ.
"""
cache = get_library_cache()
ratings = []
for p in self.file_paths:
entry = cache.get(p)
r = entry.get("rating", 0) if entry else 0
ratings.append(max(0, min(5, round(r / 20))))
cache_val = ratings[0]
if cache_val > 0 or "rating" not in self._original_tags:
self._original_tags["rating"] = cache_val
if self._batch_mode and len(set(ratings)) > 1:
self._mixed_fields.add("rating")
self._ratings = {p: r for p, r in zip(self.file_paths, ratings)}
else:
self._ratings = {}
def _load_single_file(self, file_path: str, skip_cover: bool = False) -> Dict[str, Any]:
"""Load tags from a single file. Returns dict of tag_key -> value."""
result: Dict[str, Any] = {}
@ -110,12 +139,40 @@ class MetadataEditorDialog(QDialog):
result[our_key] = val
result.update(self._load_track_disc(tags))
result.update(self._load_rating_from_tags(tags, ext))
except Exception:
pass
return result
@staticmethod
def _load_rating_from_tags(tags, ext: str) -> dict:
"""Read rating (05 stars) from audio file tags, if present."""
if tags is None:
return {}
try:
if ext in ('.m4a', '.aac', '.mp4') and '\xa9rt' in tags:
rt = tags['\xa9rt']
if isinstance(rt, list) and rt:
val = int(rt[0]) // 20 # 0-100 → 0-5
return {"rating": max(0, min(5, val))}
elif ext == '.mp3':
for frame in tags.getall('POPM'):
if frame.rating > 0:
pops = [0, 64, 128, 192, 224, 255]
stars = max(i for i, v in enumerate(pops) if v <= frame.rating)
return {"rating": stars}
elif ext in ('.flac', '.ogg', '.opus') and 'RATING' in tags:
raw = tags['RATING']
if isinstance(raw, list):
raw = raw[0]
val = int(str(raw)) // 20
return {"rating": max(0, min(5, val))}
except Exception:
pass
return {}
def _load_track_disc(self, tags) -> Dict[str, Any]:
result = {}
if tags is None:
@ -366,6 +423,11 @@ class MetadataEditorDialog(QDialog):
self._genre_label = QLabel("Genre:")
form_layout.addRow(self._genre_label, self.genre_edit)
self.rating_widget = ClickableStarRating()
self.rating_widget.rating_changed.connect(self._on_rating_changed)
self._rating_label = QLabel("Rating:")
form_layout.addRow(self._rating_label, self.rating_widget)
self._year_label = QLabel("Year:")
form_layout.addRow(self._year_label, self.year_spin)
@ -429,6 +491,10 @@ class MetadataEditorDialog(QDialog):
for key in self._mixed_fields:
self._set_mixed(key)
if "rating" in self._mixed_fields:
self.rating_widget.set_rating(0)
self._rating_label.setText("Rating: (mixed)")
def _set_mixed(self, key: str):
placeholders = {
'title': "(different values)",
@ -488,8 +554,12 @@ class MetadataEditorDialog(QDialog):
self.disc_num_spin.setValue(t.get('disc_number', 0))
self.disc_total_spin.setValue(t.get('disc_total', 0))
self.rating_widget.set_rating(t.get("rating", 0))
self._update_cover_display()
def _on_rating_changed(self, stars: int) -> None:
pass # placeholder for future live-preview
def _update_cover_display(self):
if self._cover_removed:
self.cover_label.setText("No cover")
@ -629,6 +699,7 @@ class MetadataEditorDialog(QDialog):
'disc_number': self.disc_num_spin.value(),
'disc_total': self.disc_total_spin.value(),
'comment': self.comment_edit.text().strip(),
'rating': self.rating_widget.rating(),
}
def _collect_changes(self) -> Dict[str, Any]:
@ -709,6 +780,9 @@ class MetadataEditorDialog(QDialog):
else:
self._invalidate_cover_cache(path, changes, cover_arg)
if not failed:
self._persist_rating(paths, changes)
if failed == len(paths):
QMessageBox.warning(self, "Error", "Failed to save metadata changes.")
elif failed:
@ -722,6 +796,21 @@ class MetadataEditorDialog(QDialog):
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save metadata:\n{e}")
def _persist_rating(self, paths: list[str], changes: dict[str, Any]) -> None:
if "rating" not in changes:
return
try:
cache = get_library_cache()
for p in paths:
entry = cache.get(p)
if entry is None:
continue
entry["rating"] = changes["rating"] * 20
cache.put(p, entry)
save_library_cache()
except Exception:
logger.exception("Failed to persist rating to LibraryCache")
def _invalidate_cover_cache(self, file_path: str, changes: Dict[str, Any],
cover_arg: Optional[str]) -> None:
"""Update cover cache after successful save."""

View File

@ -1,5 +1,5 @@
from PyQt6.QtWidgets import (
QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QSlider, QGroupBox, QStyle,
QWidget, QHBoxLayout, QVBoxLayout, QPushButton, QLabel, QSlider, QGroupBox, QStyle, QProxyStyle,
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtGui import QPixmap, QColor
@ -11,10 +11,19 @@ def _fmt_time(ms: int) -> str:
return f"{m}:{s:02d}"
class JumpStyle(QProxyStyle):
def styleHint(self, hint, opt=None, widget=None, returnData=None):
if hint == QStyle.StyleHint.SH_Slider_AbsoluteSetButtons:
return Qt.MouseButton.LeftButton.value
return super().styleHint(hint, opt, widget, returnData)
class PlayerHeader(QGroupBox):
prev_requested = pyqtSignal()
play_pause_requested = pyqtSignal()
next_requested = pyqtSignal()
shuffle_requested = pyqtSignal()
repeat_requested = pyqtSignal()
position_changed_by_user = pyqtSignal(int)
volume_changed = pyqtSignal(int)
@ -45,15 +54,28 @@ class PlayerHeader(QGroupBox):
self.next_btn.setToolTip("Next")
self.next_btn.clicked.connect(self.next_requested)
self.shuffle_btn = QPushButton("\u21C4")
self.shuffle_btn.setFixedWidth(36)
self.shuffle_btn.setToolTip("Shuffle")
self.shuffle_btn.clicked.connect(self.shuffle_requested)
self.repeat_btn = QPushButton("\u21BB")
self.repeat_btn.setFixedWidth(36)
self.repeat_btn.setToolTip("Repeat: Off")
self.repeat_btn.clicked.connect(self.repeat_requested)
controls_row.addWidget(self.shuffle_btn)
controls_row.addWidget(self.prev_btn)
controls_row.addWidget(self.play_btn)
controls_row.addWidget(self.next_btn)
controls_row.addWidget(self.repeat_btn)
self.time_label_start = QLabel("0:00")
self.time_label_start.setFixedWidth(40)
self.time_label_start.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.position_slider = QSlider(Qt.Orientation.Horizontal)
self.position_slider.setStyle(JumpStyle(self.position_slider.style()))
self.position_slider.setMinimum(0)
self.position_slider.setMaximum(1000)
self.position_slider.setValue(0)
@ -94,9 +116,30 @@ class PlayerHeader(QGroupBox):
now_playing_layout.addWidget(self.now_playing_label, stretch=1)
layout.addLayout(now_playing_layout)
def set_shuffle_active(self, active: bool):
if active:
self.shuffle_btn.setStyleSheet("color: palette(highlight); font-weight: bold;")
self.shuffle_btn.setToolTip("Shuffle: On")
else:
self.shuffle_btn.setStyleSheet("")
self.shuffle_btn.setToolTip("Shuffle: Off")
def set_repeat_mode(self, mode: str):
if mode == "repeat_all":
self.repeat_btn.setText("\u21BB")
self.repeat_btn.setStyleSheet("color: palette(highlight); font-weight: bold;")
self.repeat_btn.setToolTip("Repeat: All")
elif mode == "repeat_one":
self.repeat_btn.setText("\u21BB\u00B9")
self.repeat_btn.setStyleSheet("color: palette(highlight); font-weight: bold;")
self.repeat_btn.setToolTip("Repeat: One")
else:
self.repeat_btn.setText("\u21BB")
self.repeat_btn.setStyleSheet("")
self.repeat_btn.setToolTip("Repeat: Off")
def _on_slider_user_action(self):
frac = self.position_slider.value() / 1000.0
self.position_changed_by_user.emit(int(frac))
self.position_changed_by_user.emit(self.position_slider.value())
def set_cover(self, pixmap: QPixmap = None, tooltip: str = ""):
if pixmap:

View File

@ -1,4 +1,5 @@
import logging
import os
from typing import List, Optional
from PyQt6.QtWidgets import (
@ -6,8 +7,9 @@ from PyQt6.QtWidgets import (
QListWidget, QListWidgetItem, QMenu, QInputDialog, QMessageBox,
QFrame,
)
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtCore import Qt, pyqtSignal, QUrl
from PyQt6.QtGui import QColor
from PyQt6.QtGui import QDragEnterEvent, QDragMoveEvent, QDragLeaveEvent, QDropEvent
logger = logging.getLogger(__name__)
@ -16,9 +18,184 @@ SIDEBAR_MAX_WIDTH = 240
SIDEBAR_DEFAULT_WIDTH = 200
class PlaylistDropListWidget(QListWidget):
"""QListWidget that accepts external track drops onto playlist items."""
tracks_dropped_on_playlist = pyqtSignal(str, list)
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
self._hovered_item = None
def mimeData(self, items):
mime = super().mimeData(items)
if items:
playlist_id = items[0].data(Qt.ItemDataRole.UserRole)
if playlist_id:
mime.setData("application/x-neopod-playlist", playlist_id.encode())
return mime
def dragEnterEvent(self, event: QDragEnterEvent):
if event.source() is not self and event.mimeData().hasUrls():
event.acceptProposedAction()
else:
super().dragEnterEvent(event)
def dragMoveEvent(self, event: QDragMoveEvent):
if event.source() is not self and event.mimeData().hasUrls():
pos = event.position().toPoint()
item = self.itemAt(pos)
if item != self._hovered_item:
self._clear_hover()
if item:
self._hovered_item = item
bg = self.palette().color(self.palette().ColorRole.Highlight)
c = bg.name()
item.setBackground(bg)
event.acceptProposedAction()
else:
super().dragMoveEvent(event)
def dragLeaveEvent(self, event: QDragLeaveEvent):
self._clear_hover()
super().dragLeaveEvent(event)
def dropEvent(self, event: QDropEvent):
self._clear_hover()
if event.source() is not self and event.mimeData().hasUrls():
pos = event.position().toPoint()
item = self.itemAt(pos)
if item is None:
event.ignore()
return
playlist_id = item.data(Qt.ItemDataRole.UserRole)
if not playlist_id:
event.ignore()
return
paths = [url.toLocalFile() for url in event.mimeData().urls()]
paths = [p for p in paths if os.path.exists(p)]
if paths:
self.tracks_dropped_on_playlist.emit(playlist_id, paths)
event.acceptProposedAction()
else:
event.ignore()
else:
super().dropEvent(event)
def _clear_hover(self):
if self._hovered_item:
self._hovered_item.setData(Qt.ItemDataRole.BackgroundRole, None)
self._hovered_item = None
class NavDropListWidget(QListWidget):
"""QListWidget that accepts external track drops on the iPod nav item."""
tracks_dropped_for_transfer = pyqtSignal(list)
playlist_dropped_for_transfer = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
self._hovered_item = None
def _target_item_at(self, pos):
item = self.itemAt(pos)
if item and item.data(Qt.ItemDataRole.UserRole + 2) == "ipod":
return item
return None
def _repolish(self):
self.style().unpolish(self)
self.style().polish(self)
def dragEnterEvent(self, event: QDragEnterEvent):
if event.mimeData().hasFormat("application/x-neopod-playlist"):
self.setProperty("dndActive", True)
self._repolish()
pos = event.position().toPoint()
if self._target_item_at(pos):
event.acceptProposedAction()
return
# Let default handler accept so dragMoveEvent still fires
if event.source() is not self and event.mimeData().hasUrls():
pos = event.position().toPoint()
if self._target_item_at(pos):
event.acceptProposedAction()
return
super().dragEnterEvent(event)
def dragMoveEvent(self, event: QDragMoveEvent):
accept = False
if event.source() is not self and event.mimeData().hasUrls():
pos = event.position().toPoint()
item = self._target_item_at(pos)
if item != self._hovered_item:
self._clear_hover()
if item:
self._hovered_item = item
bg = self.palette().color(self.palette().ColorRole.Highlight)
item.setBackground(bg)
if item:
event.acceptProposedAction()
accept = True
if not accept and event.mimeData().hasFormat("application/x-neopod-playlist"):
pos = event.position().toPoint()
item = self._target_item_at(pos)
if item != self._hovered_item:
self._clear_hover()
if item:
self._hovered_item = item
bg = self.palette().color(self.palette().ColorRole.Highlight)
item.setBackground(bg)
if item:
event.acceptProposedAction()
accept = True
if not accept:
event.ignore()
def dragLeaveEvent(self, event: QDragLeaveEvent):
self._clear_hover()
if self.property("dndActive"):
self.setProperty("dndActive", False)
self._repolish()
super().dragLeaveEvent(event)
def dropEvent(self, event: QDropEvent):
self._clear_hover()
if self.property("dndActive"):
self.setProperty("dndActive", False)
self._repolish()
if event.source() is not self and event.mimeData().hasUrls():
pos = event.position().toPoint()
if self._target_item_at(pos):
paths = [url.toLocalFile() for url in event.mimeData().urls()]
paths = [p for p in paths if os.path.exists(p)]
if paths:
self.tracks_dropped_for_transfer.emit(paths)
event.acceptProposedAction()
return
if event.mimeData().hasFormat("application/x-neopod-playlist"):
pos = event.position().toPoint()
if self._target_item_at(pos):
playlist_id = bytes(event.mimeData().data("application/x-neopod-playlist")).decode()
if playlist_id:
self.playlist_dropped_for_transfer.emit(playlist_id)
event.acceptProposedAction()
return
super().dropEvent(event)
def _clear_hover(self):
if self._hovered_item:
self._hovered_item.setData(Qt.ItemDataRole.BackgroundRole, None)
self._hovered_item = None
class SidebarWidget(QWidget):
item_selected = pyqtSignal(str, str)
playlist_selected = pyqtSignal(str)
tracks_dropped_on_playlist = pyqtSignal(str, list)
tracks_dropped_for_nav_transfer = pyqtSignal(list)
playlist_dropped_for_nav_transfer = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
@ -78,6 +255,9 @@ class SidebarWidget(QWidget):
QListWidget::item:hover:!selected {{
background: {hl_light_name};
}}
#navList[dndActive="true"]::item:hover:!selected {{
background: transparent;
}}
""")
def _build_ui(self):
@ -126,7 +306,7 @@ class SidebarWidget(QWidget):
self._playlists_header.setStyleSheet(section_style)
layout.addWidget(self._playlists_header)
self._playlist_list = QListWidget()
self._playlist_list = PlaylistDropListWidget()
self._playlist_list.setDragDropMode(QListWidget.DragDropMode.InternalMove)
self._playlist_list.setDefaultDropAction(Qt.DropAction.MoveAction)
self._playlist_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
@ -135,6 +315,7 @@ class SidebarWidget(QWidget):
)
self._playlist_list.itemClicked.connect(self._on_playlist_clicked)
self._playlist_list.model().rowsMoved.connect(self._on_playlists_reordered)
self._playlist_list.tracks_dropped_on_playlist.connect(self._on_tracks_dropped_on_playlist)
layout.addWidget(self._playlist_list, stretch=1)
btn_style = f"""
@ -163,7 +344,8 @@ class SidebarWidget(QWidget):
line2.setFixedHeight(1)
layout.addWidget(line2)
self._nav_list = QListWidget()
self._nav_list = NavDropListWidget()
self._nav_list.setObjectName("navList")
items = [
("🎸 iPod", "ipod"),
("⚙️ Settings", "settings"),
@ -174,6 +356,8 @@ class SidebarWidget(QWidget):
item.setData(Qt.ItemDataRole.UserRole + 2, tab_id)
self._nav_list.addItem(item)
self._nav_list.itemClicked.connect(self._on_nav_item_clicked)
self._nav_list.tracks_dropped_for_transfer.connect(self._on_tracks_dropped_for_nav_transfer)
self._nav_list.playlist_dropped_for_transfer.connect(self._on_playlist_dropped_for_transfer)
layout.addWidget(self._nav_list)
self._playlist_items: dict = {}
@ -261,6 +445,15 @@ class SidebarWidget(QWidget):
ids.append(item.data(Qt.ItemDataRole.UserRole))
return ids
def _on_tracks_dropped_on_playlist(self, playlist_id: str, paths: list):
self.tracks_dropped_on_playlist.emit(playlist_id, paths)
def _on_tracks_dropped_for_nav_transfer(self, paths: list):
self.tracks_dropped_for_nav_transfer.emit(paths)
def _on_playlist_dropped_for_transfer(self, playlist_id: str):
self.playlist_dropped_for_nav_transfer.emit(playlist_id)
def _on_playlists_reordered(self):
order = self.get_playlist_order_ids()
self.item_selected.emit("playlist_reorder", "|".join(order))

118
src/ui/splash_overlay.py Normal file
View File

@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Splash overlay shown while scanning the music library for the first time."""
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QWidget, QApplication
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QPainter, QPen, QColor, QFont
class _Spinner(QWidget):
"""A spinning arc — like a circular progress indicator."""
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(64, 64)
self._angle = 0.0
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
self._timer.start(30)
def _tick(self):
self._angle = (self._angle + 6) % 360
self.update()
def paintEvent(self, event):
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
r = self.rect().adjusted(4, 4, -4, -4)
p.setPen(QPen(QColor("#e0e0e0"), 3, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
p.drawEllipse(r)
p.setPen(QPen(QColor("#3b82f6"), 4, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
p.drawArc(r, int(self._angle * 16), 60 * 16)
class SplashOverlay(QDialog):
"""Modal overlay with a spinning animation + progress counter.
Usage::
splash = SplashOverlay(window)
splash.show()
# … scan …
splash.set_progress(current, total)
splash.close()
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowFlags(
Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog
)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
self.setModal(True)
self._card = QWidget(self)
self._card.setObjectName("card")
self._card.setFixedSize(340, 240)
layout = QVBoxLayout(self._card)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.setSpacing(16)
self._spinner = _Spinner()
layout.addWidget(self._spinner, alignment=Qt.AlignmentFlag.AlignCenter)
self._title = QLabel("Создаём музыкальную\nмедиатеку…")
self._title.setAlignment(Qt.AlignmentFlag.AlignCenter)
f = self._title.font()
f.setPointSize(16)
f.setBold(True)
self._title.setFont(f)
layout.addWidget(self._title)
self._count = QLabel("")
self._count.setAlignment(Qt.AlignmentFlag.AlignCenter)
f2 = self._count.font()
f2.setPointSize(12)
self._count.setFont(f2)
layout.addWidget(self._count)
self._card.setStyleSheet("""
#card {
background: white;
border-radius: 16px;
}
QLabel {
color: #333;
background: transparent;
}
""")
def _recenter(self):
if not self.parent():
return
pr = self.parent().rect()
self.setGeometry(pr)
cx = (pr.width() - self._card.width()) // 2
cy = (pr.height() - self._card.height()) // 2
self._card.move(cx, cy)
def showEvent(self, event):
self._recenter()
super().showEvent(event)
QApplication.processEvents()
def resizeEvent(self, event):
self._recenter()
super().resizeEvent(event)
def set_progress(self, current: int, total: int) -> None:
"""Update the file counter shown on the overlay."""
if total > 0:
self._count.setText(f"Файл {current} из {total}")
else:
self._count.setText(f"Файл {current}")
QApplication.processEvents()

107
src/ui/star_rating.py Normal file
View File

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
Clickable 5star rating widget.
Scale: 0 (unrated) 5 (). Stored internally as stars;
callers convert to/from the iPod 0100 scale as needed.
"""
import math
from PyQt6.QtCore import Qt, QRectF, pyqtSignal
from PyQt6.QtGui import QPainter, QColor, QPainterPath, QBrush, QPen
from PyQt6.QtWidgets import QWidget
class ClickableStarRating(QWidget):
"""Five clickable stars with hover preview and toggle-to-unrate."""
rating_changed = pyqtSignal(int)
STAR_COUNT = 5
STAR_SIZE = 22 # diameter of each star
PADDING = 4
FILLED_COLOR = QColor("#ffb347") # warm amber
EMPTY_COLOR = QColor("#555555")
HOVER_COLOR = QColor("#ffd700") # gold
def __init__(self, parent=None):
super().__init__(parent)
self._rating = 0
self._hover_idx = -1
w = self.STAR_COUNT * self.STAR_SIZE + (self.STAR_COUNT - 1) * self.PADDING
self.setFixedSize(w, self.STAR_SIZE + 8)
self.setMouseTracking(True)
# ── public API ────────────────────────────────────────────────
def set_rating(self, stars: int) -> None:
self._rating = max(0, min(self.STAR_COUNT, stars))
self.update()
def rating(self) -> int:
return self._rating
# ── painting ───────────────────────────────────────────────────
def paintEvent(self, event): # noqa: N802
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
hovering = self._hover_idx >= 0
for i in range(self.STAR_COUNT):
if hovering:
filled = i <= self._hover_idx
else:
filled = i < self._rating
color = (
self.HOVER_COLOR if hovering and filled
else self.FILLED_COLOR if filled
else self.EMPTY_COLOR
)
painter.setBrush(QBrush(color))
cx = self.STAR_SIZE // 2 + i * (self.STAR_SIZE + self.PADDING)
cy = self.height() // 2
self._draw_star(painter, cx, cy, self.STAR_SIZE // 2 - 1)
@staticmethod
def _draw_star(painter: QPainter, cx: int, cy: int, r: int) -> None:
"""Draw a five-pointed star centred at (cx, cy) with outer radius *r*."""
path = QPainterPath()
inner_r = r * 0.4
points = []
for i in range(10):
angle = math.pi / 2 + i * math.pi / 5
radius = r if i % 2 == 0 else inner_r
x = cx + radius * math.cos(angle)
y = cy - radius * math.sin(angle)
points.append((x, y))
path.moveTo(points[0][0], points[0][1])
for x, y in points[1:]:
path.lineTo(x, y)
path.closeSubpath()
painter.drawPath(path)
# ── mouse interaction ──────────────────────────────────────────
def _star_at(self, x: int) -> int:
idx = x // (self.STAR_SIZE + self.PADDING)
return min(max(idx, 0), self.STAR_COUNT - 1)
def mousePressEvent(self, event): # noqa: N802
star = self._star_at(int(event.position().x()))
new_val = star + 1 if self._rating != star + 1 else 0
self._rating = new_val
self.rating_changed.emit(new_val)
self.update()
def mouseMoveEvent(self, event): # noqa: N802
self._hover_idx = self._star_at(int(event.position().x()))
self.update()
def leaveEvent(self, event): # noqa: N802
self._hover_idx = -1
self.update()

View File

@ -24,11 +24,15 @@ SOURCE_EXTS = {'.flac', '.wav', '.ogg', '.opus', '.wma', '.ape', '.aiff', '.alac
class WorkerThread(QThread):
"""Worker thread for background tasks"""
_processed_fwids: set[str] = set()
_auto_synced_fwids: set[str] = set()
_pending_commit_fwids: set[str] = set()
progress_signal = pyqtSignal(int, str)
finished_signal = pyqtSignal(bool, str, object)
def __init__(self, task_type: str, **kwargs):
super().__init__()
def __init__(self, task_type: str, parent=None, **kwargs):
super().__init__(parent)
self.task_type = task_type
self.kwargs = kwargs
self.is_running = True
@ -44,6 +48,8 @@ class WorkerThread(QThread):
"delete_tracks": self._run_delete_tracks,
"remove_duplicates": self._run_remove_duplicates,
"sync_metadata": self._run_sync_metadata,
"create_playlist": self._run_create_playlist,
"auto_sync_playcounts": self._run_auto_sync_playcounts,
}
handler = handlers.get(self.task_type)
if handler:
@ -204,11 +210,14 @@ class WorkerThread(QThread):
self.progress_signal.emit(60, "Syncing play counts from iPod...")
from library_cache import get_library_cache
from library_cache import get_library_cache, save_library_cache
cache = get_library_cache()
ipod_base, ipod_delta = db.read_play_stats()
ipod_base, _ipod_delta = db.read_play_stats()
fwid = db.firewire_id.hex()
WorkerThread._processed_fwids.add(fwid)
# ── build content_hash → iPod location map ──────────────────────
hash_to_location: dict[str, str] = {}
for t in tracks:
ch = t.get("content_hash")
@ -221,37 +230,49 @@ class WorkerThread(QThread):
loc = f":iPod_Control:Music:{rel.replace(os.sep, ':')}"
hash_to_location[ch] = loc
# ── merge into local cache ──────────────────────────────────────
updated_cache = 0
for file_path, entry in list(cache._data.items()):
for file_path, entry in cache.iter_entries():
if not os.path.exists(file_path):
continue
ch = entry.get("_content_hash", "")
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(
new_play_count = max(
entry.get("play_count", 0), bs.get("play_count", 0),
) + dd.get("play_count", 0)
entry["skip_count"] = max(
)
new_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(
)
new_rating = bs.get("rating", 0) or entry.get("rating", 0)
new_last_played = max(
entry.get("last_played", 0), bs.get("last_played", 0),
)
cache.update_play_stats(
file_path,
play_count=new_play_count,
rating=new_rating,
last_played=new_last_played,
skip_count=new_skip_count,
)
updated_cache += 1
if updated_cache:
cache.save()
save_library_cache()
logger.info("Updated play counts for %d local tracks from iPod", updated_cache)
db.consume_play_deltas()
# ── deferred iTunesDB commit on stable USB ──────────────────
if fwid in WorkerThread._pending_commit_fwids and os.access(mount_point, os.W_OK):
if db.sync_itunescdb():
WorkerThread._pending_commit_fwids.discard(fwid)
logger.info("Deferred iTunesDB commit for %s complete", fwid)
else:
logger.debug("Deferred commit for %s failed (read-only filesystem)", fwid)
self.progress_signal.emit(80, "Loading complete")
result = {
@ -264,6 +285,105 @@ class WorkerThread(QThread):
True, f"Loaded {len(tracks)} tracks from iPod", result
)
def _run_auto_sync_playcounts(self):
mount_point = self.kwargs.get("mount_point")
if not mount_point:
self.finished_signal.emit(False, "No mount point for auto-sync", None)
return
ipod_ctrl = os.path.join(mount_point, "iPod_Control")
if not os.path.exists(ipod_ctrl):
self.finished_signal.emit(True, "Auto-sync skipped (device gone)", None)
return
from ipod_nano7_db import Nano7Database
db = Nano7Database(mount_point)
fwid = db.firewire_id.hex()
if fwid in WorkerThread._auto_synced_fwids:
self.finished_signal.emit(True, "Auto-sync already done", None)
return
ipod_base, ipod_delta = db.read_play_stats()
if not ipod_base and not ipod_delta:
import time
time.sleep(3)
ipod_base, ipod_delta = db.read_play_stats()
if not ipod_base and not ipod_delta:
ipod_delta = db.read_play_counts_delta()
if not ipod_delta:
self.finished_signal.emit(True, "Auto-sync: no delta", None)
return
from library_cache import get_library_cache, save_library_cache
cache = get_library_cache()
stored = cache.get_last_synced_delta(fwid)
effective: dict[str, dict[str, int]] = {}
for loc, dd in ipod_delta.items():
sd = stored.get(loc, {})
eff_pc = max(0, dd.get("play_count", 0) - sd.get("play_count", 0))
eff_sc = max(0, dd.get("skip_count", 0) - sd.get("skip_count", 0))
if eff_pc > 0 or eff_sc > 0:
effective[loc] = {"play_count": eff_pc, "skip_count": eff_sc}
dd_rating = dd.get("rating", 0)
if dd_rating > 0:
effective.setdefault(loc, {})["rating"] = dd_rating
cache.set_last_synced_delta(fwid, ipod_delta)
if effective:
tracks = db.get_all_tracks()
hash_to_loc: dict[str, str] = {}
loc_to_hash: dict[str, str] = {}
for t in tracks:
ch = t.get("content_hash", "")
fpath = t.get("file_path", "")
if ch and fpath:
rel = os.path.relpath(
fpath, os.path.join(db.mountpoint, "iPod_Control", "Music"),
)
loc = f":iPod_Control:Music:{rel.replace(os.sep, ':')}"
loc_to_hash[loc] = ch
hash_to_loc[ch] = loc
self.progress_signal.emit(40, "Merging play counts...")
updated = 0
for file_path, entry in cache.iter_entries():
ch = entry.get("content_hash", "")
if ch not in hash_to_loc:
continue
loc = hash_to_loc[ch]
eff = effective.get(loc)
if not eff:
continue
bs = ipod_base.get(loc, {})
new_pc = max(entry.get("play_count", 0), bs.get("play_count", 0)) + eff.get(
"play_count", 0,
)
new_sc = max(entry.get("skip_count", 0), bs.get("skip_count", 0)) + eff.get(
"skip_count", 0,
)
new_rt = eff.get("rating", 0) or bs.get("rating", 0) or entry.get("rating", 0)
new_lp = max(entry.get("last_played", 0), bs.get("last_played", 0))
cache.update_play_stats(
file_path,
play_count=new_pc,
rating=new_rt,
last_played=new_lp,
skip_count=new_sc,
)
updated += 1
if updated:
save_library_cache()
logger.info("Auto-sync: applied %d effective deltas", updated)
self.progress_signal.emit(100, "Auto-sync complete")
WorkerThread._pending_commit_fwids.add(fwid)
WorkerThread._auto_synced_fwids.add(fwid)
self.finished_signal.emit(True, "Auto-sync complete", mount_point)
def _run_export_ipod(self):
tracks = self.kwargs.get("tracks", [])
dest_dir = self.kwargs.get("dest_dir", "")
@ -361,6 +481,26 @@ class WorkerThread(QThread):
self.progress_signal.emit(100, msg)
self.finished_signal.emit(synced > 0, msg, {"synced": synced, "errors": errors})
def _run_create_playlist(self):
mount_point = self.kwargs.get("mount_point")
name = self.kwargs.get("playlist_name", "")
track_paths = self.kwargs.get("track_paths", [])
if not mount_point or not name:
self.finished_signal.emit(False, "Missing parameters for playlist creation", None)
return
from ipod_nano7_db import Nano7Database
db = Nano7Database(mount_point)
self.progress_signal.emit(0, f"Creating playlist '{name}' on iPod...")
success = db.create_playlist(name, track_paths)
if success:
self.finished_signal.emit(True, f"Playlist '{name}' created on iPod", None)
else:
self.finished_signal.emit(False, f"Failed to create playlist '{name}'", None)
def stop(self):
self.is_running = False
self.wait()

54
src/xdg_base.py Normal file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
XDG Base Directory helpers.
Follows the XDG Base Directory Specification:
~/.config/ configuration files
~/.local/share/ persistent user data
~/.cache/ temporary cache data
"""
import os
_DATA_DIR: str | None = None
_CONFIG_DIR: str | None = None
_CACHE_DIR: str | None = None
def xdg_config_path(subdir: str = "") -> str:
global _CONFIG_DIR
if _CONFIG_DIR is None:
base = os.environ.get(
"XDG_CONFIG_HOME",
os.path.join(os.path.expanduser("~"), ".config"),
)
_CONFIG_DIR = os.path.join(base, "neo-pod-desktop")
path = os.path.join(_CONFIG_DIR, subdir) if subdir else _CONFIG_DIR
os.makedirs(path, exist_ok=True)
return path
def xdg_data_path(subdir: str = "") -> str:
global _DATA_DIR
if _DATA_DIR is None:
base = os.environ.get(
"XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"),
)
_DATA_DIR = os.path.join(base, "neo-pod-desktop")
path = os.path.join(_DATA_DIR, subdir) if subdir else _DATA_DIR
os.makedirs(path, exist_ok=True)
return path
def xdg_cache_path(subdir: str = "") -> str:
global _CACHE_DIR
if _CACHE_DIR is None:
base = os.environ.get(
"XDG_CACHE_HOME",
os.path.join(os.path.expanduser("~"), ".cache"),
)
_CACHE_DIR = os.path.join(base, "neo-pod-desktop")
path = os.path.join(_CACHE_DIR, subdir) if subdir else _CACHE_DIR
os.makedirs(path, exist_ok=True)
return path