Add Tidal as a download source with full pipeline integration

Adds Tidal as a third download source alongside Soulseek and YouTube. Uses the tidalapi library with device-flow authentication to search and download tracks in configurable quality (Low/High/Lossless/HiRes) with automatic fallback. Integrates into the download orchestrator for all modes (Tidal Only, Hybrid with fallback chain), the transfer monitor, post-processing pipeline, and file finder. Frontend includes download settings with quality selector, device auth flow, and dynamic sidebar/dashboard labels that reflect the active download source. No breaking changes for existing users.
This commit is contained in:
Broque Thomas 2026-02-28 21:47:19 -08:00
parent 49c769ff5c
commit acfc26a4bd
7 changed files with 1064 additions and 135 deletions

View file

@ -188,10 +188,19 @@ class ConfigManager:
"transfer_path": "./Transfer"
},
"download_source": {
"mode": "soulseek", # Options: "soulseek", "youtube", "hybrid"
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "hybrid"
"hybrid_primary": "soulseek", # Which source to try first in hybrid mode
"youtube_min_confidence": 0.65 # Minimum confidence for YouTube matches
},
"tidal_download": {
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
"session": {
"token_type": "",
"access_token": "",
"refresh_token": "",
"expiry_time": 0
}
},
"listenbrainz": {
"token": ""
},

View file

@ -1,10 +1,11 @@
"""
Download Orchestrator
Routes downloads between Soulseek and YouTube based on configuration.
Routes downloads between Soulseek, YouTube, and Tidal based on configuration.
Supports three modes:
Supports four modes:
- Soulseek Only: Traditional behavior
- YouTube Only: YouTube-exclusive downloads
- Tidal Only: Tidal-exclusive downloads
- Hybrid: Try primary source first, fallback to secondary if it fails
"""
@ -16,6 +17,7 @@ from utils.logging_config import get_logger
from config.settings import config_manager
from core.soulseek_client import SoulseekClient, TrackResult, AlbumResult, DownloadStatus
from core.youtube_client import YouTubeClient
from core.tidal_download_client import TidalDownloadClient
logger = get_logger("download_orchestrator")
@ -29,9 +31,10 @@ class DownloadOrchestrator:
"""
def __init__(self):
"""Initialize orchestrator with both clients"""
"""Initialize orchestrator with all clients"""
self.soulseek = SoulseekClient()
self.youtube = YouTubeClient()
self.tidal = TidalDownloadClient()
# Load mode from config
self.mode = config_manager.get('download_source.mode', 'soulseek')
@ -64,9 +67,11 @@ class DownloadOrchestrator:
return self.soulseek.is_configured()
elif self.mode == 'youtube':
return self.youtube.is_configured()
elif self.mode == 'tidal':
return self.tidal.is_configured()
elif self.mode == 'hybrid':
# In hybrid mode, at least one source must be configured
return self.soulseek.is_configured() or self.youtube.is_configured()
return self.soulseek.is_configured() or self.youtube.is_configured() or self.tidal.is_configured()
return False
@ -80,15 +85,18 @@ class DownloadOrchestrator:
return await self.soulseek.check_connection()
elif self.mode == 'youtube':
return await self.youtube.check_connection()
elif self.mode == 'tidal':
return await self.tidal.check_connection()
elif self.mode == 'hybrid':
# In hybrid mode, check both sources
# In hybrid mode, check all sources
soulseek_ok = await self.soulseek.check_connection()
youtube_ok = await self.youtube.check_connection()
tidal_ok = await self.tidal.check_connection()
logger.info(f" Soulseek: {'' if soulseek_ok else ''} | YouTube: {'' if youtube_ok else ''}")
logger.info(f" Soulseek: {'' if soulseek_ok else ''} | YouTube: {'' if youtube_ok else ''} | Tidal: {'' if tidal_ok else ''}")
# At least one must be available
return soulseek_ok or youtube_ok
return soulseek_ok or youtube_ok or tidal_ok
return False
@ -112,33 +120,38 @@ class DownloadOrchestrator:
logger.info(f"🔍 Searching YouTube: {query}")
return await self.youtube.search(query, timeout, progress_callback)
elif self.mode == 'tidal':
logger.info(f"🔍 Searching Tidal: {query}")
return await self.tidal.search(query, timeout, progress_callback)
elif self.mode == 'hybrid':
# Build ordered client list: primary first, then remaining as fallbacks
clients = {
'soulseek': self.soulseek,
'youtube': self.youtube,
'tidal': self.tidal,
}
primary = self.hybrid_primary if self.hybrid_primary in clients else 'soulseek'
fallbacks = [name for name in clients if name != primary]
# Try primary source first
if self.hybrid_primary == 'soulseek':
logger.info(f"🔍 Hybrid search - trying Soulseek first: {query}")
tracks, albums = await self.soulseek.search(query, timeout, progress_callback)
logger.info(f"🔍 Hybrid search - trying {primary} first: {query}")
tracks, albums = await clients[primary].search(query, timeout, progress_callback)
if tracks:
logger.info(f"{primary} found {len(tracks)} tracks")
return (tracks, albums)
# If Soulseek found good results, return them
# Try fallbacks in order
for fallback_name in fallbacks:
logger.info(f"🔄 {primary} found nothing, trying {fallback_name} fallback")
tracks, albums = await clients[fallback_name].search(query, timeout, progress_callback)
if tracks:
logger.info(f"✅ Soulseek found {len(tracks)} tracks")
logger.info(f"{fallback_name} found {len(tracks)} tracks")
return (tracks, albums)
# Otherwise, try YouTube as fallback
logger.info(f"🔄 Soulseek found nothing, trying YouTube fallback")
return await self.youtube.search(query, timeout, progress_callback)
else: # YouTube first
logger.info(f"🔍 Hybrid search - trying YouTube first: {query}")
tracks, albums = await self.youtube.search(query, timeout, progress_callback)
# If YouTube found good results, return them
if tracks:
logger.info(f"✅ YouTube found {len(tracks)} tracks")
return (tracks, albums)
# Otherwise, try Soulseek as fallback
logger.info(f"🔄 YouTube found nothing, trying Soulseek fallback")
return await self.soulseek.search(query, timeout, progress_callback)
# Nothing found from any source
logger.warning(f"❌ Hybrid search exhausted all sources for: {query}")
return ([], [])
# Fallback: empty results
return ([], [])
@ -203,6 +216,9 @@ class DownloadOrchestrator:
if username == 'youtube':
logger.info(f"📥 Downloading from YouTube: {filename}")
return await self.youtube.download(username, filename, file_size)
elif username == 'tidal':
logger.info(f"📥 Downloading from Tidal: {filename}")
return await self.tidal.download(username, filename, file_size)
else:
logger.info(f"📥 Downloading from Soulseek: {filename}")
return await self.soulseek.download(username, filename, file_size)
@ -214,12 +230,13 @@ class DownloadOrchestrator:
Returns:
List of DownloadStatus objects
"""
# Get downloads from both sources
# Get downloads from all sources
soulseek_downloads = await self.soulseek.get_all_downloads()
youtube_downloads = await self.youtube.get_all_downloads()
tidal_downloads = await self.tidal.get_all_downloads()
# Combine and return
return soulseek_downloads + youtube_downloads
return soulseek_downloads + youtube_downloads + tidal_downloads
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
"""
@ -241,6 +258,11 @@ class DownloadOrchestrator:
if status:
return status
# Try Tidal
status = await self.tidal.get_download_status(download_id)
if status:
return status
return None
async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool:
@ -258,16 +280,22 @@ class DownloadOrchestrator:
# If username is provided, route directly
if username == 'youtube':
return await self.youtube.cancel_download(download_id, username, remove)
elif username == 'tidal':
return await self.tidal.cancel_download(download_id, username, remove)
elif username:
return await self.soulseek.cancel_download(download_id, username, remove)
# Otherwise, try both sources
# Otherwise, try all sources
soulseek_cancelled = await self.soulseek.cancel_download(download_id, username, remove)
if soulseek_cancelled:
return True
youtube_cancelled = await self.youtube.cancel_download(download_id, username, remove)
return youtube_cancelled
if youtube_cancelled:
return True
tidal_cancelled = await self.tidal.cancel_download(download_id, username, remove)
return tidal_cancelled
async def signal_download_completion(self, download_id: str, username: str, remove: bool = True) -> bool:
"""
@ -292,10 +320,11 @@ class DownloadOrchestrator:
True if successful
"""
soulseek_cleared = await self.soulseek.clear_all_completed_downloads()
# YouTube downloads must also be cleared from memory
# YouTube and Tidal downloads must also be cleared from memory
youtube_cleared = await self.youtube.clear_all_completed_downloads()
tidal_cleared = await self.tidal.clear_all_completed_downloads()
return soulseek_cleared and youtube_cleared
return soulseek_cleared and youtube_cleared and tidal_cleared
# ===== Soulseek-specific methods (for backwards compatibility) =====
# These are internal methods that some parts of the codebase use directly
@ -353,5 +382,8 @@ class DownloadOrchestrator:
return await self.soulseek.maintain_search_history_with_buffer(keep_searches, trigger_threshold)
async def cancel_all_downloads(self) -> bool:
"""Cancel and remove all downloads from slskd."""
return await self.soulseek.cancel_all_downloads()
"""Cancel and remove all downloads from all sources."""
soulseek_ok = await self.soulseek.cancel_all_downloads()
# Clear Tidal active downloads too
tidal_ok = await self.tidal.clear_all_completed_downloads()
return soulseek_ok

View file

@ -0,0 +1,676 @@
"""
Tidal Download Client
Alternative music download source using tidalapi.
This client provides:
- Tidal search with metadata
- Device flow authentication (link.tidal.com)
- HiRes/Lossless/High quality audio downloads
- Drop-in replacement compatible with Soulseek interface
"""
import os
import re
import asyncio
import uuid
import threading
import shutil
import subprocess
from typing import List, Optional, Dict, Any, Tuple
from pathlib import Path
from datetime import datetime, timezone
try:
import tidalapi
except ImportError:
tidalapi = None
import requests as http_requests
from utils.logging_config import get_logger
from config.settings import config_manager
# Import Soulseek data structures for drop-in replacement compatibility
from core.soulseek_client import TrackResult, AlbumResult, DownloadStatus
logger = get_logger("tidal_download_client")
# Quality tier definitions
QUALITY_MAP = {
'low': {
'tidal_quality': 'LOW' if tidalapi is None else None, # set dynamically
'label': 'AAC 96kbps',
'extension': 'm4a',
'bitrate': 96,
'codec': 'aac',
},
'high': {
'tidal_quality': 'HIGH' if tidalapi is None else None,
'label': 'AAC 320kbps',
'extension': 'm4a',
'bitrate': 320,
'codec': 'aac',
},
'lossless': {
'tidal_quality': 'LOSSLESS' if tidalapi is None else None,
'label': 'FLAC 16-bit/44.1kHz',
'extension': 'flac',
'bitrate': 1411,
'codec': 'flac',
},
'hires': {
'tidal_quality': 'HI_RES_LOSSLESS' if tidalapi is None else None,
'label': 'FLAC 24-bit/96kHz',
'extension': 'flac',
'bitrate': 9216,
'codec': 'flac',
},
}
# Initialize quality map with actual tidalapi constants if available
if tidalapi is not None:
QUALITY_MAP['low']['tidal_quality'] = tidalapi.Quality.low_96k
QUALITY_MAP['high']['tidal_quality'] = tidalapi.Quality.low_320k
QUALITY_MAP['lossless']['tidal_quality'] = tidalapi.Quality.high_lossless
QUALITY_MAP['hires']['tidal_quality'] = tidalapi.Quality.hi_res_lossless
class TidalDownloadClient:
"""
Tidal download client using tidalapi.
Provides search, matching, and download capabilities as a drop-in alternative to YouTube/Soulseek.
"""
def __init__(self, download_path: str = None):
if tidalapi is None:
logger.warning("tidalapi not installed — Tidal downloads unavailable")
# Use Soulseek download path for consistency (post-processing expects files here)
if download_path is None:
download_path = config_manager.get('soulseek.download_path', './downloads')
self.download_path = Path(download_path)
self.download_path.mkdir(parents=True, exist_ok=True)
logger.info(f"Tidal download client using download path: {self.download_path}")
# Callback for shutdown check (avoids circular imports)
self.shutdown_check = None
# tidalapi session
self.session: Optional['tidalapi.Session'] = None
self._init_session()
# Download queue management (mirrors YouTube's download tracking)
self.active_downloads: Dict[str, Dict[str, Any]] = {}
self._download_lock = threading.Lock()
# Device auth state
self._device_auth_future = None
self._device_auth_link = None
def set_shutdown_check(self, check_callable):
"""Set a callback function to check for system shutdown"""
self.shutdown_check = check_callable
# ===================== Auth =====================
def _init_session(self):
"""Create a tidalapi session and try to restore saved tokens."""
if tidalapi is None:
return
self.session = tidalapi.Session()
# Try to restore saved session
saved = config_manager.get('tidal_download.session', {})
token_type = saved.get('token_type', '')
access_token = saved.get('access_token', '')
refresh_token = saved.get('refresh_token', '')
expiry_time = saved.get('expiry_time', 0)
if token_type and access_token:
try:
# Convert stored float timestamp back to datetime for tidalapi
expiry_dt = datetime.fromtimestamp(expiry_time, tz=timezone.utc) if expiry_time else None
# tidalapi's load_oauth_session restores from saved tokens
restored = self.session.load_oauth_session(
token_type=token_type,
access_token=access_token,
refresh_token=refresh_token,
expiry_time=expiry_dt,
)
if restored and self.session.check_login():
logger.info("Restored Tidal download session from saved tokens")
self._save_session() # refresh may have rotated tokens
return
else:
logger.warning("Saved Tidal session tokens are invalid/expired")
except Exception as e:
logger.warning(f"Could not restore Tidal session: {e}")
def _save_session(self):
"""Persist session tokens to config."""
if not self.session:
return
config_manager.set('tidal_download.session', {
'token_type': self.session.token_type or '',
'access_token': self.session.access_token or '',
'refresh_token': self.session.refresh_token or '',
'expiry_time': self.session.expiry_time.timestamp() if self.session.expiry_time else 0,
})
def is_authenticated(self) -> bool:
"""Check if we have a valid Tidal session."""
if not self.session:
return False
try:
return self.session.check_login()
except Exception:
return False
def start_device_auth(self) -> Optional[Dict[str, str]]:
"""
Start the device-code OAuth flow.
Returns dict with 'verification_uri' and 'user_code', or None on error.
"""
if tidalapi is None:
return None
try:
if not self.session:
self.session = tidalapi.Session()
login, future = self.session.login_oauth()
self._device_auth_future = future
self._device_auth_link = {
'verification_uri': login.verification_uri_complete or f"https://link.tidal.com/{login.user_code}",
'user_code': login.user_code,
}
logger.info(f"Tidal device auth started — code: {login.user_code}")
return self._device_auth_link
except Exception as e:
logger.error(f"Failed to start Tidal device auth: {e}")
return None
def check_device_auth(self) -> Dict[str, Any]:
"""
Check if device auth has completed.
Returns {'status': 'pending'|'completed'|'error', ...}
"""
if not self._device_auth_future:
return {'status': 'error', 'message': 'No auth in progress'}
try:
if self._device_auth_future.running():
return {
'status': 'pending',
'verification_uri': self._device_auth_link.get('verification_uri', ''),
'user_code': self._device_auth_link.get('user_code', ''),
}
# Future is done — check result
result = self._device_auth_future.result(timeout=0)
if self.session and self.session.check_login():
self._save_session()
logger.info("Tidal device auth completed successfully")
return {'status': 'completed', 'message': 'Authenticated successfully'}
else:
return {'status': 'error', 'message': 'Auth completed but session invalid'}
except Exception as e:
logger.error(f"Tidal device auth check error: {e}")
return {'status': 'error', 'message': str(e)}
# ===================== Search =====================
def is_available(self) -> bool:
"""Check if Tidal download client is available (tidalapi installed and authenticated)."""
return tidalapi is not None and self.is_authenticated()
def is_configured(self) -> bool:
"""Check if Tidal client is configured and ready (matches Soulseek interface)."""
return self.is_available()
async def check_connection(self) -> bool:
"""Test if Tidal is accessible (async, Soulseek-compatible)."""
try:
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self.is_available)
except Exception as e:
logger.error(f"Tidal connection check failed: {e}")
return False
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
"""
Search Tidal for tracks (async, Soulseek-compatible interface).
Returns:
Tuple of (track_results, album_results). Album results always empty.
"""
if not self.is_available():
logger.warning("Tidal not available for search (not authenticated)")
return ([], [])
logger.info(f"Searching Tidal for: {query}")
try:
loop = asyncio.get_event_loop()
def _search():
results = self.session.search(query, models=[tidalapi.media.Track], limit=50)
return results.get('tracks', []) if isinstance(results, dict) else []
tidal_tracks = await loop.run_in_executor(None, _search)
if not tidal_tracks:
logger.warning(f"No Tidal results for: {query}")
return ([], [])
# Get configured quality for display
quality_key = config_manager.get('tidal_download.quality', 'lossless')
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
track_results = []
for track in tidal_tracks:
try:
track_result = self._tidal_to_track_result(track, quality_info)
track_results.append(track_result)
except Exception as e:
logger.debug(f"Skipping track conversion error: {e}")
logger.info(f"Found {len(track_results)} Tidal tracks")
return (track_results, [])
except Exception as e:
logger.error(f"Tidal search failed: {e}")
import traceback
traceback.print_exc()
return ([], [])
def _tidal_to_track_result(self, track, quality_info: dict) -> TrackResult:
"""Convert tidalapi Track to TrackResult (Soulseek-compatible format)."""
artist_name = track.artist.name if track.artist else 'Unknown Artist'
title = track.name or 'Unknown Title'
album_name = track.album.name if track.album else None
# Duration in milliseconds
duration_ms = int(track.duration * 1000) if track.duration else None
# Encode track_id in filename (same pattern as YouTube: "id||display_name")
display_name = f"{artist_name} - {title}"
filename = f"{track.id}||{display_name}"
track_result = TrackResult(
username='tidal',
filename=filename,
size=0, # Unknown until download
bitrate=quality_info.get('bitrate'),
duration=duration_ms,
quality=quality_info.get('codec', 'flac'),
free_upload_slots=999,
upload_speed=999999,
queue_length=0,
artist=artist_name,
title=title,
album=album_name,
track_number=track.track_num,
)
return track_result
# ===================== Download =====================
async def download(self, username: str, filename: str, file_size: int = 0) -> Optional[str]:
"""
Download a Tidal track (async, Soulseek-compatible interface).
Returns download_id immediately and runs download in background thread.
Args:
username: Ignored for Tidal (always "tidal")
filename: Encoded as "track_id||display_name"
file_size: Ignored
"""
try:
if '||' not in filename:
logger.error(f"Invalid filename format: {filename}")
return None
track_id_str, display_name = filename.split('||', 1)
try:
track_id = int(track_id_str)
except ValueError:
logger.error(f"Invalid Tidal track ID: {track_id_str}")
return None
logger.info(f"Starting Tidal download: {display_name}")
download_id = str(uuid.uuid4())
with self._download_lock:
self.active_downloads[download_id] = {
'id': download_id,
'filename': filename, # Keep original encoded format for context matching
'username': 'tidal',
'state': 'Initializing',
'progress': 0.0,
'size': 0,
'transferred': 0,
'speed': 0,
'time_remaining': None,
'track_id': track_id,
'display_name': display_name,
'file_path': None,
}
# Start download in background thread
download_thread = threading.Thread(
target=self._download_thread_worker,
args=(download_id, track_id, display_name, filename),
daemon=True,
)
download_thread.start()
logger.info(f"Tidal download {download_id} started in background")
return download_id
except Exception as e:
logger.error(f"Failed to start Tidal download: {e}")
import traceback
traceback.print_exc()
return None
def _download_thread_worker(self, download_id: str, track_id: int, display_name: str, original_filename: str):
"""Background thread worker for downloading Tidal tracks."""
try:
with self._download_lock:
if download_id in self.active_downloads:
self.active_downloads[download_id]['state'] = 'InProgress, Downloading'
file_path = self._download_sync(download_id, track_id, display_name)
if file_path:
with self._download_lock:
if download_id in self.active_downloads:
self.active_downloads[download_id]['state'] = 'Completed, Succeeded'
self.active_downloads[download_id]['progress'] = 100.0
self.active_downloads[download_id]['file_path'] = file_path
logger.info(f"Tidal download {download_id} completed: {file_path}")
else:
with self._download_lock:
if download_id in self.active_downloads:
self.active_downloads[download_id]['state'] = 'Errored'
logger.error(f"Tidal download {download_id} failed")
except Exception as e:
logger.error(f"Tidal download thread failed for {download_id}: {e}")
import traceback
traceback.print_exc()
with self._download_lock:
if download_id in self.active_downloads:
self.active_downloads[download_id]['state'] = 'Errored'
def _download_sync(self, download_id: str, track_id: int, display_name: str) -> Optional[str]:
"""
Synchronous download method (runs in background thread).
Returns file path if successful, None otherwise.
"""
if not self.session or not self.session.check_login():
logger.error("Tidal session not authenticated")
return None
try:
# Get track object
track = self.session.track(track_id)
if not track:
logger.error(f"Could not fetch Tidal track: {track_id}")
return None
# Determine quality
quality_key = config_manager.get('tidal_download.quality', 'lossless')
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
# Try quality fallback 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
chain = quality_chain[start_idx:]
stream = None
actual_quality = None
for q_key in chain:
q_info = QUALITY_MAP[q_key]
try:
# Set session quality before requesting stream
self.session.audio_quality = q_info['tidal_quality']
stream = track.get_stream()
if stream and stream.manifest_mime_type:
actual_quality = q_info
logger.info(f"Got Tidal stream at quality: {q_key}")
break
except Exception as e:
logger.warning(f"Quality {q_key} unavailable: {e}")
continue
if not stream:
logger.error("No Tidal stream available at any quality")
return None
# Parse manifest to get download URL
manifest = stream.get_stream_manifest()
urls = manifest.get_urls()
if not urls:
logger.error("No download URLs in Tidal stream manifest")
return None
download_url = urls[0]
# Determine file extension from manifest
codec = manifest.get_codecs()
if codec and 'flac' in codec.lower():
extension = 'flac'
elif codec and ('mp4a' in codec.lower() or 'aac' in codec.lower()):
extension = 'm4a'
elif codec and 'alac' in codec.lower():
extension = 'm4a'
else:
# Default based on quality
extension = actual_quality.get('extension', 'flac') if actual_quality else 'flac'
# Build output filename: "Artist - Title.ext"
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
out_filename = f"{safe_name}.{extension}"
out_path = self.download_path / out_filename
# Check for shutdown before downloading
if self.shutdown_check and self.shutdown_check():
logger.info("Server shutting down, aborting Tidal download")
return None
# Download with progress tracking
logger.info(f"Downloading from Tidal: {out_filename}")
response = http_requests.get(download_url, stream=True, timeout=120)
response.raise_for_status()
total_size = int(response.headers.get('content-length', 0))
downloaded = 0
chunk_size = 64 * 1024 # 64KB chunks
with self._download_lock:
if download_id in self.active_downloads:
self.active_downloads[download_id]['size'] = total_size
with open(out_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=chunk_size):
if not chunk:
continue
# Check for shutdown
if self.shutdown_check and self.shutdown_check():
logger.info("Server shutting down, aborting Tidal download mid-stream")
f.close()
out_path.unlink(missing_ok=True)
return None
f.write(chunk)
downloaded += len(chunk)
# Update progress
if total_size > 0:
progress = (downloaded / total_size) * 100
else:
progress = 0
with self._download_lock:
if download_id in self.active_downloads:
self.active_downloads[download_id]['transferred'] = downloaded
self.active_downloads[download_id]['progress'] = round(progress, 1)
# HiRes FLAC in MP4 container: extract raw FLAC with FFmpeg if available
if extension == 'flac' and self._is_mp4_container(out_path):
extracted = self._extract_flac_from_mp4(out_path)
if extracted:
out_path = Path(extracted)
logger.info(f"Tidal download complete: {out_path}")
return str(out_path)
except Exception as e:
logger.error(f"Tidal download failed: {e}")
import traceback
traceback.print_exc()
return None
def _is_mp4_container(self, filepath: Path) -> bool:
"""Check if a file is actually an MP4 container (HiRes FLAC can be wrapped in MP4)."""
try:
with open(filepath, 'rb') as f:
header = f.read(12)
# MP4 files have 'ftyp' at offset 4
return b'ftyp' in header
except Exception:
return False
def _extract_flac_from_mp4(self, mp4_path: Path) -> Optional[str]:
"""Extract FLAC audio from MP4 container using FFmpeg."""
ffmpeg = shutil.which('ffmpeg')
if not ffmpeg:
# Also check tools directory
tools_dir = Path(__file__).parent.parent / 'tools'
ffmpeg_candidate = tools_dir / ('ffmpeg.exe' if os.name == 'nt' else 'ffmpeg')
if ffmpeg_candidate.exists():
ffmpeg = str(ffmpeg_candidate)
else:
logger.warning("FFmpeg not found — cannot extract FLAC from MP4 container")
return None
flac_path = mp4_path.with_suffix('.flac')
temp_path = mp4_path.with_suffix('.tmp.flac')
try:
result = subprocess.run(
[ffmpeg, '-i', str(mp4_path), '-vn', '-acodec', 'copy', str(temp_path), '-y'],
capture_output=True, text=True, timeout=120,
)
if result.returncode == 0 and temp_path.exists() and temp_path.stat().st_size > 0:
mp4_path.unlink(missing_ok=True)
temp_path.rename(flac_path)
logger.info(f"Extracted FLAC from MP4 container: {flac_path.name}")
return str(flac_path)
else:
logger.warning(f"FFmpeg extraction failed: {result.stderr[:200] if result.stderr else 'unknown error'}")
temp_path.unlink(missing_ok=True)
return None
except Exception as e:
logger.warning(f"FFmpeg extraction error: {e}")
temp_path.unlink(missing_ok=True)
return None
# ===================== Status / Cancel / Clear =====================
async def get_all_downloads(self) -> List[DownloadStatus]:
"""Get all active downloads (matches Soulseek interface)."""
download_statuses = []
with self._download_lock:
for download_id, info in self.active_downloads.items():
status = DownloadStatus(
id=info['id'],
filename=info['filename'],
username=info['username'],
state=info['state'],
progress=info['progress'],
size=info['size'],
transferred=info['transferred'],
speed=info['speed'],
time_remaining=info.get('time_remaining'),
file_path=info.get('file_path'),
)
download_statuses.append(status)
return download_statuses
async def get_download_status(self, download_id: str) -> Optional[DownloadStatus]:
"""Get status of a specific download (matches Soulseek interface)."""
with self._download_lock:
if download_id not in self.active_downloads:
return None
info = self.active_downloads[download_id]
return DownloadStatus(
id=info['id'],
filename=info['filename'],
username=info['username'],
state=info['state'],
progress=info['progress'],
size=info['size'],
transferred=info['transferred'],
speed=info['speed'],
time_remaining=info.get('time_remaining'),
file_path=info.get('file_path'),
)
async def cancel_download(self, download_id: str, username: str = None, remove: bool = False) -> bool:
"""Cancel an active download (matches Soulseek interface)."""
try:
with self._download_lock:
if download_id not in self.active_downloads:
logger.warning(f"Download {download_id} not found")
return False
self.active_downloads[download_id]['state'] = 'Cancelled'
logger.info(f"Marked Tidal download {download_id} as cancelled")
if remove:
del self.active_downloads[download_id]
logger.info(f"Removed Tidal download {download_id} from queue")
return True
except Exception as e:
logger.error(f"Failed to cancel download {download_id}: {e}")
return False
async def clear_all_completed_downloads(self) -> bool:
"""Clear all terminal downloads from the list (matches Soulseek interface)."""
try:
with self._download_lock:
ids_to_remove = [
did for did, info in self.active_downloads.items()
if info.get('state', '') in ('Completed, Succeeded', 'Cancelled', 'Errored', 'Aborted')
]
for did in ids_to_remove:
del self.active_downloads[did]
return True
except Exception as e:
logger.error(f"Error clearing downloads: {e}")
return False

View file

@ -42,4 +42,7 @@ asyncio-mqtt>=0.16.0
pyacoustid>=1.3.0
# WebSocket client for Hydrabase connection
websocket-client>=1.7.0
websocket-client>=1.7.0
# Tidal download support
tidalapi>=0.7.6

View file

@ -137,8 +137,8 @@ def extract_filename(full_path):
Extract filename by working backwards from the end until we hit a separator.
This is cross-platform compatible and handles both Windows and Unix path separators.
Special handling for YouTube: If the filename contains '||' (YouTube encoding format),
treat it as a filename, not a path, to avoid splitting on '/' in video titles.
Special handling for YouTube/Tidal: If the filename contains '||' (encoded format),
treat it as a filename, not a path, to avoid splitting on '/' in titles.
"""
if not full_path:
return ""
@ -177,11 +177,14 @@ try:
matching_engine = MusicMatchingEngine()
sync_service = PlaylistSyncService(spotify_client, plex_client, soulseek_client, jellyfin_client, navidrome_client)
# Inject shutdown check callback into YouTube client (avoids circular imports)
# Inject shutdown check callback into YouTube and Tidal clients (avoids circular imports)
# The callback uses the global IS_SHUTTING_DOWN flag from this module
if hasattr(soulseek_client, 'youtube'):
soulseek_client.youtube.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
print("✅ Configured YouTube client shutdown callback")
if hasattr(soulseek_client, 'tidal'):
soulseek_client.tidal.set_shutdown_check(lambda: IS_SHUTTING_DOWN)
print("✅ Configured Tidal download client shutdown callback")
# Initialize web scan manager for automatic post-download scanning
media_clients = {
@ -382,12 +385,12 @@ def get_cached_transfer_data():
key = _make_context_key(transfer.get('username'), transfer.get('filename', ''))
live_transfers_lookup[key] = transfer
# Also add YouTube downloads (through orchestrator)
# Also add YouTube/Tidal downloads (through orchestrator)
try:
all_downloads = run_async(soulseek_client.get_all_downloads())
for download in all_downloads:
# Only add YouTube downloads (Soulseek ones are already in the lookup)
if download.username == 'youtube':
# Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup)
if download.username in ('youtube', 'tidal'):
key = _make_context_key(download.username, download.filename)
# Convert DownloadStatus to transfer dict format
live_transfers_lookup[key] = {
@ -401,7 +404,7 @@ def get_cached_transfer_data():
'averageSpeed': download.speed,
}
except Exception as e:
print(f"⚠️ Could not fetch YouTube downloads: {e}")
print(f"⚠️ Could not fetch YouTube/Tidal downloads: {e}")
# Update cache
transfer_data_cache['data'] = live_transfers_lookup
@ -706,12 +709,12 @@ class WebUIDownloadMonitor:
key = _make_context_key(username, file_info.get('filename', ''))
live_transfers[key] = file_info
# Also get YouTube downloads (through orchestrator)
# Also get YouTube/Tidal downloads (through orchestrator)
try:
all_downloads = run_async(soulseek_client.get_all_downloads())
for download in all_downloads:
# Only add YouTube downloads (Soulseek ones are already in the lookup)
if download.username == 'youtube':
# Only add YouTube/Tidal downloads (Soulseek ones are already in the lookup)
if download.username in ('youtube', 'tidal'):
key = _make_context_key(download.username, download.filename)
# Convert DownloadStatus to transfer dict format for monitor compatibility
live_transfers[key] = {
@ -725,7 +728,7 @@ class WebUIDownloadMonitor:
'averageSpeed': download.speed,
}
except Exception as yt_error:
print(f"⚠️ Monitor: Could not fetch YouTube downloads: {yt_error}")
print(f"⚠️ Monitor: Could not fetch YouTube/Tidal downloads: {yt_error}")
return live_transfers
except Exception as e:
@ -1568,7 +1571,7 @@ def _prepare_stream_task(track_data):
def _find_streaming_download_in_all_downloads(all_downloads, track_data):
"""
Find streaming download in DownloadStatus list (works for both Soulseek and YouTube).
Find streaming download in DownloadStatus list (works for Soulseek, YouTube, and Tidal).
Replaces the old _find_streaming_download_in_transfers function.
"""
try:
@ -1600,26 +1603,34 @@ def _find_streaming_download_in_all_downloads(all_downloads, track_data):
return None
def _find_downloaded_file(download_path, track_data):
"""Find the downloaded audio file in the downloads directory tree (works for Soulseek and YouTube)"""
"""Find the downloaded audio file in the downloads directory tree (works for Soulseek, YouTube, and Tidal)"""
# Ensure path is accessible in Docker (handles E:/ -> /host/mnt/e/)
download_path = docker_resolve_path(download_path)
audio_extensions = {'.mp3', '.flac', '.ogg', '.aac', '.wma', '.wav', '.m4a'}
target_filename = extract_filename(track_data.get('filename', ''))
# YOUTUBE SUPPORT: Handle encoded filename format "video_id||title"
# The file on disk will be "title.mp3", not "video_id||title"
# YOUTUBE/TIDAL SUPPORT: Handle encoded filename format "id||title"
# The file on disk will be "title.ext", not "id||title"
is_youtube = track_data.get('username') == 'youtube'
is_tidal = track_data.get('username') == 'tidal'
is_streaming_source = is_youtube or is_tidal
target_filename_youtube = None
if is_youtube and '||' in target_filename:
if is_streaming_source and '||' in target_filename:
_, title = target_filename.split('||', 1)
# yt-dlp will create "Title.mp3" from "Title"
target_filename_youtube = f"{title}.mp3"
print(f"🎵 [YouTube Stream] Looking for file: {target_filename_youtube}")
elif is_youtube:
# Fallback: if YouTube but no encoded format, use as-is
if is_tidal:
# Tidal files can be flac or m4a — match any audio extension
safe_title = re.sub(r'[<>:"/\\|?*]', '_', title)
target_filename_youtube = safe_title # Extension-less for flexible matching
print(f"🎵 [Tidal Stream] Looking for file starting with: {target_filename_youtube}")
else:
# yt-dlp will create "Title.mp3" from "Title"
target_filename_youtube = f"{title}.mp3"
print(f"🎵 [YouTube Stream] Looking for file: {target_filename_youtube}")
elif is_streaming_source:
# Fallback: if streaming source but no encoded format, use as-is
target_filename_youtube = target_filename
print(f"🎵 [YouTube Stream] Using direct filename: {target_filename_youtube}")
print(f"🎵 [Stream] Using direct filename: {target_filename_youtube}")
try:
# Walk through the downloads directory to find the file
@ -1642,15 +1653,18 @@ def _find_downloaded_file(download_path, track_data):
continue
# Check if this is our target file
if is_youtube and target_filename_youtube:
# For YouTube, use fuzzy matching (case-insensitive, flexible)
# Because yt-dlp might sanitize the filename differently
if is_streaming_source and target_filename_youtube:
# For YouTube/Tidal, use fuzzy matching (case-insensitive, flexible)
from difflib import SequenceMatcher
similarity = SequenceMatcher(None,
file.lower(),
target_filename_youtube.lower()).ratio()
# For Tidal, compare without extension (file could be .flac or .m4a)
compare_target = target_filename_youtube.lower()
compare_file = file.lower()
if is_tidal:
compare_file = os.path.splitext(compare_file)[0]
similarity = SequenceMatcher(None, compare_file, compare_target).ratio()
print(f"🔍 [YouTube Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}")
source_label = 'Tidal' if is_tidal else 'YouTube'
print(f"🔍 [{source_label} Stream] Comparing: '{file}' vs '{target_filename_youtube}' = {similarity:.2f}")
# Keep track of best match
if similarity > best_similarity:
@ -1667,13 +1681,14 @@ def _find_downloaded_file(download_path, track_data):
print(f"✅ Found streaming file: {file_path}")
return file_path
# For YouTube, if we found a good enough match (80%+), use it
if is_youtube and best_match and best_similarity >= 0.80:
print(f"✅ Found good match ({best_similarity:.2f}) for YouTube streaming file: {best_match}")
# For YouTube/Tidal, if we found a good enough match (80%+), use it
if is_streaming_source and best_match and best_similarity >= 0.80:
source_label = 'Tidal' if is_tidal else 'YouTube'
print(f"✅ Found good match ({best_similarity:.2f}) for {source_label} streaming file: {best_match}")
return best_match
print(f"❌ Could not find downloaded file: {target_filename}")
if is_youtube:
if is_streaming_source:
print(f" Looking for: {target_filename_youtube}")
print(f" Best similarity: {best_similarity:.2f}")
return None
@ -1798,6 +1813,7 @@ def run_service_test(service, test_config):
mode_messages = {
'soulseek': "Successfully connected to Soulseek network via slskd.",
'youtube': "YouTube download source ready.",
'tidal': "Tidal download source ready.",
'hybrid': "Download sources ready (Hybrid mode)."
}
message = mode_messages.get(download_mode, "Download source connected.")
@ -1807,6 +1823,7 @@ def run_service_test(service, test_config):
mode_errors = {
'soulseek': "slskd is not connected to the Soulseek network. Check slskd status and credentials.",
'youtube': "YouTube download source not available.",
'tidal': "Tidal download source not available. Check authentication.",
'hybrid': "Could not connect to download sources. Check configuration."
}
error = mode_errors.get(download_mode, "Download source connection failed.")
@ -2217,6 +2234,10 @@ def get_status():
}
_status_cache_timestamps['soulseek'] = current_time
# Include download source mode so frontend can update labels
download_mode = config_manager.get('download_source.mode', 'soulseek')
_status_cache['soulseek']['source'] = download_mode
status_data = {
'spotify': _status_cache['spotify'],
'media_server': _status_cache['media_server'],
@ -2514,7 +2535,7 @@ def handle_settings():
if 'active_media_server' in new_settings:
config_manager.set_active_media_server(new_settings['active_media_server'])
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'listenbrainz', 'acoustid', 'import', 'lossy_copy']:
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'listenbrainz', 'acoustid', 'import', 'lossy_copy']:
if service in new_settings:
for key, value in new_settings[service].items():
config_manager.set(f'{service}.{key}', value)
@ -4346,8 +4367,8 @@ def stream_enhanced_search_track():
search_queries = []
import re
if download_mode == 'youtube' or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') == 'youtube'):
# YouTube mode: Include artist for better context
if download_mode in ('youtube', 'tidal') or (download_mode == 'hybrid' and config_manager.get('download_source.hybrid_primary') in ('youtube', 'tidal')):
# YouTube/Tidal mode: Include artist for better context
# Primary query: Artist + Track
if artist_name and track_name:
search_queries.append(f"{artist_name} {track_name}".strip())
@ -4358,7 +4379,7 @@ def stream_enhanced_search_track():
if cleaned_name and cleaned_name.lower() != track_name.lower():
search_queries.append(f"{artist_name} {cleaned_name}".strip())
logger.info(f"🔍 YouTube mode: Searching with artist + track name: {search_queries}")
logger.info(f"🔍 {download_mode.title()} mode: Searching with artist + track name: {search_queries}")
else:
# Soulseek mode: Track name only to avoid keyword filtering
# Primary query: Full track name
@ -4519,7 +4540,7 @@ def start_download():
if download_id:
# Register download for post-processing (simple transfer to /Transfer)
context_key = _make_context_key(username, filename)
is_youtube = username == 'youtube'
is_streaming_source = username in ('youtube', 'tidal')
with matched_context_lock:
matched_downloads_context[context_key] = {
'search_result': {
@ -4535,7 +4556,8 @@ def start_download():
'spotify_album': None,
'track_info': None
}
logger.info(f"{'[YouTube]' if is_youtube else '[Soulseek]'} Registered simple download for post-processing: {context_key}")
source_label = username.title() if is_streaming_source else 'Soulseek'
logger.info(f"[{source_label}] Registered simple download for post-processing: {context_key}")
# Extract track name from filename for activity
track_name = filename.split('/')[-1] if '/' in filename else filename.split('\\')[-1] if '\\' in filename else filename
@ -4574,7 +4596,7 @@ def _find_completed_file_robust(download_dir, api_filename, transfer_dir=None):
from difflib import SequenceMatcher
from unidecode import unidecode
# YOUTUBE SUPPORT: Handle encoded filename format "video_id||title"
# YOUTUBE/TIDAL SUPPORT: Handle encoded filename format "id||title"
# Extract just the title part for file matching
if '||' in api_filename:
_, title = api_filename.split('||', 1)
@ -4715,7 +4737,7 @@ def get_download_status():
global _processed_download_ids
transfers_data = run_async(soulseek_client._make_request('GET', 'transfers/downloads'))
# Don't return early if no Soulseek transfers - YouTube downloads need to be checked too!
# Don't return early if no Soulseek transfers - YouTube/Tidal downloads need to be checked too!
all_transfers = []
completed_matched_downloads = []
# Track files already claimed this poll cycle to prevent two contexts from
@ -4724,7 +4746,7 @@ def get_download_status():
_files_claimed_this_cycle = set()
if not transfers_data:
# No Soulseek transfers, but continue to check YouTube downloads below
# No Soulseek transfers, but continue to check YouTube/Tidal downloads below
pass
else:
# This logic now correctly processes the nested structure from the slskd API
@ -4882,17 +4904,18 @@ def get_download_status():
processing_thread.daemon = True
processing_thread.start()
# Also include YouTube downloads in the response
# Also include YouTube/Tidal downloads in the response
try:
all_youtube_downloads = run_async(soulseek_client.get_all_downloads())
all_streaming_downloads = run_async(soulseek_client.get_all_downloads())
for download in all_youtube_downloads:
if download.username == 'youtube':
for download in all_streaming_downloads:
if download.username in ('youtube', 'tidal'):
source_label = download.username.title()
# Convert DownloadStatus to transfer format that frontend expects
youtube_transfer = {
streaming_transfer = {
'id': download.id,
'filename': download.filename,
'username': 'youtube',
'username': download.username,
'state': download.state,
'percentComplete': download.progress,
'size': download.size,
@ -4900,12 +4923,12 @@ def get_download_status():
'averageSpeed': download.speed,
'direction': 'Download', # Required by frontend
}
all_transfers.append(youtube_transfer)
all_transfers.append(streaming_transfer)
# Check if YouTube download is completed and needs post-processing
# Check if download is completed and needs post-processing
# Verify bytes match before trusting state string
_yt_bytes_ok = download.size <= 0 or download.transferred >= download.size
if _yt_bytes_ok and download.state and ('succeeded' in download.state.lower() or 'completed' in download.state.lower()):
_st_bytes_ok = download.size <= 0 or download.transferred >= download.size
if _st_bytes_ok and download.state and ('succeeded' in download.state.lower() or 'completed' in download.state.lower()):
context_key = _make_context_key(download.username, download.filename)
with matched_context_lock:
@ -4918,34 +4941,34 @@ def get_download_status():
if found_path:
# Prevent two contexts from claiming the same physical file
_yt_norm = os.path.normpath(found_path)
if _yt_norm in _files_claimed_this_cycle:
print(f"⚠️ [YouTube] File already claimed this cycle: {os.path.basename(found_path)} — deferring")
_st_norm = os.path.normpath(found_path)
if _st_norm in _files_claimed_this_cycle:
print(f"⚠️ [{source_label}] File already claimed this cycle: {os.path.basename(found_path)} — deferring")
continue
_files_claimed_this_cycle.add(_yt_norm)
print(f"🎯 [YouTube] Found completed matched file on disk: {found_path}")
_files_claimed_this_cycle.add(_st_norm)
print(f"🎯 [{source_label}] Found completed matched file on disk: {found_path}")
# Start post-processing thread
def process_youtube_download():
def process_streaming_download(_ctx_key=context_key, _ctx=context, _path=found_path, _label=source_label):
try:
print(f"🚀 [YouTube] Starting post-processing thread for: {context_key}")
thread = threading.Thread(target=_post_process_matched_download, args=(context_key, context, found_path))
print(f"🚀 [{_label}] Starting post-processing thread for: {_ctx_key}")
thread = threading.Thread(target=_post_process_matched_download, args=(_ctx_key, _ctx, _path))
thread.daemon = True
thread.start()
_processed_download_ids.add(context_key)
print(f"✅ [YouTube] Marked as processed: {context_key}")
_processed_download_ids.add(_ctx_key)
print(f"✅ [{_label}] Marked as processed: {_ctx_key}")
except Exception as e:
print(f"❌ [YouTube] Error starting post-processing thread for {context_key}: {e}")
print(f"❌ [{_label}] Error starting post-processing thread for {_ctx_key}: {e}")
processing_thread = threading.Thread(target=process_youtube_download)
processing_thread = threading.Thread(target=process_streaming_download)
processing_thread.daemon = True
processing_thread.start()
else:
# File not found - likely already processed and moved to library
# Mark as processed to prevent infinite checking
_processed_download_ids.add(context_key)
except Exception as yt_error:
except Exception as streaming_error:
import traceback
print(f"⚠️ Could not fetch YouTube downloads for status: {yt_error}")
print(f"⚠️ Could not fetch YouTube/Tidal downloads for status: {streaming_error}")
traceback.print_exc()
return jsonify({"transfers": all_transfers})
@ -14100,11 +14123,12 @@ def get_valid_candidates(results, spotify_track, query):
if not initial_candidates:
return []
# Skip quality filtering for YouTube results (always MP3 320kbps - no quality options)
is_youtube_source = initial_candidates[0].username == "youtube" if initial_candidates else False
# Skip quality filtering for YouTube/Tidal results (quality is fixed by source, not user-selectable per-result)
is_streaming_source = initial_candidates[0].username in ("youtube", "tidal") if initial_candidates else False
if is_youtube_source:
print(f"🎵 [YouTube] Skipping quality filter - YouTube always provides MP3 320kbps")
if is_streaming_source:
source_label = initial_candidates[0].username.title()
print(f"🎵 [{source_label}] Skipping quality filter - streaming source handles quality internally")
quality_filtered_candidates = initial_candidates
else:
# Filter by user's quality profile before artist verification (Soulseek only)
@ -14132,8 +14156,8 @@ def get_valid_candidates(results, spotify_track, query):
artist_word_sets.append(words)
for candidate in quality_filtered_candidates:
# Skip artist check for YouTube results (title matching is sufficient as processed by matching engine)
if is_youtube_source:
# Skip artist check for streaming results (title matching is sufficient as processed by matching engine)
if is_streaming_source:
verified_candidates.append(candidate)
continue
@ -16211,8 +16235,8 @@ def _store_batch_source(batch_id, username, filename):
"""Browse the successful download's folder and store results on the batch for reuse."""
_sr = source_reuse_logger
_sr.info(f"_store_batch_source called: batch={batch_id}, user={username}, file={filename}")
if not batch_id or username == 'youtube':
_sr.info(f"Skipped — no batch_id or youtube")
if not batch_id or username in ('youtube', 'tidal'):
_sr.info(f"Skipped — no batch_id or streaming source ({username})")
return
with tasks_lock:
@ -18153,6 +18177,46 @@ def get_discover_album(source, album_id):
return jsonify({"error": str(e)}), 500
# ===================================================================
# TIDAL DOWNLOAD AUTH ENDPOINTS
# ===================================================================
@app.route('/api/tidal/download/auth/start', methods=['POST'])
def tidal_download_auth_start():
"""Start Tidal device-code OAuth flow for download client."""
try:
tidal_dl = soulseek_client.tidal
result = tidal_dl.start_device_auth()
if result:
return jsonify({"success": True, **result})
else:
return jsonify({"error": "Failed to start Tidal auth. Is tidalapi installed?"}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route('/api/tidal/download/auth/check', methods=['GET'])
def tidal_download_auth_check():
"""Check status of Tidal device-code OAuth flow."""
try:
tidal_dl = soulseek_client.tidal
result = tidal_dl.check_device_auth()
return jsonify(result)
except Exception as e:
return jsonify({"status": "error", "message": str(e)}), 500
@app.route('/api/tidal/download/auth/status', methods=['GET'])
def tidal_download_auth_status():
"""Check if Tidal download client is authenticated."""
try:
tidal_dl = soulseek_client.tidal
authenticated = tidal_dl.is_authenticated()
return jsonify({"authenticated": authenticated})
except Exception as e:
return jsonify({"authenticated": False, "error": str(e)})
# ===================================================================
# TIDAL PLAYLIST API ENDPOINTS
# ===================================================================

View file

@ -162,7 +162,7 @@
</div>
<div class="status-indicator" id="soulseek-indicator">
<span class="status-dot disconnected"></span>
<span class="status-name">Soulseek</span>
<span class="status-name" id="download-source-name">Soulseek</span>
</div>
</div>
</div>
@ -372,7 +372,7 @@
</div>
<div class="service-card" id="soulseek-service-card">
<div class="service-card-header">
<span class="service-card-title">Soulseek</span>
<span class="service-card-title" id="download-source-title">Soulseek</span>
<span class="service-card-indicator disconnected"
id="soulseek-status-indicator">●</span>
</div>
@ -3075,6 +3075,7 @@
onchange="updateDownloadSourceUI()">
<option value="soulseek">Soulseek Only</option>
<option value="youtube">YouTube Only</option>
<option value="tidal">Tidal Only</option>
<option value="hybrid">Hybrid (Try both with fallback)</option>
</select>
<div class="setting-help-text">
@ -3090,6 +3091,7 @@
<select id="hybrid-primary-source" class="form-select">
<option value="soulseek">Try Soulseek First</option>
<option value="youtube">Try YouTube First</option>
<option value="tidal">Try Tidal First</option>
</select>
<div class="setting-help-text">
Which source to try first when hybrid mode is enabled.
@ -3107,6 +3109,33 @@
</div>
</div>
<!-- Tidal Download Settings (shown only when tidal mode is selected) -->
<div id="tidal-download-settings-container" style="display: none;">
<div class="form-group">
<label>Tidal Download Quality:</label>
<select id="tidal-download-quality" class="form-select">
<option value="low">Low (AAC 96kbps)</option>
<option value="high">High (AAC 320kbps)</option>
<option value="lossless">Lossless (FLAC 16-bit/44.1kHz)</option>
<option value="hires">HiRes (FLAC 24-bit/96kHz)</option>
</select>
<div class="setting-help-text">
Audio quality for Tidal downloads. HiRes requires a Tidal HiFi Plus subscription.
</div>
</div>
<div class="form-group">
<label>Tidal Download Auth:</label>
<div class="form-actions" style="margin-top: 4px;">
<button class="auth-button" id="tidal-download-auth-btn" onclick="startTidalDownloadAuth()">
Link Tidal Account
</button>
<span id="tidal-download-auth-status" class="setting-help-text" style="margin-left: 8px;"></span>
</div>
<div id="tidal-download-auth-code" style="display: none; margin-top: 8px;" class="setting-help-text">
</div>
</div>
</div>
<div class="form-group">
<label>Log Level:</label>
<select id="log-level-select" class="form-select" onchange="changeLogLevel()">

View file

@ -1835,6 +1835,7 @@ async function loadSettingsData() {
document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek';
document.getElementById('hybrid-primary-source').value = settings.download_source?.hybrid_primary || 'soulseek';
document.getElementById('youtube-min-confidence').value = settings.download_source?.youtube_min_confidence || 0.65;
document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless';
// Update UI based on download source mode
updateDownloadSourceUI();
@ -1985,12 +1986,17 @@ function toggleServer(serverType) {
function updateDownloadSourceUI() {
const mode = document.getElementById('download-source-mode').value;
const hybridContainer = document.getElementById('hybrid-settings-container');
const tidalContainer = document.getElementById('tidal-download-settings-container');
// Show hybrid settings only when hybrid mode is selected
if (mode === 'hybrid') {
hybridContainer.style.display = 'block';
} else {
hybridContainer.style.display = 'none';
hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none';
// Show Tidal download settings when tidal mode is selected
tidalContainer.style.display = mode === 'tidal' ? 'block' : 'none';
// Check Tidal download auth status when switching to tidal mode
if (mode === 'tidal') {
checkTidalDownloadAuthStatus();
}
}
@ -2495,6 +2501,9 @@ async function saveSettings(quiet = false) {
hybrid_primary: document.getElementById('hybrid-primary-source').value,
youtube_min_confidence: parseFloat(document.getElementById('youtube-min-confidence').value) || 0.65
},
tidal_download: {
quality: document.getElementById('tidal-download-quality').value || 'lossless'
},
database: {
max_workers: parseInt(document.getElementById('max-workers').value)
},
@ -2839,6 +2848,97 @@ async function authenticateTidal() {
}
}
// ===== Tidal Download Auth (Device Flow) =====
async function checkTidalDownloadAuthStatus() {
const statusEl = document.getElementById('tidal-download-auth-status');
const btn = document.getElementById('tidal-download-auth-btn');
try {
const resp = await fetch('/api/tidal/download/auth/status');
const data = await resp.json();
if (data.authenticated) {
statusEl.textContent = 'Authenticated';
statusEl.style.color = '#4caf50';
btn.textContent = 'Re-link Tidal Account';
} else {
statusEl.textContent = 'Not authenticated';
statusEl.style.color = '#ff9800';
btn.textContent = 'Link Tidal Account';
}
} catch (e) {
statusEl.textContent = '';
}
}
let _tidalAuthPollTimer = null;
async function startTidalDownloadAuth() {
const btn = document.getElementById('tidal-download-auth-btn');
const statusEl = document.getElementById('tidal-download-auth-status');
const codeEl = document.getElementById('tidal-download-auth-code');
btn.disabled = true;
btn.textContent = 'Starting...';
statusEl.textContent = '';
try {
const resp = await fetch('/api/tidal/download/auth/start', { method: 'POST' });
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || 'Failed to start auth');
}
// Show the link/code to the user
const uri = data.verification_uri || '';
const code = data.user_code || '';
codeEl.style.display = 'block';
codeEl.innerHTML = `Go to <a href="${uri}" target="_blank" style="color:#1db954;">${uri}</a> and enter code: <strong>${code}</strong>`;
btn.textContent = 'Waiting for approval...';
statusEl.textContent = 'Waiting...';
statusEl.style.color = '#ff9800';
// Poll for completion
if (_tidalAuthPollTimer) clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = setInterval(async () => {
try {
const checkResp = await fetch('/api/tidal/download/auth/check');
const checkData = await checkResp.json();
if (checkData.status === 'completed') {
clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = null;
codeEl.style.display = 'none';
statusEl.textContent = 'Authenticated';
statusEl.style.color = '#4caf50';
btn.disabled = false;
btn.textContent = 'Re-link Tidal Account';
showToast('Tidal download account linked successfully', 'success');
} else if (checkData.status === 'error') {
clearInterval(_tidalAuthPollTimer);
_tidalAuthPollTimer = null;
codeEl.style.display = 'none';
statusEl.textContent = 'Auth failed';
statusEl.style.color = '#f44336';
btn.disabled = false;
btn.textContent = 'Link Tidal Account';
showToast('Tidal auth failed: ' + (checkData.message || 'Unknown error'), 'error');
}
// status === 'pending' — keep polling
} catch (pollErr) {
console.error('Tidal auth poll error:', pollErr);
}
}, 3000);
} catch (error) {
console.error('Tidal download auth error:', error);
showToast('Failed to start Tidal auth: ' + error.message, 'error');
btn.disabled = false;
btn.textContent = 'Link Tidal Account';
codeEl.style.display = 'none';
}
}
const PATH_INPUT_IDS = {
download: 'download-path',
transfer: 'transfer-path',
@ -3532,9 +3632,9 @@ function initializeSearchModeToggle() {
const slskdResult = data.result;
// Check if audio format is supported (YouTube is always MP3, so skip check)
const isYouTube = slskdResult.username === 'youtube';
if (!isYouTube && slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) {
// Check if audio format is supported (YouTube/Tidal use encoded filenames, skip check)
const isStreamingSource = slskdResult.username === 'youtube' || slskdResult.username === 'tidal';
if (!isStreamingSource && slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) {
const format = getFileExtension(slskdResult.filename);
hideLoadingOverlay();
showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error');
@ -9199,7 +9299,7 @@ async function updateModalWithLiveDownloadProgress() {
// Extract display title from filename (handle YouTube encoding)
let downloadTitle = '';
if (downloadInfo.filename) {
if (downloadInfo.username === 'youtube' && downloadInfo.filename.includes('||')) {
if ((downloadInfo.username === 'youtube' || downloadInfo.username === 'tidal') && downloadInfo.filename.includes('||')) {
const parts = downloadInfo.filename.split('||');
downloadTitle = parts[1] || parts[0];
} else {
@ -9996,10 +10096,10 @@ function renderQueue(containerId, downloads, isActiveQueue) {
// Extract display title from filename
let title = 'Unknown File';
if (item.filename) {
// YouTube filenames are encoded as "video_id||title"
if (item.username === 'youtube' && item.filename.includes('||')) {
// YouTube/Tidal filenames are encoded as "id||title"
if ((item.username === 'youtube' || item.username === 'tidal') && item.filename.includes('||')) {
const parts = item.filename.split('||');
title = parts[1] || parts[0]; // Use title part, fallback to video_id
title = parts[1] || parts[0]; // Use title part, fallback to id
} else {
// Regular Soulseek filename - extract last part of path
title = item.filename.split(/[\\/]/).pop();
@ -10555,9 +10655,9 @@ async function streamTrack(index) {
const result = window.currentSearchResults[index];
console.log(`🎵 Streaming track:`, result);
// Check for unsupported formats before streaming (YouTube is always MP3, so skip check)
const isYouTube = result.username === 'youtube';
if (!isYouTube && result.filename) {
// Check for unsupported formats before streaming (YouTube/Tidal use encoded filenames, skip check)
const isStreamingSource = result.username === 'youtube' || result.username === 'tidal';
if (!isStreamingSource && result.filename) {
const format = getFileExtension(result.filename);
console.log(`🎵 [STREAM CHECK] File: ${result.filename}, Extension: ${format}`);
@ -10594,13 +10694,13 @@ async function streamAlbumTrack(albumIndex, trackIndex) {
const album = window.currentSearchResults[albumIndex];
console.log(`🎵 Album data:`, album);
// Surgical Fix: Handle YouTube results which are "flat" (no tracks array)
if (album.username === 'youtube') {
// For YouTube results, the "album" is actually the track itself
// Surgical Fix: Handle YouTube/Tidal results which are "flat" (no tracks array)
if (album.username === 'youtube' || album.username === 'tidal') {
// For YouTube/Tidal results, the "album" is actually the track itself
const track = album;
const trackData = {
...track,
username: 'youtube',
username: track.username,
filename: track.filename,
artist: track.artist,
album: track.title, // Use title as album name for player
@ -10631,9 +10731,9 @@ async function streamAlbumTrack(albumIndex, trackIndex) {
console.log(`🎵 Enhanced track data:`, trackData);
// Check for unsupported formats before streaming (YouTube is always MP3, so skip check)
const isYouTube = trackData.username === 'youtube';
if (!isYouTube && trackData.filename && !isAudioFormatSupported(trackData.filename)) {
// Check for unsupported formats before streaming (YouTube/Tidal use encoded filenames, skip check)
const isStreamingSource2 = trackData.username === 'youtube' || trackData.username === 'tidal';
if (!isStreamingSource2 && trackData.filename && !isAudioFormatSupported(trackData.filename)) {
const format = getFileExtension(trackData.filename);
showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error');
return;
@ -25137,6 +25237,14 @@ function updateServiceStatus(service, statusData) {
disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none';
}
}
// Update download source title on dashboard card
if (service === 'soulseek' && statusData.source) {
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', hybrid: 'Hybrid' };
const displayName = sourceNames[statusData.source] || 'Soulseek';
const titleEl = document.getElementById('download-source-title');
if (titleEl) titleEl.textContent = displayName;
}
}
function updateSidebarServiceStatus(service, statusData) {
@ -25170,6 +25278,14 @@ function updateSidebarServiceStatus(service, statusData) {
musicSourceNameElement.textContent = sourceName;
}
}
// Update download source name based on configured mode
if (service === 'soulseek' && statusData.source) {
const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', hybrid: 'Hybrid' };
const displayName = sourceNames[statusData.source] || 'Soulseek';
const sidebarName = document.getElementById('download-source-name');
if (sidebarName) sidebarName.textContent = displayName;
}
}
}