Few bug fixes, new PKGBUILD added.
This commit is contained in:
1 parent
d5545dadcd
commit
d2e68bdcb4
12 files changed
+528
-52
No files matched your search
@@ -0,0 +1,5 @@
|
|||||||
|
/navipy/
|
||||||
|
/src/
|
||||||
|
/pkg/
|
||||||
|
/*.pkg.tar.*
|
||||||
|
/*.src.tar.*
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
||||||
|
pkgname=navipy-git
|
||||||
|
pkgver=r8.gd5545da
|
||||||
|
pkgrel=1
|
||||||
|
pkgdesc='Accessible Subsonic desktop client tested with Navidrome'
|
||||||
|
arch=('any')
|
||||||
|
url='https://git.stormux.org/storm/navipy'
|
||||||
|
license=('GPL-3.0-only')
|
||||||
|
depends=(
|
||||||
|
'python'
|
||||||
|
'pyside6'
|
||||||
|
'python-requests'
|
||||||
|
'python-dbus'
|
||||||
|
'python-gobject'
|
||||||
|
'qt6-multimedia'
|
||||||
|
)
|
||||||
|
makedepends=('git')
|
||||||
|
provides=('navipy')
|
||||||
|
conflicts=('navipy')
|
||||||
|
source=("navipy::git+${url}.git"
|
||||||
|
'navipy.desktop')
|
||||||
|
sha256sums=('SKIP'
|
||||||
|
'6ce7cb0a79f7f65abfafea816a6efeed246adf60c0569c85db29906faaeeb3ea')
|
||||||
|
|
||||||
|
pkgver() {
|
||||||
|
cd "$srcdir/navipy"
|
||||||
|
printf 'r%s.g%s' "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
|
||||||
|
}
|
||||||
|
|
||||||
|
package() {
|
||||||
|
cd "$srcdir/navipy"
|
||||||
|
|
||||||
|
# Keep the application's private src package beside its entry point.
|
||||||
|
install -Dm644 navipy.py "$pkgdir/usr/share/navipy/navipy.py"
|
||||||
|
local file
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
install -Dm644 "$file" "$pkgdir/usr/share/navipy/$file"
|
||||||
|
done < <(find src -type f -name '*.py' -print0)
|
||||||
|
|
||||||
|
install -Dm644 README.md "$pkgdir/usr/share/doc/navipy/README.md"
|
||||||
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
|
|
||||||
|
install -dm755 "$pkgdir/usr/bin" "$pkgdir/usr/share/applications"
|
||||||
|
cat > "$pkgdir/usr/bin/navipy" <<'LAUNCHER'
|
||||||
|
#!/bin/sh
|
||||||
|
exec /usr/bin/python /usr/share/navipy/navipy.py "$@"
|
||||||
|
LAUNCHER
|
||||||
|
chmod 755 "$pkgdir/usr/bin/navipy"
|
||||||
|
|
||||||
|
install -Dm644 "$srcdir/navipy.desktop" "$pkgdir/usr/share/applications/navipy.desktop"
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=NaviPy
|
||||||
|
Comment=Accessible Subsonic and Navidrome music player
|
||||||
|
Exec=navipy
|
||||||
|
TryExec=navipy
|
||||||
|
Icon=audio-x-generic
|
||||||
|
Terminal=false
|
||||||
|
Categories=AudioVideo;Audio;Player;
|
||||||
|
Keywords=Music;Subsonic;Navidrome;Accessibility;
|
||||||
|
StartupWMClass=navipy
|
||||||
Vendored
+8
-3
@@ -380,10 +380,16 @@ class CacheManager:
|
|||||||
now = self._nowIso()
|
now = self._nowIso()
|
||||||
conn = self.db.connection
|
conn = self.db.connection
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"""INSERT OR REPLACE INTO playlists
|
"""INSERT INTO playlists
|
||||||
(id, name, song_count, duration, public, owner, created, changed,
|
(id, name, song_count, duration, public, owner, created, changed,
|
||||||
cover_art, last_updated)
|
cover_art, last_updated)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
name=excluded.name, song_count=excluded.song_count,
|
||||||
|
duration=excluded.duration, public=excluded.public,
|
||||||
|
owner=excluded.owner, created=excluded.created,
|
||||||
|
changed=excluded.changed, cover_art=excluded.cover_art,
|
||||||
|
last_updated=excluded.last_updated""",
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
p.id, p.name, p.songCount, p.duration, int(p.public),
|
p.id, p.name, p.songCount, p.duration, int(p.public),
|
||||||
@@ -398,7 +404,6 @@ class CacheManager:
|
|||||||
def setPlaylist(self, playlist: Playlist) -> None:
|
def setPlaylist(self, playlist: Playlist) -> None:
|
||||||
"""Insert or update a playlist with its songs."""
|
"""Insert or update a playlist with its songs."""
|
||||||
self.setPlaylists([playlist])
|
self.setPlaylists([playlist])
|
||||||
if playlist.songs:
|
|
||||||
self.setPlaylistSongs(playlist.id, playlist.songs)
|
self.setPlaylistSongs(playlist.id, playlist.songs)
|
||||||
|
|
||||||
def setPlaylistSongs(self, playlistId: str, songs: List[Song]) -> None:
|
def setPlaylistSongs(self, playlistId: str, songs: List[Song]) -> None:
|
||||||
|
|||||||
Vendored
+19
-1
@@ -50,7 +50,7 @@ class CachedClient:
|
|||||||
self._shutdownRequested = False
|
self._shutdownRequested = False
|
||||||
|
|
||||||
# Initialize database and cache manager
|
# Initialize database and cache manager
|
||||||
self.db = CacheDatabase(client.serverUrl, dataDir)
|
self.db = CacheDatabase(client.serverUrl, dataDir, username=client.username)
|
||||||
self.cache = CacheManager(self.db)
|
self.cache = CacheManager(self.db)
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
@@ -176,6 +176,8 @@ class CachedClient:
|
|||||||
|
|
||||||
def getArtists(self, musicFolderId: str = None, forceRefresh: bool = False) -> List[Artist]:
|
def getArtists(self, musicFolderId: str = None, forceRefresh: bool = False) -> List[Artist]:
|
||||||
"""Get all artists, using cache if available."""
|
"""Get all artists, using cache if available."""
|
||||||
|
if forceRefresh:
|
||||||
|
self.client.clearCache()
|
||||||
if not forceRefresh:
|
if not forceRefresh:
|
||||||
cached = self.cache.getArtists()
|
cached = self.cache.getArtists()
|
||||||
if cached:
|
if cached:
|
||||||
@@ -189,6 +191,8 @@ class CachedClient:
|
|||||||
|
|
||||||
def getArtist(self, artistId: str, forceRefresh: bool = False) -> Dict[str, Any]:
|
def getArtist(self, artistId: str, forceRefresh: bool = False) -> Dict[str, Any]:
|
||||||
"""Get artist details including albums."""
|
"""Get artist details including albums."""
|
||||||
|
if forceRefresh:
|
||||||
|
self.client.clearCache()
|
||||||
if not forceRefresh:
|
if not forceRefresh:
|
||||||
cachedArtist = self.cache.getArtist(artistId)
|
cachedArtist = self.cache.getArtist(artistId)
|
||||||
cachedAlbums = self.cache.getAlbums(artistId=artistId)
|
cachedAlbums = self.cache.getAlbums(artistId=artistId)
|
||||||
@@ -205,6 +209,8 @@ class CachedClient:
|
|||||||
|
|
||||||
def getAlbum(self, albumId: str, forceRefresh: bool = False) -> Dict[str, Any]:
|
def getAlbum(self, albumId: str, forceRefresh: bool = False) -> Dict[str, Any]:
|
||||||
"""Get album details including songs."""
|
"""Get album details including songs."""
|
||||||
|
if forceRefresh:
|
||||||
|
self.client.clearCache()
|
||||||
if not forceRefresh:
|
if not forceRefresh:
|
||||||
cachedAlbum = self.cache.getAlbum(albumId)
|
cachedAlbum = self.cache.getAlbum(albumId)
|
||||||
cachedSongs = self.cache.getSongs(albumId=albumId)
|
cachedSongs = self.cache.getSongs(albumId=albumId)
|
||||||
@@ -221,6 +227,8 @@ class CachedClient:
|
|||||||
|
|
||||||
def getSong(self, songId: str, forceRefresh: bool = False) -> Song:
|
def getSong(self, songId: str, forceRefresh: bool = False) -> Song:
|
||||||
"""Get song details."""
|
"""Get song details."""
|
||||||
|
if forceRefresh:
|
||||||
|
self.client.clearCache()
|
||||||
if not forceRefresh:
|
if not forceRefresh:
|
||||||
cached = self.cache.getSong(songId)
|
cached = self.cache.getSong(songId)
|
||||||
if cached:
|
if cached:
|
||||||
@@ -233,6 +241,8 @@ class CachedClient:
|
|||||||
|
|
||||||
def getGenres(self, forceRefresh: bool = False) -> List[Genre]:
|
def getGenres(self, forceRefresh: bool = False) -> List[Genre]:
|
||||||
"""Get all genres."""
|
"""Get all genres."""
|
||||||
|
if forceRefresh:
|
||||||
|
self.client.clearCache()
|
||||||
if not forceRefresh:
|
if not forceRefresh:
|
||||||
cached = self.cache.getGenres()
|
cached = self.cache.getGenres()
|
||||||
if cached:
|
if cached:
|
||||||
@@ -269,6 +279,8 @@ class CachedClient:
|
|||||||
|
|
||||||
def getPlaylists(self, forceRefresh: bool = False) -> List[Playlist]:
|
def getPlaylists(self, forceRefresh: bool = False) -> List[Playlist]:
|
||||||
"""Get all playlists."""
|
"""Get all playlists."""
|
||||||
|
if forceRefresh:
|
||||||
|
self.client.clearCache()
|
||||||
if not forceRefresh:
|
if not forceRefresh:
|
||||||
cached = self.cache.getPlaylists()
|
cached = self.cache.getPlaylists()
|
||||||
if cached:
|
if cached:
|
||||||
@@ -282,6 +294,8 @@ class CachedClient:
|
|||||||
|
|
||||||
def getPlaylist(self, playlistId: str, forceRefresh: bool = False) -> Playlist:
|
def getPlaylist(self, playlistId: str, forceRefresh: bool = False) -> Playlist:
|
||||||
"""Get playlist details including songs."""
|
"""Get playlist details including songs."""
|
||||||
|
if forceRefresh:
|
||||||
|
self.client.clearCache()
|
||||||
if not forceRefresh:
|
if not forceRefresh:
|
||||||
cached = self.cache.getPlaylist(playlistId)
|
cached = self.cache.getPlaylist(playlistId)
|
||||||
if cached and cached.songs:
|
if cached and cached.songs:
|
||||||
@@ -294,6 +308,8 @@ class CachedClient:
|
|||||||
|
|
||||||
def getStarred(self, forceRefresh: bool = False) -> Dict[str, List]:
|
def getStarred(self, forceRefresh: bool = False) -> Dict[str, List]:
|
||||||
"""Get all starred items."""
|
"""Get all starred items."""
|
||||||
|
if forceRefresh:
|
||||||
|
self.client.clearCache()
|
||||||
if not forceRefresh:
|
if not forceRefresh:
|
||||||
cached = self.cache.getStarred()
|
cached = self.cache.getStarred()
|
||||||
if any(cached.values()):
|
if any(cached.values()):
|
||||||
@@ -339,6 +355,7 @@ class CachedClient:
|
|||||||
self.logger.info("Starting incremental sync")
|
self.logger.info("Starting incremental sync")
|
||||||
self._shutdownRequested = False
|
self._shutdownRequested = False
|
||||||
self._resetThrottle()
|
self._resetThrottle()
|
||||||
|
self.client.clearCache()
|
||||||
stats = {"artists": 0, "albums": 0, "songs": 0, "playlists": 0, "genres": 0}
|
stats = {"artists": 0, "albums": 0, "songs": 0, "playlists": 0, "genres": 0}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -450,6 +467,7 @@ class CachedClient:
|
|||||||
self.logger.info("Starting full sync")
|
self.logger.info("Starting full sync")
|
||||||
self._shutdownRequested = False
|
self._shutdownRequested = False
|
||||||
self._resetThrottle()
|
self._resetThrottle()
|
||||||
|
self.client.clearCache()
|
||||||
stats = {"artists": 0, "albums": 0, "songs": 0, "playlists": 0, "genres": 0}
|
stats = {"artists": 0, "albums": 0, "songs": 0, "playlists": 0, "genres": 0}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
Vendored
+5
-3
@@ -5,6 +5,7 @@ SQLite database connection and schema management for the cache layer.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
@@ -132,16 +133,17 @@ class CacheDatabase:
|
|||||||
Uses WAL mode for better concurrent read/write performance.
|
Uses WAL mode for better concurrent read/write performance.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, serverUrl: str, dataDir: Path):
|
def __init__(self, serverUrl: str, dataDir: Path, username: str = ""):
|
||||||
"""
|
"""
|
||||||
Initialize the cache database.
|
Initialize the cache database.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
serverUrl: The server URL to create a unique cache file per server
|
serverUrl: The server URL for the account cache
|
||||||
|
username: Account identity; legacy URL-only caches are not reused
|
||||||
dataDir: The XDG data directory path
|
dataDir: The XDG data directory path
|
||||||
"""
|
"""
|
||||||
self.logger = logging.getLogger("navipy.cache.database")
|
self.logger = logging.getLogger("navipy.cache.database")
|
||||||
self.serverHash = self._hashServer(serverUrl)
|
self.serverHash = self._hashServer(json.dumps([serverUrl.rstrip("/"), username]))
|
||||||
self.dbPath = dataDir / f"cache_{self.serverHash}.db"
|
self.dbPath = dataDir / f"cache_{self.serverHash}.db"
|
||||||
self._localConn = threading.local()
|
self._localConn = threading.local()
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|||||||
+6
-8
@@ -1529,6 +1529,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.queueList.blockSignals(False)
|
self.queueList.blockSignals(False)
|
||||||
|
|
||||||
hasItems = len(queue) > 0
|
hasItems = len(queue) > 0
|
||||||
|
if not hasItems:
|
||||||
|
self.nowPlayingInfo.setText("No track playing")
|
||||||
|
self.positionLabel.setText("00:00 / 00:00")
|
||||||
|
self.positionSlider.setValue(0)
|
||||||
self.queueList.setEnabled(hasItems)
|
self.queueList.setEnabled(hasItems)
|
||||||
self.removeQueueButton.setEnabled(hasItems)
|
self.removeQueueButton.setEnabled(hasItems)
|
||||||
self.clearQueueButton.setEnabled(hasItems)
|
self.clearQueueButton.setEnabled(hasItems)
|
||||||
@@ -1547,13 +1551,7 @@ class MainWindow(QMainWindow):
|
|||||||
row = self.queueList.currentRow()
|
row = self.queueList.currentRow()
|
||||||
if row < 0:
|
if row < 0:
|
||||||
return
|
return
|
||||||
if 0 <= row < len(self.playback.queue):
|
self.playback.removeIndex(row)
|
||||||
del self.playback.queue[row]
|
|
||||||
if row <= self.playback.currentIndex:
|
|
||||||
self.playback.currentIndex = max(-1, self.playback.currentIndex - 1)
|
|
||||||
if not self.playback.queue:
|
|
||||||
self.playback.stop()
|
|
||||||
self.playback.queueChanged.emit(self.playback.queue.copy())
|
|
||||||
|
|
||||||
def clearQueue(self):
|
def clearQueue(self):
|
||||||
"""Clear the entire queue."""
|
"""Clear the entire queue."""
|
||||||
@@ -1975,7 +1973,7 @@ class MainWindow(QMainWindow):
|
|||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
self.logger.error("Background task '%s' failed: %s", label, exc)
|
self.logger.error("Background task '%s' failed: %s", label, exc)
|
||||||
if on_error:
|
if on_error:
|
||||||
self._on_ui(lambda: on_error(exc))
|
self._on_ui(lambda error=exc: on_error(error))
|
||||||
return
|
return
|
||||||
if on_success:
|
if on_success:
|
||||||
self._on_ui(lambda: on_success(result))
|
self._on_ui(lambda: on_success(result))
|
||||||
|
|||||||
@@ -106,6 +106,43 @@ class PlaybackManager(QObject):
|
|||||||
self.queueChanged.emit(self.queue.copy())
|
self.queueChanged.emit(self.queue.copy())
|
||||||
self.playbackStateChanged.emit("stopped")
|
self.playbackStateChanged.emit("stopped")
|
||||||
|
|
||||||
|
def removeIndex(self, index: int) -> None:
|
||||||
|
"""Remove a track, preserving shuffle order and selecting a replacement."""
|
||||||
|
if not 0 <= index < len(self.queue):
|
||||||
|
return
|
||||||
|
oldCurrent = self.currentIndex
|
||||||
|
oldPosition = self._shufflePosition
|
||||||
|
state = self.player.playbackState()
|
||||||
|
del self.queue[index]
|
||||||
|
if not self.queue:
|
||||||
|
self.clearQueue()
|
||||||
|
self.player.setSource(QUrl())
|
||||||
|
return
|
||||||
|
|
||||||
|
self._shuffleOrder = [
|
||||||
|
i - 1 if i > index else i
|
||||||
|
for i in self._shuffleOrder if i != index
|
||||||
|
]
|
||||||
|
if index == oldCurrent:
|
||||||
|
if self.shuffleEnabled and self._shuffleOrder:
|
||||||
|
position = min(max(0, oldPosition), len(self._shuffleOrder) - 1)
|
||||||
|
target = self._shuffleOrder[position]
|
||||||
|
else:
|
||||||
|
target = min(index, len(self.queue) - 1)
|
||||||
|
self.playIndex(target)
|
||||||
|
if state == QMediaPlayer.PausedState:
|
||||||
|
self.player.pause()
|
||||||
|
elif state == QMediaPlayer.StoppedState:
|
||||||
|
self.player.stop()
|
||||||
|
else:
|
||||||
|
if index < oldCurrent:
|
||||||
|
self.currentIndex -= 1
|
||||||
|
if self.currentIndex >= 0:
|
||||||
|
self._syncShufflePosition(self.currentIndex)
|
||||||
|
else:
|
||||||
|
self._shufflePosition = -1
|
||||||
|
self.queueChanged.emit(self.queue.copy())
|
||||||
|
|
||||||
def playIndex(self, index: int) -> None:
|
def playIndex(self, index: int) -> None:
|
||||||
"""Play the song at the given queue index."""
|
"""Play the song at the given queue index."""
|
||||||
if not (0 <= index < len(self.queue)):
|
if not (0 <= index < len(self.queue)):
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ Accessible search dialog for finding tracks quickly.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import QPoint, Signal, Qt
|
from PySide6.QtCore import QPoint, Signal, Slot, Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
@@ -31,10 +32,16 @@ class SearchDialog(QDialog):
|
|||||||
"""Search dialog that mirrors the library tree interactions."""
|
"""Search dialog that mirrors the library tree interactions."""
|
||||||
|
|
||||||
songActivated = Signal(Song)
|
songActivated = Signal(Song)
|
||||||
|
_taskFinished = Signal(object)
|
||||||
|
|
||||||
def __init__(self, client: SubsonicClient, parent=None):
|
def __init__(self, client: SubsonicClient, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.client = client
|
self.client = client
|
||||||
|
self._executor = ThreadPoolExecutor(max_workers=2)
|
||||||
|
self._generation = 0
|
||||||
|
self._closed = False
|
||||||
|
self._taskFinished.connect(self._deliverTask, Qt.QueuedConnection)
|
||||||
|
self.finished.connect(self._stopTasks)
|
||||||
self.setWindowTitle("Search Library")
|
self.setWindowTitle("Search Library")
|
||||||
self.setMinimumSize(500, 420)
|
self.setMinimumSize(500, 420)
|
||||||
self.setupUi()
|
self.setupUi()
|
||||||
@@ -106,26 +113,74 @@ class SearchDialog(QDialog):
|
|||||||
self.resultsTree.clear()
|
self.resultsTree.clear()
|
||||||
self._resultsLoaded = False
|
self._resultsLoaded = False
|
||||||
|
|
||||||
try:
|
self._generation += 1
|
||||||
mode = self.filterSelect.currentText()
|
mode = self.filterSelect.currentText()
|
||||||
if mode == "Genres":
|
|
||||||
songs = self.client.getSongsByGenre(query, count=200)
|
|
||||||
self.populateSongResults(songs, f"Songs in genre '{query}'")
|
|
||||||
else:
|
|
||||||
artistCount = 40 if mode in ("All", "Artists") else 0
|
|
||||||
albumCount = 40 if mode in ("All", "Albums") else 0
|
|
||||||
songCount = 80 if mode in ("All", "Songs") else 40
|
|
||||||
results = self.client.search(query, artistCount=artistCount, albumCount=albumCount, songCount=songCount)
|
|
||||||
self.populateResults(results)
|
|
||||||
|
|
||||||
|
def fetch():
|
||||||
|
if mode == "Genres":
|
||||||
|
return self.client.getSongsByGenre(query, count=200)
|
||||||
|
return self.client.search(
|
||||||
|
query, artistCount=40 if mode in ("All", "Artists") else 0,
|
||||||
|
albumCount=40 if mode in ("All", "Albums") else 0,
|
||||||
|
songCount=80 if mode in ("All", "Songs") else 40,
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply(results):
|
||||||
|
if mode == "Genres":
|
||||||
|
self.populateSongResults(results, f"Songs in genre '{query}'")
|
||||||
|
else:
|
||||||
|
self.populateResults(results)
|
||||||
if self._resultsLoaded:
|
if self._resultsLoaded:
|
||||||
self._focusFirstResult()
|
self._focusFirstResult()
|
||||||
self.statusLabel.setText("Search complete. Use the tree to play or queue items.")
|
self.statusLabel.setText("Search complete. Use the tree to play or queue items.")
|
||||||
else:
|
else:
|
||||||
self.statusLabel.setText("No results found.")
|
self.statusLabel.setText("No results found.")
|
||||||
except Exception as e:
|
|
||||||
AccessibleTextDialog.showError("Search Error", str(e), parent=self)
|
def failed(error):
|
||||||
self.statusLabel.setText("Search failed.")
|
self.statusLabel.setText("Search failed.")
|
||||||
|
AccessibleTextDialog.showError("Search Error", str(error), parent=self)
|
||||||
|
|
||||||
|
self._runTask(fetch, apply, failed)
|
||||||
|
|
||||||
|
def _runTask(self, fetch, apply, failed):
|
||||||
|
"""Run network work off-thread; discard results from older searches."""
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
generation = self._generation
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
return fetch()
|
||||||
|
finally:
|
||||||
|
db = getattr(self.client, "db", None)
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def completed(future):
|
||||||
|
if future.cancelled():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
result = future.result()
|
||||||
|
callback = lambda: apply(result)
|
||||||
|
except Exception as error:
|
||||||
|
callback = lambda error=error: failed(error)
|
||||||
|
try:
|
||||||
|
self._taskFinished.emit((generation, callback))
|
||||||
|
except RuntimeError:
|
||||||
|
pass # The dialog may have been destroyed while fetching.
|
||||||
|
|
||||||
|
self._executor.submit(work).add_done_callback(completed)
|
||||||
|
|
||||||
|
@Slot(object)
|
||||||
|
def _deliverTask(self, payload):
|
||||||
|
generation, callback = payload
|
||||||
|
if not self._closed and generation == self._generation:
|
||||||
|
callback()
|
||||||
|
|
||||||
|
def _stopTasks(self, result):
|
||||||
|
self._closed = True
|
||||||
|
self._generation += 1
|
||||||
|
self._executor.shutdown(wait=False, cancel_futures=True)
|
||||||
|
|
||||||
def _focusFirstResult(self):
|
def _focusFirstResult(self):
|
||||||
"""Move focus to the first result item for immediate keyboard navigation."""
|
"""Move focus to the first result item for immediate keyboard navigation."""
|
||||||
@@ -196,10 +251,6 @@ class SearchDialog(QDialog):
|
|||||||
if itemType == "song":
|
if itemType == "song":
|
||||||
song: Song = data.get("song")
|
song: Song = data.get("song")
|
||||||
if song:
|
if song:
|
||||||
if hasattr(parentWindow, "playback"):
|
|
||||||
parentWindow.playback.enqueue(song, playNow=True)
|
|
||||||
if hasattr(parentWindow, "enablePlaybackActions"):
|
|
||||||
parentWindow.enablePlaybackActions(True)
|
|
||||||
self.songActivated.emit(song)
|
self.songActivated.emit(song)
|
||||||
self.accept()
|
self.accept()
|
||||||
elif itemType == "album":
|
elif itemType == "album":
|
||||||
@@ -218,35 +269,46 @@ class SearchDialog(QDialog):
|
|||||||
def onItemExpanded(self, item: QTreeWidgetItem):
|
def onItemExpanded(self, item: QTreeWidgetItem):
|
||||||
"""Lazy-load children for artist or album results."""
|
"""Lazy-load children for artist or album results."""
|
||||||
data: Dict = item.data(0, Qt.UserRole) or {}
|
data: Dict = item.data(0, Qt.UserRole) or {}
|
||||||
if data.get("loaded") or not self.client:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
item.takeChildren()
|
|
||||||
itemType = data.get("type")
|
itemType = data.get("type")
|
||||||
|
if (data.get("loaded") or data.get("loading") or not self.client
|
||||||
|
or itemType not in ("artist", "album")):
|
||||||
|
return
|
||||||
|
itemId = data.get("id")
|
||||||
|
data["loading"] = True
|
||||||
|
item.setData(0, Qt.UserRole, data)
|
||||||
|
|
||||||
|
def fetch():
|
||||||
if itemType == "artist":
|
if itemType == "artist":
|
||||||
artistData = self.client.getArtist(data.get("id"))
|
return self.client.getArtist(itemId)
|
||||||
for album in artistData.get("albums", []):
|
return self.client.getAlbum(itemId)
|
||||||
|
|
||||||
|
def apply(result):
|
||||||
|
item.takeChildren()
|
||||||
|
if itemType == "artist":
|
||||||
|
for album in result.get("albums", []):
|
||||||
label = f"{album.name} ({album.songCount} tracks)"
|
label = f"{album.name} ({album.songCount} tracks)"
|
||||||
albumItem = QTreeWidgetItem([label])
|
child = QTreeWidgetItem([label])
|
||||||
albumItem.setData(0, Qt.UserRole, {"type": "album", "id": album.id, "album": album, "loaded": False})
|
child.setData(0, Qt.UserRole, {"type": "album", "id": album.id, "album": album, "loaded": False})
|
||||||
albumItem.setData(0, Qt.AccessibleTextRole, label)
|
child.setData(0, Qt.AccessibleTextRole, label)
|
||||||
albumItem.addChild(QTreeWidgetItem(["Loading..."]))
|
child.addChild(QTreeWidgetItem(["Loading..."]))
|
||||||
item.addChild(albumItem)
|
item.addChild(child)
|
||||||
elif itemType == "album":
|
else:
|
||||||
albumData = self.client.getAlbum(data.get("id"))
|
for song in result.get("songs", []):
|
||||||
for song in albumData.get("songs", []):
|
|
||||||
text = f"{song.title} — {song.artist} ({song.durationFormatted})"
|
text = f"{song.title} — {song.artist} ({song.durationFormatted})"
|
||||||
child = QTreeWidgetItem([text])
|
child = QTreeWidgetItem([text])
|
||||||
child.setData(0, Qt.UserRole, {"type": "song", "song": song})
|
child.setData(0, Qt.UserRole, {"type": "song", "song": song})
|
||||||
child.setData(0, Qt.AccessibleTextRole, text)
|
child.setData(0, Qt.AccessibleTextRole, text)
|
||||||
item.addChild(child)
|
item.addChild(child)
|
||||||
data["loaded"] = True
|
data.update(loaded=True, loading=False)
|
||||||
item.setData(0, Qt.UserRole, data)
|
item.setData(0, Qt.UserRole, data)
|
||||||
# Update accessibility for newly added children
|
self.resultsTree.updateChildAccessibility(item, item.isExpanded())
|
||||||
self.resultsTree.updateChildAccessibility(item, True)
|
|
||||||
except Exception as e:
|
def failed(error):
|
||||||
AccessibleTextDialog.showError("Search Error", str(e), parent=self)
|
data["loading"] = False
|
||||||
|
item.setData(0, Qt.UserRole, data)
|
||||||
|
AccessibleTextDialog.showError("Search Error", str(error), parent=self)
|
||||||
|
|
||||||
|
self._runTask(fetch, apply, failed)
|
||||||
|
|
||||||
def showContextMenu(self, position: QPoint):
|
def showContextMenu(self, position: QPoint):
|
||||||
"""Show a context menu with play/queue/favorite actions for search results."""
|
"""Show a context menu with play/queue/favorite actions for search results."""
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class FakeClient:
|
|||||||
"""Minimal fake SubsonicClient for incremental sync tests."""
|
"""Minimal fake SubsonicClient for incremental sync tests."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self.username = "test"
|
||||||
self.serverUrl = "https://fake.example"
|
self.serverUrl = "https://fake.example"
|
||||||
self._artists = [
|
self._artists = [
|
||||||
Artist(id="a1", name="Artist One", albumCount=1),
|
Artist(id="a1", name="Artist One", albumCount=1),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ def test_update_starred_sets_and_clears(tmp_path: Path):
|
|||||||
def test_playlist_changed_triggers_refetch(tmp_path: Path):
|
def test_playlist_changed_triggers_refetch(tmp_path: Path):
|
||||||
class Client:
|
class Client:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self.username = "test"
|
||||||
self.serverUrl = "https://fake.example"
|
self.serverUrl = "https://fake.example"
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
self._playlists = [
|
self._playlists = [
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
"""Regression coverage for background work, cache isolation, and playback."""
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PySide6.QtCore import Qt
|
||||||
|
from PySide6.QtMultimedia import QMediaPlayer
|
||||||
|
from PySide6.QtWidgets import QApplication, QWidget, QTreeWidgetItem
|
||||||
|
|
||||||
|
from src.api.client import SubsonicClient
|
||||||
|
from src.api.models import Song, Playlist
|
||||||
|
from src.cache import CachedClient, CacheDatabase, CacheManager
|
||||||
|
from src.main_window import MainWindow
|
||||||
|
from src.managers.playback_manager import PlaybackManager
|
||||||
|
from src.widgets.search_dialog import SearchDialog
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
return QApplication.instance() or QApplication([])
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for(app, predicate):
|
||||||
|
deadline = time.monotonic() + 3
|
||||||
|
while not predicate() and time.monotonic() < deadline:
|
||||||
|
app.processEvents()
|
||||||
|
time.sleep(0.005)
|
||||||
|
assert predicate()
|
||||||
|
|
||||||
|
|
||||||
|
def song(identifier):
|
||||||
|
return Song(id=identifier, title=identifier, album="Album", albumId="a", artist="Artist")
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_error_survives_until_ui_delivery(app):
|
||||||
|
callbacks, errors = [], []
|
||||||
|
with ThreadPoolExecutor(1) as executor:
|
||||||
|
harness = SimpleNamespace(_executor=executor, logger=logging.getLogger("test"),
|
||||||
|
_on_ui=callbacks.append)
|
||||||
|
def fail():
|
||||||
|
raise ValueError("synthetic failure")
|
||||||
|
MainWindow._run_background(harness, "test", fail, on_error=errors.append)
|
||||||
|
assert len(callbacks) == 1
|
||||||
|
callbacks[0]()
|
||||||
|
assert str(errors[0]) == "synthetic failure"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("refresh", ["forced", "full", "incremental"])
|
||||||
|
def test_refresh_bypasses_api_memory_cache(tmp_path, refresh):
|
||||||
|
raw = SubsonicClient("https://fake.example", "user", "fake")
|
||||||
|
state = {"name": "Old"}
|
||||||
|
def request(endpoint, params=None):
|
||||||
|
if endpoint == "getArtists":
|
||||||
|
return {"artists": {"index": [{"artist": [{"id": "a", "name": state["name"], "albumCount": 0}]}]}}
|
||||||
|
if endpoint == "getArtist":
|
||||||
|
return {"artist": {"id": "a", "name": state["name"]}}
|
||||||
|
return {}
|
||||||
|
raw._makeRequest = Mock(side_effect=request)
|
||||||
|
cached = CachedClient(raw, tmp_path)
|
||||||
|
try:
|
||||||
|
assert cached.getArtists()[0].name == "Old"
|
||||||
|
state["name"] = "New"
|
||||||
|
if refresh == "forced":
|
||||||
|
result = cached.getArtists(forceRefresh=True)
|
||||||
|
else:
|
||||||
|
getattr(cached, "syncFull" if refresh == "full" else "syncIncremental")()
|
||||||
|
result = cached.getArtists()
|
||||||
|
assert result[0].name == "New"
|
||||||
|
finally:
|
||||||
|
cached.db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_accounts_have_separate_persistent_caches(tmp_path):
|
||||||
|
first = CachedClient(SubsonicClient("https://fake.example", "alice", "fake"), tmp_path)
|
||||||
|
second = CachedClient(SubsonicClient("https://fake.example", "bob", "fake"), tmp_path)
|
||||||
|
again = CachedClient(SubsonicClient("https://fake.example", "alice", "new password"), tmp_path)
|
||||||
|
try:
|
||||||
|
first.cache.setPlaylist(Playlist(id="p", name="Private", songs=[song("s")]))
|
||||||
|
assert second.cache.getPlaylists() == []
|
||||||
|
assert again.cache.getPlaylist("p").name == "Private"
|
||||||
|
assert first.db.dbPath != second.db.dbPath
|
||||||
|
finally:
|
||||||
|
for client in (first, second, again):
|
||||||
|
client.db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_playlist_metadata_preserves_order_and_empty_detail_clears(tmp_path):
|
||||||
|
db = CacheDatabase("https://fake.example", tmp_path)
|
||||||
|
cache = CacheManager(db)
|
||||||
|
try:
|
||||||
|
cache.setPlaylist(Playlist(id="p", name="Before", songs=[song("b"), song("a"), song("b")]))
|
||||||
|
cache.setPlaylists([Playlist(id="p", name="After", songCount=3)])
|
||||||
|
assert [s.id for s in cache.getPlaylist("p").songs] == ["b", "a", "b"]
|
||||||
|
assert cache.getPlaylist("p").name == "After"
|
||||||
|
cache.setPlaylist(Playlist(id="p", name="Empty", songs=[]))
|
||||||
|
assert cache.getPlaylist("p").songs == []
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_activation_enqueues_once(app):
|
||||||
|
parent = QWidget()
|
||||||
|
parent.playback = SimpleNamespace(enqueue=Mock())
|
||||||
|
parent.enablePlaybackActions = Mock()
|
||||||
|
parent.statusBar = SimpleNamespace(showMessage=Mock())
|
||||||
|
dialog = SearchDialog(Mock(), parent)
|
||||||
|
dialog.songActivated.connect(lambda s: MainWindow.onSearchSongChosen(parent, s))
|
||||||
|
chosen = song("s")
|
||||||
|
item = QTreeWidgetItem(["song"])
|
||||||
|
item.setData(0, Qt.UserRole, {"type": "song", "song": chosen})
|
||||||
|
dialog.handleActivate(item)
|
||||||
|
parent.playback.enqueue.assert_called_once_with(chosen, playNow=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def playback(app, monkeypatch):
|
||||||
|
# Exercise the real manager with a fake media backend; no audio or network.
|
||||||
|
import src.managers.playback_manager as module
|
||||||
|
factory = Mock()
|
||||||
|
factory.PlayingState = QMediaPlayer.PlayingState
|
||||||
|
factory.PausedState = QMediaPlayer.PausedState
|
||||||
|
factory.StoppedState = QMediaPlayer.StoppedState
|
||||||
|
monkeypatch.setattr(module, "QMediaPlayer", factory)
|
||||||
|
monkeypatch.setattr(module, "QAudioOutput", Mock())
|
||||||
|
manager = PlaybackManager()
|
||||||
|
manager.player.playbackState.return_value = QMediaPlayer.PlayingState
|
||||||
|
manager.setStreamResolver(lambda s: "https://fake.example/" + s.id)
|
||||||
|
manager.queue = [song(str(i)) for i in range(4)]
|
||||||
|
manager.currentIndex = 1
|
||||||
|
manager.shuffleEnabled = True
|
||||||
|
manager._shuffleOrder = [2, 1, 3, 0]
|
||||||
|
manager._shufflePosition = 1
|
||||||
|
return manager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("removed", [0, 2, 3])
|
||||||
|
def test_queue_removal_preserves_current_and_valid_shuffle(playback, removed):
|
||||||
|
current = playback.currentSong()
|
||||||
|
playback.removeIndex(removed)
|
||||||
|
assert playback.currentSong() is current
|
||||||
|
assert sorted(playback._shuffleOrder) == list(range(3))
|
||||||
|
assert playback._shuffleOrder[playback._shufflePosition] == playback.currentIndex
|
||||||
|
playback.player.setSource.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("state", [QMediaPlayer.PlayingState, QMediaPlayer.PausedState, QMediaPlayer.StoppedState])
|
||||||
|
def test_removing_current_selects_shuffle_successor(playback, state):
|
||||||
|
playback.player.playbackState.return_value = state
|
||||||
|
changes = []
|
||||||
|
playback.trackChanged.connect(changes.append)
|
||||||
|
playback.removeIndex(1)
|
||||||
|
assert playback.currentSong().id == "3"
|
||||||
|
assert changes[-1].id == "3"
|
||||||
|
assert sorted(playback._shuffleOrder) == [0, 1, 2]
|
||||||
|
if state == QMediaPlayer.PausedState:
|
||||||
|
playback.player.pause.assert_called_once()
|
||||||
|
elif state == QMediaPlayer.StoppedState:
|
||||||
|
playback.player.stop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_removing_last_track_clears_source(playback):
|
||||||
|
playback.queue = [song("only")]
|
||||||
|
playback.currentIndex = 0
|
||||||
|
playback.removeIndex(0)
|
||||||
|
assert playback.currentSong() is None
|
||||||
|
assert playback.queue == []
|
||||||
|
assert playback._shuffleOrder == []
|
||||||
|
assert playback.player.setSource.call_args.args[0].isEmpty()
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_is_nonblocking_and_discards_old_results(app):
|
||||||
|
release = threading.Event()
|
||||||
|
started = threading.Event()
|
||||||
|
def search(query, **kwargs):
|
||||||
|
if query == "old":
|
||||||
|
started.set()
|
||||||
|
assert release.wait(3)
|
||||||
|
return {"songs": [song(query)]}
|
||||||
|
dialog = SearchDialog(SimpleNamespace(search=search))
|
||||||
|
try:
|
||||||
|
dialog.queryEdit.setText("old")
|
||||||
|
dialog.runSearch()
|
||||||
|
assert started.wait(1)
|
||||||
|
dialog.queryEdit.setText("new")
|
||||||
|
dialog.runSearch()
|
||||||
|
wait_for(app, lambda: dialog._resultsLoaded)
|
||||||
|
assert dialog.resultsTree.topLevelItem(0).child(0).data(0, Qt.UserRole)["song"].id == "new"
|
||||||
|
release.set()
|
||||||
|
dialog._executor.shutdown(wait=True)
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.resultsTree.topLevelItem(0).child(0).data(0, Qt.UserRole)["song"].id == "new"
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
dialog.reject()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalidate", ["search", "close"])
|
||||||
|
def test_expansion_results_are_ignored_after_search_or_close(app, invalidate):
|
||||||
|
release = threading.Event()
|
||||||
|
started = threading.Event()
|
||||||
|
def get_album(identifier):
|
||||||
|
started.set()
|
||||||
|
assert release.wait(3)
|
||||||
|
return {"songs": [song("late")]}
|
||||||
|
client = SimpleNamespace(getAlbum=get_album, search=lambda *args, **kw: {})
|
||||||
|
dialog = SearchDialog(client)
|
||||||
|
item = QTreeWidgetItem(["album"])
|
||||||
|
item.setData(0, Qt.UserRole, {"type": "album", "id": "a", "loaded": False})
|
||||||
|
dialog.resultsTree.addTopLevelItem(item)
|
||||||
|
try:
|
||||||
|
dialog.onItemExpanded(item)
|
||||||
|
assert started.wait(1)
|
||||||
|
if invalidate == "search":
|
||||||
|
dialog.queryEdit.setText("new")
|
||||||
|
dialog.runSearch()
|
||||||
|
else:
|
||||||
|
dialog.reject()
|
||||||
|
release.set()
|
||||||
|
dialog._executor.shutdown(wait=True)
|
||||||
|
app.processEvents()
|
||||||
|
assert dialog.resultsTree.topLevelItemCount() == (0 if invalidate == "search" else 1)
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
dialog.reject()
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchanged_playlist_keeps_tracks_across_incremental_sync(tmp_path):
|
||||||
|
from test_cache_basic import FakeClient
|
||||||
|
client = CachedClient(FakeClient(), tmp_path)
|
||||||
|
try:
|
||||||
|
client.syncIncremental()
|
||||||
|
client.syncIncremental()
|
||||||
|
assert [s.id for s in client.cache.getPlaylist("p1").songs] == ["s1"]
|
||||||
|
finally:
|
||||||
|
client.db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("index, expected", [(1, "2"), (3, "2")])
|
||||||
|
def test_removing_current_without_shuffle(playback, index, expected):
|
||||||
|
playback.setShuffle(False)
|
||||||
|
playback.currentIndex = index
|
||||||
|
playback.removeIndex(index)
|
||||||
|
assert playback.currentSong().id == expected
|
||||||
|
assert playback._shuffleOrder == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_expansion_fetches_off_thread_and_updates_ui(app):
|
||||||
|
main_thread = threading.get_ident()
|
||||||
|
worker_threads = []
|
||||||
|
def get_album(identifier):
|
||||||
|
worker_threads.append(threading.get_ident())
|
||||||
|
return {"songs": [song("loaded")]}
|
||||||
|
dialog = SearchDialog(SimpleNamespace(getAlbum=get_album))
|
||||||
|
item = QTreeWidgetItem(["album"])
|
||||||
|
item.setData(0, Qt.UserRole, {"type": "album", "id": "a", "loaded": False})
|
||||||
|
dialog.resultsTree.addTopLevelItem(item)
|
||||||
|
try:
|
||||||
|
dialog.onItemExpanded(item)
|
||||||
|
wait_for(app, lambda: item.data(0, Qt.UserRole).get("loaded"))
|
||||||
|
assert item.child(0).data(0, Qt.UserRole)["song"].id == "loaded"
|
||||||
|
assert worker_threads[0] != main_thread
|
||||||
|
finally:
|
||||||
|
dialog.reject()
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_error_delivered_on_ui_thread(app, monkeypatch):
|
||||||
|
from src.widgets.accessible_text_dialog import AccessibleTextDialog
|
||||||
|
errors = []
|
||||||
|
monkeypatch.setattr(AccessibleTextDialog, "showError",
|
||||||
|
lambda title, message, parent: errors.append((message, threading.get_ident())))
|
||||||
|
def fail(*args, **kwargs):
|
||||||
|
raise ValueError("synthetic search failure")
|
||||||
|
dialog = SearchDialog(SimpleNamespace(search=fail))
|
||||||
|
try:
|
||||||
|
dialog.queryEdit.setText("query")
|
||||||
|
dialog.runSearch()
|
||||||
|
wait_for(app, lambda: bool(errors))
|
||||||
|
assert errors == [("synthetic search failure", threading.get_ident())]
|
||||||
|
assert dialog.statusLabel.text() == "Search failed."
|
||||||
|
finally:
|
||||||
|
dialog.reject()
|
||||||
Reference in new issue
Block a user