summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pyproject.toml6
-rw-r--r--tests/conftest.py27
-rw-r--r--tests/test_delimiters_category.py9
-rw-r--r--tests/test_dialog_preferences_integration.py9
-rw-r--r--tests/test_settings_dialog_structure.py10
-rw-r--r--tests/test_settings_ui.py10
-rw-r--r--tidal_dl_ng/gui/dialog_playlist_manager.py3
-rw-r--r--tidal_dl_ng/gui/playlist_membership.py4
-rw-r--r--tidal_dl_ng/helper/playlist_api.py216
-rw-r--r--tox.ini3
10 files changed, 162 insertions, 135 deletions
diff --git a/pyproject.toml b/pyproject.toml
index 70f5ac1..d8199ec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -233,3 +233,9 @@ warn_unused_configs = true
warn_unused_ignores = true
disallow_untyped_defs = true
disallow_any_unimported = true
+
+[tool.deptry]
+# shiboken6 is a transitive dependency from PySide6 that we intentionally use directly
+# for type checking and widget deletion (Shiboken.isValid, Shiboken.delete)
+extend_exclude = []
+ignore_transitive = ["shiboken6"]
diff --git a/tests/conftest.py b/tests/conftest.py
index 1e6cfe3..c85c444 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,3 +1,6 @@
+import os
+import tempfile
+from pathlib import Path
from unittest.mock import Mock
import pytest
@@ -6,14 +9,24 @@ from tidalapi import Album, Track, Video
from tidalapi.artist import Artist
-@pytest.fixture
-def qt_app():
- """Create a QApplication instance for testing Qt widgets."""
- app = QtWidgets.QApplication.instance()
- if app is None:
- app = QtWidgets.QApplication([])
+@pytest.fixture(scope="session")
+def qapp():
+ """Provide a QApplication configured for headless CI (offscreen/minimal)."""
+ # Ensure headless backend for CI runners
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
+ # Qt expects XDG_RUNTIME_DIR to exist with correct permissions
+ runtime_dir = Path(tempfile.mkdtemp(prefix="xdg-runtime-")).resolve()
+ os.environ.setdefault("XDG_RUNTIME_DIR", str(runtime_dir))
+
+ # Lower graphical requirements
+ QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_DisableHighDpiScaling, True)
+
+ app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([])
yield app
- # Note: don't quit the app as it may be shared across tests
+
+
+# Backward compatible alias
+qt_app = qapp
@pytest.fixture
diff --git a/tests/test_delimiters_category.py b/tests/test_delimiters_category.py
index df5e950..8028e34 100644
--- a/tests/test_delimiters_category.py
+++ b/tests/test_delimiters_category.py
@@ -9,15 +9,6 @@ from tidal_dl_ng.dialog import DialogPreferences
from tidal_dl_ng.ui.dialog_settings import Ui_DialogSettings
-@pytest.fixture
-def qapp():
- """Create QApplication instance for tests."""
- app = QtWidgets.QApplication.instance()
- if app is None:
- app = QtWidgets.QApplication([])
- yield app
-
-
class TestDelimitersPage:
"""Test the Delimiters page specifically."""
diff --git a/tests/test_dialog_preferences_integration.py b/tests/test_dialog_preferences_integration.py
index b7df74d..1975a6e 100644
--- a/tests/test_dialog_preferences_integration.py
+++ b/tests/test_dialog_preferences_integration.py
@@ -10,15 +10,6 @@ from tidal_dl_ng.dialog import DialogPreferences
@pytest.fixture
-def qapp():
- """Create QApplication instance for tests."""
- app = QtWidgets.QApplication.instance()
- if app is None:
- app = QtWidgets.QApplication([])
- yield app
-
-
-@pytest.fixture
def mock_settings():
"""Create a mock Settings object with all required attributes."""
settings = Mock(spec=Settings)
diff --git a/tests/test_settings_dialog_structure.py b/tests/test_settings_dialog_structure.py
index 3c492a5..3c16238 100644
--- a/tests/test_settings_dialog_structure.py
+++ b/tests/test_settings_dialog_structure.py
@@ -1,20 +1,10 @@
"""Tests for the settings dialog category structure and organization."""
-import pytest
from PySide6 import QtCore, QtWidgets
from tidal_dl_ng.ui.dialog_settings import Ui_DialogSettings
-@pytest.fixture
-def qapp():
- """Create QApplication instance for tests."""
- app = QtWidgets.QApplication.instance()
- if app is None:
- app = QtWidgets.QApplication([])
- yield app
-
-
class TestSettingsDialogStructure:
"""Test the structural integrity of the settings dialog."""
diff --git a/tests/test_settings_ui.py b/tests/test_settings_ui.py
index e77a464..67957d5 100644
--- a/tests/test_settings_ui.py
+++ b/tests/test_settings_ui.py
@@ -10,16 +10,6 @@ from tidal_dl_ng.dialog import DialogPreferences
from tidal_dl_ng.ui.dialog_settings import Ui_DialogSettings
-@pytest.fixture
-def qapp():
- """Create QApplication instance for tests."""
- app = QtWidgets.QApplication.instance()
- if app is None:
- app = QtWidgets.QApplication([])
- yield app
- # Cleanup is handled by pytest-qt if available, otherwise by QApplication
-
-
class TestUiDialogSettings:
"""Test the generated UI class."""
diff --git a/tidal_dl_ng/gui/dialog_playlist_manager.py b/tidal_dl_ng/gui/dialog_playlist_manager.py
index 0066e72..f665543 100644
--- a/tidal_dl_ng/gui/dialog_playlist_manager.py
+++ b/tidal_dl_ng/gui/dialog_playlist_manager.py
@@ -316,8 +316,7 @@ class PlaylistManagerDialog(QtWidgets.QDialog):
TODO: Integrate with app's notification system (Toast/Snackbar)
"""
- # Silent - errors are shown via UI notifications
- pass
+ logger_gui.debug(f"PlaylistManagerDialog notification: {message}")
def closeEvent(self, event: QtGui.QCloseEvent) -> None:
"""Handle dialog close event.
diff --git a/tidal_dl_ng/gui/playlist_membership.py b/tidal_dl_ng/gui/playlist_membership.py
index 927c96b..31d9d2d 100644
--- a/tidal_dl_ng/gui/playlist_membership.py
+++ b/tidal_dl_ng/gui/playlist_membership.py
@@ -508,14 +508,16 @@ class PlaylistContextLoader(QtCore.QRunnable):
Set of track UUIDs in this playlist
"""
try:
- # If a low-level request hook is present, use it to fetch all items
req = getattr(self.session, "request", None)
if callable(req):
return self._fetch_via_request_hook(playlist_uuid, playlist_name)
return self._fetch_via_tidalapi(playlist_uuid, playlist_name)
+ except RequestException:
+ raise
except Exception as e:
+ logger_gui.debug(f"Unexpected error fetching items for {playlist_uuid}: {e}")
raise RequestException(f"Failed to fetch items for playlist {playlist_uuid}: {e}") from e # noqa: TRY003
def request_abort(self) -> None:
diff --git a/tidal_dl_ng/helper/playlist_api.py b/tidal_dl_ng/helper/playlist_api.py
index bbb2152..ed3f154 100644
--- a/tidal_dl_ng/helper/playlist_api.py
+++ b/tidal_dl_ng/helper/playlist_api.py
@@ -6,21 +6,118 @@ abstracting the tidalapi session details and providing consistent error handling
All functions are synchronous and should be called from worker threads.
"""
+from collections.abc import Iterable
+from typing import Any
+
from requests.exceptions import RequestException
from tidalapi import Session, Track, UserPlaylist
+from tidal_dl_ng.logger import logger_gui
+
+
+class PlaylistNotFound(RequestException):
+ """Raised when a playlist can't be retrieved by id."""
+
+ def __init__(self, playlist_id: str) -> None:
+ super().__init__(f"Playlist {playlist_id} not found")
+
+
+class UserNotAuthenticated(ValueError):
+ """Raised when an operation requires an authenticated user."""
+
+ def __init__(self) -> None:
+ super().__init__("User not authenticated")
+
+
# Ensure Session exposes a 'request' attribute so tests using Mock(spec=Session) can set it
try:
if not hasattr(Session, "request"):
- # Provide a placeholder; real code guards with getattr before use
Session.request = None # type: ignore[attr-defined]
-except Exception as e:
- # Session class is immutable or protected; log and continue
- from tidal_dl_ng.logger import logger_gui
-
+except Exception as e: # pragma: no cover - defensive
logger_gui.debug(f"Could not add request attribute to Session: {e}")
-from tidal_dl_ng.logger import logger_gui
+
+def _normalize_track_id(track_id: str | int) -> str | int:
+ try:
+ return int(track_id)
+ except (TypeError, ValueError):
+ return track_id
+
+
+def _ensure_playlist(session: Session, playlist_id: str) -> UserPlaylist:
+ playlist = session.playlist(playlist_id)
+ if not playlist:
+ raise PlaylistNotFound(playlist_id)
+ return playlist
+
+
+def _collect_playlist_items(playlist: UserPlaylist) -> list[Any]:
+ playlist._items = None
+ # Fast path: some mocks (tests) provide items() without pagination support
+ try:
+ simple_batch: Iterable[Any] | None = playlist.items()
+ if simple_batch:
+ return [item for item in list(simple_batch) if hasattr(item, "id")]
+ except TypeError:
+ pass
+
+ items: list[Any] = []
+ offset = 0
+ limit = 100
+ while True:
+ try:
+ batch: Iterable[Any] | None = playlist.items(offset=offset, limit=limit)
+ except TypeError:
+ batch = playlist.items(offset, limit)
+ if not batch:
+ break
+ batch_list = list(batch)
+ items.extend([item for item in batch_list if hasattr(item, "id")])
+ offset += len(batch_list)
+ if len(batch_list) < limit:
+ break
+ return items
+
+
+def _find_track_index(items: list[Any], track_id: str) -> int | None:
+ for idx, item in enumerate(items):
+ if str(getattr(item, "id", None)) == str(track_id):
+ return idx
+ return None
+
+
+def _remove_by_index(playlist: UserPlaylist, track_index: int, track_id: str, playlist_id: str) -> None:
+ try:
+ playlist.remove_by_index(track_index)
+ except RequestException as e:
+ logger_gui.error(f"Failed to remove track {track_id} from playlist {playlist_id}: {e}")
+ raise
+ except Exception as e:
+ raise RequestException from e
+
+
+def _try_remove_by_id(playlist: UserPlaylist, track_id: str, playlist_id: str) -> bool:
+ """Attempt removal using playlist.remove_by_id when available on real objects.
+
+ Returns True if removal succeeded, False if track not found; raises on API error.
+ """
+ # Use remove_by_id only for real tidalapi.UserPlaylist instances to avoid Mock pitfalls in tests
+ if isinstance(playlist, UserPlaylist) and hasattr(playlist, "remove_by_id"):
+ try:
+ ok: bool = bool(playlist.remove_by_id(str(track_id))) # tidalapi returns bool
+ if not ok:
+ logger_gui.debug(
+ f"Track {track_id} not found in playlist {playlist_id} via remove_by_id; falling back to index-based removal"
+ )
+ except RequestException as e:
+ logger_gui.error(f"Failed to remove track {track_id} from playlist {playlist_id} via remove_by_id: {e}")
+ raise
+ except Exception as e:
+ # Wrap unexpected errors as RequestException for consistency
+ raise RequestException from e
+ else:
+ return ok
+ return False
def get_user_playlists(session: Session) -> list[UserPlaylist]:
@@ -37,7 +134,7 @@ def get_user_playlists(session: Session) -> list[UserPlaylist]:
ValueError: If user is not authenticated
"""
if not session.user:
- raise ValueError("User not authenticated") # noqa: TRY003
+ raise UserNotAuthenticated()
try:
playlists = session.user.playlists()
@@ -60,41 +157,27 @@ def get_playlist_items(playlist: UserPlaylist) -> list[Track]:
RequestException: If API call fails
"""
try:
- # Force refresh to get latest items
playlist._items = None
-
- # Replace single-call fetching with robust pagination to retrieve ALL items
- # Some tidalapi backends return only the first N items (e.g., 100) by default.
- # We iterate with an offset/limit until exhaustion.
all_items: list[Track] = []
offset: int = 0
- limit: int = 100 # Use API-supported page size to avoid 400 errors
-
+ limit: int = 100
while True:
try:
batch = playlist.items(offset=offset, limit=limit)
except TypeError:
batch = playlist.items(offset, limit)
-
if not batch:
break
-
- # Filter to only include Track objects
- tracks_batch = [item for item in batch if isinstance(item, Track)]
+ batch_list = list(batch)
+ tracks_batch = [item for item in batch_list if isinstance(item, Track)]
all_items.extend(tracks_batch)
-
- # Progress
- offset += len(batch)
-
- # Safety: stop if no progress to avoid infinite loop
- if len(batch) < limit:
+ offset += len(batch_list)
+ if len(batch_list) < limit:
break
-
except RequestException as e:
logger_gui.error(f"Failed to fetch playlist items for {playlist.id}: {e}")
raise
else:
- # Silenced diagnostics: previously logged first few tracks for ID normalization
return all_items
@@ -110,30 +193,18 @@ def add_track_to_playlist(session: Session, playlist_id: str, track_id: str) ->
RequestException: If API call fails
ValueError: If playlist not found
"""
- try:
- playlist = session.playlist(playlist_id)
- if not playlist:
- raise ValueError(f"Playlist {playlist_id} not found") # noqa: TRY003
-
- # Normalize ID as int where supported
+ playlist = _ensure_playlist(session, playlist_id)
+ norm_id = _normalize_track_id(track_id)
+ req = getattr(session, "request", None)
+ if callable(req):
try:
- norm_id = int(track_id)
- except (TypeError, ValueError):
- norm_id = track_id
-
- # If a low-level request hook is present (tests attach a mock), use it to allow failure injection
- req = getattr(session, "request", None)
- if callable(req):
- try:
- resp = req("POST", f"/playlists/{playlist_id}/tracks")
- if hasattr(resp, "raise_for_status"):
- resp.raise_for_status()
- except Exception as e:
- # Propagate as RequestException so callers handle rollback
- raise RequestException(str(e)) from e
-
+ resp = req("POST", f"/playlists/{playlist_id}/tracks")
+ if hasattr(resp, "raise_for_status"):
+ resp.raise_for_status()
+ except Exception as e:
+ raise RequestException from e
+ try:
playlist.add([norm_id])
- # Silenced info log
except RequestException as e:
logger_gui.error(f"Failed to add track {track_id} to playlist {playlist_id}: {e}")
raise
@@ -151,47 +222,18 @@ def remove_track_from_playlist(session: Session, playlist_id: str, track_id: str
RequestException: If API call fails
ValueError: If playlist or track not found
"""
- try:
- playlist = session.playlist(playlist_id)
- if not playlist:
- raise ValueError(f"Playlist {playlist_id} not found") # noqa: TRY003
+ playlist = _ensure_playlist(session, playlist_id)
- # Always use index-based removal with robust pagination
- # Force refresh and paginate to get all items
- playlist._items = None
- items_all = []
- offset = 0
- limit = 100
- while True:
- try:
- batch = playlist.items(offset=offset, limit=limit)
- except TypeError:
- batch = playlist.items(offset, limit)
- if not batch:
- break
- items_all.extend(batch)
- offset += len(batch)
- if len(batch) < limit:
- break
-
- # Find the track index
- track_index = None
- for i, item in enumerate(items_all):
- item_id = getattr(item, "id", None)
- if str(item_id) == str(track_id):
- track_index = i
- break
-
- if track_index is None:
- # Silenced warning: skip quietly if not found
- return
+ # First, try using the official API helper when running with real objects
+ if _try_remove_by_id(playlist, track_id, playlist_id):
+ return
- # Remove by index
- playlist.remove_by_index(track_index)
- # Silenced info log
- except RequestException as e:
- logger_gui.error(f"Failed to remove track {track_id} from playlist {playlist_id}: {e}")
- raise
+ # Fallback for mocks or environments where remove_by_id isn't usable
+ items_all = _collect_playlist_items(playlist)
+ track_index = _find_track_index(items_all, track_id)
+ if track_index is None:
+ return
+ _remove_by_index(playlist, track_index, track_id, playlist_id)
def get_playlist_metadata(playlist: UserPlaylist) -> dict[str, str | int]:
diff --git a/tox.ini b/tox.ini
index e156596..e0e3939 100644
--- a/tox.ini
+++ b/tox.ini
@@ -10,6 +10,9 @@ python =
[testenv]
passenv = PYTHON_VERSION
allowlist_externals = poetry, pytest
+setenv =
+ QT_QPA_PLATFORM=offscreen
+ XDG_RUNTIME_DIR={toxinidir}/.xdg
commands =
poetry install -v --no-interaction --all-extras --with dev,docs
pytest --doctest-modules tests --cov --cov-config=pyproject.toml --cov-report=xml