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.
- karaoke_gen/__init__.py +32 -1
- karaoke_gen/audio_fetcher.py +476 -56
- karaoke_gen/audio_processor.py +11 -3
- karaoke_gen/instrumental_review/server.py +154 -860
- karaoke_gen/instrumental_review/static/index.html +1506 -0
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +62 -1
- karaoke_gen/karaoke_gen.py +114 -1
- karaoke_gen/lyrics_processor.py +81 -4
- karaoke_gen/utils/bulk_cli.py +3 -0
- karaoke_gen/utils/cli_args.py +4 -2
- karaoke_gen/utils/gen_cli.py +196 -5
- karaoke_gen/utils/remote_cli.py +523 -34
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/METADATA +4 -1
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/RECORD +31 -25
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
- lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-COYImAcx.js} +1722 -489
- lyrics_transcriber/frontend/web_assets/assets/index-COYImAcx.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +1 -1
- lyrics_transcriber/review/server.py +5 -5
- lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/licenses/LICENSE +0 -0
karaoke_gen/audio_fetcher.py
CHANGED
|
@@ -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
|
|
13
|
-
from
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
#
|
|
177
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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, "
|
|
228
|
-
provider=getattr(result, "
|
|
229
|
-
duration=getattr(result, "
|
|
230
|
-
quality=
|
|
231
|
-
source_id=getattr(result, "
|
|
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
|
-
|
|
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, '
|
|
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, '
|
|
577
|
+
self.logger.info(f"Downloading from {getattr(selected, 'source_name', 'Unknown')}...")
|
|
355
578
|
|
|
356
579
|
try:
|
|
357
|
-
|
|
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
|
|
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, "
|
|
373
|
-
duration=getattr(selected, "
|
|
374
|
-
quality=
|
|
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
|
|
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
|
|
791
|
+
The selected Release object
|
|
792
|
+
|
|
793
|
+
Raises:
|
|
794
|
+
UserCancelledError: If user cancels selection
|
|
391
795
|
"""
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
#
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|