Skip to content

Commit acfc26a

Browse files
committed
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.
1 parent 49c769f commit acfc26a

File tree

7 files changed

+1065
-136
lines changed

7 files changed

+1065
-136
lines changed

config/settings.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,19 @@ def _get_default_config(self) -> Dict[str, Any]:
188188
"transfer_path": "./Transfer"
189189
},
190190
"download_source": {
191-
"mode": "soulseek", # Options: "soulseek", "youtube", "hybrid"
191+
"mode": "soulseek", # Options: "soulseek", "youtube", "tidal", "hybrid"
192192
"hybrid_primary": "soulseek", # Which source to try first in hybrid mode
193193
"youtube_min_confidence": 0.65 # Minimum confidence for YouTube matches
194194
},
195+
"tidal_download": {
196+
"quality": "lossless", # Options: "low", "high", "lossless", "hires"
197+
"session": {
198+
"token_type": "",
199+
"access_token": "",
200+
"refresh_token": "",
201+
"expiry_time": 0
202+
}
203+
},
195204
"listenbrainz": {
196205
"token": ""
197206
},

core/download_orchestrator.py

Lines changed: 69 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
"""
22
Download 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

@@ -16,6 +17,7 @@
1617
from config.settings import config_manager
1718
from core.soulseek_client import SoulseekClient, TrackResult, AlbumResult, DownloadStatus
1819
from core.youtube_client import YouTubeClient
20+
from core.tidal_download_client import TidalDownloadClient
1921

2022
logger = 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

Comments
 (0)