Adjust Tidal provider to a fully async implementation (#1995)
authorJozef Kruszynski <60214390+jozefKruszynski@users.noreply.github.com>
Wed, 5 Mar 2025 22:44:23 +0000 (23:44 +0100)
committerGitHub <noreply@github.com>
Wed, 5 Mar 2025 22:44:23 +0000 (23:44 +0100)
music_assistant/helpers/app_vars.py
music_assistant/providers/tidal/__init__.py
music_assistant/providers/tidal/auth_manager.py [new file with mode: 0644]
music_assistant/providers/tidal/helpers.py [deleted file]
music_assistant/providers/tidal/manifest.json
requirements_all.txt

index 2ec9bb8da75891fa15bf2c858ded28f3a49f4843..9bbbabec81ca568e71ac80d406445eabede6f4e6 100644 (file)
@@ -2,4 +2,4 @@
 # fmt: off
 # flake8: noqa
 # ruff: noqa
-(lambda __g: [(lambda __mod: [[[None for __g['app_var'], app_var.__name__ in [(lambda index: (lambda __l: [[AV(aap(__l['var'].encode()).decode()) for __l['var'] in [(vars.split('acb2')[__l['index']][::(-1)])]][0] for __l['index'] in [(index)]][0])({}), 'app_var')]][0] for __g['vars'] in [('3YTNyUDOyQTOacb2=EmN5M2YjdzMhljYzYzYhlDMmFGNlVTOmNDZwMzNxYzNacb2=UDMzEGOyADO1QWO5kDNygTMlJGN5QzNzIWOmZTOiVmMacb2yMTNzITNacb2=UDZhJmMldTZ3QTY4IjZ3kTNxYjN0czNwI2YxkTM5MjNacb2==QMh5WOmZnewM2d4UDblRzZacb20QzMwAjNacb2=QzNiRTO3EjMjFzMldjY3QTMwEDMwADMiNWZ5UWO3UWMacb2RJ1UJpXUPlzdvZUZ0w2VzVjMq1mblRnZvBHZ4x2RMZWbqhVS5JkdQ1WS38FVDpFTw9WcthGb41GaoV3dQV1QHNVRutUMjRFe09VeGh1RO1yQFtkZ3RnL5IERNJTVE5EerpWTzUkaPlWUYlFcKNET3FkaOlXSU9EMRpnT49maJdHaYpVa3lWS0MXVUpXSU5URWRlT1kUaPlWTzMGcKlXZuElZpFVMWtkSp9UaBhVZwo0QMl2Yq1UevtWVyEFbUNTWqlkNJNkWwRXbJNXSp5UMJpXVGpUaPl2YHJGaKlXZ')]][0] for __g['aap'] in [(__mod.b64decode)]][0])(__import__('base64', __g, __g, ('b64decode',), 0)) for __g['AV'] in [((lambda b, d: d.get('__metaclass__', getattr(b[0], '__class__', type(b[0])))('AV', b, d))((str,), (lambda __l: [__l for __l['__repr__'], __l['__repr__'].__name__ in [(lambda self: (lambda __l: [__name__ for __l['self'] in [(self)]][0])({}), '__repr__')]][0])({'__module__': __name__})))]][0])(globals()) # type: ignore
+(lambda __g: [(lambda __mod: [[[None for __g['app_var'], app_var.__name__ in [(lambda index: (lambda __l: [[AV(aap(__l['var'].encode()).decode()) for __l['var'] in [(vars.split('acb2')[__l['index']][::(-1)])]][0] for __l['index'] in [(index)]][0])({}), 'app_var')]][0] for __g['vars'] in [('3YTNyUDOyQTOacb2=EmN5M2YjdzMhljYzYzYhlDMmFGNlVTOmNDZwMzNxYzNacb2=UDMzEGOyADO1QWO5kDNygTMlJGN5QzNzIWOmZTOiVmMacb2yMTNzITNacb2=UDZhJmMldTZ3QTY4IjZ3kTNxYjN0czNwI2YxkTM5MjNacb2==QMh5WOmZnewM2d4UDblRzZacb20QzMwAjNacb2=QzNiRTO3EjMjFzMldjY3QTMwEDMwADMiNWZ5UWO3UWMacb2RJ1UJpXUPlzdvZUZ0w2VzVjMq1mblRnZvBHZ4x2RMZWbqhVS5JkdQ1WS38FVDpFTw9WcthGb41GaoV3dQV1QHNVRutUMjRFe09VeGh1RO1yQFtkZ3RnL5IERNJTVE5EerpWTzUkaPlWUYlFcKNET3FkaOlXSU9EMRpnT49maJdHaYpVa3lWS0MXVUpXSU5URWRlT1kUaPlWTzMGcKlXZuElZpFVMWtkSp9UaBhVZwo0QMl2Yq1UevtWVyEFbUNTWqlkNJNkWwRXbJNXSp5UMJpXVGpUaPl2YHJGaKlXZacb2==QVnRlQFFHa5sEckJ1UEJkNacb2=0DOHRla6N3YJZjTxFUVlhmTWFTYrh2czkTUjFETilUS5oFci52NZ1GU1VGe')]][0] for __g['aap'] in [(__mod.b64decode)]][0])(__import__('base64', __g, __g, ('b64decode',), 0)) for __g['AV'] in [((lambda b, d: d.get('__metaclass__', getattr(b[0], '__class__', type(b[0])))('AV', b, d))((str,), (lambda __l: [__l for __l['__repr__'], __l['__repr__'].__name__ in [(lambda self: (lambda __l: [__name__ for __l['self'] in [(self)]][0])({}), '__repr__')]][0])({'__module__': __name__})))]][0])(globals()) # type: ignore
index 05bd51c9235e4bb76a32ab8fa59bb952c81db035..ca6aa8b83f12bd670872c78f9a8368c70c9b0b5f 100644 (file)
@@ -3,15 +3,19 @@
 from __future__ import annotations
 
 import asyncio
-import base64
-import pickle
-from collections.abc import Callable
+import functools
+import json
+from collections.abc import Awaitable, Callable, Coroutine
 from contextlib import suppress
-from datetime import datetime, timedelta
 from enum import StrEnum
-from typing import TYPE_CHECKING, ParamSpec, TypeVar, cast
+from typing import TYPE_CHECKING, Any, TypeVar, cast
 
-from music_assistant_models.config_entries import ConfigEntry, ConfigValueOption, ConfigValueType
+from aiohttp import ClientResponse
+from music_assistant_models.config_entries import (
+    ConfigEntry,
+    ConfigValueOption,
+    ConfigValueType,
+)
 from music_assistant_models.enums import (
     AlbumType,
     ConfigEntryType,
@@ -22,7 +26,11 @@ from music_assistant_models.enums import (
     ProviderFeature,
     StreamType,
 )
-from music_assistant_models.errors import LoginFailed, MediaNotFoundError
+from music_assistant_models.errors import (
+    LoginFailed,
+    MediaNotFoundError,
+    ResourceTemporarilyUnavailable,
+)
 from music_assistant_models.media_items import (
     Album,
     Artist,
@@ -37,52 +45,22 @@ from music_assistant_models.media_items import (
     UniqueList,
 )
 from music_assistant_models.streamdetails import StreamDetails
-from tidalapi import Album as TidalAlbum
-from tidalapi import Artist as TidalArtist
-from tidalapi import Config as TidalConfig
-from tidalapi import Playlist as TidalPlaylist
-from tidalapi import Session as TidalSession
-from tidalapi import Track as TidalTrack
-from tidalapi import exceptions as tidal_exceptions
-
-from music_assistant.constants import CACHE_CATEGORY_DEFAULT, CACHE_CATEGORY_MEDIA_INFO
-from music_assistant.helpers.auth import AuthenticationHelper
-from music_assistant.helpers.tags import AudioTags, async_parse_tags
-from music_assistant.helpers.throttle_retry import ThrottlerManager, throttle_with_retries
-from music_assistant.models.music_provider import MusicProvider
 
-from .helpers import (
-    DEFAULT_LIMIT,
-    add_playlist_tracks,
-    create_playlist,
-    get_album,
-    get_album_tracks,
-    get_artist,
-    get_artist_albums,
-    get_artist_toptracks,
-    get_library_albums,
-    get_library_artists,
-    get_library_playlists,
-    get_library_tracks,
-    get_playlist,
-    get_playlist_tracks,
-    get_similar_tracks,
-    get_stream,
-    get_track,
-    get_track_lyrics,
-    get_tracks_by_isrc,
-    library_items_add_remove,
-    remove_playlist_tracks,
-    search,
+from music_assistant.constants import CACHE_CATEGORY_DEFAULT
+from music_assistant.helpers.throttle_retry import (
+    ThrottlerManager,
+    throttle_with_retries,
 )
+from music_assistant.models.music_provider import MusicProvider
+
+from .auth_manager import ManualAuthenticationHelper, TidalAuthManager
 
 if TYPE_CHECKING:
-    from collections.abc import AsyncGenerator, Awaitable
+    from collections.abc import AsyncGenerator
 
+    from aiohttp import ClientResponse
     from music_assistant_models.config_entries import ProviderConfig
     from music_assistant_models.provider import ProviderManifest
-    from tidalapi.media import Lyrics as TidalLyrics
-    from tidalapi.media import Stream as TidalStream
 
     from music_assistant.mass import MusicAssistant
     from music_assistant.models import ProviderInstanceType
@@ -103,6 +81,8 @@ CONF_AUTH_TOKEN = "auth_token"
 CONF_REFRESH_TOKEN = "refresh_token"
 CONF_USER_ID = "user_id"
 CONF_EXPIRY_TIME = "expiry_time"
+CONF_COUNTRY_CODE = "country_code"
+CONF_SESSION_ID = "session_id"
 CONF_QUALITY = "quality"
 
 # Labels
@@ -113,8 +93,9 @@ LABEL_COMPLETE_PKCE_LOGIN = "complete_pkce_login_label"
 BROWSE_URL = "https://tidal.com/browse"
 RESOURCES_URL = "https://resources.tidal.com/images"
 
-_R = TypeVar("_R")
-_P = ParamSpec("_P")
+DEFAULT_LIMIT = 50
+
+T = TypeVar("T")
 
 
 class TidalQualityEnum(StrEnum):
@@ -131,36 +112,6 @@ async def setup(
     return TidalProvider(mass, manifest, config)
 
 
-async def tidal_auth_url(auth_helper: AuthenticationHelper, quality: str) -> str:
-    """Generate the Tidal authentication URL."""
-
-    def inner() -> str:
-        config = TidalConfig(quality=quality, item_limit=10000, alac=False)
-        session = TidalSession(config=config)
-        url = session.pkce_login_url()
-        # Schedule auth_helper.send_url to run in event loop
-        auth_helper.mass.loop.call_soon_threadsafe(auth_helper.send_url, url)
-        session_bytes = pickle.dumps(session)
-        base64_bytes = base64.b64encode(session_bytes)
-        return base64_bytes.decode("utf-8")
-
-    return await asyncio.to_thread(inner)
-
-
-async def tidal_pkce_login(base64_session: str, url: str) -> TidalSession:
-    """Async wrapper around the tidalapi Session function."""
-
-    def inner() -> TidalSession:
-        base64_bytes = base64_session.encode("utf-8")
-        message_bytes = base64.b64decode(base64_bytes)
-        session = pickle.loads(message_bytes)  # noqa: S301
-        token = session.pkce_get_auth_token(url_redirect=url)
-        session.process_auth_token(token)
-        return session
-
-    return await asyncio.to_thread(inner)
-
-
 async def get_config_entries(
     mass: MusicAssistant,
     instance_id: str | None = None,  # noqa: ARG001
@@ -177,11 +128,11 @@ async def get_config_entries(
     assert values is not None
 
     if action == CONF_ACTION_START_PKCE_LOGIN:
-        async with AuthenticationHelper(mass, cast(str, values["session_id"])) as auth_helper:
+        async with ManualAuthenticationHelper(mass, cast(str, values["session_id"])) as auth_helper:
             quality = str(values.get(CONF_QUALITY))
-            base64_session = await tidal_auth_url(auth_helper, quality)
+            base64_session = await TidalAuthManager.generate_auth_url(auth_helper, quality)
             values[CONF_TEMP_SESSION] = base64_session
-            # Tidal is (ab)using the AuthenticationHelper just to send the user to an URL
+            # Tidal is using the ManualAuthenticationHelper just to send the user to an URL
             # there is no actual oauth callback happening, instead the user is redirected
             # to a non-existent page and needs to copy the URL from the browser and paste it
             # we simply wait here to allow the user to start the auth
@@ -191,19 +142,20 @@ async def get_config_entries(
         quality = str(values.get(CONF_QUALITY))
         pkce_url = str(values.get(CONF_OOPS_URL))
         base64_session = str(values.get(CONF_TEMP_SESSION))
-        tidal_session = await tidal_pkce_login(base64_session, pkce_url)
-        if not tidal_session.check_login():
-            msg = "Authentication to Tidal failed"
-            raise LoginFailed(msg)
-        # set the retrieved token on the values object to pass along
-        values[CONF_AUTH_TOKEN] = tidal_session.access_token
-        values[CONF_REFRESH_TOKEN] = tidal_session.refresh_token
-        values[CONF_EXPIRY_TIME] = tidal_session.expiry_time.isoformat()
-        values[CONF_USER_ID] = str(tidal_session.user.id)
+        auth_data = await TidalAuthManager.process_pkce_login(
+            mass.http_session, base64_session, pkce_url
+        )
+        values[CONF_AUTH_TOKEN] = auth_data["access_token"]
+        values[CONF_REFRESH_TOKEN] = auth_data["refresh_token"]
+        values[CONF_EXPIRY_TIME] = auth_data["expires_at"]
+        values[CONF_USER_ID] = auth_data["userId"]
         values[CONF_TEMP_SESSION] = ""
 
     if action == CONF_ACTION_CLEAR_AUTH:
         values[CONF_AUTH_TOKEN] = None
+        values[CONF_REFRESH_TOKEN] = None
+        values[CONF_EXPIRY_TIME] = None
+        values[CONF_USER_ID] = None
 
     if values.get(CONF_AUTH_TOKEN):
         auth_entries: tuple[ConfigEntry, ...] = (
@@ -311,7 +263,7 @@ async def get_config_entries(
             ),
         )
 
-    # return the collected config entries
+    # return the auth_data config entry
     return (
         *auth_entries,
         ConfigEntry(
@@ -351,16 +303,80 @@ async def get_config_entries(
 class TidalProvider(MusicProvider):
     """Implementation of a Tidal MusicProvider."""
 
-    _tidal_session: TidalSession | None = None
-    _tidal_user_id: str
-    # rate limiter needs to be specified on provider-level,
-    # so make it an class attribute
+    BASE_URL: str = "https://api.tidal.com/v1"
+    OPEN_API_URL: str = "https://openapi.tidal.com/v2/"
+
     throttler = ThrottlerManager(rate_limit=1, period=2)
 
+    #
+    # INITIALIZATION & SETUP
+    #
+
+    def __init__(self, mass: MusicAssistant, manifest: ProviderManifest, config: ProviderConfig):
+        """Initialize Tidal provider."""
+        super().__init__(mass, manifest, config)
+        self.auth = TidalAuthManager(
+            http_session=mass.http_session,
+            config_updater=self._update_auth_config,
+            logger=self.logger,
+        )
+
+    def _update_auth_config(self, auth_info: dict[str, Any]) -> None:
+        """Update auth config with new auth info."""
+        self.update_config_value(CONF_AUTH_TOKEN, auth_info["access_token"], encrypted=True)
+        self.update_config_value(CONF_REFRESH_TOKEN, auth_info["refresh_token"], encrypted=True)
+        self.update_config_value(CONF_EXPIRY_TIME, auth_info["expires_at"])
+        self.update_config_value(CONF_USER_ID, auth_info["userId"])
+        # Also update country/session for backward compatibility
+        # if "countryCode" in auth_info:
+        #    self.update_config_value(CONF_COUNTRY_CODE, auth_info["countryCode"])
+        # if "sessionId" in auth_info:
+        #    self.update_config_value(CONF_SESSION_ID, auth_info["sessionId"])
+
     async def handle_async_init(self) -> None:
         """Handle async initialization of the provider."""
-        self._tidal_user_id = str(self.config.get_value(CONF_USER_ID))
-        await self._get_tidal_session()
+        # Load auth info from individual config values
+        access_token = self.config.get_value(CONF_AUTH_TOKEN)
+        refresh_token = self.config.get_value(CONF_REFRESH_TOKEN)
+        expires_at = self.config.get_value(CONF_EXPIRY_TIME)
+        user_id = self.config.get_value(CONF_USER_ID)
+
+        if not access_token or not refresh_token:
+            raise LoginFailed("Missing authentication data")
+
+        # Handle conversion from ISO format to timestamp if needed
+        if isinstance(expires_at, str) and "T" in expires_at:
+            # This looks like an ISO format date
+            import datetime
+
+            try:
+                dt = datetime.datetime.fromisoformat(expires_at)
+                # Convert to timestamp
+                expires_at = dt.timestamp()
+                # Update the config with the numeric value
+                self.update_config_value(CONF_EXPIRY_TIME, expires_at)
+            except ValueError:
+                self.logger.warning(
+                    "Could not parse expiry time %s, setting to expired", expires_at
+                )
+                expires_at = 0
+
+        # Create auth data dictionary from individual config values
+        auth_data = {
+            "access_token": access_token,
+            "refresh_token": refresh_token,
+            "expires_at": expires_at,
+            "userId": user_id,
+        }
+
+        # Initialize auth manager
+        if not await self.auth.initialize(json.dumps(auth_data)):
+            raise LoginFailed("Failed to authenticate with Tidal")
+
+        # Get user information from sessions API
+        api_result = await self._get_data("sessions")
+        user_info = self._extract_data(api_result)
+        await self.auth.update_user_info(user_info)
 
     @property
     def supported_features(self) -> set[ProviderFeature]:
@@ -383,6 +399,242 @@ class TidalProvider(MusicProvider):
             ProviderFeature.PLAYLIST_TRACKS_EDIT,
         }
 
+    #
+    # API REQUEST HELPERS & DECORATORS
+    #
+
+    @staticmethod
+    def prepare_api_request(method: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
+        """Prepare API requests with authentication and common parameters."""
+
+        @functools.wraps(method)
+        async def wrapper(self: TidalProvider, endpoint: str, **kwargs: Any) -> T:
+            # Ensure we have a valid token through auth manager
+            if not await self.auth.ensure_valid_token():
+                raise LoginFailed("Failed to authenticate with Tidal")
+
+            # Add required parameters to every request
+            params = kwargs.pop("params", {}) or {}
+
+            # Add session ID and country code if available
+            if self.auth.session_id:
+                params["sessionId"] = self.auth.session_id
+
+            if self.auth.country_code:
+                params["countryCode"] = self.auth.country_code
+
+            kwargs["params"] = params
+
+            # Prepare headers
+            headers = kwargs.pop("headers", {}) or {}
+            headers["Authorization"] = f"Bearer {self.auth.access_token}"
+
+            # Add locale headers
+            locale = self.mass.metadata.locale.replace("_", "-")
+            language = locale.split("-")[0]
+            headers["Accept-Language"] = f"{locale}, {language};q=0.9, *;q=0.5"
+            kwargs["headers"] = headers
+
+            return await method(self, endpoint, **kwargs)
+
+        return wrapper
+
+    @staticmethod
+    def handle_item_errors(
+        item_type: str,
+    ) -> Callable[[Callable[..., Coroutine[Any, Any, T]]], Callable[..., Coroutine[Any, Any, T]]]:
+        """Handle standard error patterns in item getters."""
+
+        def decorator(
+            method: Callable[..., Coroutine[Any, Any, T]],
+        ) -> Callable[..., Coroutine[Any, Any, T]]:
+            @functools.wraps(method)
+            async def wrapper(self: TidalProvider, item_id: str, *args: Any, **kwargs: Any) -> T:
+                try:
+                    return await method(self, item_id, *args, **kwargs)
+                except ResourceTemporarilyUnavailable:
+                    raise
+                except Exception as err:
+                    raise MediaNotFoundError(f"{item_type} {item_id} not found") from err
+
+            return wrapper
+
+        return decorator
+
+    #
+    # CORE API METHODS
+    #
+
+    @throttle_with_retries
+    @prepare_api_request
+    async def _get_data(
+        self, endpoint: str, **kwargs: Any
+    ) -> dict[str, Any] | tuple[dict[str, Any], str]:
+        """Get data from Tidal API using mass.http_session."""
+        # Check if we want to return the ETag
+        return_etag = kwargs.pop("return_etag", False)
+
+        base_url = kwargs.pop("base_url", self.BASE_URL)
+        url = f"{base_url}/{endpoint}"
+
+        self.logger.debug("Making request to Tidal API: %s", endpoint)
+
+        async with self.mass.http_session.get(url, **kwargs) as response:
+            return await self._handle_response(response, return_etag)
+
+    @prepare_api_request
+    async def _post_data(
+        self,
+        endpoint: str,
+        data: dict[str, Any] | None = None,
+        as_form: bool = False,
+        **kwargs: Any,
+    ) -> dict[str, Any]:
+        """Post data to Tidal API using mass.http_session."""
+        url = f"{self.BASE_URL}/{endpoint}"
+
+        if as_form:
+            # Set content type for form data
+            headers = kwargs.get("headers", {})
+            headers["Content-Type"] = "application/x-www-form-urlencoded"
+            kwargs["headers"] = headers
+            # Use data parameter for form-encoded data
+            async with self.mass.http_session.post(url, data=data, **kwargs) as response:
+                return cast(
+                    dict[str, Any],
+                    await self._handle_response(response, return_etag=False),
+                )
+        else:
+            # Use json parameter for JSON data (default)
+            async with self.mass.http_session.post(url, json=data, **kwargs) as response:
+                return cast(
+                    dict[str, Any],
+                    await self._handle_response(response, return_etag=False),
+                )
+
+    @prepare_api_request
+    async def _delete_data(
+        self, endpoint: str, data: dict[str, Any] | None = None, **kwargs: Any
+    ) -> dict[str, Any]:
+        """Delete data from Tidal API using mass.http_session."""
+        url = f"{self.BASE_URL}/{endpoint}"
+        self.logger.debug("Making DELETE request to Tidal API: %s", endpoint)
+
+        # For DELETE requests with a body, we need to use json parameter
+        async with self.mass.http_session.delete(url, json=data, **kwargs) as response:
+            return cast(dict[str, Any], await self._handle_response(response, return_etag=False))
+
+    async def _handle_response(
+        self, response: ClientResponse, return_etag: bool = False
+    ) -> dict[str, Any] | tuple[dict[str, Any], str]:
+        """Handle API response and common error conditions."""
+        # Handle error responses
+        if response.status == 401:
+            # Authentication error is handled by the calling method (which will retry)
+            raise LoginFailed("Authentication failed")
+
+        if response.status == 404:
+            raise MediaNotFoundError(f"Item not found: {response.url}")
+
+        if response.status == 429:
+            retry_after = int(response.headers.get("Retry-After", 30))
+            raise ResourceTemporarilyUnavailable(
+                "Tidal Rate limit reached", backoff_time=retry_after
+            )
+
+        if response.status == 412:
+            text = await response.text()
+            self.logger.error("Precondition failed: %s", text)
+            raise ResourceTemporarilyUnavailable(
+                "Resource changed while updating, please try again"
+            )
+
+        if response.status >= 400:
+            text = await response.text()
+            self.logger.error("API error: %s - %s", response.status, text)
+            raise ResourceTemporarilyUnavailable("API error")
+
+        # Parse successful response
+        try:
+            # Check if there's content to parse
+            if (
+                response.content_length == 0
+                or not response.content_type
+                or response.content_type == ""
+            ):
+                # Empty response, return success indicator
+                data = {"success": True}
+            else:
+                data = await response.json()
+
+            # Return with etag if requested
+            if return_etag:
+                etag = response.headers.get("ETag", "")
+                return data, etag
+            return data
+        except json.JSONDecodeError as err:
+            self.logger.error("Failed to parse JSON response: %s", err)
+            raise ResourceTemporarilyUnavailable("Failed to parse response") from err
+        except (TypeError, ValueError, KeyError) as err:
+            self.logger.error("Invalid response format: %s", err)
+            raise ResourceTemporarilyUnavailable("Invalid response format") from err
+
+    async def _paginate_api(
+        self,
+        endpoint: str,
+        item_key: str = "items",
+        nested_key: str | None = None,
+        limit: int = DEFAULT_LIMIT,
+        **kwargs: Any,
+    ) -> AsyncGenerator[Any, None]:
+        """Paginate through all items from a Tidal API endpoint."""
+        offset = 0
+        while True:
+            # Get a batch of items
+            params = {"limit": limit, "offset": offset}
+            if "params" in kwargs:
+                params.update(kwargs.pop("params"))
+
+            api_result = await self._get_data(endpoint, params=params, **kwargs)
+            response = self._extract_data(api_result)
+
+            # Extract items from response
+            items = response.get(item_key, [])
+            if not items:
+                break
+
+            # Process each item in the batch
+            for item in items:
+                if nested_key and nested_key in item and item[nested_key]:
+                    yield item[nested_key]
+                else:
+                    yield item
+
+            # Update offset for next batch
+            offset += len(items)
+
+            # Stop if we've received fewer items than the limit
+            if len(items) < limit:
+                break
+
+    def _extract_data(
+        self, api_result: dict[str, Any] | tuple[dict[str, Any], str]
+    ) -> dict[str, Any]:
+        """Extract data from API result that might be tuple of (data, etag)."""
+        return api_result[0] if isinstance(api_result, tuple) else api_result
+
+    def _extract_data_and_etag(
+        self, api_result: dict[str, Any] | tuple[dict[str, Any], str]
+    ) -> tuple[dict[str, Any], str | None]:
+        """Extract both data and etag from API result."""
+        if isinstance(api_result, tuple):
+            return api_result
+        return api_result, None
+
+    #
+    # SEARCH & DISCOVERY
+    #
+
     async def search(
         self,
         search_query: str,
@@ -404,197 +656,180 @@ class TidalProvider(MusicProvider):
         if not media_types:
             return parsed_results
 
-        tidal_session = await self._get_tidal_session()
-        search_query = search_query.replace("'", "")
-        results = await search(tidal_session, search_query, media_types, limit)
+        api_result = await self._get_data(
+            "search",
+            params={
+                "query": search_query.replace("'", ""),
+                "limit": limit,
+                "types": ",".join(media_types),
+            },
+        )
+
+        # Handle potential tuple return (data, etag)
+        results = self._extract_data(api_result)
 
         if results["artists"]:
-            parsed_results.artists = [self._parse_artist(artist) for artist in results["artists"]]
+            parsed_results.artists = [
+                self._parse_artist(artist) for artist in results["artists"]["items"]
+            ]
         if results["albums"]:
-            parsed_results.albums = [self._parse_album(album) for album in results["albums"]]
+            parsed_results.albums = [
+                self._parse_album(album) for album in results["albums"]["items"]
+            ]
         if results["playlists"]:
             parsed_results.playlists = [
-                self._parse_playlist(playlist) for playlist in results["playlists"]
+                self._parse_playlist(playlist) for playlist in results["playlists"]["items"]
             ]
         if results["tracks"]:
-            parsed_results.tracks = [self._parse_track(track) for track in results["tracks"]]
+            parsed_results.tracks = [
+                self._parse_track(track) for track in results["tracks"]["items"]
+            ]
         return parsed_results
 
-    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
-        """Retrieve all library artists from Tidal."""
-        tidal_session = await self._get_tidal_session()
-        artist: TidalArtist  # satisfy the type checker
-        async for artist in self._iter_items(
-            get_library_artists, tidal_session, self._tidal_user_id, limit=DEFAULT_LIMIT
-        ):
-            yield self._parse_artist(artist)
+    @handle_item_errors("Track")
+    async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
+        """Get similar tracks for given track id."""
+        api_result = await self._get_data(f"tracks/{prov_track_id}/radio", params={"limit": limit})
+        similar_tracks = self._extract_data(api_result)
+        return [self._parse_track(track_obj) for track_obj in similar_tracks.get("items", [])]
 
-    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
-        """Retrieve all library albums from Tidal."""
-        tidal_session = await self._get_tidal_session()
-        album: TidalAlbum  # satisfy the type checker
-        async for album in self._iter_items(
-            get_library_albums, tidal_session, self._tidal_user_id, limit=DEFAULT_LIMIT
-        ):
-            yield self._parse_album(album)
+    #
+    # ITEM RETRIEVAL METHODS
+    #
 
-    async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
-        """Retrieve library tracks from Tidal."""
-        tidal_session = await self._get_tidal_session()
-        track: TidalTrack  # satisfy the type checker
-        async for track in self._iter_items(
-            get_library_tracks, tidal_session, self._tidal_user_id, limit=DEFAULT_LIMIT
-        ):
-            yield self._parse_track(track)
+    @handle_item_errors("Artist")
+    async def get_artist(self, prov_artist_id: str) -> Artist:
+        """Get artist details for given artist id."""
+        api_result = await self._get_data(f"artists/{prov_artist_id}")
+        artist_obj = self._extract_data(api_result)
+        return self._parse_artist(artist_obj)
 
-    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
-        """Retrieve all library playlists from the provider."""
-        tidal_session = await self._get_tidal_session()
-        playlist: TidalPlaylist  # satisfy the type checker
-        async for playlist in self._iter_items(
-            get_library_playlists, tidal_session, self._tidal_user_id
-        ):
-            yield self._parse_playlist(playlist)
+    @handle_item_errors("Album")
+    async def get_album(self, prov_album_id: str) -> Album:
+        """Get album details for given album id."""
+        api_result = await self._get_data(f"albums/{prov_album_id}")
+        album_obj = self._extract_data(api_result)
+        return self._parse_album(album_obj)
 
-    @throttle_with_retries
+    @handle_item_errors("Track")
+    async def get_track(self, prov_track_id: str) -> Track:
+        """Get track details for given track id."""
+        api_result = await self._get_data(f"tracks/{prov_track_id}")
+        track_obj = self._extract_data(api_result)
+        track = self._parse_track(track_obj)
+        # Get additional details like lyrics if needed
+        with suppress(MediaNotFoundError):
+            api_result = await self._get_data(f"tracks/{prov_track_id}/lyrics")
+            lyrics_data = self._extract_data(api_result)
+
+            if lyrics_data and "text" in lyrics_data:
+                track.metadata.lyrics = lyrics_data["text"]
+
+        return track
+
+    @handle_item_errors("Playlist")
+    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
+        """Get playlist details for given playlist id."""
+        api_result = await self._get_data(f"playlists/{prov_playlist_id}")
+        playlist_obj = self._extract_data(api_result)
+        return self._parse_playlist(playlist_obj)
+
+    @handle_item_errors("Track")
     async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
         """Get album tracks for given album id."""
-        tidal_session = await self._get_tidal_session()
-        tracks_obj = await get_album_tracks(tidal_session, prov_album_id)
-        return [self._parse_track(track_obj=track_obj) for track_obj in tracks_obj]
+        api_result = await self._get_data(f"albums/{prov_album_id}/tracks")
+        album_tracks = self._extract_data(api_result)
+        return [self._parse_track(track_obj) for track_obj in album_tracks.get("items", [])]
 
-    @throttle_with_retries
+    @handle_item_errors("Album")
     async def get_artist_albums(self, prov_artist_id: str) -> list[Album]:
         """Get a list of all albums for the given artist."""
-        tidal_session = await self._get_tidal_session()
-        artist_albums_obj = await get_artist_albums(tidal_session, prov_artist_id)
-        return [self._parse_album(album) for album in artist_albums_obj]
+        api_result = await self._get_data(f"artists/{prov_artist_id}/albums")
+        artist_albums = self._extract_data(api_result)
+        return [self._parse_album(album_obj) for album_obj in artist_albums.get("items", [])]
 
-    @throttle_with_retries
+    @handle_item_errors("Track")
     async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:
         """Get a list of 10 most popular tracks for the given artist."""
-        tidal_session = await self._get_tidal_session()
-        try:
-            artist_toptracks_obj = await get_artist_toptracks(tidal_session, prov_artist_id)
-            return [self._parse_track(track) for track in artist_toptracks_obj]
-        except tidal_exceptions.ObjectNotFound as err:
-            self.logger.warning(f"Failed to get toptracks for artist {prov_artist_id}: {err}")
-            return []
+        api_result = await self._get_data(
+            f"artists/{prov_artist_id}/toptracks", params={"limit": 10, "offset": 0}
+        )
+        artist_top_tracks = self._extract_data(api_result)
+        return [self._parse_track(track_obj) for track_obj in artist_top_tracks.get("items", [])]
 
+    @handle_item_errors("Playlist")
     async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
         """Get playlist tracks."""
-        tidal_session = await self._get_tidal_session()
         result: list[Track] = []
         page_size = 200
         offset = page * page_size
-        track_obj: TidalTrack  # satisfy the type checker
-        tidal_tracks = await get_playlist_tracks(
-            tidal_session, prov_playlist_id, limit=page_size, offset=offset
+        api_result = await self._get_data(
+            f"playlists/{prov_playlist_id}/tracks",
+            params={"limit": page_size, "offset": offset},
         )
-        for index, track_obj in enumerate(tidal_tracks, 1):
+        tidal_tracks = self._extract_data(api_result)
+        for index, track_obj in enumerate(tidal_tracks.get("items", []), 1):
             track = self._parse_track(track_obj=track_obj)
             track.position = offset + index
             result.append(track)
         return result
 
-    @throttle_with_retries
-    async def get_similar_tracks(self, prov_track_id: str, limit: int = 25) -> list[Track]:
-        """Get similar tracks for given track id."""
-        tidal_session = await self._get_tidal_session()
-        similar_tracks_obj = await get_similar_tracks(tidal_session, prov_track_id, limit)
-        return [self._parse_track(track) for track in similar_tracks_obj]
-
-    async def library_add(self, item: MediaItemType) -> bool:
-        """Add item to library."""
-        tidal_session = await self._get_tidal_session()
-        return await library_items_add_remove(
-            tidal_session,
-            str(self._tidal_user_id),
-            item.item_id,
-            item.media_type,
-            add=True,
-        )
-
-    async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
-        """Remove item from library."""
-        tidal_session = await self._get_tidal_session()
-        return await library_items_add_remove(
-            tidal_session,
-            str(self._tidal_user_id),
-            prov_item_id,
-            media_type,
-            add=False,
-        )
-
-    async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
-        """Add track(s) to playlist."""
-        tidal_session = await self._get_tidal_session()
-        await add_playlist_tracks(tidal_session, prov_playlist_id, prov_track_ids)
-
-    async def remove_playlist_tracks(
-        self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
-    ) -> None:
-        """Remove track(s) from playlist."""
-        tidal_session = await self._get_tidal_session()
-        prov_track_ids: list[str] = []
-        # Get tracks by position
-        for pos in positions_to_remove:
-            tracks = await get_playlist_tracks(
-                tidal_session, prov_playlist_id, limit=1, offset=pos - 1
-            )
-            if tracks and len(tracks) > 0:
-                prov_track_ids.append(str(tracks[0].id))
-
-        if prov_track_ids:
-            await remove_playlist_tracks(tidal_session, prov_playlist_id, prov_track_ids)
-
-    async def create_playlist(self, name: str) -> Playlist:
-        """Create a new playlist on provider with given name."""
-        tidal_session = await self._get_tidal_session()
-        playlist_obj = await create_playlist(
-            session=tidal_session,
-            user_id=str(self._tidal_user_id),
-            title=name,
-            description="",
-        )
-        return self._parse_playlist(playlist_obj)
-
-    async def get_stream_details(self, item_id: str, media_type: MediaType) -> StreamDetails:
+    async def get_stream_details(
+        self, item_id: str, media_type: MediaType = MediaType.TRACK
+    ) -> StreamDetails:
         """Return the content details for the given track when it will be streamed."""
-        tidal_session = await self._get_tidal_session()
-        # make sure a valid track is requested.
         # Try direct track lookup first with exception handling
         try:
-            track = await get_track(tidal_session, item_id)
+            track = await self.get_track(item_id)
         except MediaNotFoundError:
-            # Fallback to ISRC lookup
             self.logger.info(
-                """Track %s not found, attempting fallback by ISRC.
-                It's likely that this track has a new ID upstream in Tidal's WebApp.""",
+                "Track %s not found, attempting fallback by ISRC lookup",
                 item_id,
             )
-            track = await self._get_track_by_isrc(item_id, tidal_session)
-            if not track:
+            track_result = await self._get_track_by_isrc(item_id)
+            if not track_result:
                 raise MediaNotFoundError(f"Track {item_id} not found")
+            track = track_result
+
+        quality = self.config.get_value(CONF_QUALITY)
+
+        # Request stream manifest
+        async with self.throttler.bypass():
+            api_result = await self._get_data(
+                f"tracks/{item_id}/playbackinfopostpaywall",
+                params={
+                    "playbackmode": "STREAM",
+                    "audioquality": quality,
+                    "assetpresentation": "FULL",
+                },
+            )
+        stream_data = self._extract_data(api_result)
 
-        stream: TidalStream = await get_stream(track)
-        manifest = stream.get_stream_manifest()
+        # Extract streaming information
+        manifest_type = stream_data.get("manifestMimeType", "")
+        is_mpd = "dash+xml" in manifest_type
 
-        url = (
-            # for mpeg-dash streams we just pass the complete base64 manifest
-            f"data:application/dash+xml;base64,{manifest.manifest}"
-            if stream.is_mpd
-            # as far as I can oversee a BTS stream is just a single URL
-            else manifest.urls[0]
-        )
+        if is_mpd:
+            url = f"data:application/dash+xml;base64,{stream_data['manifest']}"
+        else:
+            # For non-MPD streams, use the direct URL
+            url = stream_data.get("urls", [None])[0]
+            if not url:
+                raise MediaNotFoundError(f"No stream URL for track {item_id}")
+
+        # Determine audio format info
+        codec = stream_data.get("codec", "")
+        content_type = ContentType.try_parse(codec)
+        bit_depth = 24 if "HI_RES_LOSSLESS" in stream_data.get("audioMode", "") else 16
+        sample_rate = stream_data.get("sampleRate", 44100)
 
         return StreamDetails(
-            item_id=track.id,
+            item_id=track.item_id,
             provider=self.lookup_key,
             audio_format=AudioFormat(
-                content_type=ContentType.try_parse(manifest.codecs),
-                sample_rate=manifest.sample_rate,
-                bit_depth=stream.bit_depth,
+                content_type=content_type,
+                sample_rate=sample_rate,
+                bit_depth=bit_depth,
                 channels=2,
             ),
             stream_type=StreamType.HTTP,
@@ -604,131 +839,7 @@ class TidalProvider(MusicProvider):
             allow_seek=True,
         )
 
-    @throttle_with_retries
-    async def get_artist(self, prov_artist_id: str) -> Artist:
-        """Get artist details for given artist id."""
-        tidal_session = await self._get_tidal_session()
-        try:
-            artist_obj = await get_artist(tidal_session, prov_artist_id)
-            return self._parse_artist(artist_obj)
-        except tidal_exceptions.ObjectNotFound as err:
-            raise MediaNotFoundError from err
-
-    @throttle_with_retries
-    async def get_album(self, prov_album_id: str) -> Album:
-        """Get album details for given album id."""
-        tidal_session = await self._get_tidal_session()
-        try:
-            album_obj = await get_album(tidal_session, prov_album_id)
-            return self._parse_album(album_obj)
-        except tidal_exceptions.ObjectNotFound as err:
-            raise MediaNotFoundError from err
-
-    @throttle_with_retries
-    async def get_track(self, prov_track_id: str) -> Track:
-        """Get track details for given track id."""
-        tidal_session = await self._get_tidal_session()
-        track_obj = await get_track(tidal_session, prov_track_id)
-        try:
-            track = self._parse_track(track_obj)
-            # get some extra details for the full track info
-            with suppress(tidal_exceptions.MetadataNotAvailable, AttributeError):
-                lyrics: TidalLyrics = await get_track_lyrics(tidal_session, prov_track_id)
-                if lyrics and hasattr(lyrics, "text"):
-                    track.metadata.lyrics = lyrics.text
-            return track
-        except tidal_exceptions.ObjectNotFound as err:
-            raise MediaNotFoundError from err
-
-    @throttle_with_retries
-    async def get_playlist(self, prov_playlist_id: str) -> Playlist:
-        """Get playlist details for given playlist id."""
-        tidal_session = await self._get_tidal_session()
-        playlist_obj = await get_playlist(tidal_session, prov_playlist_id)
-        return self._parse_playlist(playlist_obj)
-
-    def get_item_mapping(self, media_type: MediaType, key: str, name: str) -> ItemMapping:
-        """Create a generic item mapping."""
-        return ItemMapping(
-            media_type=media_type,
-            item_id=key,
-            provider=self.lookup_key,
-            name=name,
-        )
-
-    async def _get_tidal_session(self) -> TidalSession:
-        """Ensure the current token is valid and return a tidal session."""
-        if (
-            self._tidal_session
-            and self._tidal_session.access_token
-            and datetime.fromisoformat(str(self.config.get_value(CONF_EXPIRY_TIME)))
-            > (datetime.now() + timedelta(days=1))
-        ):
-            return self._tidal_session
-
-        try:
-            self._tidal_session = await self._load_tidal_session(
-                token_type="Bearer",
-                quality=str(self.config.get_value(CONF_QUALITY)),
-                access_token=str(self.config.get_value(CONF_AUTH_TOKEN)),
-                refresh_token=str(self.config.get_value(CONF_REFRESH_TOKEN)),
-                expiry_time=datetime.fromisoformat(str(self.config.get_value(CONF_EXPIRY_TIME))),
-            )
-        except Exception as err:
-            if "401 Client Error: Unauthorized" in str(err):
-                err_msg = "Credentials expired, you need to re-setup"
-                # clear stored creds
-                self.update_config_value(CONF_AUTH_TOKEN, None)
-                self.update_config_value(CONF_REFRESH_TOKEN, None)
-                # if we're already loaded and the login got invalid, we need to unload
-                if self.available:
-                    self.unload_with_error(err_msg)
-                raise LoginFailed(err_msg)
-            raise
-
-        self.update_config_value(
-            CONF_AUTH_TOKEN,
-            self._tidal_session.access_token,
-            encrypted=True,
-        )
-        self.update_config_value(
-            CONF_REFRESH_TOKEN,
-            self._tidal_session.refresh_token,
-            encrypted=True,
-        )
-        self.update_config_value(
-            CONF_EXPIRY_TIME,
-            self._tidal_session.expiry_time.isoformat(),
-        )
-        return self._tidal_session
-
-    async def _load_tidal_session(
-        self,
-        token_type: str,
-        quality: str,
-        access_token: str,
-        refresh_token: str,
-        expiry_time: datetime | None = None,
-    ) -> TidalSession:
-        """Load the tidalapi Session."""
-
-        def inner() -> TidalSession:
-            config = TidalConfig(quality=quality, item_limit=10000, alac=False)
-            session = TidalSession(config=config)
-            session.load_oauth_session(
-                token_type=token_type,
-                access_token=access_token,
-                refresh_token=refresh_token,
-                expiry_time=expiry_time,
-                is_pkce=True,
-            )
-            return session
-
-        return await asyncio.to_thread(inner)
-
-    async def _get_track_by_isrc(
-        self, item_id: str, tidal_session: TidalSession
-    ) -> TidalTrack | None:
+    async def _get_track_by_isrc(self, item_id: str) -> Track | None:
         """Get track by ISRC from library item, with caching."""
         # Try to get from cache first
         cache_key = f"isrc_map_{item_id}"
@@ -737,11 +848,11 @@ class TidalProvider(MusicProvider):
         )
 
         if cached_track_id:
-            self.logger.debug(
-                "Using cached track id",
-            )
+            self.logger.debug("Using cached track id")
             try:
-                return await get_track(tidal_session, str(cached_track_id))
+                api_result = await self._get_data(f"tracks/{cached_track_id}")
+                track_data = self._extract_data(api_result)
+                return self._parse_track(track_data)
             except MediaNotFoundError:
                 # Track no longer exists, invalidate cache
                 await self.mass.cache.delete(
@@ -767,29 +878,213 @@ class TidalProvider(MusicProvider):
             return None
 
         self.logger.debug("Attempting track lookup by ISRC: %s", isrc)
-        tracks: list[TidalTrack] = await get_tracks_by_isrc(tidal_session, isrc)
-        if not tracks:
+
+        # Get tracks by ISRC using direct API
+        api_result = await self._get_data(
+            "tracks",
+            params={
+                "filter[isrc]": isrc,
+            },
+            base_url=self.OPEN_API_URL,
+        )
+        tracks_data = self._extract_data(api_result)
+
+        if not tracks_data and not tracks_data.get("data"):
             return None
 
+        track_data = tracks_data["data"][0]
+        track_id = str(track_data["id"])
+
         # Cache the mapping for future use
         await self.mass.cache.set(
-            cache_key, tracks[0].id, category=CACHE_CATEGORY_DEFAULT, base_key=self.lookup_key
+            cache_key,
+            track_id,
+            category=CACHE_CATEGORY_DEFAULT,
+            base_key=self.lookup_key,
+        )
+
+        return await self.get_track(track_id)
+
+    def get_item_mapping(self, media_type: MediaType, key: str, name: str) -> ItemMapping:
+        """Create a generic item mapping."""
+        return ItemMapping(
+            media_type=media_type,
+            item_id=key,
+            provider=self.lookup_key,
+            name=name,
         )
 
-        return tracks[0]
+    #
+    # LIBRARY MANAGEMENT
+    #
+
+    async def get_library_artists(self) -> AsyncGenerator[Artist, None]:
+        """Retrieve all library artists from Tidal."""
+        user_id = self.auth.user_id
+        path = f"users/{user_id}/favorites/artists"
+
+        async for artist_item in self._paginate_api(path, nested_key="item"):
+            if artist_item and artist_item.get("id"):
+                yield self._parse_artist(artist_item)
+
+    async def get_library_albums(self) -> AsyncGenerator[Album, None]:
+        """Retrieve all library albums from Tidal."""
+        user_id = self.auth.user_id
+        path = f"users/{user_id}/favorites/albums"
+
+        async for album_item in self._paginate_api(path, nested_key="item"):
+            if album_item and album_item.get("id"):
+                yield self._parse_album(album_item)
+
+    async def get_library_tracks(self) -> AsyncGenerator[Track, None]:
+        """Retrieve library tracks from Tidal."""
+        user_id = self.auth.user_id
+        path = f"users/{user_id}/favorites/tracks"
+
+        async for track_item in self._paginate_api(path, nested_key="item"):
+            if track_item and track_item.get("id"):
+                yield self._parse_track(track_item)
+
+    async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
+        """Retrieve all library playlists from the provider."""
+        user_id = self.auth.user_id
+        path = f"users/{user_id}/playlistsAndFavoritePlaylists"
+
+        async for playlist_item in self._paginate_api(path, nested_key="playlist"):
+            if playlist_item and playlist_item.get("uuid"):
+                yield self._parse_playlist(playlist_item)
 
-    # Parsers
+    async def library_add(self, item: MediaItemType) -> bool:
+        """Add item to library."""
+        endpoint = None
+        data = {}
+
+        if item.media_type == MediaType.ARTIST:
+            endpoint = "favorites/artists"
+            data = {"artistId": item.item_id}
+        elif item.media_type == MediaType.ALBUM:
+            endpoint = "favorites/albums"
+            data = {"albumId": item.item_id}
+        elif item.media_type == MediaType.TRACK:
+            endpoint = "favorites/tracks"
+            data = {"trackId": item.item_id}
+        elif item.media_type == MediaType.PLAYLIST:
+            endpoint = "favorites/playlists"
+            data = {"playlistId": item.item_id}
+        else:
+            return False
+
+        endpoint = f"users/{self.auth.user_id}/{endpoint}"
+
+        await self._post_data(endpoint, data=data, as_form=True)
+        return True
+
+    async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
+        """Remove item from library."""
+        endpoint = None
+
+        if media_type == MediaType.ARTIST:
+            endpoint = f"favorites/artists/{prov_item_id}"
+        elif media_type == MediaType.ALBUM:
+            endpoint = f"favorites/albums/{prov_item_id}"
+        elif media_type == MediaType.TRACK:
+            endpoint = f"favorites/tracks/{prov_item_id}"
+        elif media_type == MediaType.PLAYLIST:
+            endpoint = f"favorites/playlists/{prov_item_id}"
+        else:
+            return False
+
+        endpoint = f"users/{self.auth.user_id}/{endpoint}"
+
+        try:
+            await self._delete_data(endpoint)
+            return True
+        except Exception:
+            # Log but don't raise - just return False to indicate failure
+            self.logger.warning("Failed to remove %s:%s library", media_type, prov_item_id)
+            return False
+
+    #
+    # PLAYLIST MANAGEMENT
+    #
+
+    async def create_playlist(self, name: str) -> Playlist:
+        """Create a new playlist on provider with given name."""
+        # Create playlist using form-encoded data
+        data = {"title": name, "description": ""}
 
-    def _parse_artist(self, artist_obj: TidalArtist) -> Artist:
+        try:
+            playlist_obj = await self._post_data(
+                f"users/{self.auth.user_id}/playlists", data=data, as_form=True
+            )
+
+            return self._parse_playlist(playlist_obj)
+        except Exception as err:
+            self.logger.error("Failed to create playlist: %s", err)
+            raise ResourceTemporarilyUnavailable("Failed to create playlist") from err
+
+    async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
+        """Add track(s) to playlist."""
+        try:
+            # Get playlist details first with ETag
+            api_result = await self._get_data(f"playlists/{prov_playlist_id}", return_etag=True)
+            playlist_obj, etag = self._extract_data_and_etag(api_result)
+
+            # Send using form-encoded data like the synchronous library
+            data = {
+                "onArtifactNotFound": "SKIP",
+                "trackIds": ",".join(map(str, prov_track_ids)),
+                "toIndex": playlist_obj["numberOfTracks"],
+                "onDupes": "SKIP",
+            }
+
+            # Force using form data instead of JSON and include ETag
+            headers = {"If-None-Match": etag} if etag else {}
+            await self._post_data(
+                f"playlists/{prov_playlist_id}/items",
+                data=data,
+                as_form=True,
+                headers=headers,
+            )
+
+        except MediaNotFoundError as err:
+            raise MediaNotFoundError(f"Playlist {prov_playlist_id} not found") from err
+
+    async def remove_playlist_tracks(
+        self, prov_playlist_id: str, positions_to_remove: tuple[int, ...]
+    ) -> None:
+        """Remove track(s) from playlist."""
+        # Get playlist with ETag first
+        api_result = await self._get_data(f"playlists/{prov_playlist_id}", return_etag=True)
+        _, etag = self._extract_data_and_etag(api_result)
+
+        # Format positions as string in URL path
+        # Tidal can use directly indices in path, not track IDs in the body
+        position_string = ",".join([str(pos - 1) for pos in positions_to_remove])
+
+        # Use DELETE with If-None-Match header
+        # Tidal uses this incorrectly, but it's required
+        headers = {"If-None-Match": etag} if etag else {}
+
+        # Make a direct DELETE request to the endpoint with positions in the URL path
+        await self._delete_data(
+            f"playlists/{prov_playlist_id}/items/{position_string}", headers=headers
+        )
+
+    #
+    # ITEM PARSERS
+    #
+
+    def _parse_artist(self, artist_obj: dict[str, Any]) -> Artist:
         """Parse tidal artist object to generic layout."""
-        artist_id = artist_obj.id
+        artist_id = str(artist_obj["id"])
         artist = Artist(
-            item_id=str(artist_id),
+            item_id=artist_id,
             provider=self.lookup_key,
-            name=artist_obj.name,
+            name=artist_obj["name"],
             provider_mappings={
                 ProviderMapping(
-                    item_id=str(artist_id),
+                    item_id=artist_id,
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     # NOTE: don't use the /browse endpoint as it's
@@ -799,8 +1094,8 @@ class TidalProvider(MusicProvider):
             },
         )
         # metadata
-        if artist_obj.picture:
-            picture_id = artist_obj.picture.replace("-", "/")
+        if artist_obj["picture"]:
+            picture_id = artist_obj["picture"].replace("-", "/")
             image_url = f"{RESOURCES_URL}/{picture_id}/750x750.jpg"
             artist.metadata.images = UniqueList(
                 [
@@ -815,53 +1110,53 @@ class TidalProvider(MusicProvider):
 
         return artist
 
-    def _parse_album(self, album_obj: TidalAlbum) -> Album:
+    def _parse_album(self, album_obj: dict[str, Any]) -> Album:
         """Parse tidal album object to generic layout."""
-        name = album_obj.name
-        version = album_obj.version or ""
-        album_id = album_obj.id
+        name = album_obj["title"]
+        version = album_obj["version"] or ""
+        album_id = str(album_obj["id"])
         album = Album(
-            item_id=str(album_id),
+            item_id=album_id,
             provider=self.lookup_key,
             name=name,
             version=version,
             provider_mappings={
                 ProviderMapping(
-                    item_id=str(album_id),
+                    item_id=album_id,
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     audio_format=AudioFormat(
                         content_type=ContentType.FLAC,
                     ),
                     url=f"https://tidal.com/album/{album_id}",
-                    available=album_obj.available,
+                    available=album_obj["streamReady"],
                 )
             },
         )
         various_artist_album: bool = False
-        for artist_obj in album_obj.artists:
-            if artist_obj.name == "Various Artists":
+        for artist_obj in album_obj["artists"]:
+            if artist_obj["name"] == "Various Artists":
                 various_artist_album = True
             album.artists.append(self._parse_artist(artist_obj))
 
-        if album_obj.type == "COMPILATION" or various_artist_album:
+        if album_obj["type"] == "COMPILATION" or various_artist_album:
             album.album_type = AlbumType.COMPILATION
-        elif album_obj.type == "ALBUM":
+        elif album_obj["type"] == "ALBUM":
             album.album_type = AlbumType.ALBUM
-        elif album_obj.type == "EP":
+        elif album_obj["type"] == "EP":
             album.album_type = AlbumType.EP
-        elif album_obj.type == "SINGLE":
+        elif album_obj["type"] == "SINGLE":
             album.album_type = AlbumType.SINGLE
 
-        album.year = int(album_obj.year)
+        album.year = int(album_obj["releaseDate"].split("-")[0])
         # metadata
-        if album_obj.universal_product_number:
-            album.external_ids.add((ExternalID.BARCODE, album_obj.universal_product_number))
-        album.metadata.copyright = album_obj.copyright
-        album.metadata.explicit = album_obj.explicit
-        album.metadata.popularity = album_obj.popularity
-        if album_obj.cover:
-            picture_id = album_obj.cover.replace("-", "/")
+        if album_obj["upc"]:
+            album.external_ids.add((ExternalID.BARCODE, album_obj["upc"]))
+        album.metadata.copyright = album_obj["copyright"]
+        album.metadata.explicit = album_obj["explicit"]
+        album.metadata.popularity = album_obj["popularity"]
+        if album_obj["cover"]:
+            picture_id = album_obj["cover"].replace("-", "/")
             image_url = f"{RESOURCES_URL}/{picture_id}/750x750.jpg"
             album.metadata.images = UniqueList(
                 [
@@ -878,17 +1173,18 @@ class TidalProvider(MusicProvider):
 
     def _parse_track(
         self,
-        track_obj: TidalTrack,
+        track_obj: dict[str, Any],
     ) -> Track:
         """Parse tidal track object to generic layout."""
-        version = track_obj.version or ""
-        track_id = str(track_obj.id)
+        version = track_obj["version"] or ""
+        track_id = str(track_obj["id"])
+        hi_res_lossless = "HI_RES_LOSSLESS" in track_obj["mediaMetadata"]["tags"]
         track = Track(
-            item_id=str(track_id),
+            item_id=track_id,
             provider=self.lookup_key,
-            name=track_obj.name,
+            name=track_obj["title"],
             version=version,
-            duration=track_obj.duration,
+            duration=track_obj["duration"],
             provider_mappings={
                 ProviderMapping(
                     item_id=str(track_id),
@@ -896,35 +1192,35 @@ class TidalProvider(MusicProvider):
                     provider_instance=self.instance_id,
                     audio_format=AudioFormat(
                         content_type=ContentType.FLAC,
-                        bit_depth=24 if track_obj.is_hi_res_lossless else 16,
+                        bit_depth=24 if hi_res_lossless else 16,
                     ),
                     url=f"https://tidal.com/track/{track_id}",
-                    available=track_obj.available,
+                    available=track_obj["streamReady"],
                 )
             },
-            disc_number=track_obj.volume_num or 0,
-            track_number=track_obj.track_num or 0,
+            disc_number=track_obj["volumeNumber"] or 0,
+            track_number=track_obj["trackNumber"] or 0,
         )
-        if track_obj.isrc:
-            track.external_ids.add((ExternalID.ISRC, track_obj.isrc))
+        if track_obj["isrc"]:
+            track.external_ids.add((ExternalID.ISRC, track_obj["isrc"]))
         track.artists = UniqueList()
-        for track_artist in track_obj.artists:
+        for track_artist in track_obj["artists"]:
             artist = self._parse_artist(track_artist)
             track.artists.append(artist)
         # metadata
-        track.metadata.explicit = track_obj.explicit
-        track.metadata.popularity = track_obj.popularity
-        track.metadata.copyright = track_obj.copyright
-        if track_obj.album:
+        track.metadata.explicit = track_obj["explicit"]
+        track.metadata.popularity = track_obj["popularity"]
+        track.metadata.copyright = track_obj["copyright"]
+        if track_obj["album"]:
             # Here we use an ItemMapping as Tidal returns
             # minimal data when getting an Album from a Track
             track.album = self.get_item_mapping(
                 media_type=MediaType.ALBUM,
-                key=str(track_obj.album.id),
-                name=track_obj.album.name,
+                key=str(track_obj["album"]["id"]),
+                name=track_obj["album"]["title"],
             )
-            if track_obj.album.cover:
-                picture_id = track_obj.album.cover.replace("-", "/")
+            if track_obj["album"]["cover"]:
+                picture_id = track_obj["album"]["cover"].replace("-", "/")
                 image_url = f"{RESOURCES_URL}/{picture_id}/750x750.jpg"
                 track.metadata.images = UniqueList(
                     [
@@ -938,20 +1234,21 @@ class TidalProvider(MusicProvider):
                 )
         return track
 
-    def _parse_playlist(self, playlist_obj: TidalPlaylist) -> Playlist:
+    def _parse_playlist(self, playlist_obj: dict[str, Any]) -> Playlist:
         """Parse tidal playlist object to generic layout."""
-        playlist_id = playlist_obj.id
-        creator_id = playlist_obj.creator.id if playlist_obj.creator else None
-        creator_name = playlist_obj.creator.name if playlist_obj.creator else "Tidal"
-        is_editable = bool(creator_id and str(creator_id) == self._tidal_user_id)
+        playlist_id = str(playlist_obj["uuid"])
+        creator_id = None
+        if playlist_obj["creator"]:
+            creator_id = playlist_obj["creator"]["id"]
+        is_editable = bool(creator_id and str(creator_id) == str(self.auth.user_id))
         playlist = Playlist(
-            item_id=str(playlist_id),
+            item_id=playlist_id,
             provider=self.instance_id if is_editable else self.lookup_key,
-            name=playlist_obj.name,
-            owner=creator_name,
+            name=playlist_obj["title"],
+            owner=creator_id or "Tidal",
             provider_mappings={
                 ProviderMapping(
-                    item_id=str(playlist_id),
+                    item_id=playlist_id,
                     provider_domain=self.domain,
                     provider_instance=self.instance_id,
                     url=f"{BROWSE_URL}/playlist/{playlist_id}",
@@ -960,9 +1257,9 @@ class TidalProvider(MusicProvider):
             is_editable=is_editable,
         )
         # metadata
-        playlist.cache_checksum = str(playlist_obj.last_updated)
-        playlist.metadata.popularity = playlist_obj.popularity
-        if picture := (playlist_obj.square_picture or playlist_obj.picture):
+        playlist.cache_checksum = str(playlist_obj["lastUpdated"])
+        playlist.metadata.popularity = playlist_obj["popularity"]
+        if picture := (playlist_obj["squareImage"] or playlist_obj["image"]):
             picture_id = picture.replace("-", "/")
             image_url = f"{RESOURCES_URL}/{picture_id}/750x750.jpg"
             playlist.metadata.images = UniqueList(
@@ -977,45 +1274,3 @@ class TidalProvider(MusicProvider):
             )
 
         return playlist
-
-    async def _iter_items(
-        self,
-        func: Callable[_P, list[_R]] | Callable[_P, Awaitable[list[_R]]],
-        *args: _P.args,
-        **kwargs: _P.kwargs,
-    ) -> AsyncGenerator[_R, None]:
-        """Yield all items from a larger listing."""
-        offset = 0
-        while True:
-            if asyncio.iscoroutinefunction(func):
-                chunk = await func(*args, **kwargs, offset=offset)  # type: ignore[arg-type]
-            else:
-                chunk = await asyncio.to_thread(func, *args, **kwargs, offset=offset)  # type: ignore[arg-type]
-            offset += len(chunk)
-            for item in chunk:
-                yield item
-            if len(chunk) < DEFAULT_LIMIT:
-                break
-
-    async def _get_media_info(
-        self, item_id: str, url: str, force_refresh: bool = False
-    ) -> AudioTags:
-        """Retrieve (cached) mediainfo for track."""
-        cache_category = CACHE_CATEGORY_MEDIA_INFO
-        cache_base_key = self.lookup_key
-        # do we have some cached info for this url ?
-        cached_info = await self.mass.cache.get(
-            item_id, category=cache_category, base_key=cache_base_key
-        )
-        if cached_info and not force_refresh:
-            media_info = AudioTags.parse(cached_info)
-        else:
-            # parse info with ffprobe (and store in cache)
-            media_info = await async_parse_tags(url)
-            await self.mass.cache.set(
-                item_id,
-                media_info.raw,
-                category=cache_category,
-                base_key=cache_base_key,
-            )
-        return media_info
diff --git a/music_assistant/providers/tidal/auth_manager.py b/music_assistant/providers/tidal/auth_manager.py
new file mode 100644 (file)
index 0000000..a3f2335
--- /dev/null
@@ -0,0 +1,281 @@
+"""Authentication manager for Tidal integration."""
+
+import json
+import random
+import time
+import urllib
+from collections.abc import Callable
+from types import TracebackType
+from typing import TYPE_CHECKING, Any
+
+import pkce
+from aiohttp import ClientSession
+from music_assistant_models.enums import EventType
+from music_assistant_models.errors import LoginFailed
+
+from music_assistant.helpers.app_vars import app_var  # type: ignore[attr-defined]
+
+if TYPE_CHECKING:
+    from music_assistant.mass import MusicAssistant
+
+# Configuration constants
+TOKEN_TYPE = "Bearer"
+AUTH_URL = "https://auth.tidal.com/v1/oauth2"
+REDIRECT_URI = "https://tidal.com/android/login/auth"
+
+
+class ManualAuthenticationHelper:
+    """Helper for authentication flows that require manual user intervention.
+
+    For Tidal where the OAuth flow doesn't redirect to our callback,
+    but instead requires the user to manually copy a URL after authentication.
+    """
+
+    def __init__(self, mass: "MusicAssistant", session_id: str) -> None:
+        """Initialize the Manual Authentication Helper."""
+        self.mass = mass
+        self.session_id = session_id
+
+    async def __aenter__(self) -> "ManualAuthenticationHelper":
+        """Enter context manager."""
+        return self
+
+    async def __aexit__(
+        self,
+        exc_type: type[BaseException] | None,
+        exc_val: BaseException | None,
+        exc_tb: TracebackType | None,
+    ) -> bool | None:
+        """Exit context manager."""
+        return None
+
+    def send_url(self, auth_url: str) -> None:
+        """Send the URL to the user for authentication."""
+        self.mass.signal_event(EventType.AUTH_SESSION, self.session_id, auth_url)
+
+
+class TidalAuthManager:
+    """Manager for Tidal authentication process."""
+
+    def __init__(
+        self,
+        http_session: ClientSession,
+        config_updater: Callable[[dict[str, Any]], None],
+        logger: Any,
+    ):
+        """Initialize Tidal auth manager."""
+        self.http_session = http_session
+        self.update_config = config_updater
+        self.logger = logger
+        self._auth_info = None
+        self._user_id = None
+        self._country_code = None
+        self._session_id = None
+
+    async def initialize(self, auth_data: str) -> bool:
+        """Initialize the auth manager with stored auth data."""
+        if not auth_data:
+            return False
+
+        # Parse stored auth data
+        self._auth_info = json.loads(auth_data)
+
+        # Ensure we have a valid token
+        return await self.ensure_valid_token()
+
+    @property
+    def user_id(self) -> str | None:
+        """Return the current user ID."""
+        return self._user_id
+
+    @property
+    def country_code(self) -> str | None:
+        """Return the current country code."""
+        return self._country_code
+
+    @property
+    def session_id(self) -> str | None:
+        """Return the current session ID."""
+        return self._session_id
+
+    @property
+    def access_token(self) -> str | None:
+        """Return the current access token."""
+        return self._auth_info.get("access_token") if self._auth_info else None
+
+    async def ensure_valid_token(self) -> bool:
+        """Ensure we have a valid token, refresh if needed."""
+        if not self._auth_info:
+            return False
+
+        # Check if token is expired
+        expires_at = self._auth_info.get("expires_at", 0)  # type: ignore[unreachable]
+        if expires_at > time.time() - 60:
+            return True
+
+        # Need to refresh token
+        return await self.refresh_token()
+
+    async def refresh_token(self) -> bool:
+        """Refresh the auth token."""
+        if not self._auth_info:
+            return False
+
+        refresh_token = self._auth_info.get("refresh_token")  # type: ignore[unreachable]
+        if not refresh_token:
+            return False
+
+        client_id = self._auth_info.get("client_id", "")
+
+        data = {
+            "refresh_token": refresh_token,
+            "client_id": client_id,
+            "grant_type": "refresh_token",
+            "scope": "r_usr w_usr w_sub",
+        }
+
+        headers = {"Content-Type": "application/x-www-form-urlencoded"}
+
+        async with self.http_session.post(
+            f"{AUTH_URL}/token", data=data, headers=headers
+        ) as response:
+            if response.status != 200:
+                self.logger.error("Failed to refresh token: %s", await response.text())
+                return False
+
+            token_data = await response.json()
+
+            # Update auth info
+            self._auth_info["access_token"] = token_data["access_token"]
+            if "refresh_token" in token_data:
+                self._auth_info["refresh_token"] = token_data["refresh_token"]
+
+            # Update expiration
+            if "expires_in" in token_data:
+                self._auth_info["expires_at"] = time.time() + token_data["expires_in"]
+
+            # Store updated auth info
+            self.update_config(self._auth_info)
+
+            return True
+
+    async def update_user_info(self, user_info: dict[str, Any]) -> None:
+        """Update user info from API response."""
+        self._user_id = user_info.get("userId")
+        self._country_code = user_info.get("countryCode")
+        self._session_id = user_info.get("sessionId")
+
+    @staticmethod
+    async def generate_auth_url(auth_helper: ManualAuthenticationHelper, quality: str) -> str:
+        """Generate the Tidal authentication URL."""
+        # Generate PKCE challenge
+        code_verifier, code_challenge = pkce.generate_pkce_pair()
+        # Generate unique client key
+        client_unique_key = format(random.getrandbits(64), "02x")
+        # Store these values for later use
+        auth_params = {
+            "code_verifier": code_verifier,
+            "client_unique_key": client_unique_key,
+            "client_id": app_var(9),
+            "client_secret": app_var(10),
+            "quality": quality,
+        }
+
+        # Create auth URL
+        params = {
+            "response_type": "code",
+            "redirect_uri": REDIRECT_URI,
+            "client_id": auth_params["client_id"],
+            "lang": "EN",
+            "appMode": "android",
+            "client_unique_key": client_unique_key,
+            "code_challenge": code_challenge,
+            "code_challenge_method": "S256",
+            "restrict_signup": "true",
+        }
+
+        url = f"https://login.tidal.com/authorize?{urllib.parse.urlencode(params)}"
+
+        # Send URL to user
+        auth_helper.mass.loop.call_soon_threadsafe(auth_helper.send_url, url)
+
+        # Return serialized auth params
+        return json.dumps(auth_params)
+
+    @staticmethod
+    async def process_pkce_login(
+        http_session: ClientSession, base64_auth_params: str, redirect_url: str
+    ) -> dict[str, Any]:
+        """Process TIDAL authentication with PKCE flow."""
+        # Parse the stored auth parameters
+        try:
+            auth_params = json.loads(base64_auth_params)
+        except json.JSONDecodeError as err:
+            raise LoginFailed("Invalid authentication data") from err
+
+        # Extract required parameters
+        code_verifier = auth_params.get("code_verifier")
+        client_unique_key = auth_params.get("client_unique_key")
+        client_secret = auth_params.get("client_secret")
+        client_id = auth_params.get("client_id")
+        quality = auth_params.get("quality")
+
+        if not code_verifier or not client_unique_key:
+            raise LoginFailed("Missing required authentication parameters")
+
+        # Extract the authorization code from the redirect URL
+        parsed_url = urllib.parse.urlparse(redirect_url)
+        query_params = urllib.parse.parse_qs(parsed_url.query)
+        code = query_params.get("code", [""])[0]
+
+        if not code:
+            raise LoginFailed("No authorization code found in redirect URL")
+
+        # Prepare the token exchange request
+        token_url = f"{AUTH_URL}/token"
+        data = {
+            "code": code,
+            "client_id": client_id,
+            "grant_type": "authorization_code",
+            "redirect_uri": REDIRECT_URI,
+            "scope": "r_usr w_usr w_sub",
+            "code_verifier": code_verifier,
+            "client_unique_key": client_unique_key,
+        }
+
+        headers = {"Content-Type": "application/x-www-form-urlencoded"}
+
+        # Make the token exchange request
+        async with http_session.post(token_url, data=data, headers=headers) as response:
+            if response.status != 200:
+                error_text = await response.text()
+                raise LoginFailed(f"Token exchange failed: {error_text}")
+
+            token_data = await response.json()
+
+        # Validate we have authentication data
+        if not token_data.get("access_token") or not token_data.get("refresh_token"):
+            raise LoginFailed("Failed to obtain authentication tokens from Tidal")
+
+        # Get user information using the new token
+        headers = {"Authorization": f"Bearer {token_data['access_token']}"}
+        sessions_url = "https://api.tidal.com/v1/sessions"
+
+        # Again use mass.http_session
+        async with http_session.get(sessions_url, headers=headers) as response:
+            if response.status != 200:
+                error_text = await response.text()
+                raise LoginFailed(f"Failed to get user info: {error_text}")
+
+            user_info = await response.json()
+
+        # Combine token and user info, add expiration time
+        auth_data = {**token_data, **user_info}
+
+        # Add standard fields used by TidalProvider
+        auth_data["expires_at"] = time.time() + token_data.get("expires_in", 3600)
+        auth_data["quality"] = quality
+        auth_data["client_id"] = client_id
+        auth_data["client_secret"] = client_secret
+
+        return auth_data
diff --git a/music_assistant/providers/tidal/helpers.py b/music_assistant/providers/tidal/helpers.py
deleted file mode 100644 (file)
index 24e1805..0000000
+++ /dev/null
@@ -1,433 +0,0 @@
-"""Helper module for parsing the Tidal API.
-
-This helpers file is an async wrapper around the excellent tidalapi package.
-While the tidalapi package does an excellent job at parsing the Tidal results,
-it is unfortunately not async, which is required for Music Assistant to run smoothly.
-This also nicely separates the parsing logic from the Tidal provider logic.
-
-CREDITS:
-tidalapi: https://github.com/tamland/python-tidal
-"""
-
-import asyncio
-import logging
-
-from music_assistant_models.enums import MediaType
-from music_assistant_models.errors import (
-    MediaNotFoundError,
-    ResourceTemporarilyUnavailable,
-)
-from tidalapi import Album as TidalAlbum
-from tidalapi import Artist as TidalArtist
-from tidalapi import Favorites as TidalFavorites
-from tidalapi import LoggedInUser
-from tidalapi import Playlist as TidalPlaylist
-from tidalapi import Session as TidalSession
-from tidalapi import Track as TidalTrack
-from tidalapi import UserPlaylist as TidalUserPlaylist
-from tidalapi.exceptions import (
-    InvalidISRC,
-    MetadataNotAvailable,
-    ObjectNotFound,
-    TooManyRequests,
-)
-from tidalapi.media import Lyrics as TidalLyrics
-from tidalapi.media import Stream as TidalStream
-
-DEFAULT_LIMIT = 50
-LOGGER = logging.getLogger(__name__)
-
-
-async def get_library_artists(
-    session: TidalSession, user_id: str, limit: int = DEFAULT_LIMIT, offset: int = 0
-) -> list[TidalArtist]:
-    """Async wrapper around the tidalapi Favorites.artists function."""
-
-    def inner() -> list[TidalArtist]:
-        artists: list[TidalArtist] = TidalFavorites(session, user_id).artists(
-            limit=limit, offset=offset
-        )
-        return artists
-
-    return await asyncio.to_thread(inner)
-
-
-async def library_items_add_remove(
-    session: TidalSession,
-    user_id: str,
-    item_id: str,
-    media_type: MediaType,
-    add: bool = True,
-) -> bool:
-    """Async wrapper around the tidalapi Favorites.items add/remove function."""
-
-    def inner() -> bool:
-        tidal_favorites = TidalFavorites(session, user_id)
-        if media_type == MediaType.UNKNOWN:
-            return False
-        response: bool = False
-        if add:
-            match media_type:
-                case MediaType.ARTIST:
-                    response = tidal_favorites.add_artist(item_id)
-                case MediaType.ALBUM:
-                    response = tidal_favorites.add_album(item_id)
-                case MediaType.TRACK:
-                    response = tidal_favorites.add_track(item_id)
-                case MediaType.PLAYLIST:
-                    response = tidal_favorites.add_playlist(item_id)
-        else:
-            match media_type:
-                case MediaType.ARTIST:
-                    response = tidal_favorites.remove_artist(item_id)
-                case MediaType.ALBUM:
-                    response = tidal_favorites.remove_album(item_id)
-                case MediaType.TRACK:
-                    response = tidal_favorites.remove_track(item_id)
-                case MediaType.PLAYLIST:
-                    response = tidal_favorites.remove_playlist(item_id)
-        return response
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_artist(session: TidalSession, prov_artist_id: str) -> TidalArtist:
-    """Async wrapper around the tidalapi Artist function."""
-
-    def inner() -> TidalArtist:
-        try:
-            return TidalArtist(session, prov_artist_id)
-        except ObjectNotFound as err:
-            msg = f"Artist {prov_artist_id} not found"
-            raise MediaNotFoundError(msg) from err
-        except TooManyRequests:
-            msg = "Tidal API rate limit reached"
-            raise ResourceTemporarilyUnavailable(msg)
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_artist_albums(session: TidalSession, prov_artist_id: str) -> list[TidalAlbum]:
-    """Async wrapper around 3 tidalapi album functions."""
-
-    def inner() -> list[TidalAlbum]:
-        try:
-            artist_obj = TidalArtist(session, prov_artist_id)
-        except ObjectNotFound as err:
-            msg = f"Artist {prov_artist_id} not found"
-            raise MediaNotFoundError(msg) from err
-        except TooManyRequests:
-            msg = "Tidal API rate limit reached"
-            raise ResourceTemporarilyUnavailable(msg)
-        else:
-            all_albums: list[TidalAlbum] = artist_obj.get_albums(limit=DEFAULT_LIMIT)
-            # extend with EPs and singles
-            all_albums.extend(artist_obj.get_ep_singles(limit=DEFAULT_LIMIT))
-            # extend with compilations
-            # note that the Tidal API gives back really strange results here so
-            # filter on either various artists or the artist id
-            for album in artist_obj.get_other(limit=DEFAULT_LIMIT):
-                if album.artist.id == artist_obj.id or album.artist.name == "Various Artists":
-                    all_albums.append(album)
-            return all_albums
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_artist_toptracks(
-    session: TidalSession, prov_artist_id: str, limit: int = 10, offset: int = 0
-) -> list[TidalTrack]:
-    """Async wrapper around the tidalapi Artist.get_top_tracks function."""
-
-    def inner() -> list[TidalTrack]:
-        top_tracks: list[TidalTrack] = TidalArtist(session, prov_artist_id).get_top_tracks(
-            limit=limit, offset=offset
-        )
-        return top_tracks
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_library_albums(
-    session: TidalSession, user_id: str, limit: int = DEFAULT_LIMIT, offset: int = 0
-) -> list[TidalAlbum]:
-    """Async wrapper around the tidalapi Favorites.albums function."""
-
-    def inner() -> list[TidalAlbum]:
-        albums: list[TidalAlbum] = TidalFavorites(session, user_id).albums(
-            limit=limit, offset=offset
-        )
-        return albums
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_album(session: TidalSession, prov_album_id: str) -> TidalAlbum:
-    """Async wrapper around the tidalapi Album function."""
-
-    def inner() -> TidalAlbum:
-        try:
-            return TidalAlbum(session, prov_album_id)
-        except ObjectNotFound as err:
-            msg = f"Album {prov_album_id} not found"
-            raise MediaNotFoundError(msg) from err
-        except TooManyRequests:
-            msg = "Tidal API rate limit reached"
-            raise ResourceTemporarilyUnavailable(msg)
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_track(session: TidalSession, prov_track_id: str) -> TidalTrack:
-    """Async wrapper around the tidalapi Track function."""
-
-    def inner() -> TidalTrack:
-        try:
-            return TidalTrack(session, prov_track_id)
-        except ObjectNotFound as err:
-            msg = f"Track {prov_track_id} not found"
-            raise MediaNotFoundError(msg) from err
-        except TooManyRequests:
-            msg = "Tidal API rate limit reached"
-            raise ResourceTemporarilyUnavailable(msg)
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_track_lyrics(session: TidalSession, prov_track_id: str) -> TidalLyrics | None:
-    """Async wrapper around the tidalapi Track lyrics function."""
-
-    def inner() -> TidalLyrics | None:
-        try:
-            track: TidalTrack = TidalTrack(session, prov_track_id)
-            lyrics = track.lyrics()
-            if lyrics and hasattr(lyrics, "text"):
-                return lyrics
-        except ObjectNotFound as err:
-            msg = f"Track {prov_track_id} not found"
-            raise MediaNotFoundError(msg) from err
-        except MetadataNotAvailable as err:
-            msg = f"Lyrics not available for track {prov_track_id}"
-            LOGGER.debug(msg)
-            raise MetadataNotAvailable(msg) from err
-        except TooManyRequests:
-            msg = "Tidal API rate limit reached"
-            raise ResourceTemporarilyUnavailable(msg)
-        return None
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_tracks_by_isrc(session: TidalSession, isrc: str) -> list[TidalTrack]:
-    """Async wrapper around the tidalapi Track function."""
-
-    def inner() -> list[TidalTrack]:
-        try:
-            tracks: list[TidalTrack] = session.get_tracks_by_isrc(isrc)
-            return tracks
-        except InvalidISRC as err:
-            msg = f"ISRC {isrc} invalid or not found"
-            raise MediaNotFoundError(msg) from err
-        except TooManyRequests:
-            msg = "Tidal API rate limit reached"
-            raise ResourceTemporarilyUnavailable(msg)
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_stream(track: TidalTrack) -> TidalStream:
-    """Async wrapper around the tidalapi Track.get_stream_url function."""
-
-    def inner() -> TidalStream:
-        try:
-            return track.get_stream()
-        except ObjectNotFound as err:
-            msg = f"Track {track.id} has no available stream"
-            raise MediaNotFoundError(msg) from err
-        except TooManyRequests:
-            msg = "Tidal API rate limit reached"
-            raise ResourceTemporarilyUnavailable(msg)
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_album_tracks(session: TidalSession, prov_album_id: str) -> list[TidalTrack]:
-    """Async wrapper around the tidalapi Album.tracks function."""
-
-    def inner() -> list[TidalTrack]:
-        try:
-            tracks: list[TidalTrack] = TidalAlbum(session, prov_album_id).tracks(
-                limit=DEFAULT_LIMIT
-            )
-            return tracks
-        except ObjectNotFound as err:
-            msg = f"Album {prov_album_id} not found"
-            raise MediaNotFoundError(msg) from err
-        except TooManyRequests:
-            msg = "Tidal API rate limit reached"
-            raise ResourceTemporarilyUnavailable(msg)
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_library_tracks(
-    session: TidalSession, user_id: str, limit: int = DEFAULT_LIMIT, offset: int = 0
-) -> list[TidalTrack]:
-    """Async wrapper around the tidalapi Favorites.tracks function."""
-
-    def inner() -> list[TidalTrack]:
-        tracks: list[TidalTrack] = TidalFavorites(session, user_id).tracks(
-            limit=limit, offset=offset
-        )
-        return tracks
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_library_playlists(
-    session: TidalSession, user_id: str, offset: int = 0
-) -> list[TidalPlaylist]:
-    """Async wrapper around the tidalapi LoggedInUser.playlist_and_favorite_playlists function."""
-
-    def inner() -> list[TidalPlaylist]:
-        playlists: list[TidalPlaylist] = LoggedInUser(
-            session, user_id
-        ).playlist_and_favorite_playlists(offset=offset)
-        return playlists
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_playlist(session: TidalSession, prov_playlist_id: str) -> TidalPlaylist:
-    """Async wrapper around the tidal Playlist function."""
-
-    def inner() -> TidalPlaylist:
-        try:
-            return TidalPlaylist(session, prov_playlist_id)
-        except ObjectNotFound as err:
-            msg = f"Playlist {prov_playlist_id} not found"
-            raise MediaNotFoundError(msg) from err
-        except TooManyRequests:
-            msg = "Tidal API rate limit reached"
-            raise ResourceTemporarilyUnavailable(msg)
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_playlist_tracks(
-    session: TidalSession,
-    prov_playlist_id: str,
-    limit: int = DEFAULT_LIMIT,
-    offset: int = 0,
-) -> list[TidalTrack]:
-    """Async wrapper around the tidal Playlist.tracks function."""
-
-    def inner() -> list[TidalTrack]:
-        try:
-            tracks: list[TidalTrack] = TidalPlaylist(session, prov_playlist_id).tracks(
-                limit=limit, offset=offset
-            )
-            return tracks
-        except ObjectNotFound as err:
-            msg = f"Playlist {prov_playlist_id} not found"
-            raise MediaNotFoundError(msg) from err
-        except TooManyRequests:
-            msg = "Tidal API rate limit reached"
-            raise ResourceTemporarilyUnavailable(msg)
-
-    return await asyncio.to_thread(inner)
-
-
-async def add_playlist_tracks(
-    session: TidalSession, prov_playlist_id: str, track_ids: list[str]
-) -> None:
-    """Async wrapper around the tidal Playlist.add function."""
-
-    def inner() -> None:
-        TidalUserPlaylist(session, prov_playlist_id).add(track_ids)
-
-    return await asyncio.to_thread(inner)
-
-
-async def remove_playlist_tracks(
-    session: TidalSession, prov_playlist_id: str, track_ids: list[str]
-) -> None:
-    """Async wrapper around the tidal Playlist.remove function."""
-
-    def inner() -> None:
-        for item in track_ids:
-            TidalUserPlaylist(session, prov_playlist_id).remove_by_id(int(item))
-
-    return await asyncio.to_thread(inner)
-
-
-async def create_playlist(
-    session: TidalSession, user_id: str, title: str, description: str | None = None
-) -> TidalPlaylist:
-    """Async wrapper around the tidal LoggedInUser.create_playlist function."""
-
-    def inner() -> TidalPlaylist:
-        playlist: TidalPlaylist = LoggedInUser(session, user_id).create_playlist(title, description)
-        return playlist
-
-    return await asyncio.to_thread(inner)
-
-
-async def get_similar_tracks(
-    session: TidalSession, prov_track_id: str, limit: int = 25
-) -> list[TidalTrack]:
-    """Async wrapper around the tidal Track.get_similar_tracks function."""
-
-    def inner() -> list[TidalTrack]:
-        try:
-            tracks: list[TidalTrack] = TidalTrack(session, prov_track_id).get_track_radio(
-                limit=limit
-            )
-            return tracks
-        except ObjectNotFound as err:
-            msg = f"Source track {prov_track_id} not found"
-            raise MediaNotFoundError(msg) from err
-        except MetadataNotAvailable as err:
-            msg = f"No similar tracks available for {prov_track_id}"
-            raise MediaNotFoundError(msg) from err
-        except TooManyRequests:
-            msg = "Tidal API rate limit reached"
-            raise ResourceTemporarilyUnavailable(msg)
-
-    return await asyncio.to_thread(inner)
-
-
-async def search(
-    session: TidalSession,
-    query: str,
-    media_types: list[MediaType],
-    limit: int = 50,
-    offset: int = 0,
-) -> dict[str, str]:
-    """Async wrapper around the tidalapi Search function."""
-
-    def inner() -> dict[str, str]:
-        search_types = []
-        if MediaType.ARTIST in media_types:
-            search_types.append(TidalArtist)
-        if MediaType.ALBUM in media_types:
-            search_types.append(TidalAlbum)
-        if MediaType.TRACK in media_types:
-            search_types.append(TidalTrack)
-        if MediaType.PLAYLIST in media_types:
-            search_types.append(TidalPlaylist)
-
-        models = search_types
-        results: dict[str, str] = session.search(query, models, limit, offset)
-        return results
-
-    return await asyncio.to_thread(inner)
-
-
-async def token_refresh(session: TidalSession) -> None:
-    """Async wrapper around the tidalapi Session.refresh function."""
-
-    def inner() -> None:
-        session.token_refresh(session.refresh_token)
-
-    return await asyncio.to_thread(inner)
index 1820542485e9ac90361213a75fc1605b6b73ccc1..5885473af34eca799196458498aa0f6ded165d58 100644 (file)
@@ -4,7 +4,7 @@
   "name": "Tidal",
   "description": "Support for the Tidal streaming provider in Music Assistant.",
   "codeowners": ["@jozefKruszynski"],
-  "requirements": ["tidalapi==0.8.3"],
+  "requirements": [],
   "documentation": "https://music-assistant.io/music-providers/tidal/",
   "multi_instance": true
 }
index e5d30c63e14f6eb3bda47c2a88d3788de79f76af..f7968d0d37e5a4cc89e3c8ec0d35cc45cadd601c 100644 (file)
@@ -46,7 +46,6 @@ snapcast==2.3.6
 soco==0.30.6
 soundcloudpy==0.1.2
 sxm==0.2.8
-tidalapi==0.8.3
 unidecode==1.3.8
 xmltodict==0.14.2
 yt-dlp==2024.12.23