Few bug fixes, new PKGBUILD added.

This commit is contained in:
Storm Dragon committed 2026-09-15 14:21:34 -04:00
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
+8 -3
View File
@@ -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:
+19 -1
View File
@@ -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:
+5 -3
View File
@@ -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
View File
@@ -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))
+37
View File
@@ -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)):
+99 -37
View File
@@ -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."""
+1
View File
@@ -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),
+1
View File
@@ -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 = [
+285
View File
@@ -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()