karaoke-gen 0.71.42__py3-none-any.whl → 0.75.16__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 (32) hide show
  1. karaoke_gen/__init__.py +32 -1
  2. karaoke_gen/audio_fetcher.py +476 -56
  3. karaoke_gen/audio_processor.py +11 -3
  4. karaoke_gen/instrumental_review/server.py +154 -860
  5. karaoke_gen/instrumental_review/static/index.html +1506 -0
  6. karaoke_gen/karaoke_finalise/karaoke_finalise.py +62 -1
  7. karaoke_gen/karaoke_gen.py +114 -1
  8. karaoke_gen/lyrics_processor.py +81 -4
  9. karaoke_gen/utils/bulk_cli.py +3 -0
  10. karaoke_gen/utils/cli_args.py +4 -2
  11. karaoke_gen/utils/gen_cli.py +196 -5
  12. karaoke_gen/utils/remote_cli.py +523 -34
  13. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/METADATA +4 -1
  14. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/RECORD +31 -25
  15. lyrics_transcriber/frontend/package.json +1 -1
  16. lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
  17. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
  18. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
  19. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
  20. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
  21. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
  22. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
  23. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
  24. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  25. lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-COYImAcx.js} +1722 -489
  26. lyrics_transcriber/frontend/web_assets/assets/index-COYImAcx.js.map +1 -0
  27. lyrics_transcriber/frontend/web_assets/index.html +1 -1
  28. lyrics_transcriber/review/server.py +5 -5
  29. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
  30. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/WHEEL +0 -0
  31. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/entry_points.txt +0 -0
  32. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/licenses/LICENSE +0 -0
@@ -7,15 +7,29 @@ using flacfetch, replacing the previous direct yt-dlp usage.
7
7
 
8
8
  import logging
9
9
  import os
10
+ import signal
11
+ import sys
10
12
  import tempfile
13
+ import threading
11
14
  from abc import ABC, abstractmethod
12
- from dataclasses import dataclass
13
- from typing import List, Optional
15
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
16
+ from dataclasses import dataclass, asdict, field
17
+ from typing import List, Optional, Dict, Any
18
+
19
+ # Global flag to track if user requested cancellation via Ctrl+C
20
+ _interrupt_requested = False
14
21
 
15
22
 
16
23
  @dataclass
17
24
  class AudioSearchResult:
18
- """Represents a single search result for audio."""
25
+ """Represents a single search result for audio.
26
+
27
+ Used by both local CLI and cloud backend. Supports serialization
28
+ for Firestore storage via to_dict()/from_dict().
29
+
30
+ For rich display, this class can serialize the full flacfetch Release
31
+ data so remote CLIs can use flacfetch's shared display functions.
32
+ """
19
33
 
20
34
  title: str
21
35
  artist: str
@@ -24,13 +38,75 @@ class AudioSearchResult:
24
38
  duration: Optional[int] = None # Duration in seconds
25
39
  quality: Optional[str] = None # e.g., "FLAC", "320kbps", etc.
26
40
  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
41
+ index: int = 0 # Index in the results list (for API selection)
42
+ seeders: Optional[int] = None # Number of seeders (for torrent sources)
43
+ target_file: Optional[str] = None # Target filename in the release
44
+ # Raw result object from the provider (for download) - not serialized
45
+ raw_result: Optional[object] = field(default=None, repr=False)
46
+
47
+ def to_dict(self) -> Dict[str, Any]:
48
+ """Convert to dict for JSON/Firestore serialization.
49
+
50
+ Includes full flacfetch Release data if available, enabling
51
+ remote CLIs to use flacfetch's shared display functions.
52
+ """
53
+ result = {
54
+ "title": self.title,
55
+ "artist": self.artist,
56
+ "url": self.url,
57
+ "provider": self.provider,
58
+ "duration": self.duration,
59
+ "quality": self.quality,
60
+ "source_id": self.source_id,
61
+ "index": self.index,
62
+ "seeders": self.seeders,
63
+ "target_file": self.target_file,
64
+ }
65
+
66
+ # If we have a raw_result (flacfetch Release), include its full data
67
+ # This enables rich display on the remote CLI
68
+ 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
82
+
83
+ return result
84
+
85
+ @classmethod
86
+ def from_dict(cls, data: Dict[str, Any]) -> "AudioSearchResult":
87
+ """Create from dict (e.g., from Firestore)."""
88
+ return cls(
89
+ title=data.get("title", ""),
90
+ artist=data.get("artist", ""),
91
+ url=data.get("url", ""),
92
+ provider=data.get("provider", "Unknown"),
93
+ duration=data.get("duration"),
94
+ quality=data.get("quality"),
95
+ source_id=data.get("source_id"),
96
+ index=data.get("index", 0),
97
+ seeders=data.get("seeders"),
98
+ target_file=data.get("target_file"),
99
+ raw_result=None, # Not stored in serialized form
100
+ )
29
101
 
30
102
 
31
103
  @dataclass
32
104
  class AudioFetchResult:
33
- """Result of an audio fetch operation."""
105
+ """Result of an audio fetch operation.
106
+
107
+ Used by both local CLI and cloud backend. Supports serialization
108
+ for Firestore storage via to_dict()/from_dict().
109
+ """
34
110
 
35
111
  filepath: str
36
112
  artist: str
@@ -38,6 +114,22 @@ class AudioFetchResult:
38
114
  provider: str
39
115
  duration: Optional[int] = None
40
116
  quality: Optional[str] = None
117
+
118
+ def to_dict(self) -> Dict[str, Any]:
119
+ """Convert to dict for JSON/Firestore serialization."""
120
+ return asdict(self)
121
+
122
+ @classmethod
123
+ def from_dict(cls, data: Dict[str, Any]) -> "AudioFetchResult":
124
+ """Create from dict (e.g., from Firestore)."""
125
+ return cls(
126
+ filepath=data.get("filepath", ""),
127
+ artist=data.get("artist", ""),
128
+ title=data.get("title", ""),
129
+ provider=data.get("provider", "Unknown"),
130
+ duration=data.get("duration"),
131
+ quality=data.get("quality"),
132
+ )
41
133
 
42
134
 
43
135
  class AudioFetcherError(Exception):
@@ -58,6 +150,19 @@ class DownloadError(AudioFetcherError):
58
150
  pass
59
151
 
60
152
 
153
+ class UserCancelledError(AudioFetcherError):
154
+ """Raised when user explicitly cancels the operation (e.g., enters 0 or Ctrl+C)."""
155
+
156
+ pass
157
+
158
+
159
+ def _check_interrupt():
160
+ """Check if interrupt was requested and raise UserCancelledError if so."""
161
+ global _interrupt_requested
162
+ if _interrupt_requested:
163
+ raise UserCancelledError("Operation cancelled by user")
164
+
165
+
61
166
  class AudioFetcher(ABC):
62
167
  """Abstract base class for audio fetching implementations."""
63
168
 
@@ -140,6 +245,8 @@ class FlacFetchAudioFetcher(AudioFetcher):
140
245
 
141
246
  This provides access to multiple audio sources including private music trackers
142
247
  and YouTube, with intelligent prioritization of high-quality sources.
248
+
249
+ Also exported as FlacFetcher for shorter name.
143
250
  """
144
251
 
145
252
  def __init__(
@@ -163,31 +270,101 @@ class FlacFetchAudioFetcher(AudioFetcher):
163
270
  self._ops_api_key = ops_api_key or os.environ.get("OPS_API_KEY")
164
271
  self._provider_priority = provider_priority
165
272
  self._manager = None
273
+ self._transmission_available = None # Cached result of Transmission check
274
+
275
+ def _check_transmission_available(self) -> bool:
276
+ """
277
+ Check if Transmission daemon is available for torrent downloads.
278
+
279
+ This prevents adding tracker providers (Redacted/OPS) when Transmission
280
+ isn't running, which would result in search results that can't be downloaded.
281
+
282
+ Returns:
283
+ True if Transmission is available and responsive, False otherwise.
284
+ """
285
+ if self._transmission_available is not None:
286
+ self.logger.info(f"[Transmission] Using cached status: available={self._transmission_available}")
287
+ return self._transmission_available
288
+
289
+ host = os.environ.get("TRANSMISSION_HOST", "localhost")
290
+ port = int(os.environ.get("TRANSMISSION_PORT", "9091"))
291
+ self.logger.info(f"[Transmission] Checking availability at {host}:{port}")
292
+
293
+ try:
294
+ import transmission_rpc
295
+ self.logger.info(f"[Transmission] transmission_rpc imported successfully")
296
+
297
+ client = transmission_rpc.Client(host=host, port=port, timeout=5)
298
+ self.logger.info(f"[Transmission] Client created, calling session_stats()...")
299
+
300
+ # Simple test to verify connection works
301
+ stats = client.session_stats()
302
+ self.logger.info(f"[Transmission] Connected! Download dir: {getattr(stats, 'download_dir', 'unknown')}")
303
+
304
+ self._transmission_available = True
305
+ except ImportError as e:
306
+ self._transmission_available = False
307
+ self.logger.warning(f"[Transmission] transmission_rpc not installed: {e}")
308
+ except Exception as e:
309
+ self._transmission_available = False
310
+ self.logger.warning(f"[Transmission] Connection failed to {host}:{port}: {type(e).__name__}: {e}")
311
+
312
+ self.logger.info(f"[Transmission] Final status: available={self._transmission_available}")
313
+ return self._transmission_available
166
314
 
167
315
  def _get_manager(self):
168
316
  """Lazily initialize and return the FetchManager."""
169
317
  if self._manager is None:
170
318
  # Import flacfetch here to avoid import errors if not installed
171
319
  from flacfetch.core.manager import FetchManager
172
- from flacfetch.providers.youtube import YouTubeProvider
320
+ from flacfetch.providers.youtube import YoutubeProvider
321
+ from flacfetch.downloaders.youtube import YoutubeDownloader
322
+
323
+ # Try to import TorrentDownloader (has optional dependencies)
324
+ TorrentDownloader = None
325
+ try:
326
+ from flacfetch.downloaders.torrent import TorrentDownloader
327
+ except ImportError:
328
+ self.logger.debug("TorrentDownloader not available (missing dependencies)")
173
329
 
174
330
  self._manager = FetchManager()
175
331
 
176
- # Add providers based on available API keys
177
- if self._redacted_api_key:
332
+ # Only add tracker providers if we can actually download from them
333
+ # This requires both TorrentDownloader and a running Transmission daemon
334
+ has_torrent_downloader = TorrentDownloader is not None
335
+ transmission_available = self._check_transmission_available()
336
+ can_use_trackers = has_torrent_downloader and transmission_available
337
+
338
+ self.logger.info(
339
+ f"[FlacFetcher] Provider setup: TorrentDownloader={has_torrent_downloader}, "
340
+ f"Transmission={transmission_available}, can_use_trackers={can_use_trackers}"
341
+ )
342
+
343
+ if not can_use_trackers and (self._redacted_api_key or self._ops_api_key):
344
+ self.logger.warning(
345
+ "[FlacFetcher] Tracker providers (Redacted/OPS) DISABLED: "
346
+ f"TorrentDownloader={has_torrent_downloader}, Transmission={transmission_available}. "
347
+ "Only YouTube sources will be used."
348
+ )
349
+
350
+ # Add providers and downloaders based on available API keys
351
+ if self._redacted_api_key and can_use_trackers:
178
352
  from flacfetch.providers.redacted import RedactedProvider
179
353
 
180
354
  self._manager.add_provider(RedactedProvider(api_key=self._redacted_api_key))
181
- self.logger.debug("Added Redacted provider")
355
+ self._manager.register_downloader("Redacted", TorrentDownloader())
356
+ self.logger.info("[FlacFetcher] Added Redacted provider with TorrentDownloader")
182
357
 
183
- if self._ops_api_key:
358
+ if self._ops_api_key and can_use_trackers:
184
359
  from flacfetch.providers.ops import OPSProvider
185
360
 
186
361
  self._manager.add_provider(OPSProvider(api_key=self._ops_api_key))
187
- self.logger.debug("Added OPS provider")
362
+ self._manager.register_downloader("OPS", TorrentDownloader())
363
+ self.logger.info("[FlacFetcher] Added OPS provider with TorrentDownloader")
188
364
 
189
- # Always add YouTube as a fallback provider
190
- self._manager.add_provider(YouTubeProvider())
365
+ # Always add YouTube as a fallback provider with its downloader
366
+ self._manager.add_provider(YoutubeProvider())
367
+ self._manager.register_downloader("YouTube", YoutubeDownloader())
191
368
  self.logger.debug("Added YouTube provider")
192
369
 
193
370
  return self._manager
@@ -219,16 +396,23 @@ class FlacFetchAudioFetcher(AudioFetcher):
219
396
 
220
397
  # Convert to our AudioSearchResult format
221
398
  search_results = []
222
- for result in results:
399
+ for i, result in enumerate(results):
400
+ # Get quality as string if it's a Quality object
401
+ quality = getattr(result, "quality", None)
402
+ quality_str = str(quality) if quality else None
403
+
223
404
  search_results.append(
224
405
  AudioSearchResult(
225
406
  title=getattr(result, "title", title),
226
407
  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),
408
+ url=getattr(result, "download_url", "") or "",
409
+ provider=getattr(result, "source_name", "Unknown"),
410
+ duration=getattr(result, "duration_seconds", None),
411
+ quality=quality_str,
412
+ source_id=getattr(result, "info_hash", None),
413
+ index=i, # Set index for API selection
414
+ seeders=getattr(result, "seeders", None),
415
+ target_file=getattr(result, "target_file", None),
232
416
  raw_result=result,
233
417
  )
234
418
  )
@@ -265,7 +449,7 @@ class FlacFetchAudioFetcher(AudioFetcher):
265
449
  if output_filename is None:
266
450
  output_filename = f"{result.artist} - {result.title}"
267
451
 
268
- self.logger.info(f"Downloading: {result.artist} - {result.title} from {result.provider}")
452
+ self.logger.info(f"Downloading: {result.artist} - {result.title} from {result.provider or 'Unknown'}")
269
453
 
270
454
  try:
271
455
  # Use flacfetch to download
@@ -292,6 +476,40 @@ class FlacFetchAudioFetcher(AudioFetcher):
292
476
  except Exception as e:
293
477
  raise DownloadError(f"Failed to download {result.artist} - {result.title}: {e}") from e
294
478
 
479
+ def select_best(self, results: List[AudioSearchResult]) -> int:
480
+ """
481
+ Select the best result from a list of search results.
482
+
483
+ Uses flacfetch's built-in quality ranking to determine the best source.
484
+ This is useful for automated/non-interactive usage.
485
+
486
+ Args:
487
+ results: List of AudioSearchResult objects from search()
488
+
489
+ Returns:
490
+ Index of the best result in the list
491
+ """
492
+ if not results:
493
+ return 0
494
+
495
+ manager = self._get_manager()
496
+
497
+ # Get raw results that have raw_result set
498
+ raw_results = [r.raw_result for r in results if r.raw_result is not None]
499
+
500
+ if raw_results:
501
+ try:
502
+ best = manager.select_best(raw_results)
503
+ # Find index of best result
504
+ for i, r in enumerate(results):
505
+ if r.raw_result == best:
506
+ return i
507
+ except Exception as e:
508
+ self.logger.warning(f"select_best failed, using first result: {e}")
509
+
510
+ # Fallback: return first result
511
+ return 0
512
+
295
513
  def search_and_download(
296
514
  self,
297
515
  artist: str,
@@ -319,6 +537,7 @@ class FlacFetchAudioFetcher(AudioFetcher):
319
537
  Raises:
320
538
  NoResultsError: If no results are found
321
539
  DownloadError: If download fails
540
+ UserCancelledError: If user cancels (Ctrl+C or enters 0)
322
541
  """
323
542
  from flacfetch.core.models import TrackQuery
324
543
 
@@ -326,7 +545,9 @@ class FlacFetchAudioFetcher(AudioFetcher):
326
545
  query = TrackQuery(artist=artist, title=title)
327
546
 
328
547
  self.logger.info(f"Searching for: {artist} - {title}")
329
- results = manager.search(query)
548
+
549
+ # Run search in a thread so we can handle Ctrl+C
550
+ results = self._interruptible_search(manager, query)
330
551
 
331
552
  if not results:
332
553
  raise NoResultsError(f"No results found for: {artist} - {title}")
@@ -336,11 +557,13 @@ class FlacFetchAudioFetcher(AudioFetcher):
336
557
  if auto_select:
337
558
  # Auto mode: select best result based on flacfetch's ranking
338
559
  selected = manager.select_best(results)
339
- self.logger.info(f"Auto-selected: {getattr(selected, 'title', title)} from {getattr(selected, 'provider', 'Unknown')}")
560
+ self.logger.info(f"Auto-selected: {getattr(selected, 'title', title)} from {getattr(selected, 'source_name', 'Unknown')}")
340
561
  else:
341
562
  # Interactive mode: present options to user
342
563
  selected = self._interactive_select(results, artist, title)
343
564
 
565
+ # Note: _interactive_select now raises UserCancelledError instead of returning None
566
+ # This check is kept as a safety net
344
567
  if selected is None:
345
568
  raise NoResultsError(f"No result selected for: {artist} - {title}")
346
569
 
@@ -351,69 +574,262 @@ class FlacFetchAudioFetcher(AudioFetcher):
351
574
  if output_filename is None:
352
575
  output_filename = f"{artist} - {title}"
353
576
 
354
- self.logger.info(f"Downloading from {getattr(selected, 'provider', 'Unknown')}...")
577
+ self.logger.info(f"Downloading from {getattr(selected, 'source_name', 'Unknown')}...")
355
578
 
356
579
  try:
357
- filepath = manager.download(
580
+ # Use interruptible download so Ctrl+C works during torrent downloads
581
+ filepath = self._interruptible_download(
582
+ manager,
358
583
  selected,
359
584
  output_path=output_dir,
360
585
  output_filename=output_filename,
361
586
  )
362
587
 
363
- if filepath is None:
588
+ if not filepath:
364
589
  raise DownloadError(f"Download returned no file path for: {artist} - {title}")
365
590
 
366
591
  self.logger.info(f"Downloaded to: {filepath}")
367
592
 
593
+ # Get quality as string if it's a Quality object
594
+ quality = getattr(selected, "quality", None)
595
+ quality_str = str(quality) if quality else None
596
+
368
597
  return AudioFetchResult(
369
598
  filepath=filepath,
370
599
  artist=artist,
371
600
  title=title,
372
- provider=getattr(selected, "provider", "Unknown"),
373
- duration=getattr(selected, "duration", None),
374
- quality=getattr(selected, "quality", None),
601
+ provider=getattr(selected, "source_name", "Unknown"),
602
+ duration=getattr(selected, "duration_seconds", None),
603
+ quality=quality_str,
375
604
  )
376
605
 
606
+ except (UserCancelledError, KeyboardInterrupt):
607
+ # Let cancellation exceptions propagate without wrapping
608
+ raise
377
609
  except Exception as e:
378
610
  raise DownloadError(f"Failed to download {artist} - {title}: {e}") from e
379
611
 
612
+ def _interruptible_search(self, manager, query) -> list:
613
+ """
614
+ Run search in a way that can be interrupted by Ctrl+C.
615
+
616
+ The flacfetch search is a blocking network operation that doesn't
617
+ respond to SIGINT while running. This method runs it in a background
618
+ thread and periodically checks for interrupts.
619
+
620
+ Args:
621
+ manager: The FetchManager instance
622
+ query: The TrackQuery to search for
623
+
624
+ Returns:
625
+ List of search results
626
+
627
+ Raises:
628
+ UserCancelledError: If user presses Ctrl+C during search
629
+ """
630
+ global _interrupt_requested
631
+ _interrupt_requested = False
632
+ result_container = {"results": None, "error": None}
633
+
634
+ def do_search():
635
+ try:
636
+ result_container["results"] = manager.search(query)
637
+ except Exception as e:
638
+ result_container["error"] = e
639
+
640
+ # Set up signal handler for immediate response to Ctrl+C
641
+ original_handler = signal.getsignal(signal.SIGINT)
642
+
643
+ def interrupt_handler(signum, frame):
644
+ global _interrupt_requested
645
+ _interrupt_requested = True
646
+ # Print immediately so user knows it was received
647
+ print("\nCancelling... please wait", file=sys.stderr)
648
+
649
+ signal.signal(signal.SIGINT, interrupt_handler)
650
+
651
+ try:
652
+ # Start search in background thread
653
+ search_thread = threading.Thread(target=do_search, daemon=True)
654
+ search_thread.start()
655
+
656
+ # Wait for completion with periodic interrupt checks
657
+ while search_thread.is_alive():
658
+ search_thread.join(timeout=0.1) # Check every 100ms
659
+ if _interrupt_requested:
660
+ # Don't wait for thread - it's a daemon and will be killed
661
+ raise UserCancelledError("Search cancelled by user (Ctrl+C)")
662
+
663
+ # Check for errors from the search
664
+ if result_container["error"] is not None:
665
+ raise result_container["error"]
666
+
667
+ return result_container["results"]
668
+
669
+ finally:
670
+ # Restore original signal handler
671
+ signal.signal(signal.SIGINT, original_handler)
672
+ _interrupt_requested = False
673
+
674
+ def _interruptible_download(self, manager, selected, output_path: str, output_filename: str) -> str:
675
+ """
676
+ Run download in a way that can be interrupted by Ctrl+C.
677
+
678
+ The flacfetch/transmission download is a blocking operation that doesn't
679
+ respond to SIGINT while running (especially for torrent downloads).
680
+ This method runs it in a background thread and periodically checks for interrupts.
681
+
682
+ Args:
683
+ manager: The FetchManager instance
684
+ selected: The selected result to download
685
+ output_path: Directory to save the file
686
+ output_filename: Filename to save as
687
+
688
+ Returns:
689
+ Path to the downloaded file
690
+
691
+ Raises:
692
+ UserCancelledError: If user presses Ctrl+C during download
693
+ DownloadError: If download fails
694
+ """
695
+ global _interrupt_requested
696
+ _interrupt_requested = False
697
+ result_container = {"filepath": None, "error": None}
698
+ was_cancelled = False
699
+
700
+ def do_download():
701
+ try:
702
+ result_container["filepath"] = manager.download(
703
+ selected,
704
+ output_path=output_path,
705
+ output_filename=output_filename,
706
+ )
707
+ except Exception as e:
708
+ result_container["error"] = e
709
+
710
+ # Set up signal handler for immediate response to Ctrl+C
711
+ original_handler = signal.getsignal(signal.SIGINT)
712
+
713
+ def interrupt_handler(signum, frame):
714
+ global _interrupt_requested
715
+ _interrupt_requested = True
716
+ # Print immediately so user knows it was received
717
+ print("\nCancelling download... please wait (may take a few seconds)", file=sys.stderr)
718
+
719
+ signal.signal(signal.SIGINT, interrupt_handler)
720
+
721
+ try:
722
+ # Start download in background thread
723
+ download_thread = threading.Thread(target=do_download, daemon=True)
724
+ download_thread.start()
725
+
726
+ # Wait for completion with periodic interrupt checks
727
+ while download_thread.is_alive():
728
+ download_thread.join(timeout=0.2) # Check every 200ms
729
+ if _interrupt_requested:
730
+ was_cancelled = True
731
+ # Clean up any pending torrents before raising
732
+ self._cleanup_transmission_torrents(selected)
733
+ raise UserCancelledError("Download cancelled by user (Ctrl+C)")
734
+
735
+ # Check for errors from the download
736
+ if result_container["error"] is not None:
737
+ raise result_container["error"]
738
+
739
+ return result_container["filepath"]
740
+
741
+ finally:
742
+ # Restore original signal handler
743
+ signal.signal(signal.SIGINT, original_handler)
744
+ _interrupt_requested = False
745
+
746
+ def _cleanup_transmission_torrents(self, selected) -> None:
747
+ """
748
+ Clean up any torrents in Transmission that were started for this download.
749
+
750
+ Called when a download is cancelled to remove incomplete torrents and their data.
751
+
752
+ Args:
753
+ selected: The selected result that was being downloaded
754
+ """
755
+ try:
756
+ import transmission_rpc
757
+ host = os.environ.get("TRANSMISSION_HOST", "localhost")
758
+ port = int(os.environ.get("TRANSMISSION_PORT", "9091"))
759
+ client = transmission_rpc.Client(host=host, port=port, timeout=5)
760
+
761
+ # Get the release name to match against torrents
762
+ release_name = getattr(selected, 'name', None) or getattr(selected, 'title', None)
763
+ if not release_name:
764
+ self.logger.debug("[Transmission] No release name to match for cleanup")
765
+ return
766
+
767
+ # Find and remove matching incomplete torrents
768
+ torrents = client.get_torrents()
769
+ for torrent in torrents:
770
+ # Match by name similarity and incomplete status
771
+ if torrent.progress < 100 and release_name.lower() in torrent.name.lower():
772
+ self.logger.info(f"[Transmission] Removing cancelled torrent: {torrent.name}")
773
+ client.remove_torrent(torrent.id, delete_data=True)
774
+
775
+ except Exception as e:
776
+ # Don't fail the cancellation if cleanup fails
777
+ self.logger.debug(f"[Transmission] Cleanup failed (non-fatal): {e}")
778
+
380
779
  def _interactive_select(self, results: list, artist: str, title: str) -> object:
381
780
  """
382
781
  Present search results to the user for interactive selection.
383
782
 
783
+ Uses flacfetch's built-in CLIHandler for rich, colorized output.
784
+
384
785
  Args:
385
- results: List of search results from flacfetch
786
+ results: List of Release objects from flacfetch
386
787
  artist: The artist name being searched
387
788
  title: The track title being searched
388
789
 
389
790
  Returns:
390
- The selected result object
791
+ The selected Release object
792
+
793
+ Raises:
794
+ UserCancelledError: If user cancels selection
391
795
  """
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 ""
796
+ try:
797
+ # Use flacfetch's built-in CLIHandler for rich display
798
+ from flacfetch.interface.cli import CLIHandler
799
+
800
+ handler = CLIHandler(target_artist=artist)
801
+ result = handler.select_release(results)
802
+ if result is None:
803
+ # User selected 0 to cancel
804
+ raise UserCancelledError("Selection cancelled by user")
805
+ return result
806
+ except ImportError:
807
+ # Fallback to basic display if CLIHandler not available
808
+ return self._basic_interactive_select(results, artist, title)
809
+ except (KeyboardInterrupt, EOFError):
810
+ raise UserCancelledError("Selection cancelled by user (Ctrl+C)")
811
+ except (AttributeError, TypeError):
812
+ # Fallback if results aren't proper Release objects (e.g., in tests)
813
+ return self._basic_interactive_select(results, artist, title)
814
+
815
+ def _basic_interactive_select(self, results: list, artist: str, title: str) -> object:
816
+ """
817
+ Basic fallback for interactive selection without rich formatting.
411
818
 
412
- print(f" {i}. [{provider}] {result_artist} - {result_title}{quality_str}{duration_str}")
819
+ Args:
820
+ results: List of Release objects from flacfetch
821
+ artist: The artist name being searched
822
+ title: The track title being searched
413
823
 
414
- print()
415
- print(" 0. Cancel")
416
- print()
824
+ Returns:
825
+ The selected Release object
826
+
827
+ Raises:
828
+ UserCancelledError: If user cancels selection
829
+ """
830
+ # Use flacfetch's shared display function
831
+ from flacfetch import print_releases
832
+ print_releases(results, target_artist=artist, use_colors=True)
417
833
 
418
834
  while True:
419
835
  try:
@@ -421,7 +837,7 @@ class FlacFetchAudioFetcher(AudioFetcher):
421
837
 
422
838
  if choice == "0":
423
839
  self.logger.info("User cancelled selection")
424
- return None
840
+ raise UserCancelledError("Selection cancelled by user")
425
841
 
426
842
  choice_num = int(choice)
427
843
  if 1 <= choice_num <= len(results):
@@ -435,7 +851,11 @@ class FlacFetchAudioFetcher(AudioFetcher):
435
851
  print("Please enter a valid number")
436
852
  except KeyboardInterrupt:
437
853
  print("\nCancelled")
438
- return None
854
+ raise UserCancelledError("Selection cancelled by user (Ctrl+C)")
855
+
856
+
857
+ # Alias for shorter name - used by backend and other consumers
858
+ FlacFetcher = FlacFetchAudioFetcher
439
859
 
440
860
 
441
861
  def create_audio_fetcher(