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()
|
||||
conn = self.db.connection
|
||||
conn.executemany(
|
||||
"""INSERT OR REPLACE INTO playlists
|
||||
"""INSERT INTO playlists
|
||||
(id, name, song_count, duration, public, owner, created, changed,
|
||||
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),
|
||||
@@ -398,7 +404,6 @@ class CacheManager:
|
||||
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:
|
||||
|
||||
Vendored
+19
-1
@@ -50,7 +50,7 @@ class CachedClient:
|
||||
self._shutdownRequested = False
|
||||
|
||||
# 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)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
@@ -176,6 +176,8 @@ class CachedClient:
|
||||
|
||||
def getArtists(self, musicFolderId: str = None, forceRefresh: bool = False) -> List[Artist]:
|
||||
"""Get all artists, using cache if available."""
|
||||
if forceRefresh:
|
||||
self.client.clearCache()
|
||||
if not forceRefresh:
|
||||
cached = self.cache.getArtists()
|
||||
if cached:
|
||||
@@ -189,6 +191,8 @@ class CachedClient:
|
||||
|
||||
def getArtist(self, artistId: str, forceRefresh: bool = False) -> Dict[str, Any]:
|
||||
"""Get artist details including albums."""
|
||||
if forceRefresh:
|
||||
self.client.clearCache()
|
||||
if not forceRefresh:
|
||||
cachedArtist = self.cache.getArtist(artistId)
|
||||
cachedAlbums = self.cache.getAlbums(artistId=artistId)
|
||||
@@ -205,6 +209,8 @@ class CachedClient:
|
||||
|
||||
def getAlbum(self, albumId: str, forceRefresh: bool = False) -> Dict[str, Any]:
|
||||
"""Get album details including songs."""
|
||||
if forceRefresh:
|
||||
self.client.clearCache()
|
||||
if not forceRefresh:
|
||||
cachedAlbum = self.cache.getAlbum(albumId)
|
||||
cachedSongs = self.cache.getSongs(albumId=albumId)
|
||||
@@ -221,6 +227,8 @@ class CachedClient:
|
||||
|
||||
def getSong(self, songId: str, forceRefresh: bool = False) -> Song:
|
||||
"""Get song details."""
|
||||
if forceRefresh:
|
||||
self.client.clearCache()
|
||||
if not forceRefresh:
|
||||
cached = self.cache.getSong(songId)
|
||||
if cached:
|
||||
@@ -233,6 +241,8 @@ class CachedClient:
|
||||
|
||||
def getGenres(self, forceRefresh: bool = False) -> List[Genre]:
|
||||
"""Get all genres."""
|
||||
if forceRefresh:
|
||||
self.client.clearCache()
|
||||
if not forceRefresh:
|
||||
cached = self.cache.getGenres()
|
||||
if cached:
|
||||
@@ -269,6 +279,8 @@ class CachedClient:
|
||||
|
||||
def getPlaylists(self, forceRefresh: bool = False) -> List[Playlist]:
|
||||
"""Get all playlists."""
|
||||
if forceRefresh:
|
||||
self.client.clearCache()
|
||||
if not forceRefresh:
|
||||
cached = self.cache.getPlaylists()
|
||||
if cached:
|
||||
@@ -282,6 +294,8 @@ class CachedClient:
|
||||
|
||||
def getPlaylist(self, playlistId: str, forceRefresh: bool = False) -> Playlist:
|
||||
"""Get playlist details including songs."""
|
||||
if forceRefresh:
|
||||
self.client.clearCache()
|
||||
if not forceRefresh:
|
||||
cached = self.cache.getPlaylist(playlistId)
|
||||
if cached and cached.songs:
|
||||
@@ -294,6 +308,8 @@ class CachedClient:
|
||||
|
||||
def getStarred(self, forceRefresh: bool = False) -> Dict[str, List]:
|
||||
"""Get all starred items."""
|
||||
if forceRefresh:
|
||||
self.client.clearCache()
|
||||
if not forceRefresh:
|
||||
cached = self.cache.getStarred()
|
||||
if any(cached.values()):
|
||||
@@ -339,6 +355,7 @@ class CachedClient:
|
||||
self.logger.info("Starting incremental sync")
|
||||
self._shutdownRequested = False
|
||||
self._resetThrottle()
|
||||
self.client.clearCache()
|
||||
stats = {"artists": 0, "albums": 0, "songs": 0, "playlists": 0, "genres": 0}
|
||||
|
||||
try:
|
||||
@@ -450,6 +467,7 @@ class CachedClient:
|
||||
self.logger.info("Starting full sync")
|
||||
self._shutdownRequested = False
|
||||
self._resetThrottle()
|
||||
self.client.clearCache()
|
||||
stats = {"artists": 0, "albums": 0, "songs": 0, "playlists": 0, "genres": 0}
|
||||
|
||||
try:
|
||||
|
||||
Vendored
+5
-3
@@ -5,6 +5,7 @@ SQLite database connection and schema management for the cache layer.
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
@@ -132,16 +133,17 @@ class CacheDatabase:
|
||||
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.
|
||||
|
||||
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
|
||||
"""
|
||||
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._localConn = threading.local()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
+6
-8
@@ -1529,6 +1529,10 @@ class MainWindow(QMainWindow):
|
||||
self.queueList.blockSignals(False)
|
||||
|
||||
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.removeQueueButton.setEnabled(hasItems)
|
||||
self.clearQueueButton.setEnabled(hasItems)
|
||||
@@ -1547,13 +1551,7 @@ class MainWindow(QMainWindow):
|
||||
row = self.queueList.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
if 0 <= row < len(self.playback.queue):
|
||||
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())
|
||||
self.playback.removeIndex(row)
|
||||
|
||||
def clearQueue(self):
|
||||
"""Clear the entire queue."""
|
||||
@@ -1975,7 +1973,7 @@ class MainWindow(QMainWindow):
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.logger.error("Background task '%s' failed: %s", label, exc)
|
||||
if on_error:
|
||||
self._on_ui(lambda: on_error(exc))
|
||||
self._on_ui(lambda error=exc: on_error(error))
|
||||
return
|
||||
if on_success:
|
||||
self._on_ui(lambda: on_success(result))
|
||||
|
||||
@@ -106,6 +106,43 @@ class PlaybackManager(QObject):
|
||||
self.queueChanged.emit(self.queue.copy())
|
||||
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:
|
||||
"""Play the song at the given queue index."""
|
||||
if not (0 <= index < len(self.queue)):
|
||||
|
||||
@@ -4,9 +4,10 @@ Accessible search dialog for finding tracks quickly.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
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 (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
@@ -31,10 +32,16 @@ class SearchDialog(QDialog):
|
||||
"""Search dialog that mirrors the library tree interactions."""
|
||||
|
||||
songActivated = Signal(Song)
|
||||
_taskFinished = Signal(object)
|
||||
|
||||
def __init__(self, client: SubsonicClient, parent=None):
|
||||
super().__init__(parent)
|
||||
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.setMinimumSize(500, 420)
|
||||
self.setupUi()
|
||||
@@ -106,26 +113,74 @@ class SearchDialog(QDialog):
|
||||
self.resultsTree.clear()
|
||||
self._resultsLoaded = False
|
||||
|
||||
try:
|
||||
self._generation += 1
|
||||
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:
|
||||
self._focusFirstResult()
|
||||
self.statusLabel.setText("Search complete. Use the tree to play or queue items.")
|
||||
else:
|
||||
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.")
|
||||
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):
|
||||
"""Move focus to the first result item for immediate keyboard navigation."""
|
||||
@@ -196,10 +251,6 @@ class SearchDialog(QDialog):
|
||||
if itemType == "song":
|
||||
song: Song = data.get("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.accept()
|
||||
elif itemType == "album":
|
||||
@@ -218,35 +269,46 @@ class SearchDialog(QDialog):
|
||||
def onItemExpanded(self, item: QTreeWidgetItem):
|
||||
"""Lazy-load children for artist or album results."""
|
||||
data: Dict = item.data(0, Qt.UserRole) or {}
|
||||
if data.get("loaded") or not self.client:
|
||||
return
|
||||
|
||||
try:
|
||||
item.takeChildren()
|
||||
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":
|
||||
artistData = self.client.getArtist(data.get("id"))
|
||||
for album in artistData.get("albums", []):
|
||||
return self.client.getArtist(itemId)
|
||||
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)"
|
||||
albumItem = QTreeWidgetItem([label])
|
||||
albumItem.setData(0, Qt.UserRole, {"type": "album", "id": album.id, "album": album, "loaded": False})
|
||||
albumItem.setData(0, Qt.AccessibleTextRole, label)
|
||||
albumItem.addChild(QTreeWidgetItem(["Loading..."]))
|
||||
item.addChild(albumItem)
|
||||
elif itemType == "album":
|
||||
albumData = self.client.getAlbum(data.get("id"))
|
||||
for song in albumData.get("songs", []):
|
||||
child = QTreeWidgetItem([label])
|
||||
child.setData(0, Qt.UserRole, {"type": "album", "id": album.id, "album": album, "loaded": False})
|
||||
child.setData(0, Qt.AccessibleTextRole, label)
|
||||
child.addChild(QTreeWidgetItem(["Loading..."]))
|
||||
item.addChild(child)
|
||||
else:
|
||||
for song in result.get("songs", []):
|
||||
text = f"{song.title} — {song.artist} ({song.durationFormatted})"
|
||||
child = QTreeWidgetItem([text])
|
||||
child.setData(0, Qt.UserRole, {"type": "song", "song": song})
|
||||
child.setData(0, Qt.AccessibleTextRole, text)
|
||||
item.addChild(child)
|
||||
data["loaded"] = True
|
||||
data.update(loaded=True, loading=False)
|
||||
item.setData(0, Qt.UserRole, data)
|
||||
# Update accessibility for newly added children
|
||||
self.resultsTree.updateChildAccessibility(item, True)
|
||||
except Exception as e:
|
||||
AccessibleTextDialog.showError("Search Error", str(e), parent=self)
|
||||
self.resultsTree.updateChildAccessibility(item, item.isExpanded())
|
||||
|
||||
def failed(error):
|
||||
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):
|
||||
"""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."""
|
||||
|
||||
def __init__(self):
|
||||
self.username = "test"
|
||||
self.serverUrl = "https://fake.example"
|
||||
self._artists = [
|
||||
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):
|
||||
class Client:
|
||||
def __init__(self):
|
||||
self.username = "test"
|
||||
self.serverUrl = "https://fake.example"
|
||||
now = datetime.now()
|
||||
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