karaoke-gen 0.75.16__py3-none-any.whl → 0.76.20__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/audio_fetcher.py +984 -33
- karaoke_gen/audio_processor.py +4 -0
- karaoke_gen/instrumental_review/static/index.html +37 -14
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +25 -1
- karaoke_gen/karaoke_gen.py +208 -39
- karaoke_gen/lyrics_processor.py +111 -31
- karaoke_gen/utils/__init__.py +26 -0
- karaoke_gen/utils/cli_args.py +15 -6
- karaoke_gen/utils/gen_cli.py +30 -5
- karaoke_gen/utils/remote_cli.py +301 -20
- {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/METADATA +107 -5
- {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/RECORD +47 -43
- lyrics_transcriber/core/controller.py +76 -2
- lyrics_transcriber/frontend/index.html +5 -1
- lyrics_transcriber/frontend/package-lock.json +4553 -0
- lyrics_transcriber/frontend/package.json +4 -1
- lyrics_transcriber/frontend/playwright.config.ts +69 -0
- lyrics_transcriber/frontend/public/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/frontend/src/App.tsx +94 -63
- lyrics_transcriber/frontend/src/api.ts +25 -10
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +55 -21
- lyrics_transcriber/frontend/src/components/AppHeader.tsx +65 -0
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +5 -5
- lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +9 -9
- lyrics_transcriber/frontend/src/components/EditModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +1 -1
- lyrics_transcriber/frontend/src/components/Header.tsx +34 -48
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +22 -21
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +3 -3
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +2 -2
- lyrics_transcriber/frontend/src/components/shared/constants.ts +15 -5
- lyrics_transcriber/frontend/src/main.tsx +1 -7
- lyrics_transcriber/frontend/src/theme.ts +337 -135
- lyrics_transcriber/frontend/vite.config.ts +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-COYImAcx.js → index-BECn1o8Q.js} +38 -22
- lyrics_transcriber/frontend/web_assets/assets/{index-COYImAcx.js.map → index-BECn1o8Q.js.map} +1 -1
- lyrics_transcriber/frontend/web_assets/index.html +1 -1
- lyrics_transcriber/frontend/yarn.lock +1005 -1046
- lyrics_transcriber/output/countdown_processor.py +39 -0
- lyrics_transcriber/review/server.py +1 -1
- lyrics_transcriber/transcribers/audioshake.py +96 -7
- lyrics_transcriber/types.py +14 -12
- {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/licenses/LICENSE +0 -0
karaoke_gen/audio_fetcher.py
CHANGED
|
@@ -3,6 +3,11 @@ 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
|
|
@@ -11,11 +16,19 @@ import signal
|
|
|
11
16
|
import sys
|
|
12
17
|
import tempfile
|
|
13
18
|
import threading
|
|
19
|
+
import time
|
|
14
20
|
from abc import ABC, abstractmethod
|
|
15
21
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
|
16
22
|
from dataclasses import dataclass, asdict, field
|
|
17
23
|
from typing import List, Optional, Dict, Any
|
|
18
24
|
|
|
25
|
+
# Optional import for remote fetcher
|
|
26
|
+
try:
|
|
27
|
+
import httpx
|
|
28
|
+
HTTPX_AVAILABLE = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
HTTPX_AVAILABLE = False
|
|
31
|
+
|
|
19
32
|
# Global flag to track if user requested cancellation via Ctrl+C
|
|
20
33
|
_interrupt_requested = False
|
|
21
34
|
|
|
@@ -63,22 +76,35 @@ class AudioSearchResult:
|
|
|
63
76
|
"target_file": self.target_file,
|
|
64
77
|
}
|
|
65
78
|
|
|
66
|
-
# If we have a raw_result (flacfetch Release), include its full data
|
|
79
|
+
# If we have a raw_result (flacfetch Release or dict), include its full data
|
|
67
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)
|
|
68
84
|
if self.raw_result:
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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']
|
|
82
108
|
|
|
83
109
|
return result
|
|
84
110
|
|
|
@@ -238,6 +264,36 @@ class AudioFetcher(ABC):
|
|
|
238
264
|
"""
|
|
239
265
|
pass
|
|
240
266
|
|
|
267
|
+
@abstractmethod
|
|
268
|
+
def download_from_url(
|
|
269
|
+
self,
|
|
270
|
+
url: str,
|
|
271
|
+
output_dir: str,
|
|
272
|
+
output_filename: Optional[str] = None,
|
|
273
|
+
artist: Optional[str] = None,
|
|
274
|
+
title: Optional[str] = None,
|
|
275
|
+
) -> AudioFetchResult:
|
|
276
|
+
"""
|
|
277
|
+
Download audio directly from a URL (e.g., YouTube URL).
|
|
278
|
+
|
|
279
|
+
This bypasses the search step and downloads directly from the provided URL.
|
|
280
|
+
Useful when the user provides a specific YouTube URL rather than artist/title.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
url: The URL to download from (e.g., YouTube video URL)
|
|
284
|
+
output_dir: Directory to save the downloaded file
|
|
285
|
+
output_filename: Optional filename (without extension)
|
|
286
|
+
artist: Optional artist name for metadata
|
|
287
|
+
title: Optional title for metadata
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
AudioFetchResult with the downloaded file path
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
DownloadError: If download fails
|
|
294
|
+
"""
|
|
295
|
+
pass
|
|
296
|
+
|
|
241
297
|
|
|
242
298
|
class FlacFetchAudioFetcher(AudioFetcher):
|
|
243
299
|
"""
|
|
@@ -252,8 +308,10 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
252
308
|
def __init__(
|
|
253
309
|
self,
|
|
254
310
|
logger: Optional[logging.Logger] = None,
|
|
255
|
-
|
|
311
|
+
red_api_key: Optional[str] = None,
|
|
312
|
+
red_api_url: Optional[str] = None,
|
|
256
313
|
ops_api_key: Optional[str] = None,
|
|
314
|
+
ops_api_url: Optional[str] = None,
|
|
257
315
|
provider_priority: Optional[List[str]] = None,
|
|
258
316
|
):
|
|
259
317
|
"""
|
|
@@ -261,13 +319,17 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
261
319
|
|
|
262
320
|
Args:
|
|
263
321
|
logger: Logger instance for output
|
|
264
|
-
|
|
322
|
+
red_api_key: API key for RED tracker (optional)
|
|
323
|
+
red_api_url: Base URL for RED tracker API (optional, required if using RED)
|
|
265
324
|
ops_api_key: API key for OPS tracker (optional)
|
|
325
|
+
ops_api_url: Base URL for OPS tracker API (optional, required if using OPS)
|
|
266
326
|
provider_priority: Custom provider priority order (optional)
|
|
267
327
|
"""
|
|
268
328
|
self.logger = logger or logging.getLogger(__name__)
|
|
269
|
-
self.
|
|
329
|
+
self._red_api_key = red_api_key or os.environ.get("RED_API_KEY")
|
|
330
|
+
self._red_api_url = red_api_url or os.environ.get("RED_API_URL")
|
|
270
331
|
self._ops_api_key = ops_api_key or os.environ.get("OPS_API_KEY")
|
|
332
|
+
self._ops_api_url = ops_api_url or os.environ.get("OPS_API_URL")
|
|
271
333
|
self._provider_priority = provider_priority
|
|
272
334
|
self._manager = None
|
|
273
335
|
self._transmission_available = None # Cached result of Transmission check
|
|
@@ -276,7 +338,7 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
276
338
|
"""
|
|
277
339
|
Check if Transmission daemon is available for torrent downloads.
|
|
278
340
|
|
|
279
|
-
This prevents adding tracker providers (
|
|
341
|
+
This prevents adding tracker providers (RED/OPS) when Transmission
|
|
280
342
|
isn't running, which would result in search results that can't be downloaded.
|
|
281
343
|
|
|
282
344
|
Returns:
|
|
@@ -340,27 +402,31 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
340
402
|
f"Transmission={transmission_available}, can_use_trackers={can_use_trackers}"
|
|
341
403
|
)
|
|
342
404
|
|
|
343
|
-
if not can_use_trackers and (self.
|
|
405
|
+
if not can_use_trackers and (self._red_api_key or self._ops_api_key):
|
|
344
406
|
self.logger.warning(
|
|
345
|
-
"[FlacFetcher] Tracker providers (
|
|
407
|
+
"[FlacFetcher] Tracker providers (RED/OPS) DISABLED: "
|
|
346
408
|
f"TorrentDownloader={has_torrent_downloader}, Transmission={transmission_available}. "
|
|
347
409
|
"Only YouTube sources will be used."
|
|
348
410
|
)
|
|
349
411
|
|
|
350
|
-
# Add providers and downloaders based on available API keys
|
|
351
|
-
if self.
|
|
352
|
-
from flacfetch.providers.
|
|
412
|
+
# Add providers and downloaders based on available API keys and URLs
|
|
413
|
+
if self._red_api_key and self._red_api_url and can_use_trackers:
|
|
414
|
+
from flacfetch.providers.red import REDProvider
|
|
353
415
|
|
|
354
|
-
self._manager.add_provider(
|
|
355
|
-
self._manager.register_downloader("
|
|
356
|
-
self.logger.info("[FlacFetcher] Added
|
|
416
|
+
self._manager.add_provider(REDProvider(api_key=self._red_api_key, base_url=self._red_api_url))
|
|
417
|
+
self._manager.register_downloader("RED", TorrentDownloader())
|
|
418
|
+
self.logger.info("[FlacFetcher] Added RED provider with TorrentDownloader")
|
|
419
|
+
elif self._red_api_key and not self._red_api_url:
|
|
420
|
+
self.logger.warning("[FlacFetcher] RED_API_KEY set but RED_API_URL not set - RED provider disabled")
|
|
357
421
|
|
|
358
|
-
if self._ops_api_key and can_use_trackers:
|
|
422
|
+
if self._ops_api_key and self._ops_api_url and can_use_trackers:
|
|
359
423
|
from flacfetch.providers.ops import OPSProvider
|
|
360
424
|
|
|
361
|
-
self._manager.add_provider(OPSProvider(api_key=self._ops_api_key))
|
|
425
|
+
self._manager.add_provider(OPSProvider(api_key=self._ops_api_key, base_url=self._ops_api_url))
|
|
362
426
|
self._manager.register_downloader("OPS", TorrentDownloader())
|
|
363
427
|
self.logger.info("[FlacFetcher] Added OPS provider with TorrentDownloader")
|
|
428
|
+
elif self._ops_api_key and not self._ops_api_url:
|
|
429
|
+
self.logger.warning("[FlacFetcher] OPS_API_KEY set but OPS_API_URL not set - OPS provider disabled")
|
|
364
430
|
|
|
365
431
|
# Always add YouTube as a fallback provider with its downloader
|
|
366
432
|
self._manager.add_provider(YoutubeProvider())
|
|
@@ -609,6 +675,94 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
609
675
|
except Exception as e:
|
|
610
676
|
raise DownloadError(f"Failed to download {artist} - {title}: {e}") from e
|
|
611
677
|
|
|
678
|
+
def download_from_url(
|
|
679
|
+
self,
|
|
680
|
+
url: str,
|
|
681
|
+
output_dir: str,
|
|
682
|
+
output_filename: Optional[str] = None,
|
|
683
|
+
artist: Optional[str] = None,
|
|
684
|
+
title: Optional[str] = None,
|
|
685
|
+
) -> AudioFetchResult:
|
|
686
|
+
"""
|
|
687
|
+
Download audio directly from a URL (e.g., YouTube URL).
|
|
688
|
+
|
|
689
|
+
Uses flacfetch's download_by_id() method which supports direct YouTube downloads.
|
|
690
|
+
|
|
691
|
+
Args:
|
|
692
|
+
url: The URL to download from (e.g., YouTube video URL)
|
|
693
|
+
output_dir: Directory to save the downloaded file
|
|
694
|
+
output_filename: Optional filename (without extension)
|
|
695
|
+
artist: Optional artist name for metadata
|
|
696
|
+
title: Optional title for metadata
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
AudioFetchResult with the downloaded file path
|
|
700
|
+
|
|
701
|
+
Raises:
|
|
702
|
+
DownloadError: If download fails
|
|
703
|
+
"""
|
|
704
|
+
import re
|
|
705
|
+
|
|
706
|
+
manager = self._get_manager()
|
|
707
|
+
|
|
708
|
+
# Ensure output directory exists
|
|
709
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
710
|
+
|
|
711
|
+
# Detect source type from URL
|
|
712
|
+
source_name = "YouTube" # Default to YouTube for now
|
|
713
|
+
source_id = None
|
|
714
|
+
|
|
715
|
+
# Extract YouTube video ID from URL
|
|
716
|
+
youtube_patterns = [
|
|
717
|
+
r'(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})',
|
|
718
|
+
r'youtube\.com/embed/([a-zA-Z0-9_-]{11})',
|
|
719
|
+
r'youtube\.com/v/([a-zA-Z0-9_-]{11})',
|
|
720
|
+
]
|
|
721
|
+
for pattern in youtube_patterns:
|
|
722
|
+
match = re.search(pattern, url)
|
|
723
|
+
if match:
|
|
724
|
+
source_id = match.group(1)
|
|
725
|
+
break
|
|
726
|
+
|
|
727
|
+
if not source_id:
|
|
728
|
+
# For other URLs, use the full URL as the source_id
|
|
729
|
+
source_id = url
|
|
730
|
+
|
|
731
|
+
# Generate filename if not provided
|
|
732
|
+
if output_filename is None:
|
|
733
|
+
if artist and title:
|
|
734
|
+
output_filename = f"{artist} - {title}"
|
|
735
|
+
else:
|
|
736
|
+
output_filename = source_id
|
|
737
|
+
|
|
738
|
+
self.logger.info(f"Downloading from URL: {url}")
|
|
739
|
+
|
|
740
|
+
try:
|
|
741
|
+
filepath = manager.download_by_id(
|
|
742
|
+
source_name=source_name,
|
|
743
|
+
source_id=source_id,
|
|
744
|
+
output_path=output_dir,
|
|
745
|
+
output_filename=output_filename,
|
|
746
|
+
download_url=url, # Pass full URL for direct download
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
if not filepath:
|
|
750
|
+
raise DownloadError(f"Download returned no file path for URL: {url}")
|
|
751
|
+
|
|
752
|
+
self.logger.info(f"Downloaded to: {filepath}")
|
|
753
|
+
|
|
754
|
+
return AudioFetchResult(
|
|
755
|
+
filepath=filepath,
|
|
756
|
+
artist=artist or "",
|
|
757
|
+
title=title or "",
|
|
758
|
+
provider=source_name,
|
|
759
|
+
duration=None, # Could extract from yt-dlp info if needed
|
|
760
|
+
quality=None,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
except Exception as e:
|
|
764
|
+
raise DownloadError(f"Failed to download from URL {url}: {e}") from e
|
|
765
|
+
|
|
612
766
|
def _interruptible_search(self, manager, query) -> list:
|
|
613
767
|
"""
|
|
614
768
|
Run search in a way that can be interrupted by Ctrl+C.
|
|
@@ -858,24 +1012,821 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
858
1012
|
FlacFetcher = FlacFetchAudioFetcher
|
|
859
1013
|
|
|
860
1014
|
|
|
1015
|
+
class RemoteFlacFetchAudioFetcher(AudioFetcher):
|
|
1016
|
+
"""
|
|
1017
|
+
Audio fetcher implementation using remote flacfetch HTTP API.
|
|
1018
|
+
|
|
1019
|
+
This fetcher communicates with a dedicated flacfetch server that handles:
|
|
1020
|
+
- BitTorrent downloads from private trackers (RED, OPS)
|
|
1021
|
+
- YouTube downloads
|
|
1022
|
+
- File streaming back to the client
|
|
1023
|
+
|
|
1024
|
+
Used when FLACFETCH_API_URL and FLACFETCH_API_KEY environment variables are set.
|
|
1025
|
+
"""
|
|
1026
|
+
|
|
1027
|
+
def __init__(
|
|
1028
|
+
self,
|
|
1029
|
+
api_url: str,
|
|
1030
|
+
api_key: str,
|
|
1031
|
+
logger: Optional[logging.Logger] = None,
|
|
1032
|
+
timeout: int = 60,
|
|
1033
|
+
download_timeout: int = 600,
|
|
1034
|
+
):
|
|
1035
|
+
"""
|
|
1036
|
+
Initialize the remote FlacFetch audio fetcher.
|
|
1037
|
+
|
|
1038
|
+
Args:
|
|
1039
|
+
api_url: Base URL of flacfetch API server (e.g., http://10.0.0.5:8080)
|
|
1040
|
+
api_key: API key for authentication
|
|
1041
|
+
logger: Logger instance for output
|
|
1042
|
+
timeout: Request timeout in seconds for search/status calls
|
|
1043
|
+
download_timeout: Maximum wait time for downloads to complete
|
|
1044
|
+
"""
|
|
1045
|
+
if not HTTPX_AVAILABLE:
|
|
1046
|
+
raise ImportError("httpx is required for remote flacfetch. Install with: pip install httpx")
|
|
1047
|
+
|
|
1048
|
+
self.api_url = api_url.rstrip('/')
|
|
1049
|
+
self.api_key = api_key
|
|
1050
|
+
self.logger = logger or logging.getLogger(__name__)
|
|
1051
|
+
self.timeout = timeout
|
|
1052
|
+
self.download_timeout = download_timeout
|
|
1053
|
+
self._last_search_id: Optional[str] = None
|
|
1054
|
+
self._last_search_results: List[Dict[str, Any]] = []
|
|
1055
|
+
|
|
1056
|
+
self.logger.info(f"[RemoteFlacFetcher] Initialized with API URL: {self.api_url}")
|
|
1057
|
+
|
|
1058
|
+
def _headers(self) -> Dict[str, str]:
|
|
1059
|
+
"""Get request headers with authentication."""
|
|
1060
|
+
return {
|
|
1061
|
+
"X-API-Key": self.api_key,
|
|
1062
|
+
"Content-Type": "application/json",
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
def _check_health(self) -> bool:
|
|
1066
|
+
"""Check if the remote flacfetch service is healthy."""
|
|
1067
|
+
try:
|
|
1068
|
+
with httpx.Client() as client:
|
|
1069
|
+
resp = client.get(
|
|
1070
|
+
f"{self.api_url}/health",
|
|
1071
|
+
headers=self._headers(),
|
|
1072
|
+
timeout=10,
|
|
1073
|
+
)
|
|
1074
|
+
if resp.status_code == 200:
|
|
1075
|
+
data = resp.json()
|
|
1076
|
+
status = data.get("status", "unknown")
|
|
1077
|
+
self.logger.debug(f"[RemoteFlacFetcher] Health check: {status}")
|
|
1078
|
+
return status in ["healthy", "degraded"]
|
|
1079
|
+
return False
|
|
1080
|
+
except Exception as e:
|
|
1081
|
+
self.logger.warning(f"[RemoteFlacFetcher] Health check failed: {e}")
|
|
1082
|
+
return False
|
|
1083
|
+
|
|
1084
|
+
def search(self, artist: str, title: str) -> List[AudioSearchResult]:
|
|
1085
|
+
"""
|
|
1086
|
+
Search for audio matching the given artist and title via remote API.
|
|
1087
|
+
|
|
1088
|
+
Args:
|
|
1089
|
+
artist: The artist name to search for
|
|
1090
|
+
title: The track title to search for
|
|
1091
|
+
|
|
1092
|
+
Returns:
|
|
1093
|
+
List of AudioSearchResult objects
|
|
1094
|
+
|
|
1095
|
+
Raises:
|
|
1096
|
+
NoResultsError: If no results are found
|
|
1097
|
+
AudioFetcherError: For other errors
|
|
1098
|
+
"""
|
|
1099
|
+
self.logger.info(f"[RemoteFlacFetcher] Searching for: {artist} - {title}")
|
|
1100
|
+
|
|
1101
|
+
try:
|
|
1102
|
+
with httpx.Client() as client:
|
|
1103
|
+
resp = client.post(
|
|
1104
|
+
f"{self.api_url}/search",
|
|
1105
|
+
headers=self._headers(),
|
|
1106
|
+
json={"artist": artist, "title": title},
|
|
1107
|
+
timeout=self.timeout,
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
if resp.status_code == 404:
|
|
1111
|
+
raise NoResultsError(f"No results found for: {artist} - {title}")
|
|
1112
|
+
|
|
1113
|
+
resp.raise_for_status()
|
|
1114
|
+
data = resp.json()
|
|
1115
|
+
|
|
1116
|
+
self._last_search_id = data.get("search_id")
|
|
1117
|
+
self._last_search_results = data.get("results", [])
|
|
1118
|
+
|
|
1119
|
+
if not self._last_search_results:
|
|
1120
|
+
raise NoResultsError(f"No results found for: {artist} - {title}")
|
|
1121
|
+
|
|
1122
|
+
# Convert API results to AudioSearchResult objects
|
|
1123
|
+
search_results = []
|
|
1124
|
+
for i, result in enumerate(self._last_search_results):
|
|
1125
|
+
search_results.append(
|
|
1126
|
+
AudioSearchResult(
|
|
1127
|
+
title=result.get("title", title),
|
|
1128
|
+
artist=result.get("artist", artist),
|
|
1129
|
+
url=result.get("download_url", "") or result.get("url", ""),
|
|
1130
|
+
provider=result.get("provider", result.get("source_name", "Unknown")),
|
|
1131
|
+
duration=result.get("duration_seconds", result.get("duration")),
|
|
1132
|
+
quality=result.get("quality_str", result.get("quality")),
|
|
1133
|
+
source_id=result.get("info_hash"),
|
|
1134
|
+
index=i,
|
|
1135
|
+
seeders=result.get("seeders"),
|
|
1136
|
+
target_file=result.get("target_file"),
|
|
1137
|
+
raw_result=result, # Store the full API result
|
|
1138
|
+
)
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
self.logger.info(f"[RemoteFlacFetcher] Found {len(search_results)} results")
|
|
1142
|
+
return search_results
|
|
1143
|
+
|
|
1144
|
+
except httpx.RequestError as e:
|
|
1145
|
+
raise AudioFetcherError(f"Search request failed: {e}") from e
|
|
1146
|
+
except httpx.HTTPStatusError as e:
|
|
1147
|
+
if e.response.status_code == 404:
|
|
1148
|
+
raise NoResultsError(f"No results found for: {artist} - {title}") from e
|
|
1149
|
+
raise AudioFetcherError(f"Search failed: {e.response.status_code} - {e.response.text}") from e
|
|
1150
|
+
|
|
1151
|
+
def download(
|
|
1152
|
+
self,
|
|
1153
|
+
result: AudioSearchResult,
|
|
1154
|
+
output_dir: str,
|
|
1155
|
+
output_filename: Optional[str] = None,
|
|
1156
|
+
) -> AudioFetchResult:
|
|
1157
|
+
"""
|
|
1158
|
+
Download audio from a search result via remote API.
|
|
1159
|
+
|
|
1160
|
+
Args:
|
|
1161
|
+
result: The search result to download
|
|
1162
|
+
output_dir: Directory to save the downloaded file
|
|
1163
|
+
output_filename: Optional filename (without extension)
|
|
1164
|
+
|
|
1165
|
+
Returns:
|
|
1166
|
+
AudioFetchResult with the downloaded file path
|
|
1167
|
+
|
|
1168
|
+
Raises:
|
|
1169
|
+
DownloadError: If download fails
|
|
1170
|
+
"""
|
|
1171
|
+
if not self._last_search_id:
|
|
1172
|
+
raise DownloadError("No search performed - call search() first")
|
|
1173
|
+
|
|
1174
|
+
# Ensure output directory exists
|
|
1175
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1176
|
+
|
|
1177
|
+
# Generate filename if not provided
|
|
1178
|
+
if output_filename is None:
|
|
1179
|
+
output_filename = f"{result.artist} - {result.title}"
|
|
1180
|
+
|
|
1181
|
+
self.logger.info(f"[RemoteFlacFetcher] Downloading: {result.artist} - {result.title} from {result.provider}")
|
|
1182
|
+
|
|
1183
|
+
try:
|
|
1184
|
+
# Start the download
|
|
1185
|
+
with httpx.Client() as client:
|
|
1186
|
+
resp = client.post(
|
|
1187
|
+
f"{self.api_url}/download",
|
|
1188
|
+
headers=self._headers(),
|
|
1189
|
+
json={
|
|
1190
|
+
"search_id": self._last_search_id,
|
|
1191
|
+
"result_index": result.index,
|
|
1192
|
+
"output_filename": output_filename,
|
|
1193
|
+
# Don't set upload_to_gcs - we want local download
|
|
1194
|
+
},
|
|
1195
|
+
timeout=self.timeout,
|
|
1196
|
+
)
|
|
1197
|
+
resp.raise_for_status()
|
|
1198
|
+
data = resp.json()
|
|
1199
|
+
download_id = data.get("download_id")
|
|
1200
|
+
|
|
1201
|
+
if not download_id:
|
|
1202
|
+
raise DownloadError("No download_id returned from API")
|
|
1203
|
+
|
|
1204
|
+
self.logger.info(f"[RemoteFlacFetcher] Download started: {download_id}")
|
|
1205
|
+
|
|
1206
|
+
# Wait for download to complete
|
|
1207
|
+
filepath = self._wait_and_stream_download(
|
|
1208
|
+
download_id=download_id,
|
|
1209
|
+
output_dir=output_dir,
|
|
1210
|
+
output_filename=output_filename,
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
self.logger.info(f"[RemoteFlacFetcher] Downloaded to: {filepath}")
|
|
1214
|
+
|
|
1215
|
+
return AudioFetchResult(
|
|
1216
|
+
filepath=filepath,
|
|
1217
|
+
artist=result.artist,
|
|
1218
|
+
title=result.title,
|
|
1219
|
+
provider=result.provider,
|
|
1220
|
+
duration=result.duration,
|
|
1221
|
+
quality=result.quality,
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
except httpx.RequestError as e:
|
|
1225
|
+
raise DownloadError(f"Download request failed: {e}") from e
|
|
1226
|
+
except httpx.HTTPStatusError as e:
|
|
1227
|
+
raise DownloadError(f"Download failed: {e.response.status_code} - {e.response.text}") from e
|
|
1228
|
+
|
|
1229
|
+
def _wait_and_stream_download(
|
|
1230
|
+
self,
|
|
1231
|
+
download_id: str,
|
|
1232
|
+
output_dir: str,
|
|
1233
|
+
output_filename: str,
|
|
1234
|
+
poll_interval: float = 2.0,
|
|
1235
|
+
) -> str:
|
|
1236
|
+
"""
|
|
1237
|
+
Wait for a remote download to complete, then stream the file locally.
|
|
1238
|
+
|
|
1239
|
+
Args:
|
|
1240
|
+
download_id: Download ID from /download endpoint
|
|
1241
|
+
output_dir: Local directory to save file
|
|
1242
|
+
output_filename: Local filename (without extension)
|
|
1243
|
+
poll_interval: Seconds between status checks
|
|
1244
|
+
|
|
1245
|
+
Returns:
|
|
1246
|
+
Path to the downloaded local file
|
|
1247
|
+
|
|
1248
|
+
Raises:
|
|
1249
|
+
DownloadError: On download failure or timeout
|
|
1250
|
+
UserCancelledError: If user presses Ctrl+C
|
|
1251
|
+
"""
|
|
1252
|
+
global _interrupt_requested
|
|
1253
|
+
_interrupt_requested = False
|
|
1254
|
+
|
|
1255
|
+
# Set up signal handler for Ctrl+C
|
|
1256
|
+
original_handler = signal.getsignal(signal.SIGINT)
|
|
1257
|
+
|
|
1258
|
+
def interrupt_handler(signum, frame):
|
|
1259
|
+
global _interrupt_requested
|
|
1260
|
+
_interrupt_requested = True
|
|
1261
|
+
print("\nCancelling download... please wait", file=sys.stderr)
|
|
1262
|
+
|
|
1263
|
+
signal.signal(signal.SIGINT, interrupt_handler)
|
|
1264
|
+
|
|
1265
|
+
try:
|
|
1266
|
+
elapsed = 0.0
|
|
1267
|
+
last_progress = -1
|
|
1268
|
+
|
|
1269
|
+
while elapsed < self.download_timeout:
|
|
1270
|
+
# Check for interrupt
|
|
1271
|
+
if _interrupt_requested:
|
|
1272
|
+
raise UserCancelledError("Download cancelled by user (Ctrl+C)")
|
|
1273
|
+
|
|
1274
|
+
# Check status
|
|
1275
|
+
with httpx.Client() as client:
|
|
1276
|
+
resp = client.get(
|
|
1277
|
+
f"{self.api_url}/download/{download_id}/status",
|
|
1278
|
+
headers=self._headers(),
|
|
1279
|
+
timeout=10,
|
|
1280
|
+
)
|
|
1281
|
+
resp.raise_for_status()
|
|
1282
|
+
status = resp.json()
|
|
1283
|
+
|
|
1284
|
+
download_status = status.get("status")
|
|
1285
|
+
progress = status.get("progress", 0)
|
|
1286
|
+
speed = status.get("download_speed_kbps", 0)
|
|
1287
|
+
|
|
1288
|
+
# Log progress updates
|
|
1289
|
+
if int(progress) != last_progress:
|
|
1290
|
+
if download_status == "downloading":
|
|
1291
|
+
self.logger.info(f"[RemoteFlacFetcher] Progress: {progress:.1f}% ({speed:.1f} KB/s)")
|
|
1292
|
+
elif download_status in ["uploading", "processing"]:
|
|
1293
|
+
self.logger.info(f"[RemoteFlacFetcher] {download_status.capitalize()}...")
|
|
1294
|
+
last_progress = int(progress)
|
|
1295
|
+
|
|
1296
|
+
if download_status in ["complete", "seeding"]:
|
|
1297
|
+
# Download complete - now stream the file locally
|
|
1298
|
+
self.logger.info(f"[RemoteFlacFetcher] Remote download complete, streaming to local...")
|
|
1299
|
+
return self._stream_file_locally(download_id, output_dir, output_filename)
|
|
1300
|
+
|
|
1301
|
+
elif download_status == "failed":
|
|
1302
|
+
error = status.get("error", "Unknown error")
|
|
1303
|
+
raise DownloadError(f"Remote download failed: {error}")
|
|
1304
|
+
|
|
1305
|
+
elif download_status == "cancelled":
|
|
1306
|
+
raise DownloadError("Download was cancelled on server")
|
|
1307
|
+
|
|
1308
|
+
time.sleep(poll_interval)
|
|
1309
|
+
elapsed += poll_interval
|
|
1310
|
+
|
|
1311
|
+
raise DownloadError(f"Download timed out after {self.download_timeout}s")
|
|
1312
|
+
|
|
1313
|
+
finally:
|
|
1314
|
+
# Restore original signal handler
|
|
1315
|
+
signal.signal(signal.SIGINT, original_handler)
|
|
1316
|
+
_interrupt_requested = False
|
|
1317
|
+
|
|
1318
|
+
def _stream_file_locally(
|
|
1319
|
+
self,
|
|
1320
|
+
download_id: str,
|
|
1321
|
+
output_dir: str,
|
|
1322
|
+
output_filename: str,
|
|
1323
|
+
) -> str:
|
|
1324
|
+
"""
|
|
1325
|
+
Stream a completed download from the remote server to local disk.
|
|
1326
|
+
|
|
1327
|
+
Args:
|
|
1328
|
+
download_id: Download ID
|
|
1329
|
+
output_dir: Local directory to save file
|
|
1330
|
+
output_filename: Local filename (without extension)
|
|
1331
|
+
|
|
1332
|
+
Returns:
|
|
1333
|
+
Path to the downloaded local file
|
|
1334
|
+
|
|
1335
|
+
Raises:
|
|
1336
|
+
DownloadError: On streaming failure
|
|
1337
|
+
"""
|
|
1338
|
+
try:
|
|
1339
|
+
# Stream the file from the remote server
|
|
1340
|
+
with httpx.Client() as client:
|
|
1341
|
+
with client.stream(
|
|
1342
|
+
"GET",
|
|
1343
|
+
f"{self.api_url}/download/{download_id}/file",
|
|
1344
|
+
headers=self._headers(),
|
|
1345
|
+
timeout=300, # 5 minute timeout for file streaming
|
|
1346
|
+
) as resp:
|
|
1347
|
+
resp.raise_for_status()
|
|
1348
|
+
|
|
1349
|
+
# Get content-disposition header for filename/extension
|
|
1350
|
+
content_disp = resp.headers.get("content-disposition", "")
|
|
1351
|
+
|
|
1352
|
+
# Try to extract extension from the server's filename
|
|
1353
|
+
extension = ".flac" # Default
|
|
1354
|
+
if "filename=" in content_disp:
|
|
1355
|
+
import re
|
|
1356
|
+
match = re.search(r'filename="?([^";\s]+)"?', content_disp)
|
|
1357
|
+
if match:
|
|
1358
|
+
server_filename = match.group(1)
|
|
1359
|
+
_, ext = os.path.splitext(server_filename)
|
|
1360
|
+
if ext:
|
|
1361
|
+
extension = ext
|
|
1362
|
+
|
|
1363
|
+
# Also try content-type
|
|
1364
|
+
content_type = resp.headers.get("content-type", "")
|
|
1365
|
+
if "audio/mpeg" in content_type or "audio/mp3" in content_type:
|
|
1366
|
+
extension = ".mp3"
|
|
1367
|
+
elif "audio/wav" in content_type:
|
|
1368
|
+
extension = ".wav"
|
|
1369
|
+
elif "audio/x-flac" in content_type or "audio/flac" in content_type:
|
|
1370
|
+
extension = ".flac"
|
|
1371
|
+
elif "audio/mp4" in content_type or "audio/m4a" in content_type:
|
|
1372
|
+
extension = ".m4a"
|
|
1373
|
+
|
|
1374
|
+
# Build local filepath
|
|
1375
|
+
local_filepath = os.path.join(output_dir, f"{output_filename}{extension}")
|
|
1376
|
+
|
|
1377
|
+
# Stream to local file
|
|
1378
|
+
total_bytes = 0
|
|
1379
|
+
with open(local_filepath, "wb") as f:
|
|
1380
|
+
for chunk in resp.iter_bytes(chunk_size=8192):
|
|
1381
|
+
f.write(chunk)
|
|
1382
|
+
total_bytes += len(chunk)
|
|
1383
|
+
|
|
1384
|
+
self.logger.info(f"[RemoteFlacFetcher] Streamed {total_bytes / 1024 / 1024:.1f} MB to {local_filepath}")
|
|
1385
|
+
return local_filepath
|
|
1386
|
+
|
|
1387
|
+
except httpx.RequestError as e:
|
|
1388
|
+
raise DownloadError(f"Failed to stream file: {e}") from e
|
|
1389
|
+
except httpx.HTTPStatusError as e:
|
|
1390
|
+
raise DownloadError(f"Failed to stream file: {e.response.status_code}") from e
|
|
1391
|
+
|
|
1392
|
+
def select_best(self, results: List[AudioSearchResult]) -> int:
|
|
1393
|
+
"""
|
|
1394
|
+
Select the best result from a list of search results.
|
|
1395
|
+
|
|
1396
|
+
For remote fetcher, we use simple heuristics since we don't have
|
|
1397
|
+
access to flacfetch's internal ranking. Prefers:
|
|
1398
|
+
1. Lossless sources (FLAC) over lossy
|
|
1399
|
+
2. Higher seeders for torrents
|
|
1400
|
+
3. First result otherwise (API typically returns sorted by quality)
|
|
1401
|
+
|
|
1402
|
+
Args:
|
|
1403
|
+
results: List of AudioSearchResult objects from search()
|
|
1404
|
+
|
|
1405
|
+
Returns:
|
|
1406
|
+
Index of the best result in the list
|
|
1407
|
+
"""
|
|
1408
|
+
if not results:
|
|
1409
|
+
return 0
|
|
1410
|
+
|
|
1411
|
+
# Score each result
|
|
1412
|
+
best_index = 0
|
|
1413
|
+
best_score = -1
|
|
1414
|
+
|
|
1415
|
+
for i, result in enumerate(results):
|
|
1416
|
+
score = 0
|
|
1417
|
+
|
|
1418
|
+
# Prefer lossless
|
|
1419
|
+
quality = (result.quality or "").lower()
|
|
1420
|
+
if "flac" in quality or "lossless" in quality:
|
|
1421
|
+
score += 1000
|
|
1422
|
+
elif "320" in quality:
|
|
1423
|
+
score += 500
|
|
1424
|
+
elif "256" in quality or "192" in quality:
|
|
1425
|
+
score += 200
|
|
1426
|
+
|
|
1427
|
+
# Prefer higher seeders (for torrents)
|
|
1428
|
+
if result.seeders:
|
|
1429
|
+
score += min(result.seeders, 100) # Cap at 100 points
|
|
1430
|
+
|
|
1431
|
+
# Prefer non-YouTube sources (typically higher quality)
|
|
1432
|
+
provider = (result.provider or "").lower()
|
|
1433
|
+
if "youtube" not in provider:
|
|
1434
|
+
score += 50
|
|
1435
|
+
|
|
1436
|
+
if score > best_score:
|
|
1437
|
+
best_score = score
|
|
1438
|
+
best_index = i
|
|
1439
|
+
|
|
1440
|
+
return best_index
|
|
1441
|
+
|
|
1442
|
+
def search_and_download(
|
|
1443
|
+
self,
|
|
1444
|
+
artist: str,
|
|
1445
|
+
title: str,
|
|
1446
|
+
output_dir: str,
|
|
1447
|
+
output_filename: Optional[str] = None,
|
|
1448
|
+
auto_select: bool = False,
|
|
1449
|
+
) -> AudioFetchResult:
|
|
1450
|
+
"""
|
|
1451
|
+
Search for audio and download it in one operation via remote API.
|
|
1452
|
+
|
|
1453
|
+
Args:
|
|
1454
|
+
artist: The artist name to search for
|
|
1455
|
+
title: The track title to search for
|
|
1456
|
+
output_dir: Directory to save the downloaded file
|
|
1457
|
+
output_filename: Optional filename (without extension)
|
|
1458
|
+
auto_select: If True, automatically select the best result
|
|
1459
|
+
|
|
1460
|
+
Returns:
|
|
1461
|
+
AudioFetchResult with the downloaded file path
|
|
1462
|
+
|
|
1463
|
+
Raises:
|
|
1464
|
+
NoResultsError: If no results are found
|
|
1465
|
+
DownloadError: If download fails
|
|
1466
|
+
UserCancelledError: If user cancels
|
|
1467
|
+
"""
|
|
1468
|
+
# Search
|
|
1469
|
+
results = self.search(artist, title)
|
|
1470
|
+
|
|
1471
|
+
if auto_select:
|
|
1472
|
+
# Auto mode: select best result
|
|
1473
|
+
best_index = self.select_best(results)
|
|
1474
|
+
selected = results[best_index]
|
|
1475
|
+
self.logger.info(f"[RemoteFlacFetcher] Auto-selected: {selected.title} from {selected.provider}")
|
|
1476
|
+
else:
|
|
1477
|
+
# Interactive mode: present options to user
|
|
1478
|
+
selected = self._interactive_select(results, artist, title)
|
|
1479
|
+
|
|
1480
|
+
# Download
|
|
1481
|
+
return self.download(selected, output_dir, output_filename)
|
|
1482
|
+
|
|
1483
|
+
def _convert_api_result_for_release(self, api_result: dict) -> dict:
|
|
1484
|
+
"""
|
|
1485
|
+
Convert API SearchResultItem format to format expected by Release.from_dict().
|
|
1486
|
+
|
|
1487
|
+
The flacfetch API returns:
|
|
1488
|
+
- provider: source name (RED, OPS, YouTube)
|
|
1489
|
+
- quality: display string (e.g., "FLAC 16bit CD")
|
|
1490
|
+
- quality_data: structured dict with format, bit_depth, media, etc.
|
|
1491
|
+
|
|
1492
|
+
But Release.from_dict() expects:
|
|
1493
|
+
- source_name: provider name
|
|
1494
|
+
- quality: dict with format, bit_depth, media, etc.
|
|
1495
|
+
|
|
1496
|
+
This mirrors the convert_api_result_to_display() function in flacfetch-remote CLI.
|
|
1497
|
+
"""
|
|
1498
|
+
result = dict(api_result) # Copy to avoid modifying original
|
|
1499
|
+
|
|
1500
|
+
# Map provider to source_name
|
|
1501
|
+
result["source_name"] = api_result.get("provider", "Unknown")
|
|
1502
|
+
|
|
1503
|
+
# Store original quality string as quality_str (used by display functions)
|
|
1504
|
+
result["quality_str"] = api_result.get("quality", "")
|
|
1505
|
+
|
|
1506
|
+
# Map quality_data to quality (Release.from_dict expects quality to be a dict)
|
|
1507
|
+
quality_data = api_result.get("quality_data")
|
|
1508
|
+
if quality_data and isinstance(quality_data, dict):
|
|
1509
|
+
result["quality"] = quality_data
|
|
1510
|
+
else:
|
|
1511
|
+
# Fallback: parse quality string to determine format
|
|
1512
|
+
quality_str = api_result.get("quality", "").upper()
|
|
1513
|
+
format_name = "OTHER"
|
|
1514
|
+
media_name = "OTHER"
|
|
1515
|
+
|
|
1516
|
+
if "FLAC" in quality_str:
|
|
1517
|
+
format_name = "FLAC"
|
|
1518
|
+
elif "MP3" in quality_str:
|
|
1519
|
+
format_name = "MP3"
|
|
1520
|
+
elif "WAV" in quality_str:
|
|
1521
|
+
format_name = "WAV"
|
|
1522
|
+
|
|
1523
|
+
if "CD" in quality_str:
|
|
1524
|
+
media_name = "CD"
|
|
1525
|
+
elif "WEB" in quality_str:
|
|
1526
|
+
media_name = "WEB"
|
|
1527
|
+
elif "VINYL" in quality_str:
|
|
1528
|
+
media_name = "VINYL"
|
|
1529
|
+
|
|
1530
|
+
result["quality"] = {"format": format_name, "media": media_name}
|
|
1531
|
+
|
|
1532
|
+
# Copy is_lossless if available
|
|
1533
|
+
if "is_lossless" in api_result:
|
|
1534
|
+
result["is_lossless"] = api_result["is_lossless"]
|
|
1535
|
+
|
|
1536
|
+
return result
|
|
1537
|
+
|
|
1538
|
+
def _interactive_select(
|
|
1539
|
+
self,
|
|
1540
|
+
results: List[AudioSearchResult],
|
|
1541
|
+
artist: str,
|
|
1542
|
+
title: str,
|
|
1543
|
+
) -> AudioSearchResult:
|
|
1544
|
+
"""
|
|
1545
|
+
Present search results to the user for interactive selection.
|
|
1546
|
+
|
|
1547
|
+
Uses flacfetch's built-in display functions if available, otherwise
|
|
1548
|
+
falls back to basic text display.
|
|
1549
|
+
|
|
1550
|
+
Args:
|
|
1551
|
+
results: List of AudioSearchResult objects
|
|
1552
|
+
artist: The artist name being searched
|
|
1553
|
+
title: The track title being searched
|
|
1554
|
+
|
|
1555
|
+
Returns:
|
|
1556
|
+
The selected AudioSearchResult
|
|
1557
|
+
|
|
1558
|
+
Raises:
|
|
1559
|
+
UserCancelledError: If user cancels selection
|
|
1560
|
+
"""
|
|
1561
|
+
# Try to use flacfetch's display functions with raw API results
|
|
1562
|
+
try:
|
|
1563
|
+
# Convert raw_result dicts back to Release objects for display
|
|
1564
|
+
from flacfetch.core.models import Release
|
|
1565
|
+
|
|
1566
|
+
releases = []
|
|
1567
|
+
for r in results:
|
|
1568
|
+
if r.raw_result and isinstance(r.raw_result, dict):
|
|
1569
|
+
# Convert API format to Release.from_dict() format
|
|
1570
|
+
converted = self._convert_api_result_for_release(r.raw_result)
|
|
1571
|
+
release = Release.from_dict(converted)
|
|
1572
|
+
releases.append(release)
|
|
1573
|
+
elif r.raw_result and hasattr(r.raw_result, 'title'):
|
|
1574
|
+
# It's already a Release object
|
|
1575
|
+
releases.append(r.raw_result)
|
|
1576
|
+
|
|
1577
|
+
if releases:
|
|
1578
|
+
from flacfetch.interface.cli import CLIHandler
|
|
1579
|
+
handler = CLIHandler(target_artist=artist)
|
|
1580
|
+
selected_release = handler.select_release(releases)
|
|
1581
|
+
|
|
1582
|
+
if selected_release is None:
|
|
1583
|
+
raise UserCancelledError("Selection cancelled by user")
|
|
1584
|
+
|
|
1585
|
+
# Find the matching AudioSearchResult by index
|
|
1586
|
+
# CLIHandler returns the release at the selected index
|
|
1587
|
+
for i, release in enumerate(releases):
|
|
1588
|
+
if release == selected_release:
|
|
1589
|
+
return results[i]
|
|
1590
|
+
|
|
1591
|
+
# Fallback: try matching by download_url
|
|
1592
|
+
for r in results:
|
|
1593
|
+
if r.raw_result == selected_release or (
|
|
1594
|
+
isinstance(r.raw_result, dict) and
|
|
1595
|
+
r.raw_result.get("download_url") == getattr(selected_release, "download_url", None)
|
|
1596
|
+
):
|
|
1597
|
+
return r
|
|
1598
|
+
|
|
1599
|
+
except (ImportError, AttributeError, TypeError) as e:
|
|
1600
|
+
self.logger.debug(f"[RemoteFlacFetcher] Falling back to basic display: {e}")
|
|
1601
|
+
|
|
1602
|
+
# Fallback to basic display
|
|
1603
|
+
return self._basic_interactive_select(results, artist, title)
|
|
1604
|
+
|
|
1605
|
+
def _basic_interactive_select(
|
|
1606
|
+
self,
|
|
1607
|
+
results: List[AudioSearchResult],
|
|
1608
|
+
artist: str,
|
|
1609
|
+
title: str,
|
|
1610
|
+
) -> AudioSearchResult:
|
|
1611
|
+
"""
|
|
1612
|
+
Basic fallback for interactive selection without rich formatting.
|
|
1613
|
+
|
|
1614
|
+
Args:
|
|
1615
|
+
results: List of AudioSearchResult objects
|
|
1616
|
+
artist: The artist name being searched
|
|
1617
|
+
title: The track title being searched
|
|
1618
|
+
|
|
1619
|
+
Returns:
|
|
1620
|
+
The selected AudioSearchResult
|
|
1621
|
+
|
|
1622
|
+
Raises:
|
|
1623
|
+
UserCancelledError: If user cancels selection
|
|
1624
|
+
"""
|
|
1625
|
+
print(f"\nFound {len(results)} releases:\n")
|
|
1626
|
+
|
|
1627
|
+
for i, result in enumerate(results, 1):
|
|
1628
|
+
# Try to get lossless info from raw_result (API response)
|
|
1629
|
+
is_lossless = False
|
|
1630
|
+
if result.raw_result and isinstance(result.raw_result, dict):
|
|
1631
|
+
is_lossless = result.raw_result.get("is_lossless", False)
|
|
1632
|
+
elif result.quality:
|
|
1633
|
+
is_lossless = "flac" in result.quality.lower() or "lossless" in result.quality.lower()
|
|
1634
|
+
|
|
1635
|
+
format_indicator = "[LOSSLESS]" if is_lossless else "[lossy]"
|
|
1636
|
+
quality = f"({result.quality})" if result.quality else ""
|
|
1637
|
+
provider = f"[{result.provider}]" if result.provider else ""
|
|
1638
|
+
seeders = f"Seeders: {result.seeders}" if result.seeders else ""
|
|
1639
|
+
duration = ""
|
|
1640
|
+
if result.duration:
|
|
1641
|
+
mins, secs = divmod(result.duration, 60)
|
|
1642
|
+
duration = f"[{int(mins)}:{int(secs):02d}]"
|
|
1643
|
+
|
|
1644
|
+
print(f"{i}. {format_indicator} {provider} {result.artist}: {result.title} {quality} {duration} {seeders}")
|
|
1645
|
+
|
|
1646
|
+
print()
|
|
1647
|
+
|
|
1648
|
+
while True:
|
|
1649
|
+
try:
|
|
1650
|
+
choice = input(f"Select a release (1-{len(results)}, 0 to cancel): ").strip()
|
|
1651
|
+
|
|
1652
|
+
if choice == "0":
|
|
1653
|
+
raise UserCancelledError("Selection cancelled by user")
|
|
1654
|
+
|
|
1655
|
+
choice_num = int(choice)
|
|
1656
|
+
if 1 <= choice_num <= len(results):
|
|
1657
|
+
selected = results[choice_num - 1]
|
|
1658
|
+
self.logger.info(f"[RemoteFlacFetcher] User selected option {choice_num}")
|
|
1659
|
+
return selected
|
|
1660
|
+
else:
|
|
1661
|
+
print(f"Please enter a number between 0 and {len(results)}")
|
|
1662
|
+
|
|
1663
|
+
except ValueError:
|
|
1664
|
+
print("Please enter a valid number")
|
|
1665
|
+
except (KeyboardInterrupt, EOFError):
|
|
1666
|
+
print("\nCancelled")
|
|
1667
|
+
raise UserCancelledError("Selection cancelled by user (Ctrl+C)")
|
|
1668
|
+
|
|
1669
|
+
def download_from_url(
|
|
1670
|
+
self,
|
|
1671
|
+
url: str,
|
|
1672
|
+
output_dir: str,
|
|
1673
|
+
output_filename: Optional[str] = None,
|
|
1674
|
+
artist: Optional[str] = None,
|
|
1675
|
+
title: Optional[str] = None,
|
|
1676
|
+
) -> AudioFetchResult:
|
|
1677
|
+
"""
|
|
1678
|
+
Download audio directly from a URL (e.g., YouTube URL).
|
|
1679
|
+
|
|
1680
|
+
For YouTube URLs, this uses local flacfetch since YouTube downloads
|
|
1681
|
+
don't require the remote flacfetch infrastructure (no torrents).
|
|
1682
|
+
|
|
1683
|
+
Args:
|
|
1684
|
+
url: The URL to download from (e.g., YouTube video URL)
|
|
1685
|
+
output_dir: Directory to save the downloaded file
|
|
1686
|
+
output_filename: Optional filename (without extension)
|
|
1687
|
+
artist: Optional artist name for metadata
|
|
1688
|
+
title: Optional title for metadata
|
|
1689
|
+
|
|
1690
|
+
Returns:
|
|
1691
|
+
AudioFetchResult with the downloaded file path
|
|
1692
|
+
|
|
1693
|
+
Raises:
|
|
1694
|
+
DownloadError: If download fails
|
|
1695
|
+
"""
|
|
1696
|
+
import re
|
|
1697
|
+
|
|
1698
|
+
self.logger.info(f"[RemoteFlacFetcher] Downloading from URL: {url}")
|
|
1699
|
+
self.logger.info("[RemoteFlacFetcher] Using local flacfetch for YouTube download (no remote API needed)")
|
|
1700
|
+
|
|
1701
|
+
try:
|
|
1702
|
+
# Use local flacfetch for YouTube downloads - no need for remote API
|
|
1703
|
+
# This avoids needing yt-dlp directly in karaoke-gen
|
|
1704
|
+
from flacfetch.core.manager import FetchManager
|
|
1705
|
+
from flacfetch.providers.youtube import YoutubeProvider
|
|
1706
|
+
from flacfetch.downloaders.youtube import YoutubeDownloader
|
|
1707
|
+
|
|
1708
|
+
# Create a minimal local manager for YouTube downloads
|
|
1709
|
+
manager = FetchManager()
|
|
1710
|
+
manager.add_provider(YoutubeProvider())
|
|
1711
|
+
manager.register_downloader("YouTube", YoutubeDownloader())
|
|
1712
|
+
|
|
1713
|
+
# Ensure output directory exists
|
|
1714
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1715
|
+
|
|
1716
|
+
# Extract video ID from URL
|
|
1717
|
+
source_id = None
|
|
1718
|
+
youtube_patterns = [
|
|
1719
|
+
r'(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})',
|
|
1720
|
+
r'youtube\.com/embed/([a-zA-Z0-9_-]{11})',
|
|
1721
|
+
r'youtube\.com/v/([a-zA-Z0-9_-]{11})',
|
|
1722
|
+
]
|
|
1723
|
+
for pattern in youtube_patterns:
|
|
1724
|
+
match = re.search(pattern, url)
|
|
1725
|
+
if match:
|
|
1726
|
+
source_id = match.group(1)
|
|
1727
|
+
break
|
|
1728
|
+
|
|
1729
|
+
if not source_id:
|
|
1730
|
+
source_id = url
|
|
1731
|
+
|
|
1732
|
+
# Generate filename if not provided
|
|
1733
|
+
if output_filename is None:
|
|
1734
|
+
if artist and title:
|
|
1735
|
+
output_filename = f"{artist} - {title}"
|
|
1736
|
+
else:
|
|
1737
|
+
output_filename = source_id
|
|
1738
|
+
|
|
1739
|
+
# Use flacfetch's download_by_id for direct URL download
|
|
1740
|
+
filepath = manager.download_by_id(
|
|
1741
|
+
source_name="YouTube",
|
|
1742
|
+
source_id=source_id,
|
|
1743
|
+
output_path=output_dir,
|
|
1744
|
+
output_filename=output_filename,
|
|
1745
|
+
download_url=url,
|
|
1746
|
+
)
|
|
1747
|
+
|
|
1748
|
+
if not filepath:
|
|
1749
|
+
raise DownloadError(f"Download returned no file path for URL: {url}")
|
|
1750
|
+
|
|
1751
|
+
self.logger.info(f"[RemoteFlacFetcher] Downloaded to: {filepath}")
|
|
1752
|
+
|
|
1753
|
+
return AudioFetchResult(
|
|
1754
|
+
filepath=filepath,
|
|
1755
|
+
artist=artist or "",
|
|
1756
|
+
title=title or "",
|
|
1757
|
+
provider="YouTube",
|
|
1758
|
+
duration=None,
|
|
1759
|
+
quality=None,
|
|
1760
|
+
)
|
|
1761
|
+
|
|
1762
|
+
except ImportError as e:
|
|
1763
|
+
raise DownloadError(
|
|
1764
|
+
f"flacfetch is required for URL downloads but import failed: {e}"
|
|
1765
|
+
) from e
|
|
1766
|
+
except Exception as e:
|
|
1767
|
+
raise DownloadError(f"Failed to download from URL {url}: {e}") from e
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
# Alias for shorter name
|
|
1771
|
+
RemoteFlacFetcher = RemoteFlacFetchAudioFetcher
|
|
1772
|
+
|
|
1773
|
+
|
|
861
1774
|
def create_audio_fetcher(
|
|
862
1775
|
logger: Optional[logging.Logger] = None,
|
|
863
|
-
|
|
1776
|
+
red_api_key: Optional[str] = None,
|
|
1777
|
+
red_api_url: Optional[str] = None,
|
|
864
1778
|
ops_api_key: Optional[str] = None,
|
|
1779
|
+
ops_api_url: Optional[str] = None,
|
|
1780
|
+
flacfetch_api_url: Optional[str] = None,
|
|
1781
|
+
flacfetch_api_key: Optional[str] = None,
|
|
865
1782
|
) -> AudioFetcher:
|
|
866
1783
|
"""
|
|
867
1784
|
Factory function to create an appropriate AudioFetcher instance.
|
|
1785
|
+
|
|
1786
|
+
If FLACFETCH_API_URL and FLACFETCH_API_KEY environment variables are set
|
|
1787
|
+
(or passed as arguments), returns a RemoteFlacFetchAudioFetcher that uses
|
|
1788
|
+
the remote flacfetch HTTP API server.
|
|
1789
|
+
|
|
1790
|
+
Otherwise, returns a local FlacFetchAudioFetcher that uses the flacfetch
|
|
1791
|
+
library directly.
|
|
868
1792
|
|
|
869
1793
|
Args:
|
|
870
1794
|
logger: Logger instance for output
|
|
871
|
-
|
|
872
|
-
|
|
1795
|
+
red_api_key: API key for RED tracker (optional, for local mode)
|
|
1796
|
+
red_api_url: Base URL for RED tracker API (optional, for local mode)
|
|
1797
|
+
ops_api_key: API key for OPS tracker (optional, for local mode)
|
|
1798
|
+
ops_api_url: Base URL for OPS tracker API (optional, for local mode)
|
|
1799
|
+
flacfetch_api_url: URL of remote flacfetch API server (optional)
|
|
1800
|
+
flacfetch_api_key: API key for remote flacfetch server (optional)
|
|
873
1801
|
|
|
874
1802
|
Returns:
|
|
875
|
-
An AudioFetcher instance
|
|
1803
|
+
An AudioFetcher instance (remote or local depending on configuration)
|
|
876
1804
|
"""
|
|
1805
|
+
# Check for remote flacfetch API configuration
|
|
1806
|
+
api_url = flacfetch_api_url or os.environ.get("FLACFETCH_API_URL")
|
|
1807
|
+
api_key = flacfetch_api_key or os.environ.get("FLACFETCH_API_KEY")
|
|
1808
|
+
|
|
1809
|
+
if api_url and api_key:
|
|
1810
|
+
# Use remote flacfetch API
|
|
1811
|
+
if logger:
|
|
1812
|
+
logger.info(f"Using remote flacfetch API at: {api_url}")
|
|
1813
|
+
return RemoteFlacFetchAudioFetcher(
|
|
1814
|
+
api_url=api_url,
|
|
1815
|
+
api_key=api_key,
|
|
1816
|
+
logger=logger,
|
|
1817
|
+
)
|
|
1818
|
+
elif api_url and not api_key:
|
|
1819
|
+
if logger:
|
|
1820
|
+
logger.warning("FLACFETCH_API_URL is set but FLACFETCH_API_KEY is not - falling back to local mode")
|
|
1821
|
+
elif api_key and not api_url:
|
|
1822
|
+
if logger:
|
|
1823
|
+
logger.warning("FLACFETCH_API_KEY is set but FLACFETCH_API_URL is not - falling back to local mode")
|
|
1824
|
+
|
|
1825
|
+
# Use local flacfetch library
|
|
877
1826
|
return FlacFetchAudioFetcher(
|
|
878
1827
|
logger=logger,
|
|
879
|
-
|
|
1828
|
+
red_api_key=red_api_key,
|
|
1829
|
+
red_api_url=red_api_url,
|
|
880
1830
|
ops_api_key=ops_api_key,
|
|
1831
|
+
ops_api_url=ops_api_url,
|
|
881
1832
|
)
|