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

Lyrics/subtitles, better metadata and other fixes #57

Merged
merged 8 commits into from
May 15, 2021
8 changes: 4 additions & 4 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@ repos:
hooks:
- id: mypy
exclude: ^docs/conf.py
- repo: https://github.com/terrencepreilly/darglint
rev: v1.5.3
hooks:
- id: darglint
#- repo: https://github.com/terrencepreilly/darglint
# rev: v1.5.3
# hooks:
# - id: darglint
- repo: https://github.com/commitizen-tools/commitizen
rev: v2.3.0
hooks:
Expand Down
65 changes: 47 additions & 18 deletions tests/test_tidal_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# - [ ] Track
# - [x] loading track info
# - [x] downloading tracks
# - [ ] track lyrics (no lyrics support atm)
# - [x] track lyrics
# - [ ] track metadata generation
# - [x] loading album info
# - [x] listing tracks from albums
Expand Down Expand Up @@ -62,6 +62,38 @@ async def test_track_title(sess: TidalSession, id_, artist, title):
assert track.title == title and track.artist_name == artist


@pytest.mark.asyncio
@pytest.mark.parametrize(
"id_, lyrics_len",
(
(22563745, None),
(22563746, 3079),
),
)
async def test_track_lyrics(sess: TidalSession, id_, lyrics_len):
track = await sess.track(id_)
if lyrics_len is None:
assert await track.lyrics() is None
else:
assert len(await track.lyrics()) == lyrics_len


@pytest.mark.asyncio
@pytest.mark.parametrize(
"id_, subtitles_len",
(
(22563745, None),
(22563746, 4193),
),
)
async def test_track_subtitles(sess: TidalSession, id_, subtitles_len):
track = await sess.track(id_)
if subtitles_len is None:
assert await track.subtitles() is None
else:
assert len(await track.subtitles()) == subtitles_len


@pytest.mark.asyncio
@pytest.mark.parametrize(
"id_, artist, title",
Expand Down Expand Up @@ -207,11 +239,7 @@ async def test_object_cache(sess: TidalSession, url):
(320, 320),
"d6fe27022ee874bb07527aac70b0ce34eab6f014d5d9e34b3803df074a79e5de",
),
(
"http://www.tidal.com/track/82804684",
(1280, 1280),
None
),
("http://www.tidal.com/track/82804684", (1280, 1280), None),
),
)
async def test_cover_download(sess: TidalSession, object_url, cover_size, sha256sum):
Expand All @@ -236,24 +264,25 @@ async def test_cover_download(sess: TidalSession, object_url, cover_size, sha256
AudioQuality.Normal,
3114802,
"audio/mp4",
'"1751009e4a30270dda182b034757e195"',
),
(
152676390,
AudioQuality.High,
AudioQuality.High,
10347474,
"audio/mp4",
'"970df936b04363528662c9c74b714d13-2"',
'"780130927f84364021b1300423d60f47"',
),
(152676390, AudioQuality.HiFi, AudioQuality.HiFi, 30980403, "audio/flac", '"3bb27f3e6d8f7fd987bcc0d3cdc7c452"'),
# DASH Support needed (#53)
# (
# 152676390,
# AudioQuality.High,
# AudioQuality.High,
# 10347474,
# "audio/mp4",
# '"970df936b04363528662c9c74b714d13-2"',
# ),
(152676390, AudioQuality.HiFi, AudioQuality.HiFi, 30980344, "audio/flac", '"3bb27f3e6d8f7fd987bcc0d3cdc7c452"'),
(
152676390,
AudioQuality.Master,
AudioQuality.Master,
57347695,
57347594,
"audio/flac",
'"3ed31735943386e6effb814dba8e77b6-7"',
'"5e26dad761f202b59af8ac9962e7ccb7-7"',
),
),
)
Expand Down
117 changes: 93 additions & 24 deletions tidal_async/api.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import base64
import enum
import json
Expand All @@ -6,9 +7,10 @@
from typing import TYPE_CHECKING, AsyncGenerator, Optional, Tuple

import music_service_async_interface as generic
from aiohttp import ClientResponseError

from tidal_async.exceptions import InsufficientAudioQuality
from tidal_async.utils import cacheable, gen_artist, gen_title, id_from_url, snake_to_camel
from tidal_async.utils import artists_names, cacheable, gen_artist, gen_title, id_from_url, snake_to_camel

if TYPE_CHECKING:
from tidal_async import TidalSession
Expand Down Expand Up @@ -97,10 +99,20 @@ def __getattr__(self, attr):
return self[attr]


# TODO [#3]: Downloading lyrics
class ArtistType(enum.Enum):
main = "MAIN"
featured = "FEATURED"
contributor = "CONTRIBUTOR"
artist = "ARTIST"


class Track(TidalObject, generic.Track):
urlname = "track"

def __init__(self, sess: "TidalSession", dict_, id_field_name="id"):
super().__init__(sess, dict_, id_field_name)
self._lyrics_dict = None

def __repr__(self):
cls = self.__class__
return f"<{cls.__module__}.{cls.__qualname__} ({self.get_id()}): {self.artist_name} - {self.title}>"
Expand All @@ -113,6 +125,7 @@ async def reload_info(self):
},
)
self.dict = await resp.json()
self._lyrics_dict = None

@property
def title(self) -> str:
Expand All @@ -134,10 +147,9 @@ def cover(self):
def artist(self):
return Artist(self.sess, self["artist"])

async def artists(self) -> AsyncGenerator[Tuple["Artist", str], None]:
# TODO [#49]: Artist types enum
async def artists(self) -> AsyncGenerator[Tuple["Artist", ArtistType], None]:
for artist in self["artists"]:
yield await Artist.from_id(self.sess, artist["id"]), artist["type"]
yield await Artist.from_id(self.sess, artist["id"]), ArtistType(artist["type"])

@property
def audio_quality(self):
Expand Down Expand Up @@ -178,31 +190,78 @@ async def get_file_url(

return manifest["urls"][0]

async def _lyrics(self) -> Optional[dict]:
if self._lyrics_dict is not None:
return self._lyrics_dict

try:
resp = await self.sess.get(
f"/v1/tracks/{self.get_id()}/lyrics", params={"countryCode": self.sess.country_code}
)
except ClientResponseError as e:
if e.status == 404:
return None
else:
raise

self._lyrics_dict = await resp.json()
return self._lyrics_dict

async def lyrics(self) -> Optional[str]:
"""Gets lyrics for track

:return: Lyrics string when available, `None` when not
"""
lyrics_dict = await self._lyrics()
if lyrics_dict is None or "lyrics" not in lyrics_dict:
return None

return lyrics_dict["lyrics"]

async def subtitles(self) -> Optional[str]:
"""Gets subtitles (time-synchronized lyrics) for track

:return: Subtitles string in LRC format when available, `None` when not
"""
lyrics_dict = await self._lyrics()
if lyrics_dict is None or "subtitles" not in lyrics_dict:
return None

return lyrics_dict["subtitles"]

async def get_metadata(self):
# TODO [#22]: Rewrite Track.get_metadata
wvffle marked this conversation as resolved.
Show resolved Hide resolved
# - [ ] lyrics
# - [x] rewrite title parsing
# - [x] replayGain
# - [ ] multiple artists
# - [ ] Picard like artist and view artist separation
album = self.album
await album.reload_info()

[artist, artists, albumartist, albumartists, url, lyrics] = await asyncio.gather(
gen_artist(self),
artists_names(self),
gen_artist(album),
artists_names(album),
self.get_url(),
self.lyrics(),
)

tags = {
# general metatags
"artist": await gen_artist(self),
"title": await gen_title(self),
"artist": artist,
"artists": artists,
"title": gen_title(self),
# album related metatags
"albumartist": await gen_artist(album),
"album": await gen_title(album),
"albumartist": albumartist,
"albumartists": albumartists,
"album": gen_title(album),
"date": album.release_date,
# track/disc position metatags
"discnumber": self.volume_number,
"disc": self.volume_number,
"disctotal": album.number_of_volumes,
"tracknumber": self.track_number,
"track": self.track_number,
"tracktotal": album.number_of_tracks,
"replaygain_track_gain": self.replay_gain,
"replaygain_track_peak": self.peak,
# replaygain
"rg_track_gain": self.replay_gain,
"rg_track_peak": self.peak,
# track url
"url": url,
}

# Tidal sometimes returns null for track copyright
Expand All @@ -211,11 +270,22 @@ async def get_metadata(self):
elif "copyright" in album and album.copyright:
tags["copyright"] = album.copyright

# identifiers for later use in own music libraries
# identifiers for later use in music libraries
if "isrc" in self and self.isrc:
tags["isrc"] = self.isrc
if "upc" in album and album.upc:
tags["upc"] = album.upc
tags["barcode"] = album.upc

if lyrics:
tags["lyrics"] = lyrics

# uses cached lyrics data
subtitles = await self.subtitles()
if subtitles:
# TODO: Support for subtitles tag
# prelimitary invalid support for subtitles tag
# depends on beetbox/mediafile#48
tags["subtitles"] = subtitles

return tags

Expand Down Expand Up @@ -296,10 +366,9 @@ async def reload_info(self):
def cover(self):
return Cover(self.sess, self["cover"]) if self["cover"] is not None else None

async def artists(self) -> AsyncGenerator[Tuple["Artist", str], None]:
# TODO [#50]: Artist types enum
async def artists(self) -> AsyncGenerator[Tuple["Artist", ArtistType], None]:
for artist in self["artists"]:
yield await Artist.from_id(self.sess, artist["id"]), artist["type"]
yield await Artist.from_id(self.sess, artist["id"]), ArtistType(artist["type"])

async def tracks(self, per_request_limit=50) -> AsyncGenerator[Track, None]:
offset = 0
Expand Down
1 change: 1 addition & 0 deletions tidal_async/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ class TidalMultiSession(TidalSession):
# It helps with downloading multiple tracks simultaneously and overriding region lock
# TODO [#8]: [TidalMultiSession] Run request on random session
# TODO [#9]: [TidalMultiSession] Retry failed (404) requests (regionlock) on next session
# TODO: [TidalMultiSession] Merge search results from all sessions
# TODO [#10]: [TidalMultiSession] Try file download request on all sessions in queue fullness order
# Someone told me that Tidal blocks downloading of files simultaneously, but I didn't really noticed that
def __init__(self, client_id):
Expand Down
44 changes: 25 additions & 19 deletions tidal_async/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import contextlib
from urllib.parse import urlparse

from music_service_async_interface import InvalidURL
Expand Down Expand Up @@ -37,7 +38,13 @@ async def cli_auth_url_getter(authorization_url):
return input("Enter auth_url: ")


class Cacheable:
@contextlib.contextmanager
def lock_context_manager(lock):
yield lock.acquire().__await__()
lock.release()


class AsyncCacheable:
# NOTE: Used snipped from https://stackoverflow.com/a/46723144
def __init__(self, co):
self.co = co
Expand All @@ -46,14 +53,13 @@ def __init__(self, co):
self.lock = asyncio.Lock()

def __await__(self):
yield from self.lock.acquire().__await__()
with lock_context_manager(self.lock) as t:
yield from t

if self.done:
return self.result
self.result = yield from self.co.__await__()
self.done = True

self.lock.release()
if self.done:
return self.result
self.result = yield from self.co.__await__()
self.done = True

return self.result

Expand All @@ -62,28 +68,28 @@ def cacheable(f):
# NOTE: Used snipped from https://stackoverflow.com/a/46723144
wvffle marked this conversation as resolved.
Show resolved Hide resolved
def wrapped(*args, **kwargs):
r = f(*args, **kwargs)
return Cacheable(r)
return AsyncCacheable(r)

return wrapped


async def gen_title(obj):
"""Generates full title from track/album version and artist list"""
artists = [a async for a in obj.artists() if a[1] != "MAIN"]
def gen_title(obj):
"""Generates full title from track/album version"""
title = obj.title.strip()
version = obj.version.strip() if "version" in obj and obj.version else ""

if not artists:
wvffle marked this conversation as resolved.
Show resolved Hide resolved
return f"{title} ({version})" if version and version not in title else title

if "feat" not in title:
title += f' (feat. {", ".join([a[0].name for a in artists])})'

return f"{title} ({version})" if version and version not in title else title


async def gen_artist(obj):
return ", ".join([a[0].name async for a in obj.artists() if a[1] == "MAIN"])
artists = [a async for a in obj.artists()]
main = ", ".join(a[0].name for a in artists if a[1].value == "MAIN")
feat = ", ".join(a[0].name for a in artists if a[1].value == "FEATURED")
return main if not feat else f"{main} feat. {feat}"


async def artists_names(obj):
return [a[0].name async for a in obj.artists()]


try:
Expand Down