Refactor Dolby Atmos Implementation
This commit is contained in:
+37
-29
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
+4
-14
@@ -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
|
||||||
@@ -316,12 +317,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,
|
||||||
@@ -1202,12 +1197,7 @@ class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
|
|||||||
"""
|
"""
|
||||||
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:
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user