Skip to content

Commit

Permalink
Merge pull request #237 from tamland/feature/hls-parsing
Browse files Browse the repository at this point in the history
Feature/MPEG-DASH-HLS
  • Loading branch information
tehkillerbee authored Feb 19, 2024
2 parents c46bb03 + aacc401 commit fd967e9
Show file tree
Hide file tree
Showing 18 changed files with 745 additions and 146 deletions.
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

0 comments on commit fd967e9

Please sign in to comment.