diff --git a/AGENTS.md b/AGENTS.md index cba6e27..1b55c6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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_.py`; use fixtures to stub Navidrome endpoints instead of hitting real servers. +- Automated `pytest` suite lives under `tests/`; keep new tests named `test_.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 diff --git a/src/cache/__init__.py b/src/cache/__init__.py new file mode 100644 index 0000000..705891d --- /dev/null +++ b/src/cache/__init__.py @@ -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"] diff --git a/src/cache/cache_manager.py b/src/cache/cache_manager.py new file mode 100644 index 0000000..96b4e94 --- /dev/null +++ b/src/cache/cache_manager.py @@ -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() diff --git a/src/cache/cached_client.py b/src/cache/cached_client.py new file mode 100644 index 0000000..16b859d --- /dev/null +++ b/src/cache/cached_client.py @@ -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) diff --git a/src/cache/database.py b/src/cache/database.py new file mode 100644 index 0000000..6b30a1f --- /dev/null +++ b/src/cache/database.py @@ -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 diff --git a/src/main_window.py b/src/main_window.py index 1e1d5b8..88ee950 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -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() diff --git a/src/widgets/accessible_text_dialog.py b/src/widgets/accessible_text_dialog.py index 69a1bee..104fdba 100644 --- a/src/widgets/accessible_text_dialog.py +++ b/src/widgets/accessible_text_dialog.py @@ -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: diff --git a/test.py b/test.py new file mode 100644 index 0000000..fbe88c3 --- /dev/null +++ b/test.py @@ -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()) diff --git a/tests/test_cache_basic.py b/tests/test_cache_basic.py new file mode 100644 index 0000000..fada7c4 --- /dev/null +++ b/tests/test_cache_basic.py @@ -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 client—no 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 diff --git a/tests/test_cache_extra.py b/tests/test_cache_extra.py new file mode 100644 index 0000000..124330e --- /dev/null +++ b/tests/test_cache_extra.py @@ -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