Cache system added using sqlite. Should be nicer to the server.
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user