karaoke-gen 0.75.54__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.

Potentially problematic release.


This version of karaoke-gen might be problematic. Click here for more details.

Files changed (287) hide show
  1. karaoke_gen/__init__.py +38 -0
  2. karaoke_gen/audio_fetcher.py +1614 -0
  3. karaoke_gen/audio_processor.py +790 -0
  4. karaoke_gen/config.py +83 -0
  5. karaoke_gen/file_handler.py +387 -0
  6. karaoke_gen/instrumental_review/__init__.py +45 -0
  7. karaoke_gen/instrumental_review/analyzer.py +408 -0
  8. karaoke_gen/instrumental_review/editor.py +322 -0
  9. karaoke_gen/instrumental_review/models.py +171 -0
  10. karaoke_gen/instrumental_review/server.py +475 -0
  11. karaoke_gen/instrumental_review/static/index.html +1529 -0
  12. karaoke_gen/instrumental_review/waveform.py +409 -0
  13. karaoke_gen/karaoke_finalise/__init__.py +1 -0
  14. karaoke_gen/karaoke_finalise/karaoke_finalise.py +1833 -0
  15. karaoke_gen/karaoke_gen.py +1026 -0
  16. karaoke_gen/lyrics_processor.py +474 -0
  17. karaoke_gen/metadata.py +160 -0
  18. karaoke_gen/pipeline/__init__.py +87 -0
  19. karaoke_gen/pipeline/base.py +215 -0
  20. karaoke_gen/pipeline/context.py +230 -0
  21. karaoke_gen/pipeline/executors/__init__.py +21 -0
  22. karaoke_gen/pipeline/executors/local.py +159 -0
  23. karaoke_gen/pipeline/executors/remote.py +257 -0
  24. karaoke_gen/pipeline/stages/__init__.py +27 -0
  25. karaoke_gen/pipeline/stages/finalize.py +202 -0
  26. karaoke_gen/pipeline/stages/render.py +165 -0
  27. karaoke_gen/pipeline/stages/screens.py +139 -0
  28. karaoke_gen/pipeline/stages/separation.py +191 -0
  29. karaoke_gen/pipeline/stages/transcription.py +191 -0
  30. karaoke_gen/resources/AvenirNext-Bold.ttf +0 -0
  31. karaoke_gen/resources/Montserrat-Bold.ttf +0 -0
  32. karaoke_gen/resources/Oswald-Bold.ttf +0 -0
  33. karaoke_gen/resources/Oswald-SemiBold.ttf +0 -0
  34. karaoke_gen/resources/Zurich_Cn_BT_Bold.ttf +0 -0
  35. karaoke_gen/style_loader.py +531 -0
  36. karaoke_gen/utils/__init__.py +18 -0
  37. karaoke_gen/utils/bulk_cli.py +492 -0
  38. karaoke_gen/utils/cli_args.py +432 -0
  39. karaoke_gen/utils/gen_cli.py +978 -0
  40. karaoke_gen/utils/remote_cli.py +3268 -0
  41. karaoke_gen/video_background_processor.py +351 -0
  42. karaoke_gen/video_generator.py +424 -0
  43. karaoke_gen-0.75.54.dist-info/METADATA +718 -0
  44. karaoke_gen-0.75.54.dist-info/RECORD +287 -0
  45. karaoke_gen-0.75.54.dist-info/WHEEL +4 -0
  46. karaoke_gen-0.75.54.dist-info/entry_points.txt +5 -0
  47. karaoke_gen-0.75.54.dist-info/licenses/LICENSE +21 -0
  48. lyrics_transcriber/__init__.py +10 -0
  49. lyrics_transcriber/cli/__init__.py +0 -0
  50. lyrics_transcriber/cli/cli_main.py +285 -0
  51. lyrics_transcriber/core/__init__.py +0 -0
  52. lyrics_transcriber/core/config.py +50 -0
  53. lyrics_transcriber/core/controller.py +594 -0
  54. lyrics_transcriber/correction/__init__.py +0 -0
  55. lyrics_transcriber/correction/agentic/__init__.py +9 -0
  56. lyrics_transcriber/correction/agentic/adapter.py +71 -0
  57. lyrics_transcriber/correction/agentic/agent.py +313 -0
  58. lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
  59. lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
  60. lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
  61. lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
  62. lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
  63. lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
  64. lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
  65. lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
  66. lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
  67. lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
  68. lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
  69. lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
  70. lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
  71. lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
  72. lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
  73. lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
  74. lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
  75. lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
  76. lyrics_transcriber/correction/agentic/models/enums.py +38 -0
  77. lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
  78. lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
  79. lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
  80. lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
  81. lyrics_transcriber/correction/agentic/models/utils.py +19 -0
  82. lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
  83. lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
  84. lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
  85. lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
  86. lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
  87. lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
  88. lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
  89. lyrics_transcriber/correction/agentic/providers/base.py +36 -0
  90. lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
  91. lyrics_transcriber/correction/agentic/providers/config.py +73 -0
  92. lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
  93. lyrics_transcriber/correction/agentic/providers/health.py +28 -0
  94. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
  95. lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
  96. lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
  97. lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
  98. lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
  99. lyrics_transcriber/correction/agentic/router.py +35 -0
  100. lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
  101. lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
  102. lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
  103. lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
  104. lyrics_transcriber/correction/anchor_sequence.py +919 -0
  105. lyrics_transcriber/correction/corrector.py +760 -0
  106. lyrics_transcriber/correction/feedback/__init__.py +2 -0
  107. lyrics_transcriber/correction/feedback/schemas.py +107 -0
  108. lyrics_transcriber/correction/feedback/store.py +236 -0
  109. lyrics_transcriber/correction/handlers/__init__.py +0 -0
  110. lyrics_transcriber/correction/handlers/base.py +52 -0
  111. lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
  112. lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
  113. lyrics_transcriber/correction/handlers/llm.py +293 -0
  114. lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
  115. lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
  116. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
  117. lyrics_transcriber/correction/handlers/repeat.py +88 -0
  118. lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
  119. lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
  120. lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
  121. lyrics_transcriber/correction/handlers/word_operations.py +187 -0
  122. lyrics_transcriber/correction/operations.py +352 -0
  123. lyrics_transcriber/correction/phrase_analyzer.py +435 -0
  124. lyrics_transcriber/correction/text_utils.py +30 -0
  125. lyrics_transcriber/frontend/.gitignore +23 -0
  126. lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
  127. lyrics_transcriber/frontend/.yarnrc.yml +3 -0
  128. lyrics_transcriber/frontend/README.md +50 -0
  129. lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
  130. lyrics_transcriber/frontend/__init__.py +25 -0
  131. lyrics_transcriber/frontend/eslint.config.js +28 -0
  132. lyrics_transcriber/frontend/index.html +18 -0
  133. lyrics_transcriber/frontend/package.json +42 -0
  134. lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
  135. lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
  136. lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
  137. lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
  138. lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
  139. lyrics_transcriber/frontend/public/favicon.ico +0 -0
  140. lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
  141. lyrics_transcriber/frontend/src/App.tsx +214 -0
  142. lyrics_transcriber/frontend/src/api.ts +254 -0
  143. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
  144. lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
  145. lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
  146. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
  147. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
  148. lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
  149. lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
  150. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
  151. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
  152. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
  153. lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
  154. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
  155. lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
  156. lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
  157. lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
  158. lyrics_transcriber/frontend/src/components/Header.tsx +413 -0
  159. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1387 -0
  160. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
  161. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
  162. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
  163. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
  164. lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +51 -0
  165. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
  166. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
  167. lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
  168. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
  169. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
  170. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +336 -0
  171. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
  172. lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
  173. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
  174. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
  175. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
  176. lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
  177. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
  178. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
  179. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
  180. lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
  181. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
  182. lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
  183. lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
  184. lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
  185. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
  186. lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
  187. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
  188. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
  189. lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
  190. lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
  191. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
  192. lyrics_transcriber/frontend/src/main.tsx +17 -0
  193. lyrics_transcriber/frontend/src/theme.ts +177 -0
  194. lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
  195. lyrics_transcriber/frontend/src/types.js +2 -0
  196. lyrics_transcriber/frontend/src/types.ts +199 -0
  197. lyrics_transcriber/frontend/src/validation.ts +132 -0
  198. lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
  199. lyrics_transcriber/frontend/tsconfig.app.json +26 -0
  200. lyrics_transcriber/frontend/tsconfig.json +25 -0
  201. lyrics_transcriber/frontend/tsconfig.node.json +23 -0
  202. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
  203. lyrics_transcriber/frontend/update_version.js +11 -0
  204. lyrics_transcriber/frontend/vite.config.d.ts +2 -0
  205. lyrics_transcriber/frontend/vite.config.js +10 -0
  206. lyrics_transcriber/frontend/vite.config.ts +11 -0
  207. lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
  208. lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
  209. lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
  210. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js +43288 -0
  211. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
  212. lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
  213. lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
  214. lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
  215. lyrics_transcriber/frontend/web_assets/index.html +18 -0
  216. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
  217. lyrics_transcriber/frontend/yarn.lock +3752 -0
  218. lyrics_transcriber/lyrics/__init__.py +0 -0
  219. lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
  220. lyrics_transcriber/lyrics/file_provider.py +95 -0
  221. lyrics_transcriber/lyrics/genius.py +384 -0
  222. lyrics_transcriber/lyrics/lrclib.py +231 -0
  223. lyrics_transcriber/lyrics/musixmatch.py +156 -0
  224. lyrics_transcriber/lyrics/spotify.py +290 -0
  225. lyrics_transcriber/lyrics/user_input_provider.py +44 -0
  226. lyrics_transcriber/output/__init__.py +0 -0
  227. lyrics_transcriber/output/ass/__init__.py +21 -0
  228. lyrics_transcriber/output/ass/ass.py +2088 -0
  229. lyrics_transcriber/output/ass/ass_specs.txt +732 -0
  230. lyrics_transcriber/output/ass/config.py +180 -0
  231. lyrics_transcriber/output/ass/constants.py +23 -0
  232. lyrics_transcriber/output/ass/event.py +94 -0
  233. lyrics_transcriber/output/ass/formatters.py +132 -0
  234. lyrics_transcriber/output/ass/lyrics_line.py +265 -0
  235. lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
  236. lyrics_transcriber/output/ass/section_detector.py +89 -0
  237. lyrics_transcriber/output/ass/section_screen.py +106 -0
  238. lyrics_transcriber/output/ass/style.py +187 -0
  239. lyrics_transcriber/output/cdg.py +619 -0
  240. lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
  241. lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
  242. lyrics_transcriber/output/cdgmaker/composer.py +2260 -0
  243. lyrics_transcriber/output/cdgmaker/config.py +151 -0
  244. lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
  245. lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
  246. lyrics_transcriber/output/cdgmaker/pack.py +507 -0
  247. lyrics_transcriber/output/cdgmaker/render.py +346 -0
  248. lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
  249. lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
  250. lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
  251. lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
  252. lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
  253. lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
  254. lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
  255. lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
  256. lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
  257. lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
  258. lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
  259. lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
  260. lyrics_transcriber/output/cdgmaker/utils.py +132 -0
  261. lyrics_transcriber/output/countdown_processor.py +306 -0
  262. lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
  263. lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
  264. lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
  265. lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
  266. lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
  267. lyrics_transcriber/output/fonts/arial.ttf +0 -0
  268. lyrics_transcriber/output/fonts/georgia.ttf +0 -0
  269. lyrics_transcriber/output/fonts/verdana.ttf +0 -0
  270. lyrics_transcriber/output/generator.py +257 -0
  271. lyrics_transcriber/output/lrc_to_cdg.py +61 -0
  272. lyrics_transcriber/output/lyrics_file.py +102 -0
  273. lyrics_transcriber/output/plain_text.py +96 -0
  274. lyrics_transcriber/output/segment_resizer.py +431 -0
  275. lyrics_transcriber/output/subtitles.py +397 -0
  276. lyrics_transcriber/output/video.py +544 -0
  277. lyrics_transcriber/review/__init__.py +0 -0
  278. lyrics_transcriber/review/server.py +676 -0
  279. lyrics_transcriber/storage/__init__.py +0 -0
  280. lyrics_transcriber/storage/dropbox.py +225 -0
  281. lyrics_transcriber/transcribers/__init__.py +0 -0
  282. lyrics_transcriber/transcribers/audioshake.py +379 -0
  283. lyrics_transcriber/transcribers/base_transcriber.py +157 -0
  284. lyrics_transcriber/transcribers/whisper.py +330 -0
  285. lyrics_transcriber/types.py +650 -0
  286. lyrics_transcriber/utils/__init__.py +0 -0
  287. lyrics_transcriber/utils/word_utils.py +27 -0
@@ -0,0 +1,1614 @@
1
+ """
2
+ Audio Fetcher module - abstraction layer for fetching audio files.
3
+
4
+ This module provides a clean interface for searching and downloading audio files
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.
11
+ """
12
+
13
+ import logging
14
+ import os
15
+ import signal
16
+ import sys
17
+ import tempfile
18
+ import threading
19
+ import time
20
+ from abc import ABC, abstractmethod
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
34
+
35
+
36
+ @dataclass
37
+ class AudioSearchResult:
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
+ """
46
+
47
+ title: str
48
+ artist: str
49
+ url: str
50
+ provider: str
51
+ duration: Optional[int] = None # Duration in seconds
52
+ quality: Optional[str] = None # e.g., "FLAC", "320kbps", etc.
53
+ source_id: Optional[str] = None # Unique ID from the source
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
+ )
127
+
128
+
129
+ @dataclass
130
+ class AudioFetchResult:
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
+ """
136
+
137
+ filepath: str
138
+ artist: str
139
+ title: str
140
+ provider: str
141
+ duration: Optional[int] = None
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
+ )
159
+
160
+
161
+ class AudioFetcherError(Exception):
162
+ """Base exception for audio fetcher errors."""
163
+
164
+ pass
165
+
166
+
167
+ class NoResultsError(AudioFetcherError):
168
+ """Raised when no search results are found."""
169
+
170
+ pass
171
+
172
+
173
+ class DownloadError(AudioFetcherError):
174
+ """Raised when download fails."""
175
+
176
+ pass
177
+
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
+
192
+ class AudioFetcher(ABC):
193
+ """Abstract base class for audio fetching implementations."""
194
+
195
+ @abstractmethod
196
+ def search(self, artist: str, title: str) -> List[AudioSearchResult]:
197
+ """
198
+ Search for audio matching the given artist and title.
199
+
200
+ Args:
201
+ artist: The artist name to search for
202
+ title: The track title to search for
203
+
204
+ Returns:
205
+ List of AudioSearchResult objects
206
+
207
+ Raises:
208
+ NoResultsError: If no results are found
209
+ AudioFetcherError: For other errors
210
+ """
211
+ pass
212
+
213
+ @abstractmethod
214
+ def download(
215
+ self,
216
+ result: AudioSearchResult,
217
+ output_dir: str,
218
+ output_filename: Optional[str] = None,
219
+ ) -> AudioFetchResult:
220
+ """
221
+ Download audio from a search result.
222
+
223
+ Args:
224
+ result: The search result to download
225
+ output_dir: Directory to save the downloaded file
226
+ output_filename: Optional filename (without extension)
227
+
228
+ Returns:
229
+ AudioFetchResult with the downloaded file path
230
+
231
+ Raises:
232
+ DownloadError: If download fails
233
+ """
234
+ pass
235
+
236
+ @abstractmethod
237
+ def search_and_download(
238
+ self,
239
+ artist: str,
240
+ title: str,
241
+ output_dir: str,
242
+ output_filename: Optional[str] = None,
243
+ auto_select: bool = False,
244
+ ) -> AudioFetchResult:
245
+ """
246
+ Search for audio and download it in one operation.
247
+
248
+ In interactive mode (auto_select=False), this will present options to the user.
249
+ In auto mode (auto_select=True), this will automatically select the best result.
250
+
251
+ Args:
252
+ artist: The artist name to search for
253
+ title: The track title to search for
254
+ output_dir: Directory to save the downloaded file
255
+ output_filename: Optional filename (without extension)
256
+ auto_select: If True, automatically select the best result
257
+
258
+ Returns:
259
+ AudioFetchResult with the downloaded file path
260
+
261
+ Raises:
262
+ NoResultsError: If no results are found
263
+ DownloadError: If download fails
264
+ """
265
+ pass
266
+
267
+
268
+ class FlacFetchAudioFetcher(AudioFetcher):
269
+ """
270
+ Audio fetcher implementation using flacfetch library.
271
+
272
+ This provides access to multiple audio sources including private music trackers
273
+ and YouTube, with intelligent prioritization of high-quality sources.
274
+
275
+ Also exported as FlacFetcher for shorter name.
276
+ """
277
+
278
+ def __init__(
279
+ self,
280
+ logger: Optional[logging.Logger] = None,
281
+ red_api_key: Optional[str] = None,
282
+ red_api_url: Optional[str] = None,
283
+ ops_api_key: Optional[str] = None,
284
+ ops_api_url: Optional[str] = None,
285
+ provider_priority: Optional[List[str]] = None,
286
+ ):
287
+ """
288
+ Initialize the FlacFetch audio fetcher.
289
+
290
+ Args:
291
+ logger: Logger instance for output
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)
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)
296
+ provider_priority: Custom provider priority order (optional)
297
+ """
298
+ self.logger = logger or logging.getLogger(__name__)
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")
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")
303
+ self._provider_priority = provider_priority
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
346
+
347
+ def _get_manager(self):
348
+ """Lazily initialize and return the FetchManager."""
349
+ if self._manager is None:
350
+ # Import flacfetch here to avoid import errors if not installed
351
+ from flacfetch.core.manager import FetchManager
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)")
361
+
362
+ self._manager = FetchManager()
363
+
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
385
+
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")
391
+
392
+ if self._ops_api_key and self._ops_api_url and can_use_trackers:
393
+ from flacfetch.providers.ops import OPSProvider
394
+
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")
400
+
401
+ # Always add YouTube as a fallback provider with its downloader
402
+ self._manager.add_provider(YoutubeProvider())
403
+ self._manager.register_downloader("YouTube", YoutubeDownloader())
404
+ self.logger.debug("Added YouTube provider")
405
+
406
+ return self._manager
407
+
408
+ def search(self, artist: str, title: str) -> List[AudioSearchResult]:
409
+ """
410
+ Search for audio matching the given artist and title.
411
+
412
+ Args:
413
+ artist: The artist name to search for
414
+ title: The track title to search for
415
+
416
+ Returns:
417
+ List of AudioSearchResult objects
418
+
419
+ Raises:
420
+ NoResultsError: If no results are found
421
+ """
422
+ from flacfetch.core.models import TrackQuery
423
+
424
+ manager = self._get_manager()
425
+ query = TrackQuery(artist=artist, title=title)
426
+
427
+ self.logger.info(f"Searching for: {artist} - {title}")
428
+ results = manager.search(query)
429
+
430
+ if not results:
431
+ raise NoResultsError(f"No results found for: {artist} - {title}")
432
+
433
+ # Convert to our AudioSearchResult format
434
+ search_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
+
440
+ search_results.append(
441
+ AudioSearchResult(
442
+ title=getattr(result, "title", title),
443
+ artist=getattr(result, "artist", artist),
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),
452
+ raw_result=result,
453
+ )
454
+ )
455
+
456
+ self.logger.info(f"Found {len(search_results)} results")
457
+ return search_results
458
+
459
+ def download(
460
+ self,
461
+ result: AudioSearchResult,
462
+ output_dir: str,
463
+ output_filename: Optional[str] = None,
464
+ ) -> AudioFetchResult:
465
+ """
466
+ Download audio from a search result.
467
+
468
+ Args:
469
+ result: The search result to download
470
+ output_dir: Directory to save the downloaded file
471
+ output_filename: Optional filename (without extension)
472
+
473
+ Returns:
474
+ AudioFetchResult with the downloaded file path
475
+
476
+ Raises:
477
+ DownloadError: If download fails
478
+ """
479
+ manager = self._get_manager()
480
+
481
+ # Ensure output directory exists
482
+ os.makedirs(output_dir, exist_ok=True)
483
+
484
+ # Generate filename if not provided
485
+ if output_filename is None:
486
+ output_filename = f"{result.artist} - {result.title}"
487
+
488
+ self.logger.info(f"Downloading: {result.artist} - {result.title} from {result.provider or 'Unknown'}")
489
+
490
+ try:
491
+ # Use flacfetch to download
492
+ filepath = manager.download(
493
+ result.raw_result,
494
+ output_path=output_dir,
495
+ output_filename=output_filename,
496
+ )
497
+
498
+ if filepath is None:
499
+ raise DownloadError(f"Download returned no file path for: {result.artist} - {result.title}")
500
+
501
+ self.logger.info(f"Downloaded to: {filepath}")
502
+
503
+ return AudioFetchResult(
504
+ filepath=filepath,
505
+ artist=result.artist,
506
+ title=result.title,
507
+ provider=result.provider,
508
+ duration=result.duration,
509
+ quality=result.quality,
510
+ )
511
+
512
+ except Exception as e:
513
+ raise DownloadError(f"Failed to download {result.artist} - {result.title}: {e}") from e
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
+
549
+ def search_and_download(
550
+ self,
551
+ artist: str,
552
+ title: str,
553
+ output_dir: str,
554
+ output_filename: Optional[str] = None,
555
+ auto_select: bool = False,
556
+ ) -> AudioFetchResult:
557
+ """
558
+ Search for audio and download it in one operation.
559
+
560
+ In interactive mode (auto_select=False), this will present options to the user.
561
+ In auto mode (auto_select=True), this will automatically select the best result.
562
+
563
+ Args:
564
+ artist: The artist name to search for
565
+ title: The track title to search for
566
+ output_dir: Directory to save the downloaded file
567
+ output_filename: Optional filename (without extension)
568
+ auto_select: If True, automatically select the best result
569
+
570
+ Returns:
571
+ AudioFetchResult with the downloaded file path
572
+
573
+ Raises:
574
+ NoResultsError: If no results are found
575
+ DownloadError: If download fails
576
+ UserCancelledError: If user cancels (Ctrl+C or enters 0)
577
+ """
578
+ from flacfetch.core.models import TrackQuery
579
+
580
+ manager = self._get_manager()
581
+ query = TrackQuery(artist=artist, title=title)
582
+
583
+ self.logger.info(f"Searching for: {artist} - {title}")
584
+
585
+ # Run search in a thread so we can handle Ctrl+C
586
+ results = self._interruptible_search(manager, query)
587
+
588
+ if not results:
589
+ raise NoResultsError(f"No results found for: {artist} - {title}")
590
+
591
+ self.logger.info(f"Found {len(results)} results")
592
+
593
+ if auto_select:
594
+ # Auto mode: select best result based on flacfetch's ranking
595
+ selected = manager.select_best(results)
596
+ self.logger.info(f"Auto-selected: {getattr(selected, 'title', title)} from {getattr(selected, 'source_name', 'Unknown')}")
597
+ else:
598
+ # Interactive mode: present options to user
599
+ selected = self._interactive_select(results, artist, title)
600
+
601
+ # Note: _interactive_select now raises UserCancelledError instead of returning None
602
+ # This check is kept as a safety net
603
+ if selected is None:
604
+ raise NoResultsError(f"No result selected for: {artist} - {title}")
605
+
606
+ # Ensure output directory exists
607
+ os.makedirs(output_dir, exist_ok=True)
608
+
609
+ # Generate filename if not provided
610
+ if output_filename is None:
611
+ output_filename = f"{artist} - {title}"
612
+
613
+ self.logger.info(f"Downloading from {getattr(selected, 'source_name', 'Unknown')}...")
614
+
615
+ try:
616
+ # Use interruptible download so Ctrl+C works during torrent downloads
617
+ filepath = self._interruptible_download(
618
+ manager,
619
+ selected,
620
+ output_path=output_dir,
621
+ output_filename=output_filename,
622
+ )
623
+
624
+ if not filepath:
625
+ raise DownloadError(f"Download returned no file path for: {artist} - {title}")
626
+
627
+ self.logger.info(f"Downloaded to: {filepath}")
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
+
633
+ return AudioFetchResult(
634
+ filepath=filepath,
635
+ artist=artist,
636
+ title=title,
637
+ provider=getattr(selected, "source_name", "Unknown"),
638
+ duration=getattr(selected, "duration_seconds", None),
639
+ quality=quality_str,
640
+ )
641
+
642
+ except (UserCancelledError, KeyboardInterrupt):
643
+ # Let cancellation exceptions propagate without wrapping
644
+ raise
645
+ except Exception as e:
646
+ raise DownloadError(f"Failed to download {artist} - {title}: {e}") from e
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
+
815
+ def _interactive_select(self, results: list, artist: str, title: str) -> object:
816
+ """
817
+ Present search results to the user for interactive selection.
818
+
819
+ Uses flacfetch's built-in CLIHandler for rich, colorized output.
820
+
821
+ Args:
822
+ results: List of Release objects from flacfetch
823
+ artist: The artist name being searched
824
+ title: The track title being searched
825
+
826
+ Returns:
827
+ The selected Release object
828
+
829
+ Raises:
830
+ UserCancelledError: If user cancels selection
831
+ """
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.
854
+
855
+ Args:
856
+ results: List of Release objects from flacfetch
857
+ artist: The artist name being searched
858
+ title: The track title being searched
859
+
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)
869
+
870
+ while True:
871
+ try:
872
+ choice = input("Enter your choice (1-{}, or 0 to cancel): ".format(len(results))).strip()
873
+
874
+ if choice == "0":
875
+ self.logger.info("User cancelled selection")
876
+ raise UserCancelledError("Selection cancelled by user")
877
+
878
+ choice_num = int(choice)
879
+ if 1 <= choice_num <= len(results):
880
+ selected = results[choice_num - 1]
881
+ self.logger.info(f"User selected option {choice_num}")
882
+ return selected
883
+ else:
884
+ print(f"Please enter a number between 0 and {len(results)}")
885
+
886
+ except ValueError:
887
+ print("Please enter a valid number")
888
+ except KeyboardInterrupt:
889
+ print("\nCancelled")
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
1554
+
1555
+
1556
+ def create_audio_fetcher(
1557
+ logger: Optional[logging.Logger] = None,
1558
+ red_api_key: Optional[str] = None,
1559
+ red_api_url: Optional[str] = None,
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,
1564
+ ) -> AudioFetcher:
1565
+ """
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.
1574
+
1575
+ Args:
1576
+ logger: Logger instance for output
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)
1583
+
1584
+ Returns:
1585
+ An AudioFetcher instance (remote or local depending on configuration)
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
1608
+ return FlacFetchAudioFetcher(
1609
+ logger=logger,
1610
+ red_api_key=red_api_key,
1611
+ red_api_url=red_api_url,
1612
+ ops_api_key=ops_api_key,
1613
+ ops_api_url=ops_api_url,
1614
+ )