From 69cf98ef54a9b12151dcf767b03635a5573fc960 Mon Sep 17 00:00:00 2001 From: Santiago Gutierrez Date: Sat, 24 Jan 2026 01:07:18 -0600 Subject: [PATCH 1/2] Adding logic changes for refresh functionality --- mopidy_tidal/__init__.py | 9 + mopidy_tidal/backend.py | 6 + mopidy_tidal/http.py | 125 ++++++++++++ mopidy_tidal/playlists.py | 310 ++++++++++++++++++++---------- poetry.lock | 389 ++++++++++++++++++++++++++++++++++++-- pyproject.toml | 4 +- 6 files changed, 733 insertions(+), 110 deletions(-) create mode 100644 mopidy_tidal/http.py diff --git a/mopidy_tidal/__init__.py b/mopidy_tidal/__init__.py index 4551f7bb..fb496f4f 100755 --- a/mopidy_tidal/__init__.py +++ b/mopidy_tidal/__init__.py @@ -47,3 +47,12 @@ def setup(self, registry): from .backend import TidalBackend registry.add("backend", TidalBackend) + + from .http import TidalRpcHandler + + registry.add('http:app', { + 'name': 'tidal', + 'factory': lambda config, core: [ + (r'/rpc', TidalRpcHandler, {'core': core}), + ], + }) diff --git a/mopidy_tidal/backend.py b/mopidy_tidal/backend.py index d1be3c79..99043f9d 100755 --- a/mopidy_tidal/backend.py +++ b/mopidy_tidal/backend.py @@ -209,6 +209,9 @@ def on_start(self): else: logger.info("[TIDAL BACKEND] Not using OAuth/PKCE or not logged in - token refresh not applicable") + # Start playlist periodic refresh if configured + self.playlists.start_periodic_refresh() + logger.info("[TIDAL BACKEND] Backend initialization complete") logger.info("=" * 80) @@ -351,6 +354,9 @@ def on_stop(self): # Stop token refresh thread self._stop_token_refresh_thread() + # Stop playlist periodic refresh thread + self.playlists.stop_periodic_refresh() + logger.info("[TIDAL BACKEND] ✓ Tidal backend stopped") def _start_token_refresh_thread(self): diff --git a/mopidy_tidal/http.py b/mopidy_tidal/http.py new file mode 100644 index 00000000..1947e225 --- /dev/null +++ b/mopidy_tidal/http.py @@ -0,0 +1,125 @@ +import json +import logging +import tornado.web + +logger = logging.getLogger(__name__) + +class TidalRpcHandler(tornado.web.RequestHandler): + """JSON-RPC 2.0 handler for Tidal-specific methods""" + + def initialize(self, core): + self.core = core + + def set_default_headers(self): + self.set_header("Content-Type", "application/json") + self.set_header("Access-Control-Allow-Origin", "*") + self.set_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.set_header("Access-Control-Allow-Headers", "Content-Type") + + def options(self): + self.set_status(204) + self.finish() + + def _get_tidal_backend(self): + """Helper to get Tidal backend""" + try: + # Get the actual backends list from the proxy + backends = self.core.backends.get(timeout=1) + + for backend in backends: + uri_schemes = backend.uri_schemes.get(timeout=1) + if uri_schemes and 'tidal' in uri_schemes: + return backend + except Exception as e: + logger.warning(f"Error finding Tidal backend: {e}") + + return None + + def _jsonrpc_error(self, request_id, code, message): + """Create JSON-RPC error response""" + return { + 'jsonrpc': '2.0', + 'id': request_id, + 'error': {'code': code, 'message': message} + } + + def _jsonrpc_success(self, request_id, result): + """Create JSON-RPC success response""" + return { + 'jsonrpc': '2.0', + 'id': request_id, + 'result': result + } + + async def post(self): + try: + request = json.loads(self.request.body.decode('utf-8')) + request_id = request.get('id') + + # Validate JSON-RPC + if request.get('jsonrpc') != '2.0': + self.write(self._jsonrpc_error( + request_id, -32600, 'Invalid Request' + )) + return + + method = request.get('method') + params = request.get('params', {}) + + logger.info(f"Tidal RPC call: {method}") + + # Get backend + backend = self._get_tidal_backend() + if not backend: + self.write(self._jsonrpc_error( + request_id, -32603, 'Tidal backend not available' + )) + return + + # Route methods + if method == 'tidal.refresh_playlists': + playlist_uris = params.get('uris') + if not playlist_uris: + self.write(self._jsonrpc_error( + request_id, + -32602, + 'Missing required parameter: uris. ' + 'Use the core playlists.refresh() method if you want to refresh all playlists.' + )) + return + + backend.playlists.refresh(*playlist_uris).get() + self.write(self._jsonrpc_success(request_id, { + 'refreshed': playlist_uris + })) + + elif method == 'tidal.describe': + # List available methods + result = { + 'methods': [ + 'tidal.refresh_playlists', + 'tidal.describe' + ], + 'tidal.refresh_playlists': { + 'description': 'Refresh specific playlists by URI', + 'params': { + 'uris': 'List of playlist URIs to refresh (required)' + } + } + } + self.write(self._jsonrpc_success(request_id, result)) + + else: + self.write(self._jsonrpc_error( + request_id, -32601, f'Method not found: {method}' + )) + + except json.JSONDecodeError: + self.write(self._jsonrpc_error( + None, -32700, 'Parse error' + )) + except Exception as e: + logger.exception("Tidal RPC error") + self.write(self._jsonrpc_error( + request.get('id'), -32603, str(e) + )) \ No newline at end of file diff --git a/mopidy_tidal/playlists.py b/mopidy_tidal/playlists.py index e47a2ba8..913a5bd8 100644 --- a/mopidy_tidal/playlists.py +++ b/mopidy_tidal/playlists.py @@ -3,11 +3,12 @@ import difflib import logging import operator +import threading from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from threading import Event, Timer -from typing import TYPE_CHECKING, Collection, List, Optional, Tuple, Union, Dict +from typing import TYPE_CHECKING, Collection, List, Optional, Tuple, Union +from continuous_threading import PeriodicThread from mopidy import backend from mopidy.models import Playlist as MopidyPlaylist from mopidy.models import Ref, Track @@ -64,12 +65,104 @@ def __init__(self, *args, **kwargs): self._playlists_metadata = PlaylistMetadataCache() self._playlists = PlaylistCache() self._current_tidal_playlists = [] - self._playlists_loaded_event = Event() + self._cache_lock = threading.RLock() + self._refresh_thread: Optional[PeriodicThread] = None + + def start_periodic_refresh(self): + """Start the periodic refresh thread. Called by backend on_start.""" + refresh_secs = self.backend._config["tidal"].get("playlist_cache_refresh_secs") + if not refresh_secs or refresh_secs <= 0: + logger.info("Playlist periodic refresh disabled (playlist_cache_refresh_secs not set or <= 0)") + return + + self._refresh_thread = PeriodicThread( + target=self._do_periodic_refresh, + period=refresh_secs, + name="tidal-playlist-refresh", + daemon=True + ) + self._refresh_thread.start() + logger.info("Started playlist periodic refresh thread (every %s seconds)", refresh_secs) + + def stop_periodic_refresh(self): + """Stop the periodic refresh thread. Called by backend on_stop.""" + if self._refresh_thread: + logger.info("Stopping playlist periodic refresh thread...") + self._refresh_thread.stop() + self._refresh_thread.join(timeout=5) + if self._refresh_thread.is_alive(): + logger.warning("Playlist refresh thread did not stop cleanly") + else: + logger.info("Playlist periodic refresh thread stopped") + self._refresh_thread = None + + def _do_periodic_refresh(self): + """Perform a periodic refresh, only updating playlists that have changed.""" + try: + logger.debug("Periodic playlist refresh starting...") + + # Fetch fresh playlist list from Tidal + self._calculate_added_and_removed_playlist_ids() + + session = self.backend.session + mapped_playlists = {} + mapped_metadata = {} + + for pl in self._current_tidal_playlists: + uri = "tidal:playlist:" + pl.id + + # Always update metadata + mapped_metadata[uri] = MopidyPlaylist( + uri=uri, + name=pl.name, + tracks=[mock_track] * pl.num_tracks, + last_modified=to_timestamp(pl.last_updated), + ) + + # Check if full playlist needs refresh based on last_updated timestamp + with self._cache_lock: + cached = self._playlists.get(uri) + + if self._playlist_needs_refresh(pl, cached): + logger.info('Playlist "%s" has changed, refreshing tracks...', pl.name) + pl_tracks = self._retrieve_api_tracks(session, pl) + tracks = full_models_mappers.create_mopidy_tracks(pl_tracks) + mapped_playlists[uri] = MopidyPlaylist( + uri=uri, + name=pl.name, + tracks=tracks, + last_modified=to_timestamp(pl.last_updated), + ) + + # Update caches atomically + with self._cache_lock: + self._playlists_metadata.update(mapped_metadata) + self._playlists.update(mapped_playlists) + + backend.BackendListener.send("playlists_loaded") + logger.debug("Periodic playlist refresh complete") + + except Exception as e: + logger.error("Error in periodic playlist refresh: %s", e, exc_info=True) + + def _playlist_needs_refresh(self, tidal_playlist: TidalPlaylist, cached: Optional[MopidyPlaylist]) -> bool: + """Check if a playlist needs refresh by comparing timestamps.""" + if cached is None: + return True + + upstream_last_updated = to_timestamp(getattr(tidal_playlist, "last_updated", None)) + local_last_updated = to_timestamp(cached.last_modified) + + if not upstream_last_updated: + # Can't determine if changed, assume it needs refresh + return True + + return upstream_last_updated > local_last_updated def _calculate_added_and_removed_playlist_ids( self, ) -> Tuple[Collection[str], Collection[str]]: - logger.info("Calculating playlist updates..") + logger.debug("Calculating playlist updates...") session = self.backend.session updated_playlists = [] @@ -91,60 +184,44 @@ def _calculate_added_and_removed_playlist_ids( self._current_tidal_playlists = updated_playlists updated_ids = set(pl.id for pl in updated_playlists) - if not self._playlists_metadata: - return updated_ids, set() - - current_ids = set(uri.split(":")[-1] for uri in self._playlists_metadata.keys()) - added_ids = updated_ids.difference(current_ids) - removed_ids = current_ids.difference(updated_ids) - self._playlists_metadata.prune( - *[ + + with self._cache_lock: + if not self._playlists_metadata: + return updated_ids, set() + + current_ids = set(uri.split(":")[-1] for uri in self._playlists_metadata.keys()) + added_ids = updated_ids.difference(current_ids) + removed_ids = current_ids.difference(updated_ids) + + # Prune removed playlists from both caches + uris_to_remove = [ uri for uri in self._playlists_metadata.keys() if uri.split(":")[-1] in removed_ids ] - ) + self._playlists_metadata.prune(*uris_to_remove) + self._playlists.prune(*uris_to_remove) return added_ids, removed_ids - def _has_changes(self, playlist: MopidyPlaylist): - upstream_playlist = self.backend.session.playlist(playlist.uri.split(":")[-1]) - if not upstream_playlist: - return True - - upstream_last_updated_at = to_timestamp( - getattr(upstream_playlist, "last_updated", None) - ) - local_last_updated_at = to_timestamp(playlist.last_modified) - - if not upstream_last_updated_at: - logger.warning( - "You are using a version of python-tidal that does not " - "support last_updated on playlist objects" - ) - return True - - if upstream_last_updated_at > local_last_updated_at: - logger.info( - 'The playlist "%s" has been updated: refresh forced', playlist.name - ) - return True - - return False - @login_hack(List[Ref.playlist]) def as_list(self) -> List[Ref]: - if not self._playlists_loaded_event.is_set(): - added_ids, _ = self._calculate_added_and_removed_playlist_ids() - if added_ids: - self.refresh(include_items=False) + """Return list of playlists from cache. Read-only operation.""" + logger.debug("Listing TIDAL playlists from cache...") + + # On first call, if metadata cache is empty, populate it + with self._cache_lock: + is_empty = not self._playlists_metadata - logger.debug("Listing TIDAL playlists..") - refs = [ - Ref.playlist(uri=pl.uri, name=pl.name) - for pl in self._playlists_metadata.values() - ] + if is_empty: + logger.info("Playlist metadata cache is empty, triggering initial refresh...") + self.refresh(metadata_only=True) + with self._cache_lock: + refs = [ + Ref.playlist(uri=pl.uri, name=pl.name) + for pl in self._playlists_metadata.values() + ] return sorted(refs, key=operator.attrgetter("name")) def _lookup_mix(self, uri): @@ -153,15 +230,22 @@ def _lookup_mix(self, uri): return session.mix(mix_id) def _get_or_refresh_playlist(self, uri) -> Optional[MopidyPlaylist]: + """Get playlist from cache. Only triggers refresh on cache miss.""" parts = uri.split(":") if parts[1] == "mix": mix = self._lookup_mix(uri) return full_models_mappers.create_mopidy_mix_playlist(mix) - playlist = self._playlists.get(uri) - if (playlist is None) or (playlist and self._has_changes(playlist)): - self.refresh(uri, include_items=True) - return self._playlists.get(uri) + with self._cache_lock: + playlist = self._playlists.get(uri) + + if playlist is None: + # Cache miss - trigger refresh for this specific playlist + self.refresh(uri) + with self._cache_lock: + playlist = self._playlists.get(uri) + + return playlist def create(self, name): tidal_playlist = self.backend.session.user.create_playlist(name, "") @@ -194,72 +278,104 @@ def delete(self, uri): else: raise e - self._playlists_metadata.prune(uri) - self._playlists.prune(uri) + with self._cache_lock: + self._playlists_metadata.prune(uri) + self._playlists.prune(uri) @login_hack def lookup(self, uri) -> Optional[MopidyPlaylist]: return self._get_or_refresh_playlist(uri) @login_hack - def refresh(self, *uris, include_items: bool = True) -> Dict[str, MopidyPlaylist]: + def refresh(self, *uris, metadata_only: bool = False): if uris: - logger.info("Looking up playlists: %r", uris) + logger.info("Refreshing playlists: %r", uris) else: - logger.info("Refreshing TIDAL playlists..") + logger.info("Refreshing all TIDAL playlists...") session = self.backend.session - plists = self._current_tidal_playlists mapped_playlists = {} - playlist_cache = self._playlists if include_items else self._playlists_metadata - - for pl in plists: - uri = "tidal:playlist:" + pl.id - # Skip or cache hit case - if (uris and uri not in uris) or pl in playlist_cache: - continue - - # Cache miss case - if include_items: - pl_tracks = self._retrieve_api_tracks(session, pl) - tracks = full_models_mappers.create_mopidy_tracks(pl_tracks) - else: - # Create as many mock tracks as the number of items in the playlist. - # Playlist metadata is concerned only with the number of tracks, not - # the actual list. - tracks = [mock_track] * pl.num_tracks - - mapped_playlists[uri] = MopidyPlaylist( - uri=uri, - name=pl.name, - tracks=tracks, - last_modified=to_timestamp(pl.last_updated), - ) + mapped_metadata = {} - # When we trigger a playlists_loaded event the backend may call as_list - # again. Set an event in playlist_cache_refresh_secs seconds to ensure - # that we don't perform another playlist sync. - self._playlists_loaded_event.set() - playlist_cache_refresh_secs = self.backend._config["tidal"].get( - "playlist_cache_refresh_secs" - ) - - if playlist_cache_refresh_secs: - Timer( - playlist_cache_refresh_secs, - lambda: self._playlists_loaded_event.clear(), - ).start() + if uris: + # Refreshing specific URIs - fetch each playlist directly from API + with self._cache_lock: + for uri in uris: + self._playlists_metadata.prune(uri) + self._playlists.prune(uri) + + for uri in uris: + playlist_id = uri.split(":")[-1] + pl = session.playlist(playlist_id) + if not pl: + logger.warning("Could not fetch playlist %s from Tidal API", uri) + continue + + # Create metadata entry with mock tracks + mapped_metadata[uri] = MopidyPlaylist( + uri=uri, + name=pl.name, + tracks=[mock_track] * pl.num_tracks, + last_modified=to_timestamp(pl.last_updated), + ) + + # Fetch full playlist with tracks (skip if metadata_only) + if not metadata_only: + pl_tracks = self._retrieve_api_tracks(session, pl) + tracks = full_models_mappers.create_mopidy_tracks(pl_tracks) + + mapped_playlists[uri] = MopidyPlaylist( + uri=uri, + name=pl.name, + tracks=tracks, + last_modified=to_timestamp(pl.last_updated), + ) + else: + # Refreshing all playlists - use the playlist list from user library + self._calculate_added_and_removed_playlist_ids() + + with self._cache_lock: + self._playlists_metadata.clear() + self._playlists.clear() + + for pl in self._current_tidal_playlists: + uri = "tidal:playlist:" + pl.id + + # Create metadata entry with mock tracks + mapped_metadata[uri] = MopidyPlaylist( + uri=uri, + name=pl.name, + tracks=[mock_track] * pl.num_tracks, + last_modified=to_timestamp(pl.last_updated), + ) + + # Fetch full playlist with tracks (skip if metadata_only) + if not metadata_only: + pl_tracks = self._retrieve_api_tracks(session, pl) + tracks = full_models_mappers.create_mopidy_tracks(pl_tracks) + + mapped_playlists[uri] = MopidyPlaylist( + uri=uri, + name=pl.name, + tracks=tracks, + last_modified=to_timestamp(pl.last_updated), + ) + + # Update caches atomically + with self._cache_lock: + self._playlists_metadata.update(mapped_metadata) + if not metadata_only: + self._playlists.update(mapped_playlists) - # Update the right playlist cache and send the playlists_loaded event. - playlist_cache.update(mapped_playlists) backend.BackendListener.send("playlists_loaded") logger.info("TIDAL playlists refreshed") @login_hack def get_items(self, uri) -> Optional[List[Ref]]: + """Get playlist items from cache. Read-only operation (triggers refresh on cache miss).""" playlist = self._get_or_refresh_playlist(uri) if not playlist: - return + return None return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] diff --git a/poetry.lock b/poetry.lock index ccbee4be..632e7f3f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,45 @@ -# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "appnope" +version = "0.1.4" +description = "Disable App Nap on macOS >= 10.9" +optional = false +python-versions = ">=3.6" +groups = ["main"] +markers = "sys_platform == \"darwin\"" +files = [ + {file = "appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c"}, + {file = "appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee"}, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +description = "Annotate AST trees with source code positions" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a"}, + {file = "asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7"}, +] + +[package.extras] +astroid = ["astroid (>=2,<5)"] +test = ["astroid (>=2,<5)", "pytest (<9.0)", "pytest-cov", "pytest-xdist"] + +[[package]] +name = "backcall" +version = "0.2.0" +description = "Specifications for callback functions passed in to an API" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "backcall-0.2.0-py2.py3-none-any.whl", hash = "sha256:fbbce6a29f263178a1f7915c1940bde0ec2b2a967566fe1c65c1dfb7422bd255"}, + {file = "backcall-0.2.0.tar.gz", hash = "sha256:5cbdbf27be5e7cfadb448baf0aa95508f91f2bbc6c6437cd9cd06e2a4c215e1e"}, +] [[package]] name = "black" @@ -6,6 +47,7 @@ version = "23.12.1" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "black-23.12.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2"}, {file = "black-23.12.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba"}, @@ -42,7 +84,7 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +d = ["aiohttp (>=3.7.4) ; sys_platform != \"win32\" or implementation_name != \"pypy\"", "aiohttp (>=3.7.4,!=3.9.0) ; sys_platform == \"win32\" and implementation_name == \"pypy\""] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -52,6 +94,7 @@ version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" +groups = ["main", "complete"] files = [ {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, @@ -63,6 +106,7 @@ version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7.0" +groups = ["main", "complete"] files = [ {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, @@ -162,6 +206,7 @@ version = "8.1.7" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, @@ -176,10 +221,27 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {main = "sys_platform == \"win32\"", dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} + +[[package]] +name = "continuous-threading" +version = "2.0.6" +description = "Library to help manage threads that run continuously for a long time." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "continuous_threading-2.0.6-py3-none-any.whl", hash = "sha256:bdccf2887cfc8bb80a64ccf2bda06b0fe3e5418676afeca39b35a5039431ba20"}, + {file = "continuous_threading-2.0.6.tar.gz", hash = "sha256:3d38a6d4ec277f7578942e4753996831e9ff6d21d414bba8e745e0cc3995a8b1"}, +] + +[package.dependencies] +psutil = ">=5.4.0" [[package]] name = "coverage" @@ -187,6 +249,7 @@ version = "7.4.3" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "coverage-7.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8580b827d4746d47294c0e0b92854c85a92c2227927433998f0d3320ae8a71b6"}, {file = "coverage-7.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:718187eeb9849fc6cc23e0d9b092bc2348821c5e1a901c9f8975df0bc785bfd4"}, @@ -246,7 +309,19 @@ files = [ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} [package.extras] -toml = ["tomli"] +toml = ["tomli ; python_full_version <= \"3.11.0a6\""] + +[[package]] +name = "decorator" +version = "5.2.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, + {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, +] [[package]] name = "deepdiff" @@ -254,6 +329,7 @@ version = "6.7.1" description = "Deep Difference and Search of any Python object/data. Recreate objects by adding adding deltas to each other." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "deepdiff-6.7.1-py3-none-any.whl", hash = "sha256:58396bb7a863cbb4ed5193f548c56f18218060362311aa1dc36397b2f25108bd"}, {file = "deepdiff-6.7.1.tar.gz", hash = "sha256:b367e6fa6caac1c9f500adc79ada1b5b1242c50d5f716a1a4362030197847d30"}, @@ -272,6 +348,8 @@ version = "1.2.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_version < \"3.11\"" files = [ {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, @@ -280,12 +358,28 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "executing" +version = "2.2.1" +description = "Get the currently executing AST node of a frame, and other information" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, + {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, +] + +[package.extras] +tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich ; python_version >= \"3.11\""] + [[package]] name = "idna" version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.5" +groups = ["main", "complete"] files = [ {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, @@ -297,17 +391,59 @@ version = "2.0.0" description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "ipython" +version = "8.12.0" +description = "IPython: Productive Interactive Computing" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "ipython-8.12.0-py3-none-any.whl", hash = "sha256:1c183bf61b148b00bcebfa5d9b39312733ae97f6dad90d7e9b4d86c8647f498c"}, + {file = "ipython-8.12.0.tar.gz", hash = "sha256:a950236df04ad75b5bc7f816f9af3d74dc118fd42f2ff7e80e8e60ca1f182e2d"}, +] + +[package.dependencies] +appnope = {version = "*", markers = "sys_platform == \"darwin\""} +backcall = "*" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +decorator = "*" +jedi = ">=0.16" +matplotlib-inline = "*" +pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} +pickleshare = "*" +prompt-toolkit = ">=3.0.30,<3.0.37 || >3.0.37,<3.1.0" +pygments = ">=2.4.0" +stack-data = "*" +traitlets = ">=5" +typing-extensions = {version = "*", markers = "python_version < \"3.10\""} + +[package.extras] +all = ["black", "curio", "docrepr", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.21)", "pandas", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] +black = ["black"] +doc = ["docrepr", "ipykernel", "matplotlib", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] +kernel = ["ipykernel"] +nbconvert = ["nbconvert"] +nbformat = ["nbformat"] +notebook = ["ipywidgets", "notebook"] +parallel = ["ipyparallel"] +qtconsole = ["qtconsole"] +test = ["pytest (<7.1)", "pytest-asyncio", "testpath"] +test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.21)", "pandas", "pytest (<7.1)", "pytest-asyncio", "testpath", "trio"] + [[package]] name = "isodate" version = "0.6.1" description = "An ISO 8601 date/time/duration parser and formatter" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "isodate-0.6.1-py2.py3-none-any.whl", hash = "sha256:0751eece944162659049d35f4f549ed815792b38793f07cf73381c1c87cbed96"}, {file = "isodate-0.6.1.tar.gz", hash = "sha256:48c5881de7e8b0a0d648cb024c8062dc84e7b840ed81e864c7614fd3c127bde9"}, @@ -322,6 +458,7 @@ version = "5.13.2" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, @@ -330,12 +467,48 @@ files = [ [package.extras] colors = ["colorama (>=0.4.6)"] +[[package]] +name = "jedi" +version = "0.19.2" +description = "An autocompletion tool for Python that can be used for text editors." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9"}, + {file = "jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0"}, +] + +[package.dependencies] +parso = ">=0.8.4,<0.9.0" + +[package.extras] +docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx (==1.8.5)", "sphinx-rtd-theme (==0.4.3)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["Django", "attrs", "colorama", "docopt", "pytest (<9.0.0)"] + +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +description = "Inline Matplotlib backend for Jupyter" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca"}, + {file = "matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90"}, +] + +[package.dependencies] +traitlets = "*" + [[package]] name = "mopidy" version = "3.4.2" description = "Mopidy is an extensible music server written in Python" optional = false python-versions = ">=3.7" +groups = ["main", "complete"] files = [ {file = "Mopidy-3.4.2-py3-none-any.whl", hash = "sha256:90be54768647ec7893c48ebf729baf40138e350e17e4ca0293488a6d238f501d"}, {file = "Mopidy-3.4.2.tar.gz", hash = "sha256:ada9ecbfc09eecc8c9e6742a8a4fea1632a134a1ab060527d8aa3d36df0547b6"}, @@ -359,6 +532,7 @@ version = "3.69.3" description = "Fully-featured Mopidy frontend client" optional = false python-versions = ">=3.7" +groups = ["complete"] files = [ {file = "Mopidy-Iris-3.69.3.tar.gz", hash = "sha256:3c40179daa62cb1a338a3474e7723bcd061160b783395ef5f4bd106cedabe2be"}, {file = "Mopidy_Iris-3.69.3-py3-none-any.whl", hash = "sha256:715f515988cc1f2ed8328d2b731e3029f86e2a827b907921078d9caf8a0c5855"}, @@ -381,6 +555,7 @@ version = "3.2.1" description = "Mopidy extension for playing music from your local music archive" optional = false python-versions = ">=3.7" +groups = ["complete"] files = [ {file = "Mopidy-Local-3.2.1.tar.gz", hash = "sha256:29165157134fe869228da675e4d0083888368a29dc7dd3203fe1a27d7b4d83a3"}, {file = "Mopidy_Local-3.2.1-py3-none-any.whl", hash = "sha256:20e142397664d4348a0868e255d1b6e55fffd6c507fc2afda2314a4de885b38d"}, @@ -403,6 +578,7 @@ version = "3.3.0" description = "Mopidy extension for controlling Mopidy from MPD clients" optional = false python-versions = ">=3.7" +groups = ["complete"] files = [ {file = "Mopidy-MPD-3.3.0.tar.gz", hash = "sha256:09e2cc46a8fd73006f42b3b1ed71d557c3230e3c0ea2c38d565b0dda8faf4d53"}, {file = "Mopidy_MPD-3.3.0-py3-none-any.whl", hash = "sha256:9a8b98998896cbb77ba917c448548ae90460e6f0d22d9c6c810b79a5363938f3"}, @@ -424,6 +600,7 @@ version = "0.4.0" description = "MPEG-DASH MPD(Media Presentation Description) Parser" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "mpegdash-0.4.0-py3-none-any.whl", hash = "sha256:d07f6e1f2a67ddce1be501e3ad7abc29a2d6a7b1830b4da974b49c2ebe99cf2a"}, {file = "mpegdash-0.4.0.tar.gz", hash = "sha256:65368c7a367c6875eb8c456a08644eb0708981a745044da0c9e942a3bc2b6389"}, @@ -435,6 +612,7 @@ version = "1.0.0" description = "Multiple dispatch" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "multipledispatch-1.0.0-py3-none-any.whl", hash = "sha256:0c53cd8b077546da4e48869f49b13164bebafd0c2a5afceb6bb6a316e7fb46e4"}, {file = "multipledispatch-1.0.0.tar.gz", hash = "sha256:5c839915465c68206c3e9c473357908216c28383b425361e5d144594bf85a7e0"}, @@ -446,6 +624,7 @@ version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false python-versions = ">=3.5" +groups = ["dev"] files = [ {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, @@ -457,6 +636,7 @@ version = "4.1.0" description = "An OrderedSet is a custom MutableSet that remembers its order, so that every" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8"}, {file = "ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562"}, @@ -471,17 +651,35 @@ version = "23.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] +[[package]] +name = "parso" +version = "0.8.5" +description = "A Python Parser" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887"}, + {file = "parso-0.8.5.tar.gz", hash = "sha256:034d7354a9a018bdce352f48b2a8a450f05e9d6ee85db84764e9b6bd96dafe5a"}, +] + +[package.extras] +qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] +testing = ["docopt", "pytest"] + [[package]] name = "pathspec" version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -493,6 +691,7 @@ version = "4.9.0" description = "Pexpect allows easy control of interactive console applications." optional = false python-versions = "*" +groups = ["main", "dev"] files = [ {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, @@ -501,12 +700,25 @@ files = [ [package.dependencies] ptyprocess = ">=0.5" +[[package]] +name = "pickleshare" +version = "0.7.5" +description = "Tiny 'shelve'-like database with concurrency support" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pickleshare-0.7.5-py2.py3-none-any.whl", hash = "sha256:9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"}, + {file = "pickleshare-0.7.5.tar.gz", hash = "sha256:87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca"}, +] + [[package]] name = "platformdirs" version = "4.2.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, @@ -522,6 +734,7 @@ version = "1.4.0" description = "plugin and hook calling mechanisms for python" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, @@ -537,28 +750,96 @@ version = "0.4.0" description = "A drop-in replacement for pprint that's actually pretty" optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "pprintpp-0.4.0-py2.py3-none-any.whl", hash = "sha256:b6b4dcdd0c0c0d75e4d7b2f21a9e933e5b2ce62b26e1a54537f9651ae5a5c01d"}, {file = "pprintpp-0.4.0.tar.gz", hash = "sha256:ea826108e2c7f49dc6d66c752973c3fc9749142a798d6b254e1e301cfdbc6403"}, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +description = "Library for building powerful interactive command lines in Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, + {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, +] + +[package.dependencies] +wcwidth = "*" + +[[package]] +name = "psutil" +version = "7.2.1" +description = "Cross-platform lib for process and system monitoring." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "psutil-7.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ba9f33bb525b14c3ea563b2fd521a84d2fa214ec59e3e6a2858f78d0844dd60d"}, + {file = "psutil-7.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:81442dac7abfc2f4f4385ea9e12ddf5a796721c0f6133260687fec5c3780fa49"}, + {file = "psutil-7.2.1-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea46c0d060491051d39f0d2cff4f98d5c72b288289f57a21556cc7d504db37fc"}, + {file = "psutil-7.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35630d5af80d5d0d49cfc4d64c1c13838baf6717a13effb35869a5919b854cdf"}, + {file = "psutil-7.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:923f8653416604e356073e6e0bccbe7c09990acef442def2f5640dd0faa9689f"}, + {file = "psutil-7.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cfbe6b40ca48019a51827f20d830887b3107a74a79b01ceb8cc8de4ccb17b672"}, + {file = "psutil-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:494c513ccc53225ae23eec7fe6e1482f1b8a44674241b54561f755a898650679"}, + {file = "psutil-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fce5f92c22b00cdefd1645aa58ab4877a01679e901555067b1bd77039aa589f"}, + {file = "psutil-7.2.1-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93f3f7b0bb07711b49626e7940d6fe52aa9940ad86e8f7e74842e73189712129"}, + {file = "psutil-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d34d2ca888208eea2b5c68186841336a7f5e0b990edec929be909353a202768a"}, + {file = "psutil-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2ceae842a78d1603753561132d5ad1b2f8a7979cb0c283f5b52fb4e6e14b1a79"}, + {file = "psutil-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:08a2f175e48a898c8eb8eace45ce01777f4785bc744c90aa2cc7f2fa5462a266"}, + {file = "psutil-7.2.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b2e953fcfaedcfbc952b44744f22d16575d3aa78eb4f51ae74165b4e96e55f42"}, + {file = "psutil-7.2.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:05cc68dbb8c174828624062e73078e7e35406f4ca2d0866c272c2410d8ef06d1"}, + {file = "psutil-7.2.1-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e38404ca2bb30ed7267a46c02f06ff842e92da3bb8c5bfdadbd35a5722314d8"}, + {file = "psutil-7.2.1-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab2b98c9fc19f13f59628d94df5cc4cc4844bc572467d113a8b517d634e362c6"}, + {file = "psutil-7.2.1-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f78baafb38436d5a128f837fab2d92c276dfb48af01a240b861ae02b2413ada8"}, + {file = "psutil-7.2.1-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:99a4cd17a5fdd1f3d014396502daa70b5ec21bf4ffe38393e152f8e449757d67"}, + {file = "psutil-7.2.1-cp37-abi3-win_amd64.whl", hash = "sha256:b1b0671619343aa71c20ff9767eced0483e4fc9e1f489d50923738caf6a03c17"}, + {file = "psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442"}, + {file = "psutil-7.2.1.tar.gz", hash = "sha256:f7583aec590485b43ca601dd9cea0dcd65bd7bb21d30ef4ddbf4ea6b5ed1bdd3"}, +] + +[package.extras] +dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel"] +test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "setuptools"] + [[package]] name = "ptyprocess" version = "0.7.0" description = "Run a subprocess in a pseudo terminal" optional = false python-versions = "*" +groups = ["main", "dev"] files = [ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, ] +[[package]] +name = "pure-eval" +version = "0.2.3" +description = "Safely evaluate AST nodes without side effects" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, + {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, +] + +[package.extras] +tests = ["pytest"] + [[package]] name = "pycairo" version = "1.26.0" description = "Python interface for cairo" optional = false python-versions = ">=3.8" +groups = ["complete"] files = [ {file = "pycairo-1.26.0-cp310-cp310-win32.whl", hash = "sha256:696ba8024d2827e66e088a6e05a3b0aea30d289476bcb2ca47c9670d40900a50"}, {file = "pycairo-1.26.0-cp310-cp310-win_amd64.whl", hash = "sha256:b6690a00fb225c19f42d76660e676aba7ae7cb18f3632cb02bce7f0d9b9c3800"}, @@ -577,12 +858,28 @@ files = [ {file = "pycairo-1.26.0.tar.gz", hash = "sha256:2dddd0a874fbddb21e14acd9b955881ee1dc6e63b9c549a192d613a907f9cbeb"}, ] +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pygobject" version = "3.46.0" description = "Python bindings for GObject Introspection" optional = false python-versions = ">=3.8, <4" +groups = ["complete"] files = [ {file = "PyGObject-3.46.0.tar.gz", hash = "sha256:481437b05af0a66b7c366ea052710eb3aacbb979d22d30b797f7ec29347ab1e6"}, ] @@ -596,6 +893,7 @@ version = "4.0.2" description = "Pykka is a Python implementation of the actor model" optional = false python-versions = ">=3.8.0,<4.0.0" +groups = ["main", "complete"] files = [ {file = "pykka-4.0.2-py3-none-any.whl", hash = "sha256:100f9ceb5b977aad5eb7b3165d0989d539eff685a5d77b3f733e7c3fe704fd7b"}, {file = "pykka-4.0.2.tar.gz", hash = "sha256:05e687c426922b0084d79f22a6c1813e0c4e0c59d8f860aa32c18c5f6127e276"}, @@ -610,6 +908,7 @@ version = "7.4.4" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, @@ -632,6 +931,7 @@ version = "4.1.0" description = "Pytest plugin for measuring coverage." optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, @@ -650,6 +950,7 @@ version = "0.1.14" description = "A simple plugin to use with pytest" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["dev"] files = [ {file = "pytest-diff-0.1.14.tar.gz", hash = "sha256:f1a86070fa53c2d6f29f5e242aac78df29dcb24a0ccaabb9b354d099665bc0fc"}, {file = "pytest_diff-0.1.14-py3-none-any.whl", hash = "sha256:bbed16b05f5a73d19575f293d6777cbd2b1de7e59df5e8a933574177bdd0552b"}, @@ -667,6 +968,7 @@ version = "3.12.0" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "pytest-mock-3.12.0.tar.gz", hash = "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9"}, {file = "pytest_mock-3.12.0-py3-none-any.whl", hash = "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f"}, @@ -684,6 +986,7 @@ version = "0.9.7" description = "pytest-sugar is a plugin for pytest that changes the default look and feel of pytest (e.g. progressbar, show tests that fail instantly)." optional = false python-versions = "*" +groups = ["dev"] files = [ {file = "pytest-sugar-0.9.7.tar.gz", hash = "sha256:f1e74c1abfa55f7241cf7088032b6e378566f16b938f3f08905e2cf4494edd46"}, {file = "pytest_sugar-0.9.7-py2.py3-none-any.whl", hash = "sha256:8cb5a4e5f8bbcd834622b0235db9e50432f4cbd71fef55b467fe44e43701e062"}, @@ -703,6 +1006,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -717,6 +1021,7 @@ version = "2.2.1" description = "API rate limit decorator" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "ratelimit-2.2.1.tar.gz", hash = "sha256:af8a9b64b821529aca09ebaf6d8d279100d766f19e90b5059ac6a718ca6dee42"}, ] @@ -727,6 +1032,7 @@ version = "2.31.0" description = "Python HTTP for Humans." optional = false python-versions = ">=3.7" +groups = ["main", "complete"] files = [ {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, @@ -748,6 +1054,7 @@ version = "69.1.1" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" +groups = ["main", "complete"] files = [ {file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"}, {file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"}, @@ -755,7 +1062,7 @@ files = [ [package.extras] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov ; platform_python_implementation != \"PyPy\"", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1) ; platform_python_implementation != \"PyPy\"", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [[package]] @@ -764,17 +1071,39 @@ version = "1.16.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +groups = ["main"] files = [ {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] +[[package]] +name = "stack-data" +version = "0.6.3" +description = "Extract data from python stack frames and tracebacks for informative displays" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, + {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, +] + +[package.dependencies] +asttokens = ">=2.1.0" +executing = ">=1.2.0" +pure-eval = "*" + +[package.extras] +tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"] + [[package]] name = "termcolor" version = "2.4.0" description = "ANSI color formatting for output in terminal" optional = false python-versions = ">=3.8" +groups = ["dev"] files = [ {file = "termcolor-2.4.0-py3-none-any.whl", hash = "sha256:9297c0df9c99445c2412e832e882a7884038a25617c60cea2ad69488d4040d63"}, {file = "termcolor-2.4.0.tar.gz", hash = "sha256:aab9e56047c8ac41ed798fa36d892a37aca6b3e9159f3e0c24bc64a9b3ac7b7a"}, @@ -785,13 +1114,14 @@ tests = ["pytest", "pytest-cov"] [[package]] name = "tidalapi" -version = "0.7.5" +version = "0.7.6" description = "Unofficial API for TIDAL music streaming service." optional = false -python-versions = ">=3.8,<4.0" +python-versions = "<4.0,>=3.8" +groups = ["main"] files = [ - {file = "tidalapi-0.7.5-py3-none-any.whl", hash = "sha256:99623dc1325d16f740e88b4bc7e5041a9ac9ff212935acc1d4ae4c9af9f37ea1"}, - {file = "tidalapi-0.7.5.tar.gz", hash = "sha256:433ac8590edc5a93c0bb7fbfcea02a5fd78358e17c1775e3f11b790fc23a87d3"}, + {file = "tidalapi-0.7.6-py3-none-any.whl", hash = "sha256:ac9afe91296d2db71381e70470a710c052ce45b5014c4735bd1908ca3938f233"}, + {file = "tidalapi-0.7.6.tar.gz", hash = "sha256:5fa537e13d6c3383fe245a4e7dc23b0a650b7066784825f03f67c51ca8b57161"}, ] [package.dependencies] @@ -808,6 +1138,8 @@ version = "2.0.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.7" +groups = ["dev"] +markers = "python_full_version <= \"3.11.0a6\"" files = [ {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, @@ -819,6 +1151,7 @@ version = "6.4" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." optional = false python-versions = ">= 3.8" +groups = ["main", "complete"] files = [ {file = "tornado-6.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:02ccefc7d8211e5a7f9e8bc3f9e5b0ad6262ba2fbb683a6443ecc804e5224ce0"}, {file = "tornado-6.4-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:27787de946a9cffd63ce5814c33f734c627a87072ec7eed71f7fc4417bb16263"}, @@ -833,16 +1166,34 @@ files = [ {file = "tornado-6.4.tar.gz", hash = "sha256:72291fa6e6bc84e626589f1c29d90a5a6d593ef5ae68052ee2ef000dfd273dee"}, ] +[[package]] +name = "traitlets" +version = "5.14.3" +description = "Traitlets Python configuration system" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f"}, + {file = "traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7"}, +] + +[package.extras] +docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] +test = ["argcomplete (>=3.0.3)", "mypy (>=1.7.0)", "pre-commit", "pytest (>=7.0,<8.2)", "pytest-mock", "pytest-mypy-testing"] + [[package]] name = "typing-extensions" version = "4.10.0" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" +groups = ["main", "complete", "dev"] files = [ {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] +markers = {complete = "python_version < \"3.10\"", dev = "python_version < \"3.11\""} [[package]] name = "uritools" @@ -850,6 +1201,7 @@ version = "4.0.2" description = "URI parsing, classification and composition" optional = false python-versions = ">=3.7" +groups = ["complete"] files = [ {file = "uritools-4.0.2-py3-none-any.whl", hash = "sha256:607b15eae1e7b69a120f463a7d98f91a56671e1ab92aae13f8e1f25c017fe60e"}, {file = "uritools-4.0.2.tar.gz", hash = "sha256:04df2b787d0eb76200e8319382a03562fbfe4741fd66c15506b08d3b8211d573"}, @@ -861,18 +1213,31 @@ version = "2.2.1" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.8" +groups = ["main", "complete"] files = [ {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "wcwidth" +version = "0.2.14" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1"}, + {file = "wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605"}, +] + [metadata] -lock-version = "2.0" -python-versions = "^3.9" -content-hash = "5bfad62bafbbaba4ae3c4c2d5fd9bd9b26f1f295c5a9d54451da546e82635d25" +lock-version = "2.1" +python-versions = "^3.8" +content-hash = "05f3c1283dcbd60c2a1acb75c91efc7727f26d3c75d417bfbebd982695c06f91" diff --git a/pyproject.toml b/pyproject.toml index 3bfc9171..6af96fd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,9 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.8" Mopidy = "^3.0" -tidalapi = "^0.7.5" +tidalapi = "0.7.6" +ipython = "8.12.0" +continuous-threading = "^2.0.0" [tool.poetry.group.dev.dependencies] pytest = "^7.3.1" From baa6028fd1ee7c4c61d804b6f8e1646d2620d2ed Mon Sep 17 00:00:00 2001 From: Santiago Gutierrez Date: Mon, 26 Jan 2026 17:15:23 -0600 Subject: [PATCH 2/2] Adding tests --- tests/conftest.py | 7 +- tests/test_playlist.py | 33 +- tests/test_playlist_refresh.py | 662 +++++++++++++++++++++++++++++++++ 3 files changed, 695 insertions(+), 7 deletions(-) create mode 100644 tests/test_playlist_refresh.py diff --git a/tests/conftest.py b/tests/conftest.py index dae5cba7..cbca9ea8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -242,15 +242,18 @@ def tidal_tracks(tidal_artists, tidal_albums): def make_playlist(playlist_id, tracks): - return _make_mock( + mock = _make_mock( mock=Mock(spec=UserPlaylist, session=Mock()), name=f"Playlist-{playlist_id}", id=str(playlist_id), uri=f"tidal:playlist:{playlist_id}", - tracks=tracks, num_tracks=len(tracks), last_updated=10, ) + # tracks should be a callable for get_items() pagination support + mock.tracks = Mock(return_value=tracks) + mock.tracks.__name__ = "tracks" + return mock @pytest.fixture diff --git a/tests/test_playlist.py b/tests/test_playlist.py index a2ddef7f..a103bb82 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -16,7 +16,6 @@ @pytest.fixture def tpp(mocker): - mocker.patch("mopidy_tidal.playlists.Timer") backend = mocker.Mock() backend._config = {"tidal": {"playlist_cache_refresh_secs": 0}} @@ -31,7 +30,10 @@ def test_create(tpp, mocker): playlist.tracks.__name__ = "tracks" playlist.tracks.return_value = [] playlist.name = "playlist name" + playlist.num_tracks = 0 backend.session.user.create_playlist.return_value = playlist + # Also mock session.playlist() for the refresh call + backend.session.playlist.return_value = playlist p = tpp.create("playlist") assert p == MopidyPlaylist( last_modified=9, name="playlist name", uri="tidal:playlist:17" @@ -105,10 +107,11 @@ def test_save_no_changes(tpp, mocker, tidal_playlists): session = backend.session tidal_pl = tidal_playlists[0] uri = tidal_pl.uri + tracks_list = tidal_pl.tracks() # Call to get the list mopidy_pl = mocker.Mock( uri=uri, last_modified=10, - tracks=tidal_pl.tracks, + tracks=tracks_list, ) mopidy_pl.name = tidal_pl.name session.playlist.return_value = tidal_pl @@ -204,7 +207,7 @@ def test_refresh_metadata(tpp, mocker, tidal_playlists): tpp, backend = tpp tpp._current_tidal_playlists = tidal_playlists assert not len(tpp._playlists_metadata) - tpp.refresh(include_items=False) + tpp.refresh(metadata_only=True) listener.send.assert_called_once_with("playlists_loaded") @@ -245,7 +248,7 @@ def api_test(tpp, mocker, api_method, tp): api_method.return_value = tracks api_method.__name__ = "get_playlist_tracks" - tpp.refresh(include_items=True) + tpp.refresh() listener.send.assert_called_once_with("playlists_loaded") assert len(tpp._playlists) == 1 playlist = tpp._playlists["tidal:playlist:1-1-1"] @@ -301,6 +304,10 @@ def test_prevent_duplicate_playlist_sync(tpp, mocker, tidal_playlists): def test_playlist_sync_downtime(mocker, tidal_playlists, config): + """Test that periodic refresh updates the playlist cache. + + This tests the PeriodicThread-based refresh mechanism. + """ backend = mocker.Mock() tpp = TidalPlaylistsProvider(backend) tpp._playlists = PlaylistCache(persist=False) @@ -309,24 +316,40 @@ def test_playlist_sync_downtime(mocker, tidal_playlists, config): backend.session.configure_mock(**{"user.favorites.playlists": tidal_playlists[:1]}) backend.session.user.playlists.return_value = tidal_playlists[1:] + + # Initial as_list populates metadata cache tpp.as_list() + + # Add a new playlist to the mock data p = mocker.Mock(spec=TidalPlaylist, session=mocker.Mock, playlist_id="2") p.id = p.playlist_id p.num_tracks = 2 p.name = "Playlist-2" p.last_updated = 10 backend.session.user.playlists.return_value.append(p) + + # as_list is read-only, so it won't see the new playlist yet assert tpp.as_list() == [ Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), ] - sleep(0.1) + + # Start the periodic refresh thread + tpp.start_periodic_refresh() + + # Wait for periodic refresh to run + sleep(0.15) + + # Now as_list should see the new playlist assert tpp.as_list() == [ Ref(name="Playlist-101", type="playlist", uri="tidal:playlist:101"), Ref(name="Playlist-2", type="playlist", uri="tidal:playlist:2"), Ref(name="Playlist-222", type="playlist", uri="tidal:playlist:222"), ] + # Clean up + tpp.stop_periodic_refresh() + def test_update_changes(tpp, mocker, tidal_playlists): tpp, backend = tpp diff --git a/tests/test_playlist_refresh.py b/tests/test_playlist_refresh.py new file mode 100644 index 00000000..9348a8b2 --- /dev/null +++ b/tests/test_playlist_refresh.py @@ -0,0 +1,662 @@ +""" +Tests for playlist refresh behavior. + +These tests cover the refactored refresh logic including: +- Refreshing specific URIs (fetches directly from API) +- Refreshing all playlists (uses _current_tidal_playlists) +- metadata_only parameter behavior +- _playlist_needs_refresh() timestamp comparison +- _do_periodic_refresh() behavior +- as_list() initial population +- _get_or_refresh_playlist() cache miss handling +- Thread management for periodic refresh +""" + +import threading +from unittest.mock import Mock, MagicMock, patch, call + +import pytest +from mopidy.models import Playlist as MopidyPlaylist, Ref, Track + +from mopidy_tidal.playlists import ( + PlaylistCache, + PlaylistMetadataCache, + TidalPlaylistsProvider, +) + + +@pytest.fixture +def mock_backend(mocker): + """Create a mock backend with session.""" + backend = mocker.Mock() + backend._config = {"tidal": {"playlist_cache_refresh_secs": 0}} + backend.session = mocker.Mock() + return backend + + +@pytest.fixture +def playlist_provider(mock_backend): + """Create a TidalPlaylistsProvider with mocked backend.""" + tpp = TidalPlaylistsProvider(mock_backend) + tpp._playlists = PlaylistCache(persist=False) + tpp._playlists_metadata = PlaylistMetadataCache(persist=False) + return tpp + + +@pytest.fixture +def mock_tidal_playlist(mocker): + """Create a mock TidalPlaylist.""" + def _make_playlist(playlist_id="12345", name="Test Playlist", num_tracks=5, last_updated=1000): + pl = mocker.Mock() + pl.id = playlist_id + pl.name = name + pl.num_tracks = num_tracks + pl.last_updated = last_updated + pl.tracks = mocker.Mock() + pl.tracks.__name__ = "tracks" + pl.tracks.return_value = [] + return pl + return _make_playlist + + +@pytest.fixture +def mock_mopidy_playlist(): + """Create a mock MopidyPlaylist.""" + def _make_playlist(uri="tidal:playlist:12345", name="Test Playlist", last_modified=1000, tracks=None): + return MopidyPlaylist( + uri=uri, + name=name, + last_modified=last_modified, + tracks=tracks or [], + ) + return _make_playlist + + +@pytest.fixture +def mock_tracks(mocker): + """Create mock tracks.""" + def _make_tracks(count=3): + tracks = [] + for i in range(count): + track = mocker.Mock() + track.id = i + track.uri = f"tidal:track:{i}:{i}:{i}" + track.name = f"Track-{i}" + track.full_name = f"Track-{i}" + track.artist = mocker.Mock() + track.artist.name = "Artist" + track.artist.id = i + track.album = mocker.Mock() + track.album.name = "Album" + track.album.id = i + track.duration = 180 + track.track_num = i + 1 + track.disc_num = 1 + tracks.append(track) + return tracks + return _make_tracks + + +# ============================================================================= +# Tests for refresh() with specific URIs +# ============================================================================= + +class TestRefreshSpecificUri: + """Tests for refresh() when called with specific URIs.""" + + def test_refresh_specific_uri_fetches_from_api( + self, playlist_provider, mock_backend, mock_tidal_playlist, mocker + ): + """When refresh(uri) is called, it fetches the playlist directly from the API.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + tidal_pl = mock_tidal_playlist(playlist_id="12345", name="My Playlist") + mock_backend.session.playlist.return_value = tidal_pl + + # Mock track retrieval + mocker.patch.object( + playlist_provider, "_retrieve_api_tracks", return_value=[] + ) + mocker.patch( + "mopidy_tidal.playlists.full_models_mappers.create_mopidy_tracks", + return_value=[] + ) + + playlist_provider.refresh("tidal:playlist:12345") + + # Verify session.playlist was called with the correct ID + mock_backend.session.playlist.assert_called_once_with("12345") + + def test_refresh_specific_uri_prunes_cache_before_fetch( + self, playlist_provider, mock_backend, mock_tidal_playlist, mock_mopidy_playlist, mocker + ): + """Cache entries are pruned before fetching fresh data.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + # Pre-populate caches + uri = "tidal:playlist:12345" + playlist_provider._playlists[uri] = mock_mopidy_playlist(uri=uri) + playlist_provider._playlists_metadata[uri] = mock_mopidy_playlist(uri=uri) + + tidal_pl = mock_tidal_playlist(playlist_id="12345") + mock_backend.session.playlist.return_value = tidal_pl + + mocker.patch.object(playlist_provider, "_retrieve_api_tracks", return_value=[]) + mocker.patch( + "mopidy_tidal.playlists.full_models_mappers.create_mopidy_tracks", + return_value=[] + ) + + # Spy on prune methods + prune_spy = mocker.spy(playlist_provider._playlists, "prune") + prune_metadata_spy = mocker.spy(playlist_provider._playlists_metadata, "prune") + + playlist_provider.refresh(uri) + + prune_spy.assert_called_with(uri) + prune_metadata_spy.assert_called_with(uri) + + def test_refresh_specific_uri_not_found( + self, playlist_provider, mock_backend, mocker + ): + """When session.playlist() returns None, no cache update occurs.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + mock_backend.session.playlist.return_value = None + + playlist_provider.refresh("tidal:playlist:nonexistent") + + # Caches should remain empty + assert len(playlist_provider._playlists) == 0 + assert len(playlist_provider._playlists_metadata) == 0 + + def test_refresh_specific_uri_updates_both_caches( + self, playlist_provider, mock_backend, mock_tidal_playlist, mocker + ): + """Both caches are updated with the new playlist data.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + uri = "tidal:playlist:12345" + tidal_pl = mock_tidal_playlist(playlist_id="12345", name="My Playlist", num_tracks=3) + mock_backend.session.playlist.return_value = tidal_pl + + # Create actual Track objects (MopidyPlaylist validates track types) + mock_tracks = [ + Track(uri=f"tidal:track:{i}:{i}:{i}", name=f"Track {i}") + for i in range(3) + ] + mocker.patch.object(playlist_provider, "_retrieve_api_tracks", return_value=[]) + mocker.patch( + "mopidy_tidal.playlists.full_models_mappers.create_mopidy_tracks", + return_value=mock_tracks + ) + + playlist_provider.refresh(uri) + + # Both caches should be updated + assert uri in playlist_provider._playlists + assert uri in playlist_provider._playlists_metadata + assert playlist_provider._playlists[uri].name == "My Playlist" + assert playlist_provider._playlists_metadata[uri].name == "My Playlist" + + +# ============================================================================= +# Tests for refresh() for all playlists +# ============================================================================= + +class TestRefreshAllPlaylists: + """Tests for refresh() when called without URIs (refresh all).""" + + def test_refresh_all_uses_current_tidal_playlists( + self, playlist_provider, mock_backend, mock_tidal_playlist, mocker + ): + """When refresh() is called without URIs, it uses _current_tidal_playlists.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + pl1 = mock_tidal_playlist(playlist_id="111", name="Playlist 1") + pl2 = mock_tidal_playlist(playlist_id="222", name="Playlist 2") + + # Mock _calculate_added_and_removed_playlist_ids to populate _current_tidal_playlists + def populate_playlists(): + playlist_provider._current_tidal_playlists = [pl1, pl2] + return set(), set() + + mocker.patch.object( + playlist_provider, + "_calculate_added_and_removed_playlist_ids", + side_effect=populate_playlists + ) + mocker.patch.object(playlist_provider, "_retrieve_api_tracks", return_value=[]) + mocker.patch( + "mopidy_tidal.playlists.full_models_mappers.create_mopidy_tracks", + return_value=[] + ) + + playlist_provider.refresh() + + playlist_provider._calculate_added_and_removed_playlist_ids.assert_called_once() + assert "tidal:playlist:111" in playlist_provider._playlists + assert "tidal:playlist:222" in playlist_provider._playlists + + def test_refresh_all_clears_both_caches( + self, playlist_provider, mock_backend, mock_mopidy_playlist, mocker + ): + """Both caches are completely cleared before repopulating.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + # Pre-populate caches + playlist_provider._playlists["tidal:playlist:old"] = mock_mopidy_playlist() + playlist_provider._playlists_metadata["tidal:playlist:old"] = mock_mopidy_playlist() + + mocker.patch.object( + playlist_provider, + "_calculate_added_and_removed_playlist_ids", + side_effect=lambda: setattr(playlist_provider, "_current_tidal_playlists", []) or (set(), set()) + ) + + clear_spy = mocker.spy(playlist_provider._playlists, "clear") + clear_metadata_spy = mocker.spy(playlist_provider._playlists_metadata, "clear") + + playlist_provider.refresh() + + clear_spy.assert_called_once() + clear_metadata_spy.assert_called_once() + + +# ============================================================================= +# Tests for metadata_only parameter +# ============================================================================= + +class TestMetadataOnly: + """Tests for the metadata_only parameter.""" + + def test_refresh_metadata_only_skips_track_fetch( + self, playlist_provider, mock_backend, mock_tidal_playlist, mocker + ): + """When metadata_only=True, tracks are not fetched.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + uri = "tidal:playlist:12345" + tidal_pl = mock_tidal_playlist(playlist_id="12345") + mock_backend.session.playlist.return_value = tidal_pl + + retrieve_tracks_mock = mocker.patch.object( + playlist_provider, "_retrieve_api_tracks" + ) + + playlist_provider.refresh(uri, metadata_only=True) + + # _retrieve_api_tracks should NOT be called + retrieve_tracks_mock.assert_not_called() + + # Metadata cache should be updated + assert uri in playlist_provider._playlists_metadata + + # Full playlist cache should NOT be updated + assert uri not in playlist_provider._playlists + + def test_refresh_fetches_tracks_when_not_metadata_only( + self, playlist_provider, mock_backend, mock_tidal_playlist, mocker + ): + """When metadata_only=False (default), tracks are fetched.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + uri = "tidal:playlist:12345" + tidal_pl = mock_tidal_playlist(playlist_id="12345") + mock_backend.session.playlist.return_value = tidal_pl + + retrieve_tracks_mock = mocker.patch.object( + playlist_provider, "_retrieve_api_tracks", return_value=[] + ) + mocker.patch( + "mopidy_tidal.playlists.full_models_mappers.create_mopidy_tracks", + return_value=[] + ) + + playlist_provider.refresh(uri, metadata_only=False) + + # _retrieve_api_tracks SHOULD be called + retrieve_tracks_mock.assert_called_once() + + # Both caches should be updated + assert uri in playlist_provider._playlists_metadata + assert uri in playlist_provider._playlists + + +# ============================================================================= +# Tests for _playlist_needs_refresh() +# ============================================================================= + +class TestPlaylistNeedsRefresh: + """Tests for _playlist_needs_refresh() timestamp comparison.""" + + def test_returns_true_when_cached_is_none( + self, playlist_provider, mock_tidal_playlist + ): + """Returns True when there's no cached playlist.""" + tidal_pl = mock_tidal_playlist() + + result = playlist_provider._playlist_needs_refresh(tidal_pl, None) + + assert result is True + + def test_returns_true_when_upstream_newer( + self, playlist_provider, mock_tidal_playlist, mock_mopidy_playlist + ): + """Returns True when upstream last_updated > cached last_modified.""" + tidal_pl = mock_tidal_playlist(last_updated=2000) + cached = mock_mopidy_playlist(last_modified=1000) + + result = playlist_provider._playlist_needs_refresh(tidal_pl, cached) + + assert result is True + + def test_returns_false_when_cached_is_current( + self, playlist_provider, mock_tidal_playlist, mock_mopidy_playlist + ): + """Returns False when cached timestamp matches upstream.""" + tidal_pl = mock_tidal_playlist(last_updated=1000) + cached = mock_mopidy_playlist(last_modified=1000) + + result = playlist_provider._playlist_needs_refresh(tidal_pl, cached) + + assert result is False + + def test_returns_false_when_cached_is_newer( + self, playlist_provider, mock_tidal_playlist, mock_mopidy_playlist + ): + """Returns False when cached is newer than upstream (edge case).""" + tidal_pl = mock_tidal_playlist(last_updated=1000) + cached = mock_mopidy_playlist(last_modified=2000) + + result = playlist_provider._playlist_needs_refresh(tidal_pl, cached) + + assert result is False + + def test_returns_true_when_no_upstream_timestamp( + self, playlist_provider, mock_mopidy_playlist, mocker + ): + """Returns True when upstream has no last_updated attribute.""" + tidal_pl = mocker.Mock(spec=[]) # No last_updated attribute + cached = mock_mopidy_playlist(last_modified=1000) + + result = playlist_provider._playlist_needs_refresh(tidal_pl, cached) + + assert result is True + + +# ============================================================================= +# Tests for _do_periodic_refresh() +# ============================================================================= + +class TestDoPeriodicRefresh: + """Tests for _do_periodic_refresh() behavior.""" + + def test_periodic_refresh_only_updates_changed_playlists( + self, playlist_provider, mock_backend, mock_tidal_playlist, mock_mopidy_playlist, mocker + ): + """Only playlists with newer last_updated have their tracks fetched.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + # Two playlists: one changed, one unchanged + pl_changed = mock_tidal_playlist(playlist_id="111", name="Changed", last_updated=2000) + pl_unchanged = mock_tidal_playlist(playlist_id="222", name="Unchanged", last_updated=1000) + + # Cache has the unchanged playlist with same timestamp + playlist_provider._playlists["tidal:playlist:222"] = mock_mopidy_playlist( + uri="tidal:playlist:222", last_modified=1000 + ) + + mocker.patch.object( + playlist_provider, + "_calculate_added_and_removed_playlist_ids", + side_effect=lambda: setattr(playlist_provider, "_current_tidal_playlists", [pl_changed, pl_unchanged]) or (set(), set()) + ) + + retrieve_tracks_mock = mocker.patch.object( + playlist_provider, "_retrieve_api_tracks", return_value=[] + ) + mocker.patch( + "mopidy_tidal.playlists.full_models_mappers.create_mopidy_tracks", + return_value=[] + ) + + playlist_provider._do_periodic_refresh() + + # _retrieve_api_tracks should only be called for the changed playlist + assert retrieve_tracks_mock.call_count == 1 + retrieve_tracks_mock.assert_called_with(mock_backend.session, pl_changed) + + def test_periodic_refresh_updates_all_metadata( + self, playlist_provider, mock_backend, mock_tidal_playlist, mock_mopidy_playlist, mocker + ): + """All playlist metadata is updated regardless of change status.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + pl1 = mock_tidal_playlist(playlist_id="111", name="Playlist 1", last_updated=1000) + pl2 = mock_tidal_playlist(playlist_id="222", name="Playlist 2", last_updated=1000) + + # Both cached with same timestamp (no changes) + playlist_provider._playlists["tidal:playlist:111"] = mock_mopidy_playlist( + uri="tidal:playlist:111", last_modified=1000 + ) + playlist_provider._playlists["tidal:playlist:222"] = mock_mopidy_playlist( + uri="tidal:playlist:222", last_modified=1000 + ) + + mocker.patch.object( + playlist_provider, + "_calculate_added_and_removed_playlist_ids", + side_effect=lambda: setattr(playlist_provider, "_current_tidal_playlists", [pl1, pl2]) or (set(), set()) + ) + + playlist_provider._do_periodic_refresh() + + # Both should have metadata updated + assert "tidal:playlist:111" in playlist_provider._playlists_metadata + assert "tidal:playlist:222" in playlist_provider._playlists_metadata + + def test_periodic_refresh_handles_exceptions( + self, playlist_provider, mocker + ): + """Exceptions are caught and logged, don't crash.""" + mocker.patch.object( + playlist_provider, + "_calculate_added_and_removed_playlist_ids", + side_effect=Exception("API Error") + ) + + # Should not raise + playlist_provider._do_periodic_refresh() + + +# ============================================================================= +# Tests for as_list() initial population +# ============================================================================= + +class TestAsListInitialPopulation: + """Tests for as_list() triggering initial refresh.""" + + def test_as_list_triggers_refresh_when_cache_empty( + self, playlist_provider, mock_backend, mocker + ): + """First call to as_list() triggers metadata refresh when cache is empty.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + refresh_mock = mocker.patch.object(playlist_provider, "refresh") + + # Cache is empty + assert len(playlist_provider._playlists_metadata) == 0 + + playlist_provider.as_list() + + refresh_mock.assert_called_once_with(metadata_only=True) + + def test_as_list_returns_cached_data_without_refresh( + self, playlist_provider, mock_mopidy_playlist, mocker + ): + """Returns playlist refs from cache without triggering refresh.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + # Pre-populate cache + playlist_provider._playlists_metadata["tidal:playlist:123"] = mock_mopidy_playlist( + uri="tidal:playlist:123", name="My Playlist" + ) + + refresh_mock = mocker.patch.object(playlist_provider, "refresh") + + result = playlist_provider.as_list() + + # Should not trigger refresh + refresh_mock.assert_not_called() + + # Should return the cached playlist + assert len(result) == 1 + assert result[0].uri == "tidal:playlist:123" + assert result[0].name == "My Playlist" + + +# ============================================================================= +# Tests for _get_or_refresh_playlist() +# ============================================================================= + +class TestGetOrRefreshPlaylist: + """Tests for _get_or_refresh_playlist() cache miss handling.""" + + def test_returns_cached_playlist( + self, playlist_provider, mock_mopidy_playlist, mocker + ): + """Returns cached playlist when available.""" + uri = "tidal:playlist:12345" + cached = mock_mopidy_playlist(uri=uri, name="Cached Playlist") + playlist_provider._playlists[uri] = cached + + refresh_mock = mocker.patch.object(playlist_provider, "refresh") + + result = playlist_provider._get_or_refresh_playlist(uri) + + assert result == cached + refresh_mock.assert_not_called() + + def test_triggers_refresh_on_cache_miss( + self, playlist_provider, mock_backend, mock_mopidy_playlist, mocker + ): + """Triggers refresh when playlist not in cache.""" + mocker.patch("mopidy_tidal.playlists.backend.BackendListener") + + uri = "tidal:playlist:12345" + + # Set up refresh to populate the cache + def populate_cache(*args, **kwargs): + playlist_provider._playlists[uri] = mock_mopidy_playlist(uri=uri) + + refresh_mock = mocker.patch.object( + playlist_provider, "refresh", side_effect=populate_cache + ) + + result = playlist_provider._get_or_refresh_playlist(uri) + + refresh_mock.assert_called_once_with(uri) + assert result is not None + + def test_handles_mix_uri( + self, playlist_provider, mock_backend, mocker + ): + """Handles mix:// URIs by looking up the mix.""" + mock_mix = mocker.Mock() + mock_mix.items.return_value = [] + mock_backend.session.mix.return_value = mock_mix + + mocker.patch( + "mopidy_tidal.playlists.full_models_mappers.create_mopidy_mix_playlist", + return_value=MopidyPlaylist(uri="tidal:mix:abc", name="Mix") + ) + + result = playlist_provider._get_or_refresh_playlist("tidal:mix:abc") + + mock_backend.session.mix.assert_called_once_with("abc") + assert result.uri == "tidal:mix:abc" + + +# ============================================================================= +# Tests for thread management +# ============================================================================= + +class TestThreadManagement: + """Tests for periodic refresh thread management.""" + + def test_start_periodic_refresh_does_nothing_when_config_not_set( + self, playlist_provider, mock_backend + ): + """Thread is not started when playlist_cache_refresh_secs is not configured.""" + mock_backend._config = {"tidal": {}} + + playlist_provider.start_periodic_refresh() + + assert playlist_provider._refresh_thread is None + + def test_start_periodic_refresh_does_nothing_when_config_zero( + self, playlist_provider, mock_backend + ): + """Thread is not started when config is 0.""" + mock_backend._config = {"tidal": {"playlist_cache_refresh_secs": 0}} + + playlist_provider.start_periodic_refresh() + + assert playlist_provider._refresh_thread is None + + def test_start_periodic_refresh_does_nothing_when_config_negative( + self, playlist_provider, mock_backend + ): + """Thread is not started when config is negative.""" + mock_backend._config = {"tidal": {"playlist_cache_refresh_secs": -1}} + + playlist_provider.start_periodic_refresh() + + assert playlist_provider._refresh_thread is None + + def test_start_periodic_refresh_starts_thread( + self, playlist_provider, mock_backend, mocker + ): + """Thread is started with correct period when config is valid.""" + mock_backend._config = {"tidal": {"playlist_cache_refresh_secs": 300}} + + mock_thread_class = mocker.patch("mopidy_tidal.playlists.PeriodicThread") + mock_thread_instance = mocker.Mock() + mock_thread_class.return_value = mock_thread_instance + + playlist_provider.start_periodic_refresh() + + mock_thread_class.assert_called_once_with( + target=playlist_provider._do_periodic_refresh, + period=300, + name="tidal-playlist-refresh", + daemon=True + ) + mock_thread_instance.start.assert_called_once() + assert playlist_provider._refresh_thread is mock_thread_instance + + def test_stop_periodic_refresh_stops_thread( + self, playlist_provider, mocker + ): + """Thread is stopped and joined cleanly.""" + mock_thread = mocker.Mock() + mock_thread.is_alive.return_value = False + playlist_provider._refresh_thread = mock_thread + + playlist_provider.stop_periodic_refresh() + + mock_thread.stop.assert_called_once() + mock_thread.join.assert_called_once_with(timeout=5) + assert playlist_provider._refresh_thread is None + + def test_stop_periodic_refresh_does_nothing_when_no_thread( + self, playlist_provider + ): + """Does nothing when there's no thread running.""" + playlist_provider._refresh_thread = None + + # Should not raise + playlist_provider.stop_periodic_refresh() + + assert playlist_provider._refresh_thread is None \ No newline at end of file