second commit
This commit is contained in:
22
env/lib/python3.11/site-packages/tidalapi/__init__.py
vendored
Normal file
22
env/lib/python3.11/site-packages/tidalapi/__init__.py
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2023- The Tidalapi Developers
|
||||
|
||||
from .album import Album # noqa: F401
|
||||
from .artist import Artist, Role # noqa: F401
|
||||
from .genre import Genre # noqa: F401
|
||||
from .media import Quality, Track, Video, VideoQuality # noqa: F401
|
||||
from .mix import Mix, MixV2 # noqa: F401
|
||||
from .page import Page # noqa: F401
|
||||
from .playlist import Playlist, UserPlaylist # noqa: F401
|
||||
from .request import Requests # noqa: F401
|
||||
from .session import Config, Session # noqa: F401
|
||||
from .user import ( # noqa: F401
|
||||
Favorites,
|
||||
FetchedUser,
|
||||
LoggedInUser,
|
||||
PlaylistCreator,
|
||||
User,
|
||||
)
|
||||
|
||||
__version__ = "0.8.2"
|
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/__init__.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/__init__.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/album.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/album.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/artist.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/artist.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/exceptions.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/exceptions.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/genre.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/genre.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/media.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/media.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/mix.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/mix.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/page.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/page.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/playlist.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/playlist.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/request.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/request.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/session.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/session.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/types.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/types.cpython-311.pyc
vendored
Normal file
Binary file not shown.
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/user.cpython-311.pyc
vendored
Normal file
BIN
env/lib/python3.11/site-packages/tidalapi/__pycache__/user.cpython-311.pyc
vendored
Normal file
Binary file not shown.
345
env/lib/python3.11/site-packages/tidalapi/album.py
vendored
Normal file
345
env/lib/python3.11/site-packages/tidalapi/album.py
vendored
Normal file
@ -0,0 +1,345 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2023- The Tidalapi Developers
|
||||
# Copyright (C) 2019-2022 morguldir
|
||||
# Copyright (C) 2014 Thomas Amland
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import copy
|
||||
import functools
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional, Union, cast
|
||||
|
||||
import dateutil.parser
|
||||
|
||||
from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound, TooManyRequests
|
||||
from tidalapi.types import JsonObj
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidalapi.artist import Artist
|
||||
from tidalapi.media import Track, Video
|
||||
from tidalapi.page import Page
|
||||
from tidalapi.session import Session
|
||||
|
||||
|
||||
DEFAULT_ALBUM_IMG = "0dfd3368-3aa1-49a3-935f-10ffb39803c0"
|
||||
|
||||
|
||||
class Album:
|
||||
"""Contains information about a TIDAL album.
|
||||
|
||||
If the album is created from a media object, this object will only contain the id,
|
||||
name, cover and video cover. TIDAL does this to reduce the network load.
|
||||
"""
|
||||
|
||||
id: Optional[int] = -1
|
||||
name: Optional[str] = None
|
||||
cover = None
|
||||
video_cover = None
|
||||
type = None
|
||||
|
||||
duration: Optional[int] = -1
|
||||
available: Optional[bool] = False
|
||||
ad_supported_ready: Optional[bool] = False
|
||||
dj_ready: Optional[bool] = False
|
||||
allow_streaming: Optional[bool] = False
|
||||
premium_streaming_only: Optional[bool] = False
|
||||
num_tracks: Optional[int] = -1
|
||||
num_videos: Optional[int] = -1
|
||||
num_volumes: Optional[int] = -1
|
||||
tidal_release_date: Optional[datetime] = None
|
||||
release_date: Optional[datetime] = None
|
||||
copyright = None
|
||||
version = None
|
||||
explicit: Optional[bool] = True
|
||||
universal_product_number: Optional[int] = -1
|
||||
popularity: Optional[int] = -1
|
||||
user_date_added: Optional[datetime] = None
|
||||
audio_quality: Optional[str] = ""
|
||||
audio_modes: Optional[List[str]] = [""]
|
||||
media_metadata_tags: Optional[List[str]] = [""]
|
||||
|
||||
artist: Optional["Artist"] = None
|
||||
artists: Optional[List["Artist"]] = None
|
||||
|
||||
# Direct URL to https://listen.tidal.com/album/<album_id>
|
||||
listen_url: str = ""
|
||||
# Direct URL to https://tidal.com/browse/album/<album_id>
|
||||
share_url: str = ""
|
||||
|
||||
def __init__(self, session: "Session", album_id: Optional[str]):
|
||||
self.session = session
|
||||
self.request = session.request
|
||||
self.artist = session.artist()
|
||||
self.id = album_id
|
||||
|
||||
if self.id:
|
||||
try:
|
||||
request = self.request.request("GET", "albums/%s" % self.id)
|
||||
except ObjectNotFound:
|
||||
raise ObjectNotFound("Album not found")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Album unavailable")
|
||||
else:
|
||||
self.request.map_json(request.json(), parse=self.parse)
|
||||
|
||||
def parse(
|
||||
self,
|
||||
json_obj: JsonObj,
|
||||
artist: Optional["Artist"] = None,
|
||||
artists: Optional[List["Artist"]] = None,
|
||||
) -> "Album":
|
||||
if artists is None:
|
||||
artists = self.session.parse_artists(json_obj["artists"])
|
||||
|
||||
# Sometimes the artist field is not filled, an example is 140196345
|
||||
if "artist" not in json_obj:
|
||||
artist = artists[0]
|
||||
elif artist is None:
|
||||
artist = self.session.parse_artist(json_obj["artist"])
|
||||
|
||||
self.id = json_obj["id"]
|
||||
self.name = json_obj["title"]
|
||||
self.cover = json_obj["cover"]
|
||||
self.video_cover = json_obj["videoCover"]
|
||||
self.duration = json_obj.get("duration")
|
||||
self.available = json_obj.get("streamReady")
|
||||
self.ad_supported_ready = json_obj.get("adSupportedStreamReady")
|
||||
self.dj_ready = json_obj.get("djReady")
|
||||
self.allow_streaming = json_obj.get("allowStreaming")
|
||||
self.premium_streaming_only = json_obj.get("premiumStreamingOnly")
|
||||
self.num_tracks = json_obj.get("numberOfTracks")
|
||||
self.num_videos = json_obj.get("numberOfVideos")
|
||||
self.num_volumes = json_obj.get("numberOfVolumes")
|
||||
self.copyright = json_obj.get("copyright")
|
||||
self.version = json_obj.get("version")
|
||||
self.explicit = json_obj.get("explicit")
|
||||
self.universal_product_number = json_obj.get("upc")
|
||||
self.popularity = json_obj.get("popularity")
|
||||
self.type = json_obj.get("type")
|
||||
|
||||
# Certain fields may not be available
|
||||
self.audio_quality = json_obj.get("audioQuality")
|
||||
self.audio_modes = json_obj.get("audioModes")
|
||||
|
||||
if "mediaMetadata" in json_obj:
|
||||
self.media_metadata_tags = json_obj.get("mediaMetadata")["tags"]
|
||||
|
||||
self.artist = artist
|
||||
self.artists = artists
|
||||
|
||||
release_date = json_obj.get("releaseDate")
|
||||
self.release_date = (
|
||||
dateutil.parser.isoparse(release_date) if release_date else None
|
||||
)
|
||||
|
||||
tidal_release_date = json_obj.get("streamStartDate")
|
||||
self.tidal_release_date = (
|
||||
dateutil.parser.isoparse(tidal_release_date) if tidal_release_date else None
|
||||
)
|
||||
|
||||
user_date_added = json_obj.get("dateAdded")
|
||||
self.user_date_added = (
|
||||
dateutil.parser.isoparse(user_date_added) if user_date_added else None
|
||||
)
|
||||
self.listen_url = f"{self.session.config.listen_base_url}/album/{self.id}"
|
||||
self.share_url = f"{self.session.config.share_base_url}/album/{self.id}"
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
@property
|
||||
def year(self) -> Optional[int]:
|
||||
"""Get the year using :class:`available_release_date`
|
||||
|
||||
:return: An :any:`python:int` containing the year the track was released
|
||||
"""
|
||||
return self.available_release_date.year if self.available_release_date else None
|
||||
|
||||
@property
|
||||
def available_release_date(self) -> Optional[datetime]:
|
||||
"""Get the release date if it's available, otherwise get the day it was released
|
||||
on TIDAL.
|
||||
|
||||
:return: A :any:`python:datetime.datetime` object with the release date, or the
|
||||
tidal release date, can be None
|
||||
"""
|
||||
if self.release_date:
|
||||
return self.release_date
|
||||
if self.tidal_release_date:
|
||||
return self.tidal_release_date
|
||||
return None
|
||||
|
||||
def tracks(
|
||||
self,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0,
|
||||
sparse_album: bool = False,
|
||||
) -> List["Track"]:
|
||||
"""Returns the tracks in classes album.
|
||||
|
||||
:param limit: The amount of items you want returned.
|
||||
:param offset: The position of the first item you want to include.
|
||||
:param sparse_album: Provide a sparse track.album, containing only essential attributes from track JSON
|
||||
False: Populate the track.album attributes from the parent Album object (self)
|
||||
:return: A list of the :class:`Tracks <.Track>` in the album.
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
|
||||
if sparse_album:
|
||||
parse_track_callable = self.session.parse_track
|
||||
else:
|
||||
# Parse tracks attributes but provide the Album object directly from self
|
||||
parse_track_callable = functools.partial(
|
||||
self.session.parse_track, album=self
|
||||
)
|
||||
|
||||
tracks = self.request.map_request(
|
||||
"albums/%s/tracks" % self.id, params, parse=parse_track_callable
|
||||
)
|
||||
assert isinstance(tracks, list)
|
||||
return cast(List["Track"], tracks)
|
||||
|
||||
def items(
|
||||
self, limit: int = 100, offset: int = 0, sparse_album: bool = False
|
||||
) -> List[Union["Track", "Video"]]:
|
||||
"""Gets the first 'limit' tracks and videos in the album from TIDAL.
|
||||
|
||||
:param limit: The number of items you want to retrieve
|
||||
:param offset: The index you want to start retrieving items from
|
||||
:param sparse_album: Provide a sparse track.album, containing only essential attributes from track JSON
|
||||
False: Populate the track.album attributes from the parent Album object (self)
|
||||
:return: A list of :class:`Tracks<.Track>` and :class:`Videos`<.Video>`
|
||||
"""
|
||||
params = {"offset": offset, "limit": limit}
|
||||
|
||||
if sparse_album:
|
||||
parse_media_callable = self.session.parse_media
|
||||
else:
|
||||
# Parse tracks attributes but provide the Album object directly from self
|
||||
parse_media_callable = functools.partial(
|
||||
self.session.parse_media, album=self
|
||||
)
|
||||
|
||||
items = self.request.map_request(
|
||||
"albums/%s/items" % self.id, params=params, parse=parse_media_callable
|
||||
)
|
||||
assert isinstance(items, list)
|
||||
return cast(List[Union["Track", "Video"]], items)
|
||||
|
||||
def image(self, dimensions: int = 320, default: str = DEFAULT_ALBUM_IMG) -> str:
|
||||
"""A url to an album image cover.
|
||||
|
||||
:param dimensions: The width and height that you want from the image
|
||||
:param default: An optional default image to serve if one is not available
|
||||
:return: A url to the image.
|
||||
|
||||
Valid resolutions: 80x80, 160x160, 320x320, 640x640, 1280x1280
|
||||
"""
|
||||
|
||||
if dimensions not in [80, 160, 320, 640, 1280]:
|
||||
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))
|
||||
|
||||
if not self.cover:
|
||||
return self.session.config.image_url % (
|
||||
default.replace("-", "/"),
|
||||
dimensions,
|
||||
dimensions,
|
||||
)
|
||||
else:
|
||||
return self.session.config.image_url % (
|
||||
self.cover.replace("-", "/"),
|
||||
dimensions,
|
||||
dimensions,
|
||||
)
|
||||
|
||||
def video(self, dimensions: int) -> str:
|
||||
"""Creates a url to an mp4 video cover for the album.
|
||||
|
||||
Valid resolutions: 80x80, 160x160, 320x320, 640x640, 1280x1280
|
||||
|
||||
:param dimensions: The width an height of the video
|
||||
:type dimensions: int
|
||||
:return: A url to an mp4 of the video cover.
|
||||
"""
|
||||
if not self.video_cover:
|
||||
raise AttributeError("This album does not have a video cover.")
|
||||
|
||||
if dimensions not in [80, 160, 320, 640, 1280]:
|
||||
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))
|
||||
|
||||
return self.session.config.video_url % (
|
||||
self.video_cover.replace("-", "/"),
|
||||
dimensions,
|
||||
dimensions,
|
||||
)
|
||||
|
||||
def page(self) -> "Page":
|
||||
"""
|
||||
Retrieve the album page as seen on https://listen.tidal.com/album/$id
|
||||
|
||||
:return: A :class:`Page` containing the different categories, e.g. similar artists and albums
|
||||
"""
|
||||
return self.session.page.get("pages/album", params={"albumId": self.id})
|
||||
|
||||
def similar(self) -> List["Album"]:
|
||||
"""Retrieve albums similar to the current one. MetadataNotAvailable is raised,
|
||||
when no similar albums exist.
|
||||
|
||||
:return: A :any:`list` of similar albums
|
||||
"""
|
||||
try:
|
||||
request = self.request.request("GET", "albums/%s/similar" % self.id)
|
||||
except ObjectNotFound:
|
||||
raise MetadataNotAvailable("No similar albums exist for this album")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Similar artists unavailable")
|
||||
else:
|
||||
albums = self.request.map_json(
|
||||
request.json(), parse=self.session.parse_album
|
||||
)
|
||||
assert isinstance(albums, list)
|
||||
return cast(List["Album"], albums)
|
||||
|
||||
def review(self) -> str:
|
||||
"""Retrieve the album review.
|
||||
|
||||
:return: A :class:`str` containing the album review, with wimp links
|
||||
:raises: :class:`requests.HTTPError` if there isn't a review yet
|
||||
"""
|
||||
review = self.request.request("GET", "albums/%s/review" % self.id).json()[
|
||||
"text"
|
||||
]
|
||||
assert isinstance(review, str)
|
||||
return review
|
||||
|
||||
def get_audio_resolution(self, individual_tracks: bool = False) -> [[int, int]]:
|
||||
"""Retrieve the audio resolution (bit rate + sample rate) for the album track(s)
|
||||
|
||||
This function assumes that all album tracks use the same audio resolution.
|
||||
Some albums may consist of tracks with multiple audio resolution(s).
|
||||
The audio resolution can therefore be fetched for individual tracks by setting the `all_tracks` argument accordingly.
|
||||
|
||||
WARNING: For individual tracks, many additional requests are needed. Handle with care!
|
||||
|
||||
:param individual_tracks: Fetch individual track resolutions
|
||||
:type individual_tracks: bool
|
||||
:return: A :class:`tuple` containing the (bit_rate, sample_rate) for one or more tracks
|
||||
"""
|
||||
if individual_tracks:
|
||||
# Return for individual tracks
|
||||
return [res.get_stream().get_audio_resolution() for res in self.tracks()]
|
||||
else:
|
||||
# Return for first track only
|
||||
return [self.tracks()[0].get_stream().get_audio_resolution()]
|
294
env/lib/python3.11/site-packages/tidalapi/artist.py
vendored
Normal file
294
env/lib/python3.11/site-packages/tidalapi/artist.py
vendored
Normal file
@ -0,0 +1,294 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2023- The Tidalapi Developers
|
||||
# Copyright (C) 2019-2022 morguldir
|
||||
# Copyright (C) 2014 Thomas Amland
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""A module containing information and functions related to TIDAL artists."""
|
||||
|
||||
import copy
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Mapping, Optional, Union, cast
|
||||
from warnings import warn
|
||||
|
||||
import dateutil.parser
|
||||
from typing_extensions import NoReturn
|
||||
|
||||
from tidalapi.exceptions import ObjectNotFound, TooManyRequests
|
||||
from tidalapi.types import JsonObj
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidalapi.album import Album
|
||||
from tidalapi.media import Track, Video
|
||||
from tidalapi.page import Page
|
||||
from tidalapi.session import Session
|
||||
|
||||
DEFAULT_ARTIST_IMG = "1e01cdb6-f15d-4d8b-8440-a047976c1cac"
|
||||
|
||||
|
||||
class Artist:
|
||||
id: Optional[int] = -1
|
||||
name: Optional[str] = None
|
||||
roles: Optional[List["Role"]] = None
|
||||
role: Optional["Role"] = None
|
||||
picture: Optional[str] = None
|
||||
user_date_added: Optional[datetime] = None
|
||||
bio: Optional[str] = None
|
||||
|
||||
# Direct URL to https://listen.tidal.com/artist/<artist_id>
|
||||
listen_url: str = ""
|
||||
# Direct URL to https://tidal.com/browse/artist/<artist_id>
|
||||
share_url: str = ""
|
||||
|
||||
def __init__(self, session: "Session", artist_id: Optional[str]):
|
||||
"""Initialize the :class:`Artist` object, given a TIDAL artist ID :param
|
||||
session: The current TIDAL :class:`Session` :param str artist_id: TIDAL artist
|
||||
ID :raises: Raises :class:`exceptions.ObjectNotFound`"""
|
||||
self.session = session
|
||||
self.request = self.session.request
|
||||
self.id = artist_id
|
||||
|
||||
if self.id:
|
||||
try:
|
||||
request = self.request.request("GET", "artists/%s" % self.id)
|
||||
except ObjectNotFound:
|
||||
raise ObjectNotFound("Artist not found")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Artist unavailable")
|
||||
else:
|
||||
self.request.map_json(request.json(), parse=self.parse_artist)
|
||||
|
||||
def parse_artist(self, json_obj: JsonObj) -> "Artist":
|
||||
"""Parses a TIDAL artist, replaces the current :class:`Artist` object. Made for
|
||||
use within the python tidalapi module.
|
||||
|
||||
:param json_obj: :class:`JsonObj` containing the artist metadata
|
||||
:return: Returns a copy of the :class:`Artist` object
|
||||
"""
|
||||
self.id = json_obj["id"]
|
||||
self.name = json_obj["name"]
|
||||
|
||||
# Artists do not have roles as playlist creators.
|
||||
self.roles = None
|
||||
self.role = None
|
||||
if json_obj.get("type") or json_obj.get("artistTypes"):
|
||||
roles: List["Role"] = []
|
||||
for role in json_obj.get("artistTypes", [json_obj.get("type")]):
|
||||
roles.append(Role(role))
|
||||
|
||||
self.roles = roles
|
||||
self.role = roles[0]
|
||||
|
||||
# Get artist picture or use default
|
||||
self.picture = json_obj.get("picture")
|
||||
if self.picture is None:
|
||||
self.picture = DEFAULT_ARTIST_IMG
|
||||
|
||||
user_date_added = json_obj.get("dateAdded")
|
||||
self.user_date_added = (
|
||||
dateutil.parser.isoparse(user_date_added) if user_date_added else None
|
||||
)
|
||||
|
||||
self.listen_url = f"{self.session.config.listen_base_url}/artist/{self.id}"
|
||||
self.share_url = f"{self.session.config.share_base_url}/artist/{self.id}"
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
def parse_artists(self, json_obj: List[JsonObj]) -> List["Artist"]:
|
||||
"""Parses a list of TIDAL artists, returns a list of :class:`Artist` objects
|
||||
Made for use within the python tidalapi module.
|
||||
|
||||
:param List[JsonObj] json_obj: List of :class:`JsonObj` containing the artist metadata for each artist
|
||||
:return: Returns a list of :class:`Artist` objects
|
||||
"""
|
||||
return list(map(self.parse_artist, json_obj))
|
||||
|
||||
def _get_albums(
|
||||
self, params: Optional[Mapping[str, Union[int, str, None]]] = None
|
||||
) -> List["Album"]:
|
||||
return cast(
|
||||
List["Album"],
|
||||
self.request.map_request(
|
||||
f"artists/{self.id}/albums", params, parse=self.session.parse_album
|
||||
),
|
||||
)
|
||||
|
||||
def get_albums(self, limit: Optional[int] = None, offset: int = 0) -> List["Album"]:
|
||||
"""Queries TIDAL for the artists albums.
|
||||
|
||||
:return: A list of :class:`Albums<tidalapi.album.Album>`
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
return self._get_albums(params)
|
||||
|
||||
def get_albums_ep_singles(
|
||||
self, limit: Optional[int] = None, offset: int = 0
|
||||
) -> List["Album"]:
|
||||
warn(
|
||||
"This method is deprecated an will be removed in a future release. Use instead `get_ep_singles`",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return self.get_ep_singles(limit=limit, offset=offset)
|
||||
|
||||
def get_ep_singles(
|
||||
self, limit: Optional[int] = None, offset: int = 0
|
||||
) -> List["Album"]:
|
||||
"""Queries TIDAL for the artists extended plays and singles.
|
||||
|
||||
:return: A list of :class:`Albums <tidalapi.album.Album>`
|
||||
"""
|
||||
params = {"filter": "EPSANDSINGLES", "limit": limit, "offset": offset}
|
||||
return self._get_albums(params)
|
||||
|
||||
def get_albums_other(
|
||||
self, limit: Optional[int] = None, offset: int = 0
|
||||
) -> List["Album"]:
|
||||
warn(
|
||||
"This method is deprecated an will be removed in a future release. Use instead `get_other`",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
return self.get_other(limit=limit, offset=offset)
|
||||
|
||||
def get_other(self, limit: Optional[int] = None, offset: int = 0) -> List["Album"]:
|
||||
"""Queries TIDAL for albums the artist has appeared on as a featured artist.
|
||||
|
||||
:return: A list of :class:`Albums <tidalapi.album.Album>`
|
||||
"""
|
||||
params = {"filter": "COMPILATIONS", "limit": limit, "offset": offset}
|
||||
return self._get_albums(params)
|
||||
|
||||
def get_top_tracks(
|
||||
self, limit: Optional[int] = None, offset: int = 0
|
||||
) -> List["Track"]:
|
||||
"""Queries TIDAL for the artists tracks, sorted by popularity.
|
||||
|
||||
:return: A list of :class:`Tracks <tidalapi.media.Track>`
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
return cast(
|
||||
List["Track"],
|
||||
self.request.map_request(
|
||||
f"artists/{self.id}/toptracks",
|
||||
params=params,
|
||||
parse=self.session.parse_track,
|
||||
),
|
||||
)
|
||||
|
||||
def get_videos(self, limit: Optional[int] = None, offset: int = 0) -> List["Video"]:
|
||||
"""Queries tidal for the artists videos.
|
||||
|
||||
:return: A list of :class:`Videos <tidalapi.media.Video>`
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
return cast(
|
||||
List["Video"],
|
||||
self.request.map_request(
|
||||
f"artists/{self.id}/videos",
|
||||
params=params,
|
||||
parse=self.session.parse_video,
|
||||
),
|
||||
)
|
||||
|
||||
def get_bio(self) -> str:
|
||||
"""Queries TIDAL for the artists biography.
|
||||
|
||||
:return: A string containing the bio, as well as identifiers to other TIDAL
|
||||
objects inside the bio.
|
||||
"""
|
||||
return cast(
|
||||
str, self.request.request("GET", f"artists/{self.id}/bio").json()["text"]
|
||||
)
|
||||
|
||||
def get_similar(self) -> List["Artist"]:
|
||||
"""Queries TIDAL for similar artists.
|
||||
|
||||
:return: A list of :class:`Artists <tidalapi.artist.Artist>`
|
||||
"""
|
||||
return cast(
|
||||
List["Artist"],
|
||||
self.request.map_request(
|
||||
f"artists/{self.id}/similar", parse=self.session.parse_artist
|
||||
),
|
||||
)
|
||||
|
||||
def get_radio(self) -> List["Track"]:
|
||||
"""Queries TIDAL for the artist radio, which is a mix of tracks that are similar
|
||||
to what the artist makes.
|
||||
|
||||
:return: A list of :class:`Tracks <tidalapi.media.Track>`
|
||||
"""
|
||||
params = {"limit": 100}
|
||||
return cast(
|
||||
List["Track"],
|
||||
self.request.map_request(
|
||||
f"artists/{self.id}/radio",
|
||||
params=params,
|
||||
parse=self.session.parse_track,
|
||||
),
|
||||
)
|
||||
|
||||
def items(self) -> List[NoReturn]:
|
||||
"""The artist page does not supply any items. This only exists for symmetry with
|
||||
other model types.
|
||||
|
||||
:return: An empty list.
|
||||
"""
|
||||
return []
|
||||
|
||||
def image(self, dimensions: int = 320) -> str:
|
||||
"""A url to an artist picture.
|
||||
|
||||
:param dimensions: The width and height that you want from the image
|
||||
:type dimensions: int
|
||||
:return: A url to the image.
|
||||
|
||||
Valid resolutions: 160x160, 320x320, 480x480, 750x750
|
||||
"""
|
||||
if dimensions not in [160, 320, 480, 750]:
|
||||
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))
|
||||
|
||||
if not self.picture:
|
||||
json = self.request.request("GET", f"artists/{self.id}").json()
|
||||
self.picture = json.get("picture")
|
||||
if not self.picture:
|
||||
raise ValueError("No image available")
|
||||
|
||||
return self.session.config.image_url % (
|
||||
self.picture.replace("-", "/"),
|
||||
dimensions,
|
||||
dimensions,
|
||||
)
|
||||
|
||||
def page(self) -> "Page":
|
||||
"""
|
||||
Retrieve the artist page as seen on https://listen.tidal.com/artist/$id
|
||||
|
||||
:return: A :class:`.Page` containing all the categories from the page, e.g. tracks, influencers and credits
|
||||
"""
|
||||
return self.session.page.get("pages/artist", params={"artistId": self.id})
|
||||
|
||||
|
||||
class Role(Enum):
|
||||
"""An Enum with different roles an artist can have."""
|
||||
|
||||
main = "MAIN"
|
||||
featured = "FEATURED"
|
||||
contributor = "CONTRIBUTOR"
|
||||
artist = "ARTIST"
|
46
env/lib/python3.11/site-packages/tidalapi/exceptions.py
vendored
Normal file
46
env/lib/python3.11/site-packages/tidalapi/exceptions.py
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
class AuthenticationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AssetNotAvailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class TooManyRequests(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class URLNotAvailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class StreamNotAvailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MetadataNotAvailable(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ObjectNotFound(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class UnknownManifestFormat(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ManifestDecodeError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MPDNotAvailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidISRC(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidUPC(Exception):
|
||||
pass
|
80
env/lib/python3.11/site-packages/tidalapi/genre.py
vendored
Normal file
80
env/lib/python3.11/site-packages/tidalapi/genre.py
vendored
Normal file
@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2023- The Tidalapi Developers
|
||||
# Copyright (C) 2019-2020 morguldir
|
||||
# Copyright (C) 2014 Thomas Amland
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
""""""
|
||||
|
||||
import copy
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, cast
|
||||
|
||||
from tidalapi.types import JsonObj
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidalapi.session import Session, TypeRelation
|
||||
|
||||
|
||||
class Genre:
|
||||
name: str = ""
|
||||
path: str = ""
|
||||
playlists: bool = False
|
||||
artists: bool = False
|
||||
albums: bool = False
|
||||
tracks: bool = False
|
||||
videos: bool = False
|
||||
image: str = ""
|
||||
|
||||
def __init__(self, session: "Session"):
|
||||
self.session = session
|
||||
self.requests = session.request
|
||||
|
||||
def parse_genre(self, json_obj: JsonObj) -> "Genre":
|
||||
self.name = json_obj["name"]
|
||||
self.path = json_obj["path"]
|
||||
self.playlists = json_obj["hasPlaylists"]
|
||||
self.artists = json_obj["hasArtists"]
|
||||
self.albums = json_obj["hasAlbums"]
|
||||
self.tracks = json_obj["hasTracks"]
|
||||
self.videos = json_obj["hasVideos"]
|
||||
image_path = json_obj["image"].replace("-", "/")
|
||||
self.image = f"http://resources.wimpmusic.com/images/{image_path}/460x306.jpg"
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
def parse_genres(self, json_obj: List[JsonObj]) -> List["Genre"]:
|
||||
return list(map(self.parse_genre, json_obj))
|
||||
|
||||
def get_genres(self) -> List["Genre"]:
|
||||
return self.parse_genres(self.requests.request("GET", "genres").json())
|
||||
|
||||
def items(self, model: List[Optional[Any]]) -> List[Optional[Any]]:
|
||||
"""Gets the current genre's items of the specified type :param model: The
|
||||
tidalapi model you want returned.
|
||||
|
||||
See :class:`Genre`
|
||||
:return:
|
||||
"""
|
||||
type_relations: "TypeRelation" = next(
|
||||
x for x in self.session.type_conversions if x.type == model
|
||||
)
|
||||
name = type_relations.identifier
|
||||
parse = type_relations.parse
|
||||
if getattr(self, name):
|
||||
location = f"genres/{self.path}/{name}"
|
||||
return cast(
|
||||
List[Optional[Any]], self.requests.map_request(location, parse=parse)
|
||||
)
|
||||
raise TypeError("This genre does not contain {0}".format(name))
|
888
env/lib/python3.11/site-packages/tidalapi/media.py
vendored
Normal file
888
env/lib/python3.11/site-packages/tidalapi/media.py
vendored
Normal file
@ -0,0 +1,888 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2023- The Tidalapi Developers
|
||||
# Copyright (C) 2019-2022 morguldir
|
||||
# Copyright (C) 2014 Thomas Amland
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""A module containing information about various media types.
|
||||
|
||||
Classes: :class:`Media`, :class:`Track`, :class:`Video`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional, Union, cast
|
||||
|
||||
import dateutil.parser
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import tidalapi
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
from isodate import parse_duration
|
||||
from mpegdash.parser import MPEGDASHParser
|
||||
|
||||
from tidalapi.album import Album
|
||||
from tidalapi.exceptions import (
|
||||
ManifestDecodeError,
|
||||
MetadataNotAvailable,
|
||||
MPDNotAvailableError,
|
||||
ObjectNotFound,
|
||||
StreamNotAvailable,
|
||||
TooManyRequests,
|
||||
UnknownManifestFormat,
|
||||
URLNotAvailable,
|
||||
)
|
||||
from tidalapi.types import JsonObj
|
||||
|
||||
|
||||
class Quality(str, Enum):
|
||||
low_96k: str = "LOW"
|
||||
low_320k: str = "HIGH"
|
||||
high_lossless: str = "LOSSLESS"
|
||||
hi_res_lossless: str = "HI_RES_LOSSLESS"
|
||||
default: str = low_320k
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class VideoQuality(str, Enum):
|
||||
high: str = "HIGH"
|
||||
medium: str = "MEDIUM"
|
||||
low: str = "LOW"
|
||||
audio_only: str = "AUDIO_ONLY"
|
||||
default: str = high
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class AudioMode(str, Enum):
|
||||
stereo: str = "STEREO"
|
||||
dolby_atmos: str = "DOLBY_ATMOS"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class MediaMetadataTags(str, Enum):
|
||||
hi_res_lossless: str = "HIRES_LOSSLESS"
|
||||
lossless: str = "LOSSLESS"
|
||||
dolby_atmos: str = "DOLBY_ATMOS"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class AudioExtensions(str, Enum):
|
||||
FLAC: str = ".flac"
|
||||
M4A: str = ".m4a"
|
||||
MP4: str = ".mp4"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class VideoExtensions(str, Enum):
|
||||
TS: str = ".ts"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class ManifestMimeType(str, Enum):
|
||||
# EMU: str = "application/vnd.tidal.emu"
|
||||
# APPL: str = "application/vnd.apple.mpegurl"
|
||||
MPD: str = "application/dash+xml"
|
||||
BTS: str = "application/vnd.tidal.bts"
|
||||
VIDEO: str = "video/mp2t"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class Codec(str, Enum):
|
||||
MP3: str = "MP3"
|
||||
AAC: str = "AAC"
|
||||
MP4A: str = "MP4A"
|
||||
FLAC: str = "FLAC"
|
||||
Atmos: str = "EAC3"
|
||||
AC4: str = "AC4"
|
||||
LowResCodecs: [str] = [MP3, AAC, MP4A]
|
||||
PremiumCodecs: [str] = [Atmos, AC4]
|
||||
HQCodecs: [str] = PremiumCodecs + [FLAC]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class MimeType(str, Enum):
|
||||
audio_mpeg = "audio/mpeg"
|
||||
audio_mp3 = "audio/mp3"
|
||||
audio_mp4 = "audio/mp4"
|
||||
audio_m4a = "audio/m4a"
|
||||
audio_flac = "audio/flac"
|
||||
audio_xflac = "audio/x-flac"
|
||||
audio_eac3 = "audio/eac3"
|
||||
audio_ac4 = "audio/mp4"
|
||||
audio_m3u8 = "audio/mpegurl"
|
||||
video_mp4 = "video/mp4"
|
||||
video_m3u8 = "video/mpegurl"
|
||||
audio_map = {
|
||||
Codec.MP3: audio_mp3,
|
||||
Codec.AAC: audio_m4a,
|
||||
Codec.MP4A: audio_m4a,
|
||||
Codec.FLAC: audio_xflac,
|
||||
Codec.Atmos: audio_eac3,
|
||||
Codec.AC4: audio_ac4,
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
@staticmethod
|
||||
def from_audio_codec(codec):
|
||||
return MimeType.audio_map.get(codec, MimeType.audio_m4a)
|
||||
|
||||
@staticmethod
|
||||
def is_flac(mime_type):
|
||||
return (
|
||||
True if mime_type in [MimeType.audio_flac, MimeType.audio_xflac] else False
|
||||
)
|
||||
|
||||
|
||||
class Media:
|
||||
"""Base class for generic media, specifically :class:`Track` and :class:`Video`
|
||||
|
||||
This class includes data used by both of the subclasses, and a function to parse
|
||||
both of them.
|
||||
|
||||
The date_added attribute is only relevant for playlists. For the release date of the
|
||||
actual media, use the release date of the album.
|
||||
"""
|
||||
|
||||
id: Optional[int] = -1
|
||||
name: Optional[str] = None
|
||||
duration: Optional[int] = -1
|
||||
available: bool = True
|
||||
tidal_release_date: Optional[datetime] = None
|
||||
user_date_added: Optional[datetime] = None
|
||||
track_num: int = -1
|
||||
volume_num: int = 1
|
||||
explicit: bool = False
|
||||
popularity: int = -1
|
||||
artist: Optional["tidalapi.artist.Artist"] = None
|
||||
#: For the artist credit page
|
||||
artist_roles = None
|
||||
artists: Optional[List["tidalapi.artist.Artist"]] = None
|
||||
album: Optional["tidalapi.album.Album"] = None
|
||||
type: Optional[str] = None
|
||||
# Direct URL to media https://listen.tidal.com/track/<id> or https://listen.tidal.com/browse/album/<album_id>/track/<track_id>
|
||||
listen_url: str = ""
|
||||
# Direct URL to media https://tidal.com/browse/track/<id>
|
||||
share_url: str = ""
|
||||
|
||||
def __init__(
|
||||
self, session: "tidalapi.session.Session", media_id: Optional[str] = None
|
||||
):
|
||||
self.session = session
|
||||
self.requests = self.session.request
|
||||
self.album = session.album()
|
||||
self.id = media_id
|
||||
if self.id is not None:
|
||||
self._get(self.id)
|
||||
|
||||
@abstractmethod
|
||||
def _get(self, media_id: str) -> Media:
|
||||
raise NotImplementedError(
|
||||
"You are not supposed to use the media class directly."
|
||||
)
|
||||
|
||||
def parse(self, json_obj: JsonObj, album: Optional[Album] = None) -> None:
|
||||
"""Assigns all :param json_obj:
|
||||
|
||||
:param json_obj: The JSON object to parse
|
||||
:param album: The (optional) album to use, instead of parsing the JSON object
|
||||
:return:
|
||||
"""
|
||||
artists = self.session.parse_artists(json_obj["artists"])
|
||||
|
||||
# Sometimes the artist field is not filled, example: 62300893
|
||||
if "artist" in json_obj:
|
||||
artist = self.session.parse_artist(json_obj["artist"])
|
||||
else:
|
||||
artist = artists[0]
|
||||
|
||||
if album is None and json_obj["album"]:
|
||||
album = self.session.album().parse(json_obj["album"], artist, artists)
|
||||
self.album = album
|
||||
|
||||
self.id = json_obj["id"]
|
||||
self.name = json_obj["title"]
|
||||
self.duration = json_obj["duration"]
|
||||
self.available = bool(json_obj["streamReady"])
|
||||
|
||||
# Removed media does not have a release date.
|
||||
self.tidal_release_date = None
|
||||
release_date = json_obj.get("streamStartDate")
|
||||
self.tidal_release_date = (
|
||||
dateutil.parser.isoparse(release_date) if release_date else None
|
||||
)
|
||||
|
||||
# When getting items from playlists they have a date added attribute, same with
|
||||
# favorites.
|
||||
user_date_added = json_obj.get("dateAdded")
|
||||
self.user_date_added = (
|
||||
dateutil.parser.isoparse(user_date_added) if user_date_added else None
|
||||
)
|
||||
|
||||
self.track_num = json_obj["trackNumber"]
|
||||
self.volume_num = json_obj["volumeNumber"]
|
||||
self.explicit = bool(json_obj["explicit"])
|
||||
self.popularity = json_obj["popularity"]
|
||||
self.artist = artist
|
||||
self.artists = artists
|
||||
self.type = json_obj.get("type")
|
||||
|
||||
self.artist_roles = json_obj.get("artistRoles")
|
||||
|
||||
def parse_media(
|
||||
self, json_obj: JsonObj, album: Optional[Album] = None
|
||||
) -> Union["Track", "Video"]:
|
||||
"""Selects the media type when checking lists that can contain both.
|
||||
|
||||
:param json_obj: The json containing the media
|
||||
:param album: The (optional) album to use, instead of parsing the JSON object
|
||||
:return: Returns a new Video or Track object.
|
||||
"""
|
||||
if json_obj.get("type") is None or json_obj["type"] == "Track":
|
||||
return Track(self.session).parse_track(json_obj, album)
|
||||
# There are other types like Event, Live, and Video which match the video class
|
||||
return Video(self.session).parse_video(json_obj, album)
|
||||
|
||||
|
||||
class Track(Media):
|
||||
"""An object containing information about a track."""
|
||||
|
||||
replay_gain = None
|
||||
peak = None
|
||||
isrc = None
|
||||
audio_quality: Optional[str] = None
|
||||
audio_modes: Optional[List[str]] = None
|
||||
version = None
|
||||
full_name: Optional[str] = None
|
||||
copyright = None
|
||||
media_metadata_tags = None
|
||||
|
||||
def parse_track(self, json_obj: JsonObj, album: Optional[Album] = None) -> Track:
|
||||
Media.parse(self, json_obj, album)
|
||||
self.replay_gain = json_obj["replayGain"]
|
||||
# Tracks from the pages endpoints might not actually exist
|
||||
if "peak" in json_obj and "isrc" in json_obj:
|
||||
self.peak = json_obj["peak"]
|
||||
self.isrc = json_obj["isrc"]
|
||||
self.copyright = json_obj["copyright"]
|
||||
self.audio_quality = json_obj["audioQuality"]
|
||||
self.audio_modes = json_obj["audioModes"]
|
||||
self.version = json_obj["version"]
|
||||
self.media_metadata_tags = json_obj["mediaMetadata"]["tags"]
|
||||
|
||||
if self.version is not None:
|
||||
self.full_name = f"{json_obj['title']} ({json_obj['version']})"
|
||||
else:
|
||||
self.full_name = json_obj["title"]
|
||||
# Generate share URLs from track ID and album (if it exists)
|
||||
if self.album:
|
||||
self.listen_url = f"{self.session.config.listen_base_url}/album/{self.album.id}/track/{self.id}"
|
||||
else:
|
||||
self.listen_url = f"{self.session.config.listen_base_url}/track/{self.id}"
|
||||
self.share_url = f"{self.session.config.share_base_url}/track/{self.id}"
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
def _get(self, media_id: str) -> "Track":
|
||||
"""Returns information about a track, and also replaces the track used to call
|
||||
this function.
|
||||
|
||||
:param media_id: TIDAL's identifier of the track
|
||||
:return: A :class:`Track` object containing all the information about the track
|
||||
"""
|
||||
|
||||
try:
|
||||
request = self.requests.request("GET", "tracks/%s" % media_id)
|
||||
except ObjectNotFound:
|
||||
raise ObjectNotFound("Track not found or unavailable")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Track unavailable")
|
||||
else:
|
||||
json_obj = request.json()
|
||||
track = self.requests.map_json(json_obj, parse=self.parse_track)
|
||||
assert not isinstance(track, list)
|
||||
return cast("Track", track)
|
||||
|
||||
def get_url(self) -> str:
|
||||
"""Retrieves the URL for a track.
|
||||
|
||||
:return: A `str` object containing the direct track URL
|
||||
:raises: A :class:`exceptions.URLNotAvailable` if no URL is available for this track
|
||||
"""
|
||||
if self.session.is_pkce:
|
||||
raise URLNotAvailable(
|
||||
"Track URL not available with quality:'{}'".format(
|
||||
self.session.config.quality
|
||||
)
|
||||
)
|
||||
params = {
|
||||
"urlusagemode": "STREAM",
|
||||
"audioquality": self.session.config.quality,
|
||||
"assetpresentation": "FULL",
|
||||
}
|
||||
try:
|
||||
request = self.requests.request(
|
||||
"GET", "tracks/%s/urlpostpaywall" % self.id, params
|
||||
)
|
||||
except ObjectNotFound:
|
||||
raise URLNotAvailable("URL not available for this track")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("URL Unavailable")
|
||||
else:
|
||||
json_obj = request.json()
|
||||
return cast(str, json_obj["urls"][0])
|
||||
|
||||
def lyrics(self) -> "Lyrics":
|
||||
"""Retrieves the lyrics for a song.
|
||||
|
||||
:return: A :class:`Lyrics` object containing the lyrics
|
||||
:raises: A :class:`exceptions.MetadataNotAvailable` if there aren't any lyrics
|
||||
"""
|
||||
try:
|
||||
request = self.requests.request("GET", "tracks/%s/lyrics" % self.id)
|
||||
except ObjectNotFound:
|
||||
raise MetadataNotAvailable("No lyrics exists for this track")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Lyrics unavailable")
|
||||
else:
|
||||
json_obj = request.json()
|
||||
lyrics = self.requests.map_json(json_obj, parse=Lyrics().parse)
|
||||
assert not isinstance(lyrics, list)
|
||||
return cast("Lyrics", lyrics)
|
||||
|
||||
def get_track_radio(self, limit: int = 100) -> List["Track"]:
|
||||
"""Queries TIDAL for the track radio, which is a mix of tracks that are similar
|
||||
to this track.
|
||||
|
||||
:return: A list of :class:`Tracks <tidalapi.media.Track>`
|
||||
:raises: A :class:`exceptions.MetadataNotAvailable` if no track radio is available
|
||||
"""
|
||||
params = {"limit": limit}
|
||||
|
||||
try:
|
||||
request = self.requests.request(
|
||||
"GET", "tracks/%s/radio" % self.id, params=params
|
||||
)
|
||||
except ObjectNotFound:
|
||||
raise MetadataNotAvailable("Track radio not available for this track")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Track radio unavailable)")
|
||||
else:
|
||||
json_obj = request.json()
|
||||
tracks = self.requests.map_json(json_obj, parse=self.session.parse_track)
|
||||
assert isinstance(tracks, list)
|
||||
return cast(List["Track"], tracks)
|
||||
|
||||
def get_stream(self) -> "Stream":
|
||||
"""Retrieves the track streaming object, allowing for audio transmission.
|
||||
|
||||
:return: A :class:`Stream` object which holds audio file properties and
|
||||
parameters needed for streaming via `MPEG-DASH` protocol.
|
||||
:raises: A :class:`exceptions.StreamNotAvailable` if there is no stream available for this track
|
||||
"""
|
||||
params = {
|
||||
"playbackmode": "STREAM",
|
||||
"audioquality": self.session.config.quality,
|
||||
"assetpresentation": "FULL",
|
||||
}
|
||||
|
||||
try:
|
||||
request = self.requests.request(
|
||||
"GET", "tracks/%s/playbackinfopostpaywall" % self.id, params
|
||||
)
|
||||
except ObjectNotFound:
|
||||
raise StreamNotAvailable("Stream not available for this track")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Stream unavailable")
|
||||
else:
|
||||
json_obj = request.json()
|
||||
stream = self.requests.map_json(json_obj, parse=Stream().parse)
|
||||
assert not isinstance(stream, list)
|
||||
return cast("Stream", stream)
|
||||
|
||||
@property
|
||||
def is_hi_res_lossless(self) -> bool:
|
||||
try:
|
||||
if (
|
||||
self.media_metadata_tags
|
||||
and MediaMetadataTags.hi_res_lossless in self.media_metadata_tags
|
||||
):
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_lossless(self) -> bool:
|
||||
try:
|
||||
if (
|
||||
self.media_metadata_tags
|
||||
and MediaMetadataTags.lossless in self.media_metadata_tags
|
||||
):
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_dolby_atmos(self) -> bool:
|
||||
try:
|
||||
return True if AudioMode.dolby_atmos in self.audio_modes else False
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
class Stream:
|
||||
"""An object that stores the audio file properties and parameters needed for
|
||||
streaming via `MPEG-DASH` protocol.
|
||||
|
||||
The `manifest` attribute holds the MPD file content encoded in base64.
|
||||
"""
|
||||
|
||||
track_id: int = -1
|
||||
audio_mode: str = AudioMode.stereo # STEREO, DOLBY_ATMOS
|
||||
audio_quality: str = Quality.low_320k # LOW, HIGH, LOSSLESS, HI_RES_LOSSLESS
|
||||
manifest_mime_type: str = ""
|
||||
manifest_hash: str = ""
|
||||
manifest: str = ""
|
||||
asset_presentation: str = "FULL"
|
||||
album_replay_gain: float = 1.0
|
||||
album_peak_amplitude: float = 1.0
|
||||
track_replay_gain: float = 1.0
|
||||
track_peak_amplitude: float = 1.0
|
||||
bit_depth: int = 16
|
||||
sample_rate: int = 44100
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "Stream":
|
||||
self.track_id = json_obj.get("trackId")
|
||||
self.audio_mode = json_obj.get("audioMode")
|
||||
self.audio_quality = json_obj.get("audioQuality")
|
||||
self.manifest_mime_type = json_obj.get("manifestMimeType")
|
||||
self.manifest_hash = json_obj.get("manifestHash")
|
||||
self.manifest = json_obj.get("manifest")
|
||||
|
||||
# Use default values for gain, amplitude if unavailable
|
||||
self.album_replay_gain = json_obj.get("albumReplayGain", 1.0)
|
||||
self.album_peak_amplitude = json_obj.get("albumPeakAmplitude", 1.0)
|
||||
self.track_replay_gain = json_obj.get("trackReplayGain", 1.0)
|
||||
self.track_peak_amplitude = json_obj.get("trackPeakAmplitude", 1.0)
|
||||
|
||||
# Bit depth, Sample rate not available for low,hi_res quality modes. Assuming 16bit/44100Hz
|
||||
self.bit_depth = json_obj.get("bitDepth", 16)
|
||||
self.sample_rate = json_obj.get("sampleRate", 44100)
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
def get_audio_resolution(self):
|
||||
return self.bit_depth, self.sample_rate
|
||||
|
||||
def get_stream_manifest(self) -> "StreamManifest":
|
||||
return StreamManifest(self)
|
||||
|
||||
def get_manifest_data(self) -> str:
|
||||
try:
|
||||
# Stream Manifest is base64 encoded.
|
||||
return base64.b64decode(self.manifest).decode("utf-8")
|
||||
except:
|
||||
raise ManifestDecodeError
|
||||
|
||||
@property
|
||||
def is_mpd(self) -> bool:
|
||||
return True if ManifestMimeType.MPD in self.manifest_mime_type else False
|
||||
|
||||
@property
|
||||
def is_bts(self) -> bool:
|
||||
return True if ManifestMimeType.BTS in self.manifest_mime_type else False
|
||||
|
||||
|
||||
class StreamManifest:
|
||||
"""An object containing a parsed StreamManifest."""
|
||||
|
||||
manifest: str = None
|
||||
manifest_mime_type: str = None
|
||||
manifest_parsed: str = None
|
||||
codecs: str = None # MP3, AAC, FLAC, ALAC, MQA, EAC3, AC4, MHA1
|
||||
encryption_key = None
|
||||
encryption_type = None
|
||||
sample_rate: int = 44100
|
||||
urls: [str] = []
|
||||
mime_type: MimeType = MimeType.audio_mpeg
|
||||
file_extension = None
|
||||
dash_info: DashInfo = None
|
||||
|
||||
def __init__(self, stream: Stream):
|
||||
self.manifest = stream.manifest
|
||||
self.manifest_mime_type = stream.manifest_mime_type
|
||||
if stream.is_mpd:
|
||||
# See https://ottverse.com/structure-of-an-mpeg-dash-mpd/ for more details
|
||||
self.dash_info = DashInfo.from_mpd(stream.get_manifest_data())
|
||||
self.urls = self.dash_info.urls
|
||||
# MPD reports mp4a codecs slightly differently when compared to BTS. Both will be interpreted as MP4A
|
||||
if "flac" in self.dash_info.codecs:
|
||||
self.codecs = Codec.FLAC
|
||||
elif "mp4a.40.5" in self.dash_info.codecs:
|
||||
# LOW 96K: "mp4a.40.5"
|
||||
self.codecs = Codec.MP4A
|
||||
elif "mp4a.40.2" in self.dash_info.codecs:
|
||||
# LOW 320k "mp4a.40.2"
|
||||
self.codecs = Codec.MP4A
|
||||
else:
|
||||
self.codecs = self.dash_info.codecs
|
||||
self.mime_type = self.dash_info.mime_type
|
||||
self.sample_rate = self.dash_info.audio_sampling_rate
|
||||
# TODO: Handle encryption key.
|
||||
self.encryption_type = "NONE"
|
||||
self.encryption_key = None
|
||||
elif stream.is_bts:
|
||||
# Stream Manifest is base64 encoded.
|
||||
self.manifest_parsed = stream.get_manifest_data()
|
||||
# JSON string to object.
|
||||
stream_manifest = json.loads(self.manifest_parsed)
|
||||
# TODO: Handle more than one download URL
|
||||
self.urls = stream_manifest["urls"]
|
||||
# Codecs can be interpreted directly when using BTS
|
||||
self.codecs = stream_manifest["codecs"].upper().split(".")[0]
|
||||
self.mime_type = stream_manifest["mimeType"]
|
||||
self.encryption_type = stream_manifest["encryptionType"]
|
||||
self.encryption_key = (
|
||||
stream_manifest.get("keyId") if self.encryption_type else None
|
||||
)
|
||||
else:
|
||||
raise UnknownManifestFormat
|
||||
|
||||
self.file_extension = self.get_file_extension(self.urls[0], self.codecs)
|
||||
|
||||
def get_urls(self) -> [str]:
|
||||
return self.urls
|
||||
|
||||
def get_hls(self) -> str:
|
||||
if self.is_mpd:
|
||||
return self.dash_info.get_hls()
|
||||
else:
|
||||
raise MPDNotAvailableError("HLS stream requires MPD MetaData")
|
||||
|
||||
def get_codecs(self) -> str:
|
||||
return self.codecs
|
||||
|
||||
def get_sampling_rate(self) -> int:
|
||||
return self.dash_info.audio_sampling_rate
|
||||
|
||||
@staticmethod
|
||||
def get_mimetype(stream_codec, stream_url: Optional[str] = None) -> str:
|
||||
if stream_codec:
|
||||
return MimeType.from_audio_codec(stream_codec)
|
||||
if not stream_url:
|
||||
return MimeType.audio_m4a
|
||||
else:
|
||||
if AudioExtensions.FLAC in stream_url:
|
||||
return MimeType.audio_xflac
|
||||
elif AudioExtensions.MP4 in stream_url:
|
||||
return MimeType.audio_m4a
|
||||
|
||||
@staticmethod
|
||||
def get_file_extension(stream_url: str, stream_codec: Optional[str] = None) -> str:
|
||||
if AudioExtensions.FLAC in stream_url:
|
||||
# If the file extension within the URL is '*.flac', this is simply a FLAC file.
|
||||
result: str = AudioExtensions.FLAC
|
||||
elif AudioExtensions.MP4 in stream_url:
|
||||
# MPEG-4 is simply a container format for different audio / video encoded lines, like FLAC, AAC, M4A etc.
|
||||
# '*.m4a' is usually used as file extension, if the container contains only audio lines
|
||||
# See https://en.wikipedia.org/wiki/MP4_file_format
|
||||
result: str = AudioExtensions.M4A
|
||||
elif VideoExtensions.TS in stream_url:
|
||||
# Video are streamed as '*.ts' files by TIDAL.
|
||||
result: str = VideoExtensions.TS
|
||||
else:
|
||||
# If everything fails it might be an '*.mp4' file
|
||||
result: str = AudioExtensions.MP4
|
||||
|
||||
return result
|
||||
|
||||
@property
|
||||
def is_encrypted(self) -> bool:
|
||||
return True if self.encryption_key else False
|
||||
|
||||
@property
|
||||
def is_mpd(self) -> bool:
|
||||
return True if ManifestMimeType.MPD in self.manifest_mime_type else False
|
||||
|
||||
@property
|
||||
def is_bts(self) -> bool:
|
||||
return True if ManifestMimeType.BTS in self.manifest_mime_type else False
|
||||
|
||||
|
||||
class DashInfo:
|
||||
"""An object containing the decoded MPEG-DASH / MPD manifest."""
|
||||
|
||||
duration: datetime = timedelta()
|
||||
content_type: str = "audio"
|
||||
mime_type: MimeType = MimeType.audio_ac4
|
||||
codecs: str = Codec.FLAC
|
||||
first_url: str = ""
|
||||
media_url: str = ""
|
||||
timescale: int = 44100
|
||||
audio_sampling_rate: int = 44100
|
||||
chunk_size: int = -1
|
||||
last_chunk_size: int = -1
|
||||
urls: [str] = [""]
|
||||
|
||||
@staticmethod
|
||||
def from_stream(stream) -> "DashInfo":
|
||||
try:
|
||||
if stream.is_mpd and not stream.is_encrypted:
|
||||
return DashInfo(stream.get_manifest_data())
|
||||
except:
|
||||
raise ManifestDecodeError
|
||||
|
||||
@staticmethod
|
||||
def from_mpd(mpd_manifest) -> "DashInfo":
|
||||
try:
|
||||
return DashInfo(mpd_manifest)
|
||||
except:
|
||||
raise ManifestDecodeError
|
||||
|
||||
def __init__(self, mpd_xml):
|
||||
mpd = MPEGDASHParser.parse(
|
||||
mpd_xml.split("<?xml version='1.0' encoding='UTF-8'?>")[1]
|
||||
)
|
||||
|
||||
self.duration = parse_duration(mpd.media_presentation_duration)
|
||||
self.content_type = mpd.periods[0].adaptation_sets[0].content_type
|
||||
self.mime_type = mpd.periods[0].adaptation_sets[0].mime_type
|
||||
self.codecs = mpd.periods[0].adaptation_sets[0].representations[0].codecs
|
||||
self.first_url = (
|
||||
mpd.periods[0]
|
||||
.adaptation_sets[0]
|
||||
.representations[0]
|
||||
.segment_templates[0]
|
||||
.initialization
|
||||
)
|
||||
self.media_url = (
|
||||
mpd.periods[0]
|
||||
.adaptation_sets[0]
|
||||
.representations[0]
|
||||
.segment_templates[0]
|
||||
.media
|
||||
)
|
||||
# self.startNumber = mpd.periods[0].adaptation_sets[0].representations[0].segment_templates[0].start_number
|
||||
self.timescale = (
|
||||
mpd.periods[0]
|
||||
.adaptation_sets[0]
|
||||
.representations[0]
|
||||
.segment_templates[0]
|
||||
.timescale
|
||||
)
|
||||
self.audio_sampling_rate = int(
|
||||
mpd.periods[0].adaptation_sets[0].representations[0].audio_sampling_rate
|
||||
)
|
||||
self.chunk_size = (
|
||||
mpd.periods[0]
|
||||
.adaptation_sets[0]
|
||||
.representations[0]
|
||||
.segment_templates[0]
|
||||
.segment_timelines[0]
|
||||
.Ss[0]
|
||||
.d
|
||||
)
|
||||
# self.chunkcount = mpd.periods[0].adaptation_sets[0].representations[0].segment_templates[0].segment_timelines[0].Ss[0].r + 1
|
||||
self.last_chunk_size = (
|
||||
mpd.periods[0]
|
||||
.adaptation_sets[0]
|
||||
.representations[0]
|
||||
.segment_templates[0]
|
||||
.segment_timelines[0]
|
||||
.Ss[-1] # Always use last element in segment timeline.
|
||||
.d
|
||||
)
|
||||
|
||||
self.urls = self.get_urls(mpd)
|
||||
|
||||
@staticmethod
|
||||
def get_urls(mpd) -> list[str]:
|
||||
# min segments count; i.e. .initialization + the very first of .media;
|
||||
# See https://developers.broadpeak.io/docs/foundations-dash
|
||||
segments_count = 1 + 1
|
||||
|
||||
for s in (
|
||||
mpd.periods[0]
|
||||
.adaptation_sets[0]
|
||||
.representations[0]
|
||||
.segment_templates[0]
|
||||
.segment_timelines[0]
|
||||
.Ss
|
||||
):
|
||||
segments_count += s.r if s.r else 1
|
||||
|
||||
# Populate segment urls.
|
||||
segment_template = (
|
||||
mpd.periods[0]
|
||||
.adaptation_sets[0]
|
||||
.representations[0]
|
||||
.segment_templates[0]
|
||||
.media
|
||||
)
|
||||
stream_urls: list[str] = []
|
||||
|
||||
for index in range(segments_count):
|
||||
stream_urls.append(segment_template.replace("$Number$", str(index)))
|
||||
|
||||
return stream_urls
|
||||
|
||||
def get_hls(self) -> str:
|
||||
hls = "#EXTM3U\n"
|
||||
hls += "#EXT-X-TARGETDURATION:%s\n" % int(self.duration.seconds)
|
||||
hls += "#EXT-X-VERSION:3\n"
|
||||
items = self.urls
|
||||
chunk_duration = "#EXTINF:%0.3f,\n" % (
|
||||
float(self.chunk_size) / float(self.timescale)
|
||||
)
|
||||
hls += "\n".join(chunk_duration + item for item in items[0:-1])
|
||||
chunk_duration = "#EXTINF:%0.3f,\n" % (
|
||||
float(self.last_chunk_size) / float(self.timescale)
|
||||
)
|
||||
hls += "\n" + chunk_duration + items[-1] + "\n"
|
||||
hls += "#EXT-X-ENDLIST\n"
|
||||
return hls
|
||||
|
||||
|
||||
class Lyrics:
|
||||
"""An object containing lyrics for a track."""
|
||||
|
||||
track_id: int = -1
|
||||
provider: str = ""
|
||||
provider_track_id: int = -1
|
||||
provider_lyrics_id: int = -1
|
||||
text: str = ""
|
||||
#: Contains timestamps as well
|
||||
subtitles: str = ""
|
||||
right_to_left: bool = False
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "Lyrics":
|
||||
self.track_id = json_obj["trackId"]
|
||||
self.provider = json_obj["lyricsProvider"]
|
||||
self.provider_track_id = json_obj["providerCommontrackId"]
|
||||
self.provider_lyrics_id = json_obj["providerLyricsId"]
|
||||
self.text = json_obj["lyrics"]
|
||||
self.subtitles = json_obj["subtitles"]
|
||||
self.right_to_left = bool(json_obj["isRightToLeft"])
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
|
||||
class Video(Media):
|
||||
"""An object containing information about a video."""
|
||||
|
||||
release_date: Optional[datetime] = None
|
||||
video_quality: Optional[str] = None
|
||||
cover: Optional[str] = None
|
||||
|
||||
def parse_video(self, json_obj: JsonObj, album: Optional[Album] = None) -> Video:
|
||||
Media.parse(self, json_obj, album)
|
||||
release_date = json_obj.get("releaseDate")
|
||||
self.release_date = (
|
||||
dateutil.parser.isoparse(release_date) if release_date else None
|
||||
)
|
||||
self.cover = json_obj["imageId"]
|
||||
# Videos found in the /pages endpoints don't have quality
|
||||
self.video_quality = json_obj.get("quality")
|
||||
|
||||
# Generate share URLs from track ID and artist (if it exists)
|
||||
if self.artist:
|
||||
self.listen_url = f"{self.session.config.listen_base_url}/artist/{self.artist.id}/video/{self.id}"
|
||||
else:
|
||||
self.listen_url = f"{self.session.config.listen_base_url}/video/{self.id}"
|
||||
self.share_url = f"{self.session.config.share_base_url}/video/{self.id}"
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
def _get(self, media_id: str) -> Video:
|
||||
"""Returns information about the video, and replaces the object used to call
|
||||
this function.
|
||||
|
||||
:param media_id: TIDAL's identifier of the video
|
||||
:return: A :class:`Video` object containing all the information about the video.
|
||||
:raises: A :class:`exceptions.ObjectNotFound` if video is not found or unavailable
|
||||
"""
|
||||
|
||||
try:
|
||||
request = self.requests.request("GET", "videos/%s" % self.id)
|
||||
except ObjectNotFound:
|
||||
raise ObjectNotFound("Video not found or unavailable")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Video unavailable")
|
||||
else:
|
||||
json_obj = request.json()
|
||||
video = self.requests.map_json(json_obj, parse=self.parse_video)
|
||||
assert not isinstance(video, list)
|
||||
return cast("Video", video)
|
||||
|
||||
def get_url(self) -> str:
|
||||
"""Retrieves the URL to the m3u8 video playlist.
|
||||
|
||||
:return: A `str` object containing the direct video URL
|
||||
:raises: A :class:`exceptions.URLNotAvailable` if no URL is available for this video
|
||||
"""
|
||||
params = {
|
||||
"urlusagemode": "STREAM",
|
||||
"videoquality": self.session.config.video_quality,
|
||||
"assetpresentation": "FULL",
|
||||
}
|
||||
|
||||
try:
|
||||
request = self.requests.request(
|
||||
"GET", "videos/%s/urlpostpaywall" % self.id, params
|
||||
)
|
||||
except ObjectNotFound:
|
||||
raise URLNotAvailable("URL not available for this video")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("URL unavailable)")
|
||||
else:
|
||||
json_obj = request.json()
|
||||
return cast(str, json_obj["urls"][0])
|
||||
|
||||
def image(self, width: int = 1080, height: int = 720) -> str:
|
||||
if (width, height) not in [(160, 107), (480, 320), (750, 500), (1080, 720)]:
|
||||
raise ValueError("Invalid resolution {} x {}".format(width, height))
|
||||
if not self.cover:
|
||||
raise AttributeError("No cover image")
|
||||
return self.session.config.image_url % (
|
||||
self.cover.replace("-", "/"),
|
||||
width,
|
||||
height,
|
||||
)
|
291
env/lib/python3.11/site-packages/tidalapi/mix.py
vendored
Normal file
291
env/lib/python3.11/site-packages/tidalapi/mix.py
vendored
Normal file
@ -0,0 +1,291 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023- The Tidalapi Developers
|
||||
# Copyright (C) 2022 morguldir
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""A module containing functions relating to TIDAL mixes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional, Union
|
||||
|
||||
import dateutil.parser
|
||||
|
||||
from tidalapi.exceptions import ObjectNotFound, TooManyRequests
|
||||
from tidalapi.types import JsonObj
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidalapi.media import Track, Video
|
||||
from tidalapi.session import Session
|
||||
|
||||
|
||||
class MixType(Enum):
|
||||
"""An enum to track all the different types of mixes."""
|
||||
|
||||
welcome_mix = "WELCOME_MIX"
|
||||
video_daily = "VIDEO_DAILY_MIX"
|
||||
daily = "DAILY_MIX"
|
||||
discovery = "DISCOVERY_MIX"
|
||||
new_release = "NEW_RELEASE_MIX"
|
||||
track = "TRACK_MIX"
|
||||
artist = "ARTIST_MIX"
|
||||
songwriter = "SONGWRITER_MIX"
|
||||
producter = "PRODUCER_MIX"
|
||||
history_alltime = "HISTORY_ALLTIME_MIX"
|
||||
history_monthly = "HISTORY_MONTHLY_MIX"
|
||||
history_yearly = "HISTORY_YEARLY_MIX"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageResponse:
|
||||
small: str
|
||||
medium: str
|
||||
large: str
|
||||
|
||||
|
||||
class Mix:
|
||||
"""A mix from TIDAL, e.g. the listen.tidal.com/view/pages/my_collection_my_mixes.
|
||||
|
||||
These get used for many things, like artist/track radio's, recommendations, and
|
||||
historical plays
|
||||
"""
|
||||
|
||||
id: str = ""
|
||||
title: str = ""
|
||||
sub_title: str = ""
|
||||
sharing_images = None
|
||||
mix_type: Optional[MixType] = None
|
||||
content_behaviour: str = ""
|
||||
short_subtitle: str = ""
|
||||
images: Optional[ImageResponse] = None
|
||||
_retrieved = False
|
||||
_items: Optional[List[Union["Video", "Track"]]] = None
|
||||
|
||||
def __init__(self, session: Session, mix_id: Optional[str]):
|
||||
self.session = session
|
||||
self.request = session.request
|
||||
if mix_id is not None:
|
||||
self.get(mix_id)
|
||||
|
||||
def get(self, mix_id: Optional[str] = None) -> "Mix":
|
||||
"""Returns information about a mix, and also replaces the mix object used to
|
||||
call this function.
|
||||
|
||||
:param mix_id: TIDAL's identifier of the mix
|
||||
:return: A :class:`Mix` object containing all the information about the mix
|
||||
"""
|
||||
if mix_id is None:
|
||||
mix_id = self.id
|
||||
|
||||
params = {"mixId": mix_id, "deviceType": "BROWSER"}
|
||||
|
||||
try:
|
||||
request = self.request.request("GET", "pages/mix", params=params)
|
||||
except ObjectNotFound:
|
||||
raise ObjectNotFound("Mix not found")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Mix unavailable")
|
||||
else:
|
||||
result = self.session.parse_page(request.json())
|
||||
assert not isinstance(result, list)
|
||||
if len(result.categories) <= 1:
|
||||
# An empty page with no mixes was returned. Assume that the selected mix was not available
|
||||
raise ObjectNotFound("Mix not found")
|
||||
else:
|
||||
self._retrieved = True
|
||||
self.__dict__.update(result.categories[0].__dict__)
|
||||
self._items = result.categories[1].items
|
||||
return self
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "Mix":
|
||||
"""Parse a mix into a :class:`Mix`, replaces the calling object.
|
||||
|
||||
:param json_obj: The json of a mix to be parsed
|
||||
:return: A copy of the parsed mix
|
||||
"""
|
||||
self.id = json_obj["id"]
|
||||
self.title = json_obj["title"]
|
||||
self.sub_title = json_obj["subTitle"]
|
||||
self.sharing_images = json_obj["sharingImages"]
|
||||
self.mix_type = MixType(json_obj["mixType"])
|
||||
self.content_behaviour = json_obj["contentBehavior"]
|
||||
self.short_subtitle = json_obj["shortSubtitle"]
|
||||
images = json_obj["images"]
|
||||
self.images = ImageResponse(
|
||||
small=images["SMALL"]["url"],
|
||||
medium=images["MEDIUM"]["url"],
|
||||
large=images["LARGE"]["url"],
|
||||
)
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
def items(self) -> List[Union["Video", "Track"]]:
|
||||
"""Returns all the items in the mix, retrieves them with :class:`get` as well if
|
||||
not already done.
|
||||
|
||||
:return: A :class:`list` of videos and/or tracks from the mix
|
||||
"""
|
||||
if not self._retrieved:
|
||||
self.get(self.id)
|
||||
if not self._items:
|
||||
raise ValueError("Retrieved items missing")
|
||||
return self._items
|
||||
|
||||
def image(self, dimensions: int = 320) -> str:
|
||||
"""A URL to a Mix picture.
|
||||
|
||||
:param dimensions: The width and height the requested image should be
|
||||
:type dimensions: int
|
||||
:return: A url to the image
|
||||
|
||||
Original sizes: 320x320, 640x640, 1500x1500
|
||||
"""
|
||||
if not self.images:
|
||||
raise ValueError("No images present.")
|
||||
|
||||
if dimensions == 320:
|
||||
return self.images.small
|
||||
elif dimensions == 640:
|
||||
return self.images.medium
|
||||
elif dimensions == 1500:
|
||||
return self.images.large
|
||||
|
||||
raise ValueError(f"Invalid resolution {dimensions} x {dimensions}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextInfo:
|
||||
text: str
|
||||
color: str
|
||||
|
||||
|
||||
class MixV2:
|
||||
"""A mix from TIDALs v2 api endpoint."""
|
||||
|
||||
date_added: Optional[datetime] = None
|
||||
title: Optional[str] = None
|
||||
id: Optional[str] = None
|
||||
mix_type: Optional[MixType] = None
|
||||
images: Optional[ImageResponse] = None
|
||||
detail_images: Optional[ImageResponse] = None
|
||||
master = False
|
||||
title_text_info: Optional[TextInfo] = None
|
||||
sub_title_text_info: Optional[TextInfo] = None
|
||||
sub_title: Optional[str] = None
|
||||
updated: Optional[datetime] = None
|
||||
_retrieved = False
|
||||
_items: Optional[List[Union["Video", "Track"]]] = None
|
||||
|
||||
def __init__(self, session: Session, mix_id: str):
|
||||
self.session = session
|
||||
self.request = session.request
|
||||
if mix_id is not None:
|
||||
self.get(mix_id)
|
||||
|
||||
def get(self, mix_id: Optional[str] = None) -> "MixV2":
|
||||
"""Returns information about a mix, and also replaces the mix object used to
|
||||
call this function.
|
||||
|
||||
:param mix_id: TIDAL's identifier of the mix
|
||||
:return: A :class:`Mix` object containing all the information about the mix
|
||||
"""
|
||||
if mix_id is None:
|
||||
mix_id = self.id
|
||||
|
||||
params = {"mixId": mix_id, "deviceType": "BROWSER"}
|
||||
try:
|
||||
request = self.request.request("GET", "pages/mix", params=params)
|
||||
except ObjectNotFound:
|
||||
raise ObjectNotFound("Mix not found")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Mix unavailable")
|
||||
else:
|
||||
result = self.session.parse_page(request.json())
|
||||
assert not isinstance(result, list)
|
||||
|
||||
if len(result.categories) <= 1:
|
||||
# An empty page with no mixes was returned. Assume that the selected mix was not available
|
||||
raise ObjectNotFound("Mix not found")
|
||||
else:
|
||||
self._retrieved = True
|
||||
self.__dict__.update(result.categories[0].__dict__)
|
||||
self._items = result.categories[1].items
|
||||
return self
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "MixV2":
|
||||
"""Parse a mix into a :class:`MixV2`, replaces the calling object.
|
||||
|
||||
:param json_obj: The json of a mix to be parsed
|
||||
:return: A copy of the parsed mix
|
||||
"""
|
||||
date_added = json_obj.get("dateAdded")
|
||||
self.date_added = dateutil.parser.isoparse(date_added) if date_added else None
|
||||
self.title = json_obj["title"]
|
||||
self.id = json_obj["id"]
|
||||
self.title = json_obj["title"]
|
||||
self.mix_type = MixType(json_obj["mixType"])
|
||||
images = json_obj["images"]
|
||||
self.images = ImageResponse(
|
||||
small=images["SMALL"]["url"],
|
||||
medium=images["MEDIUM"]["url"],
|
||||
large=images["LARGE"]["url"],
|
||||
)
|
||||
detail_images = json_obj["detailImages"]
|
||||
self.detail_images = ImageResponse(
|
||||
small=detail_images["SMALL"]["url"],
|
||||
medium=detail_images["MEDIUM"]["url"],
|
||||
large=detail_images["LARGE"]["url"],
|
||||
)
|
||||
self.master = json_obj["master"]
|
||||
title_text_info = json_obj["titleTextInfo"]
|
||||
self.title_text_info = TextInfo(
|
||||
text=title_text_info["text"],
|
||||
color=title_text_info["color"],
|
||||
)
|
||||
sub_title_text_info = json_obj["subTitleTextInfo"]
|
||||
self.sub_title_text_info = TextInfo(
|
||||
text=sub_title_text_info["text"],
|
||||
color=sub_title_text_info["color"],
|
||||
)
|
||||
self.sub_title = json_obj["subTitle"]
|
||||
updated = json_obj.get("updated")
|
||||
self.date_added = dateutil.parser.isoparse(updated) if date_added else None
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
def image(self, dimensions: int = 320) -> str:
|
||||
"""A URL to a Mix picture.
|
||||
|
||||
:param dimensions: The width and height the requested image should be
|
||||
:type dimensions: int
|
||||
:return: A url to the image
|
||||
|
||||
Original sizes: 320x320, 640x640, 1500x1500
|
||||
"""
|
||||
if not self.images:
|
||||
raise ValueError("No images present.")
|
||||
|
||||
if dimensions == 320:
|
||||
return self.images.small
|
||||
elif dimensions == 640:
|
||||
return self.images.medium
|
||||
elif dimensions == 1500:
|
||||
return self.images.large
|
||||
|
||||
raise ValueError(f"Invalid resolution {dimensions} x {dimensions}")
|
410
env/lib/python3.11/site-packages/tidalapi/page.py
vendored
Normal file
410
env/lib/python3.11/site-packages/tidalapi/page.py
vendored
Normal file
@ -0,0 +1,410 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2023- The Tidalapi Developers
|
||||
# Copyright (C) 2021-2022 morguldir
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""
|
||||
Module for parsing TIDAL's pages format found at https://listen.tidal.com/v1/pages
|
||||
"""
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterator,
|
||||
List,
|
||||
Optional,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from tidalapi.types import JsonObj
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidalapi.album import Album
|
||||
from tidalapi.artist import Artist
|
||||
from tidalapi.media import Track, Video
|
||||
from tidalapi.mix import Mix
|
||||
from tidalapi.playlist import Playlist, UserPlaylist
|
||||
from tidalapi.request import Requests
|
||||
from tidalapi.session import Session
|
||||
|
||||
PageCategories = Union[
|
||||
"Album",
|
||||
"PageLinks",
|
||||
"FeaturedItems",
|
||||
"ItemList",
|
||||
"TextBlock",
|
||||
"LinkList",
|
||||
"Mix",
|
||||
]
|
||||
|
||||
AllCategories = Union["Artist", PageCategories]
|
||||
|
||||
|
||||
class Page:
|
||||
"""
|
||||
A page from the https://listen.tidal.com/view/pages/ endpoint
|
||||
|
||||
The :class:`categories` field will the most complete information
|
||||
However it is an iterable that goes through all the visible items on the page as well, in the natural reading order
|
||||
"""
|
||||
|
||||
title: str = ""
|
||||
categories: Optional[List["AllCategories"]] = None
|
||||
_categories_iter: Optional[Iterator["AllCategories"]] = None
|
||||
_items_iter: Optional[Iterator[Callable[..., Any]]] = None
|
||||
page_category: "PageCategory"
|
||||
request: "Requests"
|
||||
|
||||
def __init__(self, session: "Session", title: str):
|
||||
self.request = session.request
|
||||
self.categories = None
|
||||
self.title = title
|
||||
self.page_category = PageCategory(session)
|
||||
|
||||
def __iter__(self) -> "Page":
|
||||
if self.categories is None:
|
||||
raise AttributeError("No categories found")
|
||||
self._categories_iter = iter(self.categories)
|
||||
self._category = next(self._categories_iter)
|
||||
self._items_iter = iter(cast(List[Callable[..., Any]], self._category.items))
|
||||
return self
|
||||
|
||||
def __next__(self) -> Callable[..., Any]:
|
||||
if self._items_iter is None:
|
||||
return StopIteration
|
||||
try:
|
||||
item = next(self._items_iter)
|
||||
except StopIteration:
|
||||
if self._categories_iter is None:
|
||||
raise AttributeError("No categories found")
|
||||
self._category = next(self._categories_iter)
|
||||
self._items_iter = iter(
|
||||
cast(List[Callable[..., Any]], self._category.items)
|
||||
)
|
||||
return self.__next__()
|
||||
return item
|
||||
|
||||
def next(self) -> Callable[..., Any]:
|
||||
return self.__next__()
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "Page":
|
||||
"""Goes through everything in the page, and gets the title and adds all the rows
|
||||
to the categories field :param json_obj: The json to be parsed :return: A copy
|
||||
of the Page that you can use to browse all the items."""
|
||||
self.title = json_obj["title"]
|
||||
self.categories = []
|
||||
for row in json_obj["rows"]:
|
||||
page_item = self.page_category.parse(row["modules"][0])
|
||||
self.categories.append(page_item)
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> "Page":
|
||||
"""Retrieve a page from the specified endpoint, overwrites the calling page.
|
||||
|
||||
:param params: Parameter to retrieve the page with
|
||||
:param endpoint: The endpoint you want to retrieve
|
||||
:return: A copy of the new :class:`.Page` at the requested endpoint
|
||||
"""
|
||||
url = endpoint
|
||||
|
||||
if params is None:
|
||||
params = {}
|
||||
if "deviceType" not in params:
|
||||
params["deviceType"] = "BROWSER"
|
||||
|
||||
json_obj = self.request.request("GET", url, params=params).json()
|
||||
return self.parse(json_obj)
|
||||
|
||||
|
||||
@dataclass
|
||||
class More:
|
||||
api_path: str
|
||||
title: str
|
||||
|
||||
@classmethod
|
||||
def parse(cls, json_obj: JsonObj) -> Optional["More"]:
|
||||
show_more = json_obj.get("showMore")
|
||||
if show_more is None:
|
||||
return None
|
||||
else:
|
||||
return cls(api_path=show_more["apiPath"], title=show_more["title"])
|
||||
|
||||
|
||||
class PageCategory:
|
||||
type = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = ""
|
||||
request: "Requests"
|
||||
_more: Optional[More] = None
|
||||
|
||||
def __init__(self, session: "Session"):
|
||||
self.session = session
|
||||
self.request = session.request
|
||||
self.item_types: Dict[str, Callable[..., Any]] = {
|
||||
"ALBUM_LIST": self.session.parse_album,
|
||||
"ARTIST_LIST": self.session.parse_artist,
|
||||
"TRACK_LIST": self.session.parse_track,
|
||||
"PLAYLIST_LIST": self.session.parse_playlist,
|
||||
"VIDEO_LIST": self.session.parse_video,
|
||||
"MIX_LIST": self.session.parse_mix,
|
||||
}
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> AllCategories:
|
||||
result = None
|
||||
category_type = json_obj["type"]
|
||||
if category_type in ("PAGE_LINKS_CLOUD", "PAGE_LINKS"):
|
||||
category: PageCategories = PageLinks(self.session)
|
||||
elif category_type in ("FEATURED_PROMOTIONS", "MULTIPLE_TOP_PROMOTIONS"):
|
||||
category = FeaturedItems(self.session)
|
||||
elif category_type in self.item_types.keys():
|
||||
category = ItemList(self.session)
|
||||
elif category_type == "MIX_HEADER":
|
||||
return self.session.parse_mix(json_obj["mix"])
|
||||
elif category_type == "ARTIST_HEADER":
|
||||
result = self.session.parse_artist(json_obj["artist"])
|
||||
result.bio = json_obj["bio"]
|
||||
return ItemHeader(result)
|
||||
elif category_type == "ALBUM_HEADER":
|
||||
return ItemHeader(self.session.parse_album(json_obj["album"]))
|
||||
elif category_type == "HIGHLIGHT_MODULE":
|
||||
category = ItemList(self.session)
|
||||
elif category_type == "MIXED_TYPES_LIST":
|
||||
category = ItemList(self.session)
|
||||
elif category_type == "TEXT_BLOCK":
|
||||
category = TextBlock(self.session)
|
||||
elif category_type in ("ITEM_LIST_WITH_ROLES", "ALBUM_ITEMS"):
|
||||
category = ItemList(self.session)
|
||||
elif category_type == "ARTICLE_LIST":
|
||||
json_obj["items"] = json_obj["pagedList"]["items"]
|
||||
category = LinkList(self.session)
|
||||
elif category_type == "SOCIAL":
|
||||
json_obj["items"] = json_obj["socialProfiles"]
|
||||
category = LinkList(self.session)
|
||||
else:
|
||||
raise NotImplementedError(f"PageType {category_type} not implemented")
|
||||
|
||||
return category.parse(json_obj)
|
||||
|
||||
def show_more(self) -> Optional[Page]:
|
||||
"""Get the full list of items on their own :class:`.Page` from a
|
||||
:class:`.PageCategory`
|
||||
|
||||
:return: A :class:`.Page` more of the items in the category, None if there aren't any
|
||||
"""
|
||||
api_path = self._more.api_path if self._more else None
|
||||
return (
|
||||
Page(self.session, self._more.title).get(api_path)
|
||||
if api_path and self._more
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
class FeaturedItems(PageCategory):
|
||||
"""Items that have been featured by TIDAL."""
|
||||
|
||||
items: Optional[List["PageItem"]] = None
|
||||
|
||||
def __init__(self, session: "Session"):
|
||||
super().__init__(session)
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "FeaturedItems":
|
||||
self.items = []
|
||||
self.title = json_obj["title"]
|
||||
self.description = json_obj["description"]
|
||||
|
||||
for item in json_obj["items"]:
|
||||
self.items.append(PageItem(self.session, item))
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class PageLinks(PageCategory):
|
||||
"""A list of :class:`.PageLink` to other parts of TIDAL."""
|
||||
|
||||
items: Optional[List["PageLink"]] = None
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "PageLinks":
|
||||
"""Parse the list of links from TIDAL.
|
||||
|
||||
:param json_obj: The json to be parsed
|
||||
:return: A copy of this page category containing the links in the items field
|
||||
"""
|
||||
self._more = More.parse(json_obj)
|
||||
self.title = json_obj["title"]
|
||||
self.items = []
|
||||
for item in json_obj["pagedList"]["items"]:
|
||||
self.items.append(PageLink(self.session, item))
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
|
||||
class ItemList(PageCategory):
|
||||
"""A list of items from TIDAL, can be a list of mixes, for example, or a list of
|
||||
playlists and mixes in some cases."""
|
||||
|
||||
items: Optional[List[Any]] = None
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "ItemList":
|
||||
"""Parse a list of items on TIDAL from the pages endpoints.
|
||||
|
||||
:param json_obj: The json from TIDAL to be parsed
|
||||
:return: A copy of the ItemList with a list of items
|
||||
"""
|
||||
self._more = More.parse(json_obj)
|
||||
self.title = json_obj["title"]
|
||||
item_type = json_obj["type"]
|
||||
list_key = "pagedList"
|
||||
session: Optional["Session"] = None
|
||||
parse: Optional[Callable[..., Any]] = None
|
||||
|
||||
if item_type in self.item_types.keys():
|
||||
parse = self.item_types[item_type]
|
||||
elif item_type == "HIGHLIGHT_MODULE":
|
||||
session = self.session
|
||||
# Unwrap subtitle, maybe add a field for it later
|
||||
json_obj[list_key] = {"items": [x["item"] for x in json_obj["highlights"]]}
|
||||
elif item_type in ("MIXED_TYPES_LIST", "ALBUM_ITEMS"):
|
||||
session = self.session
|
||||
elif item_type == "ITEM_LIST_WITH_ROLES":
|
||||
for item in json_obj[list_key]["items"]:
|
||||
item["item"]["artistRoles"] = item["roles"]
|
||||
session = self.session
|
||||
else:
|
||||
raise NotImplementedError("PageType {} not implemented".format(item_type))
|
||||
|
||||
self.items = self.request.map_json(json_obj[list_key], parse, session)
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
|
||||
class PageLink:
|
||||
"""A Link to another :class:`.Page` on TIDAL, Call get() to retrieve the Page."""
|
||||
|
||||
title: str
|
||||
icon = None
|
||||
image_id = None
|
||||
|
||||
def __init__(self, session: "Session", json_obj: JsonObj):
|
||||
self.session = session
|
||||
self.request = session.request
|
||||
self.title = json_obj["title"]
|
||||
self.icon = json_obj["icon"]
|
||||
self.api_path = cast(str, json_obj["apiPath"])
|
||||
self.image_id = json_obj["imageId"]
|
||||
|
||||
def get(self) -> "Page":
|
||||
"""Requests the linked page from TIDAL :return: A :class:`Page` at the
|
||||
api_path."""
|
||||
return cast(
|
||||
"Page",
|
||||
self.request.map_request(
|
||||
self.api_path,
|
||||
params={"deviceType": "DESKTOP"},
|
||||
parse=self.session.parse_page,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class PageItem:
|
||||
"""An Item from a :class:`.PageCategory` from the /pages endpoint, call get() to
|
||||
retrieve the actual item."""
|
||||
|
||||
header: str = ""
|
||||
short_header: str = ""
|
||||
short_sub_header: str = ""
|
||||
image_id: str = ""
|
||||
type: str = ""
|
||||
artifact_id: str = ""
|
||||
text: str = ""
|
||||
featured: bool = False
|
||||
session: "Session"
|
||||
|
||||
def __init__(self, session: "Session", json_obj: JsonObj):
|
||||
self.session = session
|
||||
self.request = session.request
|
||||
self.header = json_obj["header"]
|
||||
self.short_header = json_obj["shortHeader"]
|
||||
self.short_sub_header = json_obj["shortSubHeader"]
|
||||
self.image_id = json_obj["imageId"]
|
||||
self.type = json_obj["type"]
|
||||
self.artifact_id = json_obj["artifactId"]
|
||||
self.text = json_obj["text"]
|
||||
self.featured = bool(json_obj["featured"])
|
||||
|
||||
def get(self) -> Union["Artist", "Playlist", "Track", "UserPlaylist", "Video"]:
|
||||
"""Retrieve the PageItem with the artifact_id matching the type.
|
||||
|
||||
:return: The fully parsed item, e.g. :class:`.Playlist`, :class:`.Video`, :class:`.Track`
|
||||
"""
|
||||
if self.type == "PLAYLIST":
|
||||
return self.session.playlist(self.artifact_id)
|
||||
elif self.type == "VIDEO":
|
||||
return self.session.video(self.artifact_id)
|
||||
elif self.type == "TRACK":
|
||||
return self.session.track(self.artifact_id)
|
||||
elif self.type == "ARTIST":
|
||||
return self.session.artist(self.artifact_id)
|
||||
elif self.type == "ALBUM":
|
||||
return self.session.album(self.artifact_id)
|
||||
raise NotImplementedError(f"PageItem type {self.type} not implemented")
|
||||
|
||||
|
||||
class TextBlock(object):
|
||||
"""A block of text, with a named icon, which seems to be left up to the
|
||||
application."""
|
||||
|
||||
text: str = ""
|
||||
icon: str = ""
|
||||
items: Optional[List[str]] = None
|
||||
|
||||
def __init__(self, session: "Session"):
|
||||
self.session = session
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "TextBlock":
|
||||
self.text = json_obj["text"]
|
||||
self.icon = json_obj["icon"]
|
||||
self.items = [self.text]
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
|
||||
class LinkList(PageCategory):
|
||||
"""A list of items containing links, e.g. social links or articles."""
|
||||
|
||||
items: Optional[List[Any]] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "LinkList":
|
||||
self.items = json_obj["items"]
|
||||
self.title = json_obj["title"]
|
||||
self.description = json_obj["description"]
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
|
||||
class ItemHeader(object):
|
||||
"""Single item in a "category" of the page."""
|
||||
|
||||
items: Optional[List[Any]] = None
|
||||
|
||||
def __init__(self, item: Any):
|
||||
self.items = [item]
|
732
env/lib/python3.11/site-packages/tidalapi/playlist.py
vendored
Normal file
732
env/lib/python3.11/site-packages/tidalapi/playlist.py
vendored
Normal file
@ -0,0 +1,732 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2023- The Tidalapi Developers
|
||||
# Copyright (C) 2019-2022 morguldir
|
||||
# Copyright (C) 2014 Thomas Amland
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""A module containing things related to TIDAL playlists."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, List, Optional, Sequence, Union, cast
|
||||
|
||||
from tidalapi.exceptions import ObjectNotFound, TooManyRequests
|
||||
from tidalapi.types import JsonObj
|
||||
from tidalapi.user import LoggedInUser
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidalapi.artist import Artist
|
||||
from tidalapi.media import Track, Video
|
||||
from tidalapi.session import Session
|
||||
from tidalapi.user import User
|
||||
|
||||
import dateutil.parser
|
||||
|
||||
|
||||
def list_validate(lst):
|
||||
if isinstance(lst, str):
|
||||
lst = [lst]
|
||||
if len(lst) == 0:
|
||||
raise ValueError("An empty list was provided.")
|
||||
return lst
|
||||
|
||||
|
||||
class Playlist:
|
||||
"""An object containing various data about a playlist and methods to work with
|
||||
them."""
|
||||
|
||||
id: Optional[str] = None
|
||||
trn: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
num_tracks: int = -1
|
||||
num_videos: int = -1
|
||||
creator: Optional[Union["Artist", "User"]] = None
|
||||
description: Optional[str] = None
|
||||
duration: int = -1
|
||||
last_updated: Optional[datetime] = None
|
||||
created: Optional[datetime] = None
|
||||
type = None
|
||||
public: Optional[bool] = False
|
||||
popularity: Optional[int] = None
|
||||
promoted_artists: Optional[List["Artist"]] = None
|
||||
last_item_added_at: Optional[datetime] = None
|
||||
picture: Optional[str] = None
|
||||
square_picture: Optional[str] = None
|
||||
user_date_added: Optional[datetime] = None
|
||||
_etag: Optional[str] = None
|
||||
|
||||
# Direct URL to https://listen.tidal.com/playlist/<playlist_id>
|
||||
listen_url: str = ""
|
||||
# Direct URL to https://tidal.com/browse/playlist/<playlist_id>
|
||||
share_url: str = ""
|
||||
|
||||
def __init__(self, session: "Session", playlist_id: Optional[str]):
|
||||
self.id = playlist_id
|
||||
self.session = session
|
||||
self.request = session.request
|
||||
self._base_url = "playlists/%s"
|
||||
if playlist_id:
|
||||
try:
|
||||
request = self.request.request("GET", self._base_url % self.id)
|
||||
except ObjectNotFound:
|
||||
raise ObjectNotFound("Playlist not found")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Playlist unavailable")
|
||||
else:
|
||||
self._etag = request.headers["etag"]
|
||||
self.parse(request.json())
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "Playlist":
|
||||
"""Parses a playlist from tidal, replaces the current playlist object.
|
||||
|
||||
:param json_obj: Json data returned from api.tidal.com containing a playlist
|
||||
:return: Returns a copy of the original :exc: 'Playlist': object
|
||||
"""
|
||||
self.id = json_obj["uuid"]
|
||||
self.trn = f"trn:playlist:{self.id}"
|
||||
self.name = json_obj["title"]
|
||||
self.num_tracks = int(json_obj["numberOfTracks"])
|
||||
self.num_videos = int(json_obj["numberOfVideos"])
|
||||
self.description = json_obj["description"]
|
||||
self.duration = int(json_obj["duration"])
|
||||
|
||||
# These can be missing on from the /pages endpoints
|
||||
last_updated = json_obj.get("lastUpdated")
|
||||
self.last_updated = (
|
||||
dateutil.parser.isoparse(last_updated) if last_updated else None
|
||||
)
|
||||
created = json_obj.get("created")
|
||||
self.created = dateutil.parser.isoparse(created) if created else None
|
||||
public = json_obj.get("publicPlaylist")
|
||||
self.public = None if public is None else bool(public)
|
||||
popularity = json_obj.get("popularity")
|
||||
self.popularity = int(popularity) if popularity else None
|
||||
|
||||
self.type = json_obj["type"]
|
||||
self.picture = json_obj["image"]
|
||||
self.square_picture = json_obj["squareImage"]
|
||||
|
||||
promoted_artists = json_obj["promotedArtists"]
|
||||
self.promoted_artists = (
|
||||
self.session.parse_artists(promoted_artists) if promoted_artists else None
|
||||
)
|
||||
|
||||
last_item_added_at = json_obj.get("lastItemAddedAt")
|
||||
self.last_item_added_at = (
|
||||
dateutil.parser.isoparse(last_item_added_at) if last_item_added_at else None
|
||||
)
|
||||
|
||||
user_date_added = json_obj.get("dateAdded")
|
||||
self.user_date_added = (
|
||||
dateutil.parser.isoparse(user_date_added) if user_date_added else None
|
||||
)
|
||||
|
||||
creator = json_obj.get("creator")
|
||||
if self.type == "ARTIST" and creator and creator.get("id"):
|
||||
self.creator = self.session.parse_artist(creator)
|
||||
else:
|
||||
self.creator = self.session.parse_user(creator) if creator else None
|
||||
|
||||
self.listen_url = f"{self.session.config.listen_base_url}/playlist/{self.id}"
|
||||
self.share_url = f"{self.session.config.share_base_url}/playlist/{self.id}"
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
def factory(self) -> Union["Playlist", "UserPlaylist"]:
|
||||
if (
|
||||
self.id
|
||||
and self.creator
|
||||
and isinstance(self.session.user, LoggedInUser)
|
||||
and self.creator.id == self.session.user.id
|
||||
):
|
||||
return UserPlaylist(self.session, self.id)
|
||||
|
||||
return self
|
||||
|
||||
def parse_factory(self, json_obj: JsonObj) -> "Playlist":
|
||||
self.parse(json_obj)
|
||||
return copy.copy(self.factory())
|
||||
|
||||
def tracks(self, limit: Optional[int] = None, offset: int = 0) -> List["Track"]:
|
||||
"""Gets the playlists' tracks from TIDAL.
|
||||
|
||||
:param limit: The amount of items you want returned.
|
||||
:param offset: The index of the first item you want included.
|
||||
:return: A list of :class:`Tracks <.Track>`
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
request = self.request.request(
|
||||
"GET", self._base_url % self.id + "/tracks", params=params
|
||||
)
|
||||
self._etag = request.headers["etag"]
|
||||
return list(
|
||||
self.request.map_json(
|
||||
json_obj=request.json(), parse=self.session.parse_track
|
||||
)
|
||||
)
|
||||
|
||||
def items(self, limit: int = 100, offset: int = 0) -> List[Union["Track", "Video"]]:
|
||||
"""Fetches up to the first 100 items, including tracks and videos.
|
||||
|
||||
:param limit: The amount of items you want, up to 100.
|
||||
:param offset: The index of the first item you want returned
|
||||
:return: A list of :class:`Tracks<.Track>` and :class:`Videos<.Video>`
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
request = self.request.request(
|
||||
"GET", self._base_url % self.id + "/items", params=params
|
||||
)
|
||||
self._etag = request.headers["etag"]
|
||||
return list(
|
||||
self.request.map_json(request.json(), parse=self.session.parse_media)
|
||||
)
|
||||
|
||||
def image(self, dimensions: int = 480, wide_fallback: bool = True) -> str:
|
||||
"""A URL to a playlist picture.
|
||||
|
||||
:param dimensions: The width and height that want from the image
|
||||
:type dimensions: int
|
||||
:param wide_fallback: Use wide image as fallback if no square cover picture exists
|
||||
:type wide_fallback: bool
|
||||
:return: A url to the image
|
||||
|
||||
Original sizes: 160x160, 320x320, 480x480, 640x640, 750x750, 1080x1080
|
||||
"""
|
||||
|
||||
if dimensions not in [160, 320, 480, 640, 750, 1080]:
|
||||
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))
|
||||
if self.square_picture:
|
||||
return self.session.config.image_url % (
|
||||
self.square_picture.replace("-", "/"),
|
||||
dimensions,
|
||||
dimensions,
|
||||
)
|
||||
elif self.picture and wide_fallback:
|
||||
return self.wide_image()
|
||||
else:
|
||||
raise AttributeError("No picture available")
|
||||
|
||||
def wide_image(self, width: int = 1080, height: int = 720) -> str:
|
||||
"""Create a url to a wider playlist image.
|
||||
|
||||
:param width: The width of the image
|
||||
:param height: The height of the image
|
||||
:return: Returns a url to the image with the specified resolution
|
||||
|
||||
Valid sizes: 160x107, 480x320, 750x500, 1080x720
|
||||
"""
|
||||
|
||||
if (width, height) not in [(160, 107), (480, 320), (750, 500), (1080, 720)]:
|
||||
raise ValueError("Invalid resolution {} x {}".format(width, height))
|
||||
if self.picture is None:
|
||||
raise AttributeError("No picture available")
|
||||
return self.session.config.image_url % (
|
||||
self.picture.replace("-", "/"),
|
||||
width,
|
||||
height,
|
||||
)
|
||||
|
||||
|
||||
class Folder:
|
||||
"""An object containing various data about a folder and methods to work with
|
||||
them."""
|
||||
|
||||
trn: Optional[str] = None
|
||||
id: Optional[str] = None
|
||||
parent_folder_id: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
parent: Optional[str] = None # TODO Determine the correct type of the parent
|
||||
added: Optional[datetime] = None
|
||||
created: Optional[datetime] = None
|
||||
last_modified: Optional[datetime] = None
|
||||
total_number_of_items: int = 0
|
||||
|
||||
# Direct URL to https://listen.tidal.com/folder/<folder_id>
|
||||
listen_url: str = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
session: "Session",
|
||||
folder_id: Optional[str],
|
||||
parent_folder_id: str = "root",
|
||||
):
|
||||
self.id = folder_id
|
||||
self.parent_folder_id = parent_folder_id
|
||||
self.session = session
|
||||
self.request = session.request
|
||||
self.playlist = session.playlist()
|
||||
self._endpoint = "my-collection/playlists/folders"
|
||||
if folder_id:
|
||||
# Go through all available folders and see if the requested folder exists
|
||||
try:
|
||||
params = {
|
||||
"folderId": parent_folder_id,
|
||||
"offset": 0,
|
||||
"limit": 50,
|
||||
"order": "NAME",
|
||||
"includeOnly": "FOLDER",
|
||||
}
|
||||
request = self.request.request(
|
||||
"GET",
|
||||
self._endpoint,
|
||||
base_url=self.session.config.api_v2_location,
|
||||
params=params,
|
||||
)
|
||||
for item in request.json().get("items"):
|
||||
if item["data"].get("id") == folder_id:
|
||||
self.parse(item)
|
||||
return
|
||||
raise ObjectNotFound
|
||||
except ObjectNotFound:
|
||||
raise ObjectNotFound(f"Folder not found")
|
||||
except TooManyRequests:
|
||||
raise TooManyRequests("Folder unavailable")
|
||||
|
||||
def _reparse(self) -> None:
|
||||
params = {
|
||||
"folderId": self.parent_folder_id,
|
||||
"offset": 0,
|
||||
"limit": 50,
|
||||
"order": "NAME",
|
||||
"includeOnly": "FOLDER",
|
||||
}
|
||||
request = self.request.request(
|
||||
"GET",
|
||||
self._endpoint,
|
||||
base_url=self.session.config.api_v2_location,
|
||||
params=params,
|
||||
)
|
||||
for item in request.json().get("items"):
|
||||
if item["data"].get("id") == self.id:
|
||||
self.parse(item)
|
||||
return
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "Folder":
|
||||
"""Parses a folder from tidal, replaces the current folder object.
|
||||
|
||||
:param json_obj: Json data returned from api.tidal.com containing a folder
|
||||
:return: Returns a copy of the original :class:`Folder` object
|
||||
"""
|
||||
self.trn = json_obj.get("trn")
|
||||
self.id = json_obj["data"].get("id")
|
||||
self.name = json_obj.get("name")
|
||||
self.parent = json_obj.get("parent")
|
||||
added = json_obj.get("addedAt")
|
||||
created = json_obj["data"].get("createdAt")
|
||||
last_modified = json_obj["data"].get("lastModifiedAt")
|
||||
self.added = dateutil.parser.isoparse(added) if added else None
|
||||
self.created = dateutil.parser.isoparse(created) if added else None
|
||||
self.last_modified = dateutil.parser.isoparse(last_modified) if added else None
|
||||
self.total_number_of_items = json_obj["data"].get("totalNumberOfItems")
|
||||
|
||||
self.listen_url = f"{self.session.config.listen_base_url}/folder/{self.id}"
|
||||
|
||||
return copy.copy(self)
|
||||
|
||||
def rename(self, name: str) -> bool:
|
||||
"""Rename the selected folder.
|
||||
|
||||
:param name: The name to be used for the folder
|
||||
:return: True, if operation was successful.
|
||||
"""
|
||||
params = {"trn": self.trn, "name": name}
|
||||
endpoint = "my-collection/playlists/folders/rename"
|
||||
res = self.request.request(
|
||||
"PUT",
|
||||
endpoint,
|
||||
base_url=self.session.config.api_v2_location,
|
||||
params=params,
|
||||
)
|
||||
self._reparse()
|
||||
return res.ok
|
||||
|
||||
def remove(self) -> bool:
|
||||
"""Remove the selected folder.
|
||||
|
||||
:return: True, if operation was successful.
|
||||
"""
|
||||
params = {"trns": self.trn}
|
||||
endpoint = "my-collection/playlists/folders/remove"
|
||||
return self.request.request(
|
||||
"PUT",
|
||||
endpoint,
|
||||
base_url=self.session.config.api_v2_location,
|
||||
params=params,
|
||||
).ok
|
||||
|
||||
def items(
|
||||
self, offset: int = 0, limit: int = 50
|
||||
) -> List[Union["Playlist", "UserPlaylist"]]:
|
||||
"""Return the items in the folder.
|
||||
|
||||
:param offset: Optional; The index of the first item to be returned. Default: 0
|
||||
:param limit: Optional; The amount of items you want returned. Default: 50
|
||||
:return: Returns a list of :class:`Playlist` or :class:`UserPlaylist` objects
|
||||
"""
|
||||
params = {
|
||||
"folderId": self.id,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"order": "NAME",
|
||||
"includeOnly": "PLAYLIST",
|
||||
}
|
||||
endpoint = "my-collection/playlists/folders"
|
||||
json_obj = self.request.request(
|
||||
"GET",
|
||||
endpoint,
|
||||
base_url=self.session.config.api_v2_location,
|
||||
params=params,
|
||||
).json()
|
||||
# Generate a dict of Playlist items from the response data
|
||||
if json_obj.get("items"):
|
||||
playlists = {"items": [item["data"] for item in json_obj.get("items")]}
|
||||
return cast(
|
||||
List[Union["Playlist", "UserPlaylist"]],
|
||||
self.request.map_json(playlists, parse=self.playlist.parse_factory),
|
||||
)
|
||||
else:
|
||||
return []
|
||||
|
||||
def add_items(self, trns: [str]):
|
||||
"""Convenience method to add items to the current folder.
|
||||
|
||||
:param trns: List of playlist trns to be added to the current folder
|
||||
:return: True, if operation was successful.
|
||||
"""
|
||||
self.move_items_to_folder(trns, self.id)
|
||||
|
||||
def move_items_to_root(self, trns: [str]):
|
||||
"""Convenience method to move items from the current folder to the root folder.
|
||||
|
||||
:param trns: List of playlist trns to be moved from the current folder
|
||||
:return: True, if operation was successful.
|
||||
"""
|
||||
self.move_items_to_folder(trns, folder="root")
|
||||
|
||||
def move_items_to_folder(self, trns: [str], folder: str = None):
|
||||
"""Move item(s) in one folder to another folder.
|
||||
|
||||
:param trns: List of playlist trns to be moved.
|
||||
:param folder: Destination folder. Default: Use the current folder
|
||||
:return: True, if operation was successful.
|
||||
"""
|
||||
trns = list_validate(trns)
|
||||
# Make sure all trns has the correct type prepended to it
|
||||
trns_full = []
|
||||
for trn in trns:
|
||||
if "trn:" in trn:
|
||||
trns_full.append(trn)
|
||||
else:
|
||||
trns_full.append(f"trn:playlist:{trn}")
|
||||
if not folder:
|
||||
folder = self.id
|
||||
params = {"folderId": folder, "trns": ",".join(trns_full)}
|
||||
endpoint = "my-collection/playlists/folders/move"
|
||||
res = self.request.request(
|
||||
"PUT",
|
||||
endpoint,
|
||||
base_url=self.session.config.api_v2_location,
|
||||
params=params,
|
||||
)
|
||||
self._reparse()
|
||||
return res.ok
|
||||
|
||||
|
||||
class UserPlaylist(Playlist):
|
||||
def _reparse(self) -> None:
|
||||
"""Re-Read Playlist to get ETag."""
|
||||
request = self.request.request("GET", self._base_url % self.id)
|
||||
self._etag = request.headers["etag"]
|
||||
self.request.map_json(request.json(), parse=self.parse)
|
||||
|
||||
def edit(
|
||||
self, title: Optional[str] = None, description: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Edit UserPlaylist title & description.
|
||||
|
||||
:param title: Playlist title
|
||||
:param description: Playlist title.
|
||||
:return: True, if successful.
|
||||
"""
|
||||
if not title:
|
||||
title = self.name
|
||||
if not description:
|
||||
description = self.description
|
||||
|
||||
data = {"title": title, "description": description}
|
||||
return self.request.request("POST", self._base_url % self.id, data=data).ok
|
||||
|
||||
def delete_by_id(self, media_ids: List[str]) -> bool:
|
||||
"""Delete one or more items from the UserPlaylist.
|
||||
|
||||
:param media_ids: Lists of Media IDs to remove.
|
||||
:return: True, if successful.
|
||||
"""
|
||||
media_ids = list_validate(media_ids)
|
||||
# Generate list of track indices of tracks found in the list of media_ids.
|
||||
track_ids = [str(track.id) for track in self.tracks()]
|
||||
matching_indices = [i for i, item in enumerate(track_ids) if item in media_ids]
|
||||
return self.remove_by_indices(matching_indices)
|
||||
|
||||
def add(
|
||||
self,
|
||||
media_ids: List[str],
|
||||
allow_duplicates: bool = False,
|
||||
position: int = -1,
|
||||
limit: int = 100,
|
||||
) -> List[int]:
|
||||
"""Add one or more items to the UserPlaylist.
|
||||
|
||||
:param media_ids: List of Media IDs to add.
|
||||
:param allow_duplicates: Allow adding duplicate items
|
||||
:param position: Insert items at a specific position.
|
||||
Default: insert at the end of the playlist
|
||||
:param limit: Maximum number of items to add
|
||||
:return: List of media IDs that has been added
|
||||
"""
|
||||
media_ids = list_validate(media_ids)
|
||||
# Insert items at a specific index
|
||||
if position < 0 or position > self.num_tracks:
|
||||
position = self.num_tracks
|
||||
data = {
|
||||
"onArtifactNotFound": "SKIP",
|
||||
"trackIds": ",".join(map(str, media_ids)),
|
||||
"toIndex": position,
|
||||
"onDupes": "ADD" if allow_duplicates else "SKIP",
|
||||
}
|
||||
params = {"limit": limit}
|
||||
headers = {"If-None-Match": self._etag} if self._etag else None
|
||||
res = self.request.request(
|
||||
"POST",
|
||||
self._base_url % self.id + "/items",
|
||||
params=params,
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
self._reparse()
|
||||
# Respond with the added item IDs:
|
||||
added_items = res.json().get("addedItemIds")
|
||||
if added_items:
|
||||
return added_items
|
||||
else:
|
||||
return []
|
||||
|
||||
def merge(
|
||||
self, playlist: str, allow_duplicates: bool = False, allow_missing: bool = True
|
||||
) -> List[int]:
|
||||
"""Add (merge) items from a playlist with the current playlist.
|
||||
|
||||
:param playlist: Playlist UUID to be merged in the current playlist
|
||||
:param allow_duplicates: If true, duplicate tracks are allowed. Otherwise,
|
||||
tracks will be skipped.
|
||||
:param allow_missing: If true, missing tracks are allowed. Otherwise, exception
|
||||
will be thrown
|
||||
:return: List of items that has been added from the playlist
|
||||
"""
|
||||
data = {
|
||||
"fromPlaylistUuid": str(playlist),
|
||||
"onArtifactNotFound": "SKIP" if allow_missing else "FAIL",
|
||||
"onDupes": "ADD" if allow_duplicates else "SKIP",
|
||||
}
|
||||
headers = {"If-None-Match": self._etag} if self._etag else None
|
||||
res = self.request.request(
|
||||
"POST",
|
||||
self._base_url % self.id + "/items",
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
self._reparse()
|
||||
# Respond with the added item IDs:
|
||||
added_items = res.json().get("addedItemIds")
|
||||
if added_items:
|
||||
return added_items
|
||||
else:
|
||||
return []
|
||||
|
||||
def add_by_isrc(
|
||||
self,
|
||||
isrc: str,
|
||||
allow_duplicates: bool = False,
|
||||
position: int = -1,
|
||||
) -> bool:
|
||||
"""Add an item to a playlist, using the track ISRC.
|
||||
|
||||
:param isrc: The ISRC of the track to be added
|
||||
:param allow_duplicates: Allow adding duplicate items
|
||||
:param position: Insert items at a specific position.
|
||||
Default: insert at the end of the playlist
|
||||
:return: True, if successful.
|
||||
"""
|
||||
if not isinstance(isrc, str):
|
||||
isrc = str(isrc)
|
||||
try:
|
||||
track = self.session.get_tracks_by_isrc(isrc)
|
||||
if track:
|
||||
# Add the first track in the list
|
||||
track_id = track[0].id
|
||||
added = self.add(
|
||||
[str(track_id)],
|
||||
allow_duplicates=allow_duplicates,
|
||||
position=position,
|
||||
)
|
||||
if track_id in added:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
except ObjectNotFound:
|
||||
return False
|
||||
|
||||
def move_by_id(self, media_id: str, position: int) -> bool:
|
||||
"""Move an item to a new position, by media ID.
|
||||
|
||||
:param media_id: The index of the item to be moved
|
||||
:param position: The new position of the item
|
||||
:return: True, if successful.
|
||||
"""
|
||||
if not isinstance(media_id, str):
|
||||
media_id = str(media_id)
|
||||
track_ids = [str(track.id) for track in self.tracks()]
|
||||
try:
|
||||
index = track_ids.index(media_id)
|
||||
if index is not None and index < self.num_tracks:
|
||||
return self.move_by_indices([index], position)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def move_by_index(self, index: int, position: int) -> bool:
|
||||
"""Move a single item to a new position.
|
||||
|
||||
:param index: The index of the item to be moved
|
||||
:param position: The new position/offset of the item
|
||||
:return: True, if successful.
|
||||
"""
|
||||
if not isinstance(index, int):
|
||||
raise ValueError
|
||||
return self.move_by_indices([index], position)
|
||||
|
||||
def move_by_indices(self, indices: Sequence[int], position: int) -> bool:
|
||||
"""Move one or more items to a new position.
|
||||
|
||||
:param indices: List containing indices to move.
|
||||
:param position: The new position/offset of the item(s)
|
||||
:return: True, if successful.
|
||||
"""
|
||||
# Move item to a new position
|
||||
if position < 0 or position >= self.num_tracks:
|
||||
position = self.num_tracks
|
||||
data = {
|
||||
"toIndex": position,
|
||||
}
|
||||
headers = {"If-None-Match": self._etag} if self._etag else None
|
||||
track_index_string = ",".join([str(x) for x in indices])
|
||||
res = self.request.request(
|
||||
"POST",
|
||||
(self._base_url + "/items/%s") % (self.id, track_index_string),
|
||||
data=data,
|
||||
headers=headers,
|
||||
)
|
||||
self._reparse()
|
||||
return res.ok
|
||||
|
||||
def remove_by_id(self, media_id: str) -> bool:
|
||||
"""Remove a single item from the playlist, using the media ID.
|
||||
|
||||
:param media_id: Media ID to remove.
|
||||
:return: True, if successful.
|
||||
"""
|
||||
if not isinstance(media_id, str):
|
||||
media_id = str(media_id)
|
||||
track_ids = [str(track.id) for track in self.tracks()]
|
||||
try:
|
||||
index = track_ids.index(media_id)
|
||||
if index is not None and index < self.num_tracks:
|
||||
return self.remove_by_index(index)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def remove_by_index(self, index: int) -> bool:
|
||||
"""Remove a single item from the UserPlaylist, using item index.
|
||||
|
||||
:param index: Media index to remove
|
||||
:return: True, if successful.
|
||||
"""
|
||||
return self.remove_by_indices([index])
|
||||
|
||||
def remove_by_indices(self, indices: Sequence[int]) -> bool:
|
||||
"""Remove one or more items from the UserPlaylist, using list of indices.
|
||||
|
||||
:param indices: List containing indices to remove.
|
||||
:return: True, if successful.
|
||||
"""
|
||||
headers = {"If-None-Match": self._etag} if self._etag else None
|
||||
track_index_string = ",".join([str(x) for x in indices])
|
||||
res = self.request.request(
|
||||
"DELETE",
|
||||
(self._base_url + "/items/%s") % (self.id, track_index_string),
|
||||
headers=headers,
|
||||
)
|
||||
self._reparse()
|
||||
return res.ok
|
||||
|
||||
def clear(self, chunk_size: int = 50) -> bool:
|
||||
"""Clear UserPlaylist.
|
||||
|
||||
:param chunk_size: Number of items to remove per request
|
||||
:return: True, if successful.
|
||||
"""
|
||||
while self.num_tracks:
|
||||
indices = range(min(self.num_tracks, chunk_size))
|
||||
if not self.remove_by_indices(indices):
|
||||
return False
|
||||
return True
|
||||
|
||||
def set_playlist_public(self):
|
||||
"""Set UserPlaylist as Public.
|
||||
|
||||
:return: True, if successful.
|
||||
"""
|
||||
res = self.request.request(
|
||||
"PUT",
|
||||
base_url=self.session.config.api_v2_location,
|
||||
path=(self._base_url + "/set-public") % self.id,
|
||||
)
|
||||
self.public = True
|
||||
self._reparse()
|
||||
return res.ok
|
||||
|
||||
def set_playlist_private(self):
|
||||
"""Set UserPlaylist as Private.
|
||||
|
||||
:return: True, if successful.
|
||||
"""
|
||||
res = self.request.request(
|
||||
"PUT",
|
||||
base_url=self.session.config.api_v2_location,
|
||||
path=(self._base_url + "/set-private") % self.id,
|
||||
)
|
||||
self.public = False
|
||||
self._reparse()
|
||||
return res.ok
|
||||
|
||||
def delete(self) -> bool:
|
||||
"""Delete UserPlaylist.
|
||||
|
||||
:return: True, if successful.
|
||||
"""
|
||||
return self.request.request("DELETE", path="playlists/%s" % self.id).ok
|
275
env/lib/python3.11/site-packages/tidalapi/request.py
vendored
Normal file
275
env/lib/python3.11/site-packages/tidalapi/request.py
vendored
Normal file
@ -0,0 +1,275 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright (C) 2023- The Tidalapi Developers
|
||||
# Copyright (C) 2019-2022 morguldir
|
||||
# Copyright (C) 2014 Thomas Amland
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""A module containing functions relating to TIDAL api requests."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
MutableMapping,
|
||||
Optional,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
from tidalapi.exceptions import ObjectNotFound, TooManyRequests
|
||||
from tidalapi.types import JsonObj
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
Params = Mapping[str, Union[str, int, None]]
|
||||
|
||||
Methods = Literal["GET", "POST", "PUT", "DELETE"]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidalapi.session import Session
|
||||
|
||||
|
||||
class Requests(object):
|
||||
"""A class for handling api requests to TIDAL."""
|
||||
|
||||
user_agent: str
|
||||
# Latest error response that can be returned and parsed after request has been completed
|
||||
latest_err_response: requests.Response
|
||||
|
||||
def __init__(self, session: "Session"):
|
||||
# More Android User-Agents here: https://user-agents.net/browsers/android
|
||||
self.user_agent = "Mozilla/5.0 (Linux; Android 12; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36"
|
||||
self.session = session
|
||||
self.config = session.config
|
||||
self.latest_err_response = requests.Response()
|
||||
|
||||
def basic_request(
|
||||
self,
|
||||
method: Methods,
|
||||
path: str,
|
||||
params: Optional[Params] = None,
|
||||
data: Optional[JsonObj] = None,
|
||||
headers: Optional[MutableMapping[str, str]] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> requests.Response:
|
||||
request_params = {
|
||||
"sessionId": self.session.session_id,
|
||||
"countryCode": self.session.country_code,
|
||||
"limit": self.config.item_limit,
|
||||
}
|
||||
|
||||
if params:
|
||||
# Don't update items with a none value, as we prefer a default value.
|
||||
# requests also does not support them.
|
||||
not_none = filter(lambda item: item[1] is not None, params.items())
|
||||
request_params.update(not_none)
|
||||
|
||||
if not headers:
|
||||
headers = {}
|
||||
|
||||
if "User-Agent" not in headers:
|
||||
headers["User-Agent"] = self.user_agent
|
||||
|
||||
if self.session.token_type and self.session.access_token is not None:
|
||||
headers["authorization"] = (
|
||||
self.session.token_type + " " + self.session.access_token
|
||||
)
|
||||
if base_url is None:
|
||||
base_url = self.session.config.api_v1_location
|
||||
|
||||
url = urljoin(base_url, path)
|
||||
request = self.session.request_session.request(
|
||||
method, url, params=request_params, data=data, headers=headers
|
||||
)
|
||||
|
||||
refresh_token = self.session.refresh_token
|
||||
if not request.ok and refresh_token:
|
||||
json_resp = None
|
||||
try:
|
||||
json_resp = request.json()
|
||||
except json.decoder.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if json_resp and json_resp.get("userMessage", "").startswith(
|
||||
"The token has expired."
|
||||
):
|
||||
log.debug("The access token has expired, trying to refresh it.")
|
||||
refreshed = self.session.token_refresh(refresh_token)
|
||||
if refreshed:
|
||||
request = self.basic_request(method, url, params, data, headers)
|
||||
else:
|
||||
log.debug("HTTP error on %d", request.status_code)
|
||||
log.debug("Response text\n%s", request.text)
|
||||
|
||||
return request
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: Methods,
|
||||
path: str,
|
||||
params: Optional[Params] = None,
|
||||
data: Optional[JsonObj] = None,
|
||||
headers: Optional[MutableMapping[str, str]] = None,
|
||||
base_url: Optional[str] = None,
|
||||
) -> requests.Response:
|
||||
"""Method for tidal requests.
|
||||
|
||||
Not meant for use outside of this library.
|
||||
|
||||
:param method: The type of request to make
|
||||
:param path: The TIDAL api endpoint you want to use.
|
||||
:param params: The parameters you want to supply with the request.
|
||||
:param data: The data you want to supply with the request.
|
||||
:param headers: The headers you want to include with the request
|
||||
:param base_url: The base url to use for the request
|
||||
:return: The json data at specified api endpoint.
|
||||
"""
|
||||
|
||||
request = self.basic_request(method, path, params, data, headers, base_url)
|
||||
log.debug("request: %s", request.request.url)
|
||||
try:
|
||||
request.raise_for_status()
|
||||
except Exception as e:
|
||||
log.info("Request resulted in exception {}".format(e))
|
||||
self.latest_err_response = request
|
||||
if request.content:
|
||||
resp = request.json()
|
||||
# Make sure request response contains the detailed error message
|
||||
if "errors" in resp:
|
||||
log.debug("Request response: '%s'", resp["errors"][0]["detail"])
|
||||
elif "userMessage" in resp:
|
||||
log.debug("Request response: '%s'", resp["userMessage"])
|
||||
else:
|
||||
log.debug("Request response: '%s'", json.dumps(resp))
|
||||
|
||||
if request.status_code and request.status_code == 404:
|
||||
raise ObjectNotFound
|
||||
elif request.status_code and request.status_code == 429:
|
||||
raise TooManyRequests
|
||||
else:
|
||||
# raise last error, usually HTTPError
|
||||
raise
|
||||
return request
|
||||
|
||||
def get_latest_err_response(self) -> dict:
|
||||
"""Get the latest request Response that resulted in an Exception :return: The
|
||||
request Response that resulted in the Exception, returned as a dict An empty
|
||||
dict will be returned, if no response was returned."""
|
||||
if self.latest_err_response.content:
|
||||
return self.latest_err_response.json()
|
||||
else:
|
||||
return {}
|
||||
|
||||
def get_latest_err_response_str(self) -> str:
|
||||
"""Get the latest request response message as a string :return: The contents of
|
||||
the (detailed) error response Response, returned as a string An empty str will
|
||||
be returned, if no response was returned."""
|
||||
if self.latest_err_response.content:
|
||||
resp = self.latest_err_response.json()
|
||||
return resp["errors"][0]["detail"]
|
||||
else:
|
||||
return ""
|
||||
|
||||
def map_request(
|
||||
self,
|
||||
url: str,
|
||||
params: Optional[Params] = None,
|
||||
parse: Optional[Callable[..., Any]] = None,
|
||||
) -> Any:
|
||||
"""Returns the data about object(s) at the specified url, with the method
|
||||
specified in the parse argument.
|
||||
|
||||
Not meant for use outside of this library
|
||||
|
||||
:param url: TIDAL api endpoint that contains the data
|
||||
:param params: TIDAL parameters to use when getting the data
|
||||
:param parse: (Optional) The method used to parse the data at the url. If not
|
||||
set, jsonObj will be returned
|
||||
:return: The object(s) at the url, with the same type as the class of the parse
|
||||
method.
|
||||
"""
|
||||
json_obj = self.request("GET", url, params).json()
|
||||
if parse:
|
||||
return self.map_json(json_obj, parse=parse)
|
||||
else:
|
||||
return json_obj
|
||||
|
||||
@classmethod
|
||||
def map_json(
|
||||
cls,
|
||||
json_obj: JsonObj,
|
||||
parse: Optional[Callable[..., Any]] = None,
|
||||
session: Optional["Session"] = None,
|
||||
) -> Any:
|
||||
items = json_obj.get("items")
|
||||
|
||||
if items is None:
|
||||
# Not a collection of items, so returning a single object
|
||||
if parse is None:
|
||||
raise ValueError("A parser must be supplied")
|
||||
return parse(json_obj)
|
||||
|
||||
if len(items) > 0 and "item" in items[0]:
|
||||
# Move created date into the item json data like it is done for playlists tracks.
|
||||
if "created" in items[0]:
|
||||
for item in items:
|
||||
item["item"]["dateAdded"] = item["created"]
|
||||
|
||||
lists: List[Any] = []
|
||||
for item in items:
|
||||
if session is not None:
|
||||
parse = cast(
|
||||
Callable[..., Any],
|
||||
session.convert_type(
|
||||
cast(str, item["type"]).lower() + "s", output="parse"
|
||||
),
|
||||
)
|
||||
if parse is None:
|
||||
raise ValueError("A parser must be supplied")
|
||||
lists.append(parse(item["item"]))
|
||||
|
||||
return lists
|
||||
if parse is None:
|
||||
raise ValueError("A parser must be supplied")
|
||||
return list(map(parse, items))
|
||||
|
||||
def get_items(self, url: str, parse: Callable[..., Any]) -> List[Any]:
|
||||
"""Returns a list of items, used when there are over a 100 items, but TIDAL
|
||||
doesn't always allow more specifying a higher limit.
|
||||
|
||||
Not meant for use outside of this library.
|
||||
|
||||
:param url: TIDAL api endpoint where you get the objects.
|
||||
:param parse: The method that parses the data in the url
|
||||
item_List: List[Any] = []
|
||||
"""
|
||||
|
||||
params = {"offset": 0, "limit": 100}
|
||||
remaining = 100
|
||||
item_list: List[Any] = []
|
||||
while remaining == 100:
|
||||
items = self.map_request(url, params=params, parse=parse)
|
||||
remaining = len(items)
|
||||
params["offset"] += 100
|
||||
item_list.extend(items or [])
|
||||
return item_list
|
1154
env/lib/python3.11/site-packages/tidalapi/session.py
vendored
Normal file
1154
env/lib/python3.11/site-packages/tidalapi/session.py
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
env/lib/python3.11/site-packages/tidalapi/types.py
vendored
Normal file
7
env/lib/python3.11/site-packages/tidalapi/types.py
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2023- The Tidalapi Developers
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
JsonObj = Dict[str, Any]
|
554
env/lib/python3.11/site-packages/tidalapi/user.py
vendored
Normal file
554
env/lib/python3.11/site-packages/tidalapi/user.py
vendored
Normal file
@ -0,0 +1,554 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright (C) 2023- The Tidalapi Developers
|
||||
# Copyright (C) 2019-2022 morguldir
|
||||
# Copyright (C) 2014 Thomas Amland
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""A module containing classes and functions related to tidal users.
|
||||
|
||||
:class:`User` is a class with user information.
|
||||
:class:`Favorites` is class with a users favorites.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import copy
|
||||
from typing import TYPE_CHECKING, List, Optional, Union, cast
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from tidalapi.exceptions import ObjectNotFound
|
||||
from tidalapi.types import JsonObj
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tidalapi.album import Album
|
||||
from tidalapi.artist import Artist
|
||||
from tidalapi.media import Track, Video
|
||||
from tidalapi.mix import MixV2
|
||||
from tidalapi.playlist import Folder, Playlist, UserPlaylist
|
||||
from tidalapi.session import Session
|
||||
|
||||
|
||||
def list_validate(lst):
|
||||
if isinstance(lst, str):
|
||||
lst = [lst]
|
||||
if len(lst) == 0:
|
||||
raise ValueError("An empty list was provided.")
|
||||
return lst
|
||||
|
||||
|
||||
class User:
|
||||
"""A class containing various information about a TIDAL user.
|
||||
|
||||
The attributes of this class are pretty varied. ID is the only attribute you can
|
||||
rely on being set. If you initialized a specific user, you will get id, first_name,
|
||||
last_name, and picture_id. If parsed as a playlist creator, you will get an ID and a
|
||||
name, if the creator isn't an artist, name will be 'user'. If the parsed user is the
|
||||
one logged in, for example in session.user, you will get the remaining attributes,
|
||||
and id.
|
||||
"""
|
||||
|
||||
id: Optional[int] = -1
|
||||
|
||||
def __init__(self, session: "Session", user_id: Optional[int]):
|
||||
self.id = user_id
|
||||
self.session = session
|
||||
self.request = session.request
|
||||
self.playlist = session.playlist()
|
||||
self.folder = session.folder()
|
||||
|
||||
def factory(self) -> Union["LoggedInUser", "FetchedUser", "PlaylistCreator"]:
|
||||
return cast(
|
||||
Union["LoggedInUser", "FetchedUser", "PlaylistCreator"],
|
||||
self.request.map_request("users/%s" % self.id, parse=self.parse),
|
||||
)
|
||||
|
||||
def parse(
|
||||
self, json_obj: JsonObj
|
||||
) -> Union["LoggedInUser", "FetchedUser", "PlaylistCreator"]:
|
||||
if "username" in json_obj:
|
||||
user: Union[LoggedInUser, FetchedUser, PlaylistCreator] = LoggedInUser(
|
||||
self.session, json_obj["id"]
|
||||
)
|
||||
|
||||
elif "firstName" in json_obj:
|
||||
user = FetchedUser(self.session, json_obj["id"])
|
||||
|
||||
elif json_obj:
|
||||
user = PlaylistCreator(self.session, json_obj["id"])
|
||||
|
||||
# When searching TIDAL does not show up as a creator in the json data.
|
||||
else:
|
||||
user = PlaylistCreator(self.session, 0)
|
||||
|
||||
return user.parse(json_obj)
|
||||
|
||||
|
||||
class FetchedUser(User):
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
picture_id: Optional[str] = None
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "FetchedUser":
|
||||
self.id = json_obj["id"]
|
||||
self.first_name = json_obj["firstName"]
|
||||
self.last_name = json_obj["lastName"]
|
||||
self.picture_id = json_obj.get("picture", None)
|
||||
|
||||
return copy(self)
|
||||
|
||||
def image(self, dimensions: int) -> str:
|
||||
if dimensions not in [100, 210, 600]:
|
||||
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))
|
||||
|
||||
if self.picture_id is None:
|
||||
raise AttributeError("No picture available")
|
||||
|
||||
return self.session.config.image_url % (
|
||||
self.picture_id.replace("-", "/"),
|
||||
dimensions,
|
||||
dimensions,
|
||||
)
|
||||
|
||||
|
||||
class LoggedInUser(FetchedUser):
|
||||
username: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
profile_metadata: Optional[JsonObj] = None
|
||||
|
||||
def __init__(self, session: "Session", user_id: Optional[int]):
|
||||
super(LoggedInUser, self).__init__(session, user_id)
|
||||
assert self.id is not None, "User is not logged in"
|
||||
self.favorites = Favorites(session, self.id)
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "LoggedInUser":
|
||||
super(LoggedInUser, self).parse(json_obj)
|
||||
self.username = json_obj["username"]
|
||||
self.email = json_obj["email"]
|
||||
self.profile_metadata = json_obj
|
||||
|
||||
return copy(self)
|
||||
|
||||
def playlists(self) -> List[Union["Playlist", "UserPlaylist"]]:
|
||||
"""Get the (personal) playlists created by the user.
|
||||
|
||||
:return: Returns a list of :class:`~tidalapi.playlist.Playlist` objects containing the playlists.
|
||||
"""
|
||||
return cast(
|
||||
List[Union["Playlist", "UserPlaylist"]],
|
||||
self.request.map_request(
|
||||
"users/%s/playlists" % self.id, parse=self.playlist.parse_factory
|
||||
),
|
||||
)
|
||||
|
||||
def playlist_folders(
|
||||
self, offset: int = 0, limit: int = 50, parent_folder_id: str = "root"
|
||||
) -> List["Folder"]:
|
||||
"""Get a list of folders created by the user.
|
||||
|
||||
:param offset: The amount of items you want returned.
|
||||
:param limit: The index of the first item you want included.
|
||||
:param parent_folder_id: Parent folder ID. Default: 'root' playlist folder
|
||||
:return: Returns a list of :class:`~tidalapi.playlist.Folder` objects containing the Folders.
|
||||
"""
|
||||
params = {
|
||||
"folderId": parent_folder_id,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"order": "NAME",
|
||||
"includeOnly": "FOLDER",
|
||||
}
|
||||
endpoint = "my-collection/playlists/folders"
|
||||
return cast(
|
||||
List["Folder"],
|
||||
self.session.request.map_request(
|
||||
url=urljoin(
|
||||
self.session.config.api_v2_location,
|
||||
endpoint,
|
||||
),
|
||||
params=params,
|
||||
parse=self.session.parse_folder,
|
||||
),
|
||||
)
|
||||
|
||||
def public_playlists(
|
||||
self, offset: int = 0, limit: int = 50
|
||||
) -> List[Union["Playlist", "UserPlaylist"]]:
|
||||
"""Get the (public) playlists created by the user.
|
||||
|
||||
:param limit: The index of the first item you want included.
|
||||
:param offset: The amount of items you want returned.
|
||||
:return: List of public playlists.
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
endpoint = "user-playlists/%s/public" % self.id
|
||||
json_obj = self.request.request(
|
||||
"GET", endpoint, base_url=self.session.config.api_v2_location, params=params
|
||||
).json()
|
||||
|
||||
# The response contains both playlists and user details (followInfo, profile) but we will discard the latter.
|
||||
playlists = {"items": []}
|
||||
for index, item in enumerate(json_obj["items"]):
|
||||
if item["playlist"]:
|
||||
playlists["items"].append(item["playlist"])
|
||||
|
||||
return cast(
|
||||
List[Union["Playlist", "UserPlaylist"]],
|
||||
self.request.map_json(playlists, parse=self.playlist.parse_factory),
|
||||
)
|
||||
|
||||
def playlist_and_favorite_playlists(
|
||||
self, offset: int = 0, limit: int = 50
|
||||
) -> List[Union["Playlist", "UserPlaylist"]]:
|
||||
"""Get the playlists created by the user, and the playlists favorited by the
|
||||
user. This function is limited to 50 by TIDAL, requiring pagination.
|
||||
|
||||
:return: Returns a list of :class:`~tidalapi.playlist.Playlist` objects containing the playlists.
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
endpoint = "users/%s/playlistsAndFavoritePlaylists" % self.id
|
||||
json_obj = self.request.request("GET", endpoint, params=params).json()
|
||||
|
||||
# This endpoint sorts them into favorited and created playlists, but we already do that when parsing them.
|
||||
for index, item in enumerate(json_obj["items"]):
|
||||
item["playlist"]["dateAdded"] = item["created"]
|
||||
json_obj["items"][index] = item["playlist"]
|
||||
|
||||
return cast(
|
||||
List[Union["Playlist", "UserPlaylist"]],
|
||||
self.request.map_json(json_obj, parse=self.playlist.parse_factory),
|
||||
)
|
||||
|
||||
def create_playlist(
|
||||
self, title: str, description: str, parent_id: str = "root"
|
||||
) -> "UserPlaylist":
|
||||
"""Create a playlist in the specified parent folder.
|
||||
|
||||
:param title: Playlist title
|
||||
:param description: Playlist description
|
||||
:param parent_id: Parent folder ID. Default: 'root' playlist folder
|
||||
:return: Returns an object of :class:`~tidalapi.playlist.UserPlaylist` containing the newly created playlist
|
||||
"""
|
||||
params = {"name": title, "description": description, "folderId": parent_id}
|
||||
endpoint = "my-collection/playlists/folders/create-playlist"
|
||||
|
||||
json_obj = self.request.request(
|
||||
method="PUT",
|
||||
path=endpoint,
|
||||
base_url=self.session.config.api_v2_location,
|
||||
params=params,
|
||||
).json()
|
||||
json = json_obj.get("data")
|
||||
if json and json.get("uuid"):
|
||||
playlist = self.session.playlist().parse(json)
|
||||
return playlist.factory()
|
||||
else:
|
||||
raise ObjectNotFound("Playlist not found after creation")
|
||||
|
||||
def create_folder(self, title: str, parent_id: str = "root") -> "Folder":
|
||||
"""Create folder in the specified parent folder.
|
||||
|
||||
:param title: Folder title
|
||||
:param parent_id: Folder parent ID. Default: 'root' playlist folder
|
||||
:return: Returns an object of :class:`~tidalapi.playlist.Folder` containing the newly created object
|
||||
"""
|
||||
params = {"name": title, "folderId": parent_id}
|
||||
endpoint = "my-collection/playlists/folders/create-folder"
|
||||
|
||||
json_obj = self.request.request(
|
||||
method="PUT",
|
||||
path=endpoint,
|
||||
base_url=self.session.config.api_v2_location,
|
||||
params=params,
|
||||
).json()
|
||||
if json_obj and json_obj.get("data"):
|
||||
return self.request.map_json(json_obj, parse=self.folder.parse)
|
||||
else:
|
||||
raise ObjectNotFound("Folder not found after creation")
|
||||
|
||||
|
||||
class PlaylistCreator(User):
|
||||
name: Optional[str] = None
|
||||
|
||||
def parse(self, json_obj: JsonObj) -> "PlaylistCreator":
|
||||
if self.id == 0 or self.session.user is None:
|
||||
self.name = "TIDAL"
|
||||
|
||||
elif "name" in json_obj:
|
||||
self.name = json_obj["name"]
|
||||
|
||||
elif self.id == self.session.user.id:
|
||||
self.name = "me"
|
||||
|
||||
else:
|
||||
self.name = "user"
|
||||
|
||||
return copy(self)
|
||||
|
||||
|
||||
class Favorites:
|
||||
"""An object containing a users favourites."""
|
||||
|
||||
def __init__(self, session: "Session", user_id: int):
|
||||
self.session = session
|
||||
self.requests = session.request
|
||||
self.base_url = f"users/{user_id}/favorites"
|
||||
self.v2_base_url = "favorites"
|
||||
|
||||
def add_album(self, album_id: str) -> bool:
|
||||
"""Adds an album to the users favorites.
|
||||
|
||||
:param album_id: TIDAL's identifier of the album.
|
||||
:return: A boolean indicating whether the request was successful or not.
|
||||
"""
|
||||
return self.requests.request(
|
||||
"POST", f"{self.base_url}/albums", data={"albumId": album_id}
|
||||
).ok
|
||||
|
||||
def add_artist(self, artist_id: str) -> bool:
|
||||
"""Adds an artist to the users favorites.
|
||||
|
||||
:param artist_id: TIDAL's identifier of the artist
|
||||
:return: A boolean indicating whether the request was successful or not.
|
||||
"""
|
||||
return self.requests.request(
|
||||
"POST", f"{self.base_url}/artists", data={"artistId": artist_id}
|
||||
).ok
|
||||
|
||||
def add_playlist(self, playlist_id: str) -> bool:
|
||||
"""Adds a playlist to the users favorites.
|
||||
|
||||
:param playlist_id: TIDAL's identifier of the playlist.
|
||||
:return: A boolean indicating whether the request was successful or not.
|
||||
"""
|
||||
return self.requests.request(
|
||||
"POST", f"{self.base_url}/playlists", data={"uuids": playlist_id}
|
||||
).ok
|
||||
|
||||
def add_track(self, track_id: str) -> bool:
|
||||
"""Adds a track to the users favorites.
|
||||
|
||||
:param track_id: TIDAL's identifier of the track.
|
||||
:return: A boolean indicating whether the request was successful or not.
|
||||
"""
|
||||
return self.requests.request(
|
||||
"POST", f"{self.base_url}/tracks", data={"trackId": track_id}
|
||||
).ok
|
||||
|
||||
def add_track_by_isrc(self, isrc: str) -> bool:
|
||||
"""Adds a track to the users favorites, using isrc.
|
||||
|
||||
:param isrc: The ISRC of the track to be added
|
||||
:return: True, if successful.
|
||||
"""
|
||||
try:
|
||||
track = self.session.get_tracks_by_isrc(isrc)
|
||||
if track:
|
||||
# Add the first track in the list
|
||||
track_id = str(track[0].id)
|
||||
return self.requests.request(
|
||||
"POST", f"{self.base_url}/tracks", data={"trackId": track_id}
|
||||
).ok
|
||||
else:
|
||||
return False
|
||||
except ObjectNotFound:
|
||||
return False
|
||||
|
||||
def add_video(self, video_id: str) -> bool:
|
||||
"""Adds a video to the users favorites.
|
||||
|
||||
:param video_id: TIDAL's identifier of the video.
|
||||
:return: A boolean indicating whether the request was successful or not.
|
||||
"""
|
||||
params = {"limit": "100"}
|
||||
return self.requests.request(
|
||||
"POST",
|
||||
f"{self.base_url}/videos",
|
||||
data={"videoIds": video_id},
|
||||
params=params,
|
||||
).ok
|
||||
|
||||
def remove_artist(self, artist_id: str) -> bool:
|
||||
"""Removes a track from the users favorites.
|
||||
|
||||
:param artist_id: TIDAL's identifier of the artist.
|
||||
:return: A boolean indicating whether the request was successful or not.
|
||||
"""
|
||||
return self.requests.request(
|
||||
"DELETE", f"{self.base_url}/artists/{artist_id}"
|
||||
).ok
|
||||
|
||||
def remove_album(self, album_id: str) -> bool:
|
||||
"""Removes an album from the users favorites.
|
||||
|
||||
:param album_id: TIDAL's identifier of the album
|
||||
:return: A boolean indicating whether the request was successful or not.
|
||||
"""
|
||||
return self.requests.request("DELETE", f"{self.base_url}/albums/{album_id}").ok
|
||||
|
||||
def remove_playlist(self, playlist_id: str) -> bool:
|
||||
"""Removes a playlist from the users favorites.
|
||||
|
||||
:param playlist_id: TIDAL's identifier of the playlist.
|
||||
:return: A boolean indicating whether the request was successful or not.
|
||||
"""
|
||||
return self.requests.request(
|
||||
"DELETE", f"{self.base_url}/playlists/{playlist_id}"
|
||||
).ok
|
||||
|
||||
def remove_track(self, track_id: str) -> bool:
|
||||
"""Removes a track from the users favorites.
|
||||
|
||||
:param track_id: TIDAL's identifier of the track.
|
||||
:return: A boolean indicating whether the request was successful or not.
|
||||
"""
|
||||
return self.requests.request("DELETE", f"{self.base_url}/tracks/{track_id}").ok
|
||||
|
||||
def remove_video(self, video_id: str) -> bool:
|
||||
"""Removes a video from the users favorites.
|
||||
|
||||
:param video_id: TIDAL's identifier of the video.
|
||||
:return: A boolean indicating whether the request was successful or not.
|
||||
"""
|
||||
return self.requests.request("DELETE", f"{self.base_url}/videos/{video_id}").ok
|
||||
|
||||
def remove_folders_playlists(self, trns: [str], type: str = "folder") -> bool:
|
||||
"""Removes one or more folders or playlists from the users favourites, using the
|
||||
v2 endpoint.
|
||||
|
||||
:param trns: List of folder (or playlist) trns to be deleted
|
||||
:param type: Type of trn: as string, either `folder` or `playlist`. Default `folder`
|
||||
:return: A boolean indicating whether theś request was successful or not.
|
||||
"""
|
||||
if type not in ("playlist", "folder"):
|
||||
raise ValueError("Invalid trn value used for playlist/folder endpoint")
|
||||
trns = list_validate(trns)
|
||||
# Make sure all trns has the correct type prepended to it
|
||||
trns_full = []
|
||||
for trn in trns:
|
||||
if "trn:" in trn:
|
||||
trns_full.append(trn)
|
||||
else:
|
||||
trns_full.append(f"trn:{type}:{trn}")
|
||||
params = {"trns": ",".join(trns_full)}
|
||||
endpoint = "my-collection/playlists/folders/remove"
|
||||
return self.requests.request(
|
||||
method="PUT",
|
||||
path=endpoint,
|
||||
base_url=self.session.config.api_v2_location,
|
||||
params=params,
|
||||
).ok
|
||||
|
||||
def artists(self, limit: Optional[int] = None, offset: int = 0) -> List["Artist"]:
|
||||
"""Get the users favorite artists.
|
||||
|
||||
:return: A :class:`list` of :class:`~tidalapi.artist.Artist` objects containing the favorite artists.
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
return cast(
|
||||
List["Artist"],
|
||||
self.requests.map_request(
|
||||
f"{self.base_url}/artists",
|
||||
params=params,
|
||||
parse=self.session.parse_artist,
|
||||
),
|
||||
)
|
||||
|
||||
def albums(self, limit: Optional[int] = None, offset: int = 0) -> List["Album"]:
|
||||
"""Get the users favorite albums.
|
||||
|
||||
:return: A :class:`list` of :class:`~tidalapi.album.Album` objects containing the favorite albums.
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
return cast(
|
||||
List["Album"],
|
||||
self.requests.map_request(
|
||||
f"{self.base_url}/albums", params=params, parse=self.session.parse_album
|
||||
),
|
||||
)
|
||||
|
||||
def playlists(
|
||||
self, limit: Optional[int] = None, offset: int = 0
|
||||
) -> List["Playlist"]:
|
||||
"""Get the users favorite playlists.
|
||||
|
||||
:return: A :class:`list` :class:`~tidalapi.playlist.Playlist` objects containing the favorite playlists.
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
return cast(
|
||||
List["Playlist"],
|
||||
self.requests.map_request(
|
||||
f"{self.base_url}/playlists",
|
||||
params=params,
|
||||
parse=self.session.parse_playlist,
|
||||
),
|
||||
)
|
||||
|
||||
def tracks(
|
||||
self,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0,
|
||||
order: str = "NAME",
|
||||
order_direction: str = "ASC",
|
||||
) -> List["Track"]:
|
||||
"""Get the users favorite tracks.
|
||||
|
||||
:param limit: Optional; The amount of items you want returned.
|
||||
:param offset: The index of the first item you want included.
|
||||
:param order: A :class:`str` describing the ordering type when returning the user favorite tracks. eg.: "NAME, "DATE"
|
||||
:param order_direction: A :class:`str` describing the ordering direction when sorting by `order`. eg.: "ASC", "DESC"
|
||||
:return: A :class:`list` of :class:`~tidalapi.media.Track` objects containing all of the favorite tracks.
|
||||
"""
|
||||
params = {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"order": order,
|
||||
"orderDirection": order_direction,
|
||||
}
|
||||
|
||||
return cast(
|
||||
List["Track"],
|
||||
self.requests.map_request(
|
||||
f"{self.base_url}/tracks", params=params, parse=self.session.parse_track
|
||||
),
|
||||
)
|
||||
|
||||
def videos(self) -> List["Video"]:
|
||||
"""Get the users favorite videos.
|
||||
|
||||
:return: A :class:`list` of :class:`~tidalapi.media.Video` objects containing all the favorite videos
|
||||
"""
|
||||
return cast(
|
||||
List["Video"],
|
||||
self.requests.get_items(
|
||||
f"{self.base_url}/videos", parse=self.session.parse_media
|
||||
),
|
||||
)
|
||||
|
||||
def mixes(self, limit: Optional[int] = 50, offset: int = 0) -> List["MixV2"]:
|
||||
"""Get the users favorite mixes & radio.
|
||||
|
||||
:return: A :class:`list` of :class:`~tidalapi.media.Mix` objects containing the user favourite mixes & radio
|
||||
"""
|
||||
params = {"limit": limit, "offset": offset}
|
||||
return cast(
|
||||
List["MixV2"],
|
||||
self.requests.map_request(
|
||||
url=urljoin(
|
||||
self.session.config.api_v2_location, f"{self.v2_base_url}/mixes"
|
||||
),
|
||||
params=params,
|
||||
parse=self.session.parse_v2_mix,
|
||||
),
|
||||
)
|
Reference in New Issue
Block a user