Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/v0.7.5 prep #242

Merged
merged 15 commits into from
Feb 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 0 additions & 17 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,6 @@ Install from `PyPI <https://pypi.python.org/pypi/tidalapi/>`_ using ``pip``:

$ pip install tidalapi


GStreamer
------------

Playback of certain audio qualities
Certain streaming qualities require gstreamer bad-plugins, e.g.:
```
sudo apt-get install gstreamer1.0-plugins-bad
```
This is mandatory to be able to play m4a streams and for playback of mpegdash or hls streams. Otherwise, you will likely get an error:
```
WARNING [MainThread] mopidy.audio.actor Could not find a application/x-hls decoder to handle media.
WARNING [MainThread] mopidy.audio.gst GStreamer warning: No decoder available for type 'application/x-hls'.
ERROR [MainThread] mopidy.audio.gst GStreamer error: Your GStreamer installation is missing a plug-in.
```


Usage
-------------

Expand Down
24 changes: 14 additions & 10 deletions examples/pkce_login.py → examples/pkce_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
# 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/>.
#
"""pkce_login.py: A simple example script that describes how to use PKCE login and MPEG-DASH streams"""
"""pkce_example.py: A simple example script that describes how to use PKCE login and MPEG-DASH streams"""

import tidalapi
from tidalapi import Quality
Expand All @@ -34,11 +34,13 @@
# HiFi: Quality.high_lossless (FLAC)
# HiFi+ Quality.hi_res (FLAC MQA)
# HiFi+ Quality.hi_res_lossless (FLAC HI_RES)
session.audio_quality = Quality.hi_res_lossless.value
#album_id = "77640617" # U2 / Achtung Baby (Max quality: HI_RES MQA, 16bit/44100Hz)
#album_id = "110827651" # The Black Keys / Let's Rock (Max quality: LOSSLESS FLAC, 24bit/48000Hz)
album_id = "77646169" # Beck / Sea Change (Max quality: HI_RES_LOSSLESS FLAC, 24bit/192000Hz)
session.audio_quality = Quality.hi_res_lossless
# album_id = "249593867" # Alice In Chains / We Die Young (Max quality: HI_RES MHA1 SONY360)
# album_id = "77640617" # U2 / Achtung Baby (Max quality: HI_RES MQA, 16bit/44100Hz)
# album_id = "110827651" # The Black Keys / Let's Rock (Max quality: LOSSLESS FLAC, 24bit/48000Hz)
album_id = "77646169" # Beck / Sea Change (Max quality: HI_RES_LOSSLESS FLAC, 24bit/192000Hz)
album = session.album(album_id)
res = album.get_audio_resolution()
tracks = album.tracks()
# list album tracks
for track in tracks:
Expand All @@ -47,11 +49,13 @@
print("MimeType:{}".format(stream.manifest_mime_type))

manifest = stream.get_stream_manifest()
audio_resolution = stream.get_audio_resolution()

print("track:{}, (quality:{}, codec:{}, {}bit/{}Hz)".format(track.id,
stream.audio_quality,
manifest.get_codecs(),
stream.bit_depth,
stream.sample_rate))
stream.audio_quality,
manifest.get_codecs(),
audio_resolution[0],
audio_resolution[1]))
if stream.is_MPD:
# HI_RES_LOSSLESS quality supported when using MPEG-DASH stream (PKCE only!)
# 1. Export as MPD manifest
Expand All @@ -65,4 +69,4 @@
elif stream.is_BTS:
# Direct URL (m4a or flac) is available for Quality < HI_RES_LOSSLESS
url = manifest.get_urls()
break
break
2 changes: 1 addition & 1 deletion examples/simple.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
# HiFi: Quality.high_lossless (FLAC)
# HiFi+ Quality.hi_res (FLAC MQA)
# HiFi+ Quality.hi_res_lossless (FLAC HI_RES)
session.audio_quality = Quality.hi_res_lossless.value
session.audio_quality = Quality.hi_res_lossless

# album_id = "77640617" # U2 / Achtung Baby (Max quality: HI_RES MQA, 16bit/44100Hz)
# album_id = "110827651" # The Black Keys / Let's Rock (Max quality: LOSSLESS FLAC, 24bit/48000Hz)
Expand Down
772 changes: 405 additions & 367 deletions poetry.lock

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name = "tidalapi"
version = "0.7.5"
description = "Unofficial API for TIDAL music streaming service."
authors = ["Thomas Amland <[email protected]>"]
maintainers = ["tehkillerbee <josaksel.dk@gmail.com>"]
maintainers = ["tehkillerbee <[email protected].com>"]
license = "LGPL-3.0-or-later"
readme = ["README.rst", "HISTORY.rst"]
homepage = "https://tidalapi.netlify.app"
Expand All @@ -23,7 +23,10 @@ classifiers = [
python = "^3.8"
requests = "^2.28.0"
python-dateutil = "^2.8.2"
typing-extensions = "^4.8.0"
typing-extensions = "^4.10.0"
ratelimit = "^2.2.1"
isodate = "^0.6.1"
mpegdash = "^0.4.0"

[tool.poetry.group.dev.dependencies]
mypy = "^1.3.0"
Expand Down
15 changes: 10 additions & 5 deletions tests/test_album.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,16 @@ def test_no_release_date(session):
)


def test_default_image_used_if_no_cover_art(mocker):
# TODO find an example if there still are any.
album = Album(mocker.Mock(), None)
assert album.cover is None
assert album.image(1280) == tidalapi.album.DEFAULT_ALBUM_IMAGE
def test_default_image_not_used_on_albums_with_cover_art(session):
album = session.album(108043414)
assert album.cover is not None
default_album_url = "https://resources.tidal.com/images/%s/%ix%i.jpg" % (
tidalapi.album.DEFAULT_ALBUM_IMG.replace("-", "/"),
1280,
1280,
)
# Album should not use default album art
assert album.image(1280) != default_album_url


def test_similar(session):
Expand Down
8 changes: 4 additions & 4 deletions tests/test_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ def test_track_url(session):
def test_lyrics(session):
track = session.track(56480040)
lyrics = track.lyrics()
assert "Think we're there" in lyrics.text
assert "Think we're there" in lyrics.subtitles
assert "I think we're there" in lyrics.text
assert "I think we're there" in lyrics.subtitles
assert lyrics.right_to_left is False


Expand Down Expand Up @@ -99,9 +99,9 @@ def test_track_with_album(session):
def test_track_streaming(session):
track = session.track(62392768)
stream = track.get_stream()
assert stream.audio_mode == "STEREO"
assert stream.audio_mode == tidalapi.media.AudioMode.stereo
assert (
stream.audio_quality == tidalapi.Quality.low_320k.value
stream.audio_quality == tidalapi.Quality.low_320k
) # i.e. the default quality for the current session


Expand Down
4 changes: 2 additions & 2 deletions tests/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,9 @@ def test_invalid_search(session):
def test_config(session):
assert session.config.item_limit == 1000
assert (
session.config.quality == tidalapi.Quality.low_320k.value
session.config.quality == tidalapi.Quality.low_320k
) # i.e. the default quality for the current session
assert session.config.video_quality == tidalapi.VideoQuality.high.value
assert session.config.video_quality == tidalapi.VideoQuality.high
assert session.config.alac is True


Expand Down
79 changes: 63 additions & 16 deletions tidalapi/album.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

import dateutil.parser

from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound
from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound, TooManyRequests
from tidalapi.types import JsonObj

if TYPE_CHECKING:
Expand All @@ -33,9 +33,7 @@
from tidalapi.session import Session


DEFAULT_ALBUM_IMAGE = (
"https://tidal.com/browse/assets/images/defaultImages/defaultAlbumImage.png"
)
DEFAULT_ALBUM_IMG = "0dfd3368-3aa1-49a3-935f-10ffb39803c0"


class Album:
Expand All @@ -53,6 +51,10 @@ class Album:

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
Expand All @@ -64,6 +66,9 @@ class Album:
universal_product_number: Optional[int] = -1
popularity: Optional[int] = -1
user_date_added: Optional[datetime] = None
audio_quality: Optional[str] = ""
audio_modes: Optional[str] = ""
media_metadata_tags: Optional[List[str]] = [""]

artist: Optional["Artist"] = None
artists: Optional[List["Artist"]] = None
Expand All @@ -75,9 +80,12 @@ def __init__(self, session: "Session", album_id: Optional[str]):
self.id = album_id

if self.id:
request = self.request.request("GET", "albums/%s" % self.id)
if request.status_code and request.status_code == 404:
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)

Expand All @@ -102,6 +110,10 @@ def parse(
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")
Expand All @@ -112,6 +124,13 @@ def parse(
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

Expand Down Expand Up @@ -183,7 +202,7 @@ def items(self, limit: int = 100, offset: int = 0) -> List[Union["Track", "Video
assert isinstance(items, list)
return cast(List[Union["Track", "Video"]], items)

def image(self, dimensions: int = 320, default: str = DEFAULT_ALBUM_IMAGE) -> str:
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
Expand All @@ -192,17 +211,22 @@ def image(self, dimensions: int = 320, default: str = DEFAULT_ALBUM_IMAGE) -> st

Valid resolutions: 80x80, 160x160, 320x320, 640x640, 1280x1280
"""
if not self.cover:
return default

if dimensions not in [80, 160, 320, 640, 1280]:
raise ValueError("Invalid resolution {0} x {0}".format(dimensions))

return self.session.config.image_url % (
self.cover.replace("-", "/"),
dimensions,
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.
Expand Down Expand Up @@ -239,9 +263,12 @@ def similar(self) -> List["Album"]:

:return: A :any:`list` of similar albums
"""
request = self.request.request("GET", "albums/%s/similar" % self.id)
if request.status_code and request.status_code == 404:
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
Expand All @@ -261,3 +288,23 @@ def review(self) -> str:
]
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()]
14 changes: 11 additions & 3 deletions tidalapi/artist.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import dateutil.parser
from typing_extensions import NoReturn

from tidalapi.exceptions import ObjectNotFound
from tidalapi.exceptions import ObjectNotFound, TooManyRequests
from tidalapi.types import JsonObj

if TYPE_CHECKING:
Expand All @@ -35,6 +35,8 @@
from tidalapi.page import Page
from tidalapi.session import Session

DEFAULT_ARTIST_IMG = "1e01cdb6-f15d-4d8b-8440-a047976c1cac"


class Artist:
id: Optional[str] = None
Expand All @@ -54,9 +56,12 @@ def __init__(self, session: "Session", artist_id: Optional[str]):
self.id = artist_id

if self.id:
request = self.request.request("GET", "artists/%s" % self.id)
if request.status_code and request.status_code == 404:
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)

Expand All @@ -81,7 +86,10 @@ def parse_artist(self, json_obj: JsonObj) -> "Artist":
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 = (
Expand Down
4 changes: 4 additions & 0 deletions tidalapi/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ class AssetNotAvailable(Exception):
pass


class TooManyRequests(Exception):
pass


class URLNotAvailable(Exception):
pass

Expand Down
Loading
Loading