Refactor Dolby Atmos Implementation

This commit is contained in:
Rikrdoga
2025-10-20 20:23:14 -06:00
parent 2c2cc0283a
commit cb32c2fa8b
6 changed files with 83 additions and 71 deletions
+37 -29
View File
@@ -1,19 +1,28 @@
import base64
import json import json
import os import os
import shutil import shutil
from collections.abc import Callable from collections.abc import Callable
from contextlib import contextmanager
from json import JSONDecodeError from json import JSONDecodeError
from pathlib import Path from pathlib import Path
from threading import Event from threading import Event
from typing import Any from typing import Any
import tidalapi import tidalapi
from tidalapi import Quality
from tidal_dl_ng.helper.decorator import SingletonMeta 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.helper.path import path_config_base, path_file_settings, path_file_token
from tidal_dl_ng.model.cfg import Settings as ModelSettings from tidal_dl_ng.model.cfg import Settings as ModelSettings
from tidal_dl_ng.model.cfg import Token as ModelToken from tidal_dl_ng.model.cfg import Token as ModelToken
_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 BaseConfig: class BaseConfig:
data: ModelSettings | ModelToken data: ModelSettings | ModelToken
@@ -89,7 +98,6 @@ class Settings(BaseConfig, metaclass=SingletonMeta):
class Tidal(BaseConfig, metaclass=SingletonMeta): class Tidal(BaseConfig, metaclass=SingletonMeta):
session: tidalapi.Session session: tidalapi.Session
atmos_session: tidalapi.Session = None
token_from_storage: bool = False token_from_storage: bool = False
settings: Settings settings: Settings
is_pkce: bool is_pkce: bool
@@ -111,12 +119,8 @@ class Tidal(BaseConfig, metaclass=SingletonMeta):
if settings: if settings:
self.settings = settings self.settings = settings
# Handle standard qualities and our special Dolby Atmos string self.session.audio_quality = tidalapi.Quality(self.settings.data.quality_audio)
if self.settings.data.quality_audio == "DOLBY_ATMOS": self.session.video_quality = tidalapi.VideoQuality.high
self.session.audio_quality = "DOLBY_ATMOS"
else:
self.session.audio_quality = tidalapi.Quality(self.settings.data.quality_audio)
self.session.video_quality = tidalapi.VideoQuality.high
return True return True
@@ -161,31 +165,35 @@ class Tidal(BaseConfig, metaclass=SingletonMeta):
self.set_option("expiry_time", self.session.expiry_time) self.set_option("expiry_time", self.session.expiry_time)
self.save() self.save()
def get_atmos_session(self) -> tidalapi.Session: @contextmanager
""" def atmos_session_context(self):
Returns the Atmos session. If it doesn't exist, it creates it
by logging in with the Fire TV client ID using the main session's token.
"""
if self.atmos_session is None:
# This config uses the Fire TV client ID needed for Dolby Atmos.
# It is handled internally and not exposed to the user.
atmos_config = tidalapi.Config(item_limit=10000)
atmos_config.client_id = "7m7Ap0JC9j1cOM3n"
atmos_config.client_secret = "vRAdA108tlvkJpTsGZS8rGZ7xTlbJ0qaZ2K9saEzsgY="
self.atmos_session = tidalapi.Session(atmos_config) if not self.session.check_login():
print("Not logged in. Cannot switch to Atmos context.")
raise ConnectionError("Not logged in. Cannot switch to Atmos context.")
# Try to log in using the token from the already logged-in main session. original_client_id = self.session.config.client_id
if not self.atmos_session.check_login(): original_client_secret = self.session.config.client_secret
self.atmos_session.load_oauth_session( original_audio_quality = self.session.audio_quality
self.data.token_type,
self.data.access_token, try:
self.data.refresh_token, self.session.config.client_id = _ATMOS_CLIENT_ID
self.data.expiry_time, self.session.config.client_secret = _ATMOS_CLIENT_SECRET
is_pkce=self.is_pkce, self.session.audio_quality = _ATMOS_REQUEST_QUALITY
)
if not self.login_token(do_pkce=self.is_pkce):
raise ConnectionError("Session re-authentication for Atmos 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.")
return self.atmos_session
def login(self, fn_print: Callable) -> bool: def login(self, fn_print: Callable) -> bool:
is_token = self.login_token() is_token = self.login_token()
+1
View File
@@ -176,6 +176,7 @@ class DialogPreferences(QtWidgets.QDialog):
"lyrics_embed", "lyrics_embed",
"lyrics_file", "lyrics_file",
"video_download", "video_download",
"download_dolby_atmos",
"download_delay", "download_delay",
"video_convert_mp4", "video_convert_mp4",
"extract_flac", "extract_flac",
+20 -26
View File
@@ -28,7 +28,15 @@ from requests.exceptions import HTTPError
from rich.progress import Progress, TaskID from rich.progress import Progress, TaskID
from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video from tidalapi import Album, Mix, Playlist, Session, Track, UserPlaylist, Video
from tidalapi.exceptions import TooManyRequests 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, Tidal from tidal_dl_ng.config import Settings, Tidal
from tidal_dl_ng.constants import ( from tidal_dl_ng.constants import (
@@ -99,7 +107,7 @@ class Download:
"""Main class for managing downloads, segment merging, file operations, and metadata for TIDAL media.""" """Main class for managing downloads, segment merging, file operations, and metadata for TIDAL media."""
settings: Settings settings: Settings
tidal: "Tidal" # Using a forward reference for type hinting tidal: "Tidal"
session: Session session: Session
skip_existing: bool = False skip_existing: bool = False
fn_logger: Callable fn_logger: Callable
@@ -111,7 +119,7 @@ class Download:
def __init__( def __init__(
self, self,
tidal_obj: "Tidal", tidal_obj: Tidal,
path_base: str, path_base: str,
fn_logger: Callable, fn_logger: Callable,
skip_existing: bool = False, skip_existing: bool = False,
@@ -125,6 +133,7 @@ class Download:
Args: Args:
session (Session): TIDAL session object. session (Session): TIDAL session object.
tidal_obj (Tidal): The TIDAL object (from config.py)
path_base (str): Base path for downloads. path_base (str): Base path for downloads.
fn_logger (Callable): Logger function or object. fn_logger (Callable): Logger function or object.
skip_existing (bool, optional): Whether to skip existing files. Defaults to False. skip_existing (bool, optional): Whether to skip existing files. Defaults to False.
@@ -785,31 +794,16 @@ class Download:
if isinstance(media, Track): if isinstance(media, Track):
try: try:
# Check which quality the user has selected if (
selected_quality = self.settings.data.quality_audio self.settings.data.download_dolby_atmos
and hasattr(media, "audio_modes")
if selected_quality == "DOLBY_ATMOS": and AudioMode.dolby_atmos.value in media.audio_modes
# Dolby Atmos requested: use the special Atmos session. ):
atmos_session = self.tidal.get_atmos_session() with self.tidal.atmos_session_context():
atmos_track = self.session.track(media.id)
# Store the original quality of the atmos_session to restore it later
original_quality = atmos_session.audio_quality
try:
# Temporarily set the session to request what Tidal considers "High" (320k AAC).
# This is the key to getting the Atmos stream with the Fire TV client ID.
atmos_session.audio_quality = Quality.low_320k
# Get the track object from the correct session
atmos_track = atmos_session.track(media.id)
# Call get_stream() WITHOUT arguments. It will use the quality from the session object.
media_stream = atmos_track.get_stream() media_stream = atmos_track.get_stream()
finally:
# IMPORTANT: Always restore the original quality on the atmos_session
atmos_session.audio_quality = original_quality
else: else:
# Standard behavior: get_stream uses the quality already set on the main session self.session.audio_quality = Quality(self.settings.data.quality_audio)
media_stream = media.get_stream() media_stream = media.get_stream()
stream_manifest = media_stream.get_stream_manifest() stream_manifest = media_stream.get_stream_manifest()
+6 -16
View File
@@ -93,6 +93,7 @@ import coloredlogs.converter
from rich.progress import Progress from rich.progress import Progress
from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video from tidalapi import Album, Mix, Playlist, Quality, Track, UserPlaylist, Video
from tidalapi.artist import Artist from tidalapi.artist import Artist
from tidalapi.media import AudioMode
from tidalapi.session import SearchTypes from tidalapi.session import SearchTypes
from tidal_dl_ng.config import HandlingApp, Settings, Tidal from tidal_dl_ng.config import HandlingApp, Settings, Tidal
@@ -315,12 +316,6 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
""" """
for item in options: for item in options:
ui_target.addItem(item.name, item) ui_target.addItem(item.name, item)
# Add our custom Dolby Atmos option to the audio quality dropdown
if ui_target == self.cb_quality_audio:
# Check if it already exists to be safe
if ui_target.findText("DOLBY_ATMOS") == -1:
ui_target.addItem("DOLBY_ATMOS", "DOLBY_ATMOS")
def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: Iterable[Any]) -> None: def _populate_search_types(self, ui_target: QtWidgets.QComboBox, options: Iterable[Any]) -> None:
"""Populate a combo box with search type options. """Populate a combo box with search type options.
@@ -893,8 +888,8 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
""" """
final_quality = quality_audio_highest(item) final_quality = quality_audio_highest(item)
if hasattr(item, "audio_modes") and "DOLBY_ATMOS" in item.audio_modes: if hasattr(item, "audio_modes") and AudioMode.dolby_atmos.value in item.audio_modes:
final_quality = "Dolby Atmos" final_quality = f"{final_quality} / Dolby Atmos"
return ResultItem( return ResultItem(
position=idx, position=idx,
@@ -1201,15 +1196,10 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
index: The index of the selected quality in the combo box. index: The index of the selected quality in the combo box.
""" """
quality_data = self.cb_quality_audio.itemData(index) quality_data = self.cb_quality_audio.itemData(index)
# Handle our custom string for Dolby Atmos self.settings.data.quality_audio = Quality(quality_data)
if isinstance(quality_data, str) and quality_data == "DOLBY_ATMOS":
self.settings.data.quality_audio = "DOLBY_ATMOS"
else:
self.settings.data.quality_audio = Quality(quality_data)
self.settings.save() self.settings.save()
if self.tidal: if self.tidal:
self.tidal.settings_apply() self.tidal.settings_apply()
+2
View File
@@ -23,6 +23,7 @@ class Settings:
download_base_path: str = "~/download" download_base_path: str = "~/download"
quality_audio: Quality = Quality.low_320k quality_audio: Quality = Quality.low_320k
quality_video: QualityVideo = QualityVideo.P480 quality_video: QualityVideo = QualityVideo.P480
download_dolby_atmos: bool = False
format_album: str = ( format_album: str = (
"Albums/{album_artist} - {album_title}{album_explicit}/{track_volume_num_optional}" "Albums/{album_artist} - {album_title}{album_explicit}/{track_volume_num_optional}"
"{album_track_num}. {artist_name} - {track_title}{album_explicit}" "{album_track_num}. {artist_name} - {track_title}{album_explicit}"
@@ -79,6 +80,7 @@ class HelpSettings:
'"HI_RES_LOSSLESS" (up to 24 Bit, 192 kHz)' '"HI_RES_LOSSLESS" (up to 24 Bit, 192 kHz)'
) )
quality_video: str = 'Desired video download quality: "360", "480", "720", "1080"' 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. # TODO: Describe possible variables.
format_album: str = "Where to download albums and how to name the items." 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." format_playlist: str = "Where to download playlists and how to name the items."
+17
View File
@@ -198,6 +198,23 @@ class Ui_DialogSettings:
self.lv_flags.addLayout(self.horizontalLayout_12) 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.lv_main.addWidget(self.gb_flags)
self.gb_choices = QGroupBox(DialogSettings) self.gb_choices = QGroupBox(DialogSettings)