Retry Tidal download at lower quality when hi-res returns garbage file

This commit is contained in:
Broque Thomas 2026-03-14 10:54:33 -07:00
parent a512d6ae70
commit c3eecc88ad

View file

@ -439,36 +439,35 @@ class TidalDownloadClient:
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless']) quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
# Try quality fallback chain: hires → lossless → high → low # Try quality fallback chain: hires → lossless → high → low
# The entire download+validation is inside the loop so that garbage
# files (stubs, empty HiRes responses) trigger a retry at the next tier.
quality_chain = ['hires', 'lossless', 'high', 'low'] quality_chain = ['hires', 'lossless', 'high', 'low']
start_idx = quality_chain.index(quality_key) if quality_key in quality_chain else 1 start_idx = quality_chain.index(quality_key) if quality_key in quality_chain else 1
chain = quality_chain[start_idx:] chain = quality_chain[start_idx:]
stream = None MIN_AUDIO_SIZE = 100 * 1024 # 100KB
actual_quality = None
for q_key in chain: for q_key in chain:
q_info = QUALITY_MAP[q_key] q_info = QUALITY_MAP[q_key]
# --- Step 1: Get stream ---
try: try:
# Set session quality before requesting stream
self.session.audio_quality = q_info['tidal_quality'] self.session.audio_quality = q_info['tidal_quality']
stream = track.get_stream() stream = track.get_stream()
if stream and stream.manifest_mime_type: if not stream or not stream.manifest_mime_type:
actual_quality = q_info logger.warning(f"Quality {q_key} returned no stream, trying next")
continue
logger.info(f"Got Tidal stream at quality: {q_key}") logger.info(f"Got Tidal stream at quality: {q_key}")
break
except Exception as e: except Exception as e:
logger.warning(f"Quality {q_key} unavailable: {e}") logger.warning(f"Quality {q_key} unavailable: {e}")
continue continue
if not stream: # --- Step 2: Parse manifest ---
logger.error("No Tidal stream available at any quality")
return None
# Parse manifest to get download URL
manifest = stream.get_stream_manifest() manifest = stream.get_stream_manifest()
urls = manifest.get_urls() urls = manifest.get_urls()
if not urls: if not urls:
logger.error("No download URLs in Tidal stream manifest") logger.warning(f"No download URLs for quality {q_key}, trying next")
return None continue
download_url = urls[0] download_url = urls[0]
@ -481,10 +480,9 @@ class TidalDownloadClient:
elif codec and 'alac' in codec.lower(): elif codec and 'alac' in codec.lower():
extension = 'm4a' extension = 'm4a'
else: else:
# Default based on quality extension = q_info.get('extension', 'flac')
extension = actual_quality.get('extension', 'flac') if actual_quality else 'flac'
# Build output filename: "Artist - Title.ext" # Build output filename
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name) safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
out_filename = f"{safe_name}.{extension}" out_filename = f"{safe_name}.{extension}"
out_path = self.download_path / out_filename out_path = self.download_path / out_filename
@ -494,8 +492,9 @@ class TidalDownloadClient:
logger.info("Server shutting down, aborting Tidal download") logger.info("Server shutting down, aborting Tidal download")
return None return None
# Download with progress tracking # --- Step 3: Download ---
logger.info(f"Downloading from Tidal: {out_filename}") try:
logger.info(f"Downloading from Tidal ({q_key}): {out_filename}")
response = http_requests.get(download_url, stream=True, timeout=120) response = http_requests.get(download_url, stream=True, timeout=120)
response.raise_for_status() response.raise_for_status()
@ -512,7 +511,6 @@ class TidalDownloadClient:
if not chunk: if not chunk:
continue continue
# Check for shutdown
if self.shutdown_check and self.shutdown_check(): if self.shutdown_check and self.shutdown_check():
logger.info("Server shutting down, aborting Tidal download mid-stream") logger.info("Server shutting down, aborting Tidal download mid-stream")
f.close() f.close()
@ -522,7 +520,6 @@ class TidalDownloadClient:
f.write(chunk) f.write(chunk)
downloaded += len(chunk) downloaded += len(chunk)
# Update progress
if total_size > 0: if total_size > 0:
progress = (downloaded / total_size) * 100 progress = (downloaded / total_size) * 100
else: else:
@ -533,40 +530,51 @@ class TidalDownloadClient:
self.active_downloads[download_id]['transferred'] = downloaded self.active_downloads[download_id]['transferred'] = downloaded
self.active_downloads[download_id]['progress'] = round(progress, 1) self.active_downloads[download_id]['progress'] = round(progress, 1)
# Validate download produced a real audio file (not a stub/preview) except Exception as dl_err:
# A valid audio file should be at least 100KB — anything less is likely logger.warning(f"Download failed at quality {q_key}: {dl_err}")
# a DRM stub, preview clip, or empty response from Tidal. out_path.unlink(missing_ok=True)
MIN_AUDIO_SIZE = 100 * 1024 # 100KB continue
# --- Step 4: Validate ---
if downloaded < MIN_AUDIO_SIZE: if downloaded < MIN_AUDIO_SIZE:
logger.error( logger.warning(
f"Tidal download too small ({downloaded} bytes) — likely a stub or " f"Tidal download too small at {q_key} ({downloaded} bytes) — "
f"preview. Expected audio file for '{display_name}'. Deleting." f"likely a stub/preview for '{display_name}'. Trying next quality."
) )
out_path.unlink(missing_ok=True) out_path.unlink(missing_ok=True)
return None continue
# HiRes FLAC in MP4 container: extract raw FLAC with FFmpeg if available # HiRes FLAC in MP4 container: extract raw FLAC with FFmpeg
if extension == 'flac' and self._is_mp4_container(out_path): if extension == 'flac' and self._is_mp4_container(out_path):
extracted = self._extract_flac_from_mp4(out_path) extracted = self._extract_flac_from_mp4(out_path)
if extracted: if extracted:
out_path = Path(extracted) out_path = Path(extracted)
else: else:
# FFmpeg extraction failed — the MP4 container is not playable as-is. logger.warning(
# Delete it rather than leaving an unplayable file. f"Cannot extract FLAC from MP4 container at {q_key}"
logger.error(f"Cannot extract FLAC from MP4 container and file is not playable — deleting {out_path.name}") f"deleting and trying next quality"
)
out_path.unlink(missing_ok=True) out_path.unlink(missing_ok=True)
return None continue
# Final size check after any extraction # Final size check after any extraction
final_size = out_path.stat().st_size if out_path.exists() else 0 final_size = out_path.stat().st_size if out_path.exists() else 0
if final_size < MIN_AUDIO_SIZE: if final_size < MIN_AUDIO_SIZE:
logger.error(f"Final file too small after processing ({final_size} bytes) — deleting {out_path.name}") logger.warning(
f"Final file too small after processing at {q_key} "
f"({final_size} bytes) — trying next quality"
)
out_path.unlink(missing_ok=True) out_path.unlink(missing_ok=True)
return None continue
logger.info(f"Tidal download complete: {out_path} ({final_size / (1024*1024):.1f} MB)") # Success — file is valid
logger.info(f"Tidal download complete ({q_key}): {out_path} ({final_size / (1024*1024):.1f} MB)")
return str(out_path) return str(out_path)
# All quality tiers exhausted
logger.error(f"No Tidal quality tier produced a valid download for '{display_name}'")
return None
except Exception as e: except Exception as e:
logger.error(f"Tidal download failed: {e}") logger.error(f"Tidal download failed: {e}")
import traceback import traceback