11"""
22Download Orchestrator
3- Routes downloads between Soulseek and YouTube based on configuration.
3+ Routes downloads between Soulseek, YouTube, and Tidal based on configuration.
44
5- Supports three modes:
5+ Supports four modes:
66- Soulseek Only: Traditional behavior
77- YouTube Only: YouTube-exclusive downloads
8+ - Tidal Only: Tidal-exclusive downloads
89- Hybrid: Try primary source first, fallback to secondary if it fails
910"""
1011
1617from config .settings import config_manager
1718from core .soulseek_client import SoulseekClient , TrackResult , AlbumResult , DownloadStatus
1819from core .youtube_client import YouTubeClient
20+ from core .tidal_download_client import TidalDownloadClient
1921
2022logger = get_logger ("download_orchestrator" )
2123
@@ -29,9 +31,10 @@ class DownloadOrchestrator:
2931 """
3032
3133 def __init__ (self ):
32- """Initialize orchestrator with both clients"""
34+ """Initialize orchestrator with all clients"""
3335 self .soulseek = SoulseekClient ()
3436 self .youtube = YouTubeClient ()
37+ self .tidal = TidalDownloadClient ()
3538
3639 # Load mode from config
3740 self .mode = config_manager .get ('download_source.mode' , 'soulseek' )
@@ -64,9 +67,11 @@ def is_configured(self) -> bool:
6467 return self .soulseek .is_configured ()
6568 elif self .mode == 'youtube' :
6669 return self .youtube .is_configured ()
70+ elif self .mode == 'tidal' :
71+ return self .tidal .is_configured ()
6772 elif self .mode == 'hybrid' :
6873 # In hybrid mode, at least one source must be configured
69- return self .soulseek .is_configured () or self .youtube .is_configured ()
74+ return self .soulseek .is_configured () or self .youtube .is_configured () or self . tidal . is_configured ()
7075
7176 return False
7277
@@ -80,15 +85,18 @@ async def check_connection(self) -> bool:
8085 return await self .soulseek .check_connection ()
8186 elif self .mode == 'youtube' :
8287 return await self .youtube .check_connection ()
88+ elif self .mode == 'tidal' :
89+ return await self .tidal .check_connection ()
8390 elif self .mode == 'hybrid' :
84- # In hybrid mode, check both sources
91+ # In hybrid mode, check all sources
8592 soulseek_ok = await self .soulseek .check_connection ()
8693 youtube_ok = await self .youtube .check_connection ()
94+ tidal_ok = await self .tidal .check_connection ()
8795
88- logger .info (f" Soulseek: { '✅' if soulseek_ok else '❌' } | YouTube: { '✅' if youtube_ok else '❌' } " )
96+ logger .info (f" Soulseek: { '✅' if soulseek_ok else '❌' } | YouTube: { '✅' if youtube_ok else '❌' } | Tidal: { '✅' if tidal_ok else '❌' } " )
8997
9098 # At least one must be available
91- return soulseek_ok or youtube_ok
99+ return soulseek_ok or youtube_ok or tidal_ok
92100
93101 return False
94102
@@ -112,33 +120,38 @@ async def search(self, query: str, timeout: int = None, progress_callback=None)
112120 logger .info (f"🔍 Searching YouTube: { query } " )
113121 return await self .youtube .search (query , timeout , progress_callback )
114122
115- elif self .mode == 'hybrid' :
116- # Try primary source first
117- if self .hybrid_primary == 'soulseek' :
118- logger .info (f"🔍 Hybrid search - trying Soulseek first: { query } " )
119- tracks , albums = await self .soulseek .search (query , timeout , progress_callback )
123+ elif self .mode == 'tidal' :
124+ logger .info (f"🔍 Searching Tidal: { query } " )
125+ return await self .tidal .search (query , timeout , progress_callback )
120126
121- # If Soulseek found good results, return them
122- if tracks :
123- logger .info (f"✅ Soulseek found { len (tracks )} tracks" )
124- return (tracks , albums )
125-
126- # Otherwise, try YouTube as fallback
127- logger .info (f"🔄 Soulseek found nothing, trying YouTube fallback" )
128- return await self .youtube .search (query , timeout , progress_callback )
129-
130- else : # YouTube first
131- logger .info (f"🔍 Hybrid search - trying YouTube first: { query } " )
132- tracks , albums = await self .youtube .search (query , timeout , progress_callback )
127+ elif self .mode == 'hybrid' :
128+ # Build ordered client list: primary first, then remaining as fallbacks
129+ clients = {
130+ 'soulseek' : self .soulseek ,
131+ 'youtube' : self .youtube ,
132+ 'tidal' : self .tidal ,
133+ }
134+ primary = self .hybrid_primary if self .hybrid_primary in clients else 'soulseek'
135+ fallbacks = [name for name in clients if name != primary ]
133136
134- # If YouTube found good results, return them
137+ # Try primary source first
138+ logger .info (f"🔍 Hybrid search - trying { primary } first: { query } " )
139+ tracks , albums = await clients [primary ].search (query , timeout , progress_callback )
140+ if tracks :
141+ logger .info (f"✅ { primary } found { len (tracks )} tracks" )
142+ return (tracks , albums )
143+
144+ # Try fallbacks in order
145+ for fallback_name in fallbacks :
146+ logger .info (f"🔄 { primary } found nothing, trying { fallback_name } fallback" )
147+ tracks , albums = await clients [fallback_name ].search (query , timeout , progress_callback )
135148 if tracks :
136- logger .info (f"✅ YouTube found { len (tracks )} tracks" )
149+ logger .info (f"✅ { fallback_name } found { len (tracks )} tracks" )
137150 return (tracks , albums )
138151
139- # Otherwise, try Soulseek as fallback
140- logger .info (f"🔄 YouTube found nothing, trying Soulseek fallback " )
141- return await self . soulseek . search ( query , timeout , progress_callback )
152+ # Nothing found from any source
153+ logger .warning (f"❌ Hybrid search exhausted all sources for: { query } " )
154+ return ([], [] )
142155
143156 # Fallback: empty results
144157 return ([], [])
@@ -203,6 +216,9 @@ async def download(self, username: str, filename: str, file_size: int = 0) -> Op
203216 if username == 'youtube' :
204217 logger .info (f"📥 Downloading from YouTube: { filename } " )
205218 return await self .youtube .download (username , filename , file_size )
219+ elif username == 'tidal' :
220+ logger .info (f"📥 Downloading from Tidal: { filename } " )
221+ return await self .tidal .download (username , filename , file_size )
206222 else :
207223 logger .info (f"📥 Downloading from Soulseek: { filename } " )
208224 return await self .soulseek .download (username , filename , file_size )
@@ -214,12 +230,13 @@ async def get_all_downloads(self) -> List[DownloadStatus]:
214230 Returns:
215231 List of DownloadStatus objects
216232 """
217- # Get downloads from both sources
233+ # Get downloads from all sources
218234 soulseek_downloads = await self .soulseek .get_all_downloads ()
219235 youtube_downloads = await self .youtube .get_all_downloads ()
236+ tidal_downloads = await self .tidal .get_all_downloads ()
220237
221238 # Combine and return
222- return soulseek_downloads + youtube_downloads
239+ return soulseek_downloads + youtube_downloads + tidal_downloads
223240
224241 async def get_download_status (self , download_id : str ) -> Optional [DownloadStatus ]:
225242 """
@@ -241,6 +258,11 @@ async def get_download_status(self, download_id: str) -> Optional[DownloadStatus
241258 if status :
242259 return status
243260
261+ # Try Tidal
262+ status = await self .tidal .get_download_status (download_id )
263+ if status :
264+ return status
265+
244266 return None
245267
246268 async def cancel_download (self , download_id : str , username : str = None , remove : bool = False ) -> bool :
@@ -258,16 +280,22 @@ async def cancel_download(self, download_id: str, username: str = None, remove:
258280 # If username is provided, route directly
259281 if username == 'youtube' :
260282 return await self .youtube .cancel_download (download_id , username , remove )
283+ elif username == 'tidal' :
284+ return await self .tidal .cancel_download (download_id , username , remove )
261285 elif username :
262286 return await self .soulseek .cancel_download (download_id , username , remove )
263287
264- # Otherwise, try both sources
288+ # Otherwise, try all sources
265289 soulseek_cancelled = await self .soulseek .cancel_download (download_id , username , remove )
266290 if soulseek_cancelled :
267291 return True
268292
269293 youtube_cancelled = await self .youtube .cancel_download (download_id , username , remove )
270- return youtube_cancelled
294+ if youtube_cancelled :
295+ return True
296+
297+ tidal_cancelled = await self .tidal .cancel_download (download_id , username , remove )
298+ return tidal_cancelled
271299
272300 async def signal_download_completion (self , download_id : str , username : str , remove : bool = True ) -> bool :
273301 """
@@ -292,10 +320,11 @@ async def clear_all_completed_downloads(self) -> bool:
292320 True if successful
293321 """
294322 soulseek_cleared = await self .soulseek .clear_all_completed_downloads ()
295- # YouTube downloads must also be cleared from memory
323+ # YouTube and Tidal downloads must also be cleared from memory
296324 youtube_cleared = await self .youtube .clear_all_completed_downloads ()
325+ tidal_cleared = await self .tidal .clear_all_completed_downloads ()
297326
298- return soulseek_cleared and youtube_cleared
327+ return soulseek_cleared and youtube_cleared and tidal_cleared
299328
300329 # ===== Soulseek-specific methods (for backwards compatibility) =====
301330 # These are internal methods that some parts of the codebase use directly
@@ -353,5 +382,8 @@ async def maintain_search_history_with_buffer(self, keep_searches: int = 50, tri
353382 return await self .soulseek .maintain_search_history_with_buffer (keep_searches , trigger_threshold )
354383
355384 async def cancel_all_downloads (self ) -> bool :
356- """Cancel and remove all downloads from slskd."""
357- return await self .soulseek .cancel_all_downloads ()
385+ """Cancel and remove all downloads from all sources."""
386+ soulseek_ok = await self .soulseek .cancel_all_downloads ()
387+ # Clear Tidal active downloads too
388+ tidal_ok = await self .tidal .clear_all_completed_downloads ()
389+ return soulseek_ok
0 commit comments