karaoke-gen 0.57.0__py3-none-any.whl → 0.71.23__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. karaoke_gen/audio_fetcher.py +461 -0
  2. karaoke_gen/audio_processor.py +407 -30
  3. karaoke_gen/config.py +62 -113
  4. karaoke_gen/file_handler.py +32 -59
  5. karaoke_gen/karaoke_finalise/karaoke_finalise.py +148 -67
  6. karaoke_gen/karaoke_gen.py +270 -61
  7. karaoke_gen/lyrics_processor.py +13 -1
  8. karaoke_gen/metadata.py +78 -73
  9. karaoke_gen/pipeline/__init__.py +87 -0
  10. karaoke_gen/pipeline/base.py +215 -0
  11. karaoke_gen/pipeline/context.py +230 -0
  12. karaoke_gen/pipeline/executors/__init__.py +21 -0
  13. karaoke_gen/pipeline/executors/local.py +159 -0
  14. karaoke_gen/pipeline/executors/remote.py +257 -0
  15. karaoke_gen/pipeline/stages/__init__.py +27 -0
  16. karaoke_gen/pipeline/stages/finalize.py +202 -0
  17. karaoke_gen/pipeline/stages/render.py +165 -0
  18. karaoke_gen/pipeline/stages/screens.py +139 -0
  19. karaoke_gen/pipeline/stages/separation.py +191 -0
  20. karaoke_gen/pipeline/stages/transcription.py +191 -0
  21. karaoke_gen/style_loader.py +531 -0
  22. karaoke_gen/utils/bulk_cli.py +6 -0
  23. karaoke_gen/utils/cli_args.py +424 -0
  24. karaoke_gen/utils/gen_cli.py +26 -261
  25. karaoke_gen/utils/remote_cli.py +1815 -0
  26. karaoke_gen/video_background_processor.py +351 -0
  27. karaoke_gen-0.71.23.dist-info/METADATA +610 -0
  28. karaoke_gen-0.71.23.dist-info/RECORD +275 -0
  29. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.23.dist-info}/WHEEL +1 -1
  30. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.23.dist-info}/entry_points.txt +1 -0
  31. lyrics_transcriber/__init__.py +10 -0
  32. lyrics_transcriber/cli/__init__.py +0 -0
  33. lyrics_transcriber/cli/cli_main.py +285 -0
  34. lyrics_transcriber/core/__init__.py +0 -0
  35. lyrics_transcriber/core/config.py +50 -0
  36. lyrics_transcriber/core/controller.py +520 -0
  37. lyrics_transcriber/correction/__init__.py +0 -0
  38. lyrics_transcriber/correction/agentic/__init__.py +9 -0
  39. lyrics_transcriber/correction/agentic/adapter.py +71 -0
  40. lyrics_transcriber/correction/agentic/agent.py +313 -0
  41. lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
  42. lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
  43. lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
  44. lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
  45. lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
  46. lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
  47. lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
  48. lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
  49. lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
  50. lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
  51. lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
  52. lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
  53. lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
  54. lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
  55. lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
  56. lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
  57. lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
  58. lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
  59. lyrics_transcriber/correction/agentic/models/enums.py +38 -0
  60. lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
  61. lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
  62. lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
  63. lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
  64. lyrics_transcriber/correction/agentic/models/utils.py +19 -0
  65. lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
  66. lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
  67. lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
  68. lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
  69. lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
  70. lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
  71. lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
  72. lyrics_transcriber/correction/agentic/providers/base.py +36 -0
  73. lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
  74. lyrics_transcriber/correction/agentic/providers/config.py +73 -0
  75. lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
  76. lyrics_transcriber/correction/agentic/providers/health.py +28 -0
  77. lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
  78. lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
  79. lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
  80. lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
  81. lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
  82. lyrics_transcriber/correction/agentic/router.py +35 -0
  83. lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
  84. lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
  85. lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
  86. lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
  87. lyrics_transcriber/correction/anchor_sequence.py +1043 -0
  88. lyrics_transcriber/correction/corrector.py +760 -0
  89. lyrics_transcriber/correction/feedback/__init__.py +2 -0
  90. lyrics_transcriber/correction/feedback/schemas.py +107 -0
  91. lyrics_transcriber/correction/feedback/store.py +236 -0
  92. lyrics_transcriber/correction/handlers/__init__.py +0 -0
  93. lyrics_transcriber/correction/handlers/base.py +52 -0
  94. lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
  95. lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
  96. lyrics_transcriber/correction/handlers/llm.py +293 -0
  97. lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
  98. lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
  99. lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
  100. lyrics_transcriber/correction/handlers/repeat.py +88 -0
  101. lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
  102. lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
  103. lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
  104. lyrics_transcriber/correction/handlers/word_operations.py +187 -0
  105. lyrics_transcriber/correction/operations.py +352 -0
  106. lyrics_transcriber/correction/phrase_analyzer.py +435 -0
  107. lyrics_transcriber/correction/text_utils.py +30 -0
  108. lyrics_transcriber/frontend/.gitignore +23 -0
  109. lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
  110. lyrics_transcriber/frontend/.yarnrc.yml +3 -0
  111. lyrics_transcriber/frontend/README.md +50 -0
  112. lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
  113. lyrics_transcriber/frontend/__init__.py +25 -0
  114. lyrics_transcriber/frontend/eslint.config.js +28 -0
  115. lyrics_transcriber/frontend/index.html +18 -0
  116. lyrics_transcriber/frontend/package.json +42 -0
  117. lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
  118. lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
  119. lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
  120. lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
  121. lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
  122. lyrics_transcriber/frontend/public/favicon.ico +0 -0
  123. lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
  124. lyrics_transcriber/frontend/src/App.tsx +212 -0
  125. lyrics_transcriber/frontend/src/api.ts +239 -0
  126. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
  127. lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
  128. lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
  129. lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
  130. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
  131. lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
  132. lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
  133. lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
  134. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
  135. lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
  136. lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
  137. lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
  138. lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
  139. lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
  140. lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
  141. lyrics_transcriber/frontend/src/components/Header.tsx +387 -0
  142. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1373 -0
  143. lyrics_transcriber/frontend/src/components/MetricsDashboard.tsx +51 -0
  144. lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
  145. lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
  146. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
  147. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
  148. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +688 -0
  149. lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
  150. lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
  151. lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
  152. lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
  153. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
  154. lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
  155. lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
  156. lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
  157. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
  158. lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
  159. lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
  160. lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
  161. lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
  162. lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
  163. lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
  164. lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
  165. lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
  166. lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
  167. lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
  168. lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
  169. lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
  170. lyrics_transcriber/frontend/src/main.tsx +17 -0
  171. lyrics_transcriber/frontend/src/theme.ts +177 -0
  172. lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
  173. lyrics_transcriber/frontend/src/types.js +2 -0
  174. lyrics_transcriber/frontend/src/types.ts +199 -0
  175. lyrics_transcriber/frontend/src/validation.ts +132 -0
  176. lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
  177. lyrics_transcriber/frontend/tsconfig.app.json +26 -0
  178. lyrics_transcriber/frontend/tsconfig.json +25 -0
  179. lyrics_transcriber/frontend/tsconfig.node.json +23 -0
  180. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
  181. lyrics_transcriber/frontend/update_version.js +11 -0
  182. lyrics_transcriber/frontend/vite.config.d.ts +2 -0
  183. lyrics_transcriber/frontend/vite.config.js +10 -0
  184. lyrics_transcriber/frontend/vite.config.ts +11 -0
  185. lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
  186. lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
  187. lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
  188. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js +42039 -0
  189. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +1 -0
  190. lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
  191. lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
  192. lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
  193. lyrics_transcriber/frontend/web_assets/index.html +18 -0
  194. lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
  195. lyrics_transcriber/frontend/yarn.lock +3752 -0
  196. lyrics_transcriber/lyrics/__init__.py +0 -0
  197. lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
  198. lyrics_transcriber/lyrics/file_provider.py +95 -0
  199. lyrics_transcriber/lyrics/genius.py +384 -0
  200. lyrics_transcriber/lyrics/lrclib.py +231 -0
  201. lyrics_transcriber/lyrics/musixmatch.py +156 -0
  202. lyrics_transcriber/lyrics/spotify.py +290 -0
  203. lyrics_transcriber/lyrics/user_input_provider.py +44 -0
  204. lyrics_transcriber/output/__init__.py +0 -0
  205. lyrics_transcriber/output/ass/__init__.py +21 -0
  206. lyrics_transcriber/output/ass/ass.py +2088 -0
  207. lyrics_transcriber/output/ass/ass_specs.txt +732 -0
  208. lyrics_transcriber/output/ass/config.py +180 -0
  209. lyrics_transcriber/output/ass/constants.py +23 -0
  210. lyrics_transcriber/output/ass/event.py +94 -0
  211. lyrics_transcriber/output/ass/formatters.py +132 -0
  212. lyrics_transcriber/output/ass/lyrics_line.py +265 -0
  213. lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
  214. lyrics_transcriber/output/ass/section_detector.py +89 -0
  215. lyrics_transcriber/output/ass/section_screen.py +106 -0
  216. lyrics_transcriber/output/ass/style.py +187 -0
  217. lyrics_transcriber/output/cdg.py +619 -0
  218. lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
  219. lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
  220. lyrics_transcriber/output/cdgmaker/composer.py +2260 -0
  221. lyrics_transcriber/output/cdgmaker/config.py +151 -0
  222. lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
  223. lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
  224. lyrics_transcriber/output/cdgmaker/pack.py +507 -0
  225. lyrics_transcriber/output/cdgmaker/render.py +346 -0
  226. lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
  227. lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
  228. lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
  229. lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
  230. lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
  231. lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
  232. lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
  233. lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
  234. lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
  235. lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
  236. lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
  237. lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
  238. lyrics_transcriber/output/cdgmaker/utils.py +132 -0
  239. lyrics_transcriber/output/countdown_processor.py +267 -0
  240. lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
  241. lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
  242. lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
  243. lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
  244. lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
  245. lyrics_transcriber/output/fonts/arial.ttf +0 -0
  246. lyrics_transcriber/output/fonts/georgia.ttf +0 -0
  247. lyrics_transcriber/output/fonts/verdana.ttf +0 -0
  248. lyrics_transcriber/output/generator.py +257 -0
  249. lyrics_transcriber/output/lrc_to_cdg.py +61 -0
  250. lyrics_transcriber/output/lyrics_file.py +102 -0
  251. lyrics_transcriber/output/plain_text.py +96 -0
  252. lyrics_transcriber/output/segment_resizer.py +431 -0
  253. lyrics_transcriber/output/subtitles.py +397 -0
  254. lyrics_transcriber/output/video.py +544 -0
  255. lyrics_transcriber/review/__init__.py +0 -0
  256. lyrics_transcriber/review/server.py +676 -0
  257. lyrics_transcriber/storage/__init__.py +0 -0
  258. lyrics_transcriber/storage/dropbox.py +225 -0
  259. lyrics_transcriber/transcribers/__init__.py +0 -0
  260. lyrics_transcriber/transcribers/audioshake.py +290 -0
  261. lyrics_transcriber/transcribers/base_transcriber.py +157 -0
  262. lyrics_transcriber/transcribers/whisper.py +330 -0
  263. lyrics_transcriber/types.py +648 -0
  264. lyrics_transcriber/utils/__init__.py +0 -0
  265. lyrics_transcriber/utils/word_utils.py +27 -0
  266. karaoke_gen-0.57.0.dist-info/METADATA +0 -167
  267. karaoke_gen-0.57.0.dist-info/RECORD +0 -23
  268. {karaoke_gen-0.57.0.dist-info → karaoke_gen-0.71.23.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,1815 @@
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, Optional
35
+
36
+ import requests
37
+
38
+ from .cli_args import create_parser, process_style_overrides, is_url, is_file
39
+
40
+
41
+ class JobStatus(str, Enum):
42
+ """Job status values (matching backend)."""
43
+ PENDING = "pending"
44
+ DOWNLOADING = "downloading"
45
+ SEPARATING_STAGE1 = "separating_stage1"
46
+ SEPARATING_STAGE2 = "separating_stage2"
47
+ AUDIO_COMPLETE = "audio_complete"
48
+ TRANSCRIBING = "transcribing"
49
+ CORRECTING = "correcting"
50
+ LYRICS_COMPLETE = "lyrics_complete"
51
+ GENERATING_SCREENS = "generating_screens"
52
+ APPLYING_PADDING = "applying_padding"
53
+ AWAITING_REVIEW = "awaiting_review"
54
+ IN_REVIEW = "in_review"
55
+ REVIEW_COMPLETE = "review_complete"
56
+ RENDERING_VIDEO = "rendering_video"
57
+ AWAITING_INSTRUMENTAL_SELECTION = "awaiting_instrumental_selection"
58
+ INSTRUMENTAL_SELECTED = "instrumental_selected"
59
+ GENERATING_VIDEO = "generating_video"
60
+ ENCODING = "encoding"
61
+ PACKAGING = "packaging"
62
+ UPLOADING = "uploading"
63
+ NOTIFYING = "notifying"
64
+ COMPLETE = "complete"
65
+ FAILED = "failed"
66
+ CANCELLED = "cancelled"
67
+ ERROR = "error"
68
+
69
+
70
+ @dataclass
71
+ class Config:
72
+ """Configuration for the remote CLI."""
73
+ service_url: str
74
+ review_ui_url: str
75
+ poll_interval: int
76
+ output_dir: str
77
+ auth_token: Optional[str] = None
78
+ non_interactive: bool = False # Auto-accept defaults for testing
79
+ # Job tracking metadata (sent as headers for filtering/tracking)
80
+ environment: str = "" # test/production/development
81
+ client_id: str = "" # Customer/user identifier
82
+
83
+
84
+ class RemoteKaraokeClient:
85
+ """Client for interacting with the karaoke-gen cloud backend."""
86
+
87
+ ALLOWED_AUDIO_EXTENSIONS = {'.mp3', '.wav', '.flac', '.m4a', '.ogg', '.aac'}
88
+ ALLOWED_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
89
+ ALLOWED_FONT_EXTENSIONS = {'.ttf', '.otf', '.woff', '.woff2'}
90
+
91
+ def __init__(self, config: Config, logger: logging.Logger):
92
+ self.config = config
93
+ self.logger = logger
94
+ self.session = requests.Session()
95
+ self._setup_auth()
96
+
97
+ def _setup_auth(self) -> None:
98
+ """Set up authentication and tracking headers."""
99
+ if self.config.auth_token:
100
+ self.session.headers['Authorization'] = f'Bearer {self.config.auth_token}'
101
+
102
+ # Set up job tracking headers (used for filtering and operational management)
103
+ if self.config.environment:
104
+ self.session.headers['X-Environment'] = self.config.environment
105
+ if self.config.client_id:
106
+ self.session.headers['X-Client-ID'] = self.config.client_id
107
+
108
+ # Always include CLI version as user-agent
109
+ from importlib import metadata
110
+ try:
111
+ version = metadata.version("karaoke-gen")
112
+ except metadata.PackageNotFoundError:
113
+ version = "unknown"
114
+ self.session.headers['User-Agent'] = f'karaoke-gen-remote/{version}'
115
+
116
+ def _get_auth_token_from_gcloud(self) -> Optional[str]:
117
+ """Get auth token from gcloud CLI."""
118
+ try:
119
+ result = subprocess.run(
120
+ ['gcloud', 'auth', 'print-identity-token'],
121
+ capture_output=True,
122
+ text=True,
123
+ check=True
124
+ )
125
+ return result.stdout.strip()
126
+ except subprocess.CalledProcessError:
127
+ return None
128
+ except FileNotFoundError:
129
+ return None
130
+
131
+ def refresh_auth(self) -> bool:
132
+ """Refresh authentication token."""
133
+ token = self._get_auth_token_from_gcloud()
134
+ if token:
135
+ self.config.auth_token = token
136
+ self.session.headers['Authorization'] = f'Bearer {token}'
137
+ return True
138
+ return False
139
+
140
+ def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
141
+ """Make an authenticated request."""
142
+ url = f"{self.config.service_url}{endpoint}"
143
+ response = self.session.request(method, url, **kwargs)
144
+ return response
145
+
146
+ def _parse_style_params(self, style_params_path: str) -> Dict[str, str]:
147
+ """
148
+ Parse style_params.json and extract file paths that need to be uploaded.
149
+
150
+ Returns a dict mapping asset_key -> local_file_path for files that exist.
151
+ """
152
+ asset_files = {}
153
+
154
+ try:
155
+ with open(style_params_path, 'r') as f:
156
+ style_params = json.load(f)
157
+ except Exception as e:
158
+ self.logger.warning(f"Failed to parse style_params.json: {e}")
159
+ return asset_files
160
+
161
+ # Map of style param paths to asset keys
162
+ file_mappings = [
163
+ ('intro', 'background_image', 'style_intro_background'),
164
+ ('intro', 'font', 'style_font'),
165
+ ('karaoke', 'background_image', 'style_karaoke_background'),
166
+ ('karaoke', 'font_path', 'style_font'),
167
+ ('end', 'background_image', 'style_end_background'),
168
+ ('end', 'font', 'style_font'),
169
+ ('cdg', 'font_path', 'style_font'),
170
+ ('cdg', 'instrumental_background', 'style_cdg_instrumental_background'),
171
+ ('cdg', 'title_screen_background', 'style_cdg_title_background'),
172
+ ('cdg', 'outro_background', 'style_cdg_outro_background'),
173
+ ]
174
+
175
+ for section, key, asset_key in file_mappings:
176
+ if section in style_params and key in style_params[section]:
177
+ file_path = style_params[section][key]
178
+ if file_path and os.path.isfile(file_path):
179
+ # Don't duplicate font uploads
180
+ if asset_key not in asset_files:
181
+ asset_files[asset_key] = file_path
182
+ self.logger.info(f" Found style asset: {asset_key} -> {file_path}")
183
+
184
+ return asset_files
185
+
186
+ def submit_job(
187
+ self,
188
+ filepath: str,
189
+ artist: str,
190
+ title: str,
191
+ style_params_path: Optional[str] = None,
192
+ enable_cdg: bool = True,
193
+ enable_txt: bool = True,
194
+ brand_prefix: Optional[str] = None,
195
+ discord_webhook_url: Optional[str] = None,
196
+ youtube_description: Optional[str] = None,
197
+ organised_dir_rclone_root: Optional[str] = None,
198
+ enable_youtube_upload: bool = False,
199
+ # Native API distribution (uses server-side credentials)
200
+ dropbox_path: Optional[str] = None,
201
+ gdrive_folder_id: Optional[str] = None,
202
+ # Lyrics configuration
203
+ lyrics_artist: Optional[str] = None,
204
+ lyrics_title: Optional[str] = None,
205
+ lyrics_file: Optional[str] = None,
206
+ subtitle_offset_ms: int = 0,
207
+ ) -> Dict[str, Any]:
208
+ """
209
+ Submit a new karaoke generation job with optional style configuration.
210
+
211
+ Args:
212
+ filepath: Path to audio file
213
+ artist: Artist name
214
+ title: Song title
215
+ style_params_path: Path to style_params.json (optional)
216
+ enable_cdg: Generate CDG+MP3 package
217
+ enable_txt: Generate TXT+MP3 package
218
+ brand_prefix: Brand code prefix (e.g., "NOMAD")
219
+ discord_webhook_url: Discord webhook for notifications
220
+ youtube_description: YouTube video description
221
+ organised_dir_rclone_root: Legacy rclone path (deprecated)
222
+ enable_youtube_upload: Enable YouTube upload
223
+ dropbox_path: Dropbox folder path for organized output (native API)
224
+ gdrive_folder_id: Google Drive folder ID for public share (native API)
225
+ lyrics_artist: Override artist name for lyrics search
226
+ lyrics_title: Override title for lyrics search
227
+ lyrics_file: Path to user-provided lyrics file
228
+ subtitle_offset_ms: Subtitle timing offset in milliseconds
229
+ """
230
+ file_path = Path(filepath)
231
+
232
+ if not file_path.exists():
233
+ raise FileNotFoundError(f"File not found: {filepath}")
234
+
235
+ ext = file_path.suffix.lower()
236
+ if ext not in self.ALLOWED_AUDIO_EXTENSIONS:
237
+ raise ValueError(
238
+ f"Unsupported file type: {ext}. "
239
+ f"Allowed: {', '.join(self.ALLOWED_AUDIO_EXTENSIONS)}"
240
+ )
241
+
242
+ self.logger.info(f"Uploading audio file: {filepath}")
243
+
244
+ # Prepare files dict for multipart upload
245
+ files_to_upload = {}
246
+ files_to_close = []
247
+
248
+ try:
249
+ # Main audio file
250
+ audio_file = open(filepath, 'rb')
251
+ files_to_close.append(audio_file)
252
+ files_to_upload['file'] = (file_path.name, audio_file)
253
+
254
+ # Parse style params and find referenced files
255
+ style_assets = {}
256
+ if style_params_path and os.path.isfile(style_params_path):
257
+ self.logger.info(f"Parsing style configuration: {style_params_path}")
258
+ style_assets = self._parse_style_params(style_params_path)
259
+
260
+ # Upload style_params.json
261
+ style_file = open(style_params_path, 'rb')
262
+ files_to_close.append(style_file)
263
+ files_to_upload['style_params'] = (Path(style_params_path).name, style_file, 'application/json')
264
+ self.logger.info(f" Will upload style_params.json")
265
+
266
+ # Upload each style asset file
267
+ for asset_key, asset_path in style_assets.items():
268
+ if os.path.isfile(asset_path):
269
+ asset_file = open(asset_path, 'rb')
270
+ files_to_close.append(asset_file)
271
+ # Determine content type
272
+ ext = Path(asset_path).suffix.lower()
273
+ if ext in self.ALLOWED_IMAGE_EXTENSIONS:
274
+ content_type = f'image/{ext[1:]}'
275
+ elif ext in self.ALLOWED_FONT_EXTENSIONS:
276
+ content_type = 'font/ttf'
277
+ else:
278
+ content_type = 'application/octet-stream'
279
+ files_to_upload[asset_key] = (Path(asset_path).name, asset_file, content_type)
280
+ self.logger.info(f" Will upload {asset_key}: {asset_path}")
281
+
282
+ # Upload lyrics file if provided
283
+ if lyrics_file and os.path.isfile(lyrics_file):
284
+ self.logger.info(f"Uploading lyrics file: {lyrics_file}")
285
+ lyrics_file_handle = open(lyrics_file, 'rb')
286
+ files_to_close.append(lyrics_file_handle)
287
+ files_to_upload['lyrics_file'] = (Path(lyrics_file).name, lyrics_file_handle, 'text/plain')
288
+
289
+ # Prepare form data
290
+ data = {
291
+ 'artist': artist,
292
+ 'title': title,
293
+ 'enable_cdg': str(enable_cdg).lower(),
294
+ 'enable_txt': str(enable_txt).lower(),
295
+ }
296
+
297
+ if brand_prefix:
298
+ data['brand_prefix'] = brand_prefix
299
+ if discord_webhook_url:
300
+ data['discord_webhook_url'] = discord_webhook_url
301
+ if youtube_description:
302
+ data['youtube_description'] = youtube_description
303
+ if enable_youtube_upload:
304
+ data['enable_youtube_upload'] = str(enable_youtube_upload).lower()
305
+
306
+ # Native API distribution (preferred for remote CLI)
307
+ if dropbox_path:
308
+ data['dropbox_path'] = dropbox_path
309
+ if gdrive_folder_id:
310
+ data['gdrive_folder_id'] = gdrive_folder_id
311
+
312
+ # Legacy rclone distribution (deprecated)
313
+ if organised_dir_rclone_root:
314
+ data['organised_dir_rclone_root'] = organised_dir_rclone_root
315
+
316
+ # Lyrics configuration
317
+ if lyrics_artist:
318
+ data['lyrics_artist'] = lyrics_artist
319
+ if lyrics_title:
320
+ data['lyrics_title'] = lyrics_title
321
+ if subtitle_offset_ms != 0:
322
+ data['subtitle_offset_ms'] = str(subtitle_offset_ms)
323
+
324
+ self.logger.info(f"Submitting job to {self.config.service_url}/api/jobs/upload")
325
+
326
+ response = self._request('POST', '/api/jobs/upload', files=files_to_upload, data=data)
327
+
328
+ finally:
329
+ # Close all opened files
330
+ for f in files_to_close:
331
+ try:
332
+ f.close()
333
+ except:
334
+ pass
335
+
336
+ if response.status_code != 200:
337
+ try:
338
+ error_detail = response.json()
339
+ except Exception:
340
+ error_detail = response.text
341
+ raise RuntimeError(f"Error submitting job: {error_detail}")
342
+
343
+ result = response.json()
344
+ if result.get('status') != 'success':
345
+ raise RuntimeError(f"Error submitting job: {result}")
346
+
347
+ # Log distribution services info if available
348
+ if 'distribution_services' in result:
349
+ dist_services = result['distribution_services']
350
+ self.logger.info("")
351
+ self.logger.info("Distribution Services:")
352
+
353
+ for service_name, service_info in dist_services.items():
354
+ if service_info.get('enabled'):
355
+ status = "✓" if service_info.get('credentials_valid', True) else "✗"
356
+ default_note = " (default)" if service_info.get('using_default') else ""
357
+
358
+ if service_name == 'dropbox':
359
+ path = service_info.get('path', '')
360
+ self.logger.info(f" {status} Dropbox: {path}{default_note}")
361
+ elif service_name == 'gdrive':
362
+ folder_id = service_info.get('folder_id', '')
363
+ self.logger.info(f" {status} Google Drive: folder {folder_id}{default_note}")
364
+ elif service_name == 'youtube':
365
+ self.logger.info(f" {status} YouTube: enabled")
366
+ elif service_name == 'discord':
367
+ self.logger.info(f" {status} Discord: notifications{default_note}")
368
+
369
+ return result
370
+
371
+ def get_job(self, job_id: str) -> Dict[str, Any]:
372
+ """Get job status and details."""
373
+ response = self._request('GET', f'/api/jobs/{job_id}')
374
+ if response.status_code == 404:
375
+ raise ValueError(f"Job not found: {job_id}")
376
+ if response.status_code != 200:
377
+ raise RuntimeError(f"Error getting job: {response.text}")
378
+ return response.json()
379
+
380
+ def cancel_job(self, job_id: str, reason: str = "User requested") -> Dict[str, Any]:
381
+ """Cancel a running job. Stops processing but keeps the job record."""
382
+ response = self._request(
383
+ 'POST',
384
+ f'/api/jobs/{job_id}/cancel',
385
+ json={'reason': reason}
386
+ )
387
+ if response.status_code == 404:
388
+ raise ValueError(f"Job not found: {job_id}")
389
+ if response.status_code == 400:
390
+ try:
391
+ error_detail = response.json().get('detail', response.text)
392
+ except Exception:
393
+ error_detail = response.text
394
+ raise RuntimeError(f"Cannot cancel job: {error_detail}")
395
+ if response.status_code != 200:
396
+ raise RuntimeError(f"Error cancelling job: {response.text}")
397
+ return response.json()
398
+
399
+ def delete_job(self, job_id: str, delete_files: bool = True) -> Dict[str, Any]:
400
+ """Delete a job and optionally its files. Permanent removal."""
401
+ response = self._request(
402
+ 'DELETE',
403
+ f'/api/jobs/{job_id}',
404
+ params={'delete_files': str(delete_files).lower()}
405
+ )
406
+ if response.status_code == 404:
407
+ raise ValueError(f"Job not found: {job_id}")
408
+ if response.status_code != 200:
409
+ raise RuntimeError(f"Error deleting job: {response.text}")
410
+ return response.json()
411
+
412
+ def retry_job(self, job_id: str) -> Dict[str, Any]:
413
+ """Retry a failed job from the last successful checkpoint."""
414
+ response = self._request(
415
+ 'POST',
416
+ f'/api/jobs/{job_id}/retry'
417
+ )
418
+ if response.status_code == 404:
419
+ raise ValueError(f"Job not found: {job_id}")
420
+ if response.status_code == 400:
421
+ try:
422
+ error_detail = response.json().get('detail', response.text)
423
+ except Exception:
424
+ error_detail = response.text
425
+ raise RuntimeError(f"Cannot retry job: {error_detail}")
426
+ if response.status_code != 200:
427
+ raise RuntimeError(f"Error retrying job: {response.text}")
428
+ return response.json()
429
+
430
+ def list_jobs(
431
+ self,
432
+ status: Optional[str] = None,
433
+ environment: Optional[str] = None,
434
+ client_id: Optional[str] = None,
435
+ limit: int = 100
436
+ ) -> list:
437
+ """
438
+ List all jobs with optional filters.
439
+
440
+ Args:
441
+ status: Filter by job status
442
+ environment: Filter by request_metadata.environment
443
+ client_id: Filter by request_metadata.client_id
444
+ limit: Maximum number of jobs to return
445
+ """
446
+ params = {'limit': limit}
447
+ if status:
448
+ params['status'] = status
449
+ if environment:
450
+ params['environment'] = environment
451
+ if client_id:
452
+ params['client_id'] = client_id
453
+ response = self._request('GET', '/api/jobs', params=params)
454
+ if response.status_code != 200:
455
+ raise RuntimeError(f"Error listing jobs: {response.text}")
456
+ return response.json()
457
+
458
+ def bulk_delete_jobs(
459
+ self,
460
+ environment: Optional[str] = None,
461
+ client_id: Optional[str] = None,
462
+ status: Optional[str] = None,
463
+ confirm: bool = False,
464
+ delete_files: bool = True
465
+ ) -> Dict[str, Any]:
466
+ """
467
+ Delete multiple jobs matching filter criteria.
468
+
469
+ Args:
470
+ environment: Delete jobs with this environment
471
+ client_id: Delete jobs from this client
472
+ status: Delete jobs with this status
473
+ confirm: Must be True to execute deletion
474
+ delete_files: Also delete GCS files
475
+
476
+ Returns:
477
+ Dict with deletion results or preview
478
+ """
479
+ params = {
480
+ 'confirm': str(confirm).lower(),
481
+ 'delete_files': str(delete_files).lower(),
482
+ }
483
+ if environment:
484
+ params['environment'] = environment
485
+ if client_id:
486
+ params['client_id'] = client_id
487
+ if status:
488
+ params['status'] = status
489
+
490
+ response = self._request('DELETE', '/api/jobs', params=params)
491
+ if response.status_code == 400:
492
+ try:
493
+ error_detail = response.json().get('detail', response.text)
494
+ except Exception:
495
+ error_detail = response.text
496
+ raise RuntimeError(f"Error: {error_detail}")
497
+ if response.status_code != 200:
498
+ raise RuntimeError(f"Error bulk deleting jobs: {response.text}")
499
+ return response.json()
500
+
501
+ def get_instrumental_options(self, job_id: str) -> Dict[str, Any]:
502
+ """Get instrumental options for selection."""
503
+ response = self._request('GET', f'/api/jobs/{job_id}/instrumental-options')
504
+ if response.status_code != 200:
505
+ try:
506
+ error_detail = response.json()
507
+ except Exception:
508
+ error_detail = response.text
509
+ raise RuntimeError(f"Error getting instrumental options: {error_detail}")
510
+ return response.json()
511
+
512
+ def select_instrumental(self, job_id: str, selection: str) -> Dict[str, Any]:
513
+ """Submit instrumental selection."""
514
+ response = self._request(
515
+ 'POST',
516
+ f'/api/jobs/{job_id}/select-instrumental',
517
+ json={'selection': selection}
518
+ )
519
+ if response.status_code != 200:
520
+ try:
521
+ error_detail = response.json()
522
+ except Exception:
523
+ error_detail = response.text
524
+ raise RuntimeError(f"Error selecting instrumental: {error_detail}")
525
+ return response.json()
526
+
527
+ def get_download_urls(self, job_id: str) -> Dict[str, Any]:
528
+ """Get signed download URLs for all job output files."""
529
+ response = self._request('GET', f'/api/jobs/{job_id}/download-urls')
530
+ if response.status_code != 200:
531
+ try:
532
+ error_detail = response.json()
533
+ except Exception:
534
+ error_detail = response.text
535
+ raise RuntimeError(f"Error getting download URLs: {error_detail}")
536
+ return response.json()
537
+
538
+ def download_file_via_url(self, url: str, local_path: str) -> bool:
539
+ """Download file from a URL via HTTP."""
540
+ try:
541
+ # Handle relative URLs by prepending service URL
542
+ if url.startswith('/'):
543
+ url = f"{self.config.service_url}{url}"
544
+
545
+ response = requests.get(url, stream=True, timeout=600)
546
+ if response.status_code != 200:
547
+ return False
548
+
549
+ # Ensure parent directory exists
550
+ os.makedirs(os.path.dirname(local_path), exist_ok=True)
551
+
552
+ with open(local_path, 'wb') as f:
553
+ for chunk in response.iter_content(chunk_size=8192):
554
+ f.write(chunk)
555
+ return True
556
+ except Exception:
557
+ return False
558
+
559
+ def download_file_via_gsutil(self, gcs_path: str, local_path: str) -> bool:
560
+ """Download file from GCS using gsutil (fallback method)."""
561
+ try:
562
+ bucket_name = os.environ.get('KARAOKE_GEN_BUCKET', 'karaoke-gen-storage-nomadkaraoke')
563
+ gcs_uri = f"gs://{bucket_name}/{gcs_path}"
564
+
565
+ result = subprocess.run(
566
+ ['gsutil', 'cp', gcs_uri, local_path],
567
+ capture_output=True,
568
+ text=True
569
+ )
570
+ return result.returncode == 0
571
+ except FileNotFoundError:
572
+ return False
573
+
574
+ def get_worker_logs(self, job_id: str, since_index: int = 0) -> Dict[str, Any]:
575
+ """
576
+ Get worker logs for debugging.
577
+
578
+ Args:
579
+ job_id: Job ID
580
+ since_index: Return only logs after this index (for pagination/polling)
581
+
582
+ Returns:
583
+ {
584
+ "logs": [{"timestamp": "...", "level": "INFO", "worker": "audio", "message": "..."}],
585
+ "next_index": 42,
586
+ "total_logs": 42
587
+ }
588
+ """
589
+ response = self._request(
590
+ 'GET',
591
+ f'/api/jobs/{job_id}/logs',
592
+ params={'since_index': since_index}
593
+ )
594
+ if response.status_code != 200:
595
+ return {"logs": [], "next_index": since_index, "total_logs": 0}
596
+ return response.json()
597
+
598
+ def get_review_data(self, job_id: str) -> Dict[str, Any]:
599
+ """Get the current review/correction data for a job."""
600
+ response = self._request('GET', f'/api/review/{job_id}/correction-data')
601
+ if response.status_code != 200:
602
+ try:
603
+ error_detail = response.json()
604
+ except Exception:
605
+ error_detail = response.text
606
+ raise RuntimeError(f"Error getting review data: {error_detail}")
607
+ return response.json()
608
+
609
+ def complete_review(self, job_id: str, updated_data: Dict[str, Any]) -> Dict[str, Any]:
610
+ """Submit the review completion with corrected data."""
611
+ response = self._request(
612
+ 'POST',
613
+ f'/api/review/{job_id}/complete',
614
+ json=updated_data
615
+ )
616
+ if response.status_code != 200:
617
+ try:
618
+ error_detail = response.json()
619
+ except Exception:
620
+ error_detail = response.text
621
+ raise RuntimeError(f"Error completing review: {error_detail}")
622
+ return response.json()
623
+
624
+
625
+ class JobMonitor:
626
+ """Monitor job progress with verbose logging."""
627
+
628
+ def __init__(self, client: RemoteKaraokeClient, config: Config, logger: logging.Logger):
629
+ self.client = client
630
+ self.config = config
631
+ self.logger = logger
632
+ self._review_opened = False
633
+ self._instrumental_prompted = False
634
+ self._last_timeline_index = 0
635
+ self._last_log_index = 0
636
+ self._show_worker_logs = True # Enable worker log display
637
+ self._polls_without_updates = 0 # Track polling activity for heartbeat
638
+ self._heartbeat_interval = 6 # Show heartbeat every N polls without updates (~30s with 5s poll)
639
+
640
+ # Status descriptions for user-friendly logging
641
+ STATUS_DESCRIPTIONS = {
642
+ 'pending': 'Job queued, waiting to start',
643
+ 'downloading': 'Downloading and preparing input files',
644
+ 'separating_stage1': 'AI audio separation (stage 1 of 2)',
645
+ 'separating_stage2': 'AI audio separation (stage 2 of 2)',
646
+ 'audio_complete': 'Audio separation complete',
647
+ 'transcribing': 'Transcribing lyrics from audio',
648
+ 'correcting': 'Auto-correcting lyrics against reference sources',
649
+ 'lyrics_complete': 'Lyrics processing complete',
650
+ 'generating_screens': 'Creating title and end screens',
651
+ 'applying_padding': 'Adding intro/outro padding',
652
+ 'awaiting_review': 'Waiting for lyrics review',
653
+ 'in_review': 'Lyrics review in progress',
654
+ 'review_complete': 'Review complete, preparing video render',
655
+ 'rendering_video': 'Rendering karaoke video with lyrics',
656
+ 'awaiting_instrumental_selection': 'Waiting for instrumental selection',
657
+ 'instrumental_selected': 'Instrumental selected, preparing final encoding',
658
+ 'generating_video': 'Downloading files for final video encoding',
659
+ 'encoding': 'Encoding final videos (15-20 min, 4 formats)',
660
+ 'packaging': 'Creating CDG/TXT packages',
661
+ 'uploading': 'Uploading to distribution services',
662
+ 'notifying': 'Sending notifications',
663
+ 'complete': 'All processing complete',
664
+ 'failed': 'Job failed',
665
+ 'cancelled': 'Job cancelled',
666
+ }
667
+
668
+ def _get_status_description(self, status: str) -> str:
669
+ """Get user-friendly description for a status."""
670
+ return self.STATUS_DESCRIPTIONS.get(status, status)
671
+
672
+ def open_browser(self, url: str) -> None:
673
+ """Open URL in the default browser."""
674
+ system = platform.system()
675
+ try:
676
+ if system == 'Darwin':
677
+ subprocess.run(['open', url], check=True)
678
+ elif system == 'Linux':
679
+ subprocess.run(['xdg-open', url], check=True, stderr=subprocess.DEVNULL)
680
+ else:
681
+ webbrowser.open(url)
682
+ except Exception:
683
+ self.logger.info(f"Please open in browser: {url}")
684
+
685
+ def open_review_ui(self, job_id: str) -> None:
686
+ """Open the lyrics review UI in browser."""
687
+ # Build the review URL with the API endpoint
688
+ base_api_url = f"{self.config.service_url}/api/review/{job_id}"
689
+ encoded_api_url = urllib.parse.quote(base_api_url, safe='')
690
+
691
+ # Try to get audio hash from job data
692
+ try:
693
+ job_data = self.client.get_job(job_id)
694
+ audio_hash = job_data.get('audio_hash', '')
695
+ except Exception:
696
+ audio_hash = ''
697
+
698
+ url = f"{self.config.review_ui_url}/?baseApiUrl={encoded_api_url}"
699
+ if audio_hash:
700
+ url += f"&audioHash={audio_hash}"
701
+
702
+ self.logger.info(f"Opening lyrics review UI: {url}")
703
+ self.open_browser(url)
704
+
705
+ def handle_review(self, job_id: str) -> None:
706
+ """Handle the lyrics review interaction."""
707
+ self.logger.info("=" * 60)
708
+ self.logger.info("LYRICS REVIEW NEEDED")
709
+ self.logger.info("=" * 60)
710
+
711
+ # In non-interactive mode, auto-accept the current corrections
712
+ if self.config.non_interactive:
713
+ self.logger.info("Non-interactive mode: Auto-accepting current corrections")
714
+ try:
715
+ # Get current review data
716
+ review_data = self.client.get_review_data(job_id)
717
+ self.logger.info("Retrieved current correction data")
718
+
719
+ # Submit as-is to complete the review
720
+ result = self.client.complete_review(job_id, review_data)
721
+ if result.get('status') == 'success':
722
+ self.logger.info("Review auto-completed successfully")
723
+ return
724
+ else:
725
+ self.logger.error(f"Failed to auto-complete review: {result}")
726
+ # In non-interactive mode, raise exception instead of falling back to manual
727
+ raise RuntimeError(f"Failed to auto-complete review: {result}")
728
+ except Exception as e:
729
+ self.logger.error(f"Error auto-completing review: {e}")
730
+ # In non-interactive mode, we can't fall back to manual - raise the error
731
+ raise RuntimeError(f"Non-interactive review failed: {e}")
732
+
733
+ # Interactive mode - open browser and wait
734
+ self.logger.info("The transcription is ready for review.")
735
+ self.logger.info("Please review and correct the lyrics in the browser.")
736
+
737
+ self.open_review_ui(job_id)
738
+
739
+ self.logger.info(f"Waiting for review completion (polling every {self.config.poll_interval}s)...")
740
+
741
+ # Poll until status changes from review states
742
+ while True:
743
+ try:
744
+ job_data = self.client.get_job(job_id)
745
+ current_status = job_data.get('status', 'unknown')
746
+
747
+ if current_status in ['awaiting_review', 'in_review']:
748
+ time.sleep(self.config.poll_interval)
749
+ else:
750
+ self.logger.info(f"Review completed, status: {current_status}")
751
+ return
752
+ except Exception as e:
753
+ self.logger.warning(f"Error checking review status: {e}")
754
+ time.sleep(self.config.poll_interval)
755
+
756
+ def handle_instrumental_selection(self, job_id: str) -> None:
757
+ """Handle instrumental selection interaction."""
758
+ self.logger.info("=" * 60)
759
+ self.logger.info("INSTRUMENTAL SELECTION NEEDED")
760
+ self.logger.info("=" * 60)
761
+
762
+ # In non-interactive mode, auto-select clean instrumental
763
+ if self.config.non_interactive:
764
+ self.logger.info("Non-interactive mode: Auto-selecting clean instrumental")
765
+ selection = 'clean'
766
+ else:
767
+ self.logger.info("")
768
+ self.logger.info("Choose which instrumental track to use for the final video:")
769
+ self.logger.info("")
770
+ self.logger.info(" 1) Clean Instrumental (no backing vocals)")
771
+ self.logger.info(" Best for songs where you want ONLY the lead vocal removed")
772
+ self.logger.info("")
773
+ self.logger.info(" 2) Instrumental with Backing Vocals")
774
+ self.logger.info(" Best for songs where backing vocals add to the karaoke experience")
775
+ self.logger.info("")
776
+
777
+ selection = ""
778
+ while not selection:
779
+ try:
780
+ choice = input("Enter your choice (1 or 2): ").strip()
781
+ if choice == '1':
782
+ selection = 'clean'
783
+ elif choice == '2':
784
+ selection = 'with_backing'
785
+ else:
786
+ self.logger.error("Invalid choice. Please enter 1 or 2.")
787
+ except KeyboardInterrupt:
788
+ print()
789
+ raise
790
+
791
+ self.logger.info(f"Submitting selection: {selection}")
792
+
793
+ try:
794
+ result = self.client.select_instrumental(job_id, selection)
795
+ if result.get('status') == 'success':
796
+ self.logger.info(f"Selection submitted successfully: {selection}")
797
+ else:
798
+ self.logger.error(f"Error submitting selection: {result}")
799
+ except Exception as e:
800
+ self.logger.error(f"Error submitting selection: {e}")
801
+
802
+ def download_outputs(self, job_id: str, job_data: Dict[str, Any]) -> None:
803
+ """
804
+ Download all output files for a completed job.
805
+
806
+ Downloads all files to match local CLI output structure:
807
+ - Final videos (4 formats)
808
+ - CDG/TXT ZIP packages (and extracts individual files)
809
+ - Lyrics files (.ass, .lrc, .txt)
810
+ - Audio stems with descriptive names
811
+ - Title/End screen files (.mov, .jpg, .png)
812
+ - With Vocals intermediate video
813
+ """
814
+ artist = job_data.get('artist', 'Unknown')
815
+ title = job_data.get('title', 'Unknown')
816
+ brand_code = job_data.get('state_data', {}).get('brand_code')
817
+
818
+ # Use brand code in folder name if available
819
+ if brand_code:
820
+ folder_name = f"{brand_code} - {artist} - {title}"
821
+ else:
822
+ folder_name = f"{artist} - {title}"
823
+
824
+ # Sanitize folder name
825
+ folder_name = "".join(c for c in folder_name if c.isalnum() or c in " -_").strip()
826
+
827
+ output_dir = Path(self.config.output_dir) / folder_name
828
+ output_dir.mkdir(parents=True, exist_ok=True)
829
+
830
+ self.logger.info(f"Downloading output files to: {output_dir}")
831
+
832
+ # Get signed download URLs from the API
833
+ try:
834
+ download_data = self.client.get_download_urls(job_id)
835
+ download_urls = download_data.get('download_urls', {})
836
+ except Exception as e:
837
+ self.logger.warning(f"Could not get signed download URLs: {e}")
838
+ self.logger.warning("Falling back to gsutil (requires gcloud auth)")
839
+ download_urls = {}
840
+
841
+ file_urls = job_data.get('file_urls', {})
842
+ base_name = f"{artist} - {title}"
843
+
844
+ def download_file(category: str, key: str, local_path: Path, filename: str) -> bool:
845
+ """Helper to download a file using signed URL or gsutil fallback."""
846
+ # Try signed URL first
847
+ signed_url = download_urls.get(category, {}).get(key)
848
+ if signed_url:
849
+ if self.client.download_file_via_url(signed_url, str(local_path)):
850
+ return True
851
+
852
+ # Fall back to gsutil
853
+ gcs_path = file_urls.get(category, {}).get(key)
854
+ if gcs_path:
855
+ return self.client.download_file_via_gsutil(gcs_path, str(local_path))
856
+ return False
857
+
858
+ # Download final videos
859
+ finals = file_urls.get('finals', {})
860
+ if finals:
861
+ self.logger.info("Downloading final videos...")
862
+ for key, blob_path in finals.items():
863
+ if blob_path:
864
+ # Use descriptive filename
865
+ if 'lossless_4k_mp4' in key:
866
+ filename = f"{base_name} (Final Karaoke Lossless 4k).mp4"
867
+ elif 'lossless_4k_mkv' in key:
868
+ filename = f"{base_name} (Final Karaoke Lossless 4k).mkv"
869
+ elif 'lossy_4k' in key:
870
+ filename = f"{base_name} (Final Karaoke Lossy 4k).mp4"
871
+ elif 'lossy_720p' in key:
872
+ filename = f"{base_name} (Final Karaoke Lossy 720p).mp4"
873
+ else:
874
+ filename = Path(blob_path).name
875
+
876
+ local_path = output_dir / filename
877
+ self.logger.info(f" Downloading {filename}...")
878
+ if download_file('finals', key, local_path, filename):
879
+ self.logger.info(f" OK: {local_path}")
880
+ else:
881
+ self.logger.warning(f" FAILED: {filename}")
882
+
883
+ # Download CDG/TXT packages
884
+ packages = file_urls.get('packages', {})
885
+ if packages:
886
+ self.logger.info("Downloading karaoke packages...")
887
+ for key, blob_path in packages.items():
888
+ if blob_path:
889
+ if 'cdg' in key.lower():
890
+ filename = f"{base_name} (Final Karaoke CDG).zip"
891
+ elif 'txt' in key.lower():
892
+ filename = f"{base_name} (Final Karaoke TXT).zip"
893
+ else:
894
+ filename = Path(blob_path).name
895
+
896
+ local_path = output_dir / filename
897
+ self.logger.info(f" Downloading {filename}...")
898
+ if download_file('packages', key, local_path, filename):
899
+ self.logger.info(f" OK: {local_path}")
900
+
901
+ # Extract CDG files to match local CLI (individual .cdg and .mp3 at root)
902
+ if 'cdg' in key.lower():
903
+ self._extract_cdg_files(local_path, output_dir, base_name)
904
+ else:
905
+ self.logger.warning(f" FAILED: {filename}")
906
+
907
+ # Download lyrics files
908
+ lyrics = file_urls.get('lyrics', {})
909
+ if lyrics:
910
+ self.logger.info("Downloading lyrics files...")
911
+ for key in ['ass', 'lrc', 'corrected_txt']:
912
+ blob_path = lyrics.get(key)
913
+ if blob_path:
914
+ ext = Path(blob_path).suffix
915
+ filename = f"{base_name} (Karaoke){ext}"
916
+ local_path = output_dir / filename
917
+ self.logger.info(f" Downloading {filename}...")
918
+ if download_file('lyrics', key, local_path, filename):
919
+ self.logger.info(f" OK: {local_path}")
920
+ else:
921
+ self.logger.warning(f" FAILED: {filename}")
922
+
923
+ # Download title/end screen files (video + images)
924
+ screens = file_urls.get('screens', {})
925
+ if screens:
926
+ self.logger.info("Downloading title/end screens...")
927
+ screen_mappings = {
928
+ 'title': f"{base_name} (Title).mov",
929
+ 'title_jpg': f"{base_name} (Title).jpg",
930
+ 'title_png': f"{base_name} (Title).png",
931
+ 'end': f"{base_name} (End).mov",
932
+ 'end_jpg': f"{base_name} (End).jpg",
933
+ 'end_png': f"{base_name} (End).png",
934
+ }
935
+ for key, filename in screen_mappings.items():
936
+ blob_path = screens.get(key)
937
+ if blob_path:
938
+ local_path = output_dir / filename
939
+ self.logger.info(f" Downloading {filename}...")
940
+ if download_file('screens', key, local_path, filename):
941
+ self.logger.info(f" OK: {local_path}")
942
+ else:
943
+ self.logger.warning(f" FAILED: {filename}")
944
+
945
+ # Download with_vocals intermediate video
946
+ videos = file_urls.get('videos', {})
947
+ if videos:
948
+ self.logger.info("Downloading intermediate videos...")
949
+ if videos.get('with_vocals'):
950
+ filename = f"{base_name} (With Vocals).mkv"
951
+ local_path = output_dir / filename
952
+ self.logger.info(f" Downloading {filename}...")
953
+ if download_file('videos', 'with_vocals', local_path, filename):
954
+ self.logger.info(f" OK: {local_path}")
955
+ else:
956
+ self.logger.warning(f" FAILED: {filename}")
957
+
958
+ # Download stems with descriptive names
959
+ stems = file_urls.get('stems', {})
960
+ if stems:
961
+ stems_dir = output_dir / 'stems'
962
+ stems_dir.mkdir(exist_ok=True)
963
+ self.logger.info("Downloading audio stems...")
964
+
965
+ # Map backend stem names to local CLI naming convention
966
+ stem_name_mappings = {
967
+ 'instrumental_clean': f"{base_name} (Instrumental model_bs_roformer_ep_317_sdr_12.9755.ckpt).flac",
968
+ 'instrumental_with_backing': f"{base_name} (Instrumental +BV mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt).flac",
969
+ 'vocals_clean': f"{base_name} (Vocals model_bs_roformer_ep_317_sdr_12.9755.ckpt).flac",
970
+ 'lead_vocals': f"{base_name} (Lead Vocals mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt).flac",
971
+ 'backing_vocals': f"{base_name} (Backing Vocals mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt).flac",
972
+ 'bass': f"{base_name} (Bass htdemucs_6s.yaml).flac",
973
+ 'drums': f"{base_name} (Drums htdemucs_6s.yaml).flac",
974
+ 'guitar': f"{base_name} (Guitar htdemucs_6s.yaml).flac",
975
+ 'piano': f"{base_name} (Piano htdemucs_6s.yaml).flac",
976
+ 'other': f"{base_name} (Other htdemucs_6s.yaml).flac",
977
+ 'vocals': f"{base_name} (Vocals htdemucs_6s.yaml).flac",
978
+ }
979
+
980
+ for key, blob_path in stems.items():
981
+ if blob_path:
982
+ # Use descriptive filename if available, otherwise use GCS filename
983
+ filename = stem_name_mappings.get(key, Path(blob_path).name)
984
+ local_path = stems_dir / filename
985
+ self.logger.info(f" Downloading {filename}...")
986
+ if download_file('stems', key, local_path, filename):
987
+ self.logger.info(f" OK: {local_path}")
988
+ else:
989
+ self.logger.warning(f" FAILED: {filename}")
990
+
991
+ # Also copy instrumental files to root directory (matching local CLI)
992
+ for src_key, dest_suffix in [
993
+ ('instrumental_clean', 'Instrumental model_bs_roformer_ep_317_sdr_12.9755.ckpt'),
994
+ ('instrumental_with_backing', 'Instrumental +BV mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt'),
995
+ ]:
996
+ if stems.get(src_key):
997
+ stem_file = stems_dir / stem_name_mappings.get(src_key, '')
998
+ if stem_file.exists():
999
+ dest_file = output_dir / f"{base_name} ({dest_suffix}).flac"
1000
+ try:
1001
+ import shutil
1002
+ shutil.copy2(stem_file, dest_file)
1003
+ self.logger.info(f" Copied to root: {dest_file.name}")
1004
+ except Exception as e:
1005
+ self.logger.warning(f" Failed to copy {dest_file.name}: {e}")
1006
+
1007
+ self.logger.info("")
1008
+ self.logger.info(f"All files downloaded to: {output_dir}")
1009
+
1010
+ # Show summary
1011
+ state_data = job_data.get('state_data', {})
1012
+ if brand_code:
1013
+ self.logger.info(f"Brand Code: {brand_code}")
1014
+
1015
+ youtube_url = state_data.get('youtube_url')
1016
+ if youtube_url:
1017
+ self.logger.info(f"YouTube URL: {youtube_url}")
1018
+
1019
+ # List downloaded files with sizes
1020
+ self.logger.info("")
1021
+ self.logger.info("Downloaded files:")
1022
+ total_size = 0
1023
+ for file_path in sorted(output_dir.rglob('*')):
1024
+ if file_path.is_file():
1025
+ size = file_path.stat().st_size
1026
+ total_size += size
1027
+ if size > 1024 * 1024:
1028
+ size_str = f"{size / (1024 * 1024):.1f} MB"
1029
+ elif size > 1024:
1030
+ size_str = f"{size / 1024:.1f} KB"
1031
+ else:
1032
+ size_str = f"{size} B"
1033
+ rel_path = file_path.relative_to(output_dir)
1034
+ self.logger.info(f" {rel_path} ({size_str})")
1035
+
1036
+ if total_size > 1024 * 1024 * 1024:
1037
+ total_str = f"{total_size / (1024 * 1024 * 1024):.2f} GB"
1038
+ elif total_size > 1024 * 1024:
1039
+ total_str = f"{total_size / (1024 * 1024):.1f} MB"
1040
+ else:
1041
+ total_str = f"{total_size / 1024:.1f} KB"
1042
+ self.logger.info(f"Total: {total_str}")
1043
+
1044
+ def _extract_cdg_files(self, zip_path: Path, output_dir: Path, base_name: str) -> None:
1045
+ """
1046
+ Extract individual .cdg and .mp3 files from CDG ZIP to match local CLI output.
1047
+
1048
+ Local CLI produces both:
1049
+ - Artist - Title (Final Karaoke CDG).zip (containing .cdg + .mp3)
1050
+ - Artist - Title (Karaoke).cdg (individual file at root)
1051
+ - Artist - Title (Karaoke).mp3 (individual file at root)
1052
+
1053
+ Args:
1054
+ zip_path: Path to the CDG ZIP file
1055
+ output_dir: Output directory for extracted files
1056
+ base_name: Base name for output files (Artist - Title)
1057
+ """
1058
+ import zipfile
1059
+
1060
+ try:
1061
+ with zipfile.ZipFile(zip_path, 'r') as zf:
1062
+ for member in zf.namelist():
1063
+ ext = Path(member).suffix.lower()
1064
+ if ext in ['.cdg', '.mp3']:
1065
+ # Extract with correct naming
1066
+ filename = f"{base_name} (Karaoke){ext}"
1067
+ extract_path = output_dir / filename
1068
+
1069
+ # Read from zip and write to destination
1070
+ with zf.open(member) as src:
1071
+ with open(extract_path, 'wb') as dst:
1072
+ dst.write(src.read())
1073
+
1074
+ self.logger.info(f" Extracted: {filename}")
1075
+ except Exception as e:
1076
+ self.logger.warning(f" Failed to extract CDG files: {e}")
1077
+
1078
+ def log_timeline_updates(self, job_data: Dict[str, Any]) -> None:
1079
+ """Log any new timeline events."""
1080
+ timeline = job_data.get('timeline', [])
1081
+
1082
+ # Log any new events since last check
1083
+ for i, event in enumerate(timeline):
1084
+ if i >= self._last_timeline_index:
1085
+ timestamp = event.get('timestamp', '')
1086
+ status = event.get('status', '')
1087
+ message = event.get('message', '')
1088
+ progress = event.get('progress', '')
1089
+
1090
+ # Format timestamp if present
1091
+ if timestamp:
1092
+ # Truncate to just time portion if it's a full ISO timestamp
1093
+ if 'T' in timestamp:
1094
+ timestamp = timestamp.split('T')[1][:8]
1095
+
1096
+ log_parts = []
1097
+ if timestamp:
1098
+ log_parts.append(f"[{timestamp}]")
1099
+ if status:
1100
+ log_parts.append(f"[{status}]")
1101
+ if progress:
1102
+ log_parts.append(f"[{progress}%]")
1103
+ if message:
1104
+ log_parts.append(message)
1105
+
1106
+ if log_parts:
1107
+ self.logger.info(" ".join(log_parts))
1108
+
1109
+ self._last_timeline_index = len(timeline)
1110
+
1111
+ def log_worker_logs(self, job_id: str) -> None:
1112
+ """Fetch and display any new worker logs."""
1113
+ if not self._show_worker_logs:
1114
+ return
1115
+
1116
+ try:
1117
+ result = self.client.get_worker_logs(job_id, since_index=self._last_log_index)
1118
+ logs = result.get('logs', [])
1119
+
1120
+ for log_entry in logs:
1121
+ timestamp = log_entry.get('timestamp', '')
1122
+ level = log_entry.get('level', 'INFO')
1123
+ worker = log_entry.get('worker', 'worker')
1124
+ message = log_entry.get('message', '')
1125
+
1126
+ # Format timestamp (just time portion)
1127
+ if timestamp and 'T' in timestamp:
1128
+ timestamp = timestamp.split('T')[1][:8]
1129
+
1130
+ # Color-code by level (using ASCII codes for terminal)
1131
+ if level == 'ERROR':
1132
+ level_prefix = f"\033[91m{level}\033[0m" # Red
1133
+ elif level == 'WARNING':
1134
+ level_prefix = f"\033[93m{level}\033[0m" # Yellow
1135
+ else:
1136
+ level_prefix = level
1137
+
1138
+ # Format: [HH:MM:SS] [worker:level] message
1139
+ log_line = f" [{timestamp}] [{worker}:{level_prefix}] {message}"
1140
+
1141
+ # Use appropriate log level
1142
+ if level == 'ERROR':
1143
+ self.logger.error(log_line)
1144
+ elif level == 'WARNING':
1145
+ self.logger.warning(log_line)
1146
+ else:
1147
+ self.logger.info(log_line)
1148
+
1149
+ # Update index for next poll
1150
+ self._last_log_index = result.get('next_index', self._last_log_index)
1151
+
1152
+ except Exception as e:
1153
+ # Log the error but don't fail
1154
+ self.logger.debug(f"Error fetching worker logs: {e}")
1155
+
1156
+ def monitor(self, job_id: str) -> int:
1157
+ """Monitor job progress until completion."""
1158
+ last_status = ""
1159
+
1160
+ self.logger.info(f"Monitoring job: {job_id}")
1161
+ self.logger.info(f"Service URL: {self.config.service_url}")
1162
+ self.logger.info(f"Polling every {self.config.poll_interval} seconds...")
1163
+ self.logger.info("")
1164
+
1165
+ while True:
1166
+ try:
1167
+ job_data = self.client.get_job(job_id)
1168
+
1169
+ status = job_data.get('status', 'unknown')
1170
+ artist = job_data.get('artist', '')
1171
+ title = job_data.get('title', '')
1172
+
1173
+ # Track whether we got any new updates this poll
1174
+ had_updates = False
1175
+ prev_timeline_index = self._last_timeline_index
1176
+ prev_log_index = self._last_log_index
1177
+
1178
+ # Log timeline updates (shows status changes and progress)
1179
+ self.log_timeline_updates(job_data)
1180
+ if self._last_timeline_index > prev_timeline_index:
1181
+ had_updates = True
1182
+
1183
+ # Log worker logs (shows detailed worker output for debugging)
1184
+ self.log_worker_logs(job_id)
1185
+ if self._last_log_index > prev_log_index:
1186
+ had_updates = True
1187
+
1188
+ # Log status changes with user-friendly descriptions
1189
+ if status != last_status:
1190
+ description = self._get_status_description(status)
1191
+ if last_status:
1192
+ self.logger.info(f"Status: {status} - {description}")
1193
+ else:
1194
+ self.logger.info(f"Current status: {status} - {description}")
1195
+ last_status = status
1196
+ had_updates = True
1197
+
1198
+ # Heartbeat: if no updates for a while, show we're still alive
1199
+ if had_updates:
1200
+ self._polls_without_updates = 0
1201
+ else:
1202
+ self._polls_without_updates += 1
1203
+ if self._polls_without_updates >= self._heartbeat_interval:
1204
+ description = self._get_status_description(status)
1205
+ self.logger.info(f" [Still processing: {description}]")
1206
+ self._polls_without_updates = 0
1207
+
1208
+ # Handle human interaction points
1209
+ if status in ['awaiting_review', 'in_review']:
1210
+ if not self._review_opened:
1211
+ self.logger.info("")
1212
+ self.handle_review(job_id)
1213
+ self._review_opened = True
1214
+ self._last_timeline_index = 0 # Reset to catch any events during review
1215
+ # Refresh auth token after potentially long review
1216
+ self.client.refresh_auth()
1217
+
1218
+ elif status == 'awaiting_instrumental_selection':
1219
+ if not self._instrumental_prompted:
1220
+ self.logger.info("")
1221
+ self.handle_instrumental_selection(job_id)
1222
+ self._instrumental_prompted = True
1223
+
1224
+ elif status == 'complete':
1225
+ self.logger.info("")
1226
+ self.logger.info("=" * 60)
1227
+ self.logger.info("JOB COMPLETE!")
1228
+ self.logger.info("=" * 60)
1229
+ self.logger.info(f"Track: {artist} - {title}")
1230
+ self.logger.info("")
1231
+ self.download_outputs(job_id, job_data)
1232
+ return 0
1233
+
1234
+ elif status in ['failed', 'error']:
1235
+ self.logger.info("")
1236
+ self.logger.error("=" * 60)
1237
+ self.logger.error("JOB FAILED")
1238
+ self.logger.error("=" * 60)
1239
+ error_message = job_data.get('error_message', 'Unknown error')
1240
+ self.logger.error(f"Error: {error_message}")
1241
+ error_details = job_data.get('error_details')
1242
+ if error_details:
1243
+ self.logger.error(f"Details: {json.dumps(error_details, indent=2)}")
1244
+ return 1
1245
+
1246
+ elif status == 'cancelled':
1247
+ self.logger.info("")
1248
+ self.logger.warning("Job was cancelled")
1249
+ return 1
1250
+
1251
+ time.sleep(self.config.poll_interval)
1252
+
1253
+ except KeyboardInterrupt:
1254
+ self.logger.info("")
1255
+ self.logger.warning(f"Monitoring interrupted. Job ID: {job_id}")
1256
+ self.logger.info(f"Resume with: karaoke-gen-remote --resume {job_id}")
1257
+ return 130
1258
+ except Exception as e:
1259
+ self.logger.warning(f"Error polling job status: {e}")
1260
+ time.sleep(self.config.poll_interval)
1261
+
1262
+
1263
+ def check_prerequisites(logger: logging.Logger) -> bool:
1264
+ """Check that required tools are available."""
1265
+ # Check for gcloud
1266
+ try:
1267
+ subprocess.run(['gcloud', '--version'], capture_output=True, check=True)
1268
+ except (subprocess.CalledProcessError, FileNotFoundError):
1269
+ logger.warning("gcloud CLI not found. Authentication may be limited.")
1270
+
1271
+ # Check for gsutil
1272
+ try:
1273
+ subprocess.run(['gsutil', 'version'], capture_output=True, check=True)
1274
+ except (subprocess.CalledProcessError, FileNotFoundError):
1275
+ logger.warning("gsutil not found. File downloads may fail. Install with: pip install gsutil")
1276
+
1277
+ return True
1278
+
1279
+
1280
+ def get_auth_token(logger: logging.Logger) -> Optional[str]:
1281
+ """Get authentication token from environment or gcloud."""
1282
+ # Check environment variable first
1283
+ token = os.environ.get('KARAOKE_GEN_AUTH_TOKEN')
1284
+ if token:
1285
+ return token
1286
+
1287
+ # Try gcloud
1288
+ try:
1289
+ result = subprocess.run(
1290
+ ['gcloud', 'auth', 'print-identity-token'],
1291
+ capture_output=True,
1292
+ text=True,
1293
+ check=True
1294
+ )
1295
+ return result.stdout.strip()
1296
+ except (subprocess.CalledProcessError, FileNotFoundError):
1297
+ return None
1298
+
1299
+
1300
+ def main():
1301
+ """Main entry point for the remote CLI."""
1302
+ # Set up logging - same format as gen_cli.py
1303
+ logger = logging.getLogger(__name__)
1304
+ log_handler = logging.StreamHandler()
1305
+ log_formatter = logging.Formatter(
1306
+ fmt="%(asctime)s.%(msecs)03d - %(levelname)s - %(module)s - %(message)s",
1307
+ datefmt="%Y-%m-%d %H:%M:%S"
1308
+ )
1309
+ log_handler.setFormatter(log_formatter)
1310
+ logger.addHandler(log_handler)
1311
+
1312
+ # Use shared CLI parser
1313
+ parser = create_parser(prog="karaoke-gen-remote")
1314
+ args = parser.parse_args()
1315
+
1316
+ # Set log level
1317
+ log_level = getattr(logging, args.log_level.upper())
1318
+ logger.setLevel(log_level)
1319
+
1320
+ # Check for KARAOKE_GEN_URL - this is REQUIRED for remote mode
1321
+ if not args.service_url:
1322
+ logger.error("KARAOKE_GEN_URL environment variable is required for karaoke-gen-remote")
1323
+ logger.error("")
1324
+ logger.error("Please set it to your cloud backend URL:")
1325
+ logger.error(" export KARAOKE_GEN_URL=https://your-backend.run.app")
1326
+ logger.error("")
1327
+ logger.error("Or pass it via command line:")
1328
+ logger.error(" karaoke-gen-remote --service-url https://your-backend.run.app ...")
1329
+ return 1
1330
+
1331
+ # Check prerequisites
1332
+ check_prerequisites(logger)
1333
+
1334
+ # Get auth token from environment variable
1335
+ auth_token = get_auth_token(logger)
1336
+
1337
+ # Create config
1338
+ config = Config(
1339
+ service_url=args.service_url.rstrip('/'),
1340
+ review_ui_url=args.review_ui_url.rstrip('/'),
1341
+ poll_interval=args.poll_interval,
1342
+ output_dir=args.output_dir,
1343
+ auth_token=auth_token,
1344
+ non_interactive=getattr(args, 'yes', False), # -y / --yes flag
1345
+ # Job tracking metadata
1346
+ environment=getattr(args, 'environment', ''),
1347
+ client_id=getattr(args, 'client_id', ''),
1348
+ )
1349
+
1350
+ # Create client
1351
+ client = RemoteKaraokeClient(config, logger)
1352
+ monitor = JobMonitor(client, config, logger)
1353
+
1354
+ # Handle resume mode
1355
+ if args.resume:
1356
+ logger.info("=" * 60)
1357
+ logger.info("Karaoke Generator (Remote) - Resume Job")
1358
+ logger.info("=" * 60)
1359
+ logger.info(f"Job ID: {args.resume}")
1360
+
1361
+ try:
1362
+ # Verify job exists
1363
+ job_data = client.get_job(args.resume)
1364
+ artist = job_data.get('artist', 'Unknown')
1365
+ title = job_data.get('title', 'Unknown')
1366
+ status = job_data.get('status', 'unknown')
1367
+
1368
+ logger.info(f"Artist: {artist}")
1369
+ logger.info(f"Title: {title}")
1370
+ logger.info(f"Current status: {status}")
1371
+ logger.info("")
1372
+
1373
+ return monitor.monitor(args.resume)
1374
+ except ValueError as e:
1375
+ logger.error(str(e))
1376
+ return 1
1377
+ except Exception as e:
1378
+ logger.error(f"Error resuming job: {e}")
1379
+ return 1
1380
+
1381
+ # Handle bulk delete mode
1382
+ if getattr(args, 'bulk_delete', False):
1383
+ filter_env = getattr(args, 'filter_environment', None)
1384
+ filter_client = getattr(args, 'filter_client_id', None)
1385
+
1386
+ if not filter_env and not filter_client:
1387
+ logger.error("Bulk delete requires at least one filter: --filter-environment or --filter-client-id")
1388
+ return 1
1389
+
1390
+ logger.info("=" * 60)
1391
+ logger.info("Karaoke Generator (Remote) - Bulk Delete Jobs")
1392
+ logger.info("=" * 60)
1393
+ if filter_env:
1394
+ logger.info(f"Environment filter: {filter_env}")
1395
+ if filter_client:
1396
+ logger.info(f"Client ID filter: {filter_client}")
1397
+ logger.info("")
1398
+
1399
+ try:
1400
+ # First get preview
1401
+ result = client.bulk_delete_jobs(
1402
+ environment=filter_env,
1403
+ client_id=filter_client,
1404
+ confirm=False
1405
+ )
1406
+
1407
+ jobs_to_delete = result.get('jobs_to_delete', 0)
1408
+ sample_jobs = result.get('sample_jobs', [])
1409
+
1410
+ if jobs_to_delete == 0:
1411
+ logger.info("No jobs match the specified filters.")
1412
+ return 0
1413
+
1414
+ logger.info(f"Found {jobs_to_delete} jobs matching filters:")
1415
+ logger.info("")
1416
+
1417
+ # Show sample
1418
+ for job in sample_jobs:
1419
+ logger.info(f" {job.get('job_id', 'unknown')[:10]}: {job.get('artist', 'Unknown')} - {job.get('title', 'Unknown')} ({job.get('status', 'unknown')})")
1420
+
1421
+ if len(sample_jobs) < jobs_to_delete:
1422
+ logger.info(f" ... and {jobs_to_delete - len(sample_jobs)} more")
1423
+
1424
+ logger.info("")
1425
+
1426
+ # Confirm unless -y flag is set
1427
+ if not config.non_interactive:
1428
+ confirm = input(f"Are you sure you want to delete {jobs_to_delete} jobs and all their files? [y/N]: ")
1429
+ if confirm.lower() != 'y':
1430
+ logger.info("Bulk deletion cancelled.")
1431
+ return 0
1432
+
1433
+ # Execute deletion
1434
+ result = client.bulk_delete_jobs(
1435
+ environment=filter_env,
1436
+ client_id=filter_client,
1437
+ confirm=True
1438
+ )
1439
+
1440
+ logger.info(f"✓ Deleted {result.get('jobs_deleted', 0)} jobs")
1441
+ if result.get('files_deleted'):
1442
+ logger.info(f"✓ Cleaned up files from {result.get('files_deleted', 0)} jobs")
1443
+ return 0
1444
+
1445
+ except Exception as e:
1446
+ logger.error(f"Error bulk deleting jobs: {e}")
1447
+ return 1
1448
+
1449
+ # Handle list jobs mode
1450
+ if getattr(args, 'list_jobs', False):
1451
+ filter_env = getattr(args, 'filter_environment', None)
1452
+ filter_client = getattr(args, 'filter_client_id', None)
1453
+
1454
+ logger.info("=" * 60)
1455
+ logger.info("Karaoke Generator (Remote) - List Jobs")
1456
+ logger.info("=" * 60)
1457
+ if filter_env:
1458
+ logger.info(f"Environment filter: {filter_env}")
1459
+ if filter_client:
1460
+ logger.info(f"Client ID filter: {filter_client}")
1461
+ logger.info("")
1462
+
1463
+ try:
1464
+ jobs = client.list_jobs(
1465
+ environment=filter_env,
1466
+ client_id=filter_client,
1467
+ limit=100
1468
+ )
1469
+
1470
+ if not jobs:
1471
+ logger.info("No jobs found.")
1472
+ return 0
1473
+
1474
+ # Print header - include environment/client if available
1475
+ logger.info(f"{'JOB ID':<12} {'STATUS':<25} {'ENV':<8} {'ARTIST':<18} {'TITLE':<25}")
1476
+ logger.info("-" * 92)
1477
+
1478
+ # Print each job
1479
+ for job in jobs:
1480
+ # Use 'or' to handle None values (not just missing keys)
1481
+ job_id = (job.get('job_id') or 'unknown')[:10]
1482
+ status = (job.get('status') or 'unknown')[:23]
1483
+ artist = (job.get('artist') or 'Unknown')[:16]
1484
+ title = (job.get('title') or 'Unknown')[:23]
1485
+ # Get environment from request_metadata
1486
+ req_metadata = job.get('request_metadata') or {}
1487
+ env = (req_metadata.get('environment') or '-')[:6]
1488
+ logger.info(f"{job_id:<12} {status:<25} {env:<8} {artist:<18} {title:<25}")
1489
+
1490
+ logger.info("")
1491
+ logger.info(f"Total: {len(jobs)} jobs")
1492
+ logger.info("")
1493
+ logger.info("To retry a failed job: karaoke-gen-remote --retry <JOB_ID>")
1494
+ logger.info("To delete a job: karaoke-gen-remote --delete <JOB_ID>")
1495
+ logger.info("To bulk delete: karaoke-gen-remote --bulk-delete --filter-environment=test")
1496
+ logger.info("To cancel a job: karaoke-gen-remote --cancel <JOB_ID>")
1497
+ return 0
1498
+
1499
+ except Exception as e:
1500
+ logger.error(f"Error listing jobs: {e}")
1501
+ return 1
1502
+
1503
+ # Handle cancel job mode
1504
+ if args.cancel:
1505
+ logger.info("=" * 60)
1506
+ logger.info("Karaoke Generator (Remote) - Cancel Job")
1507
+ logger.info("=" * 60)
1508
+ logger.info(f"Job ID: {args.cancel}")
1509
+
1510
+ try:
1511
+ # Get job info first
1512
+ job_data = client.get_job(args.cancel)
1513
+ artist = job_data.get('artist', 'Unknown')
1514
+ title = job_data.get('title', 'Unknown')
1515
+ status = job_data.get('status', 'unknown')
1516
+
1517
+ logger.info(f"Artist: {artist}")
1518
+ logger.info(f"Title: {title}")
1519
+ logger.info(f"Current status: {status}")
1520
+ logger.info("")
1521
+
1522
+ # Cancel the job
1523
+ result = client.cancel_job(args.cancel)
1524
+ logger.info(f"✓ Job cancelled successfully")
1525
+ return 0
1526
+
1527
+ except ValueError as e:
1528
+ logger.error(str(e))
1529
+ return 1
1530
+ except RuntimeError as e:
1531
+ logger.error(str(e))
1532
+ return 1
1533
+ except Exception as e:
1534
+ logger.error(f"Error cancelling job: {e}")
1535
+ return 1
1536
+
1537
+ # Handle retry job mode
1538
+ if args.retry:
1539
+ logger.info("=" * 60)
1540
+ logger.info("Karaoke Generator (Remote) - Retry Failed Job")
1541
+ logger.info("=" * 60)
1542
+ logger.info(f"Job ID: {args.retry}")
1543
+
1544
+ try:
1545
+ # Get job info first
1546
+ job_data = client.get_job(args.retry)
1547
+ artist = job_data.get('artist', 'Unknown')
1548
+ title = job_data.get('title', 'Unknown')
1549
+ status = job_data.get('status', 'unknown')
1550
+ error_message = job_data.get('error_message', 'No error message')
1551
+
1552
+ logger.info(f"Artist: {artist}")
1553
+ logger.info(f"Title: {title}")
1554
+ logger.info(f"Current status: {status}")
1555
+ if status == 'failed':
1556
+ logger.info(f"Error: {error_message}")
1557
+ logger.info("")
1558
+
1559
+ if status != 'failed':
1560
+ logger.error(f"Only failed jobs can be retried (current status: {status})")
1561
+ return 1
1562
+
1563
+ # Retry the job
1564
+ result = client.retry_job(args.retry)
1565
+ retry_stage = result.get('retry_stage', 'unknown')
1566
+ logger.info(f"✓ Job retry started from stage: {retry_stage}")
1567
+ logger.info("")
1568
+ logger.info(f"Monitoring job progress...")
1569
+ logger.info("")
1570
+
1571
+ # Monitor the retried job
1572
+ return monitor.monitor(args.retry)
1573
+
1574
+ except ValueError as e:
1575
+ logger.error(str(e))
1576
+ return 1
1577
+ except RuntimeError as e:
1578
+ logger.error(str(e))
1579
+ return 1
1580
+ except Exception as e:
1581
+ logger.error(f"Error retrying job: {e}")
1582
+ return 1
1583
+
1584
+ # Handle delete job mode
1585
+ if args.delete:
1586
+ logger.info("=" * 60)
1587
+ logger.info("Karaoke Generator (Remote) - Delete Job")
1588
+ logger.info("=" * 60)
1589
+ logger.info(f"Job ID: {args.delete}")
1590
+
1591
+ try:
1592
+ # Get job info first
1593
+ job_data = client.get_job(args.delete)
1594
+ artist = job_data.get('artist', 'Unknown')
1595
+ title = job_data.get('title', 'Unknown')
1596
+ status = job_data.get('status', 'unknown')
1597
+
1598
+ logger.info(f"Artist: {artist}")
1599
+ logger.info(f"Title: {title}")
1600
+ logger.info(f"Status: {status}")
1601
+ logger.info("")
1602
+
1603
+ # Confirm deletion unless -y flag is set
1604
+ if not config.non_interactive:
1605
+ confirm = input("Are you sure you want to delete this job and all its files? [y/N]: ")
1606
+ if confirm.lower() != 'y':
1607
+ logger.info("Deletion cancelled.")
1608
+ return 0
1609
+
1610
+ # Delete the job
1611
+ result = client.delete_job(args.delete, delete_files=True)
1612
+ logger.info(f"✓ Job deleted successfully (including all files)")
1613
+ return 0
1614
+
1615
+ except ValueError as e:
1616
+ logger.error(str(e))
1617
+ return 1
1618
+ except Exception as e:
1619
+ logger.error(f"Error deleting job: {e}")
1620
+ return 1
1621
+
1622
+ # Warn about unsupported features
1623
+ if args.finalise_only:
1624
+ logger.error("--finalise-only is not supported in remote mode")
1625
+ return 1
1626
+
1627
+ if args.edit_lyrics:
1628
+ logger.error("--edit-lyrics is not yet supported in remote mode")
1629
+ return 1
1630
+
1631
+ if args.test_email_template:
1632
+ logger.error("--test_email_template is not supported in remote mode")
1633
+ return 1
1634
+
1635
+ # Warn about features that are not yet supported in remote mode
1636
+ ignored_features = []
1637
+ if args.prep_only:
1638
+ ignored_features.append("--prep-only")
1639
+ if args.skip_separation:
1640
+ ignored_features.append("--skip-separation")
1641
+ if args.skip_transcription:
1642
+ ignored_features.append("--skip-transcription")
1643
+ if args.lyrics_only:
1644
+ ignored_features.append("--lyrics-only")
1645
+ if args.existing_instrumental:
1646
+ ignored_features.append("--existing_instrumental")
1647
+ if args.background_video:
1648
+ ignored_features.append("--background_video")
1649
+ if getattr(args, 'auto_download', False):
1650
+ ignored_features.append("--auto-download (audio search not yet supported)")
1651
+ # These are now supported but server-side handling may be partial
1652
+ if args.organised_dir:
1653
+ ignored_features.append("--organised_dir (local-only)")
1654
+ # organised_dir_rclone_root is now supported in remote mode
1655
+ if args.public_share_dir:
1656
+ ignored_features.append("--public_share_dir (local-only)")
1657
+ if args.youtube_client_secrets_file:
1658
+ ignored_features.append("--youtube_client_secrets_file (not yet implemented)")
1659
+ if args.rclone_destination:
1660
+ ignored_features.append("--rclone_destination (local-only)")
1661
+ if args.email_template_file:
1662
+ ignored_features.append("--email_template_file (not yet implemented)")
1663
+
1664
+ if ignored_features:
1665
+ logger.warning(f"The following options are not yet supported in remote mode and will be ignored:")
1666
+ for feature in ignored_features:
1667
+ logger.warning(f" - {feature}")
1668
+
1669
+ # Handle new job submission - parse input arguments same as gen_cli
1670
+ input_media, artist, title, filename_pattern = None, None, None, None
1671
+
1672
+ if not args.args:
1673
+ parser.print_help()
1674
+ return 1
1675
+
1676
+ # Allow 3 forms of positional arguments:
1677
+ # 1. URL or Media File only
1678
+ # 2. Artist and Title only
1679
+ # 3. URL, Artist, and Title
1680
+ if args.args and (is_url(args.args[0]) or is_file(args.args[0])):
1681
+ input_media = args.args[0]
1682
+ if len(args.args) > 2:
1683
+ artist = args.args[1]
1684
+ title = args.args[2]
1685
+ elif len(args.args) > 1:
1686
+ artist = args.args[1]
1687
+ else:
1688
+ logger.error("Input media provided without Artist and Title")
1689
+ return 1
1690
+ elif os.path.isdir(args.args[0]):
1691
+ logger.error("Folder processing is not yet supported in remote mode")
1692
+ return 1
1693
+ elif len(args.args) > 1:
1694
+ artist = args.args[0]
1695
+ title = args.args[1]
1696
+ logger.error("Audio search (artist+title) is not yet supported in remote mode.")
1697
+ logger.error("Please provide a local audio file path instead.")
1698
+ logger.error("")
1699
+ logger.error("For local flacfetch search, use karaoke-gen instead:")
1700
+ logger.error(f" karaoke-gen \"{artist}\" \"{title}\"")
1701
+ return 1
1702
+ else:
1703
+ parser.print_help()
1704
+ return 1
1705
+
1706
+ # For now, remote mode only supports file uploads
1707
+ if not input_media or not os.path.isfile(input_media):
1708
+ logger.error("Remote mode currently only supports local file uploads")
1709
+ logger.error("Please provide a path to an audio file (mp3, wav, flac, m4a, ogg, aac)")
1710
+ return 1
1711
+
1712
+ # Validate artist and title are provided
1713
+ if not artist or not title:
1714
+ logger.error("Artist and Title are required")
1715
+ parser.print_help()
1716
+ return 1
1717
+
1718
+ logger.info("=" * 60)
1719
+ logger.info("Karaoke Generator (Remote) - Job Submission")
1720
+ logger.info("=" * 60)
1721
+ logger.info(f"File: {input_media}")
1722
+ logger.info(f"Artist: {artist}")
1723
+ logger.info(f"Title: {title}")
1724
+ if args.style_params_json:
1725
+ logger.info(f"Style: {args.style_params_json}")
1726
+ logger.info(f"CDG: {args.enable_cdg}, TXT: {args.enable_txt}")
1727
+ if args.brand_prefix:
1728
+ logger.info(f"Brand: {args.brand_prefix}")
1729
+ if getattr(args, 'enable_youtube_upload', False):
1730
+ logger.info(f"YouTube Upload: enabled (server-side)")
1731
+ # Native API distribution (preferred for remote CLI)
1732
+ if getattr(args, 'dropbox_path', None):
1733
+ logger.info(f"Dropbox (native): {args.dropbox_path}")
1734
+ if getattr(args, 'gdrive_folder_id', None):
1735
+ logger.info(f"Google Drive (native): {args.gdrive_folder_id}")
1736
+ # Legacy rclone distribution
1737
+ if args.organised_dir_rclone_root:
1738
+ logger.info(f"Dropbox (rclone): {args.organised_dir_rclone_root}")
1739
+ if args.discord_webhook_url:
1740
+ logger.info(f"Discord: enabled")
1741
+ # Lyrics configuration
1742
+ if getattr(args, 'lyrics_artist', None):
1743
+ logger.info(f"Lyrics Artist Override: {args.lyrics_artist}")
1744
+ if getattr(args, 'lyrics_title', None):
1745
+ logger.info(f"Lyrics Title Override: {args.lyrics_title}")
1746
+ if getattr(args, 'lyrics_file', None):
1747
+ logger.info(f"Lyrics File: {args.lyrics_file}")
1748
+ if getattr(args, 'subtitle_offset_ms', 0):
1749
+ logger.info(f"Subtitle Offset: {args.subtitle_offset_ms}ms")
1750
+ logger.info(f"Service URL: {config.service_url}")
1751
+ logger.info(f"Review UI: {config.review_ui_url}")
1752
+ if config.non_interactive:
1753
+ logger.info(f"Non-interactive mode: enabled (will auto-accept defaults)")
1754
+ logger.info("")
1755
+
1756
+ # Read youtube description from file if provided
1757
+ youtube_description = None
1758
+ if args.youtube_description_file and os.path.isfile(args.youtube_description_file):
1759
+ try:
1760
+ with open(args.youtube_description_file, 'r') as f:
1761
+ youtube_description = f.read()
1762
+ logger.info(f"Loaded YouTube description from: {args.youtube_description_file}")
1763
+ except Exception as e:
1764
+ logger.warning(f"Failed to read YouTube description file: {e}")
1765
+
1766
+ try:
1767
+ # Submit job with all options
1768
+ result = client.submit_job(
1769
+ filepath=input_media,
1770
+ artist=artist,
1771
+ title=title,
1772
+ style_params_path=args.style_params_json,
1773
+ enable_cdg=args.enable_cdg,
1774
+ enable_txt=args.enable_txt,
1775
+ brand_prefix=args.brand_prefix,
1776
+ discord_webhook_url=args.discord_webhook_url,
1777
+ youtube_description=youtube_description,
1778
+ organised_dir_rclone_root=args.organised_dir_rclone_root,
1779
+ enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
1780
+ # Native API distribution (preferred for remote CLI)
1781
+ dropbox_path=getattr(args, 'dropbox_path', None),
1782
+ gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
1783
+ # Lyrics configuration
1784
+ lyrics_artist=getattr(args, 'lyrics_artist', None),
1785
+ lyrics_title=getattr(args, 'lyrics_title', None),
1786
+ lyrics_file=getattr(args, 'lyrics_file', None),
1787
+ subtitle_offset_ms=getattr(args, 'subtitle_offset_ms', 0) or 0,
1788
+ )
1789
+ job_id = result.get('job_id')
1790
+ style_assets = result.get('style_assets_uploaded', [])
1791
+ server_version = result.get('server_version', 'unknown')
1792
+
1793
+ logger.info(f"Job submitted successfully: {job_id}")
1794
+ logger.info(f"Server version: {server_version}")
1795
+ if style_assets:
1796
+ logger.info(f"Style assets uploaded: {', '.join(style_assets)}")
1797
+ logger.info("")
1798
+
1799
+ # Monitor job
1800
+ return monitor.monitor(job_id)
1801
+
1802
+ except FileNotFoundError as e:
1803
+ logger.error(str(e))
1804
+ return 1
1805
+ except ValueError as e:
1806
+ logger.error(str(e))
1807
+ return 1
1808
+ except Exception as e:
1809
+ logger.error(f"Error: {e}")
1810
+ logger.exception("Full error details:")
1811
+ return 1
1812
+
1813
+
1814
+ if __name__ == "__main__":
1815
+ sys.exit(main())