QoL improvements

- Better lyrics support for Dolby Atmos/Sony 360RA
- Added third party cover support
- Option to disable subscription checking added
- Fallback added if ffmpeg error occurs (needs newest OrpheusDL)
- More beautiful prints
This commit is contained in:
Dniel97
2022-01-20 01:05:30 +01:00
parent 823577ad88
commit 1d0a9d7243
3 changed files with 118 additions and 66 deletions

View File

@@ -5,9 +5,9 @@ OrpheusDL - Tidal
A Tidal module for the OrpheusDL modular archival music program
[Report Bug](https://github.com/yarrm80s/orpheusdl/issues)
[Report Bug](https://github.com/Dniel97/orpheusdl-tidal/issues)
·
[Request Feature](https://github.com/yarrm80s/orpheusdl/issues)
[Request Feature](https://github.com/Dniel97/orpheusdl-tidal/issues)
## Table of content
@@ -69,21 +69,25 @@ loaded module. You'll find the configuration file here: `config/settings.json`
### Global
```json
```json5
"global": {
"general": {
...
// ...
"download_quality": "lossless"
},
"formatting": {
"album_format": "{artist}/{name}{quality}{explicit}",
...
"album_format": "{artist}/{name}{quality}{explicit}"
// ...
},
"codecs": {
"proprietary_codecs": false,
"spatial_codecs": true
},
...
"covers": {
"main_resolution": 1400
// ...
}
// ...
}
```
@@ -111,6 +115,11 @@ loaded module. You'll find the configuration file here: `config/settings.json`
`spatial_codecs`: Enables/Disables downloading of Dolby Atmos (EAC-3, AC-4) and Sony 360RA
`main_resolution`: Tidal only supports 80x80, 160x160, 320x320, 480x480, 640x640, 1080x1080 and 1280x1280px
(1280px won't work for playlists). If you choose 1400 or anything above 1280, it will get the highest quality even if
the highest is 4000x4000px. That's because Tidal doesn't provide the "origin artwork" size, so the module will just get
the largest.
### Tidal
```json
"tidal": {

View File

@@ -12,11 +12,11 @@ from tqdm import tqdm
from utils.models import *
from utils.utils import sanitise_name, silentremove, download_to_temp, create_temp_filename
from .tidal_api import TidalTvSession, TidalApi, TidalAuthError, SessionStorage, TidalMobileSession, SessionType
from .tidal_api import TidalTvSession, TidalApi, SessionStorage, TidalMobileSession, SessionType
module_information = ModuleInformation(
service_name='Tidal',
module_supported_modes=ModuleModes.download | ModuleModes.credits | ModuleModes.lyrics,
module_supported_modes=ModuleModes.download | ModuleModes.credits | ModuleModes.covers | ModuleModes.lyrics,
login_behaviour=ManualEnum.manual,
global_settings={
'tv_token': '7m7Ap0JC9j1cOM3n',
@@ -32,17 +32,20 @@ module_information = ModuleInformation(
@dataclass
class AudioTrack:
codec: str
codec: CodecEnum
sample_rate: int
bitrate: int
urls: list
class ModuleInterface:
# noinspection PyTypeChecker
def __init__(self, module_controller: ModuleController):
self.cover_size = module_controller.orpheus_options.default_cover_options.resolution
self.oprinter = module_controller.printer_controller
self.print = module_controller.printer_controller.oprint
self.disable_subscription_check = module_controller.orpheus_options.disable_subscription_check
settings = module_controller.module_settings
# LOW = 96kbit/s AAC, HIGH = 320kbit/s AAC, LOSSLESS = 44.1/16 FLAC, HI_RES <= 48/24 FLAC with MQA
@@ -60,7 +63,7 @@ class ModuleInterface:
if settings['enable_mobile']:
storage: SessionStorage = module_controller.temporary_settings_controller.read(SessionType.MOBILE.name)
if not storage:
confirm = input('"enable_mobile" is enabled but no MOBILE session was found. Do you want to create a '
confirm = input(' "enable_mobile" is enabled but no MOBILE session was found. Do you want to create a '
'MOBILE session (used for AC-4/360RA) [Y/n]? ')
if confirm.upper() == 'N':
self.available_sessions = [SessionType.TV.name]
@@ -84,11 +87,11 @@ class ModuleInterface:
if session_type == SessionType.TV.name:
sessions[session_type].auth()
else:
print('Tidal: Enter your Tidal username and password:')
username = input('Username: ')
password = getpass('Password: ')
self.print('Tidal: Enter your Tidal username and password:')
username = input(' Username: ')
password = getpass(' Password: ')
sessions[session_type].auth(username, password)
print('Successfully logged in!')
self.print('Successfully logged in!')
module_controller.temporary_settings_controller.set(session_type, sessions[session_type].get_storage())
@@ -99,29 +102,28 @@ class ModuleInterface:
module_controller.temporary_settings_controller.set(session_type, sessions[session_type].get_storage())
while True:
# Check for HiFi subscription
try:
sessions[session_type].check_subscription()
# check for a valid subscription
subscription = self.check_subscription(sessions[session_type].get_subscription())
if subscription:
break
except TidalAuthError as e:
print(f'{e}')
confirm = input('Do you want to create a new session? [Y/n]: ')
if confirm.upper() == 'N':
print('Exiting...')
exit()
confirm = input(' Do you want to create a new session? [Y/n]: ')
# Create a new session finally
if session_type == SessionType.TV.name:
sessions[session_type].auth()
else:
print('Tidal: Enter your Tidal username and password:')
username = input('Username: ')
password = getpass('Password: ')
sessions[session_type].auth(username, password)
if confirm.upper() == 'N':
self.print('Exiting...')
exit()
module_controller.temporary_settings_controller.set(session_type,
sessions[session_type].get_storage())
# create a new session finally
if session_type == SessionType.TV.name:
sessions[session_type].auth()
else:
self.print('Tidal: Enter your Tidal username and password:')
username = input('Username: ')
password = getpass('Password: ')
sessions[session_type].auth(username, password)
module_controller.temporary_settings_controller.set(session_type,
sessions[session_type].get_storage())
self.session: TidalApi = TidalApi(sessions)
@@ -130,18 +132,26 @@ class ModuleInterface:
# Album cache
self.album_cache = {}
def generate_artwork_url(self, cover_id, max_size=1280):
def check_subscription(self, subscription: str) -> bool:
# returns true if "disable_subscription_checks" is enabled or subscription is HIFI Plus
if not self.disable_subscription_check and subscription not in {'HIFI', 'PREMIUM_PLUS'}:
self.print(f'Tidal: Account is not a HiFi Plus account, detected subscription: {subscription}')
return False
return True
@staticmethod
def generate_artwork_url(cover_id: str, size: int, max_size: int = 1280):
# not the best idea, but it rounds the self.cover_size to the nearest number in supported_sizes, 1281 is needed
# for the "uncompressed" cover
supported_sizes = [80, 160, 320, 480, 640, 1080, 1280, 1281]
best_size = min(supported_sizes, key=lambda x: abs(x - self.cover_size))
best_size = min(supported_sizes, key=lambda x: abs(x - size))
# only supports 80x80, 160x160, 320x320, 480x480, 640x640, 1080x1080 and 1280x1280 only for non playlists
# return "uncompressed" cover if self.cover_resolution > max_size
image_name = '{0}x{0}.jpg'.format(best_size) if best_size <= max_size else 'origin.jpg'
return f'https://resources.tidal.com/images/{cover_id.replace("-", "/")}/{image_name}'
@staticmethod
def generate_animated_artwork_url(cover_id, size=1280):
def generate_animated_artwork_url(cover_id: str, size=1280):
return 'https://resources.tidal.com/videos/{0}/{1}x{1}.mp4'.format(cover_id.replace('-', '/'), size)
def search(self, query_type: DownloadTypeEnum, query: str, track_info: TrackInfo = None, limit: int = 20):
@@ -254,11 +264,12 @@ class ModuleInterface:
# TODO: Get correct bit_depth and sample_rate for MQA, even possible?
bit_depth=24 if track_codec in [CodecEnum.MQA, CodecEnum.EAC3, CodecEnum.MHA1] else 16,
sample_rate=48 if track_codec in [CodecEnum.EAC3, CodecEnum.MHA1] else 44.1,
cover_url=self.generate_artwork_url(track_data['album']['cover']),
cover_url=self.generate_artwork_url(track_data['album']['cover'], size=self.cover_size),
explicit=track_data['explicit'] if 'explicit' in track_data else None,
tags=self.convert_tags(track_data, album_data),
codec=track_codec,
download_extra_kwargs=download_args
download_extra_kwargs=download_args,
lyrics_extra_kwargs={'track_data': track_data}
)
if not codec_options.spatial_codecs and codec_data[track_codec].spatial:
@@ -328,12 +339,12 @@ class ModuleInterface:
return tracks
def get_track_download(self, file_url: str = None, audio_track: AudioTrack = None) -> TrackDownloadInfo:
# No MPEG-DASH, just a simple file
# no MPEG-DASH, just a simple file
if file_url:
return TrackDownloadInfo(download_type=DownloadEnum.URL, file_url=file_url)
# MPEG-DASH
# Use the total_file size for a better progress bar? Is it even possible to calculate the total size from MPD?
# use the total_file size for a better progress bar? Is it even possible to calculate the total size from MPD?
try:
columns = os.get_terminal_size().columns
if os.name == 'nt':
@@ -344,23 +355,23 @@ class ModuleInterface:
except OSError:
bar = tqdm(audio_track.urls, bar_format=' ' * self.oprinter.indent_number + '{l_bar}{bar}{r_bar}')
# Download all segments and save the locations inside temp_locations
# download all segments and save the locations inside temp_locations
temp_locations = []
for download_url in bar:
temp_locations.append(download_to_temp(download_url, extension='mp4'))
# Concatenated/Merged .mp4 file
# concatenated/Merged .mp4 file
merged_temp_location = create_temp_filename() + '.mp4'
# Actual converted .flac file
# actual converted .flac file
output_location = create_temp_filename() + '.' + codec_data[audio_track.codec].container.name
# Download is finished, merge chunks into 1 file
# download is finished, merge chunks into 1 file
with open(merged_temp_location, 'wb') as dest_file:
for temp_location in temp_locations:
with open(temp_location, 'rb') as segment_file:
copyfileobj(segment_file, dest_file)
# Convert .mp4 back to .flac
# convert .mp4 back to .flac
try:
ffmpeg.input(merged_temp_location, hide_banner=None, y=None).output(output_location, acodec='copy',
loglevel='error').run()
@@ -369,19 +380,53 @@ class ModuleInterface:
for temp_location in temp_locations:
silentremove(temp_location)
except Exception:
print('FFmpeg is not installed or working! Using fallback, may have errors')
output_location = merged_temp_location
self.print('FFmpeg is not installed or working! Using fallback, may have errors')
# return the MP4 temp file, but tell orpheus to change the container to .m4a (AAC)
return TrackDownloadInfo(
download_type=DownloadEnum.TEMP_FILE_PATH,
temp_file_path=merged_temp_location,
different_codec=CodecEnum.AAC
)
# return the converted flac file now
return TrackDownloadInfo(
download_type=DownloadEnum.TEMP_FILE_PATH,
temp_file_path=output_location
temp_file_path=output_location,
)
def get_track_lyrics(self, track_id: str) -> LyricsInfo:
def get_track_cover(self, track_id: str, cover_options: CoverOptions, data=None) -> CoverInfo:
if data is None:
data = {}
track_data = data[track_id] if track_id in data else self.session.get_track(track_id)
cover_id = track_data['album']['cover']
# Tidal don't support PNG, so it will always get JPG
cover_url = self.generate_artwork_url(cover_id, size=cover_options.resolution)
return CoverInfo(url=cover_url, file_type=ImageFileTypeEnum.jpg)
def get_track_lyrics(self, track_id: str, track_data: dict) -> LyricsInfo:
embedded, synced = None, None
lyrics_data = self.session.get_lyrics(track_id)
if 'error' in lyrics_data:
# search for title and artist to find a matching track (non Atmos)
results = self.search(
DownloadTypeEnum.track,
f'{track_data["title"]} {"".join(a["name"] for a in track_data["artists"])}',
limit=10)
# check every result to find a matching result
best_tracks = [r.result_id for r in results
if r.name == track_data['title'] and
r.artists[0] == track_data['artist']['name'] and
'Dolby Atmos' not in r.additional]
# retrieve the lyrics for the first one, otherwise return empty dict
lyrics_data = self.session.get_lyrics(best_tracks[0]) if len(best_tracks) > 0 else {}
if 'lyrics' in lyrics_data:
embedded = lyrics_data['lyrics']
@@ -413,7 +458,7 @@ class ModuleInterface:
# TODO: Use playlist creation date or lastUpdated?
release_year=playlist_data['created'][:4],
creator_id=playlist_data['creator']['id'],
cover_url=self.generate_artwork_url(playlist_data['squareImage'], max_size=1080)
cover_url=self.generate_artwork_url(playlist_data['squareImage'], size=self.cover_size, max_size=1080)
)
def get_album_info(self, album_id):
@@ -446,7 +491,8 @@ class ModuleInterface:
explicit=album_data['explicit'],
quality=quality,
upc=album_data['upc'],
cover_url=self.generate_artwork_url(album_data['cover']),
all_track_cover_jpg_url=self.generate_artwork_url(album_data['cover'],
size=self.cover_size) if album_data['cover'] else None,
animated_cover_url=self.generate_animated_artwork_url(album_data['videoCover']) if album_data[
'videoCover'] else None,
artist=album_data['artist']['name'],

View File

@@ -305,9 +305,9 @@ class SessionStorage:
class TidalSession(ABC):
'''
Tidal session object with all required functions needed
'''
"""
Tidal abstract session object with all (abstract) functions needed: auth_headers(), refresh(), session_type()
"""
def __init__(self):
self.access_token = None
self.refresh_token = None
@@ -331,24 +331,23 @@ class TidalSession(ABC):
self.country_code
)
def check_subscription(self):
def get_subscription(self) -> str:
if self.access_token:
r = requests.get('https://api.tidal.com/v1/users/' + str(self.user_id) + '/subscription',
headers=self.auth_headers(), verify=False)
if r.status_code != 200:
raise TidalAuthError(r.json()['userMessage'])
if r.json()['subscription']['type'] not in ['HIFI', 'PREMIUM_PLUS']:
raise TidalAuthError('You need a HiFi subscription!')
return r.json()['subscription']['type']
@abstractmethod
def auth_headers(self) -> dict:
pass
def valid(self):
'''
"""
Checks if session is still valid and returns True/False
'''
"""
if not isinstance(self, TidalSession):
if self.access_token is None or datetime.now() > self.expires:
return False
@@ -366,9 +365,9 @@ class TidalSession(ABC):
class TidalMobileSession(TidalSession):
'''
"""
Tidal session object based on the mobile Android oauth flow
'''
"""
def __init__(self, client_token: str):
super().__init__()
@@ -472,8 +471,6 @@ class TidalMobileSession(TidalSession):
self.user_id = r.json()['userId']
self.country_code = r.json()['countryCode']
self.check_subscription()
def refresh(self):
assert (self.refresh_token is not None)
r = requests.post(self.TIDAL_AUTH_BASE + 'oauth2/token', data={
@@ -511,9 +508,9 @@ class TidalMobileSession(TidalSession):
class TidalTvSession(TidalSession):
'''
"""
Tidal session object based on the AndroidTV oauth flow
'''
"""
def __init__(self, client_token: str, client_secret: str):
super().__init__()