Merge pull request #626 from Rikrdoga:Dolby_Atmos

Fix(Atmos): Resolve Race Condition and Optimize Session Switching for Atmos Downloads
This commit is contained in:
exislow
2025-11-13 08:32:26 +01:00
committed by GitHub
2 changed files with 177 additions and 49 deletions
+67 -23
View File
@@ -2,10 +2,9 @@ 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, Lock
from typing import Any from typing import Any
import tidalapi import tidalapi
@@ -103,6 +102,19 @@ class Tidal(BaseConfig, metaclass=SingletonMeta):
self.cls_model = ModelToken self.cls_model = ModelToken
tidal_config: tidalapi.Config = tidalapi.Config(item_limit=10000) tidal_config: tidalapi.Config = tidalapi.Config(item_limit=10000)
self.session = tidalapi.Session(tidal_config) self.session = tidalapi.Session(tidal_config)
self.original_client_id = self.session.config.client_id
self.original_client_secret = self.session.config.client_secret
# Lock to ensure session-switching is thread-safe.
# This lock protects against a race condition where one thread
# changes the session credentials while another is using them.
# It is intentionally held by Download._get_stream_info
# for the *entire* duration of the credential switch AND
# the get_stream() call.
self.stream_lock = Lock()
# State-tracking flag to prevent redundant, expensive
# session re-authentication when the session is already in the
# correct mode (Atmos or Normal).
self.is_atmos_session = False
# self.session.config.client_id = "km8T1xS355y7dd3H" # self.session.config.client_id = "km8T1xS355y7dd3H"
# self.session.config.client_secret = "vcmeGW1OuZ0fWYMCSZ6vNvSLJlT3XEpW0ambgYt5ZuI=" # self.session.config.client_secret = "vcmeGW1OuZ0fWYMCSZ6vNvSLJlT3XEpW0ambgYt5ZuI="
self.file_path = path_file_token() self.file_path = path_file_token()
@@ -116,7 +128,8 @@ class Tidal(BaseConfig, metaclass=SingletonMeta):
if settings: if settings:
self.settings = settings self.settings = settings
self.session.audio_quality = tidalapi.Quality(self.settings.data.quality_audio) if not self.is_atmos_session:
self.session.audio_quality = tidalapi.Quality(self.settings.data.quality_audio)
self.session.video_quality = tidalapi.VideoQuality.high self.session.video_quality = tidalapi.VideoQuality.high
return True return True
@@ -162,33 +175,64 @@ 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()
@contextmanager def switch_to_atmos_session(self) -> bool:
def atmos_session_context(self): """
Switches the shared session to Dolby Atmos credentials.
Only re-authenticates if not already in Atmos mode.
if not self.session.check_login(): Returns:
print("Not logged in.") bool: True if successful or already in Atmos mode, False otherwise.
"""
# If we are already in Atmos mode, do nothing.
if self.is_atmos_session:
return True
original_client_id = self.session.config.client_id print("Switching session context to Dolby Atmos...")
original_client_secret = self.session.config.client_secret self.session.config.client_id = ATMOS_CLIENT_ID
original_audio_quality = self.session.audio_quality self.session.config.client_secret = ATMOS_CLIENT_SECRET
self.session.audio_quality = ATMOS_REQUEST_QUALITY
try: # Re-login with new credentials
self.session.config.client_id = ATMOS_CLIENT_ID if not self.login_token(do_pkce=self.is_pkce):
self.session.config.client_secret = ATMOS_CLIENT_SECRET print("Warning: Atmos session authentication failed.")
self.session.audio_quality = ATMOS_REQUEST_QUALITY # Try to switch back to normal to be safe
self.restore_normal_session(force=True)
return False
if not self.login_token(do_pkce=self.is_pkce): self.is_atmos_session = True # Set the flag
print("Warning: Session restore failed.") print("Session is now in Atmos mode.")
return True
yield def restore_normal_session(self, force: bool = False) -> bool:
"""
Restores the shared session to the original user credentials.
Only re-authenticates if not already in Normal mode.
finally: Args:
self.session.config.client_id = original_client_id force: If True, forces restoration even if already in Normal mode.
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): Returns:
print("Warning: Restoring the original session context failed. Please restart the application.") bool: True if successful or already in Normal mode, False otherwise.
"""
# If we are already in Normal mode (and not forced), do nothing.
if not self.is_atmos_session and not force:
return True
print("Restoring session context to Normal...")
self.session.config.client_id = self.original_client_id
self.session.config.client_secret = self.original_client_secret
# Explicitly restore audio quality to user's configured setting
self.session.audio_quality = tidalapi.Quality(self.settings.data.quality_audio)
# Re-login with original credentials
if not self.login_token(do_pkce=self.is_pkce):
print("Warning: Restoring the original session context failed. Please restart the application.")
return False
self.is_atmos_session = False # Set the flag
print("Session is now in Normal mode.")
return True
def login(self, fn_print: Callable) -> bool: def login(self, fn_print: Callable) -> bool:
is_token = self.login_token() is_token = self.login_token()
+110 -26
View File
@@ -16,6 +16,7 @@ import tempfile
import time import time
from collections.abc import Callable from collections.abc import Callable
from concurrent import futures from concurrent import futures
from dataclasses import dataclass
from threading import Event from threading import Event
from uuid import uuid4 from uuid import uuid4
@@ -75,6 +76,16 @@ from tidal_dl_ng.model.downloader import DownloadSegmentResult
from tidal_dl_ng.model.gui_data import ProgressBars from tidal_dl_ng.model.gui_data import ProgressBars
@dataclass
class TrackStreamInfo:
"""Container for track stream information."""
stream_manifest: StreamManifest | None
file_extension: str
requires_flac_extraction: bool
media_stream: Stream | None
# TODO: Set appropriate client string and use it for video download. # TODO: Set appropriate client string and use it for video download.
# https://github.com/globocom/m3u8#using-different-http-clients # https://github.com/globocom/m3u8#using-different-http-clients
class RequestsClient: class RequestsClient:
@@ -134,7 +145,8 @@ class Download:
Args: Args:
tidal_obj (Tidal): TIDAL configuration object. Required for: tidal_obj (Tidal): TIDAL configuration object. Required for:
- session: Main TIDAL API session - session: Main TIDAL API session
- atmos_session_context(): Dolby Atmos credential switching - switch_to_atmos_session(): Dolby Atmos credential switching
- restore_normal_session(): Restore original session credentials
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.
@@ -789,51 +801,123 @@ class Download:
media (Track | Video): Media item. media (Track | Video): Media item.
Returns: Returns:
tuple[StreamManifest | None, str, bool, Stream | None]: Stream info. tuple[StreamManifest | None, str, bool, Stream | None]: Stream info.
""" """
stream_manifest: StreamManifest | None = None stream_manifest: StreamManifest | None = None
media_stream: Stream | None = None media_stream: Stream | None = None
do_flac_extract: bool = False do_flac_extract: bool = False
file_extension: str = ""
if isinstance(media, Track): # CRITICAL: This lock is intentionally broad and serializes all
# stream-fetching (Phase 1) to prevent a critical race condition.
#
# THE PROBLEM:
# The single, shared session (self.tidal.session) must change its
# credentials to switch between Atmos and Hi-Res/Normal streams.
#
# THE RACE CONDITION IT FIXES:
# If this lock is released *before* get_stream() is called,
# another thread could change the session (e.g., back to "Normal")
# right after this thread switched it to "Atmos". This would
# cause this thread to call get_stream() with the wrong credentials,
# resulting in the API returning AAC 320 instead of Atmos.
#
# THE TRADEOFF:
# This creates a "tollbooth" bottleneck, serializing the get_stream()
# calls. However, the *actual* segment downloads (Phase 2)
# still run in parallel, governed by `downloads_concurrent_max`.
#
# DO NOT "OPTIMIZE" THIS by making the lock more granular.
# Correctness > Performance.
with self.tidal.stream_lock:
try: try:
if ( if isinstance(media, Track):
self.settings.data.download_dolby_atmos track_info = self._get_track_stream_info(media)
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() if track_info.stream_manifest is None:
return None, "", False, None
stream_manifest = track_info.stream_manifest
file_extension = track_info.file_extension
do_flac_extract = track_info.requires_flac_extraction
media_stream = track_info.media_stream
elif isinstance(media, Video):
# Videos always require the normal session
if not self.tidal.restore_normal_session():
self.fn_logger.error(f"Failed to restore normal session for video: {media.id}")
return None, "", False, None
file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS
stream_manifest = None
media_stream = None
do_flac_extract = False
else:
self.fn_logger.error(f"Unknown media type for stream info: {type(media)}")
return None, "", False, None
except TooManyRequests: except TooManyRequests:
self.fn_logger.exception( self.fn_logger.exception(
f"Too many requests against TIDAL backend. Skipping '{name_builder_item(media)}'. " f"Too many requests against TIDAL backend. Skipping '{name_builder_item(media)}'. "
f"Consider to activate delay between downloads." f"Consider to activate delay between downloads."
) )
return None, "", False, None return None, "", False, None
except Exception: except Exception:
self.fn_logger.exception(f"Something went wrong. Skipping '{name_builder_item(media)}'.") self.fn_logger.exception(f"Something went wrong. Skipping '{name_builder_item(media)}'.")
return None, "", False, None return None, "", False, None
file_extension = stream_manifest.file_extension
if self.settings.data.extract_flac and (
stream_manifest.codecs.upper() == Codec.FLAC and file_extension != AudioExtensions.FLAC
):
file_extension = AudioExtensions.FLAC
do_flac_extract = True
elif isinstance(media, Video):
file_extension = AudioExtensions.MP4 if self.settings.data.video_convert_mp4 else VideoExtensions.TS
return stream_manifest, file_extension, do_flac_extract, media_stream return stream_manifest, file_extension, do_flac_extract, media_stream
def _get_track_stream_info(self, media: Track) -> TrackStreamInfo:
"""
Gets stream info for a Track, handling Atmos/Normal session switching.
Args:
media: The track to get stream information for.
Returns:
TrackStreamInfo: Container with stream manifest, file extension,
FLAC extraction flag, and media stream object.
Returns TrackStreamInfo with None/empty values if fails.
"""
want_atmos = (
self.settings.data.download_dolby_atmos
and hasattr(media, "audio_modes")
and AudioMode.dolby_atmos.value in media.audio_modes
)
if want_atmos:
if not self.tidal.switch_to_atmos_session():
self.fn_logger.error(f"Failed to switch to Atmos session for track: {media.id}")
return TrackStreamInfo(None, "", False, None)
else:
if not self.tidal.restore_normal_session():
self.fn_logger.error(f"Failed to restore normal session for track: {media.id}")
return TrackStreamInfo(None, "", False, None)
media_stream = self.session.track(media.id).get_stream() if want_atmos else media.get_stream()
stream_manifest = media_stream.get_stream_manifest()
file_extension = stream_manifest.file_extension
requires_flac_extraction = False
if self.settings.data.extract_flac and (
stream_manifest.codecs.upper() == Codec.FLAC and file_extension != AudioExtensions.FLAC
):
file_extension = AudioExtensions.FLAC
requires_flac_extraction = True
return TrackStreamInfo(
stream_manifest=stream_manifest,
file_extension=file_extension,
requires_flac_extraction=requires_flac_extraction,
media_stream=media_stream,
)
def _perform_actual_download( def _perform_actual_download(
self, self,
media: Track | Video, media: Track | Video,