karaoke-gen 0.71.42__py3-none-any.whl → 0.75.53__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- karaoke_gen/__init__.py +32 -1
- karaoke_gen/audio_fetcher.py +1220 -67
- karaoke_gen/audio_processor.py +15 -3
- karaoke_gen/instrumental_review/server.py +154 -860
- karaoke_gen/instrumental_review/static/index.html +1529 -0
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +87 -2
- karaoke_gen/karaoke_gen.py +131 -14
- karaoke_gen/lyrics_processor.py +172 -4
- karaoke_gen/utils/bulk_cli.py +3 -0
- karaoke_gen/utils/cli_args.py +7 -4
- karaoke_gen/utils/gen_cli.py +221 -5
- karaoke_gen/utils/remote_cli.py +786 -43
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/METADATA +109 -4
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/RECORD +37 -31
- lyrics_transcriber/core/controller.py +76 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/App.tsx +6 -4
- lyrics_transcriber/frontend/src/api.ts +25 -10
- lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
- lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-BECn1o8Q.js} +1802 -553
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +1 -1
- lyrics_transcriber/output/countdown_processor.py +39 -0
- lyrics_transcriber/review/server.py +5 -5
- lyrics_transcriber/transcribers/audioshake.py +96 -7
- lyrics_transcriber/types.py +14 -12
- lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/licenses/LICENSE +0 -0
karaoke_gen/audio_fetcher.py
CHANGED
|
@@ -3,19 +3,46 @@ Audio Fetcher module - abstraction layer for fetching audio files.
|
|
|
3
3
|
|
|
4
4
|
This module provides a clean interface for searching and downloading audio files
|
|
5
5
|
using flacfetch, replacing the previous direct yt-dlp usage.
|
|
6
|
+
|
|
7
|
+
Supports two modes:
|
|
8
|
+
1. Local mode: Uses flacfetch library directly (requires torrent client, etc.)
|
|
9
|
+
2. Remote mode: Uses a remote flacfetch HTTP API server when FLACFETCH_API_URL
|
|
10
|
+
and FLACFETCH_API_KEY environment variables are set.
|
|
6
11
|
"""
|
|
7
12
|
|
|
8
13
|
import logging
|
|
9
14
|
import os
|
|
15
|
+
import signal
|
|
16
|
+
import sys
|
|
10
17
|
import tempfile
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
11
20
|
from abc import ABC, abstractmethod
|
|
12
|
-
from
|
|
13
|
-
from
|
|
21
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
|
22
|
+
from dataclasses import dataclass, asdict, field
|
|
23
|
+
from typing import List, Optional, Dict, Any
|
|
24
|
+
|
|
25
|
+
# Optional import for remote fetcher
|
|
26
|
+
try:
|
|
27
|
+
import httpx
|
|
28
|
+
HTTPX_AVAILABLE = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
HTTPX_AVAILABLE = False
|
|
31
|
+
|
|
32
|
+
# Global flag to track if user requested cancellation via Ctrl+C
|
|
33
|
+
_interrupt_requested = False
|
|
14
34
|
|
|
15
35
|
|
|
16
36
|
@dataclass
|
|
17
37
|
class AudioSearchResult:
|
|
18
|
-
"""Represents a single search result for audio.
|
|
38
|
+
"""Represents a single search result for audio.
|
|
39
|
+
|
|
40
|
+
Used by both local CLI and cloud backend. Supports serialization
|
|
41
|
+
for Firestore storage via to_dict()/from_dict().
|
|
42
|
+
|
|
43
|
+
For rich display, this class can serialize the full flacfetch Release
|
|
44
|
+
data so remote CLIs can use flacfetch's shared display functions.
|
|
45
|
+
"""
|
|
19
46
|
|
|
20
47
|
title: str
|
|
21
48
|
artist: str
|
|
@@ -24,13 +51,88 @@ class AudioSearchResult:
|
|
|
24
51
|
duration: Optional[int] = None # Duration in seconds
|
|
25
52
|
quality: Optional[str] = None # e.g., "FLAC", "320kbps", etc.
|
|
26
53
|
source_id: Optional[str] = None # Unique ID from the source
|
|
27
|
-
|
|
28
|
-
|
|
54
|
+
index: int = 0 # Index in the results list (for API selection)
|
|
55
|
+
seeders: Optional[int] = None # Number of seeders (for torrent sources)
|
|
56
|
+
target_file: Optional[str] = None # Target filename in the release
|
|
57
|
+
# Raw result object from the provider (for download) - not serialized
|
|
58
|
+
raw_result: Optional[object] = field(default=None, repr=False)
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
61
|
+
"""Convert to dict for JSON/Firestore serialization.
|
|
62
|
+
|
|
63
|
+
Includes full flacfetch Release data if available, enabling
|
|
64
|
+
remote CLIs to use flacfetch's shared display functions.
|
|
65
|
+
"""
|
|
66
|
+
result = {
|
|
67
|
+
"title": self.title,
|
|
68
|
+
"artist": self.artist,
|
|
69
|
+
"url": self.url,
|
|
70
|
+
"provider": self.provider,
|
|
71
|
+
"duration": self.duration,
|
|
72
|
+
"quality": self.quality,
|
|
73
|
+
"source_id": self.source_id,
|
|
74
|
+
"index": self.index,
|
|
75
|
+
"seeders": self.seeders,
|
|
76
|
+
"target_file": self.target_file,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# If we have a raw_result (flacfetch Release or dict), include its full data
|
|
80
|
+
# This enables rich display on the remote CLI
|
|
81
|
+
# raw_result can be either:
|
|
82
|
+
# - A dict (from remote flacfetch API)
|
|
83
|
+
# - A Release object (from local flacfetch)
|
|
84
|
+
if self.raw_result:
|
|
85
|
+
if isinstance(self.raw_result, dict):
|
|
86
|
+
# Remote flacfetch API returns dicts directly
|
|
87
|
+
release_dict = self.raw_result
|
|
88
|
+
else:
|
|
89
|
+
# Local flacfetch returns Release objects
|
|
90
|
+
try:
|
|
91
|
+
release_dict = self.raw_result.to_dict()
|
|
92
|
+
except AttributeError:
|
|
93
|
+
release_dict = {} # raw_result doesn't have to_dict() method
|
|
94
|
+
|
|
95
|
+
# Merge Release fields into result (they may override basic fields)
|
|
96
|
+
for key in ['year', 'label', 'edition_info', 'release_type', 'channel',
|
|
97
|
+
'view_count', 'size_bytes', 'target_file_size', 'track_pattern',
|
|
98
|
+
'match_score', 'formatted_size', 'formatted_duration',
|
|
99
|
+
'formatted_views', 'is_lossless', 'quality_str']:
|
|
100
|
+
if key in release_dict:
|
|
101
|
+
result[key] = release_dict[key]
|
|
102
|
+
|
|
103
|
+
# Handle quality dict - remote API uses 'quality_data', local uses 'quality'
|
|
104
|
+
if 'quality_data' in release_dict:
|
|
105
|
+
result['quality_data'] = release_dict['quality_data']
|
|
106
|
+
elif 'quality' in release_dict and isinstance(release_dict['quality'], dict):
|
|
107
|
+
result['quality_data'] = release_dict['quality']
|
|
108
|
+
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_dict(cls, data: Dict[str, Any]) -> "AudioSearchResult":
|
|
113
|
+
"""Create from dict (e.g., from Firestore)."""
|
|
114
|
+
return cls(
|
|
115
|
+
title=data.get("title", ""),
|
|
116
|
+
artist=data.get("artist", ""),
|
|
117
|
+
url=data.get("url", ""),
|
|
118
|
+
provider=data.get("provider", "Unknown"),
|
|
119
|
+
duration=data.get("duration"),
|
|
120
|
+
quality=data.get("quality"),
|
|
121
|
+
source_id=data.get("source_id"),
|
|
122
|
+
index=data.get("index", 0),
|
|
123
|
+
seeders=data.get("seeders"),
|
|
124
|
+
target_file=data.get("target_file"),
|
|
125
|
+
raw_result=None, # Not stored in serialized form
|
|
126
|
+
)
|
|
29
127
|
|
|
30
128
|
|
|
31
129
|
@dataclass
|
|
32
130
|
class AudioFetchResult:
|
|
33
|
-
"""Result of an audio fetch operation.
|
|
131
|
+
"""Result of an audio fetch operation.
|
|
132
|
+
|
|
133
|
+
Used by both local CLI and cloud backend. Supports serialization
|
|
134
|
+
for Firestore storage via to_dict()/from_dict().
|
|
135
|
+
"""
|
|
34
136
|
|
|
35
137
|
filepath: str
|
|
36
138
|
artist: str
|
|
@@ -38,6 +140,22 @@ class AudioFetchResult:
|
|
|
38
140
|
provider: str
|
|
39
141
|
duration: Optional[int] = None
|
|
40
142
|
quality: Optional[str] = None
|
|
143
|
+
|
|
144
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
145
|
+
"""Convert to dict for JSON/Firestore serialization."""
|
|
146
|
+
return asdict(self)
|
|
147
|
+
|
|
148
|
+
@classmethod
|
|
149
|
+
def from_dict(cls, data: Dict[str, Any]) -> "AudioFetchResult":
|
|
150
|
+
"""Create from dict (e.g., from Firestore)."""
|
|
151
|
+
return cls(
|
|
152
|
+
filepath=data.get("filepath", ""),
|
|
153
|
+
artist=data.get("artist", ""),
|
|
154
|
+
title=data.get("title", ""),
|
|
155
|
+
provider=data.get("provider", "Unknown"),
|
|
156
|
+
duration=data.get("duration"),
|
|
157
|
+
quality=data.get("quality"),
|
|
158
|
+
)
|
|
41
159
|
|
|
42
160
|
|
|
43
161
|
class AudioFetcherError(Exception):
|
|
@@ -58,6 +176,19 @@ class DownloadError(AudioFetcherError):
|
|
|
58
176
|
pass
|
|
59
177
|
|
|
60
178
|
|
|
179
|
+
class UserCancelledError(AudioFetcherError):
|
|
180
|
+
"""Raised when user explicitly cancels the operation (e.g., enters 0 or Ctrl+C)."""
|
|
181
|
+
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _check_interrupt():
|
|
186
|
+
"""Check if interrupt was requested and raise UserCancelledError if so."""
|
|
187
|
+
global _interrupt_requested
|
|
188
|
+
if _interrupt_requested:
|
|
189
|
+
raise UserCancelledError("Operation cancelled by user")
|
|
190
|
+
|
|
191
|
+
|
|
61
192
|
class AudioFetcher(ABC):
|
|
62
193
|
"""Abstract base class for audio fetching implementations."""
|
|
63
194
|
|
|
@@ -140,13 +271,17 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
140
271
|
|
|
141
272
|
This provides access to multiple audio sources including private music trackers
|
|
142
273
|
and YouTube, with intelligent prioritization of high-quality sources.
|
|
274
|
+
|
|
275
|
+
Also exported as FlacFetcher for shorter name.
|
|
143
276
|
"""
|
|
144
277
|
|
|
145
278
|
def __init__(
|
|
146
279
|
self,
|
|
147
280
|
logger: Optional[logging.Logger] = None,
|
|
148
|
-
|
|
281
|
+
red_api_key: Optional[str] = None,
|
|
282
|
+
red_api_url: Optional[str] = None,
|
|
149
283
|
ops_api_key: Optional[str] = None,
|
|
284
|
+
ops_api_url: Optional[str] = None,
|
|
150
285
|
provider_priority: Optional[List[str]] = None,
|
|
151
286
|
):
|
|
152
287
|
"""
|
|
@@ -154,40 +289,118 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
154
289
|
|
|
155
290
|
Args:
|
|
156
291
|
logger: Logger instance for output
|
|
157
|
-
|
|
292
|
+
red_api_key: API key for RED tracker (optional)
|
|
293
|
+
red_api_url: Base URL for RED tracker API (optional, required if using RED)
|
|
158
294
|
ops_api_key: API key for OPS tracker (optional)
|
|
295
|
+
ops_api_url: Base URL for OPS tracker API (optional, required if using OPS)
|
|
159
296
|
provider_priority: Custom provider priority order (optional)
|
|
160
297
|
"""
|
|
161
298
|
self.logger = logger or logging.getLogger(__name__)
|
|
162
|
-
self.
|
|
299
|
+
self._red_api_key = red_api_key or os.environ.get("RED_API_KEY")
|
|
300
|
+
self._red_api_url = red_api_url or os.environ.get("RED_API_URL")
|
|
163
301
|
self._ops_api_key = ops_api_key or os.environ.get("OPS_API_KEY")
|
|
302
|
+
self._ops_api_url = ops_api_url or os.environ.get("OPS_API_URL")
|
|
164
303
|
self._provider_priority = provider_priority
|
|
165
304
|
self._manager = None
|
|
305
|
+
self._transmission_available = None # Cached result of Transmission check
|
|
306
|
+
|
|
307
|
+
def _check_transmission_available(self) -> bool:
|
|
308
|
+
"""
|
|
309
|
+
Check if Transmission daemon is available for torrent downloads.
|
|
310
|
+
|
|
311
|
+
This prevents adding tracker providers (RED/OPS) when Transmission
|
|
312
|
+
isn't running, which would result in search results that can't be downloaded.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
True if Transmission is available and responsive, False otherwise.
|
|
316
|
+
"""
|
|
317
|
+
if self._transmission_available is not None:
|
|
318
|
+
self.logger.info(f"[Transmission] Using cached status: available={self._transmission_available}")
|
|
319
|
+
return self._transmission_available
|
|
320
|
+
|
|
321
|
+
host = os.environ.get("TRANSMISSION_HOST", "localhost")
|
|
322
|
+
port = int(os.environ.get("TRANSMISSION_PORT", "9091"))
|
|
323
|
+
self.logger.info(f"[Transmission] Checking availability at {host}:{port}")
|
|
324
|
+
|
|
325
|
+
try:
|
|
326
|
+
import transmission_rpc
|
|
327
|
+
self.logger.info(f"[Transmission] transmission_rpc imported successfully")
|
|
328
|
+
|
|
329
|
+
client = transmission_rpc.Client(host=host, port=port, timeout=5)
|
|
330
|
+
self.logger.info(f"[Transmission] Client created, calling session_stats()...")
|
|
331
|
+
|
|
332
|
+
# Simple test to verify connection works
|
|
333
|
+
stats = client.session_stats()
|
|
334
|
+
self.logger.info(f"[Transmission] Connected! Download dir: {getattr(stats, 'download_dir', 'unknown')}")
|
|
335
|
+
|
|
336
|
+
self._transmission_available = True
|
|
337
|
+
except ImportError as e:
|
|
338
|
+
self._transmission_available = False
|
|
339
|
+
self.logger.warning(f"[Transmission] transmission_rpc not installed: {e}")
|
|
340
|
+
except Exception as e:
|
|
341
|
+
self._transmission_available = False
|
|
342
|
+
self.logger.warning(f"[Transmission] Connection failed to {host}:{port}: {type(e).__name__}: {e}")
|
|
343
|
+
|
|
344
|
+
self.logger.info(f"[Transmission] Final status: available={self._transmission_available}")
|
|
345
|
+
return self._transmission_available
|
|
166
346
|
|
|
167
347
|
def _get_manager(self):
|
|
168
348
|
"""Lazily initialize and return the FetchManager."""
|
|
169
349
|
if self._manager is None:
|
|
170
350
|
# Import flacfetch here to avoid import errors if not installed
|
|
171
351
|
from flacfetch.core.manager import FetchManager
|
|
172
|
-
from flacfetch.providers.youtube import
|
|
352
|
+
from flacfetch.providers.youtube import YoutubeProvider
|
|
353
|
+
from flacfetch.downloaders.youtube import YoutubeDownloader
|
|
354
|
+
|
|
355
|
+
# Try to import TorrentDownloader (has optional dependencies)
|
|
356
|
+
TorrentDownloader = None
|
|
357
|
+
try:
|
|
358
|
+
from flacfetch.downloaders.torrent import TorrentDownloader
|
|
359
|
+
except ImportError:
|
|
360
|
+
self.logger.debug("TorrentDownloader not available (missing dependencies)")
|
|
173
361
|
|
|
174
362
|
self._manager = FetchManager()
|
|
175
363
|
|
|
176
|
-
#
|
|
177
|
-
|
|
178
|
-
|
|
364
|
+
# Only add tracker providers if we can actually download from them
|
|
365
|
+
# This requires both TorrentDownloader and a running Transmission daemon
|
|
366
|
+
has_torrent_downloader = TorrentDownloader is not None
|
|
367
|
+
transmission_available = self._check_transmission_available()
|
|
368
|
+
can_use_trackers = has_torrent_downloader and transmission_available
|
|
369
|
+
|
|
370
|
+
self.logger.info(
|
|
371
|
+
f"[FlacFetcher] Provider setup: TorrentDownloader={has_torrent_downloader}, "
|
|
372
|
+
f"Transmission={transmission_available}, can_use_trackers={can_use_trackers}"
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if not can_use_trackers and (self._red_api_key or self._ops_api_key):
|
|
376
|
+
self.logger.warning(
|
|
377
|
+
"[FlacFetcher] Tracker providers (RED/OPS) DISABLED: "
|
|
378
|
+
f"TorrentDownloader={has_torrent_downloader}, Transmission={transmission_available}. "
|
|
379
|
+
"Only YouTube sources will be used."
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Add providers and downloaders based on available API keys and URLs
|
|
383
|
+
if self._red_api_key and self._red_api_url and can_use_trackers:
|
|
384
|
+
from flacfetch.providers.red import REDProvider
|
|
179
385
|
|
|
180
|
-
self._manager.add_provider(
|
|
181
|
-
self.
|
|
386
|
+
self._manager.add_provider(REDProvider(api_key=self._red_api_key, base_url=self._red_api_url))
|
|
387
|
+
self._manager.register_downloader("RED", TorrentDownloader())
|
|
388
|
+
self.logger.info("[FlacFetcher] Added RED provider with TorrentDownloader")
|
|
389
|
+
elif self._red_api_key and not self._red_api_url:
|
|
390
|
+
self.logger.warning("[FlacFetcher] RED_API_KEY set but RED_API_URL not set - RED provider disabled")
|
|
182
391
|
|
|
183
|
-
if self._ops_api_key:
|
|
392
|
+
if self._ops_api_key and self._ops_api_url and can_use_trackers:
|
|
184
393
|
from flacfetch.providers.ops import OPSProvider
|
|
185
394
|
|
|
186
|
-
self._manager.add_provider(OPSProvider(api_key=self._ops_api_key))
|
|
187
|
-
self.
|
|
395
|
+
self._manager.add_provider(OPSProvider(api_key=self._ops_api_key, base_url=self._ops_api_url))
|
|
396
|
+
self._manager.register_downloader("OPS", TorrentDownloader())
|
|
397
|
+
self.logger.info("[FlacFetcher] Added OPS provider with TorrentDownloader")
|
|
398
|
+
elif self._ops_api_key and not self._ops_api_url:
|
|
399
|
+
self.logger.warning("[FlacFetcher] OPS_API_KEY set but OPS_API_URL not set - OPS provider disabled")
|
|
188
400
|
|
|
189
|
-
# Always add YouTube as a fallback provider
|
|
190
|
-
self._manager.add_provider(
|
|
401
|
+
# Always add YouTube as a fallback provider with its downloader
|
|
402
|
+
self._manager.add_provider(YoutubeProvider())
|
|
403
|
+
self._manager.register_downloader("YouTube", YoutubeDownloader())
|
|
191
404
|
self.logger.debug("Added YouTube provider")
|
|
192
405
|
|
|
193
406
|
return self._manager
|
|
@@ -219,16 +432,23 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
219
432
|
|
|
220
433
|
# Convert to our AudioSearchResult format
|
|
221
434
|
search_results = []
|
|
222
|
-
for result in results:
|
|
435
|
+
for i, result in enumerate(results):
|
|
436
|
+
# Get quality as string if it's a Quality object
|
|
437
|
+
quality = getattr(result, "quality", None)
|
|
438
|
+
quality_str = str(quality) if quality else None
|
|
439
|
+
|
|
223
440
|
search_results.append(
|
|
224
441
|
AudioSearchResult(
|
|
225
442
|
title=getattr(result, "title", title),
|
|
226
443
|
artist=getattr(result, "artist", artist),
|
|
227
|
-
url=getattr(result, "
|
|
228
|
-
provider=getattr(result, "
|
|
229
|
-
duration=getattr(result, "
|
|
230
|
-
quality=
|
|
231
|
-
source_id=getattr(result, "
|
|
444
|
+
url=getattr(result, "download_url", "") or "",
|
|
445
|
+
provider=getattr(result, "source_name", "Unknown"),
|
|
446
|
+
duration=getattr(result, "duration_seconds", None),
|
|
447
|
+
quality=quality_str,
|
|
448
|
+
source_id=getattr(result, "info_hash", None),
|
|
449
|
+
index=i, # Set index for API selection
|
|
450
|
+
seeders=getattr(result, "seeders", None),
|
|
451
|
+
target_file=getattr(result, "target_file", None),
|
|
232
452
|
raw_result=result,
|
|
233
453
|
)
|
|
234
454
|
)
|
|
@@ -265,7 +485,7 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
265
485
|
if output_filename is None:
|
|
266
486
|
output_filename = f"{result.artist} - {result.title}"
|
|
267
487
|
|
|
268
|
-
self.logger.info(f"Downloading: {result.artist} - {result.title} from {result.provider}")
|
|
488
|
+
self.logger.info(f"Downloading: {result.artist} - {result.title} from {result.provider or 'Unknown'}")
|
|
269
489
|
|
|
270
490
|
try:
|
|
271
491
|
# Use flacfetch to download
|
|
@@ -292,6 +512,40 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
292
512
|
except Exception as e:
|
|
293
513
|
raise DownloadError(f"Failed to download {result.artist} - {result.title}: {e}") from e
|
|
294
514
|
|
|
515
|
+
def select_best(self, results: List[AudioSearchResult]) -> int:
|
|
516
|
+
"""
|
|
517
|
+
Select the best result from a list of search results.
|
|
518
|
+
|
|
519
|
+
Uses flacfetch's built-in quality ranking to determine the best source.
|
|
520
|
+
This is useful for automated/non-interactive usage.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
results: List of AudioSearchResult objects from search()
|
|
524
|
+
|
|
525
|
+
Returns:
|
|
526
|
+
Index of the best result in the list
|
|
527
|
+
"""
|
|
528
|
+
if not results:
|
|
529
|
+
return 0
|
|
530
|
+
|
|
531
|
+
manager = self._get_manager()
|
|
532
|
+
|
|
533
|
+
# Get raw results that have raw_result set
|
|
534
|
+
raw_results = [r.raw_result for r in results if r.raw_result is not None]
|
|
535
|
+
|
|
536
|
+
if raw_results:
|
|
537
|
+
try:
|
|
538
|
+
best = manager.select_best(raw_results)
|
|
539
|
+
# Find index of best result
|
|
540
|
+
for i, r in enumerate(results):
|
|
541
|
+
if r.raw_result == best:
|
|
542
|
+
return i
|
|
543
|
+
except Exception as e:
|
|
544
|
+
self.logger.warning(f"select_best failed, using first result: {e}")
|
|
545
|
+
|
|
546
|
+
# Fallback: return first result
|
|
547
|
+
return 0
|
|
548
|
+
|
|
295
549
|
def search_and_download(
|
|
296
550
|
self,
|
|
297
551
|
artist: str,
|
|
@@ -319,6 +573,7 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
319
573
|
Raises:
|
|
320
574
|
NoResultsError: If no results are found
|
|
321
575
|
DownloadError: If download fails
|
|
576
|
+
UserCancelledError: If user cancels (Ctrl+C or enters 0)
|
|
322
577
|
"""
|
|
323
578
|
from flacfetch.core.models import TrackQuery
|
|
324
579
|
|
|
@@ -326,7 +581,9 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
326
581
|
query = TrackQuery(artist=artist, title=title)
|
|
327
582
|
|
|
328
583
|
self.logger.info(f"Searching for: {artist} - {title}")
|
|
329
|
-
|
|
584
|
+
|
|
585
|
+
# Run search in a thread so we can handle Ctrl+C
|
|
586
|
+
results = self._interruptible_search(manager, query)
|
|
330
587
|
|
|
331
588
|
if not results:
|
|
332
589
|
raise NoResultsError(f"No results found for: {artist} - {title}")
|
|
@@ -336,11 +593,13 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
336
593
|
if auto_select:
|
|
337
594
|
# Auto mode: select best result based on flacfetch's ranking
|
|
338
595
|
selected = manager.select_best(results)
|
|
339
|
-
self.logger.info(f"Auto-selected: {getattr(selected, 'title', title)} from {getattr(selected, '
|
|
596
|
+
self.logger.info(f"Auto-selected: {getattr(selected, 'title', title)} from {getattr(selected, 'source_name', 'Unknown')}")
|
|
340
597
|
else:
|
|
341
598
|
# Interactive mode: present options to user
|
|
342
599
|
selected = self._interactive_select(results, artist, title)
|
|
343
600
|
|
|
601
|
+
# Note: _interactive_select now raises UserCancelledError instead of returning None
|
|
602
|
+
# This check is kept as a safety net
|
|
344
603
|
if selected is None:
|
|
345
604
|
raise NoResultsError(f"No result selected for: {artist} - {title}")
|
|
346
605
|
|
|
@@ -351,69 +610,262 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
351
610
|
if output_filename is None:
|
|
352
611
|
output_filename = f"{artist} - {title}"
|
|
353
612
|
|
|
354
|
-
self.logger.info(f"Downloading from {getattr(selected, '
|
|
613
|
+
self.logger.info(f"Downloading from {getattr(selected, 'source_name', 'Unknown')}...")
|
|
355
614
|
|
|
356
615
|
try:
|
|
357
|
-
|
|
616
|
+
# Use interruptible download so Ctrl+C works during torrent downloads
|
|
617
|
+
filepath = self._interruptible_download(
|
|
618
|
+
manager,
|
|
358
619
|
selected,
|
|
359
620
|
output_path=output_dir,
|
|
360
621
|
output_filename=output_filename,
|
|
361
622
|
)
|
|
362
623
|
|
|
363
|
-
if filepath
|
|
624
|
+
if not filepath:
|
|
364
625
|
raise DownloadError(f"Download returned no file path for: {artist} - {title}")
|
|
365
626
|
|
|
366
627
|
self.logger.info(f"Downloaded to: {filepath}")
|
|
367
628
|
|
|
629
|
+
# Get quality as string if it's a Quality object
|
|
630
|
+
quality = getattr(selected, "quality", None)
|
|
631
|
+
quality_str = str(quality) if quality else None
|
|
632
|
+
|
|
368
633
|
return AudioFetchResult(
|
|
369
634
|
filepath=filepath,
|
|
370
635
|
artist=artist,
|
|
371
636
|
title=title,
|
|
372
|
-
provider=getattr(selected, "
|
|
373
|
-
duration=getattr(selected, "
|
|
374
|
-
quality=
|
|
637
|
+
provider=getattr(selected, "source_name", "Unknown"),
|
|
638
|
+
duration=getattr(selected, "duration_seconds", None),
|
|
639
|
+
quality=quality_str,
|
|
375
640
|
)
|
|
376
641
|
|
|
642
|
+
except (UserCancelledError, KeyboardInterrupt):
|
|
643
|
+
# Let cancellation exceptions propagate without wrapping
|
|
644
|
+
raise
|
|
377
645
|
except Exception as e:
|
|
378
646
|
raise DownloadError(f"Failed to download {artist} - {title}: {e}") from e
|
|
379
647
|
|
|
648
|
+
def _interruptible_search(self, manager, query) -> list:
|
|
649
|
+
"""
|
|
650
|
+
Run search in a way that can be interrupted by Ctrl+C.
|
|
651
|
+
|
|
652
|
+
The flacfetch search is a blocking network operation that doesn't
|
|
653
|
+
respond to SIGINT while running. This method runs it in a background
|
|
654
|
+
thread and periodically checks for interrupts.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
manager: The FetchManager instance
|
|
658
|
+
query: The TrackQuery to search for
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
List of search results
|
|
662
|
+
|
|
663
|
+
Raises:
|
|
664
|
+
UserCancelledError: If user presses Ctrl+C during search
|
|
665
|
+
"""
|
|
666
|
+
global _interrupt_requested
|
|
667
|
+
_interrupt_requested = False
|
|
668
|
+
result_container = {"results": None, "error": None}
|
|
669
|
+
|
|
670
|
+
def do_search():
|
|
671
|
+
try:
|
|
672
|
+
result_container["results"] = manager.search(query)
|
|
673
|
+
except Exception as e:
|
|
674
|
+
result_container["error"] = e
|
|
675
|
+
|
|
676
|
+
# Set up signal handler for immediate response to Ctrl+C
|
|
677
|
+
original_handler = signal.getsignal(signal.SIGINT)
|
|
678
|
+
|
|
679
|
+
def interrupt_handler(signum, frame):
|
|
680
|
+
global _interrupt_requested
|
|
681
|
+
_interrupt_requested = True
|
|
682
|
+
# Print immediately so user knows it was received
|
|
683
|
+
print("\nCancelling... please wait", file=sys.stderr)
|
|
684
|
+
|
|
685
|
+
signal.signal(signal.SIGINT, interrupt_handler)
|
|
686
|
+
|
|
687
|
+
try:
|
|
688
|
+
# Start search in background thread
|
|
689
|
+
search_thread = threading.Thread(target=do_search, daemon=True)
|
|
690
|
+
search_thread.start()
|
|
691
|
+
|
|
692
|
+
# Wait for completion with periodic interrupt checks
|
|
693
|
+
while search_thread.is_alive():
|
|
694
|
+
search_thread.join(timeout=0.1) # Check every 100ms
|
|
695
|
+
if _interrupt_requested:
|
|
696
|
+
# Don't wait for thread - it's a daemon and will be killed
|
|
697
|
+
raise UserCancelledError("Search cancelled by user (Ctrl+C)")
|
|
698
|
+
|
|
699
|
+
# Check for errors from the search
|
|
700
|
+
if result_container["error"] is not None:
|
|
701
|
+
raise result_container["error"]
|
|
702
|
+
|
|
703
|
+
return result_container["results"]
|
|
704
|
+
|
|
705
|
+
finally:
|
|
706
|
+
# Restore original signal handler
|
|
707
|
+
signal.signal(signal.SIGINT, original_handler)
|
|
708
|
+
_interrupt_requested = False
|
|
709
|
+
|
|
710
|
+
def _interruptible_download(self, manager, selected, output_path: str, output_filename: str) -> str:
|
|
711
|
+
"""
|
|
712
|
+
Run download in a way that can be interrupted by Ctrl+C.
|
|
713
|
+
|
|
714
|
+
The flacfetch/transmission download is a blocking operation that doesn't
|
|
715
|
+
respond to SIGINT while running (especially for torrent downloads).
|
|
716
|
+
This method runs it in a background thread and periodically checks for interrupts.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
manager: The FetchManager instance
|
|
720
|
+
selected: The selected result to download
|
|
721
|
+
output_path: Directory to save the file
|
|
722
|
+
output_filename: Filename to save as
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
Path to the downloaded file
|
|
726
|
+
|
|
727
|
+
Raises:
|
|
728
|
+
UserCancelledError: If user presses Ctrl+C during download
|
|
729
|
+
DownloadError: If download fails
|
|
730
|
+
"""
|
|
731
|
+
global _interrupt_requested
|
|
732
|
+
_interrupt_requested = False
|
|
733
|
+
result_container = {"filepath": None, "error": None}
|
|
734
|
+
was_cancelled = False
|
|
735
|
+
|
|
736
|
+
def do_download():
|
|
737
|
+
try:
|
|
738
|
+
result_container["filepath"] = manager.download(
|
|
739
|
+
selected,
|
|
740
|
+
output_path=output_path,
|
|
741
|
+
output_filename=output_filename,
|
|
742
|
+
)
|
|
743
|
+
except Exception as e:
|
|
744
|
+
result_container["error"] = e
|
|
745
|
+
|
|
746
|
+
# Set up signal handler for immediate response to Ctrl+C
|
|
747
|
+
original_handler = signal.getsignal(signal.SIGINT)
|
|
748
|
+
|
|
749
|
+
def interrupt_handler(signum, frame):
|
|
750
|
+
global _interrupt_requested
|
|
751
|
+
_interrupt_requested = True
|
|
752
|
+
# Print immediately so user knows it was received
|
|
753
|
+
print("\nCancelling download... please wait (may take a few seconds)", file=sys.stderr)
|
|
754
|
+
|
|
755
|
+
signal.signal(signal.SIGINT, interrupt_handler)
|
|
756
|
+
|
|
757
|
+
try:
|
|
758
|
+
# Start download in background thread
|
|
759
|
+
download_thread = threading.Thread(target=do_download, daemon=True)
|
|
760
|
+
download_thread.start()
|
|
761
|
+
|
|
762
|
+
# Wait for completion with periodic interrupt checks
|
|
763
|
+
while download_thread.is_alive():
|
|
764
|
+
download_thread.join(timeout=0.2) # Check every 200ms
|
|
765
|
+
if _interrupt_requested:
|
|
766
|
+
was_cancelled = True
|
|
767
|
+
# Clean up any pending torrents before raising
|
|
768
|
+
self._cleanup_transmission_torrents(selected)
|
|
769
|
+
raise UserCancelledError("Download cancelled by user (Ctrl+C)")
|
|
770
|
+
|
|
771
|
+
# Check for errors from the download
|
|
772
|
+
if result_container["error"] is not None:
|
|
773
|
+
raise result_container["error"]
|
|
774
|
+
|
|
775
|
+
return result_container["filepath"]
|
|
776
|
+
|
|
777
|
+
finally:
|
|
778
|
+
# Restore original signal handler
|
|
779
|
+
signal.signal(signal.SIGINT, original_handler)
|
|
780
|
+
_interrupt_requested = False
|
|
781
|
+
|
|
782
|
+
def _cleanup_transmission_torrents(self, selected) -> None:
|
|
783
|
+
"""
|
|
784
|
+
Clean up any torrents in Transmission that were started for this download.
|
|
785
|
+
|
|
786
|
+
Called when a download is cancelled to remove incomplete torrents and their data.
|
|
787
|
+
|
|
788
|
+
Args:
|
|
789
|
+
selected: The selected result that was being downloaded
|
|
790
|
+
"""
|
|
791
|
+
try:
|
|
792
|
+
import transmission_rpc
|
|
793
|
+
host = os.environ.get("TRANSMISSION_HOST", "localhost")
|
|
794
|
+
port = int(os.environ.get("TRANSMISSION_PORT", "9091"))
|
|
795
|
+
client = transmission_rpc.Client(host=host, port=port, timeout=5)
|
|
796
|
+
|
|
797
|
+
# Get the release name to match against torrents
|
|
798
|
+
release_name = getattr(selected, 'name', None) or getattr(selected, 'title', None)
|
|
799
|
+
if not release_name:
|
|
800
|
+
self.logger.debug("[Transmission] No release name to match for cleanup")
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
# Find and remove matching incomplete torrents
|
|
804
|
+
torrents = client.get_torrents()
|
|
805
|
+
for torrent in torrents:
|
|
806
|
+
# Match by name similarity and incomplete status
|
|
807
|
+
if torrent.progress < 100 and release_name.lower() in torrent.name.lower():
|
|
808
|
+
self.logger.info(f"[Transmission] Removing cancelled torrent: {torrent.name}")
|
|
809
|
+
client.remove_torrent(torrent.id, delete_data=True)
|
|
810
|
+
|
|
811
|
+
except Exception as e:
|
|
812
|
+
# Don't fail the cancellation if cleanup fails
|
|
813
|
+
self.logger.debug(f"[Transmission] Cleanup failed (non-fatal): {e}")
|
|
814
|
+
|
|
380
815
|
def _interactive_select(self, results: list, artist: str, title: str) -> object:
|
|
381
816
|
"""
|
|
382
817
|
Present search results to the user for interactive selection.
|
|
383
818
|
|
|
819
|
+
Uses flacfetch's built-in CLIHandler for rich, colorized output.
|
|
820
|
+
|
|
384
821
|
Args:
|
|
385
|
-
results: List of
|
|
822
|
+
results: List of Release objects from flacfetch
|
|
386
823
|
artist: The artist name being searched
|
|
387
824
|
title: The track title being searched
|
|
388
825
|
|
|
389
826
|
Returns:
|
|
390
|
-
The selected
|
|
827
|
+
The selected Release object
|
|
828
|
+
|
|
829
|
+
Raises:
|
|
830
|
+
UserCancelledError: If user cancels selection
|
|
391
831
|
"""
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
#
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
832
|
+
try:
|
|
833
|
+
# Use flacfetch's built-in CLIHandler for rich display
|
|
834
|
+
from flacfetch.interface.cli import CLIHandler
|
|
835
|
+
|
|
836
|
+
handler = CLIHandler(target_artist=artist)
|
|
837
|
+
result = handler.select_release(results)
|
|
838
|
+
if result is None:
|
|
839
|
+
# User selected 0 to cancel
|
|
840
|
+
raise UserCancelledError("Selection cancelled by user")
|
|
841
|
+
return result
|
|
842
|
+
except ImportError:
|
|
843
|
+
# Fallback to basic display if CLIHandler not available
|
|
844
|
+
return self._basic_interactive_select(results, artist, title)
|
|
845
|
+
except (KeyboardInterrupt, EOFError):
|
|
846
|
+
raise UserCancelledError("Selection cancelled by user (Ctrl+C)")
|
|
847
|
+
except (AttributeError, TypeError):
|
|
848
|
+
# Fallback if results aren't proper Release objects (e.g., in tests)
|
|
849
|
+
return self._basic_interactive_select(results, artist, title)
|
|
850
|
+
|
|
851
|
+
def _basic_interactive_select(self, results: list, artist: str, title: str) -> object:
|
|
852
|
+
"""
|
|
853
|
+
Basic fallback for interactive selection without rich formatting.
|
|
411
854
|
|
|
412
|
-
|
|
855
|
+
Args:
|
|
856
|
+
results: List of Release objects from flacfetch
|
|
857
|
+
artist: The artist name being searched
|
|
858
|
+
title: The track title being searched
|
|
413
859
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
860
|
+
Returns:
|
|
861
|
+
The selected Release object
|
|
862
|
+
|
|
863
|
+
Raises:
|
|
864
|
+
UserCancelledError: If user cancels selection
|
|
865
|
+
"""
|
|
866
|
+
# Use flacfetch's shared display function
|
|
867
|
+
from flacfetch import print_releases
|
|
868
|
+
print_releases(results, target_artist=artist, use_colors=True)
|
|
417
869
|
|
|
418
870
|
while True:
|
|
419
871
|
try:
|
|
@@ -421,7 +873,7 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
421
873
|
|
|
422
874
|
if choice == "0":
|
|
423
875
|
self.logger.info("User cancelled selection")
|
|
424
|
-
|
|
876
|
+
raise UserCancelledError("Selection cancelled by user")
|
|
425
877
|
|
|
426
878
|
choice_num = int(choice)
|
|
427
879
|
if 1 <= choice_num <= len(results):
|
|
@@ -435,27 +887,728 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
435
887
|
print("Please enter a valid number")
|
|
436
888
|
except KeyboardInterrupt:
|
|
437
889
|
print("\nCancelled")
|
|
438
|
-
|
|
890
|
+
raise UserCancelledError("Selection cancelled by user (Ctrl+C)")
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
# Alias for shorter name - used by backend and other consumers
|
|
894
|
+
FlacFetcher = FlacFetchAudioFetcher
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
class RemoteFlacFetchAudioFetcher(AudioFetcher):
|
|
898
|
+
"""
|
|
899
|
+
Audio fetcher implementation using remote flacfetch HTTP API.
|
|
900
|
+
|
|
901
|
+
This fetcher communicates with a dedicated flacfetch server that handles:
|
|
902
|
+
- BitTorrent downloads from private trackers (RED, OPS)
|
|
903
|
+
- YouTube downloads
|
|
904
|
+
- File streaming back to the client
|
|
905
|
+
|
|
906
|
+
Used when FLACFETCH_API_URL and FLACFETCH_API_KEY environment variables are set.
|
|
907
|
+
"""
|
|
908
|
+
|
|
909
|
+
def __init__(
|
|
910
|
+
self,
|
|
911
|
+
api_url: str,
|
|
912
|
+
api_key: str,
|
|
913
|
+
logger: Optional[logging.Logger] = None,
|
|
914
|
+
timeout: int = 60,
|
|
915
|
+
download_timeout: int = 600,
|
|
916
|
+
):
|
|
917
|
+
"""
|
|
918
|
+
Initialize the remote FlacFetch audio fetcher.
|
|
919
|
+
|
|
920
|
+
Args:
|
|
921
|
+
api_url: Base URL of flacfetch API server (e.g., http://10.0.0.5:8080)
|
|
922
|
+
api_key: API key for authentication
|
|
923
|
+
logger: Logger instance for output
|
|
924
|
+
timeout: Request timeout in seconds for search/status calls
|
|
925
|
+
download_timeout: Maximum wait time for downloads to complete
|
|
926
|
+
"""
|
|
927
|
+
if not HTTPX_AVAILABLE:
|
|
928
|
+
raise ImportError("httpx is required for remote flacfetch. Install with: pip install httpx")
|
|
929
|
+
|
|
930
|
+
self.api_url = api_url.rstrip('/')
|
|
931
|
+
self.api_key = api_key
|
|
932
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
933
|
+
self.timeout = timeout
|
|
934
|
+
self.download_timeout = download_timeout
|
|
935
|
+
self._last_search_id: Optional[str] = None
|
|
936
|
+
self._last_search_results: List[Dict[str, Any]] = []
|
|
937
|
+
|
|
938
|
+
self.logger.info(f"[RemoteFlacFetcher] Initialized with API URL: {self.api_url}")
|
|
939
|
+
|
|
940
|
+
def _headers(self) -> Dict[str, str]:
|
|
941
|
+
"""Get request headers with authentication."""
|
|
942
|
+
return {
|
|
943
|
+
"X-API-Key": self.api_key,
|
|
944
|
+
"Content-Type": "application/json",
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
def _check_health(self) -> bool:
|
|
948
|
+
"""Check if the remote flacfetch service is healthy."""
|
|
949
|
+
try:
|
|
950
|
+
with httpx.Client() as client:
|
|
951
|
+
resp = client.get(
|
|
952
|
+
f"{self.api_url}/health",
|
|
953
|
+
headers=self._headers(),
|
|
954
|
+
timeout=10,
|
|
955
|
+
)
|
|
956
|
+
if resp.status_code == 200:
|
|
957
|
+
data = resp.json()
|
|
958
|
+
status = data.get("status", "unknown")
|
|
959
|
+
self.logger.debug(f"[RemoteFlacFetcher] Health check: {status}")
|
|
960
|
+
return status in ["healthy", "degraded"]
|
|
961
|
+
return False
|
|
962
|
+
except Exception as e:
|
|
963
|
+
self.logger.warning(f"[RemoteFlacFetcher] Health check failed: {e}")
|
|
964
|
+
return False
|
|
965
|
+
|
|
966
|
+
def search(self, artist: str, title: str) -> List[AudioSearchResult]:
|
|
967
|
+
"""
|
|
968
|
+
Search for audio matching the given artist and title via remote API.
|
|
969
|
+
|
|
970
|
+
Args:
|
|
971
|
+
artist: The artist name to search for
|
|
972
|
+
title: The track title to search for
|
|
973
|
+
|
|
974
|
+
Returns:
|
|
975
|
+
List of AudioSearchResult objects
|
|
976
|
+
|
|
977
|
+
Raises:
|
|
978
|
+
NoResultsError: If no results are found
|
|
979
|
+
AudioFetcherError: For other errors
|
|
980
|
+
"""
|
|
981
|
+
self.logger.info(f"[RemoteFlacFetcher] Searching for: {artist} - {title}")
|
|
982
|
+
|
|
983
|
+
try:
|
|
984
|
+
with httpx.Client() as client:
|
|
985
|
+
resp = client.post(
|
|
986
|
+
f"{self.api_url}/search",
|
|
987
|
+
headers=self._headers(),
|
|
988
|
+
json={"artist": artist, "title": title},
|
|
989
|
+
timeout=self.timeout,
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
if resp.status_code == 404:
|
|
993
|
+
raise NoResultsError(f"No results found for: {artist} - {title}")
|
|
994
|
+
|
|
995
|
+
resp.raise_for_status()
|
|
996
|
+
data = resp.json()
|
|
997
|
+
|
|
998
|
+
self._last_search_id = data.get("search_id")
|
|
999
|
+
self._last_search_results = data.get("results", [])
|
|
1000
|
+
|
|
1001
|
+
if not self._last_search_results:
|
|
1002
|
+
raise NoResultsError(f"No results found for: {artist} - {title}")
|
|
1003
|
+
|
|
1004
|
+
# Convert API results to AudioSearchResult objects
|
|
1005
|
+
search_results = []
|
|
1006
|
+
for i, result in enumerate(self._last_search_results):
|
|
1007
|
+
search_results.append(
|
|
1008
|
+
AudioSearchResult(
|
|
1009
|
+
title=result.get("title", title),
|
|
1010
|
+
artist=result.get("artist", artist),
|
|
1011
|
+
url=result.get("download_url", "") or result.get("url", ""),
|
|
1012
|
+
provider=result.get("provider", result.get("source_name", "Unknown")),
|
|
1013
|
+
duration=result.get("duration_seconds", result.get("duration")),
|
|
1014
|
+
quality=result.get("quality_str", result.get("quality")),
|
|
1015
|
+
source_id=result.get("info_hash"),
|
|
1016
|
+
index=i,
|
|
1017
|
+
seeders=result.get("seeders"),
|
|
1018
|
+
target_file=result.get("target_file"),
|
|
1019
|
+
raw_result=result, # Store the full API result
|
|
1020
|
+
)
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
self.logger.info(f"[RemoteFlacFetcher] Found {len(search_results)} results")
|
|
1024
|
+
return search_results
|
|
1025
|
+
|
|
1026
|
+
except httpx.RequestError as e:
|
|
1027
|
+
raise AudioFetcherError(f"Search request failed: {e}") from e
|
|
1028
|
+
except httpx.HTTPStatusError as e:
|
|
1029
|
+
if e.response.status_code == 404:
|
|
1030
|
+
raise NoResultsError(f"No results found for: {artist} - {title}") from e
|
|
1031
|
+
raise AudioFetcherError(f"Search failed: {e.response.status_code} - {e.response.text}") from e
|
|
1032
|
+
|
|
1033
|
+
def download(
|
|
1034
|
+
self,
|
|
1035
|
+
result: AudioSearchResult,
|
|
1036
|
+
output_dir: str,
|
|
1037
|
+
output_filename: Optional[str] = None,
|
|
1038
|
+
) -> AudioFetchResult:
|
|
1039
|
+
"""
|
|
1040
|
+
Download audio from a search result via remote API.
|
|
1041
|
+
|
|
1042
|
+
Args:
|
|
1043
|
+
result: The search result to download
|
|
1044
|
+
output_dir: Directory to save the downloaded file
|
|
1045
|
+
output_filename: Optional filename (without extension)
|
|
1046
|
+
|
|
1047
|
+
Returns:
|
|
1048
|
+
AudioFetchResult with the downloaded file path
|
|
1049
|
+
|
|
1050
|
+
Raises:
|
|
1051
|
+
DownloadError: If download fails
|
|
1052
|
+
"""
|
|
1053
|
+
if not self._last_search_id:
|
|
1054
|
+
raise DownloadError("No search performed - call search() first")
|
|
1055
|
+
|
|
1056
|
+
# Ensure output directory exists
|
|
1057
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1058
|
+
|
|
1059
|
+
# Generate filename if not provided
|
|
1060
|
+
if output_filename is None:
|
|
1061
|
+
output_filename = f"{result.artist} - {result.title}"
|
|
1062
|
+
|
|
1063
|
+
self.logger.info(f"[RemoteFlacFetcher] Downloading: {result.artist} - {result.title} from {result.provider}")
|
|
1064
|
+
|
|
1065
|
+
try:
|
|
1066
|
+
# Start the download
|
|
1067
|
+
with httpx.Client() as client:
|
|
1068
|
+
resp = client.post(
|
|
1069
|
+
f"{self.api_url}/download",
|
|
1070
|
+
headers=self._headers(),
|
|
1071
|
+
json={
|
|
1072
|
+
"search_id": self._last_search_id,
|
|
1073
|
+
"result_index": result.index,
|
|
1074
|
+
"output_filename": output_filename,
|
|
1075
|
+
# Don't set upload_to_gcs - we want local download
|
|
1076
|
+
},
|
|
1077
|
+
timeout=self.timeout,
|
|
1078
|
+
)
|
|
1079
|
+
resp.raise_for_status()
|
|
1080
|
+
data = resp.json()
|
|
1081
|
+
download_id = data.get("download_id")
|
|
1082
|
+
|
|
1083
|
+
if not download_id:
|
|
1084
|
+
raise DownloadError("No download_id returned from API")
|
|
1085
|
+
|
|
1086
|
+
self.logger.info(f"[RemoteFlacFetcher] Download started: {download_id}")
|
|
1087
|
+
|
|
1088
|
+
# Wait for download to complete
|
|
1089
|
+
filepath = self._wait_and_stream_download(
|
|
1090
|
+
download_id=download_id,
|
|
1091
|
+
output_dir=output_dir,
|
|
1092
|
+
output_filename=output_filename,
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
self.logger.info(f"[RemoteFlacFetcher] Downloaded to: {filepath}")
|
|
1096
|
+
|
|
1097
|
+
return AudioFetchResult(
|
|
1098
|
+
filepath=filepath,
|
|
1099
|
+
artist=result.artist,
|
|
1100
|
+
title=result.title,
|
|
1101
|
+
provider=result.provider,
|
|
1102
|
+
duration=result.duration,
|
|
1103
|
+
quality=result.quality,
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
except httpx.RequestError as e:
|
|
1107
|
+
raise DownloadError(f"Download request failed: {e}") from e
|
|
1108
|
+
except httpx.HTTPStatusError as e:
|
|
1109
|
+
raise DownloadError(f"Download failed: {e.response.status_code} - {e.response.text}") from e
|
|
1110
|
+
|
|
1111
|
+
def _wait_and_stream_download(
|
|
1112
|
+
self,
|
|
1113
|
+
download_id: str,
|
|
1114
|
+
output_dir: str,
|
|
1115
|
+
output_filename: str,
|
|
1116
|
+
poll_interval: float = 2.0,
|
|
1117
|
+
) -> str:
|
|
1118
|
+
"""
|
|
1119
|
+
Wait for a remote download to complete, then stream the file locally.
|
|
1120
|
+
|
|
1121
|
+
Args:
|
|
1122
|
+
download_id: Download ID from /download endpoint
|
|
1123
|
+
output_dir: Local directory to save file
|
|
1124
|
+
output_filename: Local filename (without extension)
|
|
1125
|
+
poll_interval: Seconds between status checks
|
|
1126
|
+
|
|
1127
|
+
Returns:
|
|
1128
|
+
Path to the downloaded local file
|
|
1129
|
+
|
|
1130
|
+
Raises:
|
|
1131
|
+
DownloadError: On download failure or timeout
|
|
1132
|
+
UserCancelledError: If user presses Ctrl+C
|
|
1133
|
+
"""
|
|
1134
|
+
global _interrupt_requested
|
|
1135
|
+
_interrupt_requested = False
|
|
1136
|
+
|
|
1137
|
+
# Set up signal handler for Ctrl+C
|
|
1138
|
+
original_handler = signal.getsignal(signal.SIGINT)
|
|
1139
|
+
|
|
1140
|
+
def interrupt_handler(signum, frame):
|
|
1141
|
+
global _interrupt_requested
|
|
1142
|
+
_interrupt_requested = True
|
|
1143
|
+
print("\nCancelling download... please wait", file=sys.stderr)
|
|
1144
|
+
|
|
1145
|
+
signal.signal(signal.SIGINT, interrupt_handler)
|
|
1146
|
+
|
|
1147
|
+
try:
|
|
1148
|
+
elapsed = 0.0
|
|
1149
|
+
last_progress = -1
|
|
1150
|
+
|
|
1151
|
+
while elapsed < self.download_timeout:
|
|
1152
|
+
# Check for interrupt
|
|
1153
|
+
if _interrupt_requested:
|
|
1154
|
+
raise UserCancelledError("Download cancelled by user (Ctrl+C)")
|
|
1155
|
+
|
|
1156
|
+
# Check status
|
|
1157
|
+
with httpx.Client() as client:
|
|
1158
|
+
resp = client.get(
|
|
1159
|
+
f"{self.api_url}/download/{download_id}/status",
|
|
1160
|
+
headers=self._headers(),
|
|
1161
|
+
timeout=10,
|
|
1162
|
+
)
|
|
1163
|
+
resp.raise_for_status()
|
|
1164
|
+
status = resp.json()
|
|
1165
|
+
|
|
1166
|
+
download_status = status.get("status")
|
|
1167
|
+
progress = status.get("progress", 0)
|
|
1168
|
+
speed = status.get("download_speed_kbps", 0)
|
|
1169
|
+
|
|
1170
|
+
# Log progress updates
|
|
1171
|
+
if int(progress) != last_progress:
|
|
1172
|
+
if download_status == "downloading":
|
|
1173
|
+
self.logger.info(f"[RemoteFlacFetcher] Progress: {progress:.1f}% ({speed:.1f} KB/s)")
|
|
1174
|
+
elif download_status in ["uploading", "processing"]:
|
|
1175
|
+
self.logger.info(f"[RemoteFlacFetcher] {download_status.capitalize()}...")
|
|
1176
|
+
last_progress = int(progress)
|
|
1177
|
+
|
|
1178
|
+
if download_status in ["complete", "seeding"]:
|
|
1179
|
+
# Download complete - now stream the file locally
|
|
1180
|
+
self.logger.info(f"[RemoteFlacFetcher] Remote download complete, streaming to local...")
|
|
1181
|
+
return self._stream_file_locally(download_id, output_dir, output_filename)
|
|
1182
|
+
|
|
1183
|
+
elif download_status == "failed":
|
|
1184
|
+
error = status.get("error", "Unknown error")
|
|
1185
|
+
raise DownloadError(f"Remote download failed: {error}")
|
|
1186
|
+
|
|
1187
|
+
elif download_status == "cancelled":
|
|
1188
|
+
raise DownloadError("Download was cancelled on server")
|
|
1189
|
+
|
|
1190
|
+
time.sleep(poll_interval)
|
|
1191
|
+
elapsed += poll_interval
|
|
1192
|
+
|
|
1193
|
+
raise DownloadError(f"Download timed out after {self.download_timeout}s")
|
|
1194
|
+
|
|
1195
|
+
finally:
|
|
1196
|
+
# Restore original signal handler
|
|
1197
|
+
signal.signal(signal.SIGINT, original_handler)
|
|
1198
|
+
_interrupt_requested = False
|
|
1199
|
+
|
|
1200
|
+
def _stream_file_locally(
|
|
1201
|
+
self,
|
|
1202
|
+
download_id: str,
|
|
1203
|
+
output_dir: str,
|
|
1204
|
+
output_filename: str,
|
|
1205
|
+
) -> str:
|
|
1206
|
+
"""
|
|
1207
|
+
Stream a completed download from the remote server to local disk.
|
|
1208
|
+
|
|
1209
|
+
Args:
|
|
1210
|
+
download_id: Download ID
|
|
1211
|
+
output_dir: Local directory to save file
|
|
1212
|
+
output_filename: Local filename (without extension)
|
|
1213
|
+
|
|
1214
|
+
Returns:
|
|
1215
|
+
Path to the downloaded local file
|
|
1216
|
+
|
|
1217
|
+
Raises:
|
|
1218
|
+
DownloadError: On streaming failure
|
|
1219
|
+
"""
|
|
1220
|
+
try:
|
|
1221
|
+
# Stream the file from the remote server
|
|
1222
|
+
with httpx.Client() as client:
|
|
1223
|
+
with client.stream(
|
|
1224
|
+
"GET",
|
|
1225
|
+
f"{self.api_url}/download/{download_id}/file",
|
|
1226
|
+
headers=self._headers(),
|
|
1227
|
+
timeout=300, # 5 minute timeout for file streaming
|
|
1228
|
+
) as resp:
|
|
1229
|
+
resp.raise_for_status()
|
|
1230
|
+
|
|
1231
|
+
# Get content-disposition header for filename/extension
|
|
1232
|
+
content_disp = resp.headers.get("content-disposition", "")
|
|
1233
|
+
|
|
1234
|
+
# Try to extract extension from the server's filename
|
|
1235
|
+
extension = ".flac" # Default
|
|
1236
|
+
if "filename=" in content_disp:
|
|
1237
|
+
import re
|
|
1238
|
+
match = re.search(r'filename="?([^";\s]+)"?', content_disp)
|
|
1239
|
+
if match:
|
|
1240
|
+
server_filename = match.group(1)
|
|
1241
|
+
_, ext = os.path.splitext(server_filename)
|
|
1242
|
+
if ext:
|
|
1243
|
+
extension = ext
|
|
1244
|
+
|
|
1245
|
+
# Also try content-type
|
|
1246
|
+
content_type = resp.headers.get("content-type", "")
|
|
1247
|
+
if "audio/mpeg" in content_type or "audio/mp3" in content_type:
|
|
1248
|
+
extension = ".mp3"
|
|
1249
|
+
elif "audio/wav" in content_type:
|
|
1250
|
+
extension = ".wav"
|
|
1251
|
+
elif "audio/x-flac" in content_type or "audio/flac" in content_type:
|
|
1252
|
+
extension = ".flac"
|
|
1253
|
+
elif "audio/mp4" in content_type or "audio/m4a" in content_type:
|
|
1254
|
+
extension = ".m4a"
|
|
1255
|
+
|
|
1256
|
+
# Build local filepath
|
|
1257
|
+
local_filepath = os.path.join(output_dir, f"{output_filename}{extension}")
|
|
1258
|
+
|
|
1259
|
+
# Stream to local file
|
|
1260
|
+
total_bytes = 0
|
|
1261
|
+
with open(local_filepath, "wb") as f:
|
|
1262
|
+
for chunk in resp.iter_bytes(chunk_size=8192):
|
|
1263
|
+
f.write(chunk)
|
|
1264
|
+
total_bytes += len(chunk)
|
|
1265
|
+
|
|
1266
|
+
self.logger.info(f"[RemoteFlacFetcher] Streamed {total_bytes / 1024 / 1024:.1f} MB to {local_filepath}")
|
|
1267
|
+
return local_filepath
|
|
1268
|
+
|
|
1269
|
+
except httpx.RequestError as e:
|
|
1270
|
+
raise DownloadError(f"Failed to stream file: {e}") from e
|
|
1271
|
+
except httpx.HTTPStatusError as e:
|
|
1272
|
+
raise DownloadError(f"Failed to stream file: {e.response.status_code}") from e
|
|
1273
|
+
|
|
1274
|
+
def select_best(self, results: List[AudioSearchResult]) -> int:
|
|
1275
|
+
"""
|
|
1276
|
+
Select the best result from a list of search results.
|
|
1277
|
+
|
|
1278
|
+
For remote fetcher, we use simple heuristics since we don't have
|
|
1279
|
+
access to flacfetch's internal ranking. Prefers:
|
|
1280
|
+
1. Lossless sources (FLAC) over lossy
|
|
1281
|
+
2. Higher seeders for torrents
|
|
1282
|
+
3. First result otherwise (API typically returns sorted by quality)
|
|
1283
|
+
|
|
1284
|
+
Args:
|
|
1285
|
+
results: List of AudioSearchResult objects from search()
|
|
1286
|
+
|
|
1287
|
+
Returns:
|
|
1288
|
+
Index of the best result in the list
|
|
1289
|
+
"""
|
|
1290
|
+
if not results:
|
|
1291
|
+
return 0
|
|
1292
|
+
|
|
1293
|
+
# Score each result
|
|
1294
|
+
best_index = 0
|
|
1295
|
+
best_score = -1
|
|
1296
|
+
|
|
1297
|
+
for i, result in enumerate(results):
|
|
1298
|
+
score = 0
|
|
1299
|
+
|
|
1300
|
+
# Prefer lossless
|
|
1301
|
+
quality = (result.quality or "").lower()
|
|
1302
|
+
if "flac" in quality or "lossless" in quality:
|
|
1303
|
+
score += 1000
|
|
1304
|
+
elif "320" in quality:
|
|
1305
|
+
score += 500
|
|
1306
|
+
elif "256" in quality or "192" in quality:
|
|
1307
|
+
score += 200
|
|
1308
|
+
|
|
1309
|
+
# Prefer higher seeders (for torrents)
|
|
1310
|
+
if result.seeders:
|
|
1311
|
+
score += min(result.seeders, 100) # Cap at 100 points
|
|
1312
|
+
|
|
1313
|
+
# Prefer non-YouTube sources (typically higher quality)
|
|
1314
|
+
provider = (result.provider or "").lower()
|
|
1315
|
+
if "youtube" not in provider:
|
|
1316
|
+
score += 50
|
|
1317
|
+
|
|
1318
|
+
if score > best_score:
|
|
1319
|
+
best_score = score
|
|
1320
|
+
best_index = i
|
|
1321
|
+
|
|
1322
|
+
return best_index
|
|
1323
|
+
|
|
1324
|
+
def search_and_download(
|
|
1325
|
+
self,
|
|
1326
|
+
artist: str,
|
|
1327
|
+
title: str,
|
|
1328
|
+
output_dir: str,
|
|
1329
|
+
output_filename: Optional[str] = None,
|
|
1330
|
+
auto_select: bool = False,
|
|
1331
|
+
) -> AudioFetchResult:
|
|
1332
|
+
"""
|
|
1333
|
+
Search for audio and download it in one operation via remote API.
|
|
1334
|
+
|
|
1335
|
+
Args:
|
|
1336
|
+
artist: The artist name to search for
|
|
1337
|
+
title: The track title to search for
|
|
1338
|
+
output_dir: Directory to save the downloaded file
|
|
1339
|
+
output_filename: Optional filename (without extension)
|
|
1340
|
+
auto_select: If True, automatically select the best result
|
|
1341
|
+
|
|
1342
|
+
Returns:
|
|
1343
|
+
AudioFetchResult with the downloaded file path
|
|
1344
|
+
|
|
1345
|
+
Raises:
|
|
1346
|
+
NoResultsError: If no results are found
|
|
1347
|
+
DownloadError: If download fails
|
|
1348
|
+
UserCancelledError: If user cancels
|
|
1349
|
+
"""
|
|
1350
|
+
# Search
|
|
1351
|
+
results = self.search(artist, title)
|
|
1352
|
+
|
|
1353
|
+
if auto_select:
|
|
1354
|
+
# Auto mode: select best result
|
|
1355
|
+
best_index = self.select_best(results)
|
|
1356
|
+
selected = results[best_index]
|
|
1357
|
+
self.logger.info(f"[RemoteFlacFetcher] Auto-selected: {selected.title} from {selected.provider}")
|
|
1358
|
+
else:
|
|
1359
|
+
# Interactive mode: present options to user
|
|
1360
|
+
selected = self._interactive_select(results, artist, title)
|
|
1361
|
+
|
|
1362
|
+
# Download
|
|
1363
|
+
return self.download(selected, output_dir, output_filename)
|
|
1364
|
+
|
|
1365
|
+
def _convert_api_result_for_release(self, api_result: dict) -> dict:
|
|
1366
|
+
"""
|
|
1367
|
+
Convert API SearchResultItem format to format expected by Release.from_dict().
|
|
1368
|
+
|
|
1369
|
+
The flacfetch API returns:
|
|
1370
|
+
- provider: source name (RED, OPS, YouTube)
|
|
1371
|
+
- quality: display string (e.g., "FLAC 16bit CD")
|
|
1372
|
+
- quality_data: structured dict with format, bit_depth, media, etc.
|
|
1373
|
+
|
|
1374
|
+
But Release.from_dict() expects:
|
|
1375
|
+
- source_name: provider name
|
|
1376
|
+
- quality: dict with format, bit_depth, media, etc.
|
|
1377
|
+
|
|
1378
|
+
This mirrors the convert_api_result_to_display() function in flacfetch-remote CLI.
|
|
1379
|
+
"""
|
|
1380
|
+
result = dict(api_result) # Copy to avoid modifying original
|
|
1381
|
+
|
|
1382
|
+
# Map provider to source_name
|
|
1383
|
+
result["source_name"] = api_result.get("provider", "Unknown")
|
|
1384
|
+
|
|
1385
|
+
# Store original quality string as quality_str (used by display functions)
|
|
1386
|
+
result["quality_str"] = api_result.get("quality", "")
|
|
1387
|
+
|
|
1388
|
+
# Map quality_data to quality (Release.from_dict expects quality to be a dict)
|
|
1389
|
+
quality_data = api_result.get("quality_data")
|
|
1390
|
+
if quality_data and isinstance(quality_data, dict):
|
|
1391
|
+
result["quality"] = quality_data
|
|
1392
|
+
else:
|
|
1393
|
+
# Fallback: parse quality string to determine format
|
|
1394
|
+
quality_str = api_result.get("quality", "").upper()
|
|
1395
|
+
format_name = "OTHER"
|
|
1396
|
+
media_name = "OTHER"
|
|
1397
|
+
|
|
1398
|
+
if "FLAC" in quality_str:
|
|
1399
|
+
format_name = "FLAC"
|
|
1400
|
+
elif "MP3" in quality_str:
|
|
1401
|
+
format_name = "MP3"
|
|
1402
|
+
elif "WAV" in quality_str:
|
|
1403
|
+
format_name = "WAV"
|
|
1404
|
+
|
|
1405
|
+
if "CD" in quality_str:
|
|
1406
|
+
media_name = "CD"
|
|
1407
|
+
elif "WEB" in quality_str:
|
|
1408
|
+
media_name = "WEB"
|
|
1409
|
+
elif "VINYL" in quality_str:
|
|
1410
|
+
media_name = "VINYL"
|
|
1411
|
+
|
|
1412
|
+
result["quality"] = {"format": format_name, "media": media_name}
|
|
1413
|
+
|
|
1414
|
+
# Copy is_lossless if available
|
|
1415
|
+
if "is_lossless" in api_result:
|
|
1416
|
+
result["is_lossless"] = api_result["is_lossless"]
|
|
1417
|
+
|
|
1418
|
+
return result
|
|
1419
|
+
|
|
1420
|
+
def _interactive_select(
|
|
1421
|
+
self,
|
|
1422
|
+
results: List[AudioSearchResult],
|
|
1423
|
+
artist: str,
|
|
1424
|
+
title: str,
|
|
1425
|
+
) -> AudioSearchResult:
|
|
1426
|
+
"""
|
|
1427
|
+
Present search results to the user for interactive selection.
|
|
1428
|
+
|
|
1429
|
+
Uses flacfetch's built-in display functions if available, otherwise
|
|
1430
|
+
falls back to basic text display.
|
|
1431
|
+
|
|
1432
|
+
Args:
|
|
1433
|
+
results: List of AudioSearchResult objects
|
|
1434
|
+
artist: The artist name being searched
|
|
1435
|
+
title: The track title being searched
|
|
1436
|
+
|
|
1437
|
+
Returns:
|
|
1438
|
+
The selected AudioSearchResult
|
|
1439
|
+
|
|
1440
|
+
Raises:
|
|
1441
|
+
UserCancelledError: If user cancels selection
|
|
1442
|
+
"""
|
|
1443
|
+
# Try to use flacfetch's display functions with raw API results
|
|
1444
|
+
try:
|
|
1445
|
+
# Convert raw_result dicts back to Release objects for display
|
|
1446
|
+
from flacfetch.core.models import Release
|
|
1447
|
+
|
|
1448
|
+
releases = []
|
|
1449
|
+
for r in results:
|
|
1450
|
+
if r.raw_result and isinstance(r.raw_result, dict):
|
|
1451
|
+
# Convert API format to Release.from_dict() format
|
|
1452
|
+
converted = self._convert_api_result_for_release(r.raw_result)
|
|
1453
|
+
release = Release.from_dict(converted)
|
|
1454
|
+
releases.append(release)
|
|
1455
|
+
elif r.raw_result and hasattr(r.raw_result, 'title'):
|
|
1456
|
+
# It's already a Release object
|
|
1457
|
+
releases.append(r.raw_result)
|
|
1458
|
+
|
|
1459
|
+
if releases:
|
|
1460
|
+
from flacfetch.interface.cli import CLIHandler
|
|
1461
|
+
handler = CLIHandler(target_artist=artist)
|
|
1462
|
+
selected_release = handler.select_release(releases)
|
|
1463
|
+
|
|
1464
|
+
if selected_release is None:
|
|
1465
|
+
raise UserCancelledError("Selection cancelled by user")
|
|
1466
|
+
|
|
1467
|
+
# Find the matching AudioSearchResult by index
|
|
1468
|
+
# CLIHandler returns the release at the selected index
|
|
1469
|
+
for i, release in enumerate(releases):
|
|
1470
|
+
if release == selected_release:
|
|
1471
|
+
return results[i]
|
|
1472
|
+
|
|
1473
|
+
# Fallback: try matching by download_url
|
|
1474
|
+
for r in results:
|
|
1475
|
+
if r.raw_result == selected_release or (
|
|
1476
|
+
isinstance(r.raw_result, dict) and
|
|
1477
|
+
r.raw_result.get("download_url") == getattr(selected_release, "download_url", None)
|
|
1478
|
+
):
|
|
1479
|
+
return r
|
|
1480
|
+
|
|
1481
|
+
except (ImportError, AttributeError, TypeError) as e:
|
|
1482
|
+
self.logger.debug(f"[RemoteFlacFetcher] Falling back to basic display: {e}")
|
|
1483
|
+
|
|
1484
|
+
# Fallback to basic display
|
|
1485
|
+
return self._basic_interactive_select(results, artist, title)
|
|
1486
|
+
|
|
1487
|
+
def _basic_interactive_select(
|
|
1488
|
+
self,
|
|
1489
|
+
results: List[AudioSearchResult],
|
|
1490
|
+
artist: str,
|
|
1491
|
+
title: str,
|
|
1492
|
+
) -> AudioSearchResult:
|
|
1493
|
+
"""
|
|
1494
|
+
Basic fallback for interactive selection without rich formatting.
|
|
1495
|
+
|
|
1496
|
+
Args:
|
|
1497
|
+
results: List of AudioSearchResult objects
|
|
1498
|
+
artist: The artist name being searched
|
|
1499
|
+
title: The track title being searched
|
|
1500
|
+
|
|
1501
|
+
Returns:
|
|
1502
|
+
The selected AudioSearchResult
|
|
1503
|
+
|
|
1504
|
+
Raises:
|
|
1505
|
+
UserCancelledError: If user cancels selection
|
|
1506
|
+
"""
|
|
1507
|
+
print(f"\nFound {len(results)} releases:\n")
|
|
1508
|
+
|
|
1509
|
+
for i, result in enumerate(results, 1):
|
|
1510
|
+
# Try to get lossless info from raw_result (API response)
|
|
1511
|
+
is_lossless = False
|
|
1512
|
+
if result.raw_result and isinstance(result.raw_result, dict):
|
|
1513
|
+
is_lossless = result.raw_result.get("is_lossless", False)
|
|
1514
|
+
elif result.quality:
|
|
1515
|
+
is_lossless = "flac" in result.quality.lower() or "lossless" in result.quality.lower()
|
|
1516
|
+
|
|
1517
|
+
format_indicator = "[LOSSLESS]" if is_lossless else "[lossy]"
|
|
1518
|
+
quality = f"({result.quality})" if result.quality else ""
|
|
1519
|
+
provider = f"[{result.provider}]" if result.provider else ""
|
|
1520
|
+
seeders = f"Seeders: {result.seeders}" if result.seeders else ""
|
|
1521
|
+
duration = ""
|
|
1522
|
+
if result.duration:
|
|
1523
|
+
mins, secs = divmod(result.duration, 60)
|
|
1524
|
+
duration = f"[{int(mins)}:{int(secs):02d}]"
|
|
1525
|
+
|
|
1526
|
+
print(f"{i}. {format_indicator} {provider} {result.artist}: {result.title} {quality} {duration} {seeders}")
|
|
1527
|
+
|
|
1528
|
+
print()
|
|
1529
|
+
|
|
1530
|
+
while True:
|
|
1531
|
+
try:
|
|
1532
|
+
choice = input(f"Select a release (1-{len(results)}, 0 to cancel): ").strip()
|
|
1533
|
+
|
|
1534
|
+
if choice == "0":
|
|
1535
|
+
raise UserCancelledError("Selection cancelled by user")
|
|
1536
|
+
|
|
1537
|
+
choice_num = int(choice)
|
|
1538
|
+
if 1 <= choice_num <= len(results):
|
|
1539
|
+
selected = results[choice_num - 1]
|
|
1540
|
+
self.logger.info(f"[RemoteFlacFetcher] User selected option {choice_num}")
|
|
1541
|
+
return selected
|
|
1542
|
+
else:
|
|
1543
|
+
print(f"Please enter a number between 0 and {len(results)}")
|
|
1544
|
+
|
|
1545
|
+
except ValueError:
|
|
1546
|
+
print("Please enter a valid number")
|
|
1547
|
+
except (KeyboardInterrupt, EOFError):
|
|
1548
|
+
print("\nCancelled")
|
|
1549
|
+
raise UserCancelledError("Selection cancelled by user (Ctrl+C)")
|
|
1550
|
+
|
|
1551
|
+
|
|
1552
|
+
# Alias for shorter name
|
|
1553
|
+
RemoteFlacFetcher = RemoteFlacFetchAudioFetcher
|
|
439
1554
|
|
|
440
1555
|
|
|
441
1556
|
def create_audio_fetcher(
|
|
442
1557
|
logger: Optional[logging.Logger] = None,
|
|
443
|
-
|
|
1558
|
+
red_api_key: Optional[str] = None,
|
|
1559
|
+
red_api_url: Optional[str] = None,
|
|
444
1560
|
ops_api_key: Optional[str] = None,
|
|
1561
|
+
ops_api_url: Optional[str] = None,
|
|
1562
|
+
flacfetch_api_url: Optional[str] = None,
|
|
1563
|
+
flacfetch_api_key: Optional[str] = None,
|
|
445
1564
|
) -> AudioFetcher:
|
|
446
1565
|
"""
|
|
447
1566
|
Factory function to create an appropriate AudioFetcher instance.
|
|
1567
|
+
|
|
1568
|
+
If FLACFETCH_API_URL and FLACFETCH_API_KEY environment variables are set
|
|
1569
|
+
(or passed as arguments), returns a RemoteFlacFetchAudioFetcher that uses
|
|
1570
|
+
the remote flacfetch HTTP API server.
|
|
1571
|
+
|
|
1572
|
+
Otherwise, returns a local FlacFetchAudioFetcher that uses the flacfetch
|
|
1573
|
+
library directly.
|
|
448
1574
|
|
|
449
1575
|
Args:
|
|
450
1576
|
logger: Logger instance for output
|
|
451
|
-
|
|
452
|
-
|
|
1577
|
+
red_api_key: API key for RED tracker (optional, for local mode)
|
|
1578
|
+
red_api_url: Base URL for RED tracker API (optional, for local mode)
|
|
1579
|
+
ops_api_key: API key for OPS tracker (optional, for local mode)
|
|
1580
|
+
ops_api_url: Base URL for OPS tracker API (optional, for local mode)
|
|
1581
|
+
flacfetch_api_url: URL of remote flacfetch API server (optional)
|
|
1582
|
+
flacfetch_api_key: API key for remote flacfetch server (optional)
|
|
453
1583
|
|
|
454
1584
|
Returns:
|
|
455
|
-
An AudioFetcher instance
|
|
1585
|
+
An AudioFetcher instance (remote or local depending on configuration)
|
|
456
1586
|
"""
|
|
1587
|
+
# Check for remote flacfetch API configuration
|
|
1588
|
+
api_url = flacfetch_api_url or os.environ.get("FLACFETCH_API_URL")
|
|
1589
|
+
api_key = flacfetch_api_key or os.environ.get("FLACFETCH_API_KEY")
|
|
1590
|
+
|
|
1591
|
+
if api_url and api_key:
|
|
1592
|
+
# Use remote flacfetch API
|
|
1593
|
+
if logger:
|
|
1594
|
+
logger.info(f"Using remote flacfetch API at: {api_url}")
|
|
1595
|
+
return RemoteFlacFetchAudioFetcher(
|
|
1596
|
+
api_url=api_url,
|
|
1597
|
+
api_key=api_key,
|
|
1598
|
+
logger=logger,
|
|
1599
|
+
)
|
|
1600
|
+
elif api_url and not api_key:
|
|
1601
|
+
if logger:
|
|
1602
|
+
logger.warning("FLACFETCH_API_URL is set but FLACFETCH_API_KEY is not - falling back to local mode")
|
|
1603
|
+
elif api_key and not api_url:
|
|
1604
|
+
if logger:
|
|
1605
|
+
logger.warning("FLACFETCH_API_KEY is set but FLACFETCH_API_URL is not - falling back to local mode")
|
|
1606
|
+
|
|
1607
|
+
# Use local flacfetch library
|
|
457
1608
|
return FlacFetchAudioFetcher(
|
|
458
1609
|
logger=logger,
|
|
459
|
-
|
|
1610
|
+
red_api_key=red_api_key,
|
|
1611
|
+
red_api_url=red_api_url,
|
|
460
1612
|
ops_api_key=ops_api_key,
|
|
1613
|
+
ops_api_url=ops_api_url,
|
|
461
1614
|
)
|