Cache system added using sqlite. Should be nicer to the server.
This commit is contained in:
@@ -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
|
||||
|
||||
Vendored
+12
@@ -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"]
|
||||
Vendored
+575
@@ -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()
|
||||
Vendored
+554
@@ -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)
|
||||
Vendored
+274
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user