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,3268 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ Remote CLI for karaoke-gen - Submit jobs to a cloud-hosted backend.
4
+
5
+ This CLI provides the same interface as karaoke-gen but processes jobs on a cloud backend.
6
+ Set KARAOKE_GEN_URL environment variable to your cloud backend URL.
7
+
8
+ Usage:
9
+ karaoke-gen-remote <filepath> <artist> <title>
10
+ karaoke-gen-remote --resume <job_id>
11
+ karaoke-gen-remote --retry <job_id>
12
+ karaoke-gen-remote --list
13
+ karaoke-gen-remote --cancel <job_id>
14
+ karaoke-gen-remote --delete <job_id>
15
+ """
16
+ # Suppress SyntaxWarnings from third-party dependencies (pydub, syrics)
17
+ # that have invalid escape sequences in regex patterns (not yet fixed for Python 3.12+)
18
+ import warnings
19
+ warnings.filterwarnings("ignore", category=SyntaxWarning, module="pydub")
20
+ warnings.filterwarnings("ignore", category=SyntaxWarning, module="syrics")
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import platform
26
+ import subprocess
27
+ import sys
28
+ import time
29
+ import urllib.parse
30
+ import webbrowser
31
+ from dataclasses import dataclass
32
+ from enum import Enum
33
+ from pathlib import Path
34
+ from typing import Any, Dict, List, Optional
35
+
36
+ import requests
37
+
38
+ from .cli_args import create_parser, process_style_overrides, is_url, is_file
39
+ # Use flacfetch's shared display functions for consistent formatting
40
+ from flacfetch import print_releases, Release
41
+ from flacfetch.core.categorize import categorize_releases
42
+ from flacfetch.core.models import TrackQuery
43
+ from flacfetch.interface.cli import print_categorized_releases
44
+
45
+
46
+ class JobStatus(str, Enum):
47
+ """Job status values (matching backend)."""
48
+ PENDING = "pending"
49
+ # Audio search states (Batch 5)
50
+ SEARCHING_AUDIO = "searching_audio"
51
+ AWAITING_AUDIO_SELECTION = "awaiting_audio_selection"
52
+ DOWNLOADING_AUDIO = "downloading_audio"
53
+ # Main workflow
54
+ DOWNLOADING = "downloading"
55
+ SEPARATING_STAGE1 = "separating_stage1"
56
+ SEPARATING_STAGE2 = "separating_stage2"
57
+ AUDIO_COMPLETE = "audio_complete"
58
+ TRANSCRIBING = "transcribing"
59
+ CORRECTING = "correcting"
60
+ LYRICS_COMPLETE = "lyrics_complete"
61
+ GENERATING_SCREENS = "generating_screens"
62
+ APPLYING_PADDING = "applying_padding"
63
+ AWAITING_REVIEW = "awaiting_review"
64
+ IN_REVIEW = "in_review"
65
+ REVIEW_COMPLETE = "review_complete"
66
+ RENDERING_VIDEO = "rendering_video"
67
+ AWAITING_INSTRUMENTAL_SELECTION = "awaiting_instrumental_selection"
68
+ INSTRUMENTAL_SELECTED = "instrumental_selected"
69
+ GENERATING_VIDEO = "generating_video"
70
+ ENCODING = "encoding"
71
+ PACKAGING = "packaging"
72
+ UPLOADING = "uploading"
73
+ NOTIFYING = "notifying"
74
+ COMPLETE = "complete"
75
+ PREP_COMPLETE = "prep_complete" # Batch 6: Prep-only jobs stop here
76
+ FAILED = "failed"
77
+ CANCELLED = "cancelled"
78
+ ERROR = "error"
79
+
80
+
81
+ @dataclass
82
+ class Config:
83
+ """Configuration for the remote CLI."""
84
+ service_url: str
85
+ review_ui_url: str
86
+ poll_interval: int
87
+ output_dir: str
88
+ auth_token: Optional[str] = None
89
+ non_interactive: bool = False # Auto-accept defaults for testing
90
+ # Job tracking metadata (sent as headers for filtering/tracking)
91
+ environment: str = "" # test/production/development
92
+ client_id: str = "" # Customer/user identifier
93
+
94
+
95
+ class RemoteKaraokeClient:
96
+ """Client for interacting with the karaoke-gen cloud backend."""
97
+
98
+ ALLOWED_AUDIO_EXTENSIONS = {'.mp3', '.wav', '.flac', '.m4a', '.ogg', '.aac'}
99
+ ALLOWED_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
100
+ ALLOWED_FONT_EXTENSIONS = {'.ttf', '.otf', '.woff', '.woff2'}
101
+
102
+ def __init__(self, config: Config, logger: logging.Logger):
103
+ self.config = config
104
+ self.logger = logger
105
+ self.session = requests.Session()
106
+ self._setup_auth()
107
+
108
+ def _setup_auth(self) -> None:
109
+ """Set up authentication and tracking headers."""
110
+ if self.config.auth_token:
111
+ self.session.headers['Authorization'] = f'Bearer {self.config.auth_token}'
112
+
113
+ # Set up job tracking headers (used for filtering and operational management)
114
+ if self.config.environment:
115
+ self.session.headers['X-Environment'] = self.config.environment
116
+ if self.config.client_id:
117
+ self.session.headers['X-Client-ID'] = self.config.client_id
118
+
119
+ # Always include CLI version as user-agent
120
+ from importlib import metadata
121
+ try:
122
+ version = metadata.version("karaoke-gen")
123
+ except metadata.PackageNotFoundError:
124
+ version = "unknown"
125
+ self.session.headers['User-Agent'] = f'karaoke-gen-remote/{version}'
126
+
127
+ def _get_auth_token_from_gcloud(self) -> Optional[str]:
128
+ """Get auth token from gcloud CLI."""
129
+ try:
130
+ result = subprocess.run(
131
+ ['gcloud', 'auth', 'print-identity-token'],
132
+ capture_output=True,
133
+ text=True,
134
+ check=True
135
+ )
136
+ return result.stdout.strip()
137
+ except subprocess.CalledProcessError:
138
+ return None
139
+ except FileNotFoundError:
140
+ return None
141
+
142
+ def refresh_auth(self) -> bool:
143
+ """Refresh authentication token.
144
+
145
+ Only refreshes if we're using a gcloud-based token. If the user
146
+ provided a static token via KARAOKE_GEN_AUTH_TOKEN, we keep that
147
+ since it doesn't expire like gcloud identity tokens.
148
+ """
149
+ # Don't refresh if using a static admin token from env
150
+ if os.environ.get('KARAOKE_GEN_AUTH_TOKEN'):
151
+ # Already have a valid static token, no need to refresh
152
+ return True
153
+
154
+ # Try to refresh gcloud identity token
155
+ token = self._get_auth_token_from_gcloud()
156
+ if token:
157
+ self.config.auth_token = token
158
+ self.session.headers['Authorization'] = f'Bearer {token}'
159
+ return True
160
+ return False
161
+
162
+ def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
163
+ """Make an authenticated request."""
164
+ url = f"{self.config.service_url}{endpoint}"
165
+ response = self.session.request(method, url, **kwargs)
166
+ return response
167
+
168
+ def _upload_file_to_signed_url(self, signed_url: str, file_path: str, content_type: str) -> bool:
169
+ """
170
+ Upload a file directly to GCS using a signed URL.
171
+
172
+ Args:
173
+ signed_url: The signed URL from the backend
174
+ file_path: Local path to the file to upload
175
+ content_type: MIME type for the Content-Type header
176
+
177
+ Returns:
178
+ True if upload succeeded, False otherwise
179
+ """
180
+ try:
181
+ with open(file_path, 'rb') as f:
182
+ # Use a fresh requests session (not self.session) because
183
+ # signed URLs should not have our auth headers
184
+ response = requests.put(
185
+ signed_url,
186
+ data=f,
187
+ headers={'Content-Type': content_type},
188
+ timeout=600 # 10 minutes for large files
189
+ )
190
+
191
+ if response.status_code in (200, 201):
192
+ return True
193
+ else:
194
+ self.logger.error(f"Failed to upload to signed URL: HTTP {response.status_code} - {response.text}")
195
+ return False
196
+ except Exception as e:
197
+ self.logger.error(f"Error uploading to signed URL: {e}")
198
+ return False
199
+
200
+ def _get_content_type(self, file_path: str) -> str:
201
+ """Get the MIME content type for a file based on its extension."""
202
+ ext = Path(file_path).suffix.lower()
203
+
204
+ content_types = {
205
+ # Audio
206
+ '.mp3': 'audio/mpeg',
207
+ '.wav': 'audio/wav',
208
+ '.flac': 'audio/flac',
209
+ '.m4a': 'audio/mp4',
210
+ '.ogg': 'audio/ogg',
211
+ '.aac': 'audio/aac',
212
+ # Images
213
+ '.png': 'image/png',
214
+ '.jpg': 'image/jpeg',
215
+ '.jpeg': 'image/jpeg',
216
+ '.gif': 'image/gif',
217
+ '.webp': 'image/webp',
218
+ # Fonts
219
+ '.ttf': 'font/ttf',
220
+ '.otf': 'font/otf',
221
+ '.woff': 'font/woff',
222
+ '.woff2': 'font/woff2',
223
+ # Other
224
+ '.json': 'application/json',
225
+ '.txt': 'text/plain',
226
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
227
+ '.rtf': 'application/rtf',
228
+ }
229
+
230
+ return content_types.get(ext, 'application/octet-stream')
231
+
232
+ def _parse_style_params(self, style_params_path: str) -> Dict[str, str]:
233
+ """
234
+ Parse style_params.json and extract file paths that need to be uploaded.
235
+
236
+ Returns a dict mapping asset_key -> local_file_path for files that exist.
237
+ """
238
+ asset_files = {}
239
+
240
+ try:
241
+ with open(style_params_path, 'r') as f:
242
+ style_params = json.load(f)
243
+ except Exception as e:
244
+ self.logger.warning(f"Failed to parse style_params.json: {e}")
245
+ return asset_files
246
+
247
+ # Map of style param paths to asset keys
248
+ file_mappings = [
249
+ ('intro', 'background_image', 'style_intro_background'),
250
+ ('intro', 'font', 'style_font'),
251
+ ('karaoke', 'background_image', 'style_karaoke_background'),
252
+ ('karaoke', 'font_path', 'style_font'),
253
+ ('end', 'background_image', 'style_end_background'),
254
+ ('end', 'font', 'style_font'),
255
+ ('cdg', 'font_path', 'style_font'),
256
+ ('cdg', 'instrumental_background', 'style_cdg_instrumental_background'),
257
+ ('cdg', 'title_screen_background', 'style_cdg_title_background'),
258
+ ('cdg', 'outro_background', 'style_cdg_outro_background'),
259
+ ]
260
+
261
+ for section, key, asset_key in file_mappings:
262
+ if section in style_params and key in style_params[section]:
263
+ file_path = style_params[section][key]
264
+ if file_path and os.path.isfile(file_path):
265
+ # Don't duplicate font uploads
266
+ if asset_key not in asset_files:
267
+ asset_files[asset_key] = file_path
268
+ self.logger.info(f" Found style asset: {asset_key} -> {file_path}")
269
+
270
+ return asset_files
271
+
272
+ def submit_job_from_url(
273
+ self,
274
+ url: str,
275
+ artist: Optional[str] = None,
276
+ title: Optional[str] = None,
277
+ enable_cdg: bool = True,
278
+ enable_txt: bool = True,
279
+ brand_prefix: Optional[str] = None,
280
+ discord_webhook_url: Optional[str] = None,
281
+ youtube_description: Optional[str] = None,
282
+ organised_dir_rclone_root: Optional[str] = None,
283
+ enable_youtube_upload: bool = False,
284
+ dropbox_path: Optional[str] = None,
285
+ gdrive_folder_id: Optional[str] = None,
286
+ lyrics_artist: Optional[str] = None,
287
+ lyrics_title: Optional[str] = None,
288
+ subtitle_offset_ms: int = 0,
289
+ clean_instrumental_model: Optional[str] = None,
290
+ backing_vocals_models: Optional[list] = None,
291
+ other_stems_models: Optional[list] = None,
292
+ # Two-phase workflow (Batch 6)
293
+ prep_only: bool = False,
294
+ keep_brand_code: Optional[str] = None,
295
+ ) -> Dict[str, Any]:
296
+ """
297
+ Submit a new karaoke generation job from a YouTube/online URL.
298
+
299
+ The backend will download the audio from the URL and process it.
300
+ Artist and title will be auto-detected from the URL if not provided.
301
+
302
+ Note: Custom style configuration is not supported for URL-based jobs.
303
+ If you need custom styles, download the audio locally first and use
304
+ the regular file upload flow with submit_job().
305
+
306
+ Args:
307
+ url: YouTube or other video URL to download audio from
308
+ artist: Artist name (optional - auto-detected if not provided)
309
+ title: Song title (optional - auto-detected if not provided)
310
+ enable_cdg: Generate CDG+MP3 package
311
+ enable_txt: Generate TXT+MP3 package
312
+ brand_prefix: Brand code prefix (e.g., "NOMAD")
313
+ discord_webhook_url: Discord webhook for notifications
314
+ youtube_description: YouTube video description
315
+ organised_dir_rclone_root: Legacy rclone path (deprecated)
316
+ enable_youtube_upload: Enable YouTube upload
317
+ dropbox_path: Dropbox folder path for organized output (native API)
318
+ gdrive_folder_id: Google Drive folder ID for public share (native API)
319
+ lyrics_artist: Override artist name for lyrics search
320
+ lyrics_title: Override title for lyrics search
321
+ subtitle_offset_ms: Subtitle timing offset in milliseconds
322
+ clean_instrumental_model: Model for clean instrumental separation
323
+ backing_vocals_models: List of models for backing vocals separation
324
+ other_stems_models: List of models for other stems (bass, drums, etc.)
325
+ """
326
+ self.logger.info(f"Submitting URL-based job: {url}")
327
+
328
+ # Build request payload
329
+ create_request = {
330
+ 'url': url,
331
+ 'enable_cdg': enable_cdg,
332
+ 'enable_txt': enable_txt,
333
+ }
334
+
335
+ if artist:
336
+ create_request['artist'] = artist
337
+ if title:
338
+ create_request['title'] = title
339
+ if brand_prefix:
340
+ create_request['brand_prefix'] = brand_prefix
341
+ if discord_webhook_url:
342
+ create_request['discord_webhook_url'] = discord_webhook_url
343
+ if youtube_description:
344
+ create_request['youtube_description'] = youtube_description
345
+ if enable_youtube_upload:
346
+ create_request['enable_youtube_upload'] = enable_youtube_upload
347
+ if dropbox_path:
348
+ create_request['dropbox_path'] = dropbox_path
349
+ if gdrive_folder_id:
350
+ create_request['gdrive_folder_id'] = gdrive_folder_id
351
+ if organised_dir_rclone_root:
352
+ create_request['organised_dir_rclone_root'] = organised_dir_rclone_root
353
+ if lyrics_artist:
354
+ create_request['lyrics_artist'] = lyrics_artist
355
+ if lyrics_title:
356
+ create_request['lyrics_title'] = lyrics_title
357
+ if subtitle_offset_ms != 0:
358
+ create_request['subtitle_offset_ms'] = subtitle_offset_ms
359
+ if clean_instrumental_model:
360
+ create_request['clean_instrumental_model'] = clean_instrumental_model
361
+ if backing_vocals_models:
362
+ create_request['backing_vocals_models'] = backing_vocals_models
363
+ if other_stems_models:
364
+ create_request['other_stems_models'] = other_stems_models
365
+ # Two-phase workflow (Batch 6)
366
+ if prep_only:
367
+ create_request['prep_only'] = prep_only
368
+ if keep_brand_code:
369
+ create_request['keep_brand_code'] = keep_brand_code
370
+
371
+ self.logger.info(f"Creating URL-based job at {self.config.service_url}/api/jobs/create-from-url")
372
+
373
+ response = self._request('POST', '/api/jobs/create-from-url', json=create_request)
374
+
375
+ if response.status_code != 200:
376
+ try:
377
+ error_detail = response.json()
378
+ except Exception:
379
+ error_detail = response.text
380
+ raise RuntimeError(f"Error creating job from URL: {error_detail}")
381
+
382
+ result = response.json()
383
+ if result.get('status') != 'success':
384
+ raise RuntimeError(f"Error creating job from URL: {result}")
385
+
386
+ job_id = result['job_id']
387
+ detected_artist = result.get('detected_artist')
388
+ detected_title = result.get('detected_title')
389
+
390
+ self.logger.info(f"Job {job_id} created from URL")
391
+ if detected_artist:
392
+ self.logger.info(f" Artist: {detected_artist}")
393
+ if detected_title:
394
+ self.logger.info(f" Title: {detected_title}")
395
+
396
+ return result
397
+
398
+ def submit_job(
399
+ self,
400
+ filepath: str,
401
+ artist: str,
402
+ title: str,
403
+ style_params_path: Optional[str] = None,
404
+ enable_cdg: bool = True,
405
+ enable_txt: bool = True,
406
+ brand_prefix: Optional[str] = None,
407
+ discord_webhook_url: Optional[str] = None,
408
+ youtube_description: Optional[str] = None,
409
+ organised_dir_rclone_root: Optional[str] = None,
410
+ enable_youtube_upload: bool = False,
411
+ # Native API distribution (uses server-side credentials)
412
+ dropbox_path: Optional[str] = None,
413
+ gdrive_folder_id: Optional[str] = None,
414
+ # Lyrics configuration
415
+ lyrics_artist: Optional[str] = None,
416
+ lyrics_title: Optional[str] = None,
417
+ lyrics_file: Optional[str] = None,
418
+ subtitle_offset_ms: int = 0,
419
+ # Audio separation model configuration
420
+ clean_instrumental_model: Optional[str] = None,
421
+ backing_vocals_models: Optional[list] = None,
422
+ other_stems_models: Optional[list] = None,
423
+ # Existing instrumental (Batch 3)
424
+ existing_instrumental: Optional[str] = None,
425
+ # Two-phase workflow (Batch 6)
426
+ prep_only: bool = False,
427
+ keep_brand_code: Optional[str] = None,
428
+ ) -> Dict[str, Any]:
429
+ """
430
+ Submit a new karaoke generation job with optional style configuration.
431
+
432
+ Uses signed URL upload flow to bypass Cloud Run's 32MB request body limit:
433
+ 1. Create job and get signed upload URLs from backend
434
+ 2. Upload files directly to GCS using signed URLs
435
+ 3. Notify backend that uploads are complete to start processing
436
+
437
+ Args:
438
+ filepath: Path to audio file
439
+ artist: Artist name
440
+ title: Song title
441
+ style_params_path: Path to style_params.json (optional)
442
+ enable_cdg: Generate CDG+MP3 package
443
+ enable_txt: Generate TXT+MP3 package
444
+ brand_prefix: Brand code prefix (e.g., "NOMAD")
445
+ discord_webhook_url: Discord webhook for notifications
446
+ youtube_description: YouTube video description
447
+ organised_dir_rclone_root: Legacy rclone path (deprecated)
448
+ enable_youtube_upload: Enable YouTube upload
449
+ dropbox_path: Dropbox folder path for organized output (native API)
450
+ gdrive_folder_id: Google Drive folder ID for public share (native API)
451
+ lyrics_artist: Override artist name for lyrics search
452
+ lyrics_title: Override title for lyrics search
453
+ lyrics_file: Path to user-provided lyrics file
454
+ subtitle_offset_ms: Subtitle timing offset in milliseconds
455
+ clean_instrumental_model: Model for clean instrumental separation
456
+ backing_vocals_models: List of models for backing vocals separation
457
+ other_stems_models: List of models for other stems (bass, drums, etc.)
458
+ existing_instrumental: Path to existing instrumental file to use instead of AI separation
459
+ """
460
+ file_path = Path(filepath)
461
+
462
+ if not file_path.exists():
463
+ raise FileNotFoundError(f"File not found: {filepath}")
464
+
465
+ ext = file_path.suffix.lower()
466
+ if ext not in self.ALLOWED_AUDIO_EXTENSIONS:
467
+ raise ValueError(
468
+ f"Unsupported file type: {ext}. "
469
+ f"Allowed: {', '.join(self.ALLOWED_AUDIO_EXTENSIONS)}"
470
+ )
471
+
472
+ # Step 1: Build list of files to upload
473
+ files_info = []
474
+ local_files = {} # file_type -> local_path
475
+
476
+ # Main audio file
477
+ audio_content_type = self._get_content_type(filepath)
478
+ files_info.append({
479
+ 'filename': file_path.name,
480
+ 'content_type': audio_content_type,
481
+ 'file_type': 'audio'
482
+ })
483
+ local_files['audio'] = filepath
484
+ self.logger.info(f"Will upload audio: {filepath}")
485
+
486
+ # Parse style params and find referenced files
487
+ style_assets = {}
488
+ if style_params_path and os.path.isfile(style_params_path):
489
+ self.logger.info(f"Parsing style configuration: {style_params_path}")
490
+ style_assets = self._parse_style_params(style_params_path)
491
+
492
+ # Add style_params.json
493
+ files_info.append({
494
+ 'filename': Path(style_params_path).name,
495
+ 'content_type': 'application/json',
496
+ 'file_type': 'style_params'
497
+ })
498
+ local_files['style_params'] = style_params_path
499
+ self.logger.info(f" Will upload style_params.json")
500
+
501
+ # Add each style asset file
502
+ for asset_key, asset_path in style_assets.items():
503
+ if os.path.isfile(asset_path):
504
+ content_type = self._get_content_type(asset_path)
505
+ files_info.append({
506
+ 'filename': Path(asset_path).name,
507
+ 'content_type': content_type,
508
+ 'file_type': asset_key # e.g., 'style_intro_background'
509
+ })
510
+ local_files[asset_key] = asset_path
511
+ self.logger.info(f" Will upload {asset_key}: {asset_path}")
512
+
513
+ # Add lyrics file if provided
514
+ if lyrics_file and os.path.isfile(lyrics_file):
515
+ content_type = self._get_content_type(lyrics_file)
516
+ files_info.append({
517
+ 'filename': Path(lyrics_file).name,
518
+ 'content_type': content_type,
519
+ 'file_type': 'lyrics_file'
520
+ })
521
+ local_files['lyrics_file'] = lyrics_file
522
+ self.logger.info(f"Will upload lyrics file: {lyrics_file}")
523
+
524
+ # Add existing instrumental file if provided (Batch 3)
525
+ if existing_instrumental and os.path.isfile(existing_instrumental):
526
+ content_type = self._get_content_type(existing_instrumental)
527
+ files_info.append({
528
+ 'filename': Path(existing_instrumental).name,
529
+ 'content_type': content_type,
530
+ 'file_type': 'existing_instrumental'
531
+ })
532
+ local_files['existing_instrumental'] = existing_instrumental
533
+ self.logger.info(f"Will upload existing instrumental: {existing_instrumental}")
534
+
535
+ # Step 2: Create job and get signed upload URLs
536
+ self.logger.info(f"Creating job at {self.config.service_url}/api/jobs/create-with-upload-urls")
537
+
538
+ create_request = {
539
+ 'artist': artist,
540
+ 'title': title,
541
+ 'files': files_info,
542
+ 'enable_cdg': enable_cdg,
543
+ 'enable_txt': enable_txt,
544
+ }
545
+
546
+ if brand_prefix:
547
+ create_request['brand_prefix'] = brand_prefix
548
+ if discord_webhook_url:
549
+ create_request['discord_webhook_url'] = discord_webhook_url
550
+ if youtube_description:
551
+ create_request['youtube_description'] = youtube_description
552
+ if enable_youtube_upload:
553
+ create_request['enable_youtube_upload'] = enable_youtube_upload
554
+ if dropbox_path:
555
+ create_request['dropbox_path'] = dropbox_path
556
+ if gdrive_folder_id:
557
+ create_request['gdrive_folder_id'] = gdrive_folder_id
558
+ if organised_dir_rclone_root:
559
+ create_request['organised_dir_rclone_root'] = organised_dir_rclone_root
560
+ if lyrics_artist:
561
+ create_request['lyrics_artist'] = lyrics_artist
562
+ if lyrics_title:
563
+ create_request['lyrics_title'] = lyrics_title
564
+ if subtitle_offset_ms != 0:
565
+ create_request['subtitle_offset_ms'] = subtitle_offset_ms
566
+ if clean_instrumental_model:
567
+ create_request['clean_instrumental_model'] = clean_instrumental_model
568
+ if backing_vocals_models:
569
+ create_request['backing_vocals_models'] = backing_vocals_models
570
+ if other_stems_models:
571
+ create_request['other_stems_models'] = other_stems_models
572
+ # Two-phase workflow (Batch 6)
573
+ if prep_only:
574
+ create_request['prep_only'] = prep_only
575
+ if keep_brand_code:
576
+ create_request['keep_brand_code'] = keep_brand_code
577
+
578
+ response = self._request('POST', '/api/jobs/create-with-upload-urls', json=create_request)
579
+
580
+ if response.status_code != 200:
581
+ try:
582
+ error_detail = response.json()
583
+ except Exception:
584
+ error_detail = response.text
585
+ raise RuntimeError(f"Error creating job: {error_detail}")
586
+
587
+ create_result = response.json()
588
+ if create_result.get('status') != 'success':
589
+ raise RuntimeError(f"Error creating job: {create_result}")
590
+
591
+ job_id = create_result['job_id']
592
+ upload_urls = create_result['upload_urls']
593
+
594
+ self.logger.info(f"Job {job_id} created. Uploading {len(upload_urls)} files directly to storage...")
595
+
596
+ # Step 3: Upload each file directly to GCS using signed URLs
597
+ uploaded_files = []
598
+ for url_info in upload_urls:
599
+ file_type = url_info['file_type']
600
+ signed_url = url_info['upload_url']
601
+ content_type = url_info['content_type']
602
+ local_path = local_files.get(file_type)
603
+
604
+ if not local_path:
605
+ self.logger.warning(f"No local file found for file_type: {file_type}")
606
+ continue
607
+
608
+ # Calculate file size for logging
609
+ file_size = os.path.getsize(local_path)
610
+ file_size_mb = file_size / (1024 * 1024)
611
+ self.logger.info(f" Uploading {file_type} ({file_size_mb:.1f} MB)...")
612
+
613
+ success = self._upload_file_to_signed_url(signed_url, local_path, content_type)
614
+ if not success:
615
+ raise RuntimeError(f"Failed to upload {file_type} to storage")
616
+
617
+ uploaded_files.append(file_type)
618
+ self.logger.info(f" ✓ Uploaded {file_type}")
619
+
620
+ # Step 4: Notify backend that uploads are complete
621
+ self.logger.info(f"Notifying backend that uploads are complete...")
622
+
623
+ complete_request = {
624
+ 'uploaded_files': uploaded_files
625
+ }
626
+
627
+ response = self._request('POST', f'/api/jobs/{job_id}/uploads-complete', json=complete_request)
628
+
629
+ if response.status_code != 200:
630
+ try:
631
+ error_detail = response.json()
632
+ except Exception:
633
+ error_detail = response.text
634
+ raise RuntimeError(f"Error completing uploads: {error_detail}")
635
+
636
+ result = response.json()
637
+ if result.get('status') != 'success':
638
+ raise RuntimeError(f"Error completing uploads: {result}")
639
+
640
+ # Log distribution services info if available
641
+ if 'distribution_services' in result:
642
+ dist_services = result['distribution_services']
643
+ self.logger.info("")
644
+ self.logger.info("Distribution Services:")
645
+
646
+ for service_name, service_info in dist_services.items():
647
+ if service_info.get('enabled'):
648
+ status = "✓" if service_info.get('credentials_valid', True) else "✗"
649
+ default_note = " (default)" if service_info.get('using_default') else ""
650
+
651
+ if service_name == 'dropbox':
652
+ path = service_info.get('path', '')
653
+ self.logger.info(f" {status} Dropbox: {path}{default_note}")
654
+ elif service_name == 'gdrive':
655
+ folder_id = service_info.get('folder_id', '')
656
+ self.logger.info(f" {status} Google Drive: folder {folder_id}{default_note}")
657
+ elif service_name == 'youtube':
658
+ self.logger.info(f" {status} YouTube: enabled")
659
+ elif service_name == 'discord':
660
+ self.logger.info(f" {status} Discord: notifications{default_note}")
661
+
662
+ return result
663
+
664
+ def submit_finalise_only_job(
665
+ self,
666
+ prep_folder: str,
667
+ artist: str,
668
+ title: str,
669
+ enable_cdg: bool = True,
670
+ enable_txt: bool = True,
671
+ brand_prefix: Optional[str] = None,
672
+ keep_brand_code: Optional[str] = None,
673
+ discord_webhook_url: Optional[str] = None,
674
+ youtube_description: Optional[str] = None,
675
+ enable_youtube_upload: bool = False,
676
+ dropbox_path: Optional[str] = None,
677
+ gdrive_folder_id: Optional[str] = None,
678
+ ) -> Dict[str, Any]:
679
+ """
680
+ Submit a finalise-only job with prep output files.
681
+
682
+ This is used when the user previously ran --prep-only and now wants
683
+ to continue with the finalisation phase using cloud resources.
684
+
685
+ Args:
686
+ prep_folder: Path to the prep output folder containing stems, screens, etc.
687
+ artist: Artist name
688
+ title: Song title
689
+ enable_cdg: Generate CDG+MP3 package
690
+ enable_txt: Generate TXT+MP3 package
691
+ brand_prefix: Brand code prefix (e.g., "NOMAD")
692
+ keep_brand_code: Preserve existing brand code from folder name
693
+ discord_webhook_url: Discord webhook for notifications
694
+ youtube_description: YouTube video description
695
+ enable_youtube_upload: Enable YouTube upload
696
+ dropbox_path: Dropbox folder path for organized output
697
+ gdrive_folder_id: Google Drive folder ID for public share
698
+ """
699
+ prep_path = Path(prep_folder)
700
+
701
+ if not prep_path.exists() or not prep_path.is_dir():
702
+ raise FileNotFoundError(f"Prep folder not found: {prep_folder}")
703
+
704
+ # Detect files in prep folder
705
+ files_info = []
706
+ local_files = {} # file_type -> local_path
707
+
708
+ base_name = f"{artist} - {title}"
709
+
710
+ # Required files - with_vocals video
711
+ for ext in ['.mkv', '.mov', '.mp4']:
712
+ with_vocals_path = prep_path / f"{base_name} (With Vocals){ext}"
713
+ if with_vocals_path.exists():
714
+ files_info.append({
715
+ 'filename': with_vocals_path.name,
716
+ 'content_type': f'video/{ext[1:]}',
717
+ 'file_type': 'with_vocals'
718
+ })
719
+ local_files['with_vocals'] = str(with_vocals_path)
720
+ break
721
+
722
+ if 'with_vocals' not in local_files:
723
+ raise FileNotFoundError(f"with_vocals video not found in {prep_folder}")
724
+
725
+ # Title screen
726
+ for ext in ['.mov', '.mkv', '.mp4']:
727
+ title_path = prep_path / f"{base_name} (Title){ext}"
728
+ if title_path.exists():
729
+ files_info.append({
730
+ 'filename': title_path.name,
731
+ 'content_type': f'video/{ext[1:]}',
732
+ 'file_type': 'title_screen'
733
+ })
734
+ local_files['title_screen'] = str(title_path)
735
+ break
736
+
737
+ if 'title_screen' not in local_files:
738
+ raise FileNotFoundError(f"title_screen video not found in {prep_folder}")
739
+
740
+ # End screen
741
+ for ext in ['.mov', '.mkv', '.mp4']:
742
+ end_path = prep_path / f"{base_name} (End){ext}"
743
+ if end_path.exists():
744
+ files_info.append({
745
+ 'filename': end_path.name,
746
+ 'content_type': f'video/{ext[1:]}',
747
+ 'file_type': 'end_screen'
748
+ })
749
+ local_files['end_screen'] = str(end_path)
750
+ break
751
+
752
+ if 'end_screen' not in local_files:
753
+ raise FileNotFoundError(f"end_screen video not found in {prep_folder}")
754
+
755
+ # Instrumentals (at least one required)
756
+ stems_dir = prep_path / 'stems'
757
+ if stems_dir.exists():
758
+ for stem_file in stems_dir.iterdir():
759
+ if 'Instrumental' in stem_file.name and stem_file.suffix.lower() == '.flac':
760
+ if '+BV' not in stem_file.name:
761
+ if 'instrumental_clean' not in local_files:
762
+ files_info.append({
763
+ 'filename': stem_file.name,
764
+ 'content_type': 'audio/flac',
765
+ 'file_type': 'instrumental_clean'
766
+ })
767
+ local_files['instrumental_clean'] = str(stem_file)
768
+ elif '+BV' in stem_file.name:
769
+ if 'instrumental_backing' not in local_files:
770
+ files_info.append({
771
+ 'filename': stem_file.name,
772
+ 'content_type': 'audio/flac',
773
+ 'file_type': 'instrumental_backing'
774
+ })
775
+ local_files['instrumental_backing'] = str(stem_file)
776
+
777
+ # Also check root for instrumental files
778
+ for stem_file in prep_path.iterdir():
779
+ if 'Instrumental' in stem_file.name and stem_file.suffix.lower() == '.flac':
780
+ if '+BV' not in stem_file.name and 'instrumental_clean' not in local_files:
781
+ files_info.append({
782
+ 'filename': stem_file.name,
783
+ 'content_type': 'audio/flac',
784
+ 'file_type': 'instrumental_clean'
785
+ })
786
+ local_files['instrumental_clean'] = str(stem_file)
787
+ elif '+BV' in stem_file.name and 'instrumental_backing' not in local_files:
788
+ files_info.append({
789
+ 'filename': stem_file.name,
790
+ 'content_type': 'audio/flac',
791
+ 'file_type': 'instrumental_backing'
792
+ })
793
+ local_files['instrumental_backing'] = str(stem_file)
794
+
795
+ if 'instrumental_clean' not in local_files and 'instrumental_backing' not in local_files:
796
+ raise FileNotFoundError(f"No instrumental file found in {prep_folder}")
797
+
798
+ # Optional files - LRC
799
+ lrc_path = prep_path / f"{base_name} (Karaoke).lrc"
800
+ if lrc_path.exists():
801
+ files_info.append({
802
+ 'filename': lrc_path.name,
803
+ 'content_type': 'text/plain',
804
+ 'file_type': 'lrc'
805
+ })
806
+ local_files['lrc'] = str(lrc_path)
807
+
808
+ # Optional - Title/End JPG/PNG
809
+ for img_type, file_type in [('Title', 'title'), ('End', 'end')]:
810
+ for ext in ['.jpg', '.png']:
811
+ img_path = prep_path / f"{base_name} ({img_type}){ext}"
812
+ if img_path.exists():
813
+ files_info.append({
814
+ 'filename': img_path.name,
815
+ 'content_type': f'image/{ext[1:]}',
816
+ 'file_type': f'{file_type}_{ext[1:]}'
817
+ })
818
+ local_files[f'{file_type}_{ext[1:]}'] = str(img_path)
819
+
820
+ self.logger.info(f"Found {len(files_info)} files in prep folder")
821
+ for file_type in local_files:
822
+ self.logger.info(f" {file_type}: {Path(local_files[file_type]).name}")
823
+
824
+ # Create finalise-only job
825
+ create_request = {
826
+ 'artist': artist,
827
+ 'title': title,
828
+ 'files': files_info,
829
+ 'enable_cdg': enable_cdg,
830
+ 'enable_txt': enable_txt,
831
+ }
832
+
833
+ if brand_prefix:
834
+ create_request['brand_prefix'] = brand_prefix
835
+ if keep_brand_code:
836
+ create_request['keep_brand_code'] = keep_brand_code
837
+ if discord_webhook_url:
838
+ create_request['discord_webhook_url'] = discord_webhook_url
839
+ if youtube_description:
840
+ create_request['youtube_description'] = youtube_description
841
+ if enable_youtube_upload:
842
+ create_request['enable_youtube_upload'] = enable_youtube_upload
843
+ if dropbox_path:
844
+ create_request['dropbox_path'] = dropbox_path
845
+ if gdrive_folder_id:
846
+ create_request['gdrive_folder_id'] = gdrive_folder_id
847
+
848
+ self.logger.info(f"Creating finalise-only job at {self.config.service_url}/api/jobs/create-finalise-only")
849
+
850
+ response = self._request('POST', '/api/jobs/create-finalise-only', json=create_request)
851
+
852
+ if response.status_code != 200:
853
+ try:
854
+ error_detail = response.json()
855
+ except Exception:
856
+ error_detail = response.text
857
+ raise RuntimeError(f"Error creating finalise-only job: {error_detail}")
858
+
859
+ create_result = response.json()
860
+ if create_result.get('status') != 'success':
861
+ raise RuntimeError(f"Error creating finalise-only job: {create_result}")
862
+
863
+ job_id = create_result['job_id']
864
+ upload_urls = create_result['upload_urls']
865
+
866
+ self.logger.info(f"Job {job_id} created. Uploading {len(upload_urls)} files directly to storage...")
867
+
868
+ # Upload each file
869
+ uploaded_files = []
870
+ for url_info in upload_urls:
871
+ file_type = url_info['file_type']
872
+ signed_url = url_info['upload_url']
873
+ content_type = url_info['content_type']
874
+ local_path = local_files.get(file_type)
875
+
876
+ if not local_path:
877
+ self.logger.warning(f"No local file found for file_type: {file_type}")
878
+ continue
879
+
880
+ file_size = os.path.getsize(local_path)
881
+ file_size_mb = file_size / (1024 * 1024)
882
+ self.logger.info(f" Uploading {file_type} ({file_size_mb:.1f} MB)...")
883
+
884
+ success = self._upload_file_to_signed_url(signed_url, local_path, content_type)
885
+ if not success:
886
+ raise RuntimeError(f"Failed to upload {file_type} to storage")
887
+
888
+ uploaded_files.append(file_type)
889
+ self.logger.info(f" ✓ Uploaded {file_type}")
890
+
891
+ # Mark uploads complete
892
+ self.logger.info(f"Notifying backend that uploads are complete...")
893
+
894
+ complete_request = {
895
+ 'uploaded_files': uploaded_files
896
+ }
897
+
898
+ response = self._request('POST', f'/api/jobs/{job_id}/finalise-uploads-complete', json=complete_request)
899
+
900
+ if response.status_code != 200:
901
+ try:
902
+ error_detail = response.json()
903
+ except Exception:
904
+ error_detail = response.text
905
+ raise RuntimeError(f"Error completing finalise-only uploads: {error_detail}")
906
+
907
+ result = response.json()
908
+ if result.get('status') != 'success':
909
+ raise RuntimeError(f"Error completing finalise-only uploads: {result}")
910
+
911
+ return result
912
+
913
+ def get_job(self, job_id: str) -> Dict[str, Any]:
914
+ """Get job status and details."""
915
+ response = self._request('GET', f'/api/jobs/{job_id}')
916
+ if response.status_code == 404:
917
+ raise ValueError(f"Job not found: {job_id}")
918
+ if response.status_code != 200:
919
+ raise RuntimeError(f"Error getting job: {response.text}")
920
+ return response.json()
921
+
922
+ def cancel_job(self, job_id: str, reason: str = "User requested") -> Dict[str, Any]:
923
+ """Cancel a running job. Stops processing but keeps the job record."""
924
+ response = self._request(
925
+ 'POST',
926
+ f'/api/jobs/{job_id}/cancel',
927
+ json={'reason': reason}
928
+ )
929
+ if response.status_code == 404:
930
+ raise ValueError(f"Job not found: {job_id}")
931
+ if response.status_code == 400:
932
+ try:
933
+ error_detail = response.json().get('detail', response.text)
934
+ except Exception:
935
+ error_detail = response.text
936
+ raise RuntimeError(f"Cannot cancel job: {error_detail}")
937
+ if response.status_code != 200:
938
+ raise RuntimeError(f"Error cancelling job: {response.text}")
939
+ return response.json()
940
+
941
+ def delete_job(self, job_id: str, delete_files: bool = True) -> Dict[str, Any]:
942
+ """Delete a job and optionally its files. Permanent removal."""
943
+ response = self._request(
944
+ 'DELETE',
945
+ f'/api/jobs/{job_id}',
946
+ params={'delete_files': str(delete_files).lower()}
947
+ )
948
+ if response.status_code == 404:
949
+ raise ValueError(f"Job not found: {job_id}")
950
+ if response.status_code != 200:
951
+ raise RuntimeError(f"Error deleting job: {response.text}")
952
+ return response.json()
953
+
954
+ def retry_job(self, job_id: str) -> Dict[str, Any]:
955
+ """Retry a failed job from the last successful checkpoint."""
956
+ response = self._request(
957
+ 'POST',
958
+ f'/api/jobs/{job_id}/retry'
959
+ )
960
+ if response.status_code == 404:
961
+ raise ValueError(f"Job not found: {job_id}")
962
+ if response.status_code == 400:
963
+ try:
964
+ error_detail = response.json().get('detail', response.text)
965
+ except Exception:
966
+ error_detail = response.text
967
+ raise RuntimeError(f"Cannot retry job: {error_detail}")
968
+ if response.status_code != 200:
969
+ raise RuntimeError(f"Error retrying job: {response.text}")
970
+ return response.json()
971
+
972
+ def list_jobs(
973
+ self,
974
+ status: Optional[str] = None,
975
+ environment: Optional[str] = None,
976
+ client_id: Optional[str] = None,
977
+ limit: int = 100
978
+ ) -> list:
979
+ """
980
+ List all jobs with optional filters.
981
+
982
+ Args:
983
+ status: Filter by job status
984
+ environment: Filter by request_metadata.environment
985
+ client_id: Filter by request_metadata.client_id
986
+ limit: Maximum number of jobs to return
987
+ """
988
+ params = {'limit': limit}
989
+ if status:
990
+ params['status'] = status
991
+ if environment:
992
+ params['environment'] = environment
993
+ if client_id:
994
+ params['client_id'] = client_id
995
+ response = self._request('GET', '/api/jobs', params=params)
996
+ if response.status_code != 200:
997
+ raise RuntimeError(f"Error listing jobs: {response.text}")
998
+ return response.json()
999
+
1000
+ def bulk_delete_jobs(
1001
+ self,
1002
+ environment: Optional[str] = None,
1003
+ client_id: Optional[str] = None,
1004
+ status: Optional[str] = None,
1005
+ confirm: bool = False,
1006
+ delete_files: bool = True
1007
+ ) -> Dict[str, Any]:
1008
+ """
1009
+ Delete multiple jobs matching filter criteria.
1010
+
1011
+ Args:
1012
+ environment: Delete jobs with this environment
1013
+ client_id: Delete jobs from this client
1014
+ status: Delete jobs with this status
1015
+ confirm: Must be True to execute deletion
1016
+ delete_files: Also delete GCS files
1017
+
1018
+ Returns:
1019
+ Dict with deletion results or preview
1020
+ """
1021
+ params = {
1022
+ 'confirm': str(confirm).lower(),
1023
+ 'delete_files': str(delete_files).lower(),
1024
+ }
1025
+ if environment:
1026
+ params['environment'] = environment
1027
+ if client_id:
1028
+ params['client_id'] = client_id
1029
+ if status:
1030
+ params['status'] = status
1031
+
1032
+ response = self._request('DELETE', '/api/jobs', params=params)
1033
+ if response.status_code == 400:
1034
+ try:
1035
+ error_detail = response.json().get('detail', response.text)
1036
+ except Exception:
1037
+ error_detail = response.text
1038
+ raise RuntimeError(f"Error: {error_detail}")
1039
+ if response.status_code != 200:
1040
+ raise RuntimeError(f"Error bulk deleting jobs: {response.text}")
1041
+ return response.json()
1042
+
1043
+ def get_instrumental_options(self, job_id: str) -> Dict[str, Any]:
1044
+ """Get instrumental options for selection."""
1045
+ response = self._request('GET', f'/api/jobs/{job_id}/instrumental-options')
1046
+ if response.status_code != 200:
1047
+ try:
1048
+ error_detail = response.json()
1049
+ except Exception:
1050
+ error_detail = response.text
1051
+ raise RuntimeError(f"Error getting instrumental options: {error_detail}")
1052
+ return response.json()
1053
+
1054
+ def get_instrumental_analysis(self, job_id: str) -> Dict[str, Any]:
1055
+ """Get instrumental analysis data including backing vocals detection."""
1056
+ response = self._request('GET', f'/api/jobs/{job_id}/instrumental-analysis')
1057
+ if response.status_code != 200:
1058
+ try:
1059
+ error_detail = response.json()
1060
+ except Exception:
1061
+ error_detail = response.text
1062
+ raise RuntimeError(f"Error getting instrumental analysis: {error_detail}")
1063
+ return response.json()
1064
+
1065
+ def select_instrumental(self, job_id: str, selection: str) -> Dict[str, Any]:
1066
+ """Submit instrumental selection."""
1067
+ response = self._request(
1068
+ 'POST',
1069
+ f'/api/jobs/{job_id}/select-instrumental',
1070
+ json={'selection': selection}
1071
+ )
1072
+ if response.status_code != 200:
1073
+ try:
1074
+ error_detail = response.json()
1075
+ except Exception:
1076
+ error_detail = response.text
1077
+ raise RuntimeError(f"Error selecting instrumental: {error_detail}")
1078
+ return response.json()
1079
+
1080
+ def get_download_urls(self, job_id: str) -> Dict[str, Any]:
1081
+ """Get signed download URLs for all job output files."""
1082
+ response = self._request('GET', f'/api/jobs/{job_id}/download-urls')
1083
+ if response.status_code != 200:
1084
+ try:
1085
+ error_detail = response.json()
1086
+ except Exception:
1087
+ error_detail = response.text
1088
+ raise RuntimeError(f"Error getting download URLs: {error_detail}")
1089
+ return response.json()
1090
+
1091
+ def download_file_via_url(self, url: str, local_path: str) -> bool:
1092
+ """Download file from a URL via HTTP."""
1093
+ try:
1094
+ # Handle relative URLs by prepending service URL
1095
+ if url.startswith('/'):
1096
+ url = f"{self.config.service_url}{url}"
1097
+
1098
+ # Use session headers (includes Authorization) for authenticated downloads
1099
+ response = self.session.get(url, stream=True, timeout=600)
1100
+ if response.status_code != 200:
1101
+ return False
1102
+
1103
+ # Ensure parent directory exists
1104
+ os.makedirs(os.path.dirname(local_path), exist_ok=True)
1105
+
1106
+ with open(local_path, 'wb') as f:
1107
+ for chunk in response.iter_content(chunk_size=8192):
1108
+ f.write(chunk)
1109
+ return True
1110
+ except Exception:
1111
+ return False
1112
+
1113
+ def download_file_via_gsutil(self, gcs_path: str, local_path: str) -> bool:
1114
+ """Download file from GCS using gsutil (fallback method)."""
1115
+ try:
1116
+ bucket_name = os.environ.get('KARAOKE_GEN_BUCKET', 'karaoke-gen-storage-nomadkaraoke')
1117
+ gcs_uri = f"gs://{bucket_name}/{gcs_path}"
1118
+
1119
+ result = subprocess.run(
1120
+ ['gsutil', 'cp', gcs_uri, local_path],
1121
+ capture_output=True,
1122
+ text=True
1123
+ )
1124
+ return result.returncode == 0
1125
+ except FileNotFoundError:
1126
+ return False
1127
+
1128
+ def get_worker_logs(self, job_id: str, since_index: int = 0) -> Dict[str, Any]:
1129
+ """
1130
+ Get worker logs for debugging.
1131
+
1132
+ Args:
1133
+ job_id: Job ID
1134
+ since_index: Return only logs after this index (for pagination/polling)
1135
+
1136
+ Returns:
1137
+ {
1138
+ "logs": [{"timestamp": "...", "level": "INFO", "worker": "audio", "message": "..."}],
1139
+ "next_index": 42,
1140
+ "total_logs": 42
1141
+ }
1142
+ """
1143
+ response = self._request(
1144
+ 'GET',
1145
+ f'/api/jobs/{job_id}/logs',
1146
+ params={'since_index': since_index}
1147
+ )
1148
+ if response.status_code != 200:
1149
+ return {"logs": [], "next_index": since_index, "total_logs": 0}
1150
+ return response.json()
1151
+
1152
+ def get_review_data(self, job_id: str) -> Dict[str, Any]:
1153
+ """Get the current review/correction data for a job."""
1154
+ response = self._request('GET', f'/api/review/{job_id}/correction-data')
1155
+ if response.status_code != 200:
1156
+ try:
1157
+ error_detail = response.json()
1158
+ except Exception:
1159
+ error_detail = response.text
1160
+ raise RuntimeError(f"Error getting review data: {error_detail}")
1161
+ return response.json()
1162
+
1163
+ def complete_review(self, job_id: str, updated_data: Dict[str, Any]) -> Dict[str, Any]:
1164
+ """Submit the review completion with corrected data."""
1165
+ response = self._request(
1166
+ 'POST',
1167
+ f'/api/review/{job_id}/complete',
1168
+ json=updated_data
1169
+ )
1170
+ if response.status_code != 200:
1171
+ try:
1172
+ error_detail = response.json()
1173
+ except Exception:
1174
+ error_detail = response.text
1175
+ raise RuntimeError(f"Error completing review: {error_detail}")
1176
+ return response.json()
1177
+
1178
+ def search_audio(
1179
+ self,
1180
+ artist: str,
1181
+ title: str,
1182
+ auto_download: bool = False,
1183
+ style_params_path: Optional[str] = None,
1184
+ enable_cdg: bool = True,
1185
+ enable_txt: bool = True,
1186
+ brand_prefix: Optional[str] = None,
1187
+ discord_webhook_url: Optional[str] = None,
1188
+ youtube_description: Optional[str] = None,
1189
+ enable_youtube_upload: bool = False,
1190
+ dropbox_path: Optional[str] = None,
1191
+ gdrive_folder_id: Optional[str] = None,
1192
+ lyrics_artist: Optional[str] = None,
1193
+ lyrics_title: Optional[str] = None,
1194
+ subtitle_offset_ms: int = 0,
1195
+ clean_instrumental_model: Optional[str] = None,
1196
+ backing_vocals_models: Optional[list] = None,
1197
+ other_stems_models: Optional[list] = None,
1198
+ ) -> Dict[str, Any]:
1199
+ """
1200
+ Search for audio by artist and title (Batch 5 - Flacfetch integration).
1201
+
1202
+ This creates a job and searches for audio sources. If auto_download is True,
1203
+ it automatically selects the best source. Otherwise, it returns search results
1204
+ for user selection.
1205
+
1206
+ Args:
1207
+ artist: Artist name to search for
1208
+ title: Song title to search for
1209
+ auto_download: Automatically select best audio source (skip interactive selection)
1210
+ style_params_path: Path to style_params.json (optional)
1211
+ ... other args same as submit_job()
1212
+
1213
+ Returns:
1214
+ Dict with job_id, status, and optionally search results
1215
+ """
1216
+ self.logger.info(f"Searching for audio: {artist} - {title}")
1217
+
1218
+ request_data = {
1219
+ 'artist': artist,
1220
+ 'title': title,
1221
+ 'auto_download': auto_download,
1222
+ 'enable_cdg': enable_cdg,
1223
+ 'enable_txt': enable_txt,
1224
+ }
1225
+
1226
+ if brand_prefix:
1227
+ request_data['brand_prefix'] = brand_prefix
1228
+ if discord_webhook_url:
1229
+ request_data['discord_webhook_url'] = discord_webhook_url
1230
+ if youtube_description:
1231
+ request_data['youtube_description'] = youtube_description
1232
+ if enable_youtube_upload:
1233
+ request_data['enable_youtube_upload'] = enable_youtube_upload
1234
+ if dropbox_path:
1235
+ request_data['dropbox_path'] = dropbox_path
1236
+ if gdrive_folder_id:
1237
+ request_data['gdrive_folder_id'] = gdrive_folder_id
1238
+ if lyrics_artist:
1239
+ request_data['lyrics_artist'] = lyrics_artist
1240
+ if lyrics_title:
1241
+ request_data['lyrics_title'] = lyrics_title
1242
+ if subtitle_offset_ms != 0:
1243
+ request_data['subtitle_offset_ms'] = subtitle_offset_ms
1244
+ if clean_instrumental_model:
1245
+ request_data['clean_instrumental_model'] = clean_instrumental_model
1246
+ if backing_vocals_models:
1247
+ request_data['backing_vocals_models'] = backing_vocals_models
1248
+ if other_stems_models:
1249
+ request_data['other_stems_models'] = other_stems_models
1250
+
1251
+ # Prepare style files for upload if provided
1252
+ style_files = []
1253
+ local_style_files: Dict[str, str] = {} # file_type -> local_path
1254
+
1255
+ if style_params_path and os.path.isfile(style_params_path):
1256
+ self.logger.info(f"Parsing style configuration: {style_params_path}")
1257
+
1258
+ # Add the style_params.json itself
1259
+ style_files.append({
1260
+ 'filename': Path(style_params_path).name,
1261
+ 'content_type': 'application/json',
1262
+ 'file_type': 'style_params'
1263
+ })
1264
+ local_style_files['style_params'] = style_params_path
1265
+
1266
+ # Parse style params to find referenced files (backgrounds, fonts)
1267
+ style_assets = self._parse_style_params(style_params_path)
1268
+
1269
+ for asset_key, asset_path in style_assets.items():
1270
+ if os.path.isfile(asset_path):
1271
+ # Use full path for content type detection (not just extension)
1272
+ content_type = self._get_content_type(asset_path)
1273
+ style_files.append({
1274
+ 'filename': Path(asset_path).name,
1275
+ 'content_type': content_type,
1276
+ 'file_type': asset_key # e.g., 'style_intro_background'
1277
+ })
1278
+ local_style_files[asset_key] = asset_path
1279
+ self.logger.info(f" Will upload style asset: {asset_key}")
1280
+
1281
+ if style_files:
1282
+ request_data['style_files'] = style_files
1283
+ self.logger.info(f"Including {len(style_files)} style files in request")
1284
+
1285
+ response = self._request('POST', '/api/audio-search/search', json=request_data)
1286
+
1287
+ if response.status_code == 404:
1288
+ try:
1289
+ error_detail = response.json()
1290
+ except Exception:
1291
+ error_detail = response.text
1292
+ raise ValueError(f"No audio sources found: {error_detail}")
1293
+
1294
+ if response.status_code != 200:
1295
+ try:
1296
+ error_detail = response.json()
1297
+ except Exception:
1298
+ error_detail = response.text
1299
+ raise RuntimeError(f"Error searching for audio: {error_detail}")
1300
+
1301
+ result = response.json()
1302
+
1303
+ # Upload style files if we have signed URLs
1304
+ style_upload_urls = result.get('style_upload_urls', [])
1305
+ if style_upload_urls and local_style_files:
1306
+ self.logger.info(f"Uploading {len(style_upload_urls)} style files...")
1307
+
1308
+ for url_info in style_upload_urls:
1309
+ file_type = url_info['file_type']
1310
+ upload_url = url_info['upload_url']
1311
+
1312
+ local_path = local_style_files.get(file_type)
1313
+ if not local_path:
1314
+ self.logger.warning(f"No local file for {file_type}, skipping upload")
1315
+ continue
1316
+
1317
+ self.logger.info(f" Uploading {file_type}: {Path(local_path).name}")
1318
+
1319
+ try:
1320
+ with open(local_path, 'rb') as f:
1321
+ file_content = f.read()
1322
+
1323
+ # Use the content type from the original file info, not re-derived
1324
+ # This ensures it matches the signed URL which was generated with
1325
+ # the same content type we specified in the request
1326
+ content_type = self._get_content_type(local_path)
1327
+
1328
+ # Use PUT to upload directly to signed URL
1329
+ upload_response = requests.put(
1330
+ upload_url,
1331
+ data=file_content,
1332
+ headers={'Content-Type': content_type},
1333
+ timeout=60
1334
+ )
1335
+
1336
+ if upload_response.status_code not in (200, 201):
1337
+ self.logger.error(f"Failed to upload {file_type}: {upload_response.status_code}")
1338
+ else:
1339
+ self.logger.info(f" ✓ Uploaded {file_type}")
1340
+
1341
+ except Exception as e:
1342
+ self.logger.error(f"Error uploading {file_type}: {e}")
1343
+
1344
+ self.logger.info("Style file uploads complete")
1345
+
1346
+ return result
1347
+
1348
+ def get_audio_search_results(self, job_id: str) -> Dict[str, Any]:
1349
+ """Get audio search results for a job awaiting selection."""
1350
+ response = self._request('GET', f'/api/audio-search/{job_id}/results')
1351
+ if response.status_code != 200:
1352
+ try:
1353
+ error_detail = response.json()
1354
+ except Exception:
1355
+ error_detail = response.text
1356
+ raise RuntimeError(f"Error getting search results: {error_detail}")
1357
+ return response.json()
1358
+
1359
+ def select_audio_source(self, job_id: str, selection_index: int) -> Dict[str, Any]:
1360
+ """Select an audio source and start processing."""
1361
+ response = self._request(
1362
+ 'POST',
1363
+ f'/api/audio-search/{job_id}/select',
1364
+ json={'selection_index': selection_index}
1365
+ )
1366
+ if response.status_code != 200:
1367
+ try:
1368
+ error_detail = response.json()
1369
+ except Exception:
1370
+ error_detail = response.text
1371
+ raise RuntimeError(f"Error selecting audio: {error_detail}")
1372
+ return response.json()
1373
+
1374
+
1375
+ class JobMonitor:
1376
+ """Monitor job progress with verbose logging."""
1377
+
1378
+ def __init__(self, client: RemoteKaraokeClient, config: Config, logger: logging.Logger):
1379
+ self.client = client
1380
+ self.config = config
1381
+ self.logger = logger
1382
+ self._review_opened = False
1383
+ self._instrumental_prompted = False
1384
+ self._audio_selection_prompted = False # Batch 5: audio source selection
1385
+ self._last_timeline_index = 0
1386
+ self._last_log_index = 0
1387
+ self._show_worker_logs = True # Enable worker log display
1388
+ self._polls_without_updates = 0 # Track polling activity for heartbeat
1389
+ self._heartbeat_interval = 6 # Show heartbeat every N polls without updates (~30s with 5s poll)
1390
+
1391
+ # Status descriptions for user-friendly logging
1392
+ STATUS_DESCRIPTIONS = {
1393
+ 'pending': 'Job queued, waiting to start',
1394
+ # Audio search states (Batch 5)
1395
+ 'searching_audio': 'Searching for audio sources',
1396
+ 'awaiting_audio_selection': 'Waiting for audio source selection',
1397
+ 'downloading_audio': 'Downloading selected audio',
1398
+ # Main workflow
1399
+ 'downloading': 'Downloading and preparing input files',
1400
+ 'separating_stage1': 'AI audio separation (stage 1 of 2)',
1401
+ 'separating_stage2': 'AI audio separation (stage 2 of 2)',
1402
+ 'audio_complete': 'Audio separation complete',
1403
+ 'transcribing': 'Transcribing lyrics from audio',
1404
+ 'correcting': 'Auto-correcting lyrics against reference sources',
1405
+ 'lyrics_complete': 'Lyrics processing complete',
1406
+ 'generating_screens': 'Creating title and end screens',
1407
+ 'applying_padding': 'Adding intro/outro padding',
1408
+ 'awaiting_review': 'Waiting for lyrics review',
1409
+ 'in_review': 'Lyrics review in progress',
1410
+ 'review_complete': 'Review complete, preparing video render',
1411
+ 'rendering_video': 'Rendering karaoke video with lyrics',
1412
+ 'awaiting_instrumental_selection': 'Waiting for instrumental selection',
1413
+ 'instrumental_selected': 'Instrumental selected, preparing final encoding',
1414
+ 'generating_video': 'Downloading files for final video encoding',
1415
+ 'encoding': 'Encoding final videos (15-20 min, 4 formats)',
1416
+ 'packaging': 'Creating CDG/TXT packages',
1417
+ 'uploading': 'Uploading to distribution services',
1418
+ 'notifying': 'Sending notifications',
1419
+ 'complete': 'All processing complete',
1420
+ 'prep_complete': 'Prep phase complete - ready for local finalisation',
1421
+ 'failed': 'Job failed',
1422
+ 'cancelled': 'Job cancelled',
1423
+ }
1424
+
1425
+ def _get_status_description(self, status: str) -> str:
1426
+ """Get user-friendly description for a status."""
1427
+ return self.STATUS_DESCRIPTIONS.get(status, status)
1428
+
1429
+ def _show_download_progress(self, job_data: Dict[str, Any]) -> None:
1430
+ """Show detailed download progress during audio download."""
1431
+ try:
1432
+ # Get provider from job state_data
1433
+ state_data = job_data.get('state_data', {})
1434
+ provider = state_data.get('selected_audio_provider', 'unknown')
1435
+
1436
+ # For non-torrent providers (YouTube), just show simple message
1437
+ if provider.lower() == 'youtube':
1438
+ self.logger.info(f" [Downloading from YouTube...]")
1439
+ return
1440
+
1441
+ # Query health endpoint for transmission status (torrent providers)
1442
+ health_url = f"{self.config.service_url}/api/health/detailed"
1443
+ response = requests.get(health_url, timeout=5)
1444
+
1445
+ if response.status_code == 200:
1446
+ health = response.json()
1447
+ transmission = health.get('dependencies', {}).get('transmission', {})
1448
+
1449
+ if transmission.get('available'):
1450
+ torrents = transmission.get('torrents', [])
1451
+ if torrents:
1452
+ # Show info about active torrents
1453
+ for t in torrents:
1454
+ progress = t.get('progress', 0)
1455
+ peers = t.get('peers', 0)
1456
+ speed = t.get('download_speed', 0)
1457
+ stalled = t.get('stalled', False)
1458
+
1459
+ if stalled:
1460
+ self.logger.info(f" [Downloading from {provider}] {progress:.1f}% - STALLED (no peers)")
1461
+ elif progress < 100:
1462
+ self.logger.info(f" [Downloading from {provider}] {progress:.1f}% @ {speed:.1f} KB/s ({peers} peers)")
1463
+ else:
1464
+ self.logger.info(f" [Downloading from {provider}] Complete, processing...")
1465
+ else:
1466
+ # No torrents - might be starting or YouTube download
1467
+ self.logger.info(f" [Downloading from {provider}] Starting download...")
1468
+ else:
1469
+ self.logger.info(f" [Downloading from {provider}] Transmission not available - download may fail")
1470
+ else:
1471
+ self.logger.info(f" [Downloading from {provider}]...")
1472
+
1473
+ except Exception as e:
1474
+ # Fall back to simple message
1475
+ self.logger.info(f" [Downloading audio...]")
1476
+
1477
+ def open_browser(self, url: str) -> None:
1478
+ """Open URL in the default browser."""
1479
+ system = platform.system()
1480
+ try:
1481
+ if system == 'Darwin':
1482
+ subprocess.run(['open', url], check=True)
1483
+ elif system == 'Linux':
1484
+ subprocess.run(['xdg-open', url], check=True, stderr=subprocess.DEVNULL)
1485
+ else:
1486
+ webbrowser.open(url)
1487
+ except Exception:
1488
+ self.logger.info(f"Please open in browser: {url}")
1489
+
1490
+ def open_review_ui(self, job_id: str) -> None:
1491
+ """Open the lyrics review UI in browser."""
1492
+ # Build the review URL with the API endpoint
1493
+ base_api_url = f"{self.config.service_url}/api/review/{job_id}"
1494
+ encoded_api_url = urllib.parse.quote(base_api_url, safe='')
1495
+
1496
+ # Try to get audio hash and review token from job data
1497
+ audio_hash = ''
1498
+ review_token = ''
1499
+ try:
1500
+ job_data = self.client.get_job(job_id)
1501
+ audio_hash = job_data.get('audio_hash', '')
1502
+ review_token = job_data.get('review_token', '')
1503
+ except Exception:
1504
+ pass
1505
+
1506
+ url = f"{self.config.review_ui_url}/?baseApiUrl={encoded_api_url}"
1507
+ if audio_hash:
1508
+ url += f"&audioHash={audio_hash}"
1509
+ if review_token:
1510
+ url += f"&reviewToken={review_token}"
1511
+
1512
+ self.logger.info(f"Opening lyrics review UI: {url}")
1513
+ self.open_browser(url)
1514
+
1515
+ def handle_review(self, job_id: str) -> None:
1516
+ """Handle the lyrics review interaction."""
1517
+ self.logger.info("=" * 60)
1518
+ self.logger.info("LYRICS REVIEW NEEDED")
1519
+ self.logger.info("=" * 60)
1520
+
1521
+ # In non-interactive mode, auto-accept the current corrections
1522
+ if self.config.non_interactive:
1523
+ self.logger.info("Non-interactive mode: Auto-accepting current corrections")
1524
+ try:
1525
+ # Get current review data
1526
+ review_data = self.client.get_review_data(job_id)
1527
+ self.logger.info("Retrieved current correction data")
1528
+
1529
+ # Submit as-is to complete the review
1530
+ result = self.client.complete_review(job_id, review_data)
1531
+ if result.get('status') == 'success':
1532
+ self.logger.info("Review auto-completed successfully")
1533
+ return
1534
+ else:
1535
+ self.logger.error(f"Failed to auto-complete review: {result}")
1536
+ # In non-interactive mode, raise exception instead of falling back to manual
1537
+ raise RuntimeError(f"Failed to auto-complete review: {result}")
1538
+ except Exception as e:
1539
+ self.logger.error(f"Error auto-completing review: {e}")
1540
+ # In non-interactive mode, we can't fall back to manual - raise the error
1541
+ raise RuntimeError(f"Non-interactive review failed: {e}")
1542
+
1543
+ # Interactive mode - open browser and wait
1544
+ self.logger.info("The transcription is ready for review.")
1545
+ self.logger.info("Please review and correct the lyrics in the browser.")
1546
+
1547
+ self.open_review_ui(job_id)
1548
+
1549
+ self.logger.info(f"Waiting for review completion (polling every {self.config.poll_interval}s)...")
1550
+
1551
+ # Poll until status changes from review states
1552
+ while True:
1553
+ try:
1554
+ job_data = self.client.get_job(job_id)
1555
+ current_status = job_data.get('status', 'unknown')
1556
+
1557
+ if current_status in ['awaiting_review', 'in_review']:
1558
+ time.sleep(self.config.poll_interval)
1559
+ else:
1560
+ self.logger.info(f"Review completed, status: {current_status}")
1561
+ return
1562
+ except Exception as e:
1563
+ self.logger.warning(f"Error checking review status: {e}")
1564
+ time.sleep(self.config.poll_interval)
1565
+
1566
+ def handle_instrumental_selection(self, job_id: str) -> None:
1567
+ """Handle instrumental selection interaction with analysis-based recommendations."""
1568
+ self.logger.info("=" * 60)
1569
+ self.logger.info("INSTRUMENTAL SELECTION NEEDED")
1570
+ self.logger.info("=" * 60)
1571
+
1572
+ # Try to get analysis data for smart recommendations
1573
+ analysis_data = None
1574
+ try:
1575
+ analysis_data = self.client.get_instrumental_analysis(job_id)
1576
+ analysis = analysis_data.get('analysis', {})
1577
+
1578
+ # Display analysis summary
1579
+ self.logger.info("")
1580
+ self.logger.info("=== Backing Vocals Analysis ===")
1581
+ if analysis.get('has_audible_content'):
1582
+ self.logger.info(f" Backing vocals detected: YES")
1583
+ self.logger.info(f" Audible segments: {len(analysis.get('audible_segments', []))}")
1584
+ self.logger.info(f" Audible duration: {analysis.get('total_audible_duration_seconds', 0):.1f}s "
1585
+ f"({analysis.get('audible_percentage', 0):.1f}% of track)")
1586
+ else:
1587
+ self.logger.info(f" Backing vocals detected: NO")
1588
+ self.logger.info(f" Recommendation: {analysis.get('recommended_selection', 'review_needed')}")
1589
+ self.logger.info("")
1590
+ except Exception as e:
1591
+ self.logger.warning(f"Could not fetch analysis data: {e}")
1592
+ self.logger.info("Falling back to manual selection...")
1593
+
1594
+ # In non-interactive mode, use analysis recommendation or default to clean
1595
+ if self.config.non_interactive:
1596
+ if analysis_data and analysis_data.get('analysis', {}).get('recommended_selection') == 'clean':
1597
+ self.logger.info("Non-interactive mode: Auto-selecting clean instrumental (recommended)")
1598
+ selection = 'clean'
1599
+ else:
1600
+ self.logger.info("Non-interactive mode: Auto-selecting clean instrumental (default)")
1601
+ selection = 'clean'
1602
+ else:
1603
+ # Check if we should recommend clean based on analysis
1604
+ recommend_clean = (
1605
+ analysis_data and
1606
+ not analysis_data.get('analysis', {}).get('has_audible_content', True)
1607
+ )
1608
+
1609
+ if recommend_clean:
1610
+ self.logger.info("No backing vocals detected - recommending clean instrumental.")
1611
+ self.logger.info("")
1612
+ self.logger.info("Options:")
1613
+ self.logger.info(" 1) Accept recommendation (clean instrumental)")
1614
+ self.logger.info(" 2) Open browser to review and select")
1615
+ self.logger.info("")
1616
+
1617
+ try:
1618
+ choice = input("Enter your choice (1 or 2): ").strip()
1619
+ if choice == '1':
1620
+ selection = 'clean'
1621
+ else:
1622
+ self._open_instrumental_review_and_wait(job_id)
1623
+ return # Selection will be submitted via browser
1624
+ except KeyboardInterrupt:
1625
+ print()
1626
+ raise
1627
+ else:
1628
+ # Backing vocals detected or analysis unavailable - offer browser review
1629
+ self.logger.info("Choose how to select your instrumental:")
1630
+ self.logger.info("")
1631
+ self.logger.info(" 1) Clean Instrumental (no backing vocals)")
1632
+ self.logger.info(" Best for songs where you want ONLY the lead vocal removed")
1633
+ self.logger.info("")
1634
+ self.logger.info(" 2) Instrumental with Backing Vocals")
1635
+ self.logger.info(" Best for songs where backing vocals add to the karaoke experience")
1636
+ self.logger.info("")
1637
+ self.logger.info(" 3) Open Browser for Advanced Review")
1638
+ self.logger.info(" Listen to audio, view waveform, and optionally mute sections")
1639
+ self.logger.info(" to create a custom instrumental")
1640
+ self.logger.info("")
1641
+
1642
+ selection = ""
1643
+ while not selection:
1644
+ try:
1645
+ choice = input("Enter your choice (1, 2, or 3): ").strip()
1646
+ if choice == '1':
1647
+ selection = 'clean'
1648
+ elif choice == '2':
1649
+ selection = 'with_backing'
1650
+ elif choice == '3':
1651
+ self._open_instrumental_review_and_wait(job_id)
1652
+ return # Selection will be submitted via browser
1653
+ else:
1654
+ self.logger.error("Invalid choice. Please enter 1, 2, or 3.")
1655
+ except KeyboardInterrupt:
1656
+ print()
1657
+ raise
1658
+
1659
+ self.logger.info(f"Submitting selection: {selection}")
1660
+
1661
+ try:
1662
+ result = self.client.select_instrumental(job_id, selection)
1663
+ if result.get('status') == 'success':
1664
+ self.logger.info(f"Selection submitted successfully: {selection}")
1665
+ else:
1666
+ self.logger.error(f"Error submitting selection: {result}")
1667
+ except Exception as e:
1668
+ self.logger.error(f"Error submitting selection: {e}")
1669
+
1670
+ def _convert_api_result_to_release_dict(self, result: dict) -> dict:
1671
+ """
1672
+ Convert API search result to a dict compatible with flacfetch's Release.from_dict().
1673
+
1674
+ This enables using flacfetch's shared display functions for consistent,
1675
+ rich formatting between local and remote CLIs.
1676
+ """
1677
+ # Build quality dict from API response
1678
+ quality_data = result.get('quality_data') or {
1679
+ "format": "OTHER",
1680
+ "media": "OTHER",
1681
+ }
1682
+
1683
+ return {
1684
+ "title": result.get('title', ''),
1685
+ "artist": result.get('artist', ''),
1686
+ "source_name": result.get('provider', 'Unknown'),
1687
+ "download_url": result.get('url'),
1688
+ "info_hash": result.get('source_id'),
1689
+ "size_bytes": result.get('size_bytes'),
1690
+ "year": result.get('year'),
1691
+ "edition_info": result.get('edition_info'),
1692
+ "label": result.get('label'),
1693
+ "release_type": result.get('release_type'),
1694
+ "seeders": result.get('seeders'),
1695
+ "channel": result.get('channel'),
1696
+ "view_count": result.get('view_count'),
1697
+ "duration_seconds": result.get('duration'),
1698
+ "target_file": result.get('target_file'),
1699
+ "target_file_size": result.get('target_file_size'),
1700
+ "track_pattern": result.get('track_pattern'),
1701
+ "match_score": result.get('match_score', 0.0),
1702
+ "quality": quality_data,
1703
+ # Pre-computed fields
1704
+ "formatted_size": result.get('formatted_size'),
1705
+ "formatted_duration": result.get('formatted_duration'),
1706
+ "formatted_views": result.get('formatted_views'),
1707
+ "is_lossless": result.get('is_lossless', False),
1708
+ "quality_str": result.get('quality_str') or result.get('quality', ''),
1709
+ }
1710
+
1711
+ def _convert_to_release_objects(self, release_dicts: List[Dict[str, Any]]) -> List[Release]:
1712
+ """
1713
+ Convert API result dicts to Release objects for categorization.
1714
+
1715
+ Used by handle_audio_selection() to enable categorized display
1716
+ for large result sets (10+ results).
1717
+
1718
+ Args:
1719
+ release_dicts: List of dicts in Release-compatible format
1720
+
1721
+ Returns:
1722
+ List of Release objects (skipping any that fail to convert)
1723
+ """
1724
+ releases = []
1725
+ for d in release_dicts:
1726
+ try:
1727
+ releases.append(Release.from_dict(d))
1728
+ except Exception as e:
1729
+ self.logger.debug(f"Failed to convert result to Release: {e}")
1730
+ return releases
1731
+
1732
+ def handle_audio_selection(self, job_id: str) -> None:
1733
+ """Handle audio source selection interaction (Batch 5).
1734
+
1735
+ For 10+ results, uses categorized display (grouped by Top Seeded,
1736
+ Album Releases, Hi-Res, etc.) with a 'more' command to show full list.
1737
+ For smaller result sets, uses flat list display.
1738
+ """
1739
+ self.logger.info("=" * 60)
1740
+ self.logger.info("AUDIO SOURCE SELECTION NEEDED")
1741
+ self.logger.info("=" * 60)
1742
+
1743
+ try:
1744
+ # Get search results
1745
+ results_data = self.client.get_audio_search_results(job_id)
1746
+ results = results_data.get('results', [])
1747
+ artist = results_data.get('artist', 'Unknown')
1748
+ title = results_data.get('title', 'Unknown')
1749
+
1750
+ if not results:
1751
+ self.logger.error("No search results available")
1752
+ return
1753
+
1754
+ # In non-interactive mode, auto-select first result
1755
+ if self.config.non_interactive:
1756
+ self.logger.info("Non-interactive mode: Auto-selecting first result")
1757
+ selection_index = 0
1758
+ else:
1759
+ # Convert API results to Release-compatible dicts for flacfetch display
1760
+ # This gives us the same rich, colorized output as the local CLI
1761
+ release_dicts = [self._convert_api_result_to_release_dict(r) for r in results]
1762
+
1763
+ # Convert to Release objects for categorization
1764
+ release_objects = self._convert_to_release_objects(release_dicts)
1765
+
1766
+ # Use categorized display for large result sets (10+)
1767
+ # This groups results into categories: Top Seeded, Album Releases, Hi-Res, etc.
1768
+ use_categorized = len(release_objects) >= 10
1769
+
1770
+ if use_categorized:
1771
+ # Create query for categorization
1772
+ query = TrackQuery(artist=artist, title=title)
1773
+ categorized = categorize_releases(release_objects, query)
1774
+ # print_categorized_releases returns the flattened list of displayed releases
1775
+ display_releases = print_categorized_releases(categorized, target_artist=artist, use_colors=True)
1776
+ showing_categorized = True
1777
+ else:
1778
+ # Small result set - use simple flat list
1779
+ print_releases(release_dicts, target_artist=artist, use_colors=True)
1780
+ display_releases = release_objects
1781
+ showing_categorized = False
1782
+
1783
+ selection_index = -1
1784
+ while selection_index < 0:
1785
+ try:
1786
+ if showing_categorized:
1787
+ prompt = f"\nSelect (1-{len(display_releases)}), 'more' for full list, 0 to cancel: "
1788
+ else:
1789
+ prompt = f"\nSelect a release (1-{len(display_releases)}, 0 to cancel): "
1790
+
1791
+ choice = input(prompt).strip().lower()
1792
+
1793
+ if choice == "0":
1794
+ self.logger.info("Selection cancelled by user")
1795
+ raise KeyboardInterrupt
1796
+
1797
+ # Handle 'more' command to show full flat list
1798
+ if choice in ('more', 'm', 'all', 'a') and showing_categorized:
1799
+ print("\n" + "=" * 60)
1800
+ print("FULL LIST (all results)")
1801
+ print("=" * 60 + "\n")
1802
+ print_releases(release_dicts, target_artist=artist, use_colors=True)
1803
+ display_releases = release_objects
1804
+ showing_categorized = False
1805
+ continue
1806
+
1807
+ choice_num = int(choice)
1808
+ if 1 <= choice_num <= len(display_releases):
1809
+ # Map selection back to original results index for API call
1810
+ selected_release = display_releases[choice_num - 1]
1811
+
1812
+ # Find matching index in original results by download_url
1813
+ selection_index = self._find_original_index(
1814
+ selected_release, results, release_objects
1815
+ )
1816
+
1817
+ if selection_index < 0:
1818
+ # Fallback: use display index if mapping fails
1819
+ self.logger.warning("Could not map selection to original index, using display index")
1820
+ selection_index = choice_num - 1
1821
+ else:
1822
+ print(f"Please enter a number between 0 and {len(display_releases)}")
1823
+ except ValueError:
1824
+ if showing_categorized:
1825
+ print("Please enter a number or 'more'")
1826
+ else:
1827
+ print("Please enter a valid number")
1828
+ except KeyboardInterrupt:
1829
+ print()
1830
+ raise
1831
+
1832
+ selected = results[selection_index]
1833
+ self.logger.info(f"Selected: [{selected.get('provider')}] {selected.get('artist')} - {selected.get('title')}")
1834
+ self.logger.info("")
1835
+
1836
+ # Submit selection
1837
+ result = self.client.select_audio_source(job_id, selection_index)
1838
+ if result.get('status') == 'success':
1839
+ self.logger.info(f"Selection submitted successfully")
1840
+ else:
1841
+ self.logger.error(f"Error submitting selection: {result}")
1842
+
1843
+ except Exception as e:
1844
+ self.logger.error(f"Error handling audio selection: {e}")
1845
+
1846
+ def _find_original_index(
1847
+ self,
1848
+ selected_release: Release,
1849
+ original_results: List[Dict[str, Any]],
1850
+ release_objects: List[Release],
1851
+ ) -> int:
1852
+ """
1853
+ Map a selected Release back to its index in the original API results.
1854
+
1855
+ This is needed because categorized display may reorder results,
1856
+ but the API selection endpoint needs the original index.
1857
+
1858
+ Args:
1859
+ selected_release: The Release object user selected
1860
+ original_results: Original API results (list of dicts)
1861
+ release_objects: Release objects in same order as original_results
1862
+
1863
+ Returns:
1864
+ Index in original_results, or -1 if not found
1865
+ """
1866
+ # First try: match by object identity in release_objects
1867
+ for i, release in enumerate(release_objects):
1868
+ if release is selected_release:
1869
+ return i
1870
+
1871
+ # Second try: match by download_url
1872
+ selected_url = getattr(selected_release, 'download_url', None)
1873
+ if selected_url:
1874
+ for i, r in enumerate(original_results):
1875
+ if r.get('url') == selected_url:
1876
+ return i
1877
+
1878
+ # Third try: match by info_hash (for torrent sources)
1879
+ selected_hash = getattr(selected_release, 'info_hash', None)
1880
+ if selected_hash:
1881
+ for i, r in enumerate(original_results):
1882
+ if r.get('source_id') == selected_hash:
1883
+ return i
1884
+
1885
+ # Fourth try: match by title + artist + provider
1886
+ selected_title = getattr(selected_release, 'title', '')
1887
+ selected_artist = getattr(selected_release, 'artist', '')
1888
+ selected_source = getattr(selected_release, 'source_name', '')
1889
+
1890
+ for i, r in enumerate(original_results):
1891
+ if (r.get('title') == selected_title and
1892
+ r.get('artist') == selected_artist and
1893
+ r.get('provider') == selected_source):
1894
+ return i
1895
+
1896
+ return -1
1897
+
1898
+ def _open_instrumental_review_and_wait(self, job_id: str) -> None:
1899
+ """Open browser to instrumental review UI and wait for selection."""
1900
+ # Get instrumental token from job data
1901
+ instrumental_token = ''
1902
+ try:
1903
+ job_data = self.client.get_job(job_id)
1904
+ instrumental_token = job_data.get('instrumental_token', '')
1905
+ except Exception:
1906
+ pass
1907
+
1908
+ # Build the review URL with API endpoint and token
1909
+ # The instrumental UI is hosted at /instrumental/ on the frontend domain
1910
+ base_api_url = f"{self.config.service_url}/api/jobs/{job_id}"
1911
+ encoded_api_url = urllib.parse.quote(base_api_url, safe='')
1912
+
1913
+ # Use /instrumental/ path on the frontend (same domain as review_ui_url but different path)
1914
+ # review_ui_url is like https://gen.nomadkaraoke.com/lyrics, we want /instrumental/
1915
+ frontend_base = self.config.review_ui_url.rsplit('/', 1)[0] # Remove /lyrics
1916
+ review_url = f"{frontend_base}/instrumental/?baseApiUrl={encoded_api_url}"
1917
+ if instrumental_token:
1918
+ review_url += f"&instrumentalToken={instrumental_token}"
1919
+
1920
+ self.logger.info("")
1921
+ self.logger.info("=" * 60)
1922
+ self.logger.info("OPENING BROWSER FOR INSTRUMENTAL REVIEW")
1923
+ self.logger.info("=" * 60)
1924
+ self.logger.info(f"Review URL: {review_url}")
1925
+ self.logger.info("")
1926
+ self.logger.info("In the browser you can:")
1927
+ self.logger.info(" - View the backing vocals waveform")
1928
+ self.logger.info(" - Listen to clean instrumental, backing vocals, or combined")
1929
+ self.logger.info(" - Select regions to mute and create a custom instrumental")
1930
+ self.logger.info(" - Submit your final selection")
1931
+ self.logger.info("")
1932
+ self.logger.info("Waiting for selection to be submitted...")
1933
+ self.logger.info("(Press Ctrl+C to cancel)")
1934
+ self.logger.info("")
1935
+
1936
+ # Open browser
1937
+ webbrowser.open(review_url)
1938
+
1939
+ # Poll until job status changes from awaiting_instrumental_selection
1940
+ while True:
1941
+ try:
1942
+ job_data = self.client.get_job(job_id)
1943
+ current_status = job_data.get('status')
1944
+
1945
+ if current_status != 'awaiting_instrumental_selection':
1946
+ selection = job_data.get('state_data', {}).get('instrumental_selection', 'unknown')
1947
+ self.logger.info(f"Selection received: {selection}")
1948
+ self.logger.info(f"Job status: {current_status}")
1949
+ return
1950
+
1951
+ time.sleep(self.config.poll_interval)
1952
+
1953
+ except KeyboardInterrupt:
1954
+ print()
1955
+ self.logger.info("Cancelled. You can resume this job later with --resume")
1956
+ raise
1957
+ except Exception as e:
1958
+ self.logger.warning(f"Error checking status: {e}")
1959
+ time.sleep(self.config.poll_interval)
1960
+
1961
+
1962
+ def download_outputs(self, job_id: str, job_data: Dict[str, Any]) -> None:
1963
+ """
1964
+ Download all output files for a completed job.
1965
+
1966
+ Downloads all files to match local CLI output structure:
1967
+ - Final videos (4 formats)
1968
+ - CDG/TXT ZIP packages (and extracts individual files)
1969
+ - Lyrics files (.ass, .lrc, .txt)
1970
+ - Audio stems with descriptive names
1971
+ - Title/End screen files (.mov, .jpg, .png)
1972
+ - With Vocals intermediate video
1973
+ """
1974
+ artist = job_data.get('artist', 'Unknown')
1975
+ title = job_data.get('title', 'Unknown')
1976
+ brand_code = job_data.get('state_data', {}).get('brand_code')
1977
+
1978
+ # Use brand code in folder name if available
1979
+ if brand_code:
1980
+ folder_name = f"{brand_code} - {artist} - {title}"
1981
+ else:
1982
+ folder_name = f"{artist} - {title}"
1983
+
1984
+ # Sanitize folder name
1985
+ folder_name = "".join(c for c in folder_name if c.isalnum() or c in " -_").strip()
1986
+
1987
+ output_dir = Path(self.config.output_dir) / folder_name
1988
+ output_dir.mkdir(parents=True, exist_ok=True)
1989
+
1990
+ self.logger.info(f"Downloading output files to: {output_dir}")
1991
+
1992
+ # Get signed download URLs from the API
1993
+ try:
1994
+ download_data = self.client.get_download_urls(job_id)
1995
+ download_urls = download_data.get('download_urls', {})
1996
+ except Exception as e:
1997
+ self.logger.warning(f"Could not get signed download URLs: {e}")
1998
+ self.logger.warning("Falling back to gsutil (requires gcloud auth)")
1999
+ download_urls = {}
2000
+
2001
+ file_urls = job_data.get('file_urls', {})
2002
+ base_name = f"{artist} - {title}"
2003
+
2004
+ def download_file(category: str, key: str, local_path: Path, filename: str) -> bool:
2005
+ """Helper to download a file using signed URL or gsutil fallback."""
2006
+ # Try signed URL first
2007
+ signed_url = download_urls.get(category, {}).get(key)
2008
+ if signed_url:
2009
+ if self.client.download_file_via_url(signed_url, str(local_path)):
2010
+ return True
2011
+
2012
+ # Fall back to gsutil
2013
+ gcs_path = file_urls.get(category, {}).get(key)
2014
+ if gcs_path:
2015
+ return self.client.download_file_via_gsutil(gcs_path, str(local_path))
2016
+ return False
2017
+
2018
+ # Download final videos
2019
+ finals = file_urls.get('finals', {})
2020
+ if finals:
2021
+ self.logger.info("Downloading final videos...")
2022
+ for key, blob_path in finals.items():
2023
+ if blob_path:
2024
+ # Use descriptive filename
2025
+ if 'lossless_4k_mp4' in key:
2026
+ filename = f"{base_name} (Final Karaoke Lossless 4k).mp4"
2027
+ elif 'lossless_4k_mkv' in key:
2028
+ filename = f"{base_name} (Final Karaoke Lossless 4k).mkv"
2029
+ elif 'lossy_4k' in key:
2030
+ filename = f"{base_name} (Final Karaoke Lossy 4k).mp4"
2031
+ elif 'lossy_720p' in key:
2032
+ filename = f"{base_name} (Final Karaoke Lossy 720p).mp4"
2033
+ else:
2034
+ filename = Path(blob_path).name
2035
+
2036
+ local_path = output_dir / filename
2037
+ self.logger.info(f" Downloading {filename}...")
2038
+ if download_file('finals', key, local_path, filename):
2039
+ self.logger.info(f" OK: {local_path}")
2040
+ else:
2041
+ self.logger.warning(f" FAILED: {filename}")
2042
+
2043
+ # Download CDG/TXT packages
2044
+ packages = file_urls.get('packages', {})
2045
+ if packages:
2046
+ self.logger.info("Downloading karaoke packages...")
2047
+ for key, blob_path in packages.items():
2048
+ if blob_path:
2049
+ if 'cdg' in key.lower():
2050
+ filename = f"{base_name} (Final Karaoke CDG).zip"
2051
+ elif 'txt' in key.lower():
2052
+ filename = f"{base_name} (Final Karaoke TXT).zip"
2053
+ else:
2054
+ filename = Path(blob_path).name
2055
+
2056
+ local_path = output_dir / filename
2057
+ self.logger.info(f" Downloading {filename}...")
2058
+ if download_file('packages', key, local_path, filename):
2059
+ self.logger.info(f" OK: {local_path}")
2060
+
2061
+ # Extract CDG files to match local CLI (individual .cdg and .mp3 at root)
2062
+ if 'cdg' in key.lower():
2063
+ self._extract_cdg_files(local_path, output_dir, base_name)
2064
+ else:
2065
+ self.logger.warning(f" FAILED: {filename}")
2066
+
2067
+ # Download lyrics files
2068
+ lyrics = file_urls.get('lyrics', {})
2069
+ if lyrics:
2070
+ self.logger.info("Downloading lyrics files...")
2071
+ for key in ['ass', 'lrc', 'corrected_txt']:
2072
+ blob_path = lyrics.get(key)
2073
+ if blob_path:
2074
+ ext = Path(blob_path).suffix
2075
+ filename = f"{base_name} (Karaoke){ext}"
2076
+ local_path = output_dir / filename
2077
+ self.logger.info(f" Downloading {filename}...")
2078
+ if download_file('lyrics', key, local_path, filename):
2079
+ self.logger.info(f" OK: {local_path}")
2080
+ else:
2081
+ self.logger.warning(f" FAILED: {filename}")
2082
+
2083
+ # Download title/end screen files (video + images)
2084
+ screens = file_urls.get('screens', {})
2085
+ if screens:
2086
+ self.logger.info("Downloading title/end screens...")
2087
+ screen_mappings = {
2088
+ 'title': f"{base_name} (Title).mov",
2089
+ 'title_jpg': f"{base_name} (Title).jpg",
2090
+ 'title_png': f"{base_name} (Title).png",
2091
+ 'end': f"{base_name} (End).mov",
2092
+ 'end_jpg': f"{base_name} (End).jpg",
2093
+ 'end_png': f"{base_name} (End).png",
2094
+ }
2095
+ for key, filename in screen_mappings.items():
2096
+ blob_path = screens.get(key)
2097
+ if blob_path:
2098
+ local_path = output_dir / filename
2099
+ self.logger.info(f" Downloading {filename}...")
2100
+ if download_file('screens', key, local_path, filename):
2101
+ self.logger.info(f" OK: {local_path}")
2102
+ else:
2103
+ self.logger.warning(f" FAILED: {filename}")
2104
+
2105
+ # Download with_vocals intermediate video
2106
+ videos = file_urls.get('videos', {})
2107
+ if videos:
2108
+ self.logger.info("Downloading intermediate videos...")
2109
+ if videos.get('with_vocals'):
2110
+ filename = f"{base_name} (With Vocals).mkv"
2111
+ local_path = output_dir / filename
2112
+ self.logger.info(f" Downloading {filename}...")
2113
+ if download_file('videos', 'with_vocals', local_path, filename):
2114
+ self.logger.info(f" OK: {local_path}")
2115
+ else:
2116
+ self.logger.warning(f" FAILED: {filename}")
2117
+
2118
+ # Download stems with descriptive names
2119
+ stems = file_urls.get('stems', {})
2120
+ if stems:
2121
+ stems_dir = output_dir / 'stems'
2122
+ stems_dir.mkdir(exist_ok=True)
2123
+ self.logger.info("Downloading audio stems...")
2124
+
2125
+ # Map backend stem names to local CLI naming convention
2126
+ stem_name_mappings = {
2127
+ 'instrumental_clean': f"{base_name} (Instrumental model_bs_roformer_ep_317_sdr_12.9755.ckpt).flac",
2128
+ 'instrumental_with_backing': f"{base_name} (Instrumental +BV mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt).flac",
2129
+ 'vocals_clean': f"{base_name} (Vocals model_bs_roformer_ep_317_sdr_12.9755.ckpt).flac",
2130
+ 'lead_vocals': f"{base_name} (Lead Vocals mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt).flac",
2131
+ 'backing_vocals': f"{base_name} (Backing Vocals mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt).flac",
2132
+ 'bass': f"{base_name} (Bass htdemucs_6s.yaml).flac",
2133
+ 'drums': f"{base_name} (Drums htdemucs_6s.yaml).flac",
2134
+ 'guitar': f"{base_name} (Guitar htdemucs_6s.yaml).flac",
2135
+ 'piano': f"{base_name} (Piano htdemucs_6s.yaml).flac",
2136
+ 'other': f"{base_name} (Other htdemucs_6s.yaml).flac",
2137
+ 'vocals': f"{base_name} (Vocals htdemucs_6s.yaml).flac",
2138
+ }
2139
+
2140
+ for key, blob_path in stems.items():
2141
+ if blob_path:
2142
+ # Use descriptive filename if available, otherwise use GCS filename
2143
+ filename = stem_name_mappings.get(key, Path(blob_path).name)
2144
+ local_path = stems_dir / filename
2145
+ self.logger.info(f" Downloading {filename}...")
2146
+ if download_file('stems', key, local_path, filename):
2147
+ self.logger.info(f" OK: {local_path}")
2148
+ else:
2149
+ self.logger.warning(f" FAILED: {filename}")
2150
+
2151
+ # Also copy instrumental files to root directory (matching local CLI)
2152
+ for src_key, dest_suffix in [
2153
+ ('instrumental_clean', 'Instrumental model_bs_roformer_ep_317_sdr_12.9755.ckpt'),
2154
+ ('instrumental_with_backing', 'Instrumental +BV mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt'),
2155
+ ]:
2156
+ if stems.get(src_key):
2157
+ stem_file = stems_dir / stem_name_mappings.get(src_key, '')
2158
+ if stem_file.exists():
2159
+ dest_file = output_dir / f"{base_name} ({dest_suffix}).flac"
2160
+ try:
2161
+ import shutil
2162
+ shutil.copy2(stem_file, dest_file)
2163
+ self.logger.info(f" Copied to root: {dest_file.name}")
2164
+ except Exception as e:
2165
+ self.logger.warning(f" Failed to copy {dest_file.name}: {e}")
2166
+
2167
+ self.logger.info("")
2168
+ self.logger.info(f"All files downloaded to: {output_dir}")
2169
+
2170
+ # Show summary
2171
+ state_data = job_data.get('state_data', {})
2172
+ if brand_code:
2173
+ self.logger.info(f"Brand Code: {brand_code}")
2174
+
2175
+ youtube_url = state_data.get('youtube_url')
2176
+ if youtube_url:
2177
+ self.logger.info(f"YouTube URL: {youtube_url}")
2178
+
2179
+ # List downloaded files with sizes
2180
+ self.logger.info("")
2181
+ self.logger.info("Downloaded files:")
2182
+ total_size = 0
2183
+ for file_path in sorted(output_dir.rglob('*')):
2184
+ if file_path.is_file():
2185
+ size = file_path.stat().st_size
2186
+ total_size += size
2187
+ if size > 1024 * 1024:
2188
+ size_str = f"{size / (1024 * 1024):.1f} MB"
2189
+ elif size > 1024:
2190
+ size_str = f"{size / 1024:.1f} KB"
2191
+ else:
2192
+ size_str = f"{size} B"
2193
+ rel_path = file_path.relative_to(output_dir)
2194
+ self.logger.info(f" {rel_path} ({size_str})")
2195
+
2196
+ if total_size > 1024 * 1024 * 1024:
2197
+ total_str = f"{total_size / (1024 * 1024 * 1024):.2f} GB"
2198
+ elif total_size > 1024 * 1024:
2199
+ total_str = f"{total_size / (1024 * 1024):.1f} MB"
2200
+ else:
2201
+ total_str = f"{total_size / 1024:.1f} KB"
2202
+ self.logger.info(f"Total: {total_str}")
2203
+
2204
+ def _extract_cdg_files(self, zip_path: Path, output_dir: Path, base_name: str) -> None:
2205
+ """
2206
+ Extract individual .cdg and .mp3 files from CDG ZIP to match local CLI output.
2207
+
2208
+ Local CLI produces both:
2209
+ - Artist - Title (Final Karaoke CDG).zip (containing .cdg + .mp3)
2210
+ - Artist - Title (Karaoke).cdg (individual file at root)
2211
+ - Artist - Title (Karaoke).mp3 (individual file at root)
2212
+
2213
+ Args:
2214
+ zip_path: Path to the CDG ZIP file
2215
+ output_dir: Output directory for extracted files
2216
+ base_name: Base name for output files (Artist - Title)
2217
+ """
2218
+ import zipfile
2219
+
2220
+ try:
2221
+ with zipfile.ZipFile(zip_path, 'r') as zf:
2222
+ for member in zf.namelist():
2223
+ ext = Path(member).suffix.lower()
2224
+ if ext in ['.cdg', '.mp3']:
2225
+ # Extract with correct naming
2226
+ filename = f"{base_name} (Karaoke){ext}"
2227
+ extract_path = output_dir / filename
2228
+
2229
+ # Read from zip and write to destination
2230
+ with zf.open(member) as src:
2231
+ with open(extract_path, 'wb') as dst:
2232
+ dst.write(src.read())
2233
+
2234
+ self.logger.info(f" Extracted: {filename}")
2235
+ except Exception as e:
2236
+ self.logger.warning(f" Failed to extract CDG files: {e}")
2237
+
2238
+ def log_timeline_updates(self, job_data: Dict[str, Any]) -> None:
2239
+ """Log any new timeline events."""
2240
+ timeline = job_data.get('timeline', [])
2241
+
2242
+ # Log any new events since last check
2243
+ for i, event in enumerate(timeline):
2244
+ if i >= self._last_timeline_index:
2245
+ timestamp = event.get('timestamp', '')
2246
+ status = event.get('status', '')
2247
+ message = event.get('message', '')
2248
+ progress = event.get('progress', '')
2249
+
2250
+ # Format timestamp if present
2251
+ if timestamp:
2252
+ # Truncate to just time portion if it's a full ISO timestamp
2253
+ if 'T' in timestamp:
2254
+ timestamp = timestamp.split('T')[1][:8]
2255
+
2256
+ log_parts = []
2257
+ if timestamp:
2258
+ log_parts.append(f"[{timestamp}]")
2259
+ if status:
2260
+ log_parts.append(f"[{status}]")
2261
+ if progress:
2262
+ log_parts.append(f"[{progress}%]")
2263
+ if message:
2264
+ log_parts.append(message)
2265
+
2266
+ if log_parts:
2267
+ self.logger.info(" ".join(log_parts))
2268
+
2269
+ self._last_timeline_index = len(timeline)
2270
+
2271
+ def log_worker_logs(self, job_id: str) -> None:
2272
+ """Fetch and display any new worker logs."""
2273
+ if not self._show_worker_logs:
2274
+ return
2275
+
2276
+ try:
2277
+ result = self.client.get_worker_logs(job_id, since_index=self._last_log_index)
2278
+ logs = result.get('logs', [])
2279
+
2280
+ for log_entry in logs:
2281
+ timestamp = log_entry.get('timestamp', '')
2282
+ level = log_entry.get('level', 'INFO')
2283
+ worker = log_entry.get('worker', 'worker')
2284
+ message = log_entry.get('message', '')
2285
+
2286
+ # Format timestamp (just time portion)
2287
+ if timestamp and 'T' in timestamp:
2288
+ timestamp = timestamp.split('T')[1][:8]
2289
+
2290
+ # Color-code by level (using ASCII codes for terminal)
2291
+ if level == 'ERROR':
2292
+ level_prefix = f"\033[91m{level}\033[0m" # Red
2293
+ elif level == 'WARNING':
2294
+ level_prefix = f"\033[93m{level}\033[0m" # Yellow
2295
+ else:
2296
+ level_prefix = level
2297
+
2298
+ # Format: [HH:MM:SS] [worker:level] message
2299
+ log_line = f" [{timestamp}] [{worker}:{level_prefix}] {message}"
2300
+
2301
+ # Use appropriate log level
2302
+ if level == 'ERROR':
2303
+ self.logger.error(log_line)
2304
+ elif level == 'WARNING':
2305
+ self.logger.warning(log_line)
2306
+ else:
2307
+ self.logger.info(log_line)
2308
+
2309
+ # Update index for next poll
2310
+ self._last_log_index = result.get('next_index', self._last_log_index)
2311
+
2312
+ except Exception as e:
2313
+ # Log the error but don't fail
2314
+ self.logger.debug(f"Error fetching worker logs: {e}")
2315
+
2316
+ def monitor(self, job_id: str) -> int:
2317
+ """Monitor job progress until completion."""
2318
+ last_status = ""
2319
+
2320
+ self.logger.info(f"Monitoring job: {job_id}")
2321
+ self.logger.info(f"Service URL: {self.config.service_url}")
2322
+ self.logger.info(f"Polling every {self.config.poll_interval} seconds...")
2323
+ self.logger.info("")
2324
+
2325
+ while True:
2326
+ try:
2327
+ job_data = self.client.get_job(job_id)
2328
+
2329
+ status = job_data.get('status', 'unknown')
2330
+ artist = job_data.get('artist', '')
2331
+ title = job_data.get('title', '')
2332
+
2333
+ # Track whether we got any new updates this poll
2334
+ had_updates = False
2335
+ prev_timeline_index = self._last_timeline_index
2336
+ prev_log_index = self._last_log_index
2337
+
2338
+ # Log timeline updates (shows status changes and progress)
2339
+ self.log_timeline_updates(job_data)
2340
+ if self._last_timeline_index > prev_timeline_index:
2341
+ had_updates = True
2342
+
2343
+ # Log worker logs (shows detailed worker output for debugging)
2344
+ self.log_worker_logs(job_id)
2345
+ if self._last_log_index > prev_log_index:
2346
+ had_updates = True
2347
+
2348
+ # Log status changes with user-friendly descriptions
2349
+ if status != last_status:
2350
+ description = self._get_status_description(status)
2351
+ if last_status:
2352
+ self.logger.info(f"Status: {status} - {description}")
2353
+ else:
2354
+ self.logger.info(f"Current status: {status} - {description}")
2355
+ last_status = status
2356
+ had_updates = True
2357
+
2358
+ # Heartbeat: if no updates for a while, show we're still alive
2359
+ if had_updates:
2360
+ self._polls_without_updates = 0
2361
+ else:
2362
+ self._polls_without_updates += 1
2363
+ # More frequent updates during audio download (every poll)
2364
+ heartbeat_threshold = 1 if status == 'downloading_audio' else self._heartbeat_interval
2365
+ if self._polls_without_updates >= heartbeat_threshold:
2366
+ if status == 'downloading_audio':
2367
+ # Show detailed download progress including transmission status
2368
+ self._show_download_progress(job_data)
2369
+ else:
2370
+ description = self._get_status_description(status)
2371
+ self.logger.info(f" [Still processing: {description}]")
2372
+ self._polls_without_updates = 0
2373
+
2374
+ # Handle human interaction points
2375
+ if status == 'awaiting_audio_selection':
2376
+ if not self._audio_selection_prompted:
2377
+ self.logger.info("")
2378
+ self.handle_audio_selection(job_id)
2379
+ self._audio_selection_prompted = True
2380
+ self._last_timeline_index = 0 # Reset to catch any events
2381
+
2382
+ elif status in ['awaiting_review', 'in_review']:
2383
+ if not self._review_opened:
2384
+ self.logger.info("")
2385
+ self.handle_review(job_id)
2386
+ self._review_opened = True
2387
+ self._last_timeline_index = 0 # Reset to catch any events during review
2388
+ # Refresh auth token after potentially long review
2389
+ self.client.refresh_auth()
2390
+
2391
+ elif status == 'awaiting_instrumental_selection':
2392
+ if not self._instrumental_prompted:
2393
+ self.logger.info("")
2394
+ self.handle_instrumental_selection(job_id)
2395
+ self._instrumental_prompted = True
2396
+
2397
+ elif status == 'instrumental_selected':
2398
+ # Check if this was auto-selected due to existing instrumental
2399
+ selection = job_data.get('state_data', {}).get('instrumental_selection', '')
2400
+ if selection == 'custom' and not self._instrumental_prompted:
2401
+ self.logger.info("")
2402
+ self.logger.info("Using user-provided instrumental (--existing_instrumental)")
2403
+ self._instrumental_prompted = True
2404
+
2405
+ elif status == 'complete':
2406
+ self.logger.info("")
2407
+ self.logger.info("=" * 60)
2408
+ self.logger.info("JOB COMPLETE!")
2409
+ self.logger.info("=" * 60)
2410
+ self.logger.info(f"Track: {artist} - {title}")
2411
+ self.logger.info("")
2412
+ self.download_outputs(job_id, job_data)
2413
+ return 0
2414
+
2415
+ elif status == 'prep_complete':
2416
+ self.logger.info("")
2417
+ self.logger.info("=" * 60)
2418
+ self.logger.info("PREP PHASE COMPLETE!")
2419
+ self.logger.info("=" * 60)
2420
+ self.logger.info(f"Track: {artist} - {title}")
2421
+ self.logger.info("")
2422
+ self.logger.info("Downloading all prep outputs...")
2423
+ self.download_outputs(job_id, job_data)
2424
+ self.logger.info("")
2425
+ self.logger.info("To continue with finalisation, run:")
2426
+ # Use shlex.quote for proper shell escaping of artist/title
2427
+ import shlex
2428
+ escaped_artist = shlex.quote(artist)
2429
+ escaped_title = shlex.quote(title)
2430
+ self.logger.info(f" karaoke-gen-remote --finalise-only ./<output_folder> {escaped_artist} {escaped_title}")
2431
+ return 0
2432
+
2433
+ elif status in ['failed', 'error']:
2434
+ self.logger.info("")
2435
+ self.logger.error("=" * 60)
2436
+ self.logger.error("JOB FAILED")
2437
+ self.logger.error("=" * 60)
2438
+ error_message = job_data.get('error_message', 'Unknown error')
2439
+ self.logger.error(f"Error: {error_message}")
2440
+ error_details = job_data.get('error_details')
2441
+ if error_details:
2442
+ self.logger.error(f"Details: {json.dumps(error_details, indent=2)}")
2443
+ return 1
2444
+
2445
+ elif status == 'cancelled':
2446
+ self.logger.info("")
2447
+ self.logger.warning("Job was cancelled")
2448
+ return 1
2449
+
2450
+ time.sleep(self.config.poll_interval)
2451
+
2452
+ except KeyboardInterrupt:
2453
+ self.logger.info("")
2454
+ self.logger.warning(f"Monitoring interrupted. Job ID: {job_id}")
2455
+ self.logger.info(f"Resume with: karaoke-gen-remote --resume {job_id}")
2456
+ return 130
2457
+ except Exception as e:
2458
+ self.logger.warning(f"Error polling job status: {e}")
2459
+ time.sleep(self.config.poll_interval)
2460
+
2461
+
2462
+ def check_prerequisites(logger: logging.Logger) -> bool:
2463
+ """Check that required tools are available."""
2464
+ # Check for gcloud
2465
+ try:
2466
+ subprocess.run(['gcloud', '--version'], capture_output=True, check=True)
2467
+ except (subprocess.CalledProcessError, FileNotFoundError):
2468
+ logger.warning("gcloud CLI not found. Authentication may be limited.")
2469
+
2470
+ # Check for gsutil
2471
+ try:
2472
+ subprocess.run(['gsutil', 'version'], capture_output=True, check=True)
2473
+ except (subprocess.CalledProcessError, FileNotFoundError):
2474
+ logger.warning("gsutil not found. File downloads may fail. Install with: pip install gsutil")
2475
+
2476
+ return True
2477
+
2478
+
2479
+ def get_auth_token(logger: logging.Logger) -> Optional[str]:
2480
+ """Get authentication token from environment or gcloud."""
2481
+ # Check environment variable first
2482
+ token = os.environ.get('KARAOKE_GEN_AUTH_TOKEN')
2483
+ if token:
2484
+ return token
2485
+
2486
+ # Try gcloud
2487
+ try:
2488
+ result = subprocess.run(
2489
+ ['gcloud', 'auth', 'print-identity-token'],
2490
+ capture_output=True,
2491
+ text=True,
2492
+ check=True
2493
+ )
2494
+ return result.stdout.strip()
2495
+ except (subprocess.CalledProcessError, FileNotFoundError):
2496
+ return None
2497
+
2498
+
2499
+ def main():
2500
+ """Main entry point for the remote CLI."""
2501
+ # Set up logging - same format as gen_cli.py
2502
+ logger = logging.getLogger(__name__)
2503
+ log_handler = logging.StreamHandler()
2504
+ log_formatter = logging.Formatter(
2505
+ fmt="%(asctime)s.%(msecs)03d - %(levelname)s - %(module)s - %(message)s",
2506
+ datefmt="%Y-%m-%d %H:%M:%S"
2507
+ )
2508
+ log_handler.setFormatter(log_formatter)
2509
+ logger.addHandler(log_handler)
2510
+
2511
+ # Use shared CLI parser
2512
+ parser = create_parser(prog="karaoke-gen-remote")
2513
+ args = parser.parse_args()
2514
+
2515
+ # Set log level
2516
+ log_level = getattr(logging, args.log_level.upper())
2517
+ logger.setLevel(log_level)
2518
+
2519
+ # Check for KARAOKE_GEN_URL - this is REQUIRED for remote mode
2520
+ if not args.service_url:
2521
+ logger.error("KARAOKE_GEN_URL environment variable is required for karaoke-gen-remote")
2522
+ logger.error("")
2523
+ logger.error("Please set it to your cloud backend URL:")
2524
+ logger.error(" export KARAOKE_GEN_URL=https://your-backend.run.app")
2525
+ logger.error("")
2526
+ logger.error("Or pass it via command line:")
2527
+ logger.error(" karaoke-gen-remote --service-url https://your-backend.run.app ...")
2528
+ return 1
2529
+
2530
+ # Check prerequisites
2531
+ check_prerequisites(logger)
2532
+
2533
+ # Get auth token from environment variable
2534
+ auth_token = get_auth_token(logger)
2535
+
2536
+ # Create config
2537
+ config = Config(
2538
+ service_url=args.service_url.rstrip('/'),
2539
+ review_ui_url=args.review_ui_url.rstrip('/'),
2540
+ poll_interval=args.poll_interval,
2541
+ output_dir=args.output_dir,
2542
+ auth_token=auth_token,
2543
+ non_interactive=getattr(args, 'yes', False), # -y / --yes flag
2544
+ # Job tracking metadata
2545
+ environment=getattr(args, 'environment', ''),
2546
+ client_id=getattr(args, 'client_id', ''),
2547
+ )
2548
+
2549
+ # Create client
2550
+ client = RemoteKaraokeClient(config, logger)
2551
+ monitor = JobMonitor(client, config, logger)
2552
+
2553
+ # Handle resume mode
2554
+ if args.resume:
2555
+ logger.info("=" * 60)
2556
+ logger.info("Karaoke Generator (Remote) - Resume Job")
2557
+ logger.info("=" * 60)
2558
+ logger.info(f"Job ID: {args.resume}")
2559
+
2560
+ try:
2561
+ # Verify job exists
2562
+ job_data = client.get_job(args.resume)
2563
+ artist = job_data.get('artist', 'Unknown')
2564
+ title = job_data.get('title', 'Unknown')
2565
+ status = job_data.get('status', 'unknown')
2566
+
2567
+ logger.info(f"Artist: {artist}")
2568
+ logger.info(f"Title: {title}")
2569
+ logger.info(f"Current status: {status}")
2570
+ logger.info("")
2571
+
2572
+ return monitor.monitor(args.resume)
2573
+ except ValueError as e:
2574
+ logger.error(str(e))
2575
+ return 1
2576
+ except Exception as e:
2577
+ logger.error(f"Error resuming job: {e}")
2578
+ return 1
2579
+
2580
+ # Handle bulk delete mode
2581
+ if getattr(args, 'bulk_delete', False):
2582
+ filter_env = getattr(args, 'filter_environment', None)
2583
+ filter_client = getattr(args, 'filter_client_id', None)
2584
+
2585
+ if not filter_env and not filter_client:
2586
+ logger.error("Bulk delete requires at least one filter: --filter-environment or --filter-client-id")
2587
+ return 1
2588
+
2589
+ logger.info("=" * 60)
2590
+ logger.info("Karaoke Generator (Remote) - Bulk Delete Jobs")
2591
+ logger.info("=" * 60)
2592
+ if filter_env:
2593
+ logger.info(f"Environment filter: {filter_env}")
2594
+ if filter_client:
2595
+ logger.info(f"Client ID filter: {filter_client}")
2596
+ logger.info("")
2597
+
2598
+ try:
2599
+ # First get preview
2600
+ result = client.bulk_delete_jobs(
2601
+ environment=filter_env,
2602
+ client_id=filter_client,
2603
+ confirm=False
2604
+ )
2605
+
2606
+ jobs_to_delete = result.get('jobs_to_delete', 0)
2607
+ sample_jobs = result.get('sample_jobs', [])
2608
+
2609
+ if jobs_to_delete == 0:
2610
+ logger.info("No jobs match the specified filters.")
2611
+ return 0
2612
+
2613
+ logger.info(f"Found {jobs_to_delete} jobs matching filters:")
2614
+ logger.info("")
2615
+
2616
+ # Show sample
2617
+ for job in sample_jobs:
2618
+ logger.info(f" {job.get('job_id', 'unknown')[:10]}: {job.get('artist', 'Unknown')} - {job.get('title', 'Unknown')} ({job.get('status', 'unknown')})")
2619
+
2620
+ if len(sample_jobs) < jobs_to_delete:
2621
+ logger.info(f" ... and {jobs_to_delete - len(sample_jobs)} more")
2622
+
2623
+ logger.info("")
2624
+
2625
+ # Confirm unless -y flag is set
2626
+ if not config.non_interactive:
2627
+ confirm = input(f"Are you sure you want to delete {jobs_to_delete} jobs and all their files? [y/N]: ")
2628
+ if confirm.lower() != 'y':
2629
+ logger.info("Bulk deletion cancelled.")
2630
+ return 0
2631
+
2632
+ # Execute deletion
2633
+ result = client.bulk_delete_jobs(
2634
+ environment=filter_env,
2635
+ client_id=filter_client,
2636
+ confirm=True
2637
+ )
2638
+
2639
+ logger.info(f"✓ Deleted {result.get('jobs_deleted', 0)} jobs")
2640
+ if result.get('files_deleted'):
2641
+ logger.info(f"✓ Cleaned up files from {result.get('files_deleted', 0)} jobs")
2642
+ return 0
2643
+
2644
+ except Exception as e:
2645
+ logger.error(f"Error bulk deleting jobs: {e}")
2646
+ return 1
2647
+
2648
+ # Handle list jobs mode
2649
+ if getattr(args, 'list_jobs', False):
2650
+ filter_env = getattr(args, 'filter_environment', None)
2651
+ filter_client = getattr(args, 'filter_client_id', None)
2652
+
2653
+ logger.info("=" * 60)
2654
+ logger.info("Karaoke Generator (Remote) - List Jobs")
2655
+ logger.info("=" * 60)
2656
+ if filter_env:
2657
+ logger.info(f"Environment filter: {filter_env}")
2658
+ if filter_client:
2659
+ logger.info(f"Client ID filter: {filter_client}")
2660
+ logger.info("")
2661
+
2662
+ try:
2663
+ jobs = client.list_jobs(
2664
+ environment=filter_env,
2665
+ client_id=filter_client,
2666
+ limit=100
2667
+ )
2668
+
2669
+ if not jobs:
2670
+ logger.info("No jobs found.")
2671
+ return 0
2672
+
2673
+ # Print header - include environment/client if available
2674
+ logger.info(f"{'JOB ID':<12} {'STATUS':<25} {'ENV':<8} {'ARTIST':<18} {'TITLE':<25}")
2675
+ logger.info("-" * 92)
2676
+
2677
+ # Print each job
2678
+ for job in jobs:
2679
+ # Use 'or' to handle None values (not just missing keys)
2680
+ job_id = (job.get('job_id') or 'unknown')[:10]
2681
+ status = (job.get('status') or 'unknown')[:23]
2682
+ artist = (job.get('artist') or 'Unknown')[:16]
2683
+ title = (job.get('title') or 'Unknown')[:23]
2684
+ # Get environment from request_metadata
2685
+ req_metadata = job.get('request_metadata') or {}
2686
+ env = (req_metadata.get('environment') or '-')[:6]
2687
+ logger.info(f"{job_id:<12} {status:<25} {env:<8} {artist:<18} {title:<25}")
2688
+
2689
+ logger.info("")
2690
+ logger.info(f"Total: {len(jobs)} jobs")
2691
+ logger.info("")
2692
+ logger.info("To retry a failed job: karaoke-gen-remote --retry <JOB_ID>")
2693
+ logger.info("To delete a job: karaoke-gen-remote --delete <JOB_ID>")
2694
+ logger.info("To bulk delete: karaoke-gen-remote --bulk-delete --filter-environment=test")
2695
+ logger.info("To cancel a job: karaoke-gen-remote --cancel <JOB_ID>")
2696
+ return 0
2697
+
2698
+ except Exception as e:
2699
+ logger.error(f"Error listing jobs: {e}")
2700
+ return 1
2701
+
2702
+ # Handle cancel job mode
2703
+ if args.cancel:
2704
+ logger.info("=" * 60)
2705
+ logger.info("Karaoke Generator (Remote) - Cancel Job")
2706
+ logger.info("=" * 60)
2707
+ logger.info(f"Job ID: {args.cancel}")
2708
+
2709
+ try:
2710
+ # Get job info first
2711
+ job_data = client.get_job(args.cancel)
2712
+ artist = job_data.get('artist', 'Unknown')
2713
+ title = job_data.get('title', 'Unknown')
2714
+ status = job_data.get('status', 'unknown')
2715
+
2716
+ logger.info(f"Artist: {artist}")
2717
+ logger.info(f"Title: {title}")
2718
+ logger.info(f"Current status: {status}")
2719
+ logger.info("")
2720
+
2721
+ # Cancel the job
2722
+ result = client.cancel_job(args.cancel)
2723
+ logger.info(f"✓ Job cancelled successfully")
2724
+ return 0
2725
+
2726
+ except ValueError as e:
2727
+ logger.error(str(e))
2728
+ return 1
2729
+ except RuntimeError as e:
2730
+ logger.error(str(e))
2731
+ return 1
2732
+ except Exception as e:
2733
+ logger.error(f"Error cancelling job: {e}")
2734
+ return 1
2735
+
2736
+ # Handle retry job mode
2737
+ if args.retry:
2738
+ logger.info("=" * 60)
2739
+ logger.info("Karaoke Generator (Remote) - Retry Failed Job")
2740
+ logger.info("=" * 60)
2741
+ logger.info(f"Job ID: {args.retry}")
2742
+
2743
+ try:
2744
+ # Get job info first
2745
+ job_data = client.get_job(args.retry)
2746
+ artist = job_data.get('artist', 'Unknown')
2747
+ title = job_data.get('title', 'Unknown')
2748
+ status = job_data.get('status', 'unknown')
2749
+ error_message = job_data.get('error_message', 'No error message')
2750
+
2751
+ logger.info(f"Artist: {artist}")
2752
+ logger.info(f"Title: {title}")
2753
+ logger.info(f"Current status: {status}")
2754
+ if status == 'failed':
2755
+ logger.info(f"Error: {error_message}")
2756
+ logger.info("")
2757
+
2758
+ if status != 'failed':
2759
+ logger.error(f"Only failed jobs can be retried (current status: {status})")
2760
+ return 1
2761
+
2762
+ # Retry the job
2763
+ result = client.retry_job(args.retry)
2764
+ retry_stage = result.get('retry_stage', 'unknown')
2765
+ logger.info(f"✓ Job retry started from stage: {retry_stage}")
2766
+ logger.info("")
2767
+ logger.info(f"Monitoring job progress...")
2768
+ logger.info("")
2769
+
2770
+ # Monitor the retried job
2771
+ return monitor.monitor(args.retry)
2772
+
2773
+ except ValueError as e:
2774
+ logger.error(str(e))
2775
+ return 1
2776
+ except RuntimeError as e:
2777
+ logger.error(str(e))
2778
+ return 1
2779
+ except Exception as e:
2780
+ logger.error(f"Error retrying job: {e}")
2781
+ return 1
2782
+
2783
+ # Handle delete job mode
2784
+ if args.delete:
2785
+ logger.info("=" * 60)
2786
+ logger.info("Karaoke Generator (Remote) - Delete Job")
2787
+ logger.info("=" * 60)
2788
+ logger.info(f"Job ID: {args.delete}")
2789
+
2790
+ try:
2791
+ # Get job info first
2792
+ job_data = client.get_job(args.delete)
2793
+ artist = job_data.get('artist', 'Unknown')
2794
+ title = job_data.get('title', 'Unknown')
2795
+ status = job_data.get('status', 'unknown')
2796
+
2797
+ logger.info(f"Artist: {artist}")
2798
+ logger.info(f"Title: {title}")
2799
+ logger.info(f"Status: {status}")
2800
+ logger.info("")
2801
+
2802
+ # Confirm deletion unless -y flag is set
2803
+ if not config.non_interactive:
2804
+ confirm = input("Are you sure you want to delete this job and all its files? [y/N]: ")
2805
+ if confirm.lower() != 'y':
2806
+ logger.info("Deletion cancelled.")
2807
+ return 0
2808
+
2809
+ # Delete the job
2810
+ result = client.delete_job(args.delete, delete_files=True)
2811
+ logger.info(f"✓ Job deleted successfully (including all files)")
2812
+ return 0
2813
+
2814
+ except ValueError as e:
2815
+ logger.error(str(e))
2816
+ return 1
2817
+ except Exception as e:
2818
+ logger.error(f"Error deleting job: {e}")
2819
+ return 1
2820
+
2821
+ # Handle finalise-only mode (Batch 6)
2822
+ if args.finalise_only:
2823
+ logger.info("=" * 60)
2824
+ logger.info("Karaoke Generator (Remote) - Finalise Only Mode")
2825
+ logger.info("=" * 60)
2826
+
2827
+ # For finalise-only, we expect the current directory to be the prep output folder
2828
+ # OR a folder path as the first argument
2829
+ prep_folder = "."
2830
+ artist_arg_idx = 0
2831
+
2832
+ if args.args:
2833
+ # Check if first argument is a directory
2834
+ if os.path.isdir(args.args[0]):
2835
+ prep_folder = args.args[0]
2836
+ artist_arg_idx = 1
2837
+
2838
+ # Get artist and title from arguments
2839
+ if len(args.args) > artist_arg_idx + 1:
2840
+ artist = args.args[artist_arg_idx]
2841
+ title = args.args[artist_arg_idx + 1]
2842
+ elif len(args.args) > artist_arg_idx:
2843
+ logger.error("Finalise-only mode requires both Artist and Title")
2844
+ return 1
2845
+ else:
2846
+ # Try to extract from folder name
2847
+ folder_name = os.path.basename(os.path.abspath(prep_folder))
2848
+ parts = folder_name.split(" - ", 2)
2849
+ if len(parts) >= 2:
2850
+ # Format: "BRAND-XXXX - Artist - Title" or "Artist - Title"
2851
+ if "-" in parts[0] and parts[0].split("-")[1].isdigit():
2852
+ # Has brand code
2853
+ artist = parts[1] if len(parts) > 2 else "Unknown"
2854
+ title = parts[2] if len(parts) > 2 else parts[1]
2855
+ else:
2856
+ artist = parts[0]
2857
+ title = parts[1]
2858
+ logger.info(f"Extracted from folder name: {artist} - {title}")
2859
+ else:
2860
+ logger.error("Could not extract Artist and Title from folder name")
2861
+ logger.error("Please provide: karaoke-gen-remote --finalise-only <folder> \"Artist\" \"Title\"")
2862
+ return 1
2863
+ else:
2864
+ logger.error("Finalise-only mode requires folder path and/or Artist and Title")
2865
+ return 1
2866
+
2867
+ # Extract brand code from folder name if --keep-brand-code is set
2868
+ keep_brand_code = None
2869
+ if getattr(args, 'keep_brand_code', False):
2870
+ folder_name = os.path.basename(os.path.abspath(prep_folder))
2871
+ parts = folder_name.split(" - ", 1)
2872
+ if parts and "-" in parts[0]:
2873
+ # Check if it's a brand code format (e.g., "NOMAD-1234")
2874
+ potential_brand = parts[0]
2875
+ brand_parts = potential_brand.split("-")
2876
+ if len(brand_parts) == 2 and brand_parts[1].isdigit():
2877
+ keep_brand_code = potential_brand
2878
+ logger.info(f"Preserving brand code: {keep_brand_code}")
2879
+
2880
+ logger.info(f"Prep folder: {os.path.abspath(prep_folder)}")
2881
+ logger.info(f"Artist: {artist}")
2882
+ logger.info(f"Title: {title}")
2883
+ if keep_brand_code:
2884
+ logger.info(f"Brand Code: {keep_brand_code} (preserved)")
2885
+ logger.info("")
2886
+
2887
+ # Read youtube description from file if provided
2888
+ youtube_description = None
2889
+ if args.youtube_description_file and os.path.isfile(args.youtube_description_file):
2890
+ try:
2891
+ with open(args.youtube_description_file, 'r') as f:
2892
+ youtube_description = f.read()
2893
+ except Exception as e:
2894
+ logger.warning(f"Failed to read YouTube description file: {e}")
2895
+
2896
+ try:
2897
+ result = client.submit_finalise_only_job(
2898
+ prep_folder=prep_folder,
2899
+ artist=artist,
2900
+ title=title,
2901
+ enable_cdg=args.enable_cdg,
2902
+ enable_txt=args.enable_txt,
2903
+ brand_prefix=args.brand_prefix,
2904
+ keep_brand_code=keep_brand_code,
2905
+ discord_webhook_url=args.discord_webhook_url,
2906
+ youtube_description=youtube_description,
2907
+ enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
2908
+ dropbox_path=getattr(args, 'dropbox_path', None),
2909
+ gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
2910
+ )
2911
+ job_id = result.get('job_id')
2912
+ logger.info(f"Finalise-only job submitted: {job_id}")
2913
+ logger.info("")
2914
+
2915
+ # Monitor job
2916
+ return monitor.monitor(job_id)
2917
+
2918
+ except FileNotFoundError as e:
2919
+ logger.error(str(e))
2920
+ return 1
2921
+ except RuntimeError as e:
2922
+ logger.error(str(e))
2923
+ return 1
2924
+ except Exception as e:
2925
+ logger.error(f"Error: {e}")
2926
+ return 1
2927
+
2928
+ if args.edit_lyrics:
2929
+ logger.error("--edit-lyrics is not yet supported in remote mode")
2930
+ return 1
2931
+
2932
+ if args.test_email_template:
2933
+ logger.error("--test_email_template is not supported in remote mode")
2934
+ return 1
2935
+
2936
+ # Warn about features that are not yet supported in remote mode
2937
+ ignored_features = []
2938
+ # Note: --prep-only is now supported in remote mode (Batch 6)
2939
+ if args.skip_separation:
2940
+ ignored_features.append("--skip-separation")
2941
+ if args.skip_transcription:
2942
+ ignored_features.append("--skip-transcription")
2943
+ if args.lyrics_only:
2944
+ ignored_features.append("--lyrics-only")
2945
+ if args.background_video:
2946
+ ignored_features.append("--background_video")
2947
+ # --auto-download is now supported (Batch 5)
2948
+ # These are now supported but server-side handling may be partial
2949
+ if args.organised_dir:
2950
+ ignored_features.append("--organised_dir (local-only)")
2951
+ # organised_dir_rclone_root is now supported in remote mode
2952
+ if args.public_share_dir:
2953
+ ignored_features.append("--public_share_dir (local-only)")
2954
+ if args.youtube_client_secrets_file:
2955
+ ignored_features.append("--youtube_client_secrets_file (not yet implemented)")
2956
+ if args.rclone_destination:
2957
+ ignored_features.append("--rclone_destination (local-only)")
2958
+ if args.email_template_file:
2959
+ ignored_features.append("--email_template_file (not yet implemented)")
2960
+
2961
+ if ignored_features:
2962
+ logger.warning(f"The following options are not yet supported in remote mode and will be ignored:")
2963
+ for feature in ignored_features:
2964
+ logger.warning(f" - {feature}")
2965
+
2966
+ # Handle new job submission - parse input arguments same as gen_cli
2967
+ input_media, artist, title, filename_pattern = None, None, None, None
2968
+ use_audio_search = False # Batch 5: audio search mode
2969
+ is_url_input = False
2970
+
2971
+ if not args.args:
2972
+ parser.print_help()
2973
+ return 1
2974
+
2975
+ # Allow 3 forms of positional arguments:
2976
+ # 1. URL or Media File only
2977
+ # 2. Artist and Title only (audio search mode - Batch 5)
2978
+ # 3. URL/File, Artist, and Title
2979
+ if args.args and (is_url(args.args[0]) or is_file(args.args[0])):
2980
+ input_media = args.args[0]
2981
+ is_url_input = is_url(args.args[0])
2982
+ if len(args.args) > 2:
2983
+ artist = args.args[1]
2984
+ title = args.args[2]
2985
+ elif len(args.args) > 1:
2986
+ artist = args.args[1]
2987
+ else:
2988
+ # For URLs, artist/title can be auto-detected
2989
+ if is_url_input:
2990
+ logger.info("URL provided without Artist and Title - will be auto-detected from video metadata")
2991
+ else:
2992
+ logger.error("Input media provided without Artist and Title")
2993
+ return 1
2994
+ elif os.path.isdir(args.args[0]):
2995
+ logger.error("Folder processing is not yet supported in remote mode")
2996
+ return 1
2997
+ elif len(args.args) > 1:
2998
+ # Audio search mode: artist + title without file (Batch 5)
2999
+ artist = args.args[0]
3000
+ title = args.args[1]
3001
+ use_audio_search = True
3002
+ else:
3003
+ parser.print_help()
3004
+ return 1
3005
+
3006
+ # Validate artist and title are provided
3007
+ if not artist or not title:
3008
+ logger.error("Artist and Title are required")
3009
+ parser.print_help()
3010
+ return 1
3011
+
3012
+ # For file/URL input modes, validate input exists
3013
+ if not use_audio_search:
3014
+ if not input_media:
3015
+ logger.error("No input media or URL provided")
3016
+ return 1
3017
+
3018
+ # For file input (not URL), validate file exists
3019
+ if not is_url_input and not os.path.isfile(input_media):
3020
+ logger.error(f"File not found: {input_media}")
3021
+ logger.error("Please provide a valid path to an audio file (mp3, wav, flac, m4a, ogg, aac)")
3022
+ return 1
3023
+
3024
+ # Handle audio search mode (Batch 5)
3025
+ if use_audio_search:
3026
+ logger.info("=" * 60)
3027
+ logger.info("Karaoke Generator (Remote) - Audio Search Mode")
3028
+ logger.info("=" * 60)
3029
+ logger.info(f"Searching for: {artist} - {title}")
3030
+ if getattr(args, 'auto_download', False) or config.non_interactive:
3031
+ logger.info(f"Auto-download: enabled (will auto-select best source)")
3032
+ if args.style_params_json:
3033
+ logger.info(f"Style: {args.style_params_json}")
3034
+ logger.info(f"CDG: {args.enable_cdg}, TXT: {args.enable_txt}")
3035
+ if args.brand_prefix:
3036
+ logger.info(f"Brand: {args.brand_prefix}")
3037
+ logger.info(f"Service URL: {config.service_url}")
3038
+ logger.info("")
3039
+
3040
+ # Read youtube description from file if provided
3041
+ youtube_description = None
3042
+ if args.youtube_description_file and os.path.isfile(args.youtube_description_file):
3043
+ try:
3044
+ with open(args.youtube_description_file, 'r') as f:
3045
+ youtube_description = f.read()
3046
+ logger.info(f"Loaded YouTube description from: {args.youtube_description_file}")
3047
+ except Exception as e:
3048
+ logger.warning(f"Failed to read YouTube description file: {e}")
3049
+
3050
+ try:
3051
+ # Determine auto_download mode
3052
+ auto_download = getattr(args, 'auto_download', False) or config.non_interactive
3053
+
3054
+ result = client.search_audio(
3055
+ artist=artist,
3056
+ title=title,
3057
+ auto_download=auto_download,
3058
+ style_params_path=args.style_params_json,
3059
+ enable_cdg=args.enable_cdg,
3060
+ enable_txt=args.enable_txt,
3061
+ brand_prefix=args.brand_prefix,
3062
+ discord_webhook_url=args.discord_webhook_url,
3063
+ youtube_description=youtube_description,
3064
+ enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
3065
+ dropbox_path=getattr(args, 'dropbox_path', None),
3066
+ gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
3067
+ lyrics_artist=getattr(args, 'lyrics_artist', None),
3068
+ lyrics_title=getattr(args, 'lyrics_title', None),
3069
+ subtitle_offset_ms=getattr(args, 'subtitle_offset_ms', 0) or 0,
3070
+ clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
3071
+ backing_vocals_models=getattr(args, 'backing_vocals_models', None),
3072
+ other_stems_models=getattr(args, 'other_stems_models', None),
3073
+ )
3074
+
3075
+ job_id = result.get('job_id')
3076
+ results_count = result.get('results_count', 0)
3077
+ server_version = result.get('server_version', 'unknown')
3078
+
3079
+ logger.info(f"Job created: {job_id}")
3080
+ logger.info(f"Server version: {server_version}")
3081
+ logger.info(f"Audio sources found: {results_count}")
3082
+ logger.info("")
3083
+
3084
+ # Monitor job
3085
+ return monitor.monitor(job_id)
3086
+
3087
+ except ValueError as e:
3088
+ logger.error(str(e))
3089
+ return 1
3090
+ except Exception as e:
3091
+ logger.error(f"Error: {e}")
3092
+ logger.exception("Full error details:")
3093
+ return 1
3094
+
3095
+ # File upload mode (original flow)
3096
+ logger.info("=" * 60)
3097
+ logger.info("Karaoke Generator (Remote) - Job Submission")
3098
+ logger.info("=" * 60)
3099
+ if is_url_input:
3100
+ logger.info(f"URL: {input_media}")
3101
+ else:
3102
+ logger.info(f"File: {input_media}")
3103
+ if artist:
3104
+ logger.info(f"Artist: {artist}")
3105
+ if title:
3106
+ logger.info(f"Title: {title}")
3107
+ if not artist and not title and is_url_input:
3108
+ logger.info(f"Artist/Title: (will be auto-detected from URL)")
3109
+ if args.style_params_json:
3110
+ logger.info(f"Style: {args.style_params_json}")
3111
+ logger.info(f"CDG: {args.enable_cdg}, TXT: {args.enable_txt}")
3112
+ if args.brand_prefix:
3113
+ logger.info(f"Brand: {args.brand_prefix}")
3114
+ if getattr(args, 'enable_youtube_upload', False):
3115
+ logger.info(f"YouTube Upload: enabled (server-side)")
3116
+ # Native API distribution (preferred for remote CLI)
3117
+ if getattr(args, 'dropbox_path', None):
3118
+ logger.info(f"Dropbox (native): {args.dropbox_path}")
3119
+ if getattr(args, 'gdrive_folder_id', None):
3120
+ logger.info(f"Google Drive (native): {args.gdrive_folder_id}")
3121
+ # Legacy rclone distribution
3122
+ if args.organised_dir_rclone_root:
3123
+ logger.info(f"Dropbox (rclone): {args.organised_dir_rclone_root}")
3124
+ if args.discord_webhook_url:
3125
+ logger.info(f"Discord: enabled")
3126
+ # Lyrics configuration
3127
+ if getattr(args, 'lyrics_artist', None):
3128
+ logger.info(f"Lyrics Artist Override: {args.lyrics_artist}")
3129
+ if getattr(args, 'lyrics_title', None):
3130
+ logger.info(f"Lyrics Title Override: {args.lyrics_title}")
3131
+ if getattr(args, 'lyrics_file', None):
3132
+ logger.info(f"Lyrics File: {args.lyrics_file}")
3133
+ if getattr(args, 'subtitle_offset_ms', 0):
3134
+ logger.info(f"Subtitle Offset: {args.subtitle_offset_ms}ms")
3135
+ # Audio model configuration
3136
+ if getattr(args, 'clean_instrumental_model', None):
3137
+ logger.info(f"Clean Instrumental Model: {args.clean_instrumental_model}")
3138
+ if getattr(args, 'backing_vocals_models', None):
3139
+ logger.info(f"Backing Vocals Models: {args.backing_vocals_models}")
3140
+ if getattr(args, 'other_stems_models', None):
3141
+ logger.info(f"Other Stems Models: {args.other_stems_models}")
3142
+ if getattr(args, 'existing_instrumental', None):
3143
+ logger.info(f"Existing Instrumental: {args.existing_instrumental}")
3144
+ if getattr(args, 'prep_only', False):
3145
+ logger.info(f"Mode: prep-only (will stop after review)")
3146
+ logger.info(f"Service URL: {config.service_url}")
3147
+ logger.info(f"Review UI: {config.review_ui_url}")
3148
+ if config.non_interactive:
3149
+ logger.info(f"Non-interactive mode: enabled (will auto-accept defaults)")
3150
+ logger.info("")
3151
+
3152
+ # Read youtube description from file if provided
3153
+ youtube_description = None
3154
+ if args.youtube_description_file and os.path.isfile(args.youtube_description_file):
3155
+ try:
3156
+ with open(args.youtube_description_file, 'r') as f:
3157
+ youtube_description = f.read()
3158
+ logger.info(f"Loaded YouTube description from: {args.youtube_description_file}")
3159
+ except Exception as e:
3160
+ logger.warning(f"Failed to read YouTube description file: {e}")
3161
+
3162
+ # Extract brand code from current directory if --keep-brand-code is set
3163
+ keep_brand_code_value = None
3164
+ if getattr(args, 'keep_brand_code', False):
3165
+ cwd_name = os.path.basename(os.getcwd())
3166
+ parts = cwd_name.split(" - ", 1)
3167
+ if parts and "-" in parts[0]:
3168
+ potential_brand = parts[0]
3169
+ brand_parts = potential_brand.split("-")
3170
+ if len(brand_parts) == 2 and brand_parts[1].isdigit():
3171
+ keep_brand_code_value = potential_brand
3172
+ logger.info(f"Preserving brand code: {keep_brand_code_value}")
3173
+
3174
+ try:
3175
+ # Submit job - different endpoint for URL vs file
3176
+ if is_url_input:
3177
+ # URL-based job submission
3178
+ # Note: style_params_path is not supported for URL-based jobs
3179
+ # If custom styles are needed, download the audio locally first
3180
+ if args.style_params_json:
3181
+ logger.warning("Custom styles (--style_params_json) are not supported for URL-based jobs. "
3182
+ "Download the audio locally first and use file upload for custom styles.")
3183
+
3184
+ result = client.submit_job_from_url(
3185
+ url=input_media,
3186
+ artist=artist,
3187
+ title=title,
3188
+ enable_cdg=args.enable_cdg,
3189
+ enable_txt=args.enable_txt,
3190
+ brand_prefix=args.brand_prefix,
3191
+ discord_webhook_url=args.discord_webhook_url,
3192
+ youtube_description=youtube_description,
3193
+ organised_dir_rclone_root=args.organised_dir_rclone_root,
3194
+ enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
3195
+ # Native API distribution (preferred for remote CLI)
3196
+ dropbox_path=getattr(args, 'dropbox_path', None),
3197
+ gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
3198
+ # Lyrics configuration
3199
+ lyrics_artist=getattr(args, 'lyrics_artist', None),
3200
+ lyrics_title=getattr(args, 'lyrics_title', None),
3201
+ subtitle_offset_ms=getattr(args, 'subtitle_offset_ms', 0) or 0,
3202
+ # Audio separation model configuration
3203
+ clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
3204
+ backing_vocals_models=getattr(args, 'backing_vocals_models', None),
3205
+ other_stems_models=getattr(args, 'other_stems_models', None),
3206
+ # Two-phase workflow (Batch 6)
3207
+ prep_only=getattr(args, 'prep_only', False),
3208
+ keep_brand_code=keep_brand_code_value,
3209
+ )
3210
+ else:
3211
+ # File-based job submission
3212
+ result = client.submit_job(
3213
+ filepath=input_media,
3214
+ artist=artist,
3215
+ title=title,
3216
+ style_params_path=args.style_params_json,
3217
+ enable_cdg=args.enable_cdg,
3218
+ enable_txt=args.enable_txt,
3219
+ brand_prefix=args.brand_prefix,
3220
+ discord_webhook_url=args.discord_webhook_url,
3221
+ youtube_description=youtube_description,
3222
+ organised_dir_rclone_root=args.organised_dir_rclone_root,
3223
+ enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
3224
+ # Native API distribution (preferred for remote CLI)
3225
+ dropbox_path=getattr(args, 'dropbox_path', None),
3226
+ gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
3227
+ # Lyrics configuration
3228
+ lyrics_artist=getattr(args, 'lyrics_artist', None),
3229
+ lyrics_title=getattr(args, 'lyrics_title', None),
3230
+ lyrics_file=getattr(args, 'lyrics_file', None),
3231
+ subtitle_offset_ms=getattr(args, 'subtitle_offset_ms', 0) or 0,
3232
+ # Audio separation model configuration
3233
+ clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
3234
+ backing_vocals_models=getattr(args, 'backing_vocals_models', None),
3235
+ other_stems_models=getattr(args, 'other_stems_models', None),
3236
+ # Existing instrumental (Batch 3)
3237
+ existing_instrumental=getattr(args, 'existing_instrumental', None),
3238
+ # Two-phase workflow (Batch 6)
3239
+ prep_only=getattr(args, 'prep_only', False),
3240
+ keep_brand_code=keep_brand_code_value,
3241
+ )
3242
+ job_id = result.get('job_id')
3243
+ style_assets = result.get('style_assets_uploaded', [])
3244
+ server_version = result.get('server_version', 'unknown')
3245
+
3246
+ logger.info(f"Job submitted successfully: {job_id}")
3247
+ logger.info(f"Server version: {server_version}")
3248
+ if style_assets:
3249
+ logger.info(f"Style assets uploaded: {', '.join(style_assets)}")
3250
+ logger.info("")
3251
+
3252
+ # Monitor job
3253
+ return monitor.monitor(job_id)
3254
+
3255
+ except FileNotFoundError as e:
3256
+ logger.error(str(e))
3257
+ return 1
3258
+ except ValueError as e:
3259
+ logger.error(str(e))
3260
+ return 1
3261
+ except Exception as e:
3262
+ logger.error(f"Error: {e}")
3263
+ logger.exception("Full error details:")
3264
+ return 1
3265
+
3266
+
3267
+ if __name__ == "__main__":
3268
+ sys.exit(main())