diff --git a/distro-packages/ArchLinux/navipy-git/.gitignore b/distro-packages/ArchLinux/navipy-git/.gitignore new file mode 100644 index 0000000..0dc5680 --- /dev/null +++ b/distro-packages/ArchLinux/navipy-git/.gitignore @@ -0,0 +1,5 @@ +/navipy/ +/src/ +/pkg/ +/*.pkg.tar.* +/*.src.tar.* diff --git a/distro-packages/ArchLinux/navipy-git/PKGBUILD b/distro-packages/ArchLinux/navipy-git/PKGBUILD new file mode 100644 index 0000000..3c1d632 --- /dev/null +++ b/distro-packages/ArchLinux/navipy-git/PKGBUILD @@ -0,0 +1,51 @@ +# Maintainer: Storm Dragon +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" +} diff --git a/distro-packages/ArchLinux/navipy-git/navipy.desktop b/distro-packages/ArchLinux/navipy-git/navipy.desktop new file mode 100644 index 0000000..5eebcb2 --- /dev/null +++ b/distro-packages/ArchLinux/navipy-git/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 diff --git a/src/cache/cache_manager.py b/src/cache/cache_manager.py index 96b4e94..47f9c81 100644 --- a/src/cache/cache_manager.py +++ b/src/cache/cache_manager.py @@ -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,8 +404,7 @@ 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) + self.setPlaylistSongs(playlist.id, playlist.songs) def setPlaylistSongs(self, playlistId: str, songs: List[Song]) -> None: """Set the songs for a playlist (replaces existing).""" diff --git a/src/cache/cached_client.py b/src/cache/cached_client.py index 16b859d..c71e8c9 100644 --- a/src/cache/cached_client.py +++ b/src/cache/cached_client.py @@ -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: diff --git a/src/cache/database.py b/src/cache/database.py index 6b30a1f..7b2b1db 100644 --- a/src/cache/database.py +++ b/src/cache/database.py @@ -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() diff --git a/src/main_window.py b/src/main_window.py index 88ee950..a8c60e7 100644 --- a/src/main_window.py +++ b/src/main_window.py @@ -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)) diff --git a/src/managers/playback_manager.py b/src/managers/playback_manager.py index ba3dad9..d10e60d 100644 --- a/src/managers/playback_manager.py +++ b/src/managers/playback_manager.py @@ -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)): diff --git a/src/widgets/search_dialog.py b/src/widgets/search_dialog.py index 9f8f632..3a2a478 100644 --- a/src/widgets/search_dialog.py +++ b/src/widgets/search_dialog.py @@ -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: - 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) + self._generation += 1 + mode = self.filterSelect.currentText() + 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: + 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) - try: - item.takeChildren() - itemType = data.get("type") + 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.""" diff --git a/tests/test_cache_basic.py b/tests/test_cache_basic.py index fada7c4..aa24a3e 100644 --- a/tests/test_cache_basic.py +++ b/tests/test_cache_basic.py @@ -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), diff --git a/tests/test_cache_extra.py b/tests/test_cache_extra.py index 124330e..7539907 100644 --- a/tests/test_cache_extra.py +++ b/tests/test_cache_extra.py @@ -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 = [ diff --git a/tests/test_review_regressions.py b/tests/test_review_regressions.py new file mode 100644 index 0000000..3db4fb2 --- /dev/null +++ b/tests/test_review_regressions.py @@ -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()