karaoke-gen 0.71.42__py3-none-any.whl → 0.75.53__py3-none-any.whl

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