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
+3 -3
View File
@@ -11,7 +11,7 @@
- `python3 -m venv .venv && source .venv/bin/activate` — set up an isolated environment.
- `pip install -r requirements.txt` — install PySide6 and other runtime deps.
- `python3 navipy.py` — launch the app with logging to the data dir; exit cleanly with Ctrl+C.
- `pytest`placeholder for future tests; add a `tests/` folder with `test_*.py` as you grow coverage.
- `pytest -q`run the automated test suite; run this after major code changes.
## Coding Style & Naming Conventions
- Follow PEP 8: 4-space indentation, snake_case modules/functions, and descriptive class names for Qt widgets/actions.
@@ -19,8 +19,8 @@
- Prefer type hints for public APIs in `api/` and configuration helpers; keep JSON keys predictable and lowercase.
## Testing Guidelines
- No automated suite exists yet; add `pytest` cases for API client authentication, settings persistence (XDG paths), and any audio/control logic.
- Name files `tests/test_<feature>.py`; use fixtures to stub Navidrome endpoints instead of hitting real servers.
- Automated `pytest` suite lives under `tests/`; keep new tests named `test_<feature>.py` and prefer fixtures/fakes over live Navidrome.
- Run `pytest -q` after major code changes; set `QT_QPA_PLATFORM=offscreen` if adding Qt-dependent tests.
- Manual checks still matter: verify keyboard-only navigation, high-DPI rendering, and that logs/config are written under the XDG directories.
## Commit & Pull Request Guidelines
+12
View File
@@ -0,0 +1,12 @@
"""
SQLite caching layer for NaviPy.
Provides persistent storage for library data to enable fast startup
and reduce server load.
"""
from src.cache.database import CacheDatabase
from src.cache.cache_manager import CacheManager
from src.cache.cached_client import CachedClient
__all__ = ["CacheDatabase", "CacheManager", "CachedClient"]
+575
View File
@@ -0,0 +1,575 @@
"""
Cache manager providing CRUD operations for all cached entities.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Dict, List, Optional
from src.api.models import Album, Artist, Genre, Playlist, Song
from src.cache.database import CacheDatabase
class CacheManager:
"""
Manages CRUD operations for all cached entities.
Provides methods to get/set artists, albums, songs, playlists, and genres
with batch operations for performance.
"""
def __init__(self, db: CacheDatabase):
"""
Initialize the cache manager.
Args:
db: The CacheDatabase instance to use for storage
"""
self.db = db
self.logger = logging.getLogger("navipy.cache.manager")
@staticmethod
def _datetimeToStr(dt: Optional[datetime]) -> Optional[str]:
"""Convert datetime to ISO format string for storage."""
return dt.isoformat() if dt else None
@staticmethod
def _strToDatetime(s: Optional[str]) -> Optional[datetime]:
"""Convert ISO format string back to datetime."""
if not s:
return None
try:
return datetime.fromisoformat(s.replace('Z', '+00:00'))
except (ValueError, TypeError):
return None
@staticmethod
def _nowIso() -> str:
"""Get current time as ISO string."""
return datetime.now().isoformat()
# ==================== Artists ====================
def getArtists(self) -> List[Artist]:
"""Get all cached artists."""
cursor = self.db.connection.execute(
"SELECT * FROM artists ORDER BY name COLLATE NOCASE"
)
return [self._rowToArtist(row) for row in cursor.fetchall()]
def getArtist(self, artistId: str) -> Optional[Artist]:
"""Get a single artist by ID."""
cursor = self.db.connection.execute(
"SELECT * FROM artists WHERE id = ?", (artistId,)
)
row = cursor.fetchone()
return self._rowToArtist(row) if row else None
def setArtists(self, artists: List[Artist]) -> None:
"""Insert or update multiple artists."""
if not artists:
return
now = self._nowIso()
conn = self.db.connection
conn.executemany(
"""INSERT OR REPLACE INTO artists
(id, name, album_count, starred, cover_art, artist_image_url, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
[
(
a.id, a.name, a.albumCount,
self._datetimeToStr(a.starred),
a.coverArt, a.artistImageUrl, now
)
for a in artists
]
)
conn.commit()
def setArtist(self, artist: Artist) -> None:
"""Insert or update a single artist."""
self.setArtists([artist])
def deleteArtist(self, artistId: str) -> None:
"""Delete an artist by ID."""
conn = self.db.connection
conn.execute("DELETE FROM artists WHERE id = ?", (artistId,))
conn.commit()
def deleteArtistsNotIn(self, keepIds: List[str]) -> int:
"""Delete artists not in the given ID list. Returns count deleted."""
if not keepIds:
return 0
conn = self.db.connection
placeholders = ",".join("?" * len(keepIds))
cursor = conn.execute(
f"DELETE FROM artists WHERE id NOT IN ({placeholders})",
keepIds
)
conn.commit()
return cursor.rowcount
def _rowToArtist(self, row) -> Artist:
"""Convert a database row to an Artist object."""
return Artist(
id=row["id"],
name=row["name"],
albumCount=row["album_count"],
starred=self._strToDatetime(row["starred"]),
coverArt=row["cover_art"],
artistImageUrl=row["artist_image_url"]
)
# ==================== Albums ====================
def getAlbums(self, artistId: Optional[str] = None) -> List[Album]:
"""Get cached albums, optionally filtered by artist."""
if artistId:
cursor = self.db.connection.execute(
"SELECT * FROM albums WHERE artist_id = ? ORDER BY year DESC, name COLLATE NOCASE",
(artistId,)
)
else:
cursor = self.db.connection.execute(
"SELECT * FROM albums ORDER BY name COLLATE NOCASE"
)
return [self._rowToAlbum(row) for row in cursor.fetchall()]
def getAlbum(self, albumId: str) -> Optional[Album]:
"""Get a single album by ID."""
cursor = self.db.connection.execute(
"SELECT * FROM albums WHERE id = ?", (albumId,)
)
row = cursor.fetchone()
return self._rowToAlbum(row) if row else None
def setAlbums(self, albums: List[Album]) -> None:
"""Insert or update multiple albums."""
if not albums:
return
now = self._nowIso()
conn = self.db.connection
conn.executemany(
"""INSERT OR REPLACE INTO albums
(id, name, artist, artist_id, song_count, duration, year, genre,
cover_art, starred, play_count, user_rating, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
(
a.id, a.name, a.artist, a.artistId, a.songCount, a.duration,
a.year, a.genre, a.coverArt, self._datetimeToStr(a.starred),
a.playCount, a.userRating, now
)
for a in albums
]
)
conn.commit()
def setAlbum(self, album: Album) -> None:
"""Insert or update a single album."""
self.setAlbums([album])
def deleteAlbum(self, albumId: str) -> None:
"""Delete an album and its songs."""
conn = self.db.connection
conn.execute("DELETE FROM songs WHERE album_id = ?", (albumId,))
conn.execute("DELETE FROM albums WHERE id = ?", (albumId,))
conn.commit()
def deleteAlbumsNotIn(self, keepIds: List[str]) -> int:
"""Delete albums not in the given ID list. Returns count deleted."""
if not keepIds:
return 0
conn = self.db.connection
placeholders = ",".join("?" * len(keepIds))
cursor = conn.execute(
f"DELETE FROM albums WHERE id NOT IN ({placeholders})",
keepIds
)
conn.commit()
return cursor.rowcount
def _rowToAlbum(self, row) -> Album:
"""Convert a database row to an Album object."""
return Album(
id=row["id"],
name=row["name"],
artist=row["artist"],
artistId=row["artist_id"],
songCount=row["song_count"],
duration=row["duration"],
year=row["year"],
genre=row["genre"],
coverArt=row["cover_art"],
starred=self._strToDatetime(row["starred"]),
playCount=row["play_count"],
userRating=row["user_rating"]
)
# ==================== Songs ====================
def getSongs(self, albumId: Optional[str] = None) -> List[Song]:
"""Get cached songs, optionally filtered by album."""
if albumId:
cursor = self.db.connection.execute(
"""SELECT * FROM songs WHERE album_id = ?
ORDER BY disc_number, track, title COLLATE NOCASE""",
(albumId,)
)
else:
cursor = self.db.connection.execute(
"SELECT * FROM songs ORDER BY title COLLATE NOCASE"
)
return [self._rowToSong(row) for row in cursor.fetchall()]
def getSong(self, songId: str) -> Optional[Song]:
"""Get a single song by ID."""
cursor = self.db.connection.execute(
"SELECT * FROM songs WHERE id = ?", (songId,)
)
row = cursor.fetchone()
return self._rowToSong(row) if row else None
def getSongsByIds(self, songIds: List[str]) -> List[Song]:
"""Get multiple songs by their IDs, preserving order."""
if not songIds:
return []
placeholders = ",".join("?" * len(songIds))
cursor = self.db.connection.execute(
f"SELECT * FROM songs WHERE id IN ({placeholders})",
songIds
)
# Build a dict for order-preserving lookup
songMap = {row["id"]: self._rowToSong(row) for row in cursor.fetchall()}
return [songMap[sid] for sid in songIds if sid in songMap]
def getStarredSongs(self) -> List[Song]:
"""Get all starred (favorited) songs."""
cursor = self.db.connection.execute(
"SELECT * FROM songs WHERE starred IS NOT NULL ORDER BY title COLLATE NOCASE"
)
return [self._rowToSong(row) for row in cursor.fetchall()]
def getStarredAlbums(self) -> List[Album]:
"""Get all starred (favorited) albums."""
cursor = self.db.connection.execute(
"SELECT * FROM albums WHERE starred IS NOT NULL ORDER BY name COLLATE NOCASE"
)
return [self._rowToAlbum(row) for row in cursor.fetchall()]
def getStarredArtists(self) -> List[Artist]:
"""Get all starred (favorited) artists."""
cursor = self.db.connection.execute(
"SELECT * FROM artists WHERE starred IS NOT NULL ORDER BY name COLLATE NOCASE"
)
return [self._rowToArtist(row) for row in cursor.fetchall()]
def getSongsByGenre(self, genre: str, limit: int = 500) -> List[Song]:
"""Get songs by genre."""
cursor = self.db.connection.execute(
"SELECT * FROM songs WHERE genre = ? ORDER BY title COLLATE NOCASE LIMIT ?",
(genre, limit)
)
return [self._rowToSong(row) for row in cursor.fetchall()]
def setSongs(self, songs: List[Song]) -> None:
"""Insert or update multiple songs."""
if not songs:
return
now = self._nowIso()
conn = self.db.connection
conn.executemany(
"""INSERT OR REPLACE INTO songs
(id, title, album, album_id, artist, artist_id, duration, track,
disc_number, year, genre, bit_rate, content_type, suffix, path,
cover_art, starred, user_rating, play_count, bookmark_position, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
(
s.id, s.title, s.album, s.albumId, s.artist, s.artistId,
s.duration, s.track, s.discNumber, s.year, s.genre,
s.bitRate, s.contentType, s.suffix, s.path, s.coverArt,
self._datetimeToStr(s.starred), s.userRating, s.playCount,
s.bookmarkPosition, now
)
for s in songs
]
)
conn.commit()
def setSong(self, song: Song) -> None:
"""Insert or update a single song."""
self.setSongs([song])
def deleteSong(self, songId: str) -> None:
"""Delete a song by ID."""
conn = self.db.connection
conn.execute("DELETE FROM songs WHERE id = ?", (songId,))
conn.commit()
def deleteSongsForAlbum(self, albumId: str) -> None:
"""Delete all songs for an album."""
conn = self.db.connection
conn.execute("DELETE FROM songs WHERE album_id = ?", (albumId,))
conn.commit()
def _rowToSong(self, row) -> Song:
"""Convert a database row to a Song object."""
return Song(
id=row["id"],
title=row["title"],
album=row["album"],
albumId=row["album_id"],
artist=row["artist"],
artistId=row["artist_id"],
duration=row["duration"],
track=row["track"],
discNumber=row["disc_number"],
year=row["year"],
genre=row["genre"],
bitRate=row["bit_rate"],
contentType=row["content_type"],
suffix=row["suffix"],
path=row["path"],
coverArt=row["cover_art"],
starred=self._strToDatetime(row["starred"]),
userRating=row["user_rating"],
playCount=row["play_count"],
bookmarkPosition=row["bookmark_position"]
)
# ==================== Playlists ====================
def getPlaylists(self) -> List[Playlist]:
"""Get all cached playlists (without songs)."""
cursor = self.db.connection.execute(
"SELECT * FROM playlists ORDER BY name COLLATE NOCASE"
)
return [self._rowToPlaylist(row) for row in cursor.fetchall()]
def getPlaylist(self, playlistId: str) -> Optional[Playlist]:
"""Get a single playlist with its songs."""
cursor = self.db.connection.execute(
"SELECT * FROM playlists WHERE id = ?", (playlistId,)
)
row = cursor.fetchone()
if not row:
return None
playlist = self._rowToPlaylist(row)
playlist.songs = self.getPlaylistSongs(playlistId)
return playlist
def getPlaylistSongs(self, playlistId: str) -> List[Song]:
"""Get songs for a playlist in order."""
cursor = self.db.connection.execute(
"""SELECT s.* FROM songs s
JOIN playlist_songs ps ON s.id = ps.song_id
WHERE ps.playlist_id = ?
ORDER BY ps.position""",
(playlistId,)
)
return [self._rowToSong(row) for row in cursor.fetchall()]
def setPlaylists(self, playlists: List[Playlist]) -> None:
"""Insert or update multiple playlists (without songs)."""
if not playlists:
return
now = self._nowIso()
conn = self.db.connection
conn.executemany(
"""INSERT OR REPLACE INTO playlists
(id, name, song_count, duration, public, owner, created, changed,
cover_art, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
(
p.id, p.name, p.songCount, p.duration, int(p.public),
p.owner, self._datetimeToStr(p.created),
self._datetimeToStr(p.changed), p.coverArt, now
)
for p in playlists
]
)
conn.commit()
def setPlaylist(self, playlist: Playlist) -> None:
"""Insert or update a playlist with its songs."""
self.setPlaylists([playlist])
if playlist.songs:
self.setPlaylistSongs(playlist.id, playlist.songs)
def setPlaylistSongs(self, playlistId: str, songs: List[Song]) -> None:
"""Set the songs for a playlist (replaces existing)."""
conn = self.db.connection
# First ensure songs exist in the songs table
self.setSongs(songs)
# Clear existing playlist songs
conn.execute("DELETE FROM playlist_songs WHERE playlist_id = ?", (playlistId,))
# Insert new playlist songs with positions
conn.executemany(
"INSERT INTO playlist_songs (playlist_id, song_id, position) VALUES (?, ?, ?)",
[(playlistId, song.id, i) for i, song in enumerate(songs)]
)
conn.commit()
def deletePlaylist(self, playlistId: str) -> None:
"""Delete a playlist and its song associations."""
conn = self.db.connection
conn.execute("DELETE FROM playlist_songs WHERE playlist_id = ?", (playlistId,))
conn.execute("DELETE FROM playlists WHERE id = ?", (playlistId,))
conn.commit()
def deletePlaylistsNotIn(self, keepIds: List[str]) -> int:
"""Delete playlists not in the given ID list. Returns count deleted."""
if not keepIds:
return 0
conn = self.db.connection
placeholders = ",".join("?" * len(keepIds))
cursor = conn.execute(
f"DELETE FROM playlists WHERE id NOT IN ({placeholders})",
keepIds
)
conn.commit()
return cursor.rowcount
def _rowToPlaylist(self, row) -> Playlist:
"""Convert a database row to a Playlist object (without songs)."""
return Playlist(
id=row["id"],
name=row["name"],
songCount=row["song_count"],
duration=row["duration"],
public=bool(row["public"]),
owner=row["owner"],
created=self._strToDatetime(row["created"]),
changed=self._strToDatetime(row["changed"]),
coverArt=row["cover_art"],
songs=[]
)
# ==================== Genres ====================
def getGenres(self) -> List[Genre]:
"""Get all cached genres."""
cursor = self.db.connection.execute(
"SELECT * FROM genres ORDER BY name COLLATE NOCASE"
)
return [self._rowToGenre(row) for row in cursor.fetchall()]
def getGenre(self, name: str) -> Optional[Genre]:
"""Get a single genre by name."""
cursor = self.db.connection.execute(
"SELECT * FROM genres WHERE name = ?", (name,)
)
row = cursor.fetchone()
return self._rowToGenre(row) if row else None
def setGenres(self, genres: List[Genre]) -> None:
"""Insert or update multiple genres."""
if not genres:
return
now = self._nowIso()
conn = self.db.connection
conn.executemany(
"""INSERT OR REPLACE INTO genres
(name, song_count, album_count, last_updated)
VALUES (?, ?, ?, ?)""",
[(g.name, g.songCount, g.albumCount, now) for g in genres]
)
conn.commit()
def setGenre(self, genre: Genre) -> None:
"""Insert or update a single genre."""
self.setGenres([genre])
def deleteGenresNotIn(self, keepNames: List[str]) -> int:
"""Delete genres not in the given name list. Returns count deleted."""
if not keepNames:
return 0
conn = self.db.connection
placeholders = ",".join("?" * len(keepNames))
cursor = conn.execute(
f"DELETE FROM genres WHERE name NOT IN ({placeholders})",
keepNames
)
conn.commit()
return cursor.rowcount
def _rowToGenre(self, row) -> Genre:
"""Convert a database row to a Genre object."""
return Genre(
name=row["name"],
songCount=row["song_count"],
albumCount=row["album_count"]
)
# ==================== Sync State ====================
def getSyncState(self, entityType: str) -> Optional[Dict]:
"""Get sync state for an entity type."""
cursor = self.db.connection.execute(
"SELECT * FROM sync_state WHERE entity_type = ?", (entityType,)
)
row = cursor.fetchone()
if not row:
return None
return {
"entityType": row["entity_type"],
"lastSync": self._strToDatetime(row["last_sync"]),
"itemCount": row["item_count"]
}
def setSyncState(self, entityType: str, itemCount: int) -> None:
"""Update sync state for an entity type."""
now = self._nowIso()
self.db.connection.execute(
"""INSERT OR REPLACE INTO sync_state
(entity_type, last_sync, item_count)
VALUES (?, ?, ?)""",
(entityType, now, itemCount)
)
self.db.connection.commit()
def getLastSyncTime(self, entityType: str) -> Optional[datetime]:
"""Get the last sync time for an entity type."""
state = self.getSyncState(entityType)
return state["lastSync"] if state else None
def hasCache(self) -> bool:
"""Check if any data is cached."""
cursor = self.db.connection.execute(
"SELECT COUNT(*) as count FROM artists"
)
row = cursor.fetchone()
return row["count"] > 0 if row else False
# ==================== Starred/Favorites ====================
def getStarred(self) -> Dict[str, list]:
"""Get all starred items."""
return {
"artists": self.getStarredArtists(),
"albums": self.getStarredAlbums(),
"songs": self.getStarredSongs()
}
def updateStarred(self, itemId: str, itemType: str, starred: bool) -> None:
"""Update the starred status of an item."""
table = {
"song": "songs",
"album": "albums",
"artist": "artists"
}.get(itemType)
if not table:
return
starredValue = self._nowIso() if starred else None
self.db.connection.execute(
f"UPDATE {table} SET starred = ? WHERE id = ?",
(starredValue, itemId)
)
self.db.connection.commit()
+554
View File
@@ -0,0 +1,554 @@
"""
Cached client wrapper that adds SQLite persistence to SubsonicClient.
"""
from __future__ import annotations
import logging
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from src.api.client import SubsonicClient
from src.api.models import Album, Artist, Genre, Playlist, Song
from src.cache.cache_manager import CacheManager
from src.cache.database import CacheDatabase
class CachedClient:
"""
Wraps SubsonicClient with SQLite caching for persistent storage.
Provides the same interface as SubsonicClient but reads from cache
when available and updates cache on fetches. Also provides sync
methods for incremental and full synchronization.
"""
def __init__(
self,
client: SubsonicClient,
dataDir: Path,
onSyncProgress: Optional[Callable[[str, int, int], None]] = None
):
"""
Initialize the cached client.
Args:
client: The underlying SubsonicClient to wrap
dataDir: Path to store the SQLite cache database
onSyncProgress: Optional callback for sync progress (stage, current, total)
"""
self.client = client
self.logger = logging.getLogger("navipy.cache.client")
self.onSyncProgress = onSyncProgress
# Throttling settings (match main_window.py settings)
self.throttleStartAfter = 50
self.throttleSeconds = 0.05
self._requestCount = 0
self._shutdownRequested = False
# Initialize database and cache manager
self.db = CacheDatabase(client.serverUrl, dataDir)
self.cache = CacheManager(self.db)
def shutdown(self) -> None:
"""Signal that shutdown is requested to stop long-running syncs."""
self._shutdownRequested = True
def _resetThrottle(self) -> None:
"""Reset the request counter for a new sync operation."""
self._requestCount = 0
def _throttle(self) -> None:
"""Apply throttling to be nice to the server."""
self._requestCount += 1
if self._requestCount > self.throttleStartAfter:
time.sleep(self.throttleSeconds)
def _emitProgress(self, stage: str, current: int, total: int) -> None:
"""Emit sync progress if callback is set."""
if self.onSyncProgress:
try:
self.onSyncProgress(stage, current, total)
except Exception:
pass # Don't let callback errors break sync
# ==================== Passthrough Methods ====================
# These methods don't need caching - they're dynamic or write operations
def ping(self) -> bool:
"""Test connection to the server."""
return self.client.ping()
def getStreamUrl(self, songId: str, format: str = None, maxBitRate: int = None) -> str:
"""Generate streaming URL for a song."""
return self.client.getStreamUrl(songId, format, maxBitRate)
def getCoverArtUrl(self, coverId: str, size: int = None) -> str:
"""Generate cover art URL."""
return self.client.getCoverArtUrl(coverId, size)
def getRandomSongs(self, size: int = 10, genre: str = None,
fromYear: int = None, toYear: int = None) -> List[Song]:
"""Get random songs (not cached - always fresh)."""
return self.client.getRandomSongs(size, genre, fromYear, toYear)
def createPlaylist(self, name: str, songIds: List[str] = None) -> Playlist:
"""Create a new playlist."""
result = self.client.createPlaylist(name, songIds)
# Persist new playlist so cached lists reflect it immediately
if result:
try:
self.cache.setPlaylist(result)
except Exception as exc:
self.logger.warning("Failed to cache new playlist %s: %s", getattr(result, "id", ""), exc)
return result
def updatePlaylist(self, playlistId: str, name: str = None,
songIdsToAdd: List[str] = None,
songIndexesToRemove: List[int] = None) -> None:
"""Update a playlist."""
self.client.updatePlaylist(playlistId, name, songIdsToAdd, songIndexesToRemove)
# Refresh playlist entry so metadata stays current
try:
updated = self.client.getPlaylist(playlistId)
self.cache.setPlaylist(updated)
except Exception:
# Fall back to invalidation if fetch fails
self.cache.deletePlaylist(playlistId)
def deletePlaylist(self, playlistId: str) -> None:
"""Delete a playlist."""
self.client.deletePlaylist(playlistId)
self.cache.deletePlaylist(playlistId)
def star(self, itemId: str, itemType: str = 'song') -> None:
"""Star (favorite) an item."""
self.client.star(itemId, itemType)
self.cache.updateStarred(itemId, itemType, starred=True)
def unstar(self, itemId: str, itemType: str = 'song') -> None:
"""Unstar (unfavorite) an item."""
self.client.unstar(itemId, itemType)
self.cache.updateStarred(itemId, itemType, starred=False)
def setRating(self, itemId: str, rating: int) -> None:
"""Set rating for an item."""
self.client.setRating(itemId, rating)
def scrobble(self, songId: str, submission: bool = True) -> None:
"""Scrobble a song."""
self.client.scrobble(songId, submission)
def getPlayQueue(self) -> Dict[str, Any]:
"""Get the saved play queue (not cached)."""
return self.client.getPlayQueue()
def savePlayQueue(self, songIds: List[str], current: str = None,
position: int = None) -> None:
"""Save the current play queue."""
self.client.savePlayQueue(songIds, current, position)
def getNowPlaying(self) -> List[Song]:
"""Get songs currently being played (not cached)."""
return self.client.getNowPlaying()
def getLyrics(self, artist: str = None, title: str = None) -> Optional[str]:
"""Get lyrics for a song."""
return self.client.getLyrics(artist, title)
def startScan(self) -> None:
"""Start a library scan."""
self.client.startScan()
def getScanStatus(self) -> Dict[str, Any]:
"""Get library scan status."""
return self.client.getScanStatus()
def clearCache(self) -> None:
"""Clear both in-memory and SQLite caches."""
self.client.clearCache()
self.db.clear()
# ==================== Cached Read Methods ====================
def getArtists(self, musicFolderId: str = None, forceRefresh: bool = False) -> List[Artist]:
"""Get all artists, using cache if available."""
if not forceRefresh:
cached = self.cache.getArtists()
if cached:
return cached
# Fetch from server
artists = self.client.getArtists(musicFolderId)
self.cache.setArtists(artists)
self.cache.setSyncState("artists", len(artists))
return artists
def getArtist(self, artistId: str, forceRefresh: bool = False) -> Dict[str, Any]:
"""Get artist details including albums."""
if not forceRefresh:
cachedArtist = self.cache.getArtist(artistId)
cachedAlbums = self.cache.getAlbums(artistId=artistId)
if cachedArtist and cachedAlbums:
return {'artist': cachedArtist, 'albums': cachedAlbums}
# Fetch from server
result = self.client.getArtist(artistId)
if result.get('artist'):
self.cache.setArtist(result['artist'])
if result.get('albums'):
self.cache.setAlbums(result['albums'])
return result
def getAlbum(self, albumId: str, forceRefresh: bool = False) -> Dict[str, Any]:
"""Get album details including songs."""
if not forceRefresh:
cachedAlbum = self.cache.getAlbum(albumId)
cachedSongs = self.cache.getSongs(albumId=albumId)
if cachedAlbum and cachedSongs:
return {'album': cachedAlbum, 'songs': cachedSongs}
# Fetch from server
result = self.client.getAlbum(albumId)
if result.get('album'):
self.cache.setAlbum(result['album'])
if result.get('songs'):
self.cache.setSongs(result['songs'])
return result
def getSong(self, songId: str, forceRefresh: bool = False) -> Song:
"""Get song details."""
if not forceRefresh:
cached = self.cache.getSong(songId)
if cached:
return cached
# Fetch from server
song = self.client.getSong(songId)
self.cache.setSong(song)
return song
def getGenres(self, forceRefresh: bool = False) -> List[Genre]:
"""Get all genres."""
if not forceRefresh:
cached = self.cache.getGenres()
if cached:
return cached
# Fetch from server
genres = self.client.getGenres()
self.cache.setGenres(genres)
self.cache.setSyncState("genres", len(genres))
return genres
def getAlbumList(self, listType: str, size: int = 20, offset: int = 0,
musicFolderId: str = None, genre: str = None,
fromYear: int = None, toYear: int = None) -> List[Album]:
"""Get album list by criteria (always fetches fresh for discover sections)."""
# Album lists are dynamic (newest, recent, etc) so don't cache
return self.client.getAlbumList(listType, size, offset, musicFolderId,
genre, fromYear, toYear)
def getSongsByGenre(self, genre: str, count: int = 100, offset: int = 0) -> List[Song]:
"""Get songs by genre."""
# Try cache first for genre songs
if offset == 0:
cached = self.cache.getSongsByGenre(genre, limit=count)
if cached:
return cached
# Fetch from server
songs = self.client.getSongsByGenre(genre, count, offset)
# Only cache the first page (offset 0)
if offset == 0:
self.cache.setSongs(songs)
return songs
def getPlaylists(self, forceRefresh: bool = False) -> List[Playlist]:
"""Get all playlists."""
if not forceRefresh:
cached = self.cache.getPlaylists()
if cached:
return cached
# Fetch from server
playlists = self.client.getPlaylists()
self.cache.setPlaylists(playlists)
self.cache.setSyncState("playlists", len(playlists))
return playlists
def getPlaylist(self, playlistId: str, forceRefresh: bool = False) -> Playlist:
"""Get playlist details including songs."""
if not forceRefresh:
cached = self.cache.getPlaylist(playlistId)
if cached and cached.songs:
return cached
# Fetch from server
playlist = self.client.getPlaylist(playlistId)
self.cache.setPlaylist(playlist)
return playlist
def getStarred(self, forceRefresh: bool = False) -> Dict[str, List]:
"""Get all starred items."""
if not forceRefresh:
cached = self.cache.getStarred()
if any(cached.values()):
return cached
# Fetch from server
starred = self.client.getStarred()
# Update starred status in cache
for artist in starred.get('artists', []):
self.cache.setArtist(artist)
for album in starred.get('albums', []):
self.cache.setAlbum(album)
for song in starred.get('songs', []):
self.cache.setSong(song)
return starred
def search(self, query: str, artistCount: int = 20,
albumCount: int = 20, songCount: int = 20) -> Dict[str, List]:
"""Search for artists, albums, and songs (not cached)."""
# Search results are dynamic, don't cache
return self.client.search(query, artistCount, albumCount, songCount)
def getTopSongs(self, artist: str, count: int = 50) -> List[Song]:
"""Get top songs for an artist."""
return self.client.getTopSongs(artist, count)
def getSimilarSongs(self, songId: str, count: int = 50) -> List[Song]:
"""Get songs similar to a given song."""
return self.client.getSimilarSongs(songId, count)
# ==================== Sync Methods ====================
def hasCache(self) -> bool:
"""Check if cache has any data."""
return self.cache.hasCache()
def syncIncremental(self) -> Dict[str, int]:
"""
Perform incremental sync - only fetch changes.
Returns dict with counts of updated items.
"""
self.logger.info("Starting incremental sync")
self._shutdownRequested = False
self._resetThrottle()
stats = {"artists": 0, "albums": 0, "songs": 0, "playlists": 0, "genres": 0}
try:
# Sync artists
self._emitProgress("artists", 0, 1)
serverArtists = self.client.getArtists()
cachedArtists = self.cache.getArtists()
serverIds = {a.id for a in serverArtists}
cachedIds = {a.id for a in cachedArtists}
cachedAlbumCounts = {a.id: a.albumCount for a in cachedArtists}
# Find changes
added = serverIds - cachedIds
removed = cachedIds - serverIds
changed = {
a.id for a in serverArtists
if a.id in cachedIds and a.albumCount != cachedAlbumCounts.get(a.id, 0)
}
# Update artists
self.cache.setArtists(serverArtists)
stats["artists"] = len(serverArtists)
# Remove deleted artists
if removed:
self.cache.deleteArtistsNotIn(list(serverIds))
# Fetch albums for new/changed artists
artistsToFetch = added | changed
total = len(artistsToFetch)
for i, artistId in enumerate(artistsToFetch):
if self._shutdownRequested:
break
self._emitProgress("albums", i + 1, total)
self._throttle()
try:
artistData = self.client.getArtist(artistId)
albums = artistData.get('albums', [])
self.cache.setAlbums(albums)
stats["albums"] += len(albums)
# Fetch songs for each album
for album in albums:
if self._shutdownRequested:
break
self._throttle()
albumData = self.client.getAlbum(album.id)
songs = albumData.get('songs', [])
self.cache.setSongs(songs)
stats["songs"] += len(songs)
except Exception as e:
self.logger.warning("Failed to fetch artist %s: %s", artistId, e)
# Sync playlists
if not self._shutdownRequested:
self._emitProgress("playlists", 0, 1)
serverPlaylists = self.client.getPlaylists()
cachedPlaylists = self.cache.getPlaylists()
serverPlaylistIds = {p.id for p in serverPlaylists}
cachedPlaylistMap = {p.id: p for p in cachedPlaylists}
self.cache.setPlaylists(serverPlaylists)
self.cache.deletePlaylistsNotIn(list(serverPlaylistIds))
# Fetch songs for changed playlists
for playlist in serverPlaylists:
if self._shutdownRequested:
break
cached = cachedPlaylistMap.get(playlist.id)
if not cached or cached.changed != playlist.changed:
self._throttle()
try:
fullPlaylist = self.client.getPlaylist(playlist.id)
self.cache.setPlaylist(fullPlaylist)
except Exception as e:
self.logger.warning("Failed to fetch playlist %s: %s", playlist.id, e)
stats["playlists"] = len(serverPlaylists)
# Sync genres
if not self._shutdownRequested:
self._emitProgress("genres", 0, 1)
genres = self.client.getGenres()
self.cache.setGenres(genres)
stats["genres"] = len(genres)
# Sync starred
if not self._shutdownRequested:
self._emitProgress("favorites", 0, 1)
self.getStarred(forceRefresh=True)
self.logger.info("Incremental sync complete: %s", stats)
self.cache.setSyncState("incremental", sum(stats.values()))
except Exception as e:
self.logger.error("Incremental sync failed: %s", e)
raise
return stats
def syncFull(self) -> Dict[str, int]:
"""
Perform full sync - fetch everything from server.
Returns dict with counts of synced items.
"""
self.logger.info("Starting full sync")
self._shutdownRequested = False
self._resetThrottle()
stats = {"artists": 0, "albums": 0, "songs": 0, "playlists": 0, "genres": 0}
try:
# Clear existing cache
self.db.clear()
# Fetch all artists
self._emitProgress("artists", 0, 1)
artists = self.client.getArtists()
self.cache.setArtists(artists)
stats["artists"] = len(artists)
self._emitProgress("artists", 1, 1)
# Fetch albums and songs for each artist
totalArtists = len(artists)
for i, artist in enumerate(artists):
if self._shutdownRequested:
break
self._emitProgress("albums", i + 1, totalArtists)
self._throttle()
try:
artistData = self.client.getArtist(artist.id)
albums = artistData.get('albums', [])
self.cache.setAlbums(albums)
stats["albums"] += len(albums)
# Fetch songs for each album
for album in albums:
if self._shutdownRequested:
break
self._throttle()
try:
albumData = self.client.getAlbum(album.id)
songs = albumData.get('songs', [])
self.cache.setSongs(songs)
stats["songs"] += len(songs)
except Exception as e:
self.logger.warning("Failed to fetch album %s: %s", album.id, e)
except Exception as e:
self.logger.warning("Failed to fetch artist %s: %s", artist.id, e)
# Fetch playlists
if not self._shutdownRequested:
self._emitProgress("playlists", 0, 1)
playlists = self.client.getPlaylists()
totalPlaylists = len(playlists)
for i, playlist in enumerate(playlists):
if self._shutdownRequested:
break
self._emitProgress("playlists", i + 1, totalPlaylists)
self._throttle()
try:
fullPlaylist = self.client.getPlaylist(playlist.id)
self.cache.setPlaylist(fullPlaylist)
except Exception as e:
self.logger.warning("Failed to fetch playlist %s: %s", playlist.id, e)
stats["playlists"] = len(playlists)
# Fetch genres
if not self._shutdownRequested:
self._emitProgress("genres", 0, 1)
genres = self.client.getGenres()
self.cache.setGenres(genres)
stats["genres"] = len(genres)
self._emitProgress("genres", 1, 1)
# Fetch starred
if not self._shutdownRequested:
self._emitProgress("favorites", 0, 1)
self.getStarred(forceRefresh=True)
self._emitProgress("favorites", 1, 1)
# Vacuum to optimize database
self.db.vacuum()
self.logger.info("Full sync complete: %s", stats)
# Record full sync as a fresh baseline
self.cache.setSyncState("incremental", sum(stats.values()))
self.cache.setSyncState("full", sum(stats.values()))
except Exception as e:
self.logger.error("Full sync failed: %s", e)
raise
return stats
def getCacheStats(self) -> Dict[str, int]:
"""Get statistics about cached data."""
return self.db.getStats()
# ==================== Sync state helpers ====================
def getLastSyncTime(self, key: str) -> Optional[datetime]:
"""Expose last sync/check time for an entity key."""
return self.cache.getLastSyncTime(key)
def setSyncState(self, key: str, itemCount: int = 0) -> None:
"""Update sync/check timestamp for an entity key."""
self.cache.setSyncState(key, itemCount)
+274
View File
@@ -0,0 +1,274 @@
"""
SQLite database connection and schema management for the cache layer.
"""
from __future__ import annotations
import hashlib
import logging
import sqlite3
import threading
from pathlib import Path
from typing import Optional
SCHEMA_VERSION = 1
SCHEMA_SQL = """
-- Metadata table for schema versioning and sync state
CREATE TABLE IF NOT EXISTS cache_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Artists table
CREATE TABLE IF NOT EXISTS artists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
album_count INTEGER DEFAULT 0,
starred TEXT,
cover_art TEXT,
artist_image_url TEXT,
last_updated TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_artists_name ON artists(name COLLATE NOCASE);
-- Albums table
CREATE TABLE IF NOT EXISTS albums (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
artist TEXT NOT NULL,
artist_id TEXT,
song_count INTEGER DEFAULT 0,
duration INTEGER DEFAULT 0,
year INTEGER,
genre TEXT,
cover_art TEXT,
starred TEXT,
play_count INTEGER DEFAULT 0,
user_rating INTEGER DEFAULT 0,
last_updated TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums(artist_id);
CREATE INDEX IF NOT EXISTS idx_albums_genre ON albums(genre);
CREATE INDEX IF NOT EXISTS idx_albums_year ON albums(year);
-- Songs table (optimized for 1M+ rows)
CREATE TABLE IF NOT EXISTS songs (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
album TEXT NOT NULL,
album_id TEXT NOT NULL,
artist TEXT NOT NULL,
artist_id TEXT,
duration INTEGER DEFAULT 0,
track INTEGER,
disc_number INTEGER,
year INTEGER,
genre TEXT,
bit_rate INTEGER,
content_type TEXT,
suffix TEXT,
path TEXT,
cover_art TEXT,
starred TEXT,
user_rating INTEGER DEFAULT 0,
play_count INTEGER DEFAULT 0,
bookmark_position INTEGER,
last_updated TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_songs_album_id ON songs(album_id);
CREATE INDEX IF NOT EXISTS idx_songs_artist_id ON songs(artist_id);
CREATE INDEX IF NOT EXISTS idx_songs_genre ON songs(genre);
CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title COLLATE NOCASE);
-- Playlists table
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
song_count INTEGER DEFAULT 0,
duration INTEGER DEFAULT 0,
public INTEGER DEFAULT 0,
owner TEXT,
created TEXT,
changed TEXT,
cover_art TEXT,
last_updated TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_playlists_name ON playlists(name COLLATE NOCASE);
-- Playlist songs junction table (maintains order)
CREATE TABLE IF NOT EXISTS playlist_songs (
playlist_id TEXT NOT NULL,
song_id TEXT NOT NULL,
position INTEGER NOT NULL,
PRIMARY KEY (playlist_id, position),
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_playlist_songs_song_id ON playlist_songs(song_id);
-- Genres table
CREATE TABLE IF NOT EXISTS genres (
name TEXT PRIMARY KEY,
song_count INTEGER DEFAULT 0,
album_count INTEGER DEFAULT 0,
last_updated TEXT NOT NULL
);
-- Sync state tracking per entity type
CREATE TABLE IF NOT EXISTS sync_state (
entity_type TEXT PRIMARY KEY,
last_sync TEXT NOT NULL,
item_count INTEGER DEFAULT 0
);
"""
class CacheDatabase:
"""
SQLite database connection manager with thread-safe connections.
Each thread gets its own connection to avoid SQLite threading issues.
Uses WAL mode for better concurrent read/write performance.
"""
def __init__(self, serverUrl: str, dataDir: Path):
"""
Initialize the cache database.
Args:
serverUrl: The server URL to create a unique cache file per server
dataDir: The XDG data directory path
"""
self.logger = logging.getLogger("navipy.cache.database")
self.serverHash = self._hashServer(serverUrl)
self.dbPath = dataDir / f"cache_{self.serverHash}.db"
self._localConn = threading.local()
self._lock = threading.Lock()
# Ensure data directory exists
dataDir.mkdir(parents=True, exist_ok=True)
# Initialize schema on first connection
self._initializeSchema()
@staticmethod
def _hashServer(serverUrl: str) -> str:
"""Create a short hash of the server URL for the filename."""
return hashlib.md5(serverUrl.encode()).hexdigest()[:12]
@property
def connection(self) -> sqlite3.Connection:
"""
Get a thread-local database connection.
Returns a connection specific to the current thread, creating one
if it doesn't exist. Connections are configured with WAL mode
for better concurrent access.
"""
if not hasattr(self._localConn, 'conn') or self._localConn.conn is None:
self._localConn.conn = sqlite3.connect(
str(self.dbPath),
timeout=30.0,
check_same_thread=False
)
self._localConn.conn.row_factory = sqlite3.Row
# Enable WAL mode for concurrent reads during writes
self._localConn.conn.execute("PRAGMA journal_mode=WAL")
# Balance between safety and speed
self._localConn.conn.execute("PRAGMA synchronous=NORMAL")
# Enable foreign keys
self._localConn.conn.execute("PRAGMA foreign_keys=ON")
return self._localConn.conn
def _initializeSchema(self) -> None:
"""Create tables and indexes if they don't exist."""
conn = self.connection
try:
conn.executescript(SCHEMA_SQL)
conn.commit()
# Check and run migrations if needed
self._migrate()
except sqlite3.Error as e:
self.logger.error("Failed to initialize cache schema: %s", e)
raise
def _migrate(self) -> None:
"""Run schema migrations if needed."""
currentVersion = self.getSchemaVersion()
if currentVersion < SCHEMA_VERSION:
self.logger.info(
"Migrating cache schema from version %d to %d",
currentVersion, SCHEMA_VERSION
)
# Add migration logic here as schema evolves
# For now, just update the version
self.setMeta("schema_version", str(SCHEMA_VERSION))
def getSchemaVersion(self) -> int:
"""Get the current schema version from the database."""
version = self.getMeta("schema_version")
return int(version) if version else 0
def getMeta(self, key: str) -> Optional[str]:
"""Get a metadata value by key."""
try:
cursor = self.connection.execute(
"SELECT value FROM cache_meta WHERE key = ?",
(key,)
)
row = cursor.fetchone()
return row["value"] if row else None
except sqlite3.Error:
return None
def setMeta(self, key: str, value: str) -> None:
"""Set a metadata value."""
self.connection.execute(
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
(key, value)
)
self.connection.commit()
def close(self) -> None:
"""Close the thread-local connection if it exists."""
if hasattr(self._localConn, 'conn') and self._localConn.conn:
self._localConn.conn.close()
self._localConn.conn = None
def clear(self) -> None:
"""Clear all cached data but keep the schema."""
conn = self.connection
tables = [
"artists", "albums", "songs", "playlists",
"playlist_songs", "genres", "sync_state"
]
try:
for table in tables:
conn.execute(f"DELETE FROM {table}")
conn.commit()
self.logger.info("Cache cleared")
except sqlite3.Error as e:
self.logger.error("Failed to clear cache: %s", e)
raise
def vacuum(self) -> None:
"""Reclaim unused space in the database file."""
try:
self.connection.execute("VACUUM")
self.logger.info("Cache vacuumed")
except sqlite3.Error as e:
self.logger.error("Failed to vacuum cache: %s", e)
def getStats(self) -> dict:
"""Get cache statistics."""
conn = self.connection
stats = {}
tables = ["artists", "albums", "songs", "playlists", "genres"]
for table in tables:
cursor = conn.execute(f"SELECT COUNT(*) as count FROM {table}")
row = cursor.fetchone()
stats[table] = row["count"] if row else 0
return stats
+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()
+22 -1
View File
@@ -3,6 +3,8 @@ Accessible text display dialog - single point of truth for showing text to scree
Based on the implementation from Bifrost
"""
import time
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QTextEdit, QDialogButtonBox
)
@@ -15,6 +17,11 @@ class AccessibleTextDialog(QDialog):
Provides keyboard navigation, text selection, and proper focus management.
"""
# Class-level error debouncing to prevent dialog spam
_lastErrorKey: str = ""
_lastErrorTime: float = 0.0
_errorDebounceSeconds: float = 2.0
def __init__(self, title: str, content: str, dialogType: str = "info", parent=None):
"""
Initialize accessible text dialog
@@ -80,7 +87,21 @@ class AccessibleTextDialog(QDialog):
@classmethod
def showError(cls, title: str, message: str, details: str = None, parent=None):
"""Convenience method for error dialogs with optional details"""
"""Convenience method for error dialogs with optional details.
Includes debouncing to prevent the same error from spawning multiple dialogs
in rapid succession.
"""
# Debounce: skip if same error within debounce window
errorKey = f"{title}:{message}"
currentTime = time.monotonic()
if (errorKey == cls._lastErrorKey and
currentTime - cls._lastErrorTime < cls._errorDebounceSeconds):
return
cls._lastErrorKey = errorKey
cls._lastErrorTime = currentTime
if details:
content = f"{message}\n\nError Details:\n{details}"
else:
+17
View File
@@ -0,0 +1,17 @@
"""
Lightweight test runner.
Usage:
python test.py
"""
import sys
import pytest
def main() -> int:
return pytest.main(["-q"])
if __name__ == "__main__":
sys.exit(main())
+146
View File
@@ -0,0 +1,146 @@
"""
Basic cache layer tests to ensure CRUD operations and sync state behave.
These run against a temp SQLite file and a tiny fake clientno network or Qt.
"""
from datetime import datetime, timedelta
from pathlib import Path
import sys
import pytest
# Ensure repo root on path for `src` imports when running via python -m pytest
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from src.cache import CacheDatabase, CacheManager, CachedClient
from src.api.models import Artist, Album, Song, Playlist
class FakeClient:
"""Minimal fake SubsonicClient for incremental sync tests."""
def __init__(self):
self.serverUrl = "https://fake.example"
self._artists = [
Artist(id="a1", name="Artist One", albumCount=1),
Artist(id="a2", name="Artist Two", albumCount=0),
]
self._albums = {
"a1": [Album(id="al1", name="Album One", artist="Artist One", artistId="a1")],
}
self._songs = {
"al1": [
Song(
id="s1",
title="Song One",
album="Album One",
albumId="al1",
artist="Artist One",
artistId="a1",
)
]
}
self._playlists = [
Playlist(
id="p1",
name="PL1",
songCount=1,
songs=self._songs["al1"],
changed=datetime.now() - timedelta(hours=1),
)
]
# Methods used by CachedClient.syncIncremental
def getArtists(self, musicFolderId=None):
return self._artists
def getArtist(self, artistId):
return {"artist": next(a for a in self._artists if a.id == artistId), "albums": self._albums.get(artistId, [])}
def getAlbum(self, albumId):
songs = self._songs.get(albumId, [])
album = Album(id=albumId, name=f"Album {albumId}", artist="Artist One", artistId="a1")
return {"album": album, "songs": songs}
def getPlaylists(self):
return self._playlists
def getPlaylist(self, playlistId):
return next(p for p in self._playlists if p.id == playlistId)
def getGenres(self):
return []
def getStarred(self):
return {"artists": [], "albums": [], "songs": []}
# Passthroughs not used here
def ping(self):
return True
def clearCache(self):
pass
@pytest.fixture()
def temp_cache(tmp_path: Path):
data_dir = tmp_path / "data"
data_dir.mkdir()
db = CacheDatabase("https://fake.example", data_dir)
cache = CacheManager(db)
yield cache
db.close()
def test_artist_album_song_round_trip(temp_cache: CacheManager):
artist = Artist(id="a1", name="Artist One", albumCount=1)
album = Album(id="al1", name="Album One", artist="Artist One", artistId="a1")
song = Song(id="s1", title="Song One", album="Album One", albumId="al1", artist="Artist One", artistId="a1")
temp_cache.setArtist(artist)
temp_cache.setAlbum(album)
temp_cache.setSong(song)
assert temp_cache.getArtist("a1") == artist
assert temp_cache.getAlbum("al1") == album
assert temp_cache.getSong("s1") == song
def test_playlist_order_preserved(temp_cache: CacheManager):
songs = [
Song(id="s1", title="First", album="a", albumId="al", artist="x"),
Song(id="s2", title="Second", album="a", albumId="al", artist="x"),
Song(id="s3", title="Third", album="a", albumId="al", artist="x"),
]
playlist = Playlist(id="p1", name="Order Test", songCount=3, songs=songs)
temp_cache.setPlaylist(playlist)
stored = temp_cache.getPlaylist("p1")
assert stored is not None
assert [s.id for s in stored.songs] == ["s1", "s2", "s3"]
def test_sync_state_stores_timestamps(tmp_path: Path):
db = CacheDatabase("https://fake.example", tmp_path)
cache = CacheManager(db)
before = datetime.now()
cache.setSyncState("incremental", 5)
recorded = cache.getSyncState("incremental")
assert recorded is not None
assert recorded["itemCount"] == 5
assert recorded["lastSync"] >= before
db.close()
def test_cached_client_exposes_last_sync(tmp_path: Path):
client = CachedClient(FakeClient(), tmp_path)
# First incremental run should set sync state
stats = client.syncIncremental()
assert stats["artists"] == 2
assert client.getLastSyncTime("incremental") is not None
# Future check should consider recency
client.setSyncState("incremental", 0)
assert client.getLastSyncTime("incremental") is not None
+168
View File
@@ -0,0 +1,168 @@
"""
Additional high-value cache and settings tests.
"""
import os
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
# Ensure repo root on sys.path before importing project modules
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pytest
from PySide6.QtWidgets import QApplication
from src.cache import CacheDatabase, CacheManager, CachedClient
from src.cache.database import SCHEMA_VERSION
from src.api.models import Artist, Album, Song, Playlist, Genre
from src.config.settings import Settings
from src.widgets.accessible_text_dialog import AccessibleTextDialog
@pytest.fixture(scope="session", autouse=True)
def qt_offscreen_env():
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
return
@pytest.fixture()
def qt_app():
app = QApplication.instance()
if app:
return app
return QApplication([])
def test_schema_migration_updates_version(tmp_path: Path):
db = CacheDatabase("https://fake.example", tmp_path)
db.setMeta("schema_version", "0")
db.close()
db2 = CacheDatabase("https://fake.example", tmp_path)
assert db2.getSchemaVersion() == SCHEMA_VERSION
db2.close()
def test_update_starred_sets_and_clears(tmp_path: Path):
db = CacheDatabase("https://fake.example", tmp_path)
cache = CacheManager(db)
song = Song(id="s1", title="Star me", album="a", albumId="al", artist="x")
cache.setSong(song)
cache.updateStarred("s1", "song", True)
starred = cache.getStarredSongs()
assert starred and starred[0].id == "s1"
cache.updateStarred("s1", "song", False)
starred = cache.getStarredSongs()
assert starred == []
db.close()
def test_playlist_changed_triggers_refetch(tmp_path: Path):
class Client:
def __init__(self):
self.serverUrl = "https://fake.example"
now = datetime.now()
self._playlists = [
Playlist(
id="p1",
name="List",
songCount=1,
songs=[Song(id="s1", title="Old", album="a", albumId="al", artist="x")],
changed=now,
)
]
self._changed_calls = 0
def getArtists(self, musicFolderId=None):
return []
def getArtist(self, artistId):
return {"artist": None, "albums": []}
def getAlbum(self, albumId):
return {"album": None, "songs": []}
def getPlaylists(self):
return [Playlist(id=p.id, name=p.name, songCount=p.songCount, changed=p.changed) for p in self._playlists]
def getPlaylist(self, playlistId):
self._changed_calls += 1
return next(p for p in self._playlists if p.id == playlistId)
def getGenres(self):
return []
def getStarred(self):
return {"artists": [], "albums": [], "songs": []}
def ping(self):
return True
def clearCache(self):
pass
client = Client()
cached = CachedClient(client, tmp_path)
cached.syncIncremental()
# Update playlist change timestamp and add a new song
client._playlists[0].changed = datetime.now() + timedelta(minutes=1)
client._playlists[0].songs.append(
Song(id="s2", title="New", album="a", albumId="al", artist="x")
)
cached.syncIncremental()
playlist = cached.cache.getPlaylist("p1")
assert playlist and [s.id for s in playlist.songs] == ["s1", "s2"]
assert client._changed_calls == 2 # one per incremental run
def test_genre_prune(tmp_path: Path):
db = CacheDatabase("https://fake.example", tmp_path)
cache = CacheManager(db)
genres = [Genre(name="Rock", songCount=1, albumCount=1), Genre(name="Pop", songCount=2, albumCount=1)]
cache.setGenres(genres)
cache.deleteGenresNotIn(["Rock"])
remaining = cache.getGenres()
assert [g.name for g in remaining] == ["Rock"]
db.close()
def test_settings_respect_xdg(tmp_path: Path, monkeypatch):
cfg = tmp_path / "cfg"
data = tmp_path / "data"
cache = tmp_path / "cache"
monkeypatch.setenv("XDG_CONFIG_HOME", str(cfg))
monkeypatch.setenv("XDG_DATA_HOME", str(data))
monkeypatch.setenv("XDG_CACHE_HOME", str(cache))
settings = Settings()
settings.set("interface", "pageStep", 7)
settings.addServer("demo", "https://demo", "u", "p")
assert settings.configDir.is_relative_to(cfg)
assert settings.configFile.exists()
assert settings.serversFile.exists()
with open(settings.configFile, "r", encoding="utf-8") as f:
assert "pageStep" in f.read()
def test_error_dialog_debounce(qt_app, monkeypatch):
calls = []
def fake_exec(self):
calls.append(time.monotonic())
return 0
# Reset debounce state
AccessibleTextDialog._lastErrorKey = ""
AccessibleTextDialog._lastErrorTime = 0.0
monkeypatch.setattr(AccessibleTextDialog, "exec", fake_exec)
AccessibleTextDialog.showError("Err", "Same", parent=None)
AccessibleTextDialog.showError("Err", "Same", parent=None)
assert len(calls) == 1