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:
parent
49c769ff5c
commit
acfc26a4bd
7 changed files with 1064 additions and 135 deletions
|
|
@ -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": ""
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
676
core/tidal_download_client.py
Normal file
676
core/tidal_download_client.py
Normal 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
|
||||
|
|
@ -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
|
||||
212
web_server.py
212
web_server.py
|
|
@ -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
|
||||
# ===================================================================
|
||||
|
|
|
|||
|
|
@ -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()">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue