Cache system added using sqlite. Should be nicer to the server.
This commit is contained in:
+179
-15
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -37,7 +38,8 @@ from PySide6.QtGui import (
|
||||
from src.accessibility.accessible_tree import AccessibleTreeWidget
|
||||
from src.api.client import SubsonicClient, SubsonicError
|
||||
from src.api.models import Album, Artist, Playlist, Song, Genre
|
||||
from src.config.settings import Settings
|
||||
from src.cache import CachedClient
|
||||
from src.config.settings import Settings, getDataDir
|
||||
from src.managers.playback_manager import PlaybackManager
|
||||
from src.integrations.mpris import MprisService
|
||||
from src.widgets.accessible_text_dialog import AccessibleTextDialog
|
||||
@@ -55,16 +57,19 @@ class MainWindow(QMainWindow):
|
||||
self.setMinimumSize(980, 640)
|
||||
|
||||
self.settings = Settings()
|
||||
self.client: Optional[SubsonicClient] = None
|
||||
self.client: Optional[CachedClient] = None
|
||||
self._rawClient: Optional[SubsonicClient] = None
|
||||
self.playback = PlaybackManager(self)
|
||||
self.mpris: Optional[MprisService] = None
|
||||
self._executor = ThreadPoolExecutor(max_workers=4)
|
||||
self._shutting_down = False
|
||||
self.logger = logging.getLogger("navipy.main_window")
|
||||
self.maxBulkSongs: Optional[int] = None # Unlimited; use throttling instead
|
||||
self.bulkRequestBatch = 5
|
||||
self.bulkThrottleSeconds = 0.1
|
||||
self.bulkRequestBatch = 1 # Throttle every N requests to be nice to servers
|
||||
self.bulkThrottleSeconds = 0.05
|
||||
self.bulkChunkSize = 500
|
||||
self.bulkThrottleStartAfter = 50 # Don't throttle first N requests for quick playback start
|
||||
self._startupSyncInterval = timedelta(minutes=60)
|
||||
self.logger.info("MainWindow initialized")
|
||||
|
||||
self.pageStep = self.settings.get("interface", "pageStep", 5)
|
||||
@@ -74,6 +79,11 @@ class MainWindow(QMainWindow):
|
||||
self.announceTrackChangesEnabled = self.settings.get("interface", "announceTrackChanges", True)
|
||||
self._announceOnTrackChange = False
|
||||
|
||||
# Timer for repeating status announcements during long operations
|
||||
self._statusAnnounceTimer = QTimer(self)
|
||||
self._statusAnnounceTimer.timeout.connect(self._announceCurrentStatus)
|
||||
self._currentStatusMessage = ""
|
||||
|
||||
self.setupMenus()
|
||||
self.setupUi()
|
||||
self.connectSignals()
|
||||
@@ -114,6 +124,12 @@ class MainWindow(QMainWindow):
|
||||
self.refreshAction.setEnabled(False)
|
||||
fileMenu.addAction(self.refreshAction)
|
||||
|
||||
self.fullSyncAction = QAction("Full Library &Sync", self)
|
||||
self.fullSyncAction.setShortcut(QKeySequence("Shift+F5"))
|
||||
self.fullSyncAction.triggered.connect(self.refreshLibraryFull)
|
||||
self.fullSyncAction.setEnabled(False)
|
||||
fileMenu.addAction(self.fullSyncAction)
|
||||
|
||||
fileMenu.addSeparator()
|
||||
|
||||
self.quitAction = QAction("&Quit", self)
|
||||
@@ -505,10 +521,18 @@ class MainWindow(QMainWindow):
|
||||
self.logger.info("Ping succeeded for %s", server.get("url"))
|
||||
return client
|
||||
|
||||
def _on_connection_success(self, serverName: str, client: SubsonicClient):
|
||||
def _on_connection_success(self, serverName: str, rawClient: SubsonicClient):
|
||||
"""Handle successful connection on the UI thread."""
|
||||
self.logger.info("Connected to %s, starting connected flow", serverName)
|
||||
self.client = client
|
||||
self._rawClient = rawClient
|
||||
|
||||
# Wrap with caching layer
|
||||
self.client = CachedClient(
|
||||
rawClient,
|
||||
getDataDir(),
|
||||
onSyncProgress=self._onSyncProgress
|
||||
)
|
||||
|
||||
self.connectionStatus.setText(f"Connected to {serverName}")
|
||||
self.playback.setStreamResolver(lambda song: self.client.getStreamUrl(song.id))
|
||||
self.onConnected()
|
||||
@@ -522,30 +546,87 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def onConnected(self):
|
||||
"""Called when successfully connected to a server"""
|
||||
self.logger.info("onConnected: enabling actions and refreshing library")
|
||||
self.logger.info("onConnected: enabling actions and loading library")
|
||||
self.refreshAction.setEnabled(True)
|
||||
self.fullSyncAction.setEnabled(True)
|
||||
self.searchAction.setEnabled(True)
|
||||
self.libraryTree.setEnabled(True)
|
||||
self.libraryStatus.setText("Loading library...")
|
||||
self.refreshLibrary()
|
||||
|
||||
# Load from cache first if available, then sync in background
|
||||
if self.client and self.client.hasCache():
|
||||
self.logger.info("Cache found, loading from cache and syncing in background")
|
||||
self.libraryStatus.setText("Loading from cache...")
|
||||
self._startStatusAnnouncements("Loading library from cache")
|
||||
self._run_background(
|
||||
"load from cache",
|
||||
self._fetchLibraryData,
|
||||
on_success=self._applyLibraryDataAndSync,
|
||||
on_error=lambda err: self._handleLibraryLoadError(str(err))
|
||||
)
|
||||
else:
|
||||
self.logger.info("No cache found, performing full sync")
|
||||
self.libraryStatus.setText("Building library cache...")
|
||||
self._startStatusAnnouncements("Building library cache, please wait")
|
||||
self.refreshLibraryFull()
|
||||
|
||||
# ============ Library browsing ============
|
||||
|
||||
def refreshLibrary(self):
|
||||
"""Refresh the library browser"""
|
||||
"""Refresh the library browser with incremental sync (F5)."""
|
||||
if not self.client:
|
||||
return
|
||||
|
||||
# Clear cache to force fresh data from server
|
||||
self.client.clearCache()
|
||||
self.refreshAction.setEnabled(False)
|
||||
self.fullSyncAction.setEnabled(False)
|
||||
self.searchAction.setEnabled(False)
|
||||
self.libraryStatus.setText("Syncing library changes...")
|
||||
self._startStatusAnnouncements("Updating library cache")
|
||||
self.logger.info("Refreshing library (incremental sync)...")
|
||||
|
||||
self._run_background(
|
||||
"incremental sync",
|
||||
self._incrementalSync,
|
||||
on_success=self._onSyncComplete,
|
||||
on_error=lambda err: self._handleLibraryLoadError(str(err))
|
||||
)
|
||||
|
||||
def refreshLibraryFull(self):
|
||||
"""Perform a full library sync (Shift+F5)."""
|
||||
if not self.client:
|
||||
return
|
||||
|
||||
self.refreshAction.setEnabled(False)
|
||||
self.fullSyncAction.setEnabled(False)
|
||||
self.searchAction.setEnabled(False)
|
||||
self.libraryTree.clear()
|
||||
self.libraryTree.setEnabled(False)
|
||||
self.libraryStatus.setText("Loading artists, playlists, and genres...")
|
||||
self.logger.info("Refreshing library...")
|
||||
self.libraryStatus.setText("Full sync in progress...")
|
||||
self._startStatusAnnouncements("Full library sync in progress")
|
||||
self.logger.info("Refreshing library (full sync)...")
|
||||
|
||||
self._run_background(
|
||||
"full sync",
|
||||
self._fullSync,
|
||||
on_success=self._onSyncComplete,
|
||||
on_error=lambda err: self._handleLibraryLoadError(str(err))
|
||||
)
|
||||
|
||||
def _incrementalSync(self) -> Dict[str, int]:
|
||||
"""Perform incremental sync in background."""
|
||||
if not self.client:
|
||||
raise SubsonicError(70, "Not connected")
|
||||
return self.client.syncIncremental()
|
||||
|
||||
def _fullSync(self) -> Dict[str, int]:
|
||||
"""Perform full sync in background."""
|
||||
if not self.client:
|
||||
raise SubsonicError(70, "Not connected")
|
||||
return self.client.syncFull()
|
||||
|
||||
def _onSyncComplete(self, stats: Dict[str, int]):
|
||||
"""Handle sync completion - reload library from cache."""
|
||||
self.logger.info("Sync complete: %s", stats)
|
||||
# Now load the UI from the updated cache
|
||||
self._run_background(
|
||||
"load library",
|
||||
self._fetchLibraryData,
|
||||
@@ -553,6 +634,74 @@ class MainWindow(QMainWindow):
|
||||
on_error=lambda err: self._handleLibraryLoadError(str(err))
|
||||
)
|
||||
|
||||
def _applyLibraryDataAndSync(self, data: Dict[str, Any]):
|
||||
"""Apply library data from cache, then start background sync."""
|
||||
self.logger.info("Applying library data to UI...")
|
||||
self._applyLibraryData(data)
|
||||
if self._shouldRunStartupIncremental():
|
||||
self.logger.info("Library UI populated, starting background sync")
|
||||
self.libraryStatus.setText("Syncing...")
|
||||
self._run_background(
|
||||
"background sync",
|
||||
self._incrementalSync,
|
||||
on_success=self._onBackgroundSyncComplete,
|
||||
on_error=lambda err: self.logger.warning("Background sync failed: %s", err)
|
||||
)
|
||||
else:
|
||||
self.logger.info("Skipping startup incremental sync (recently synced)")
|
||||
self.libraryStatus.setText("Library ready (recently synced)")
|
||||
self.announceLive("Library ready")
|
||||
|
||||
def _onBackgroundSyncComplete(self, stats: Dict[str, int]):
|
||||
"""Handle background sync completion - refresh UI if changes detected."""
|
||||
self.logger.info("Background sync complete: %s", stats)
|
||||
totalChanges = sum(stats.values())
|
||||
if totalChanges > 0:
|
||||
self.announceLive(f"Library synced: {totalChanges} items updated")
|
||||
self.libraryStatus.setText("Library ready")
|
||||
|
||||
def _shouldRunStartupIncremental(self) -> bool:
|
||||
"""Decide whether to run incremental sync at startup based on recent checks."""
|
||||
if not self.client:
|
||||
return False
|
||||
last = self.client.getLastSyncTime("incremental")
|
||||
if not last:
|
||||
return True
|
||||
try:
|
||||
return datetime.now() - last > self._startupSyncInterval
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
def _onSyncProgress(self, stage: str, current: int, total: int):
|
||||
"""Handle sync progress updates on UI thread."""
|
||||
if total > 0:
|
||||
message = f"Syncing {stage}: {current}/{total}"
|
||||
else:
|
||||
message = f"Syncing {stage}..."
|
||||
self.logger.info(message)
|
||||
# Update the repeating announcement message
|
||||
self._currentStatusMessage = message
|
||||
# Capture message by value to avoid closure issues
|
||||
self._on_ui(lambda msg=message: self.libraryStatus.setText(msg))
|
||||
|
||||
def _startStatusAnnouncements(self, message: str):
|
||||
"""Start repeating status announcements every 10 seconds."""
|
||||
self._currentStatusMessage = message
|
||||
self.announceLive(message)
|
||||
self._statusAnnounceTimer.start(10000)
|
||||
|
||||
def _stopStatusAnnouncements(self):
|
||||
"""Stop repeating status announcements."""
|
||||
self._statusAnnounceTimer.stop()
|
||||
self._currentStatusMessage = ""
|
||||
|
||||
def _announceCurrentStatus(self):
|
||||
"""Called by timer to repeat the current status message."""
|
||||
if self._currentStatusMessage:
|
||||
# Clear the live region first so screen readers re-announce identical content
|
||||
self.announceLive("")
|
||||
self.announceLive(self._currentStatusMessage)
|
||||
|
||||
def _fetchLibraryData(self) -> Dict[str, Any]:
|
||||
"""Fetch core library sections in the background."""
|
||||
if not self.client:
|
||||
@@ -621,7 +770,10 @@ class MainWindow(QMainWindow):
|
||||
self.libraryTree.setCurrentItem(self.artistsRoot)
|
||||
self.libraryTree.setFocus(Qt.OtherFocusReason)
|
||||
self.refreshAction.setEnabled(True)
|
||||
self.fullSyncAction.setEnabled(True)
|
||||
self.searchAction.setEnabled(True)
|
||||
self._stopStatusAnnouncements()
|
||||
self.announceLive("Library ready")
|
||||
self.logger.info("Library ready with sections: artists=%d playlists=%d genres=%d",
|
||||
self.artistsRoot.childCount(),
|
||||
self.playlistsRoot.childCount(),
|
||||
@@ -632,9 +784,11 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _handleLibraryLoadError(self, message: str):
|
||||
"""Show error when library fails to load."""
|
||||
self._stopStatusAnnouncements()
|
||||
AccessibleTextDialog.showError("Error", f"Failed to load library: {message}", parent=self)
|
||||
self.libraryStatus.setText("Library failed to load.")
|
||||
self.refreshAction.setEnabled(True)
|
||||
self.fullSyncAction.setEnabled(True)
|
||||
self.searchAction.setEnabled(bool(self.client))
|
||||
self.libraryTree.setEnabled(True)
|
||||
self.logger.error("Library load failed: %s", message)
|
||||
@@ -1840,9 +1994,15 @@ class MainWindow(QMainWindow):
|
||||
QTimer.singleShot(0, self, wrapped)
|
||||
|
||||
def _throttle_requests(self, count: int):
|
||||
"""Sleep briefly every N requests to be gentle on the server."""
|
||||
"""Sleep briefly every N requests to be gentle on the server.
|
||||
|
||||
Skips throttling for the first bulkThrottleStartAfter requests so
|
||||
playback can start quickly, then throttles subsequent requests.
|
||||
"""
|
||||
if self.bulkRequestBatch <= 0 or self.bulkThrottleSeconds <= 0:
|
||||
return
|
||||
if count <= self.bulkThrottleStartAfter:
|
||||
return
|
||||
if count % self.bulkRequestBatch == 0:
|
||||
time.sleep(self.bulkThrottleSeconds)
|
||||
|
||||
@@ -2080,6 +2240,10 @@ class MainWindow(QMainWindow):
|
||||
# Signal background tasks to stop
|
||||
self._shutting_down = True
|
||||
|
||||
# Signal cached client to stop any running sync
|
||||
if self.client:
|
||||
self.client.shutdown()
|
||||
|
||||
self.settings.set("playback", "volume", self.volume)
|
||||
self.settings.save()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user