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.
@@ -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
 
@@ -252,8 +278,10 @@ class FlacFetchAudioFetcher(AudioFetcher):
252
278
  def __init__(
253
279
  self,
254
280
  logger: Optional[logging.Logger] = None,
255
- redacted_api_key: Optional[str] = None,
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
- redacted_api_key: API key for Redacted tracker (optional)
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._redacted_api_key = redacted_api_key or os.environ.get("REDACTED_API_KEY")
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 (Redacted/OPS) when Transmission
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._redacted_api_key or self._ops_api_key):
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 (Redacted/OPS) DISABLED: "
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._redacted_api_key and can_use_trackers:
352
- from flacfetch.providers.redacted import RedactedProvider
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(RedactedProvider(api_key=self._redacted_api_key))
355
- self._manager.register_downloader("Redacted", TorrentDownloader())
356
- self.logger.info("[FlacFetcher] Added Redacted provider with TorrentDownloader")
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
- redacted_api_key: Optional[str] = None,
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
- redacted_api_key: API key for Redacted tracker (optional)
872
- ops_api_key: API key for OPS tracker (optional)
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
- redacted_api_key=redacted_api_key,
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
  )