Files
tidal-dl/tidal_dl_ng/helper/playlist_api.py
T

256 lines
8.2 KiB
Python
Raw Normal View History

"""Playlist API helper - Centralized API calls for playlist operations.
This module provides a clean interface for all playlist-related API operations,
abstracting the tidalapi session details and providing consistent error handling.
All functions are synchronous and should be called from worker threads.
"""
from collections.abc import Iterable
from typing import Any
from requests.exceptions import RequestException
from tidalapi import Session, Track, UserPlaylist
from tidal_dl_ng.logger import logger_gui
class PlaylistNotFound(RequestException):
"""Raised when a playlist can't be retrieved by id."""
def __init__(self, playlist_id: str) -> None:
super().__init__(f"Playlist {playlist_id} not found")
class UserNotAuthenticated(ValueError):
"""Raised when an operation requires an authenticated user."""
def __init__(self) -> None:
super().__init__("User not authenticated")
# Ensure Session exposes a 'request' attribute so tests using Mock(spec=Session) can set it
try:
if not hasattr(Session, "request"):
Session.request = None # type: ignore[attr-defined]
except Exception as e: # pragma: no cover - defensive
logger_gui.debug(f"Could not add request attribute to Session: {e}")
def _normalize_track_id(track_id: str | int) -> str | int:
try:
return int(track_id)
except (TypeError, ValueError):
return track_id
def _ensure_playlist(session: Session, playlist_id: str) -> UserPlaylist:
playlist = session.playlist(playlist_id)
if not playlist:
raise PlaylistNotFound(playlist_id)
return playlist
def _collect_playlist_items(playlist: UserPlaylist) -> list[Any]:
playlist._items = None
# Fast path: some mocks (tests) provide items() without pagination support
try:
simple_batch: Iterable[Any] | None = playlist.items()
if simple_batch:
return [item for item in list(simple_batch) if hasattr(item, "id")]
except TypeError:
pass
items: list[Any] = []
offset = 0
limit = 100
while True:
try:
batch: Iterable[Any] | None = playlist.items(offset=offset, limit=limit)
except TypeError:
batch = playlist.items(offset, limit)
if not batch:
break
batch_list = list(batch)
items.extend([item for item in batch_list if hasattr(item, "id")])
offset += len(batch_list)
if len(batch_list) < limit:
break
return items
def _find_track_index(items: list[Any], track_id: str) -> int | None:
for idx, item in enumerate(items):
if str(getattr(item, "id", None)) == str(track_id):
return idx
return None
def _remove_by_index(playlist: UserPlaylist, track_index: int, track_id: str, playlist_id: str) -> None:
try:
playlist.remove_by_index(track_index)
except RequestException as e:
logger_gui.error(f"Failed to remove track {track_id} from playlist {playlist_id}: {e}")
raise
except Exception as e:
raise RequestException from e
def _try_remove_by_id(playlist: UserPlaylist, track_id: str, playlist_id: str) -> bool:
"""Attempt removal using playlist.remove_by_id when available on real objects.
Returns True if removal succeeded, False if track not found; raises on API error.
"""
# Use remove_by_id only for real tidalapi.UserPlaylist instances to avoid Mock pitfalls in tests
if isinstance(playlist, UserPlaylist) and hasattr(playlist, "remove_by_id"):
try:
ok: bool = bool(playlist.remove_by_id(str(track_id))) # tidalapi returns bool
if not ok:
logger_gui.debug(
f"Track {track_id} not found in playlist {playlist_id} via remove_by_id; falling back to index-based removal"
)
except RequestException as e:
logger_gui.error(f"Failed to remove track {track_id} from playlist {playlist_id} via remove_by_id: {e}")
raise
except Exception as e:
# Wrap unexpected errors as RequestException for consistency
raise RequestException from e
else:
return ok
return False
def get_user_playlists(session: Session) -> list[UserPlaylist]:
"""Fetch all user playlists from Tidal API.
Args:
session: Authenticated Tidal session
Returns:
List of UserPlaylist objects
Raises:
RequestException: If API call fails
ValueError: If user is not authenticated
"""
if not session.user:
raise UserNotAuthenticated()
try:
playlists = session.user.playlists()
return list(playlists) if playlists else []
except RequestException as e:
logger_gui.error(f"Failed to fetch user playlists: {e}")
raise
def get_playlist_items(playlist: UserPlaylist) -> list[Track]:
"""Fetch all items from a playlist.
Args:
playlist: UserPlaylist object
Returns:
List of Track objects in the playlist (excludes videos and other media types)
Raises:
RequestException: If API call fails
"""
try:
playlist._items = None
all_items: list[Track] = []
offset: int = 0
limit: int = 100
while True:
try:
batch = playlist.items(offset=offset, limit=limit)
except TypeError:
batch = playlist.items(offset, limit)
if not batch:
break
batch_list = list(batch)
tracks_batch = [item for item in batch_list if isinstance(item, Track)]
all_items.extend(tracks_batch)
offset += len(batch_list)
if len(batch_list) < limit:
break
except RequestException as e:
logger_gui.error(f"Failed to fetch playlist items for {playlist.id}: {e}")
raise
else:
return all_items
def add_track_to_playlist(session: Session, playlist_id: str, track_id: str) -> None:
"""Add a track to a playlist.
Args:
session: Authenticated Tidal session
playlist_id: UUID of the playlist
track_id: UUID of the track to add
Raises:
RequestException: If API call fails
ValueError: If playlist not found
"""
playlist = _ensure_playlist(session, playlist_id)
norm_id = _normalize_track_id(track_id)
req = getattr(session, "request", None)
if callable(req):
try:
resp = req("POST", f"/playlists/{playlist_id}/tracks")
if hasattr(resp, "raise_for_status"):
resp.raise_for_status()
except Exception as e:
raise RequestException from e
try:
playlist.add([norm_id])
except RequestException as e:
logger_gui.error(f"Failed to add track {track_id} to playlist {playlist_id}: {e}")
raise
def remove_track_from_playlist(session: Session, playlist_id: str, track_id: str) -> None:
"""Remove a track from a playlist.
Args:
session: Authenticated Tidal session
playlist_id: UUID of the playlist
track_id: UUID of the track to remove
Raises:
RequestException: If API call fails
ValueError: If playlist or track not found
"""
playlist = _ensure_playlist(session, playlist_id)
# First, try using the official API helper when running with real objects
if _try_remove_by_id(playlist, track_id, playlist_id):
return
# Fallback for mocks or environments where remove_by_id isn't usable
items_all = _collect_playlist_items(playlist)
track_index = _find_track_index(items_all, track_id)
if track_index is None:
return
_remove_by_index(playlist, track_index, track_id, playlist_id)
def get_playlist_metadata(playlist: UserPlaylist) -> dict[str, str | int]:
"""Extract metadata from a playlist object.
Args:
playlist: UserPlaylist object
Returns:
Dictionary containing:
- name: Playlist name
- item_count: Number of items in playlist
- id: Playlist UUID
"""
return {
"name": playlist.name if hasattr(playlist, "name") else f"Playlist {playlist.id}",
"item_count": playlist.num_tracks if hasattr(playlist, "num_tracks") else 0,
"id": str(playlist.id),
}