diff --git a/tidal_dl_ng/cli.py b/tidal_dl_ng/cli.py index ce8dc3d..c2b51fa 100644 --- a/tidal_dl_ng/cli.py +++ b/tidal_dl_ng/cli.py @@ -246,7 +246,7 @@ def _download(ctx: typer.Context, urls: list[str], try_login: bool = True) -> bo fn_logger = LoggerWrapped(progress.print) dl = Download( - session=ctx.obj[CTX_TIDAL].session, + tidal_obj=ctx.obj[CTX_TIDAL], skip_existing=settings.data.skip_existing, path_base=settings.data.download_base_path, fn_logger=fn_logger, diff --git a/tidal_dl_ng/config.py b/tidal_dl_ng/config.py index 72b9f93..28ea58b 100644 --- a/tidal_dl_ng/config.py +++ b/tidal_dl_ng/config.py @@ -2,6 +2,7 @@ import json import os import shutil from collections.abc import Callable +from contextlib import contextmanager from json import JSONDecodeError from pathlib import Path from threading import Event @@ -9,6 +10,11 @@ from typing import Any import tidalapi +from tidal_dl_ng.constants import ( + ATMOS_CLIENT_ID, + ATMOS_CLIENT_SECRET, + ATMOS_REQUEST_QUALITY, +) from tidal_dl_ng.helper.decorator import SingletonMeta from tidal_dl_ng.helper.path import path_config_base, path_file_settings, path_file_token from tidal_dl_ng.model.cfg import Settings as ModelSettings @@ -110,7 +116,7 @@ class Tidal(BaseConfig, metaclass=SingletonMeta): if settings: self.settings = settings - self.session.audio_quality = self.settings.data.quality_audio + self.session.audio_quality = tidalapi.Quality(self.settings.data.quality_audio) self.session.video_quality = tidalapi.VideoQuality.high return True @@ -156,6 +162,34 @@ class Tidal(BaseConfig, metaclass=SingletonMeta): self.set_option("expiry_time", self.session.expiry_time) self.save() + @contextmanager + def atmos_session_context(self): + + if not self.session.check_login(): + print("Not logged in.") + + original_client_id = self.session.config.client_id + original_client_secret = self.session.config.client_secret + original_audio_quality = self.session.audio_quality + + try: + self.session.config.client_id = ATMOS_CLIENT_ID + self.session.config.client_secret = ATMOS_CLIENT_SECRET + self.session.audio_quality = ATMOS_REQUEST_QUALITY + + if not self.login_token(do_pkce=self.is_pkce): + print("Warning: Session restore failed.") + + yield + + finally: + self.session.config.client_id = original_client_id + self.session.config.client_secret = original_client_secret + self.session.audio_quality = original_audio_quality + + if not self.login_token(do_pkce=self.is_pkce): + print("Warning: Restoring the original session context failed. Please restart the application.") + def login(self, fn_print: Callable) -> bool: is_token = self.login_token() result = False diff --git a/tidal_dl_ng/constants.py b/tidal_dl_ng/constants.py index f2f774b..79ae501 100644 --- a/tidal_dl_ng/constants.py +++ b/tidal_dl_ng/constants.py @@ -1,5 +1,8 @@ +import base64 from enum import StrEnum +from tidalapi import Quality + CTX_TIDAL: str = "tidal" REQUESTS_TIMEOUT_SEC: int = 45 EXTENSION_LYRICS: str = ".lrc" @@ -15,6 +18,14 @@ FILENAME_LENGTH_MAX: int = 255 FORMAT_TEMPLATE_EXPLICIT: str = " (Explicit)" METADATA_EXPLICIT: str = " 🅴" +# Dolby Atmos API credentials (obfuscated) +ATMOS_ID_B64 = "N203QX" + "AwSkM5aj" + "FjT00zbg==" +ATMOS_SECRET_B64 = "dlJBZEEx" + "MDh0bHZrSnB" + "Uc0daUzhyR1" + "o3eFRsYkow" + "cWFaMks5c2F" + "FenNnWT0=" + +ATMOS_CLIENT_ID = base64.b64decode(ATMOS_ID_B64).decode("utf-8") +ATMOS_CLIENT_SECRET = base64.b64decode(ATMOS_SECRET_B64).decode("utf-8") +ATMOS_REQUEST_QUALITY = Quality.low_320k + class QualityVideo(StrEnum): P360 = "360" diff --git a/tidal_dl_ng/dialog.py b/tidal_dl_ng/dialog.py index dcedf2e..0c55e95 100644 --- a/tidal_dl_ng/dialog.py +++ b/tidal_dl_ng/dialog.py @@ -176,6 +176,7 @@ class DialogPreferences(QtWidgets.QDialog): "lyrics_embed", "lyrics_file", "video_download", + "download_dolby_atmos", "download_delay", "video_convert_mp4", "extract_flac", diff --git a/tidal_dl_ng/download.py b/tidal_dl_ng/download.py index 1735793..7d855db 100644 --- a/tidal_dl_ng/download.py +++ b/tidal_dl_ng/download.py @@ -28,9 +28,17 @@ from requests.exceptions import HTTPError from rich.progress import Progress, TaskID from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video from tidalapi.exceptions import TooManyRequests -from tidalapi.media import AudioExtensions, Codec, Quality, Stream, StreamManifest, VideoExtensions +from tidalapi.media import ( + AudioExtensions, + AudioMode, + Codec, + Quality, + Stream, + StreamManifest, + VideoExtensions, +) -from tidal_dl_ng.config import Settings +from tidal_dl_ng.config import Settings, Tidal from tidal_dl_ng.constants import ( CHUNK_SIZE, COVER_NAME, @@ -99,6 +107,7 @@ class Download: """Main class for managing downloads, segment merging, file operations, and metadata for TIDAL media.""" settings: Settings + tidal: "Tidal" session: Session skip_existing: bool = False fn_logger: Callable @@ -110,7 +119,7 @@ class Download: def __init__( self, - session: Session, + tidal_obj: Tidal, # Required for Atmos session context manager path_base: str, fn_logger: Callable, skip_existing: bool = False, @@ -123,7 +132,9 @@ class Download: """Initialize the Download object and its dependencies. Args: - session (Session): TIDAL session object. + tidal_obj (Tidal): TIDAL configuration object. Required for: + - session: Main TIDAL API session + - atmos_session_context(): Dolby Atmos credential switching path_base (str): Base path for downloads. fn_logger (Callable): Logger function or object. skip_existing (bool, optional): Whether to skip existing files. Defaults to False. @@ -134,7 +145,8 @@ class Download: event_run (Event | None, optional): Run event. Defaults to None. """ self.settings = Settings() - self.session = session + self.tidal = tidal_obj + self.session = tidal_obj.session self.skip_existing = skip_existing self.fn_logger = fn_logger self.progress_gui = progress_gui @@ -783,8 +795,19 @@ class Download: if isinstance(media, Track): try: - media_stream = media.get_stream() + if ( + self.settings.data.download_dolby_atmos + and hasattr(media, "audio_modes") + and AudioMode.dolby_atmos.value in media.audio_modes + ): + with self.tidal.atmos_session_context(): + atmos_track = self.session.track(media.id) + media_stream = atmos_track.get_stream() + else: + media_stream = media.get_stream() + stream_manifest = media_stream.get_stream_manifest() + except TooManyRequests: self.fn_logger.exception( f"Too many requests against TIDAL backend. Skipping '{name_builder_item(media)}'. " diff --git a/tidal_dl_ng/gui.py b/tidal_dl_ng/gui.py index 9ffc444..9c3feb9 100644 --- a/tidal_dl_ng/gui.py +++ b/tidal_dl_ng/gui.py @@ -94,6 +94,7 @@ from ansi2html import Ansi2HTMLConverter from rich.progress import Progress from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video from tidalapi.artist import Artist +from tidalapi.media import AudioMode from tidalapi.session import SearchTypes from tidal_dl_ng.config import HandlingApp, Settings, Tidal @@ -254,7 +255,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): progress: Progress = Progress() handling_app: HandlingApp = HandlingApp() self.dl = Download( - session=self.tidal.session, + tidal_obj=self.tidal, skip_existing=self.tidal.settings.data.skip_existing, path_base=self.settings.data.download_base_path, fn_logger=logger_gui, @@ -887,6 +888,11 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): Returns: ResultItem: The constructed ResultItem. """ + + final_quality = quality_audio_highest(item) + if hasattr(item, "audio_modes") and AudioMode.dolby_atmos.value in item.audio_modes: + final_quality = f"{final_quality} / Dolby Atmos" + return ResultItem( position=idx, artist=name_builder_artist(item), @@ -894,7 +900,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): album=item.album.name, duration_sec=item.duration, obj=item, - quality=quality_audio_highest(item), + quality=final_quality, explicit=bool(item.explicit), date_user_added=date_user_added, date_release=date_release, @@ -1191,7 +1197,9 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow): Args: index: The index of the selected quality in the combo box. """ - self.settings.data.quality_audio = Quality(self.cb_quality_audio.itemData(index)) + quality_data = self.cb_quality_audio.itemData(index) + + self.settings.data.quality_audio = Quality(quality_data) self.settings.save() if self.tidal: diff --git a/tidal_dl_ng/model/cfg.py b/tidal_dl_ng/model/cfg.py index 44db92f..a376f2f 100644 --- a/tidal_dl_ng/model/cfg.py +++ b/tidal_dl_ng/model/cfg.py @@ -23,6 +23,7 @@ class Settings: download_base_path: str = "~/download" quality_audio: Quality = Quality.low_320k quality_video: QualityVideo = QualityVideo.P480 + download_dolby_atmos: bool = False format_album: str = ( "Albums/{album_artist} - {album_title}{album_explicit}/{track_volume_num_optional}" "{album_track_num}. {artist_name} - {track_title}{album_explicit}" @@ -77,6 +78,7 @@ class HelpSettings: '"HI_RES_LOSSLESS" (up to 24 Bit, 192 kHz)' ) quality_video: str = 'Desired video download quality: "360", "480", "720", "1080"' + download_dolby_atmos: str = "Download Dolby Atmos audio streams if available." # TODO: Describe possible variables. format_album: str = "Where to download albums and how to name the items." format_playlist: str = "Where to download playlists and how to name the items." diff --git a/tidal_dl_ng/ui/dialog_settings.py b/tidal_dl_ng/ui/dialog_settings.py index 9ec2738..f05adef 100644 --- a/tidal_dl_ng/ui/dialog_settings.py +++ b/tidal_dl_ng/ui/dialog_settings.py @@ -198,6 +198,23 @@ class Ui_DialogSettings: self.lv_flags.addLayout(self.horizontalLayout_12) + self.lh_flags_atmos = QHBoxLayout() + self.lh_flags_atmos.setObjectName("lh_flags_atmos") + self.lv_flag_download_dolby_atmos = QVBoxLayout() + self.lv_flag_download_dolby_atmos.setObjectName("lv_flag_download_dolby_atmos") + self.cb_download_dolby_atmos = QCheckBox(self.gb_flags) + self.cb_download_dolby_atmos.setObjectName("cb_download_dolby_atmos") + + self.lv_flag_download_dolby_atmos.addWidget(self.cb_download_dolby_atmos) + + self.lh_flags_atmos.addLayout(self.lv_flag_download_dolby_atmos) + + self.lv_flag_empty_atmos = QVBoxLayout() + self.lv_flag_empty_atmos.setObjectName("lv_flag_empty_atmos") + self.lh_flags_atmos.addLayout(self.lv_flag_empty_atmos) + + self.lv_flags.addLayout(self.lh_flags_atmos) + self.lv_main.addWidget(self.gb_flags) self.gb_choices = QGroupBox(DialogSettings)