Cache system added using sqlite. Should be nicer to the server.

This commit is contained in:
Storm Dragon
2025-12-16 22:47:43 -05:00
parent 0a952afb21
commit 221224270b
10 changed files with 1950 additions and 19 deletions
+179 -15
View File
@@ -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()