Compare commits
13 Commits
dd93440146
...
54cd87654f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54cd87654f | ||
|
|
d08125c7b3 | ||
|
|
ba7144c88f | ||
|
|
2d77b3ea3e | ||
|
|
597603f90e | ||
|
|
b7367e63c4 | ||
|
|
8861e55f32 | ||
|
|
aaaf354c9d | ||
|
|
a66446120c | ||
|
|
b68b86929b | ||
|
|
bfd146a4a0 | ||
|
|
309ba994a5 | ||
|
|
a36312c9d7 |
232
src/app.py
232
src/app.py
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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:
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (0‑5 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 != '':
|
||||
|
||||
@ -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
88
src/session_store.py
Normal 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
@ -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)
|
||||
|
||||
|
||||
@ -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 (0–5 stars) in ``self._original_tags['rating']``
|
||||
(only when cache has a value; file‑tag 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 (0–5 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."""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
118
src/ui/splash_overlay.py
Normal 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
107
src/ui/star_rating.py
Normal file
@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Clickable 5‑star rating widget.
|
||||
|
||||
Scale: 0 (unrated) … 5 (★★★★★). Stored internally as stars;
|
||||
callers convert to/from the iPod 0–100 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()
|
||||
176
src/worker.py
176
src/worker.py
@ -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
54
src/xdg_base.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user