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/MPEG-DASH-HLS #237

Merged
merged 33 commits into from
Feb 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
9222dd5
Fix missing dependency on python <3.11
tehkillerbee Feb 6, 2024
f069146
Version bump
tehkillerbee Feb 6, 2024
17b9dfc
Improve error handling (Missing albums, missing lyrics, missing track…
tehkillerbee Feb 6, 2024
d718856
Load pkce state from file
tehkillerbee Feb 6, 2024
94a7406
Added custom exceptions
tehkillerbee Feb 6, 2024
dc43d3e
Remove assert
tehkillerbee Feb 6, 2024
17a643f
Added exception for no Track URL
tehkillerbee Feb 6, 2024
7ab421d
WIP: Added MPEG-DASH to HLS stream parsing.
tehkillerbee Feb 6, 2024
e1a5f18
Added MPEG-DASH stream example
tehkillerbee Feb 6, 2024
dfd53cd
Update gitignore
tehkillerbee Feb 6, 2024
2ba111c
Fix pkce token refresh. Added misc exceptions
tehkillerbee Feb 7, 2024
aa9e15b
Added link to documentation
tehkillerbee Feb 7, 2024
99d7d1c
Stream error handling
tehkillerbee Feb 7, 2024
0d272dc
Added misc stream metadata
tehkillerbee Feb 7, 2024
af704a4
Fix misc getters
tehkillerbee Feb 7, 2024
fe28e3d
Add album examples, multiple mime types
tehkillerbee Feb 7, 2024
2e28937
Fix formatting
tehkillerbee Feb 7, 2024
19fec48
Cleanup. Added/moved misc helper functions. Added misc exception types.
tehkillerbee Feb 8, 2024
66955a4
Use helper functions to determine stream type
tehkillerbee Feb 8, 2024
dfe8736
Updated readme
tehkillerbee Feb 8, 2024
6363cf1
Added MPD to gitignore
tehkillerbee Feb 8, 2024
45e89e6
Improve, cleanup error handling. Cleanup misc imports. Added misc. fu…
tehkillerbee Feb 13, 2024
9ec2704
Test *NotFound
tehkillerbee Feb 13, 2024
0f81f07
Fix misc tests broken by Tidal
tehkillerbee Feb 13, 2024
44c3597
Misc. cleanup, added missing typings.
tehkillerbee Feb 13, 2024
f3e75ea
Return error if video not found. Use ObjectNotFound error type
tehkillerbee Feb 14, 2024
1108dc9
Formatting
tehkillerbee Feb 14, 2024
0b5d355
Use correct logger handle
tehkillerbee Feb 14, 2024
2937642
Formatting
tehkillerbee Feb 16, 2024
196c3ac
Fix mix error handling (ObjectNotFound). Added mix tests
tehkillerbee Feb 16, 2024
c858cc7
Formatting
tehkillerbee Feb 16, 2024
f349742
Cleanup load/save session helper functions
tehkillerbee Feb 16, 2024
aacc401
Handle ObjectNotFound exceptions gracefully
tehkillerbee Feb 16, 2024
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,6 @@ prof/
*.csv

# Json session files
tidal*.json
tidal*.json
*.m3u8
*.mpd
15 changes: 15 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ 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
46 changes: 34 additions & 12 deletions examples/pkce_login.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/>.
#
"""simple.py: A simple example script that describes how to get started using tidalapi"""
"""pkce_login.py: A simple example script that describes how to use PKCE login and MPEG-DASH streams"""

import tidalapi
from tidalapi import Quality
Expand All @@ -29,18 +29,40 @@

# Override the required playback quality, if necessary
# Note: Set the quality according to your subscription.
# Low: Quality.low_96k
# Normal: Quality.low_320k
# HiFi: Quality.high_lossless
# HiFi+ Quality.hi_res_lossless
# Low: Quality.low_96k (m4a 96k)
# Normal: Quality.low_320k (m4a 320k)
# 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 = session.album("110827651") # Let's Rock // The Black Keys
#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)
tracks = album.tracks()
# list album tracks
for track in tracks:
print(track.name)
# MPEG-DASH Stream is only supported when HiRes mode is used!
print(track.get_stream())
for artist in track.artists:
print(' by: ', artist.name)
print("{}: '{}' by '{}'".format(track.id, track.name, track.artist.name))
stream = track.get_stream()
print("MimeType:{}".format(stream.manifest_mime_type))

manifest = stream.get_stream_manifest()
print("track:{}, (quality:{}, codec:{}, {}bit/{}Hz)".format(track.id,
stream.audio_quality,
manifest.get_codecs(),
stream.bit_depth,
stream.sample_rate))
if stream.is_MPD:
# HI_RES_LOSSLESS quality supported when using MPEG-DASH stream (PKCE only!)
# 1. Export as MPD manifest
mpd = stream.get_manifest_data()
# 2. Export as HLS m3u8 playlist
hls = manifest.get_hls()
# with open("{}_{}.mpd".format(album_id, track.id), "w") as my_file:
# my_file.write(mpd)
# with open("{}_{}.m3u8".format(album_id, track.id), "w") as my_file:
# my_file.write(hls)
elif stream.is_BTS:
# Direct URL (m4a or flac) is available for Quality < HI_RES_LOSSLESS
url = manifest.get_urls()
break
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "tidalapi"
version = "0.7.4"
version = "0.7.5"
description = "Unofficial API for TIDAL music streaming service."
authors = ["Thomas Amland <thomas.amland@googlemail.com>"]
maintainers = ["tehkillerbee <josaksel.dk@gmail.com>"]
Expand Down
20 changes: 14 additions & 6 deletions tests/test_album.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import tidalapi
from tidalapi.album import Album
from tidalapi.exceptions import MetadataNotAvailable, ObjectNotFound

from .cover import verify_image_cover, verify_video_cover

Expand Down Expand Up @@ -110,12 +111,19 @@ def test_default_image_used_if_no_cover_art(mocker):
def test_similar(session):
album = session.album(108043414)
for alb in album.similar():
if alb.id == 64522277:
# Album with no similar albums should trigger AttributeError (response: 404)
with pytest.raises(AttributeError):
alb.similar()
else:
assert isinstance(alb.similar()[0], tidalapi.Album)
assert isinstance(alb.similar()[0], tidalapi.Album)
# if alb.id == 64522277:
# # Album with no similar albums should trigger MetadataNotAvailable (response: 404)
# # TODO Find an album with no similar albums related to it
# with pytest.raises(MetadataNotAvailable):
# alb.similar()
# else:
# assert isinstance(alb.similar()[0], tidalapi.Album)


def test_album_not_found(session):
with pytest.raises(ObjectNotFound):
session.album(123456789)


def test_review(session):
Expand Down
6 changes: 6 additions & 0 deletions tests/test_artist.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import requests

import tidalapi
from tidalapi.exceptions import ObjectNotFound

from .cover import verify_image_cover

Expand All @@ -42,6 +43,11 @@ def test_artist(session):
assert requests.get(artist.image(160)).status_code == 200


def test_artist_not_found(session):
with pytest.raises(ObjectNotFound):
session.artist(123456789)


def test_get_albums(session):
artist = session.artist(16147)
albums = [
Expand Down
7 changes: 4 additions & 3 deletions tests/test_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from dateutil import tz

import tidalapi
from tidalapi.exceptions import MetadataNotAvailable

from .cover import verify_image_resolution, verify_video_resolution

Expand All @@ -47,7 +48,7 @@ def test_track(session):
assert track.version is None
assert (
track.copyright
== "(P) 2019 MER under exclusive license to Sony Music Entertainment Sweden AB"
== "(P) 2019 Kreatell Music under exclusive license to Sony Music Entertainment Sweden AB"
)
assert track.isrc == "NOG841907010"
assert track.explicit is False
Expand Down Expand Up @@ -75,8 +76,8 @@ def test_lyrics(session):

def test_no_lyrics(session):
track = session.track(17626400)
# Tracks with no lyrics should trigger AttributeError (response: 404)
with pytest.raises(AttributeError):
# Tracks with no lyrics should trigger MetadataNotAvailable (response: 404)
with pytest.raises(MetadataNotAvailable):
track.lyrics()


Expand Down
13 changes: 13 additions & 0 deletions tests/test_mix.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
# 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 pytest

import tidalapi
from tidalapi.exceptions import ObjectNotFound

from .cover import verify_image_cover

Expand All @@ -32,3 +35,13 @@ def test_image(session):
mixes = session.mixes()
first = next(iter(mixes))
verify_image_cover(session, first, [320, 640, 1500])


def test_mix_unavailable(session):
with pytest.raises(ObjectNotFound):
mix = session.mix("12345678")


def test_mixv2_unavailable(session):
with pytest.raises(ObjectNotFound):
mix = session.mixv2("12345678")
4 changes: 2 additions & 2 deletions tests/test_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ def test_get_explore_items(session):
genres = explore.categories[1].show_more()
iterator = iter(genres)
next(iterator)
assert next(iterator).title == "Africa"
assert next(iterator).title == "Blues"
assert next(iterator).title == "Classical"


def test_for_you(session):
Expand Down Expand Up @@ -97,7 +97,7 @@ def test_page_links(session):
def test_genres(session):
genres = session.genres()
first = next(iter(genres))
assert first.title == "Africa"
assert first.title == "Blues"
assert isinstance(next(iter(first.get())), tidalapi.Playlist)

# NOTE local genres seems broken, and the first entry is no longer available
Expand Down
6 changes: 6 additions & 0 deletions tests/test_playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from dateutil import tz

import tidalapi
from tidalapi.exceptions import ObjectNotFound

from .cover import verify_image_cover, verify_image_resolution

Expand Down Expand Up @@ -78,6 +79,11 @@ def test_updated_playlist(session):
assert creator.name == "user"


def test_playlist_not_found(session):
with pytest.raises(ObjectNotFound):
session.playlist("12345678")


def test_video_playlist(session):
playlist = session.playlist("aa3611ff-5b25-4bbe-8ce4-36c678c3438f")
assert playlist.id == "aa3611ff-5b25-4bbe-8ce4-36c678c3438f"
Expand Down
2 changes: 1 addition & 1 deletion tidalapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@
User,
)

__version__ = "0.7.4"
__version__ = "0.7.5"
35 changes: 22 additions & 13 deletions tidalapi/album.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

import dateutil.parser

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

if TYPE_CHECKING:
Expand All @@ -31,6 +32,7 @@
from tidalapi.page import Page
from tidalapi.session import Session


DEFAULT_ALBUM_IMAGE = (
"https://tidal.com/browse/assets/images/defaultImages/defaultAlbumImage.png"
)
Expand Down Expand Up @@ -68,11 +70,16 @@ class Album:

def __init__(self, session: "Session", album_id: Optional[str]):
self.session = session
self.requests = session.request
self.request = session.request
self.artist = session.artist()
self.id = album_id

if self.id:
self.requests.map_request(f"albums/{album_id}", parse=self.parse)
request = self.request.request("GET", "albums/%s" % self.id)
if request.status_code and request.status_code == 404:
raise ObjectNotFound("Album not found")
else:
self.request.map_json(request.json(), parse=self.parse)

def parse(
self,
Expand Down Expand Up @@ -127,7 +134,7 @@ def parse(

@property
def year(self) -> Optional[int]:
"""Convenience function to get the year using :class:`available_release_date`
"""Get the year using :class:`available_release_date`

:return: An :any:`python:int` containing the year the track was released
"""
Expand Down Expand Up @@ -155,7 +162,8 @@ def tracks(self, limit: Optional[int] = None, offset: int = 0) -> List["Track"]:
:return: A list of the :class:`Tracks <.Track>` in the album.
"""
params = {"limit": limit, "offset": offset}
tracks = self.requests.map_request(

tracks = self.request.map_request(
"albums/%s/tracks" % self.id, params, parse=self.session.parse_track
)
assert isinstance(tracks, list)
Expand All @@ -169,7 +177,7 @@ def items(self, limit: int = 100, offset: int = 0) -> List[Union["Track", "Video
:return: A list of :class:`Tracks<.Track>` and :class:`Videos`<.Video>`
"""
params = {"offset": offset, "limit": limit}
items = self.requests.map_request(
items = self.request.map_request(
"albums/%s/items" % self.id, params=params, parse=self.session.parse_media
)
assert isinstance(items, list)
Expand Down Expand Up @@ -226,17 +234,18 @@ def page(self) -> "Page":
return self.session.page.get("pages/album", params={"albumId": self.id})

def similar(self) -> List["Album"]:
"""Retrieve albums similar to the current one. AttributeError is raised, when no
similar albums exists.
"""Retrieve albums similar to the current one. MetadataNotAvailable is raised,
when no similar albums exist.

:return: A :any:`list` of similar albums
"""
json_obj = self.requests.map_request("albums/%s/similar" % self.id)
if json_obj.get("status"):
assert json_obj.get("status") == 404
raise AttributeError("No similar albums exist for this album")
request = self.request.request("GET", "albums/%s/similar" % self.id)
if request.status_code and request.status_code == 404:
raise MetadataNotAvailable("No similar albums exist for this album")
else:
albums = self.requests.map_json(json_obj, parse=self.session.parse_album)
albums = self.request.map_json(
request.json(), parse=self.session.parse_album
)
assert isinstance(albums, list)
return cast(List["Album"], albums)

Expand All @@ -247,7 +256,7 @@ def review(self) -> str:
:raises: :class:`requests.HTTPError` if there isn't a review yet
"""
# morguldir: TODO: Add parsing of wimplinks?
review = self.requests.request("GET", "albums/%s/review" % self.id).json()[
review = self.request.request("GET", "albums/%s/review" % self.id).json()[
"text"
]
assert isinstance(review, str)
Expand Down
Loading
Loading