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

This commit is contained in:
Storm Dragon
2025-12-16 22:47:43 -05:00
parent 0a952afb21
commit 221224270b
10 changed files with 1950 additions and 19 deletions
+12
View File
@@ -0,0 +1,12 @@
"""
SQLite caching layer for NaviPy.
Provides persistent storage for library data to enable fast startup
and reduce server load.
"""
from src.cache.database import CacheDatabase
from src.cache.cache_manager import CacheManager
from src.cache.cached_client import CachedClient
__all__ = ["CacheDatabase", "CacheManager", "CachedClient"]
+575
View File
@@ -0,0 +1,575 @@
"""
Cache manager providing CRUD operations for all cached entities.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Dict, List, Optional
from src.api.models import Album, Artist, Genre, Playlist, Song
from src.cache.database import CacheDatabase
class CacheManager:
"""
Manages CRUD operations for all cached entities.
Provides methods to get/set artists, albums, songs, playlists, and genres
with batch operations for performance.
"""
def __init__(self, db: CacheDatabase):
"""
Initialize the cache manager.
Args:
db: The CacheDatabase instance to use for storage
"""
self.db = db
self.logger = logging.getLogger("navipy.cache.manager")
@staticmethod
def _datetimeToStr(dt: Optional[datetime]) -> Optional[str]:
"""Convert datetime to ISO format string for storage."""
return dt.isoformat() if dt else None
@staticmethod
def _strToDatetime(s: Optional[str]) -> Optional[datetime]:
"""Convert ISO format string back to datetime."""
if not s:
return None
try:
return datetime.fromisoformat(s.replace('Z', '+00:00'))
except (ValueError, TypeError):
return None
@staticmethod
def _nowIso() -> str:
"""Get current time as ISO string."""
return datetime.now().isoformat()
# ==================== Artists ====================
def getArtists(self) -> List[Artist]:
"""Get all cached artists."""
cursor = self.db.connection.execute(
"SELECT * FROM artists ORDER BY name COLLATE NOCASE"
)
return [self._rowToArtist(row) for row in cursor.fetchall()]
def getArtist(self, artistId: str) -> Optional[Artist]:
"""Get a single artist by ID."""
cursor = self.db.connection.execute(
"SELECT * FROM artists WHERE id = ?", (artistId,)
)
row = cursor.fetchone()
return self._rowToArtist(row) if row else None
def setArtists(self, artists: List[Artist]) -> None:
"""Insert or update multiple artists."""
if not artists:
return
now = self._nowIso()
conn = self.db.connection
conn.executemany(
"""INSERT OR REPLACE INTO artists
(id, name, album_count, starred, cover_art, artist_image_url, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
[
(
a.id, a.name, a.albumCount,
self._datetimeToStr(a.starred),
a.coverArt, a.artistImageUrl, now
)
for a in artists
]
)
conn.commit()
def setArtist(self, artist: Artist) -> None:
"""Insert or update a single artist."""
self.setArtists([artist])
def deleteArtist(self, artistId: str) -> None:
"""Delete an artist by ID."""
conn = self.db.connection
conn.execute("DELETE FROM artists WHERE id = ?", (artistId,))
conn.commit()
def deleteArtistsNotIn(self, keepIds: List[str]) -> int:
"""Delete artists not in the given ID list. Returns count deleted."""
if not keepIds:
return 0
conn = self.db.connection
placeholders = ",".join("?" * len(keepIds))
cursor = conn.execute(
f"DELETE FROM artists WHERE id NOT IN ({placeholders})",
keepIds
)
conn.commit()
return cursor.rowcount
def _rowToArtist(self, row) -> Artist:
"""Convert a database row to an Artist object."""
return Artist(
id=row["id"],
name=row["name"],
albumCount=row["album_count"],
starred=self._strToDatetime(row["starred"]),
coverArt=row["cover_art"],
artistImageUrl=row["artist_image_url"]
)
# ==================== Albums ====================
def getAlbums(self, artistId: Optional[str] = None) -> List[Album]:
"""Get cached albums, optionally filtered by artist."""
if artistId:
cursor = self.db.connection.execute(
"SELECT * FROM albums WHERE artist_id = ? ORDER BY year DESC, name COLLATE NOCASE",
(artistId,)
)
else:
cursor = self.db.connection.execute(
"SELECT * FROM albums ORDER BY name COLLATE NOCASE"
)
return [self._rowToAlbum(row) for row in cursor.fetchall()]
def getAlbum(self, albumId: str) -> Optional[Album]:
"""Get a single album by ID."""
cursor = self.db.connection.execute(
"SELECT * FROM albums WHERE id = ?", (albumId,)
)
row = cursor.fetchone()
return self._rowToAlbum(row) if row else None
def setAlbums(self, albums: List[Album]) -> None:
"""Insert or update multiple albums."""
if not albums:
return
now = self._nowIso()
conn = self.db.connection
conn.executemany(
"""INSERT OR REPLACE INTO albums
(id, name, artist, artist_id, song_count, duration, year, genre,
cover_art, starred, play_count, user_rating, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
(
a.id, a.name, a.artist, a.artistId, a.songCount, a.duration,
a.year, a.genre, a.coverArt, self._datetimeToStr(a.starred),
a.playCount, a.userRating, now
)
for a in albums
]
)
conn.commit()
def setAlbum(self, album: Album) -> None:
"""Insert or update a single album."""
self.setAlbums([album])
def deleteAlbum(self, albumId: str) -> None:
"""Delete an album and its songs."""
conn = self.db.connection
conn.execute("DELETE FROM songs WHERE album_id = ?", (albumId,))
conn.execute("DELETE FROM albums WHERE id = ?", (albumId,))
conn.commit()
def deleteAlbumsNotIn(self, keepIds: List[str]) -> int:
"""Delete albums not in the given ID list. Returns count deleted."""
if not keepIds:
return 0
conn = self.db.connection
placeholders = ",".join("?" * len(keepIds))
cursor = conn.execute(
f"DELETE FROM albums WHERE id NOT IN ({placeholders})",
keepIds
)
conn.commit()
return cursor.rowcount
def _rowToAlbum(self, row) -> Album:
"""Convert a database row to an Album object."""
return Album(
id=row["id"],
name=row["name"],
artist=row["artist"],
artistId=row["artist_id"],
songCount=row["song_count"],
duration=row["duration"],
year=row["year"],
genre=row["genre"],
coverArt=row["cover_art"],
starred=self._strToDatetime(row["starred"]),
playCount=row["play_count"],
userRating=row["user_rating"]
)
# ==================== Songs ====================
def getSongs(self, albumId: Optional[str] = None) -> List[Song]:
"""Get cached songs, optionally filtered by album."""
if albumId:
cursor = self.db.connection.execute(
"""SELECT * FROM songs WHERE album_id = ?
ORDER BY disc_number, track, title COLLATE NOCASE""",
(albumId,)
)
else:
cursor = self.db.connection.execute(
"SELECT * FROM songs ORDER BY title COLLATE NOCASE"
)
return [self._rowToSong(row) for row in cursor.fetchall()]
def getSong(self, songId: str) -> Optional[Song]:
"""Get a single song by ID."""
cursor = self.db.connection.execute(
"SELECT * FROM songs WHERE id = ?", (songId,)
)
row = cursor.fetchone()
return self._rowToSong(row) if row else None
def getSongsByIds(self, songIds: List[str]) -> List[Song]:
"""Get multiple songs by their IDs, preserving order."""
if not songIds:
return []
placeholders = ",".join("?" * len(songIds))
cursor = self.db.connection.execute(
f"SELECT * FROM songs WHERE id IN ({placeholders})",
songIds
)
# Build a dict for order-preserving lookup
songMap = {row["id"]: self._rowToSong(row) for row in cursor.fetchall()}
return [songMap[sid] for sid in songIds if sid in songMap]
def getStarredSongs(self) -> List[Song]:
"""Get all starred (favorited) songs."""
cursor = self.db.connection.execute(
"SELECT * FROM songs WHERE starred IS NOT NULL ORDER BY title COLLATE NOCASE"
)
return [self._rowToSong(row) for row in cursor.fetchall()]
def getStarredAlbums(self) -> List[Album]:
"""Get all starred (favorited) albums."""
cursor = self.db.connection.execute(
"SELECT * FROM albums WHERE starred IS NOT NULL ORDER BY name COLLATE NOCASE"
)
return [self._rowToAlbum(row) for row in cursor.fetchall()]
def getStarredArtists(self) -> List[Artist]:
"""Get all starred (favorited) artists."""
cursor = self.db.connection.execute(
"SELECT * FROM artists WHERE starred IS NOT NULL ORDER BY name COLLATE NOCASE"
)
return [self._rowToArtist(row) for row in cursor.fetchall()]
def getSongsByGenre(self, genre: str, limit: int = 500) -> List[Song]:
"""Get songs by genre."""
cursor = self.db.connection.execute(
"SELECT * FROM songs WHERE genre = ? ORDER BY title COLLATE NOCASE LIMIT ?",
(genre, limit)
)
return [self._rowToSong(row) for row in cursor.fetchall()]
def setSongs(self, songs: List[Song]) -> None:
"""Insert or update multiple songs."""
if not songs:
return
now = self._nowIso()
conn = self.db.connection
conn.executemany(
"""INSERT OR REPLACE INTO songs
(id, title, album, album_id, artist, artist_id, duration, track,
disc_number, year, genre, bit_rate, content_type, suffix, path,
cover_art, starred, user_rating, play_count, bookmark_position, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
(
s.id, s.title, s.album, s.albumId, s.artist, s.artistId,
s.duration, s.track, s.discNumber, s.year, s.genre,
s.bitRate, s.contentType, s.suffix, s.path, s.coverArt,
self._datetimeToStr(s.starred), s.userRating, s.playCount,
s.bookmarkPosition, now
)
for s in songs
]
)
conn.commit()
def setSong(self, song: Song) -> None:
"""Insert or update a single song."""
self.setSongs([song])
def deleteSong(self, songId: str) -> None:
"""Delete a song by ID."""
conn = self.db.connection
conn.execute("DELETE FROM songs WHERE id = ?", (songId,))
conn.commit()
def deleteSongsForAlbum(self, albumId: str) -> None:
"""Delete all songs for an album."""
conn = self.db.connection
conn.execute("DELETE FROM songs WHERE album_id = ?", (albumId,))
conn.commit()
def _rowToSong(self, row) -> Song:
"""Convert a database row to a Song object."""
return Song(
id=row["id"],
title=row["title"],
album=row["album"],
albumId=row["album_id"],
artist=row["artist"],
artistId=row["artist_id"],
duration=row["duration"],
track=row["track"],
discNumber=row["disc_number"],
year=row["year"],
genre=row["genre"],
bitRate=row["bit_rate"],
contentType=row["content_type"],
suffix=row["suffix"],
path=row["path"],
coverArt=row["cover_art"],
starred=self._strToDatetime(row["starred"]),
userRating=row["user_rating"],
playCount=row["play_count"],
bookmarkPosition=row["bookmark_position"]
)
# ==================== Playlists ====================
def getPlaylists(self) -> List[Playlist]:
"""Get all cached playlists (without songs)."""
cursor = self.db.connection.execute(
"SELECT * FROM playlists ORDER BY name COLLATE NOCASE"
)
return [self._rowToPlaylist(row) for row in cursor.fetchall()]
def getPlaylist(self, playlistId: str) -> Optional[Playlist]:
"""Get a single playlist with its songs."""
cursor = self.db.connection.execute(
"SELECT * FROM playlists WHERE id = ?", (playlistId,)
)
row = cursor.fetchone()
if not row:
return None
playlist = self._rowToPlaylist(row)
playlist.songs = self.getPlaylistSongs(playlistId)
return playlist
def getPlaylistSongs(self, playlistId: str) -> List[Song]:
"""Get songs for a playlist in order."""
cursor = self.db.connection.execute(
"""SELECT s.* FROM songs s
JOIN playlist_songs ps ON s.id = ps.song_id
WHERE ps.playlist_id = ?
ORDER BY ps.position""",
(playlistId,)
)
return [self._rowToSong(row) for row in cursor.fetchall()]
def setPlaylists(self, playlists: List[Playlist]) -> None:
"""Insert or update multiple playlists (without songs)."""
if not playlists:
return
now = self._nowIso()
conn = self.db.connection
conn.executemany(
"""INSERT OR REPLACE INTO playlists
(id, name, song_count, duration, public, owner, created, changed,
cover_art, last_updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
[
(
p.id, p.name, p.songCount, p.duration, int(p.public),
p.owner, self._datetimeToStr(p.created),
self._datetimeToStr(p.changed), p.coverArt, now
)
for p in playlists
]
)
conn.commit()
def setPlaylist(self, playlist: Playlist) -> None:
"""Insert or update a playlist with its songs."""
self.setPlaylists([playlist])
if playlist.songs:
self.setPlaylistSongs(playlist.id, playlist.songs)
def setPlaylistSongs(self, playlistId: str, songs: List[Song]) -> None:
"""Set the songs for a playlist (replaces existing)."""
conn = self.db.connection
# First ensure songs exist in the songs table
self.setSongs(songs)
# Clear existing playlist songs
conn.execute("DELETE FROM playlist_songs WHERE playlist_id = ?", (playlistId,))
# Insert new playlist songs with positions
conn.executemany(
"INSERT INTO playlist_songs (playlist_id, song_id, position) VALUES (?, ?, ?)",
[(playlistId, song.id, i) for i, song in enumerate(songs)]
)
conn.commit()
def deletePlaylist(self, playlistId: str) -> None:
"""Delete a playlist and its song associations."""
conn = self.db.connection
conn.execute("DELETE FROM playlist_songs WHERE playlist_id = ?", (playlistId,))
conn.execute("DELETE FROM playlists WHERE id = ?", (playlistId,))
conn.commit()
def deletePlaylistsNotIn(self, keepIds: List[str]) -> int:
"""Delete playlists not in the given ID list. Returns count deleted."""
if not keepIds:
return 0
conn = self.db.connection
placeholders = ",".join("?" * len(keepIds))
cursor = conn.execute(
f"DELETE FROM playlists WHERE id NOT IN ({placeholders})",
keepIds
)
conn.commit()
return cursor.rowcount
def _rowToPlaylist(self, row) -> Playlist:
"""Convert a database row to a Playlist object (without songs)."""
return Playlist(
id=row["id"],
name=row["name"],
songCount=row["song_count"],
duration=row["duration"],
public=bool(row["public"]),
owner=row["owner"],
created=self._strToDatetime(row["created"]),
changed=self._strToDatetime(row["changed"]),
coverArt=row["cover_art"],
songs=[]
)
# ==================== Genres ====================
def getGenres(self) -> List[Genre]:
"""Get all cached genres."""
cursor = self.db.connection.execute(
"SELECT * FROM genres ORDER BY name COLLATE NOCASE"
)
return [self._rowToGenre(row) for row in cursor.fetchall()]
def getGenre(self, name: str) -> Optional[Genre]:
"""Get a single genre by name."""
cursor = self.db.connection.execute(
"SELECT * FROM genres WHERE name = ?", (name,)
)
row = cursor.fetchone()
return self._rowToGenre(row) if row else None
def setGenres(self, genres: List[Genre]) -> None:
"""Insert or update multiple genres."""
if not genres:
return
now = self._nowIso()
conn = self.db.connection
conn.executemany(
"""INSERT OR REPLACE INTO genres
(name, song_count, album_count, last_updated)
VALUES (?, ?, ?, ?)""",
[(g.name, g.songCount, g.albumCount, now) for g in genres]
)
conn.commit()
def setGenre(self, genre: Genre) -> None:
"""Insert or update a single genre."""
self.setGenres([genre])
def deleteGenresNotIn(self, keepNames: List[str]) -> int:
"""Delete genres not in the given name list. Returns count deleted."""
if not keepNames:
return 0
conn = self.db.connection
placeholders = ",".join("?" * len(keepNames))
cursor = conn.execute(
f"DELETE FROM genres WHERE name NOT IN ({placeholders})",
keepNames
)
conn.commit()
return cursor.rowcount
def _rowToGenre(self, row) -> Genre:
"""Convert a database row to a Genre object."""
return Genre(
name=row["name"],
songCount=row["song_count"],
albumCount=row["album_count"]
)
# ==================== Sync State ====================
def getSyncState(self, entityType: str) -> Optional[Dict]:
"""Get sync state for an entity type."""
cursor = self.db.connection.execute(
"SELECT * FROM sync_state WHERE entity_type = ?", (entityType,)
)
row = cursor.fetchone()
if not row:
return None
return {
"entityType": row["entity_type"],
"lastSync": self._strToDatetime(row["last_sync"]),
"itemCount": row["item_count"]
}
def setSyncState(self, entityType: str, itemCount: int) -> None:
"""Update sync state for an entity type."""
now = self._nowIso()
self.db.connection.execute(
"""INSERT OR REPLACE INTO sync_state
(entity_type, last_sync, item_count)
VALUES (?, ?, ?)""",
(entityType, now, itemCount)
)
self.db.connection.commit()
def getLastSyncTime(self, entityType: str) -> Optional[datetime]:
"""Get the last sync time for an entity type."""
state = self.getSyncState(entityType)
return state["lastSync"] if state else None
def hasCache(self) -> bool:
"""Check if any data is cached."""
cursor = self.db.connection.execute(
"SELECT COUNT(*) as count FROM artists"
)
row = cursor.fetchone()
return row["count"] > 0 if row else False
# ==================== Starred/Favorites ====================
def getStarred(self) -> Dict[str, list]:
"""Get all starred items."""
return {
"artists": self.getStarredArtists(),
"albums": self.getStarredAlbums(),
"songs": self.getStarredSongs()
}
def updateStarred(self, itemId: str, itemType: str, starred: bool) -> None:
"""Update the starred status of an item."""
table = {
"song": "songs",
"album": "albums",
"artist": "artists"
}.get(itemType)
if not table:
return
starredValue = self._nowIso() if starred else None
self.db.connection.execute(
f"UPDATE {table} SET starred = ? WHERE id = ?",
(starredValue, itemId)
)
self.db.connection.commit()
+554
View File
@@ -0,0 +1,554 @@
"""
Cached client wrapper that adds SQLite persistence to SubsonicClient.
"""
from __future__ import annotations
import logging
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
from src.api.client import SubsonicClient
from src.api.models import Album, Artist, Genre, Playlist, Song
from src.cache.cache_manager import CacheManager
from src.cache.database import CacheDatabase
class CachedClient:
"""
Wraps SubsonicClient with SQLite caching for persistent storage.
Provides the same interface as SubsonicClient but reads from cache
when available and updates cache on fetches. Also provides sync
methods for incremental and full synchronization.
"""
def __init__(
self,
client: SubsonicClient,
dataDir: Path,
onSyncProgress: Optional[Callable[[str, int, int], None]] = None
):
"""
Initialize the cached client.
Args:
client: The underlying SubsonicClient to wrap
dataDir: Path to store the SQLite cache database
onSyncProgress: Optional callback for sync progress (stage, current, total)
"""
self.client = client
self.logger = logging.getLogger("navipy.cache.client")
self.onSyncProgress = onSyncProgress
# Throttling settings (match main_window.py settings)
self.throttleStartAfter = 50
self.throttleSeconds = 0.05
self._requestCount = 0
self._shutdownRequested = False
# Initialize database and cache manager
self.db = CacheDatabase(client.serverUrl, dataDir)
self.cache = CacheManager(self.db)
def shutdown(self) -> None:
"""Signal that shutdown is requested to stop long-running syncs."""
self._shutdownRequested = True
def _resetThrottle(self) -> None:
"""Reset the request counter for a new sync operation."""
self._requestCount = 0
def _throttle(self) -> None:
"""Apply throttling to be nice to the server."""
self._requestCount += 1
if self._requestCount > self.throttleStartAfter:
time.sleep(self.throttleSeconds)
def _emitProgress(self, stage: str, current: int, total: int) -> None:
"""Emit sync progress if callback is set."""
if self.onSyncProgress:
try:
self.onSyncProgress(stage, current, total)
except Exception:
pass # Don't let callback errors break sync
# ==================== Passthrough Methods ====================
# These methods don't need caching - they're dynamic or write operations
def ping(self) -> bool:
"""Test connection to the server."""
return self.client.ping()
def getStreamUrl(self, songId: str, format: str = None, maxBitRate: int = None) -> str:
"""Generate streaming URL for a song."""
return self.client.getStreamUrl(songId, format, maxBitRate)
def getCoverArtUrl(self, coverId: str, size: int = None) -> str:
"""Generate cover art URL."""
return self.client.getCoverArtUrl(coverId, size)
def getRandomSongs(self, size: int = 10, genre: str = None,
fromYear: int = None, toYear: int = None) -> List[Song]:
"""Get random songs (not cached - always fresh)."""
return self.client.getRandomSongs(size, genre, fromYear, toYear)
def createPlaylist(self, name: str, songIds: List[str] = None) -> Playlist:
"""Create a new playlist."""
result = self.client.createPlaylist(name, songIds)
# Persist new playlist so cached lists reflect it immediately
if result:
try:
self.cache.setPlaylist(result)
except Exception as exc:
self.logger.warning("Failed to cache new playlist %s: %s", getattr(result, "id", ""), exc)
return result
def updatePlaylist(self, playlistId: str, name: str = None,
songIdsToAdd: List[str] = None,
songIndexesToRemove: List[int] = None) -> None:
"""Update a playlist."""
self.client.updatePlaylist(playlistId, name, songIdsToAdd, songIndexesToRemove)
# Refresh playlist entry so metadata stays current
try:
updated = self.client.getPlaylist(playlistId)
self.cache.setPlaylist(updated)
except Exception:
# Fall back to invalidation if fetch fails
self.cache.deletePlaylist(playlistId)
def deletePlaylist(self, playlistId: str) -> None:
"""Delete a playlist."""
self.client.deletePlaylist(playlistId)
self.cache.deletePlaylist(playlistId)
def star(self, itemId: str, itemType: str = 'song') -> None:
"""Star (favorite) an item."""
self.client.star(itemId, itemType)
self.cache.updateStarred(itemId, itemType, starred=True)
def unstar(self, itemId: str, itemType: str = 'song') -> None:
"""Unstar (unfavorite) an item."""
self.client.unstar(itemId, itemType)
self.cache.updateStarred(itemId, itemType, starred=False)
def setRating(self, itemId: str, rating: int) -> None:
"""Set rating for an item."""
self.client.setRating(itemId, rating)
def scrobble(self, songId: str, submission: bool = True) -> None:
"""Scrobble a song."""
self.client.scrobble(songId, submission)
def getPlayQueue(self) -> Dict[str, Any]:
"""Get the saved play queue (not cached)."""
return self.client.getPlayQueue()
def savePlayQueue(self, songIds: List[str], current: str = None,
position: int = None) -> None:
"""Save the current play queue."""
self.client.savePlayQueue(songIds, current, position)
def getNowPlaying(self) -> List[Song]:
"""Get songs currently being played (not cached)."""
return self.client.getNowPlaying()
def getLyrics(self, artist: str = None, title: str = None) -> Optional[str]:
"""Get lyrics for a song."""
return self.client.getLyrics(artist, title)
def startScan(self) -> None:
"""Start a library scan."""
self.client.startScan()
def getScanStatus(self) -> Dict[str, Any]:
"""Get library scan status."""
return self.client.getScanStatus()
def clearCache(self) -> None:
"""Clear both in-memory and SQLite caches."""
self.client.clearCache()
self.db.clear()
# ==================== Cached Read Methods ====================
def getArtists(self, musicFolderId: str = None, forceRefresh: bool = False) -> List[Artist]:
"""Get all artists, using cache if available."""
if not forceRefresh:
cached = self.cache.getArtists()
if cached:
return cached
# Fetch from server
artists = self.client.getArtists(musicFolderId)
self.cache.setArtists(artists)
self.cache.setSyncState("artists", len(artists))
return artists
def getArtist(self, artistId: str, forceRefresh: bool = False) -> Dict[str, Any]:
"""Get artist details including albums."""
if not forceRefresh:
cachedArtist = self.cache.getArtist(artistId)
cachedAlbums = self.cache.getAlbums(artistId=artistId)
if cachedArtist and cachedAlbums:
return {'artist': cachedArtist, 'albums': cachedAlbums}
# Fetch from server
result = self.client.getArtist(artistId)
if result.get('artist'):
self.cache.setArtist(result['artist'])
if result.get('albums'):
self.cache.setAlbums(result['albums'])
return result
def getAlbum(self, albumId: str, forceRefresh: bool = False) -> Dict[str, Any]:
"""Get album details including songs."""
if not forceRefresh:
cachedAlbum = self.cache.getAlbum(albumId)
cachedSongs = self.cache.getSongs(albumId=albumId)
if cachedAlbum and cachedSongs:
return {'album': cachedAlbum, 'songs': cachedSongs}
# Fetch from server
result = self.client.getAlbum(albumId)
if result.get('album'):
self.cache.setAlbum(result['album'])
if result.get('songs'):
self.cache.setSongs(result['songs'])
return result
def getSong(self, songId: str, forceRefresh: bool = False) -> Song:
"""Get song details."""
if not forceRefresh:
cached = self.cache.getSong(songId)
if cached:
return cached
# Fetch from server
song = self.client.getSong(songId)
self.cache.setSong(song)
return song
def getGenres(self, forceRefresh: bool = False) -> List[Genre]:
"""Get all genres."""
if not forceRefresh:
cached = self.cache.getGenres()
if cached:
return cached
# Fetch from server
genres = self.client.getGenres()
self.cache.setGenres(genres)
self.cache.setSyncState("genres", len(genres))
return genres
def getAlbumList(self, listType: str, size: int = 20, offset: int = 0,
musicFolderId: str = None, genre: str = None,
fromYear: int = None, toYear: int = None) -> List[Album]:
"""Get album list by criteria (always fetches fresh for discover sections)."""
# Album lists are dynamic (newest, recent, etc) so don't cache
return self.client.getAlbumList(listType, size, offset, musicFolderId,
genre, fromYear, toYear)
def getSongsByGenre(self, genre: str, count: int = 100, offset: int = 0) -> List[Song]:
"""Get songs by genre."""
# Try cache first for genre songs
if offset == 0:
cached = self.cache.getSongsByGenre(genre, limit=count)
if cached:
return cached
# Fetch from server
songs = self.client.getSongsByGenre(genre, count, offset)
# Only cache the first page (offset 0)
if offset == 0:
self.cache.setSongs(songs)
return songs
def getPlaylists(self, forceRefresh: bool = False) -> List[Playlist]:
"""Get all playlists."""
if not forceRefresh:
cached = self.cache.getPlaylists()
if cached:
return cached
# Fetch from server
playlists = self.client.getPlaylists()
self.cache.setPlaylists(playlists)
self.cache.setSyncState("playlists", len(playlists))
return playlists
def getPlaylist(self, playlistId: str, forceRefresh: bool = False) -> Playlist:
"""Get playlist details including songs."""
if not forceRefresh:
cached = self.cache.getPlaylist(playlistId)
if cached and cached.songs:
return cached
# Fetch from server
playlist = self.client.getPlaylist(playlistId)
self.cache.setPlaylist(playlist)
return playlist
def getStarred(self, forceRefresh: bool = False) -> Dict[str, List]:
"""Get all starred items."""
if not forceRefresh:
cached = self.cache.getStarred()
if any(cached.values()):
return cached
# Fetch from server
starred = self.client.getStarred()
# Update starred status in cache
for artist in starred.get('artists', []):
self.cache.setArtist(artist)
for album in starred.get('albums', []):
self.cache.setAlbum(album)
for song in starred.get('songs', []):
self.cache.setSong(song)
return starred
def search(self, query: str, artistCount: int = 20,
albumCount: int = 20, songCount: int = 20) -> Dict[str, List]:
"""Search for artists, albums, and songs (not cached)."""
# Search results are dynamic, don't cache
return self.client.search(query, artistCount, albumCount, songCount)
def getTopSongs(self, artist: str, count: int = 50) -> List[Song]:
"""Get top songs for an artist."""
return self.client.getTopSongs(artist, count)
def getSimilarSongs(self, songId: str, count: int = 50) -> List[Song]:
"""Get songs similar to a given song."""
return self.client.getSimilarSongs(songId, count)
# ==================== Sync Methods ====================
def hasCache(self) -> bool:
"""Check if cache has any data."""
return self.cache.hasCache()
def syncIncremental(self) -> Dict[str, int]:
"""
Perform incremental sync - only fetch changes.
Returns dict with counts of updated items.
"""
self.logger.info("Starting incremental sync")
self._shutdownRequested = False
self._resetThrottle()
stats = {"artists": 0, "albums": 0, "songs": 0, "playlists": 0, "genres": 0}
try:
# Sync artists
self._emitProgress("artists", 0, 1)
serverArtists = self.client.getArtists()
cachedArtists = self.cache.getArtists()
serverIds = {a.id for a in serverArtists}
cachedIds = {a.id for a in cachedArtists}
cachedAlbumCounts = {a.id: a.albumCount for a in cachedArtists}
# Find changes
added = serverIds - cachedIds
removed = cachedIds - serverIds
changed = {
a.id for a in serverArtists
if a.id in cachedIds and a.albumCount != cachedAlbumCounts.get(a.id, 0)
}
# Update artists
self.cache.setArtists(serverArtists)
stats["artists"] = len(serverArtists)
# Remove deleted artists
if removed:
self.cache.deleteArtistsNotIn(list(serverIds))
# Fetch albums for new/changed artists
artistsToFetch = added | changed
total = len(artistsToFetch)
for i, artistId in enumerate(artistsToFetch):
if self._shutdownRequested:
break
self._emitProgress("albums", i + 1, total)
self._throttle()
try:
artistData = self.client.getArtist(artistId)
albums = artistData.get('albums', [])
self.cache.setAlbums(albums)
stats["albums"] += len(albums)
# Fetch songs for each album
for album in albums:
if self._shutdownRequested:
break
self._throttle()
albumData = self.client.getAlbum(album.id)
songs = albumData.get('songs', [])
self.cache.setSongs(songs)
stats["songs"] += len(songs)
except Exception as e:
self.logger.warning("Failed to fetch artist %s: %s", artistId, e)
# Sync playlists
if not self._shutdownRequested:
self._emitProgress("playlists", 0, 1)
serverPlaylists = self.client.getPlaylists()
cachedPlaylists = self.cache.getPlaylists()
serverPlaylistIds = {p.id for p in serverPlaylists}
cachedPlaylistMap = {p.id: p for p in cachedPlaylists}
self.cache.setPlaylists(serverPlaylists)
self.cache.deletePlaylistsNotIn(list(serverPlaylistIds))
# Fetch songs for changed playlists
for playlist in serverPlaylists:
if self._shutdownRequested:
break
cached = cachedPlaylistMap.get(playlist.id)
if not cached or cached.changed != playlist.changed:
self._throttle()
try:
fullPlaylist = self.client.getPlaylist(playlist.id)
self.cache.setPlaylist(fullPlaylist)
except Exception as e:
self.logger.warning("Failed to fetch playlist %s: %s", playlist.id, e)
stats["playlists"] = len(serverPlaylists)
# Sync genres
if not self._shutdownRequested:
self._emitProgress("genres", 0, 1)
genres = self.client.getGenres()
self.cache.setGenres(genres)
stats["genres"] = len(genres)
# Sync starred
if not self._shutdownRequested:
self._emitProgress("favorites", 0, 1)
self.getStarred(forceRefresh=True)
self.logger.info("Incremental sync complete: %s", stats)
self.cache.setSyncState("incremental", sum(stats.values()))
except Exception as e:
self.logger.error("Incremental sync failed: %s", e)
raise
return stats
def syncFull(self) -> Dict[str, int]:
"""
Perform full sync - fetch everything from server.
Returns dict with counts of synced items.
"""
self.logger.info("Starting full sync")
self._shutdownRequested = False
self._resetThrottle()
stats = {"artists": 0, "albums": 0, "songs": 0, "playlists": 0, "genres": 0}
try:
# Clear existing cache
self.db.clear()
# Fetch all artists
self._emitProgress("artists", 0, 1)
artists = self.client.getArtists()
self.cache.setArtists(artists)
stats["artists"] = len(artists)
self._emitProgress("artists", 1, 1)
# Fetch albums and songs for each artist
totalArtists = len(artists)
for i, artist in enumerate(artists):
if self._shutdownRequested:
break
self._emitProgress("albums", i + 1, totalArtists)
self._throttle()
try:
artistData = self.client.getArtist(artist.id)
albums = artistData.get('albums', [])
self.cache.setAlbums(albums)
stats["albums"] += len(albums)
# Fetch songs for each album
for album in albums:
if self._shutdownRequested:
break
self._throttle()
try:
albumData = self.client.getAlbum(album.id)
songs = albumData.get('songs', [])
self.cache.setSongs(songs)
stats["songs"] += len(songs)
except Exception as e:
self.logger.warning("Failed to fetch album %s: %s", album.id, e)
except Exception as e:
self.logger.warning("Failed to fetch artist %s: %s", artist.id, e)
# Fetch playlists
if not self._shutdownRequested:
self._emitProgress("playlists", 0, 1)
playlists = self.client.getPlaylists()
totalPlaylists = len(playlists)
for i, playlist in enumerate(playlists):
if self._shutdownRequested:
break
self._emitProgress("playlists", i + 1, totalPlaylists)
self._throttle()
try:
fullPlaylist = self.client.getPlaylist(playlist.id)
self.cache.setPlaylist(fullPlaylist)
except Exception as e:
self.logger.warning("Failed to fetch playlist %s: %s", playlist.id, e)
stats["playlists"] = len(playlists)
# Fetch genres
if not self._shutdownRequested:
self._emitProgress("genres", 0, 1)
genres = self.client.getGenres()
self.cache.setGenres(genres)
stats["genres"] = len(genres)
self._emitProgress("genres", 1, 1)
# Fetch starred
if not self._shutdownRequested:
self._emitProgress("favorites", 0, 1)
self.getStarred(forceRefresh=True)
self._emitProgress("favorites", 1, 1)
# Vacuum to optimize database
self.db.vacuum()
self.logger.info("Full sync complete: %s", stats)
# Record full sync as a fresh baseline
self.cache.setSyncState("incremental", sum(stats.values()))
self.cache.setSyncState("full", sum(stats.values()))
except Exception as e:
self.logger.error("Full sync failed: %s", e)
raise
return stats
def getCacheStats(self) -> Dict[str, int]:
"""Get statistics about cached data."""
return self.db.getStats()
# ==================== Sync state helpers ====================
def getLastSyncTime(self, key: str) -> Optional[datetime]:
"""Expose last sync/check time for an entity key."""
return self.cache.getLastSyncTime(key)
def setSyncState(self, key: str, itemCount: int = 0) -> None:
"""Update sync/check timestamp for an entity key."""
self.cache.setSyncState(key, itemCount)
+274
View File
@@ -0,0 +1,274 @@
"""
SQLite database connection and schema management for the cache layer.
"""
from __future__ import annotations
import hashlib
import logging
import sqlite3
import threading
from pathlib import Path
from typing import Optional
SCHEMA_VERSION = 1
SCHEMA_SQL = """
-- Metadata table for schema versioning and sync state
CREATE TABLE IF NOT EXISTS cache_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Artists table
CREATE TABLE IF NOT EXISTS artists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
album_count INTEGER DEFAULT 0,
starred TEXT,
cover_art TEXT,
artist_image_url TEXT,
last_updated TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_artists_name ON artists(name COLLATE NOCASE);
-- Albums table
CREATE TABLE IF NOT EXISTS albums (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
artist TEXT NOT NULL,
artist_id TEXT,
song_count INTEGER DEFAULT 0,
duration INTEGER DEFAULT 0,
year INTEGER,
genre TEXT,
cover_art TEXT,
starred TEXT,
play_count INTEGER DEFAULT 0,
user_rating INTEGER DEFAULT 0,
last_updated TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_albums_artist_id ON albums(artist_id);
CREATE INDEX IF NOT EXISTS idx_albums_genre ON albums(genre);
CREATE INDEX IF NOT EXISTS idx_albums_year ON albums(year);
-- Songs table (optimized for 1M+ rows)
CREATE TABLE IF NOT EXISTS songs (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
album TEXT NOT NULL,
album_id TEXT NOT NULL,
artist TEXT NOT NULL,
artist_id TEXT,
duration INTEGER DEFAULT 0,
track INTEGER,
disc_number INTEGER,
year INTEGER,
genre TEXT,
bit_rate INTEGER,
content_type TEXT,
suffix TEXT,
path TEXT,
cover_art TEXT,
starred TEXT,
user_rating INTEGER DEFAULT 0,
play_count INTEGER DEFAULT 0,
bookmark_position INTEGER,
last_updated TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_songs_album_id ON songs(album_id);
CREATE INDEX IF NOT EXISTS idx_songs_artist_id ON songs(artist_id);
CREATE INDEX IF NOT EXISTS idx_songs_genre ON songs(genre);
CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title COLLATE NOCASE);
-- Playlists table
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
song_count INTEGER DEFAULT 0,
duration INTEGER DEFAULT 0,
public INTEGER DEFAULT 0,
owner TEXT,
created TEXT,
changed TEXT,
cover_art TEXT,
last_updated TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_playlists_name ON playlists(name COLLATE NOCASE);
-- Playlist songs junction table (maintains order)
CREATE TABLE IF NOT EXISTS playlist_songs (
playlist_id TEXT NOT NULL,
song_id TEXT NOT NULL,
position INTEGER NOT NULL,
PRIMARY KEY (playlist_id, position),
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_playlist_songs_song_id ON playlist_songs(song_id);
-- Genres table
CREATE TABLE IF NOT EXISTS genres (
name TEXT PRIMARY KEY,
song_count INTEGER DEFAULT 0,
album_count INTEGER DEFAULT 0,
last_updated TEXT NOT NULL
);
-- Sync state tracking per entity type
CREATE TABLE IF NOT EXISTS sync_state (
entity_type TEXT PRIMARY KEY,
last_sync TEXT NOT NULL,
item_count INTEGER DEFAULT 0
);
"""
class CacheDatabase:
"""
SQLite database connection manager with thread-safe connections.
Each thread gets its own connection to avoid SQLite threading issues.
Uses WAL mode for better concurrent read/write performance.
"""
def __init__(self, serverUrl: str, dataDir: Path):
"""
Initialize the cache database.
Args:
serverUrl: The server URL to create a unique cache file per server
dataDir: The XDG data directory path
"""
self.logger = logging.getLogger("navipy.cache.database")
self.serverHash = self._hashServer(serverUrl)
self.dbPath = dataDir / f"cache_{self.serverHash}.db"
self._localConn = threading.local()
self._lock = threading.Lock()
# Ensure data directory exists
dataDir.mkdir(parents=True, exist_ok=True)
# Initialize schema on first connection
self._initializeSchema()
@staticmethod
def _hashServer(serverUrl: str) -> str:
"""Create a short hash of the server URL for the filename."""
return hashlib.md5(serverUrl.encode()).hexdigest()[:12]
@property
def connection(self) -> sqlite3.Connection:
"""
Get a thread-local database connection.
Returns a connection specific to the current thread, creating one
if it doesn't exist. Connections are configured with WAL mode
for better concurrent access.
"""
if not hasattr(self._localConn, 'conn') or self._localConn.conn is None:
self._localConn.conn = sqlite3.connect(
str(self.dbPath),
timeout=30.0,
check_same_thread=False
)
self._localConn.conn.row_factory = sqlite3.Row
# Enable WAL mode for concurrent reads during writes
self._localConn.conn.execute("PRAGMA journal_mode=WAL")
# Balance between safety and speed
self._localConn.conn.execute("PRAGMA synchronous=NORMAL")
# Enable foreign keys
self._localConn.conn.execute("PRAGMA foreign_keys=ON")
return self._localConn.conn
def _initializeSchema(self) -> None:
"""Create tables and indexes if they don't exist."""
conn = self.connection
try:
conn.executescript(SCHEMA_SQL)
conn.commit()
# Check and run migrations if needed
self._migrate()
except sqlite3.Error as e:
self.logger.error("Failed to initialize cache schema: %s", e)
raise
def _migrate(self) -> None:
"""Run schema migrations if needed."""
currentVersion = self.getSchemaVersion()
if currentVersion < SCHEMA_VERSION:
self.logger.info(
"Migrating cache schema from version %d to %d",
currentVersion, SCHEMA_VERSION
)
# Add migration logic here as schema evolves
# For now, just update the version
self.setMeta("schema_version", str(SCHEMA_VERSION))
def getSchemaVersion(self) -> int:
"""Get the current schema version from the database."""
version = self.getMeta("schema_version")
return int(version) if version else 0
def getMeta(self, key: str) -> Optional[str]:
"""Get a metadata value by key."""
try:
cursor = self.connection.execute(
"SELECT value FROM cache_meta WHERE key = ?",
(key,)
)
row = cursor.fetchone()
return row["value"] if row else None
except sqlite3.Error:
return None
def setMeta(self, key: str, value: str) -> None:
"""Set a metadata value."""
self.connection.execute(
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
(key, value)
)
self.connection.commit()
def close(self) -> None:
"""Close the thread-local connection if it exists."""
if hasattr(self._localConn, 'conn') and self._localConn.conn:
self._localConn.conn.close()
self._localConn.conn = None
def clear(self) -> None:
"""Clear all cached data but keep the schema."""
conn = self.connection
tables = [
"artists", "albums", "songs", "playlists",
"playlist_songs", "genres", "sync_state"
]
try:
for table in tables:
conn.execute(f"DELETE FROM {table}")
conn.commit()
self.logger.info("Cache cleared")
except sqlite3.Error as e:
self.logger.error("Failed to clear cache: %s", e)
raise
def vacuum(self) -> None:
"""Reclaim unused space in the database file."""
try:
self.connection.execute("VACUUM")
self.logger.info("Cache vacuumed")
except sqlite3.Error as e:
self.logger.error("Failed to vacuum cache: %s", e)
def getStats(self) -> dict:
"""Get cache statistics."""
conn = self.connection
stats = {}
tables = ["artists", "albums", "songs", "playlists", "genres"]
for table in tables:
cursor = conn.execute(f"SELECT COUNT(*) as count FROM {table}")
row = cursor.fetchone()
stats[table] = row["count"] if row else 0
return stats