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.
Files changed (47) hide show
  1. karaoke_gen/audio_fetcher.py +984 -33
  2. karaoke_gen/audio_processor.py +4 -0
  3. karaoke_gen/instrumental_review/static/index.html +37 -14
  4. karaoke_gen/karaoke_finalise/karaoke_finalise.py +25 -1
  5. karaoke_gen/karaoke_gen.py +208 -39
  6. karaoke_gen/lyrics_processor.py +111 -31
  7. karaoke_gen/utils/__init__.py +26 -0
  8. karaoke_gen/utils/cli_args.py +15 -6
  9. karaoke_gen/utils/gen_cli.py +30 -5
  10. karaoke_gen/utils/remote_cli.py +301 -20
  11. {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/METADATA +107 -5
  12. {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/RECORD +47 -43
  13. lyrics_transcriber/core/controller.py +76 -2
  14. lyrics_transcriber/frontend/index.html +5 -1
  15. lyrics_transcriber/frontend/package-lock.json +4553 -0
  16. lyrics_transcriber/frontend/package.json +4 -1
  17. lyrics_transcriber/frontend/playwright.config.ts +69 -0
  18. lyrics_transcriber/frontend/public/nomad-karaoke-logo.svg +5 -0
  19. lyrics_transcriber/frontend/src/App.tsx +94 -63
  20. lyrics_transcriber/frontend/src/api.ts +25 -10
  21. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +55 -21
  22. lyrics_transcriber/frontend/src/components/AppHeader.tsx +65 -0
  23. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +5 -5
  24. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +9 -9
  25. lyrics_transcriber/frontend/src/components/EditModal.tsx +1 -1
  26. lyrics_transcriber/frontend/src/components/EditWordList.tsx +1 -1
  27. lyrics_transcriber/frontend/src/components/Header.tsx +34 -48
  28. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +22 -21
  29. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  30. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  31. lyrics_transcriber/frontend/src/components/WordDivider.tsx +3 -3
  32. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +2 -2
  33. lyrics_transcriber/frontend/src/components/shared/constants.ts +15 -5
  34. lyrics_transcriber/frontend/src/main.tsx +1 -7
  35. lyrics_transcriber/frontend/src/theme.ts +337 -135
  36. lyrics_transcriber/frontend/vite.config.ts +5 -0
  37. lyrics_transcriber/frontend/web_assets/assets/{index-COYImAcx.js → index-BECn1o8Q.js} +38 -22
  38. lyrics_transcriber/frontend/web_assets/assets/{index-COYImAcx.js.map → index-BECn1o8Q.js.map} +1 -1
  39. lyrics_transcriber/frontend/web_assets/index.html +1 -1
  40. lyrics_transcriber/frontend/yarn.lock +1005 -1046
  41. lyrics_transcriber/output/countdown_processor.py +39 -0
  42. lyrics_transcriber/review/server.py +1 -1
  43. lyrics_transcriber/transcribers/audioshake.py +96 -7
  44. lyrics_transcriber/types.py +14 -12
  45. {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/WHEEL +0 -0
  46. {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/entry_points.txt +0 -0
  47. {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/licenses/LICENSE +0 -0
@@ -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
- try:
70
- release_dict = self.raw_result.to_dict()
71
- # Merge Release fields into result (they may override basic fields)
72
- for key in ['year', 'label', 'edition_info', 'release_type', 'channel',
73
- 'view_count', 'size_bytes', 'target_file_size', 'track_pattern',
74
- 'match_score', 'formatted_size', 'formatted_duration',
75
- 'formatted_views', 'is_lossless', 'quality_str', 'quality']:
76
- if key in release_dict:
77
- # Use 'quality_data' for the quality dict to avoid confusion with quality string
78
- result_key = 'quality_data' if key == 'quality' else key
79
- result[result_key] = release_dict[key]
80
- except AttributeError:
81
- pass # raw_result doesn't have to_dict() method
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
- redacted_api_key: Optional[str] = None,
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
- redacted_api_key: API key for Redacted tracker (optional)
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._redacted_api_key = redacted_api_key or os.environ.get("REDACTED_API_KEY")
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 (Redacted/OPS) when Transmission
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._redacted_api_key or self._ops_api_key):
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 (Redacted/OPS) DISABLED: "
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._redacted_api_key and can_use_trackers:
352
- from flacfetch.providers.redacted import RedactedProvider
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(RedactedProvider(api_key=self._redacted_api_key))
355
- self._manager.register_downloader("Redacted", TorrentDownloader())
356
- self.logger.info("[FlacFetcher] Added Redacted provider with TorrentDownloader")
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
- redacted_api_key: Optional[str] = None,
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
- redacted_api_key: API key for Redacted tracker (optional)
872
- ops_api_key: API key for OPS tracker (optional)
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
- redacted_api_key=redacted_api_key,
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
  )