karaoke-gen 0.75.16__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/audio_fetcher.py +766 -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 +18 -14
- karaoke_gen/lyrics_processor.py +97 -6
- karaoke_gen/utils/cli_args.py +6 -5
- karaoke_gen/utils/gen_cli.py +30 -5
- karaoke_gen/utils/remote_cli.py +269 -15
- {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.75.53.dist-info}/METADATA +106 -4
- {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.75.53.dist-info}/RECORD +24 -24
- 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/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/output/countdown_processor.py +39 -0
- lyrics_transcriber/transcribers/audioshake.py +96 -7
- lyrics_transcriber/types.py +14 -12
- {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.75.53.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.75.53.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.75.53.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
|
|
|
@@ -252,8 +278,10 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
252
278
|
def __init__(
|
|
253
279
|
self,
|
|
254
280
|
logger: Optional[logging.Logger] = None,
|
|
255
|
-
|
|
281
|
+
red_api_key: Optional[str] = None,
|
|
282
|
+
red_api_url: Optional[str] = None,
|
|
256
283
|
ops_api_key: Optional[str] = None,
|
|
284
|
+
ops_api_url: Optional[str] = None,
|
|
257
285
|
provider_priority: Optional[List[str]] = None,
|
|
258
286
|
):
|
|
259
287
|
"""
|
|
@@ -261,13 +289,17 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
261
289
|
|
|
262
290
|
Args:
|
|
263
291
|
logger: Logger instance for output
|
|
264
|
-
|
|
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)
|
|
265
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)
|
|
266
296
|
provider_priority: Custom provider priority order (optional)
|
|
267
297
|
"""
|
|
268
298
|
self.logger = logger or logging.getLogger(__name__)
|
|
269
|
-
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")
|
|
270
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")
|
|
271
303
|
self._provider_priority = provider_priority
|
|
272
304
|
self._manager = None
|
|
273
305
|
self._transmission_available = None # Cached result of Transmission check
|
|
@@ -276,7 +308,7 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
276
308
|
"""
|
|
277
309
|
Check if Transmission daemon is available for torrent downloads.
|
|
278
310
|
|
|
279
|
-
This prevents adding tracker providers (
|
|
311
|
+
This prevents adding tracker providers (RED/OPS) when Transmission
|
|
280
312
|
isn't running, which would result in search results that can't be downloaded.
|
|
281
313
|
|
|
282
314
|
Returns:
|
|
@@ -340,27 +372,31 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
340
372
|
f"Transmission={transmission_available}, can_use_trackers={can_use_trackers}"
|
|
341
373
|
)
|
|
342
374
|
|
|
343
|
-
if not can_use_trackers and (self.
|
|
375
|
+
if not can_use_trackers and (self._red_api_key or self._ops_api_key):
|
|
344
376
|
self.logger.warning(
|
|
345
|
-
"[FlacFetcher] Tracker providers (
|
|
377
|
+
"[FlacFetcher] Tracker providers (RED/OPS) DISABLED: "
|
|
346
378
|
f"TorrentDownloader={has_torrent_downloader}, Transmission={transmission_available}. "
|
|
347
379
|
"Only YouTube sources will be used."
|
|
348
380
|
)
|
|
349
381
|
|
|
350
|
-
# Add providers and downloaders based on available API keys
|
|
351
|
-
if self.
|
|
352
|
-
from flacfetch.providers.
|
|
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
|
|
353
385
|
|
|
354
|
-
self._manager.add_provider(
|
|
355
|
-
self._manager.register_downloader("
|
|
356
|
-
self.logger.info("[FlacFetcher] Added
|
|
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")
|
|
357
391
|
|
|
358
|
-
if self._ops_api_key and can_use_trackers:
|
|
392
|
+
if self._ops_api_key and self._ops_api_url and can_use_trackers:
|
|
359
393
|
from flacfetch.providers.ops import OPSProvider
|
|
360
394
|
|
|
361
|
-
self._manager.add_provider(OPSProvider(api_key=self._ops_api_key))
|
|
395
|
+
self._manager.add_provider(OPSProvider(api_key=self._ops_api_key, base_url=self._ops_api_url))
|
|
362
396
|
self._manager.register_downloader("OPS", TorrentDownloader())
|
|
363
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")
|
|
364
400
|
|
|
365
401
|
# Always add YouTube as a fallback provider with its downloader
|
|
366
402
|
self._manager.add_provider(YoutubeProvider())
|
|
@@ -858,24 +894,721 @@ class FlacFetchAudioFetcher(AudioFetcher):
|
|
|
858
894
|
FlacFetcher = FlacFetchAudioFetcher
|
|
859
895
|
|
|
860
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
|
|
1554
|
+
|
|
1555
|
+
|
|
861
1556
|
def create_audio_fetcher(
|
|
862
1557
|
logger: Optional[logging.Logger] = None,
|
|
863
|
-
|
|
1558
|
+
red_api_key: Optional[str] = None,
|
|
1559
|
+
red_api_url: Optional[str] = None,
|
|
864
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,
|
|
865
1564
|
) -> AudioFetcher:
|
|
866
1565
|
"""
|
|
867
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.
|
|
868
1574
|
|
|
869
1575
|
Args:
|
|
870
1576
|
logger: Logger instance for output
|
|
871
|
-
|
|
872
|
-
|
|
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)
|
|
873
1583
|
|
|
874
1584
|
Returns:
|
|
875
|
-
An AudioFetcher instance
|
|
1585
|
+
An AudioFetcher instance (remote or local depending on configuration)
|
|
876
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
|
|
877
1608
|
return FlacFetchAudioFetcher(
|
|
878
1609
|
logger=logger,
|
|
879
|
-
|
|
1610
|
+
red_api_key=red_api_key,
|
|
1611
|
+
red_api_url=red_api_url,
|
|
880
1612
|
ops_api_key=ops_api_key,
|
|
1613
|
+
ops_api_url=ops_api_url,
|
|
881
1614
|
)
|