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,1833 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import shlex
5
+ import logging
6
+ import zipfile
7
+ import shutil
8
+ import re
9
+ import requests
10
+ import pickle
11
+ from lyrics_converter import LyricsConverter
12
+ from thefuzz import fuzz
13
+ from googleapiclient.discovery import build
14
+ from google_auth_oauthlib.flow import InstalledAppFlow
15
+ from google.auth.transport.requests import Request
16
+ from googleapiclient.http import MediaFileUpload
17
+ import subprocess
18
+ import time
19
+ from google.oauth2.credentials import Credentials
20
+ import base64
21
+ from email.mime.text import MIMEText
22
+ from lyrics_transcriber.output.cdg import CDGGenerator
23
+
24
+
25
+ class KaraokeFinalise:
26
+ def __init__(
27
+ self,
28
+ logger=None,
29
+ log_level=logging.DEBUG,
30
+ log_formatter=None,
31
+ dry_run=False,
32
+ instrumental_format="flac",
33
+ enable_cdg=False,
34
+ enable_txt=False,
35
+ brand_prefix=None,
36
+ organised_dir=None,
37
+ organised_dir_rclone_root=None,
38
+ public_share_dir=None,
39
+ youtube_client_secrets_file=None,
40
+ youtube_description_file=None,
41
+ rclone_destination=None,
42
+ discord_webhook_url=None,
43
+ email_template_file=None,
44
+ cdg_styles=None,
45
+ keep_brand_code=False,
46
+ non_interactive=False,
47
+ user_youtube_credentials=None, # Add support for pre-stored credentials
48
+ server_side_mode=False, # New parameter for server-side deployment
49
+ selected_instrumental_file=None, # Add support for pre-selected instrumental file
50
+ countdown_padding_seconds=None, # Padding applied to vocals; instrumental must match
51
+ ):
52
+ self.log_level = log_level
53
+ self.log_formatter = log_formatter
54
+
55
+ if logger is None:
56
+ self.logger = logging.getLogger(__name__)
57
+ self.logger.setLevel(log_level)
58
+ # Prevent log propagation to root logger to avoid duplicate logs
59
+ # when external packages (like lyrics_converter) configure root logger handlers
60
+ self.logger.propagate = False
61
+
62
+ self.log_handler = logging.StreamHandler()
63
+
64
+ if self.log_formatter is None:
65
+ self.log_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(module)s - %(message)s")
66
+
67
+ self.log_handler.setFormatter(self.log_formatter)
68
+ self.logger.addHandler(self.log_handler)
69
+ else:
70
+ self.logger = logger
71
+
72
+ self.logger.debug(
73
+ f"KaraokeFinalise instantiating, dry_run: {dry_run}, brand_prefix: {brand_prefix}, organised_dir: {organised_dir}, public_share_dir: {public_share_dir}, rclone_destination: {rclone_destination}"
74
+ )
75
+
76
+ # Path to the Windows PyInstaller frozen bundled ffmpeg.exe, or the system-installed FFmpeg binary on Mac/Linux
77
+ ffmpeg_path = os.path.join(sys._MEIPASS, "ffmpeg.exe") if getattr(sys, "frozen", False) else "ffmpeg"
78
+
79
+ self.ffmpeg_base_command = f"{ffmpeg_path} -hide_banner -nostats"
80
+
81
+ if self.log_level == logging.DEBUG:
82
+ self.ffmpeg_base_command += " -loglevel verbose"
83
+ else:
84
+ self.ffmpeg_base_command += " -loglevel fatal"
85
+
86
+ self.dry_run = dry_run
87
+ self.instrumental_format = instrumental_format
88
+
89
+ self.brand_prefix = brand_prefix
90
+ self.organised_dir = organised_dir
91
+ self.organised_dir_rclone_root = organised_dir_rclone_root
92
+
93
+ self.public_share_dir = public_share_dir
94
+ self.youtube_client_secrets_file = youtube_client_secrets_file
95
+ self.youtube_description_file = youtube_description_file
96
+ self.rclone_destination = rclone_destination
97
+ self.discord_webhook_url = discord_webhook_url
98
+ self.enable_cdg = enable_cdg
99
+ self.enable_txt = enable_txt
100
+
101
+ self.youtube_upload_enabled = False
102
+ self.discord_notication_enabled = False
103
+ self.folder_organisation_enabled = False
104
+ self.public_share_copy_enabled = False
105
+ self.public_share_rclone_enabled = False
106
+
107
+ self.skip_notifications = False
108
+ self.non_interactive = non_interactive
109
+ self.user_youtube_credentials = user_youtube_credentials
110
+ self.server_side_mode = server_side_mode
111
+ self.selected_instrumental_file = selected_instrumental_file
112
+ self.countdown_padding_seconds = countdown_padding_seconds
113
+
114
+ self.suffixes = {
115
+ "title_mov": " (Title).mov",
116
+ "title_jpg": " (Title).jpg",
117
+ "end_mov": " (End).mov",
118
+ "end_jpg": " (End).jpg",
119
+ "with_vocals_mov": " (With Vocals).mov",
120
+ "with_vocals_mp4": " (With Vocals).mp4",
121
+ "with_vocals_mkv": " (With Vocals).mkv",
122
+ "karaoke_lrc": " (Karaoke).lrc",
123
+ "karaoke_txt": " (Karaoke).txt",
124
+ "karaoke_mp4": " (Karaoke).mp4",
125
+ "karaoke_cdg": " (Karaoke).cdg",
126
+ "karaoke_mp3": " (Karaoke).mp3",
127
+ "final_karaoke_lossless_mp4": " (Final Karaoke Lossless 4k).mp4",
128
+ "final_karaoke_lossless_mkv": " (Final Karaoke Lossless 4k).mkv",
129
+ "final_karaoke_lossy_mp4": " (Final Karaoke Lossy 4k).mp4",
130
+ "final_karaoke_lossy_720p_mp4": " (Final Karaoke Lossy 720p).mp4",
131
+ "final_karaoke_cdg_zip": " (Final Karaoke CDG).zip",
132
+ "final_karaoke_txt_zip": " (Final Karaoke TXT).zip",
133
+ }
134
+
135
+ self.youtube_url_prefix = "https://www.youtube.com/watch?v="
136
+
137
+ self.youtube_url = None
138
+ self.brand_code = None
139
+ self.new_brand_code_dir = None
140
+ self.new_brand_code_dir_path = None
141
+ self.brand_code_dir_sharing_link = None
142
+
143
+ self.email_template_file = email_template_file
144
+ self.gmail_service = None
145
+
146
+ self.cdg_styles = cdg_styles
147
+
148
+ # Determine best available AAC codec
149
+ self.aac_codec = self.detect_best_aac_codec()
150
+
151
+ self.keep_brand_code = keep_brand_code
152
+
153
+ # MP4 output flags for better compatibility and streaming
154
+ self.mp4_flags = "-pix_fmt yuv420p -movflags +faststart+frag_keyframe+empty_moov"
155
+
156
+ # Update ffmpeg base command to include -y if non-interactive
157
+ if self.non_interactive:
158
+ self.ffmpeg_base_command += " -y"
159
+
160
+ # Detect and configure hardware acceleration
161
+ # TODO: Re-enable this once we figure out why the resulting MP4s are 10x larger than when encoded with x264...
162
+ self.nvenc_available = False # self.detect_nvenc_support()
163
+ self.configure_hardware_acceleration()
164
+
165
+ def check_input_files_exist(self, base_name, with_vocals_file, instrumental_audio_file):
166
+ self.logger.info(f"Checking required input files exist...")
167
+
168
+ input_files = {
169
+ "title_mov": f"{base_name}{self.suffixes['title_mov']}",
170
+ "title_jpg": f"{base_name}{self.suffixes['title_jpg']}",
171
+ "instrumental_audio": instrumental_audio_file,
172
+ "with_vocals_mov": with_vocals_file,
173
+ }
174
+
175
+ optional_input_files = {
176
+ "end_mov": f"{base_name}{self.suffixes['end_mov']}",
177
+ "end_jpg": f"{base_name}{self.suffixes['end_jpg']}",
178
+ }
179
+
180
+ if self.enable_cdg or self.enable_txt:
181
+ input_files["karaoke_lrc"] = f"{base_name}{self.suffixes['karaoke_lrc']}"
182
+
183
+ for key, file_path in input_files.items():
184
+ if not os.path.isfile(file_path):
185
+ raise Exception(f"Input file {key} not found: {file_path}")
186
+
187
+ self.logger.info(f" Input file {key} found: {file_path}")
188
+
189
+ for key, file_path in optional_input_files.items():
190
+ if not os.path.isfile(file_path):
191
+ self.logger.info(f" Optional input file {key} not found: {file_path}")
192
+
193
+ self.logger.info(f" Input file {key} found, adding to input_files: {file_path}")
194
+ input_files[key] = file_path
195
+
196
+ return input_files
197
+
198
+ def prepare_output_filenames(self, base_name):
199
+ output_files = {
200
+ "karaoke_mp4": f"{base_name}{self.suffixes['karaoke_mp4']}",
201
+ "karaoke_mp3": f"{base_name}{self.suffixes['karaoke_mp3']}",
202
+ "karaoke_cdg": f"{base_name}{self.suffixes['karaoke_cdg']}",
203
+ "with_vocals_mp4": f"{base_name}{self.suffixes['with_vocals_mp4']}",
204
+ "final_karaoke_lossless_mp4": f"{base_name}{self.suffixes['final_karaoke_lossless_mp4']}",
205
+ "final_karaoke_lossless_mkv": f"{base_name}{self.suffixes['final_karaoke_lossless_mkv']}",
206
+ "final_karaoke_lossy_mp4": f"{base_name}{self.suffixes['final_karaoke_lossy_mp4']}",
207
+ "final_karaoke_lossy_720p_mp4": f"{base_name}{self.suffixes['final_karaoke_lossy_720p_mp4']}",
208
+ }
209
+
210
+ if self.enable_cdg:
211
+ output_files["final_karaoke_cdg_zip"] = f"{base_name}{self.suffixes['final_karaoke_cdg_zip']}"
212
+
213
+ if self.enable_txt:
214
+ output_files["karaoke_txt"] = f"{base_name}{self.suffixes['karaoke_txt']}"
215
+ output_files["final_karaoke_txt_zip"] = f"{base_name}{self.suffixes['final_karaoke_txt_zip']}"
216
+
217
+ return output_files
218
+
219
+ def prompt_user_confirmation_or_raise_exception(self, prompt_message, exit_message, allow_empty=False):
220
+ if self.non_interactive:
221
+ self.logger.info(f"Non-interactive mode, automatically confirming: {prompt_message}")
222
+ return True
223
+
224
+ if not self.prompt_user_bool(prompt_message, allow_empty=allow_empty):
225
+ self.logger.error(exit_message)
226
+ raise Exception(exit_message)
227
+
228
+ def prompt_user_bool(self, prompt_message, allow_empty=False):
229
+ if self.non_interactive:
230
+ self.logger.info(f"Non-interactive mode, automatically answering yes to: {prompt_message}")
231
+ return True
232
+
233
+ options_string = "[y]/n" if allow_empty else "y/[n]"
234
+ accept_responses = ["y", "yes"]
235
+ if allow_empty:
236
+ accept_responses.append("")
237
+
238
+ print()
239
+ response = input(f"{prompt_message} {options_string} ").strip().lower()
240
+ return response in accept_responses
241
+
242
+ def validate_input_parameters_for_features(self):
243
+ self.logger.info(f"Validating input parameters for enabled features...")
244
+
245
+ current_directory = os.getcwd()
246
+ self.logger.info(f"Current directory to process: {current_directory}")
247
+
248
+ # Enable youtube upload if client secrets file is provided and is valid JSON
249
+ if self.youtube_client_secrets_file is not None and self.youtube_description_file is not None:
250
+ if not os.path.isfile(self.youtube_client_secrets_file):
251
+ raise Exception(f"YouTube client secrets file does not exist: {self.youtube_client_secrets_file}")
252
+
253
+ if not os.path.isfile(self.youtube_description_file):
254
+ raise Exception(f"YouTube description file does not exist: {self.youtube_description_file}")
255
+
256
+ # Test parsing the file as JSON to check it's valid
257
+ try:
258
+ with open(self.youtube_client_secrets_file, "r") as f:
259
+ json.load(f)
260
+ except json.JSONDecodeError as e:
261
+ raise Exception(f"YouTube client secrets file is not valid JSON: {self.youtube_client_secrets_file}") from e
262
+
263
+ self.logger.debug(f"YouTube upload checks passed, enabling YouTube upload")
264
+ self.youtube_upload_enabled = True
265
+
266
+ # Also enable YouTube upload if pre-stored credentials are provided (server-side mode)
267
+ elif self.user_youtube_credentials is not None and self.youtube_description_file is not None:
268
+ if not os.path.isfile(self.youtube_description_file):
269
+ raise Exception(f"YouTube description file does not exist: {self.youtube_description_file}")
270
+
271
+ self.logger.debug(f"Pre-stored YouTube credentials provided, enabling YouTube upload")
272
+ self.youtube_upload_enabled = True
273
+
274
+ # Enable discord notifications if webhook URL is provided and is valid URL
275
+ if self.discord_webhook_url is not None:
276
+ # Strip whitespace/newlines that may have been introduced from environment variables or secrets
277
+ self.discord_webhook_url = self.discord_webhook_url.strip()
278
+ if not self.discord_webhook_url.startswith("https://discord.com/api/webhooks/"):
279
+ raise Exception(f"Discord webhook URL is not valid: {self.discord_webhook_url}")
280
+
281
+ self.logger.debug(f"Discord webhook URL checks passed, enabling Discord notifications")
282
+ self.discord_notication_enabled = True
283
+
284
+ # Enable folder organisation if brand prefix and target directory are provided and target directory is valid
285
+ # In server-side mode, we skip the local folder organization but may still need brand codes
286
+ if self.brand_prefix is not None and self.organised_dir is not None:
287
+ if not self.server_side_mode and not os.path.isdir(self.organised_dir):
288
+ raise Exception(f"Target directory does not exist: {self.organised_dir}")
289
+
290
+ if not self.server_side_mode:
291
+ self.logger.debug(f"Brand prefix and target directory provided, enabling local folder organisation")
292
+ self.folder_organisation_enabled = True
293
+ else:
294
+ self.logger.debug(f"Server-side mode: brand prefix provided for remote organization")
295
+ self.folder_organisation_enabled = False # Disable local folder organization in server mode
296
+
297
+ # Enable public share copy if public share directory is provided and is valid directory with MP4 and CDG subdirectories
298
+ if self.public_share_dir is not None:
299
+ if not os.path.isdir(self.public_share_dir):
300
+ raise Exception(f"Public share directory does not exist: {self.public_share_dir}")
301
+
302
+ if not os.path.isdir(os.path.join(self.public_share_dir, "MP4")):
303
+ raise Exception(f"Public share directory does not contain MP4 subdirectory: {self.public_share_dir}")
304
+
305
+ if not os.path.isdir(os.path.join(self.public_share_dir, "CDG")):
306
+ raise Exception(f"Public share directory does not contain CDG subdirectory: {self.public_share_dir}")
307
+
308
+ self.logger.debug(f"Public share directory checks passed, enabling public share copy")
309
+ self.public_share_copy_enabled = True
310
+
311
+ # Enable public share rclone if rclone destination is provided
312
+ if self.rclone_destination is not None:
313
+ self.logger.debug(f"Rclone destination provided, enabling rclone sync")
314
+ self.public_share_rclone_enabled = True
315
+
316
+ # Tell user which features are enabled, prompt them to confirm before proceeding
317
+ self.logger.info(f"Enabled features:")
318
+ self.logger.info(f" CDG ZIP creation: {self.enable_cdg}")
319
+ self.logger.info(f" TXT ZIP creation: {self.enable_txt}")
320
+ self.logger.info(f" YouTube upload: {self.youtube_upload_enabled}")
321
+ self.logger.info(f" Discord notifications: {self.discord_notication_enabled}")
322
+ self.logger.info(f" Folder organisation: {self.folder_organisation_enabled}")
323
+ self.logger.info(f" Public share copy: {self.public_share_copy_enabled}")
324
+ self.logger.info(f" Public share rclone: {self.public_share_rclone_enabled}")
325
+
326
+ # Skip user confirmation in non-interactive mode for Modal deployment
327
+ if not self.non_interactive:
328
+ self.prompt_user_confirmation_or_raise_exception(
329
+ f"Confirm features enabled log messages above match your expectations for finalisation?",
330
+ "Refusing to proceed without user confirmation they're happy with enabled features.",
331
+ allow_empty=True,
332
+ )
333
+ else:
334
+ self.logger.info("Non-interactive mode: automatically confirming enabled features")
335
+
336
+ def authenticate_youtube(self):
337
+ """Authenticate with YouTube and return service object."""
338
+ from google.auth.transport.requests import Request
339
+ from google.oauth2.credentials import Credentials
340
+ from googleapiclient.discovery import build
341
+ from google_auth_oauthlib.flow import InstalledAppFlow
342
+ import pickle
343
+ import os
344
+
345
+ # Check if we have pre-stored credentials (for non-interactive mode)
346
+ if self.user_youtube_credentials and self.non_interactive:
347
+ try:
348
+ # Create credentials object from stored data
349
+ credentials = Credentials(
350
+ token=self.user_youtube_credentials['token'],
351
+ refresh_token=self.user_youtube_credentials.get('refresh_token'),
352
+ token_uri=self.user_youtube_credentials.get('token_uri'),
353
+ client_id=self.user_youtube_credentials.get('client_id'),
354
+ client_secret=self.user_youtube_credentials.get('client_secret'),
355
+ scopes=self.user_youtube_credentials.get('scopes')
356
+ )
357
+
358
+ # Refresh token if needed
359
+ if credentials.expired and credentials.refresh_token:
360
+ credentials.refresh(Request())
361
+
362
+ # Build YouTube service with credentials
363
+ youtube = build('youtube', 'v3', credentials=credentials)
364
+ self.logger.info("Successfully authenticated with YouTube using pre-stored credentials")
365
+ return youtube
366
+
367
+ except Exception as e:
368
+ self.logger.error(f"Failed to authenticate with pre-stored credentials: {str(e)}")
369
+ # Fall through to original authentication if pre-stored credentials fail
370
+
371
+ # Original authentication code for interactive mode
372
+ if self.non_interactive:
373
+ raise Exception("YouTube authentication required but running in non-interactive mode. Please pre-authenticate or disable YouTube upload.")
374
+
375
+ # Token file stores the user's access and refresh tokens for YouTube.
376
+ youtube_token_file = "/tmp/karaoke-finalise-youtube-token.pickle"
377
+
378
+ credentials = None
379
+
380
+ # Check if we have saved credentials
381
+ if os.path.exists(youtube_token_file):
382
+ with open(youtube_token_file, "rb") as token:
383
+ credentials = pickle.load(token)
384
+
385
+ # If there are no valid credentials, let the user log in.
386
+ if not credentials or not credentials.valid:
387
+ if credentials and credentials.expired and credentials.refresh_token:
388
+ credentials.refresh(Request())
389
+ else:
390
+ if self.non_interactive:
391
+ raise Exception("YouTube authentication required but running in non-interactive mode. Please pre-authenticate or disable YouTube upload.")
392
+
393
+ flow = InstalledAppFlow.from_client_secrets_file(
394
+ self.youtube_client_secrets_file, scopes=["https://www.googleapis.com/auth/youtube"]
395
+ )
396
+ credentials = flow.run_local_server(port=0) # This will open a browser for authentication
397
+
398
+ # Save the credentials for the next run
399
+ with open(youtube_token_file, "wb") as token:
400
+ pickle.dump(credentials, token)
401
+
402
+ return build("youtube", "v3", credentials=credentials)
403
+
404
+ def get_channel_id(self):
405
+ youtube = self.authenticate_youtube()
406
+
407
+ # Get the authenticated user's channel
408
+ request = youtube.channels().list(part="snippet", mine=True)
409
+ response = request.execute()
410
+
411
+ # Extract the channel ID
412
+ if "items" in response:
413
+ channel_id = response["items"][0]["id"]
414
+ return channel_id
415
+ else:
416
+ return None
417
+
418
+ def check_if_video_title_exists_on_youtube_channel(self, youtube_title):
419
+ youtube = self.authenticate_youtube()
420
+ channel_id = self.get_channel_id()
421
+
422
+ self.logger.info(f"Searching YouTube channel {channel_id} for title: {youtube_title}")
423
+ request = youtube.search().list(part="snippet", channelId=channel_id, q=youtube_title, type="video", maxResults=10)
424
+ response = request.execute()
425
+
426
+ # Check if any videos were found
427
+ if "items" in response and len(response["items"]) > 0:
428
+ for item in response["items"]:
429
+ # YouTube search API sometimes returns results from other channels even with channelId filter
430
+ # Verify the video actually belongs to our channel
431
+ result_channel_id = item["snippet"]["channelId"]
432
+ if result_channel_id != channel_id:
433
+ self.logger.debug(
434
+ f"Skipping video from different channel: {item['snippet']['title']} (channel: {result_channel_id})"
435
+ )
436
+ continue
437
+
438
+ found_title = item["snippet"]["title"]
439
+
440
+ # In server-side mode, require an exact match to avoid false positives.
441
+ # Otherwise, use fuzzy matching for interactive CLI usage.
442
+ if self.server_side_mode:
443
+ is_match = youtube_title.lower() == found_title.lower()
444
+ similarity_score = 100 if is_match else 0
445
+ else:
446
+ similarity_score = fuzz.ratio(youtube_title.lower(), found_title.lower())
447
+ is_match = similarity_score >= 70
448
+
449
+ if is_match:
450
+ found_id = item["id"]["videoId"]
451
+ self.logger.info(
452
+ f"Potential match found on YouTube channel with ID: {found_id} and title: {found_title} (similarity: {similarity_score}%)"
453
+ )
454
+
455
+ # In non-interactive mode (server mode), we don't prompt. Just record the match and return.
456
+ if self.non_interactive:
457
+ self.logger.info(f"Non-interactive mode, found a match.")
458
+ self.youtube_video_id = found_id
459
+ self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
460
+ return True
461
+
462
+ confirmation = input(f"Is '{found_title}' the video you are finalising? (y/n): ").strip().lower()
463
+ if confirmation == "y":
464
+ self.youtube_video_id = found_id
465
+ self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
466
+ return True
467
+
468
+ self.logger.info(f"No matching video found with title: {youtube_title}")
469
+ return False
470
+
471
+ def delete_youtube_video(self, video_id):
472
+ """
473
+ Delete a YouTube video by its ID.
474
+
475
+ Args:
476
+ video_id: The YouTube video ID to delete
477
+
478
+ Returns:
479
+ True if successful, False otherwise
480
+ """
481
+ self.logger.info(f"Deleting YouTube video with ID: {video_id}")
482
+
483
+ if self.dry_run:
484
+ self.logger.info(f"DRY RUN: Would delete YouTube video with ID: {video_id}")
485
+ return True
486
+
487
+ try:
488
+ youtube = self.authenticate_youtube()
489
+ youtube.videos().delete(id=video_id).execute()
490
+ self.logger.info(f"Successfully deleted YouTube video with ID: {video_id}")
491
+ return True
492
+ except Exception as e:
493
+ self.logger.error(f"Failed to delete YouTube video with ID {video_id}: {e}")
494
+ return False
495
+
496
+ def truncate_to_nearest_word(self, title, max_length):
497
+ if len(title) <= max_length:
498
+ return title
499
+ truncated_title = title[:max_length].rsplit(" ", 1)[0]
500
+ if len(truncated_title) < max_length:
501
+ truncated_title += " ..."
502
+ return truncated_title
503
+
504
+ def upload_final_mp4_to_youtube_with_title_thumbnail(self, artist, title, input_files, output_files, replace_existing=False):
505
+ self.logger.info(f"Uploading final MKV to YouTube with title thumbnail...")
506
+ if self.dry_run:
507
+ self.logger.info(
508
+ f'DRY RUN: Would upload {output_files["final_karaoke_lossless_mkv"]} to YouTube with thumbnail {input_files["title_jpg"]} using client secrets file: {self.youtube_client_secrets_file}'
509
+ )
510
+ else:
511
+ youtube_title = f"{artist} - {title} (Karaoke)"
512
+
513
+ # Truncate title to the nearest whole word and add ellipsis if needed
514
+ max_length = 95
515
+ youtube_title = self.truncate_to_nearest_word(youtube_title, max_length)
516
+
517
+ # In server-side mode, we should always replace videos if an exact match is found.
518
+ # Otherwise, respect the replace_existing flag from CLI.
519
+ should_replace = True if self.server_side_mode else replace_existing
520
+
521
+ if self.check_if_video_title_exists_on_youtube_channel(youtube_title):
522
+ if should_replace:
523
+ self.logger.info(f"Video already exists on YouTube, deleting before re-upload: {self.youtube_url}")
524
+ if self.delete_youtube_video(self.youtube_video_id):
525
+ self.logger.info(f"Successfully deleted existing video, proceeding with upload")
526
+ # Reset the video ID and URL since we're uploading a new one
527
+ self.youtube_video_id = None
528
+ self.youtube_url = None
529
+ else:
530
+ self.logger.error(f"Failed to delete existing video, aborting upload")
531
+ return
532
+ else:
533
+ self.logger.warning(f"Video already exists on YouTube, skipping upload: {self.youtube_url}")
534
+ return
535
+
536
+ youtube_description = f"Karaoke version of {artist} - {title} created using karaoke-gen python package."
537
+ if self.youtube_description_file is not None:
538
+ with open(self.youtube_description_file, "r") as f:
539
+ youtube_description = f.read()
540
+
541
+ youtube_category_id = "10" # Category ID for Music
542
+ youtube_keywords = ["karaoke", "music", "singing", "instrumental", "lyrics", artist, title]
543
+
544
+ self.logger.info(f"Authenticating with YouTube...")
545
+ # Upload video to YouTube and set thumbnail.
546
+ youtube = self.authenticate_youtube()
547
+
548
+ body = {
549
+ "snippet": {
550
+ "title": youtube_title,
551
+ "description": youtube_description,
552
+ "tags": youtube_keywords,
553
+ "categoryId": youtube_category_id,
554
+ },
555
+ "status": {"privacyStatus": "public"},
556
+ }
557
+
558
+ # Use MediaFileUpload to handle the video file - using the MKV with FLAC audio
559
+ media_file = MediaFileUpload(output_files["final_karaoke_lossless_mkv"], mimetype="video/x-matroska", resumable=True)
560
+
561
+ # Call the API's videos.insert method to create and upload the video.
562
+ self.logger.info(f"Uploading final MKV to YouTube...")
563
+ request = youtube.videos().insert(part="snippet,status", body=body, media_body=media_file)
564
+ response = request.execute()
565
+
566
+ self.youtube_video_id = response.get("id")
567
+ self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
568
+ self.logger.info(f"Uploaded video to YouTube: {self.youtube_url}")
569
+
570
+ # Uploading the thumbnail
571
+ if input_files.get("title_jpg") and os.path.isfile(input_files["title_jpg"]):
572
+ try:
573
+ self.logger.info(f"Uploading thumbnail from: {input_files['title_jpg']}")
574
+ media_thumbnail = MediaFileUpload(input_files["title_jpg"], mimetype="image/jpeg")
575
+ youtube.thumbnails().set(videoId=self.youtube_video_id, media_body=media_thumbnail).execute()
576
+ self.logger.info(f"Uploaded thumbnail for video ID {self.youtube_video_id}")
577
+ except Exception as e:
578
+ self.logger.error(f"Failed to upload thumbnail: {e}")
579
+ self.logger.warning("Video uploaded but thumbnail not set. You may need to set it manually on YouTube.")
580
+ else:
581
+ self.logger.warning(f"Thumbnail file not found, skipping thumbnail upload: {input_files.get('title_jpg')}")
582
+
583
+ def get_next_brand_code(self):
584
+ """
585
+ Calculate the next sequence number based on existing directories in the organised_dir.
586
+ Assumes directories are named with the format: BRAND-XXXX Artist - Title
587
+ """
588
+ max_num = 0
589
+ pattern = re.compile(rf"^{re.escape(self.brand_prefix)}-(\d{{4}})")
590
+
591
+ if not os.path.isdir(self.organised_dir):
592
+ raise Exception(f"Target directory does not exist: {self.organised_dir}")
593
+
594
+ for dir_name in os.listdir(self.organised_dir):
595
+ match = pattern.match(dir_name)
596
+ if match:
597
+ num = int(match.group(1))
598
+ max_num = max(max_num, num)
599
+
600
+ self.logger.info(f"Next sequence number for brand {self.brand_prefix} calculated as: {max_num + 1}")
601
+ next_seq_number = max_num + 1
602
+
603
+ return f"{self.brand_prefix}-{next_seq_number:04d}"
604
+
605
+ def post_discord_message(self, message, webhook_url):
606
+ """Post a message to a Discord channel via webhook."""
607
+ data = {"content": message}
608
+ response = requests.post(webhook_url, json=data)
609
+ response.raise_for_status() # This will raise an exception if the request failed
610
+ self.logger.info("Message posted to Discord")
611
+
612
+ def find_with_vocals_file(self):
613
+ self.logger.info("Finding input file ending in (With Vocals).mov/.mp4/.mkv or (Karaoke).mov/.mp4/.mkv")
614
+
615
+ # Define all possible suffixes for with vocals files
616
+ with_vocals_suffixes = [
617
+ self.suffixes["with_vocals_mov"],
618
+ self.suffixes["with_vocals_mp4"],
619
+ self.suffixes["with_vocals_mkv"],
620
+ ]
621
+
622
+ # First try to find a properly named with vocals file in any supported format
623
+ with_vocals_files = [f for f in os.listdir(".") if any(f.endswith(suffix) for suffix in with_vocals_suffixes)]
624
+
625
+ if with_vocals_files:
626
+ self.logger.info(f"Found with vocals file: {with_vocals_files[0]}")
627
+ return with_vocals_files[0]
628
+
629
+ # If no with vocals file found, look for potentially misnamed karaoke files
630
+ karaoke_suffixes = [" (Karaoke).mov", " (Karaoke).mp4", " (Karaoke).mkv"]
631
+ karaoke_files = [f for f in os.listdir(".") if any(f.endswith(suffix) for suffix in karaoke_suffixes)]
632
+
633
+ if karaoke_files:
634
+ for file in karaoke_files:
635
+ # Get the current extension
636
+ current_ext = os.path.splitext(file)[1].lower() # Convert to lowercase
637
+ base_without_suffix = file.replace(f" (Karaoke){current_ext}", "")
638
+
639
+ # Map file extension to suffix dictionary key
640
+ ext_to_suffix = {".mov": "with_vocals_mov", ".mp4": "with_vocals_mp4", ".mkv": "with_vocals_mkv"}
641
+
642
+ if current_ext in ext_to_suffix:
643
+ new_file = f"{base_without_suffix}{self.suffixes[ext_to_suffix[current_ext]]}"
644
+
645
+ self.prompt_user_confirmation_or_raise_exception(
646
+ f"Found '{file}' but no '(With Vocals)', rename to {new_file} for vocal input?",
647
+ "Unable to proceed without With Vocals file or user confirmation of rename.",
648
+ allow_empty=True,
649
+ )
650
+
651
+ os.rename(file, new_file)
652
+ self.logger.info(f"Renamed '{file}' to '{new_file}'")
653
+ return new_file
654
+ else:
655
+ self.logger.warning(f"Unsupported file extension: {current_ext}")
656
+
657
+ raise Exception(
658
+ "No suitable files found for processing.\n"
659
+ "\n"
660
+ "WHAT THIS MEANS:\n"
661
+ "The finalisation step requires a '(With Vocals).mkv' video file, which is created "
662
+ "during the lyrics transcription phase. This file contains the karaoke video with "
663
+ "synchronized lyrics overlay.\n"
664
+ "\n"
665
+ "COMMON CAUSES:\n"
666
+ "1. Transcription provider not configured - No AUDIOSHAKE_API_TOKEN or RUNPOD_API_KEY set\n"
667
+ "2. Transcription failed - Check logs above for API errors or timeout messages\n"
668
+ "3. Invalid API credentials - Verify your API tokens are correct and active\n"
669
+ "4. Network issues - Unable to reach transcription service\n"
670
+ "5. Running in wrong directory - Make sure you're in the track output folder\n"
671
+ "\n"
672
+ "TROUBLESHOOTING STEPS:\n"
673
+ "1. Check environment variables:\n"
674
+ " - AUDIOSHAKE_API_TOKEN (for AudioShake transcription)\n"
675
+ " - RUNPOD_API_KEY + WHISPER_RUNPOD_ID (for Whisper transcription)\n"
676
+ "2. Review the log output above for transcription errors\n"
677
+ "3. Try running with --log_level debug for more detailed output\n"
678
+ "4. If you don't need synchronized lyrics, use --skip-lyrics for instrumental-only karaoke\n"
679
+ "\n"
680
+ "See README.md 'Transcription Providers' and 'Troubleshooting' sections for more details."
681
+ )
682
+
683
+ def choose_instrumental_audio_file(self, base_name):
684
+ self.logger.info(f"Choosing instrumental audio file to use as karaoke audio...")
685
+
686
+ search_string = " (Instrumental"
687
+ self.logger.info(f"Searching for files in current directory containing {search_string}")
688
+
689
+ all_instrumental_files = [f for f in os.listdir(".") if search_string in f]
690
+ flac_files = set(f.rsplit(".", 1)[0] for f in all_instrumental_files if f.endswith(".flac"))
691
+ mp3_files = set(f.rsplit(".", 1)[0] for f in all_instrumental_files if f.endswith(".mp3"))
692
+ wav_files = set(f.rsplit(".", 1)[0] for f in all_instrumental_files if f.endswith(".wav"))
693
+
694
+ self.logger.debug(f"FLAC files found: {flac_files}")
695
+ self.logger.debug(f"MP3 files found: {mp3_files}")
696
+ self.logger.debug(f"WAV files found: {wav_files}")
697
+
698
+ # Filter out MP3 files if their FLAC or WAV counterpart exists
699
+ # Filter out WAV files if their FLAC counterpart exists
700
+ filtered_files = [
701
+ f
702
+ for f in all_instrumental_files
703
+ if f.endswith(".flac")
704
+ or (f.endswith(".wav") and f.rsplit(".", 1)[0] not in flac_files)
705
+ or (f.endswith(".mp3") and f.rsplit(".", 1)[0] not in flac_files and f.rsplit(".", 1)[0] not in wav_files)
706
+ ]
707
+
708
+ self.logger.debug(f"Filtered instrumental files: {filtered_files}")
709
+
710
+ if not filtered_files:
711
+ raise Exception(f"No instrumental audio files found containing {search_string}")
712
+
713
+ if len(filtered_files) == 1:
714
+ return filtered_files[0]
715
+
716
+ # In non-interactive mode, always choose the first option
717
+ if self.non_interactive:
718
+ self.logger.info(f"Non-interactive mode, automatically choosing first instrumental file: {filtered_files[0]}")
719
+ return filtered_files[0]
720
+
721
+ # Sort the remaining instrumental options alphabetically
722
+ filtered_files.sort(reverse=True)
723
+
724
+ self.logger.info(f"Found multiple files containing {search_string}:")
725
+ for i, file in enumerate(filtered_files):
726
+ self.logger.info(f" {i+1}: {file}")
727
+
728
+ print()
729
+ response = input(f"Choose instrumental audio file to use as karaoke audio: [1]/{len(filtered_files)}: ").strip().lower()
730
+ if response == "":
731
+ response = "1"
732
+
733
+ try:
734
+ response = int(response)
735
+ except ValueError:
736
+ raise Exception(f"Invalid response to instrumental audio file choice prompt: {response}")
737
+
738
+ if response < 1 or response > len(filtered_files):
739
+ raise Exception(f"Invalid response to instrumental audio file choice prompt: {response}")
740
+
741
+ return filtered_files[response - 1]
742
+
743
+ def get_names_from_withvocals(self, with_vocals_file):
744
+ self.logger.info(f"Getting artist and title from {with_vocals_file}")
745
+
746
+ # Remove both possible suffixes and their extensions
747
+ base_name = with_vocals_file
748
+ for suffix_key in ["with_vocals_mov", "with_vocals_mp4", "with_vocals_mkv"]:
749
+ suffix = self.suffixes[suffix_key]
750
+ if suffix in base_name:
751
+ base_name = base_name.replace(suffix, "")
752
+ break
753
+
754
+ # If we didn't find a match above, try removing just the extension
755
+ if base_name == with_vocals_file:
756
+ base_name = os.path.splitext(base_name)[0]
757
+
758
+ artist, title = base_name.split(" - ", 1)
759
+ return base_name, artist, title
760
+
761
+ def _pad_audio_file(self, input_audio, output_audio, padding_seconds):
762
+ """
763
+ Pad an audio file by prepending silence at the beginning.
764
+
765
+ Uses the same ffmpeg approach as LyricsTranscriber's CountdownProcessor
766
+ to ensure consistent padding behavior.
767
+
768
+ Args:
769
+ input_audio: Path to input audio file
770
+ output_audio: Path for the padded output file
771
+ padding_seconds: Amount of silence to prepend (in seconds)
772
+ """
773
+ self.logger.info(f"Padding audio file with {padding_seconds}s of silence")
774
+
775
+ # Use ffmpeg to prepend silence - this matches the approach in audio_processor.py
776
+ # adelay filter adds delay in milliseconds
777
+ delay_ms = int(padding_seconds * 1000)
778
+
779
+ ffmpeg_command = (
780
+ f'{self.ffmpeg_base_command} -i "{input_audio}" '
781
+ f'-af "adelay={delay_ms}|{delay_ms}" '
782
+ f'"{output_audio}"'
783
+ )
784
+
785
+ self.execute_command(ffmpeg_command, f"Padding audio with {padding_seconds}s silence")
786
+
787
+ def execute_command(self, command, description):
788
+ """Execute a shell command and log the output. For general commands (rclone, etc.)"""
789
+ self.logger.info(f"{description}")
790
+ self.logger.debug(f"Executing command: {command}")
791
+
792
+ if self.dry_run:
793
+ self.logger.info(f"DRY RUN: Would execute: {command}")
794
+ return
795
+
796
+ try:
797
+ result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=600)
798
+
799
+ # Log command output for debugging
800
+ if result.stdout and result.stdout.strip():
801
+ self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
802
+ if result.stderr and result.stderr.strip():
803
+ self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
804
+
805
+ if result.returncode != 0:
806
+ error_msg = f"Command failed with exit code {result.returncode}"
807
+ self.logger.error(error_msg)
808
+ self.logger.error(f"Command: {command}")
809
+ if result.stdout:
810
+ self.logger.error(f"STDOUT: {result.stdout}")
811
+ if result.stderr:
812
+ self.logger.error(f"STDERR: {result.stderr}")
813
+ raise Exception(f"{error_msg}: {command}")
814
+ else:
815
+ self.logger.info(f"✓ Command completed successfully")
816
+
817
+ except subprocess.TimeoutExpired:
818
+ error_msg = f"Command timed out after 600 seconds"
819
+ self.logger.error(error_msg)
820
+ raise Exception(f"{error_msg}: {command}")
821
+ except Exception as e:
822
+ if "Command failed" not in str(e):
823
+ error_msg = f"Command failed with exception: {e}"
824
+ self.logger.error(error_msg)
825
+ raise Exception(f"{error_msg}: {command}")
826
+ else:
827
+ raise
828
+
829
+ def remux_with_instrumental(self, with_vocals_file, instrumental_audio, output_file):
830
+ """Remux the video with instrumental audio to create karaoke version"""
831
+ # Safety net: If countdown padding was applied to vocals, ensure instrumental is padded too
832
+ actual_instrumental = instrumental_audio
833
+ if self.countdown_padding_seconds and self.countdown_padding_seconds > 0:
834
+ # Check if the instrumental file is already padded (has "(Padded)" in name)
835
+ if "(Padded)" not in instrumental_audio:
836
+ self.logger.warning(
837
+ f"Countdown padding ({self.countdown_padding_seconds}s) was applied to vocals, "
838
+ f"but instrumental doesn't appear to be padded. Creating padded version..."
839
+ )
840
+ # Create a padded version of the instrumental
841
+ base, ext = os.path.splitext(instrumental_audio)
842
+ padded_instrumental = f"{base} (Padded){ext}"
843
+
844
+ if not os.path.exists(padded_instrumental):
845
+ self._pad_audio_file(instrumental_audio, padded_instrumental, self.countdown_padding_seconds)
846
+ self.logger.info(f"Created padded instrumental: {padded_instrumental}")
847
+
848
+ actual_instrumental = padded_instrumental
849
+ else:
850
+ self.logger.info(f"Using already-padded instrumental: {instrumental_audio}")
851
+
852
+ # This operation is primarily I/O bound (remuxing), so hardware acceleration doesn't provide significant benefit
853
+ # Keep the existing approach but use the new execute method
854
+ ffmpeg_command = (
855
+ f'{self.ffmpeg_base_command} -an -i "{with_vocals_file}" '
856
+ f'-vn -i "{actual_instrumental}" -c:v copy -c:a pcm_s16le "{output_file}"'
857
+ )
858
+ self.execute_command(ffmpeg_command, "Remuxing video with instrumental audio")
859
+
860
+ def convert_mov_to_mp4(self, input_file, output_file):
861
+ """Convert MOV file to MP4 format with hardware acceleration support"""
862
+ # Hardware-accelerated version
863
+ gpu_command = (
864
+ f'{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
865
+ f'-c:v {self.video_encoder} {self.get_nvenc_quality_settings("high")} -c:a {self.aac_codec} -ar 48000 {self.mp4_flags} "{output_file}"'
866
+ )
867
+
868
+ # Software fallback version
869
+ cpu_command = (
870
+ f'{self.ffmpeg_base_command} -i "{input_file}" '
871
+ f'-c:v libx264 -c:a {self.aac_codec} -ar 48000 {self.mp4_flags} "{output_file}"'
872
+ )
873
+
874
+ self.execute_command_with_fallback(gpu_command, cpu_command, "Converting MOV video to MP4")
875
+
876
+ def encode_lossless_mp4(self, title_mov_file, karaoke_mp4_file, env_mov_input, ffmpeg_filter, output_file):
877
+ """Create the final MP4 with PCM audio (lossless) using hardware acceleration when available"""
878
+ # Hardware-accelerated version
879
+ gpu_command = (
880
+ f"{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i {title_mov_file} "
881
+ f"{self.hwaccel_decode_flags} -i {karaoke_mp4_file} {env_mov_input} "
882
+ f'{ffmpeg_filter} -map "[outv]" -map "[outa]" -c:v {self.video_encoder} '
883
+ f'{self.get_nvenc_quality_settings("lossless")} -c:a pcm_s16le {self.mp4_flags} "{output_file}"'
884
+ )
885
+
886
+ # Software fallback version
887
+ cpu_command = (
888
+ f"{self.ffmpeg_base_command} -i {title_mov_file} -i {karaoke_mp4_file} {env_mov_input} "
889
+ f'{ffmpeg_filter} -map "[outv]" -map "[outa]" -c:v libx264 -c:a pcm_s16le '
890
+ f'{self.mp4_flags} "{output_file}"'
891
+ )
892
+
893
+ self.execute_command_with_fallback(gpu_command, cpu_command, "Creating MP4 version with PCM audio")
894
+
895
+ def encode_lossy_mp4(self, input_file, output_file):
896
+ """Create MP4 with AAC audio (lossy, for wider compatibility)"""
897
+ # This is primarily an audio re-encoding operation, video is copied
898
+ # Hardware acceleration doesn't provide significant benefit for copy operations
899
+ ffmpeg_command = (
900
+ f'{self.ffmpeg_base_command} -i "{input_file}" '
901
+ f'-c:v copy -c:a {self.aac_codec} -ar 48000 -b:a 320k {self.mp4_flags} "{output_file}"'
902
+ )
903
+ self.execute_command(ffmpeg_command, "Creating MP4 version with AAC audio")
904
+
905
+ def encode_lossless_mkv(self, input_file, output_file):
906
+ """Create MKV with FLAC audio (for YouTube)"""
907
+ # This is primarily an audio re-encoding operation, video is copied
908
+ # Hardware acceleration doesn't provide significant benefit for copy operations
909
+ ffmpeg_command = (
910
+ f'{self.ffmpeg_base_command} -i "{input_file}" '
911
+ f'-c:v copy -c:a flac "{output_file}"'
912
+ )
913
+ self.execute_command(ffmpeg_command, "Creating MKV version with FLAC audio for YouTube")
914
+
915
+ def encode_720p_version(self, input_file, output_file):
916
+ """Create 720p MP4 with AAC audio (for smaller file size) using hardware acceleration when available"""
917
+ # Hardware-accelerated version with GPU scaling and encoding
918
+ gpu_command = (
919
+ f'{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
920
+ f'-c:v {self.video_encoder} -vf "{self.scale_filter}=1280:720" '
921
+ f'{self.get_nvenc_quality_settings("medium")} -b:v 2000k '
922
+ f'-c:a {self.aac_codec} -ar 48000 -b:a 128k {self.mp4_flags} "{output_file}"'
923
+ )
924
+
925
+ # Software fallback version
926
+ cpu_command = (
927
+ f'{self.ffmpeg_base_command} -i "{input_file}" '
928
+ f'-c:v libx264 -vf "scale=1280:720" -b:v 2000k -preset medium -tune animation '
929
+ f'-c:a {self.aac_codec} -ar 48000 -b:a 128k {self.mp4_flags} "{output_file}"'
930
+ )
931
+
932
+ self.execute_command_with_fallback(gpu_command, cpu_command, "Encoding 720p version of the final video")
933
+
934
+ def prepare_concat_filter(self, input_files):
935
+ """Prepare the concat filter and additional input for end credits if present"""
936
+ env_mov_input = ""
937
+ ffmpeg_filter = '-filter_complex "[0:v:0][0:a:0][1:v:0][1:a:0]concat=n=2:v=1:a=1[outv][outa]"'
938
+
939
+ if "end_mov" in input_files and os.path.isfile(input_files["end_mov"]):
940
+ self.logger.info(f"Found end_mov file: {input_files['end_mov']}, including in final MP4")
941
+ end_mov_file = shlex.quote(os.path.abspath(input_files["end_mov"]))
942
+ env_mov_input = f"-i {end_mov_file}"
943
+ ffmpeg_filter = '-filter_complex "[0:v:0][0:a:0][1:v:0][1:a:0][2:v:0][2:a:0]concat=n=3:v=1:a=1[outv][outa]"'
944
+
945
+ return env_mov_input, ffmpeg_filter
946
+
947
+ def remux_and_encode_output_video_files(self, with_vocals_file, input_files, output_files):
948
+ self.logger.info(f"Remuxing and encoding output video files (4 formats, ~15-20 minutes total)...")
949
+
950
+ # Check if output files already exist
951
+ if os.path.isfile(output_files["final_karaoke_lossless_mp4"]) and os.path.isfile(output_files["final_karaoke_lossless_mkv"]):
952
+ if not self.prompt_user_bool(
953
+ f"Found existing Final Karaoke output files. Overwrite (y) or skip (n)?",
954
+ ):
955
+ self.logger.info(f"Skipping Karaoke MP4 remux and Final video renders, existing files will be used.")
956
+ return
957
+
958
+ # Create karaoke version with instrumental audio
959
+ self.logger.info(f"[Step 1/6] Remuxing video with instrumental audio...")
960
+ self.remux_with_instrumental(with_vocals_file, input_files["instrumental_audio"], output_files["karaoke_mp4"])
961
+
962
+ # Convert the with vocals video to MP4 if needed
963
+ if not with_vocals_file.endswith(".mp4"):
964
+ self.logger.info(f"[Step 2/6] Converting karaoke video to MP4...")
965
+ self.convert_mov_to_mp4(with_vocals_file, output_files["with_vocals_mp4"])
966
+
967
+ # Delete the with vocals mov after successfully converting it to mp4
968
+ if not self.dry_run and os.path.isfile(with_vocals_file):
969
+ self.logger.info(f"Deleting with vocals MOV file: {with_vocals_file}")
970
+ os.remove(with_vocals_file)
971
+ else:
972
+ self.logger.info(f"[Step 2/6] Skipped - video already in MP4 format")
973
+
974
+ # Quote file paths to handle special characters
975
+ title_mov_file = shlex.quote(os.path.abspath(input_files["title_mov"]))
976
+ karaoke_mp4_file = shlex.quote(os.path.abspath(output_files["karaoke_mp4"]))
977
+
978
+ # Prepare concat filter for combining videos
979
+ env_mov_input, ffmpeg_filter = self.prepare_concat_filter(input_files)
980
+
981
+ # Create all output versions with progress logging
982
+ self.logger.info(f"[Step 3/6] Encoding lossless 4K MP4 (title + karaoke + end, ~5 minutes)...")
983
+ self.encode_lossless_mp4(title_mov_file, karaoke_mp4_file, env_mov_input, ffmpeg_filter, output_files["final_karaoke_lossless_mp4"])
984
+
985
+ self.logger.info(f"[Step 4/6] Encoding lossy 4K MP4 with AAC audio (~1 minute)...")
986
+ self.encode_lossy_mp4(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossy_mp4"])
987
+
988
+ self.logger.info(f"[Step 5/6] Creating MKV with FLAC audio for YouTube (~1 minute)...")
989
+ self.encode_lossless_mkv(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossless_mkv"])
990
+
991
+ self.logger.info(f"[Step 6/6] Encoding 720p version (~3 minutes)...")
992
+ self.encode_720p_version(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossy_720p_mp4"])
993
+
994
+ # Skip user confirmation in non-interactive mode for Modal deployment
995
+ if not self.non_interactive:
996
+ # Prompt user to check final video files before proceeding
997
+ self.prompt_user_confirmation_or_raise_exception(
998
+ f"Final video files created:\n"
999
+ f"- Lossless 4K MP4: {output_files['final_karaoke_lossless_mp4']}\n"
1000
+ f"- Lossless 4K MKV: {output_files['final_karaoke_lossless_mkv']}\n"
1001
+ f"- Lossy 4K MP4: {output_files['final_karaoke_lossy_mp4']}\n"
1002
+ f"- Lossy 720p MP4: {output_files['final_karaoke_lossy_720p_mp4']}\n"
1003
+ f"Please check them! Proceed?",
1004
+ "Refusing to proceed without user confirmation they're happy with the Final videos.",
1005
+ allow_empty=True,
1006
+ )
1007
+ else:
1008
+ self.logger.info("Non-interactive mode: automatically confirming final video files")
1009
+
1010
+ def create_cdg_zip_file(self, input_files, output_files, artist, title):
1011
+ self.logger.info(f"Creating CDG and MP3 files, then zipping them...")
1012
+
1013
+ # Check if CDG file already exists, if so, ask user to overwrite or skip
1014
+ if os.path.isfile(output_files["final_karaoke_cdg_zip"]):
1015
+ if not self.prompt_user_bool(
1016
+ f"Found existing CDG ZIP file: {output_files['final_karaoke_cdg_zip']}. Overwrite (y) or skip (n)?",
1017
+ ):
1018
+ self.logger.info(f"Skipping CDG ZIP file creation, existing file will be used.")
1019
+ return
1020
+
1021
+ # Check if individual MP3 and CDG files already exist
1022
+ if os.path.isfile(output_files["karaoke_mp3"]) and os.path.isfile(output_files["karaoke_cdg"]):
1023
+ self.logger.info(f"Found existing MP3 and CDG files, creating ZIP file directly")
1024
+ if not self.dry_run:
1025
+ with zipfile.ZipFile(output_files["final_karaoke_cdg_zip"], "w") as zipf:
1026
+ zipf.write(output_files["karaoke_mp3"], os.path.basename(output_files["karaoke_mp3"]))
1027
+ zipf.write(output_files["karaoke_cdg"], os.path.basename(output_files["karaoke_cdg"]))
1028
+ self.logger.info(f"Created CDG ZIP file: {output_files['final_karaoke_cdg_zip']}")
1029
+ return
1030
+
1031
+ # Generate CDG and MP3 files if they don't exist
1032
+ if self.dry_run:
1033
+ self.logger.info(f"DRY RUN: Would generate CDG and MP3 files")
1034
+ else:
1035
+ self.logger.info(f"Generating CDG and MP3 files")
1036
+
1037
+ if self.cdg_styles is None:
1038
+ raise ValueError("CDG styles configuration is required when enable_cdg is True")
1039
+
1040
+ generator = CDGGenerator(output_dir=os.getcwd(), logger=self.logger)
1041
+ cdg_file, mp3_file, zip_file = generator.generate_cdg_from_lrc(
1042
+ lrc_file=input_files["karaoke_lrc"],
1043
+ audio_file=input_files["instrumental_audio"],
1044
+ title=title,
1045
+ artist=artist,
1046
+ cdg_styles=self.cdg_styles,
1047
+ )
1048
+
1049
+ # Rename the generated ZIP file to match our expected naming convention
1050
+ if os.path.isfile(zip_file):
1051
+ os.rename(zip_file, output_files["final_karaoke_cdg_zip"])
1052
+ self.logger.info(f"Renamed CDG ZIP file from {zip_file} to {output_files['final_karaoke_cdg_zip']}")
1053
+
1054
+ if not os.path.isfile(output_files["final_karaoke_cdg_zip"]):
1055
+ self.logger.error(f"Failed to find any CDG ZIP file. Listing directory contents:")
1056
+ for file in os.listdir():
1057
+ self.logger.error(f" - {file}")
1058
+ raise Exception(f"Failed to create CDG ZIP file: {output_files['final_karaoke_cdg_zip']}")
1059
+
1060
+ self.logger.info(f"CDG ZIP file created: {output_files['final_karaoke_cdg_zip']}")
1061
+
1062
+ # Extract the CDG ZIP file
1063
+ self.logger.info(f"Extracting CDG ZIP file: {output_files['final_karaoke_cdg_zip']}")
1064
+ with zipfile.ZipFile(output_files["final_karaoke_cdg_zip"], "r") as zip_ref:
1065
+ zip_ref.extractall()
1066
+
1067
+ if os.path.isfile(output_files["karaoke_mp3"]):
1068
+ self.logger.info(f"Found extracted MP3 file: {output_files['karaoke_mp3']}")
1069
+ else:
1070
+ self.logger.error("Failed to find extracted MP3 file")
1071
+ raise Exception("Failed to extract MP3 file from CDG ZIP")
1072
+
1073
+ def create_txt_zip_file(self, input_files, output_files):
1074
+ self.logger.info(f"Creating TXT ZIP file...")
1075
+
1076
+ # Check if TXT file already exists, if so, ask user to overwrite or skip
1077
+ if os.path.isfile(output_files["final_karaoke_txt_zip"]):
1078
+ if not self.prompt_user_bool(
1079
+ f"Found existing TXT ZIP file: {output_files['final_karaoke_txt_zip']}. Overwrite (y) or skip (n)?",
1080
+ ):
1081
+ self.logger.info(f"Skipping TXT ZIP file creation, existing file will be used.")
1082
+ return
1083
+
1084
+ # Create the ZIP file containing the MP3 and TXT files
1085
+ if self.dry_run:
1086
+ self.logger.info(f"DRY RUN: Would create TXT ZIP file: {output_files['final_karaoke_txt_zip']}")
1087
+ else:
1088
+ self.logger.info(f"Running karaoke-converter to convert MidiCo LRC file {input_files['karaoke_lrc']} to TXT format")
1089
+ txt_converter = LyricsConverter(output_format="txt", filepath=input_files["karaoke_lrc"])
1090
+ converted_txt = txt_converter.convert_file()
1091
+
1092
+ with open(output_files["karaoke_txt"], "w") as txt_file:
1093
+ txt_file.write(converted_txt)
1094
+ self.logger.info(f"TXT file written: {output_files['karaoke_txt']}")
1095
+
1096
+ self.logger.info(f"Creating ZIP file containing {output_files['karaoke_mp3']} and {output_files['karaoke_txt']}")
1097
+ with zipfile.ZipFile(output_files["final_karaoke_txt_zip"], "w") as zipf:
1098
+ zipf.write(output_files["karaoke_mp3"], os.path.basename(output_files["karaoke_mp3"]))
1099
+ zipf.write(output_files["karaoke_txt"], os.path.basename(output_files["karaoke_txt"]))
1100
+
1101
+ if not os.path.isfile(output_files["final_karaoke_txt_zip"]):
1102
+ raise Exception(f"Failed to create TXT ZIP file: {output_files['final_karaoke_txt_zip']}")
1103
+
1104
+ self.logger.info(f"TXT ZIP file created: {output_files['final_karaoke_txt_zip']}")
1105
+
1106
+ def move_files_to_brand_code_folder(self, brand_code, artist, title, output_files):
1107
+ self.logger.info(f"Moving files to new brand-prefixed directory...")
1108
+
1109
+ self.new_brand_code_dir = f"{brand_code} - {artist} - {title}"
1110
+ self.new_brand_code_dir_path = os.path.join(self.organised_dir, self.new_brand_code_dir)
1111
+
1112
+ # self.prompt_user_confirmation_or_raise_exception(
1113
+ # f"Move files to new brand-prefixed directory {self.new_brand_code_dir_path} and delete current dir?",
1114
+ # "Refusing to move files without user confirmation of move.",
1115
+ # allow_empty=True,
1116
+ # )
1117
+
1118
+ orig_dir = os.getcwd()
1119
+ os.chdir(os.path.dirname(orig_dir))
1120
+ self.logger.info(f"Changed dir to parent directory: {os.getcwd()}")
1121
+
1122
+ if self.dry_run:
1123
+ self.logger.info(f"DRY RUN: Would move original directory {orig_dir} to: {self.new_brand_code_dir_path}")
1124
+ else:
1125
+ os.rename(orig_dir, self.new_brand_code_dir_path)
1126
+
1127
+ # Update output_files dictionary with the new paths after moving
1128
+ self.logger.info(f"Updating output file paths to reflect move to {self.new_brand_code_dir_path}")
1129
+ for key in output_files:
1130
+ if output_files[key]: # Check if the path exists (e.g., optional files)
1131
+ old_basename = os.path.basename(output_files[key])
1132
+ new_path = os.path.join(self.new_brand_code_dir_path, old_basename)
1133
+ output_files[key] = new_path
1134
+ self.logger.debug(f" Updated {key}: {new_path}")
1135
+
1136
+ def copy_final_files_to_public_share_dirs(self, brand_code, base_name, output_files):
1137
+ self.logger.info(f"Copying final MP4, 720p MP4, and ZIP to public share directory...")
1138
+
1139
+ # Validate public_share_dir is a valid folder with MP4, MP4-720p, and CDG subdirectories
1140
+ if not os.path.isdir(self.public_share_dir):
1141
+ raise Exception(f"Public share directory does not exist: {self.public_share_dir}")
1142
+
1143
+ if not os.path.isdir(os.path.join(self.public_share_dir, "MP4")):
1144
+ raise Exception(f"Public share directory does not contain MP4 subdirectory: {self.public_share_dir}")
1145
+
1146
+ if not os.path.isdir(os.path.join(self.public_share_dir, "MP4-720p")):
1147
+ raise Exception(f"Public share directory does not contain MP4-720p subdirectory: {self.public_share_dir}")
1148
+
1149
+ if not os.path.isdir(os.path.join(self.public_share_dir, "CDG")):
1150
+ raise Exception(f"Public share directory does not contain CDG subdirectory: {self.public_share_dir}")
1151
+
1152
+ if brand_code is None:
1153
+ raise Exception(f"New track prefix was not set, refusing to copy to public share directory")
1154
+
1155
+ dest_mp4_dir = os.path.join(self.public_share_dir, "MP4")
1156
+ dest_720p_dir = os.path.join(self.public_share_dir, "MP4-720p")
1157
+ dest_cdg_dir = os.path.join(self.public_share_dir, "CDG")
1158
+ os.makedirs(dest_mp4_dir, exist_ok=True)
1159
+ os.makedirs(dest_720p_dir, exist_ok=True)
1160
+ os.makedirs(dest_cdg_dir, exist_ok=True)
1161
+
1162
+ dest_mp4_file = os.path.join(dest_mp4_dir, f"{brand_code} - {base_name}.mp4")
1163
+ dest_720p_mp4_file = os.path.join(dest_720p_dir, f"{brand_code} - {base_name}.mp4")
1164
+ dest_zip_file = os.path.join(dest_cdg_dir, f"{brand_code} - {base_name}.zip")
1165
+
1166
+ if self.dry_run:
1167
+ self.logger.info(
1168
+ f"DRY RUN: Would copy final MP4, 720p MP4, and ZIP to {dest_mp4_file}, {dest_720p_mp4_file}, and {dest_zip_file}"
1169
+ )
1170
+ else:
1171
+ shutil.copy2(output_files["final_karaoke_lossy_mp4"], dest_mp4_file) # Changed to use lossy MP4
1172
+ shutil.copy2(output_files["final_karaoke_lossy_720p_mp4"], dest_720p_mp4_file)
1173
+
1174
+ # Only copy CDG ZIP if CDG creation is enabled
1175
+ if self.enable_cdg and "final_karaoke_cdg_zip" in output_files:
1176
+ shutil.copy2(output_files["final_karaoke_cdg_zip"], dest_zip_file)
1177
+ self.logger.info(f"Copied CDG ZIP file to public share directory")
1178
+ else:
1179
+ self.logger.info(f"CDG creation disabled, skipping CDG ZIP copy")
1180
+
1181
+ self.logger.info(f"Copied final files to public share directory")
1182
+
1183
+ def sync_public_share_dir_to_rclone_destination(self):
1184
+ self.logger.info(f"Copying public share directory to rclone destination...")
1185
+
1186
+ # Delete .DS_Store files recursively before copying
1187
+ for root, dirs, files in os.walk(self.public_share_dir):
1188
+ for file in files:
1189
+ if file == ".DS_Store":
1190
+ file_path = os.path.join(root, file)
1191
+ os.remove(file_path)
1192
+ self.logger.info(f"Deleted .DS_Store file: {file_path}")
1193
+
1194
+ rclone_cmd = f"rclone copy -v --ignore-existing {shlex.quote(self.public_share_dir)} {shlex.quote(self.rclone_destination)}"
1195
+ self.execute_command(rclone_cmd, "Copying to cloud destination")
1196
+
1197
+ def post_discord_notification(self):
1198
+ self.logger.info(f"Posting Discord notification...")
1199
+
1200
+ if self.skip_notifications:
1201
+ self.logger.info(f"Skipping Discord notification as video was previously uploaded to YouTube")
1202
+ return
1203
+
1204
+ # Only post if we have a YouTube URL
1205
+ if not self.youtube_url:
1206
+ self.logger.info(f"Skipping Discord notification - no YouTube URL available")
1207
+ return
1208
+
1209
+ if self.dry_run:
1210
+ self.logger.info(
1211
+ f"DRY RUN: Would post Discord notification for youtube URL {self.youtube_url} using webhook URL: {self.discord_webhook_url}"
1212
+ )
1213
+ else:
1214
+ discord_message = f"New upload: {self.youtube_url}"
1215
+ self.post_discord_message(discord_message, self.discord_webhook_url)
1216
+
1217
+ def generate_organised_folder_sharing_link(self):
1218
+ self.logger.info(f"Getting Organised Folder sharing link for new brand code directory...")
1219
+
1220
+ rclone_dest = f"{self.organised_dir_rclone_root}/{self.new_brand_code_dir}"
1221
+ rclone_link_cmd = f"rclone link {shlex.quote(rclone_dest)}"
1222
+
1223
+ if self.dry_run:
1224
+ self.logger.info(f"DRY RUN: Would get sharing link with: {rclone_link_cmd}")
1225
+ return "https://file-sharing-service.com/example"
1226
+
1227
+ # Add a 5-second delay to allow dropbox to index the folder before generating a link
1228
+ self.logger.info("Waiting 5 seconds before generating link...")
1229
+ time.sleep(5)
1230
+
1231
+ try:
1232
+ self.logger.info(f"Running command: {rclone_link_cmd}")
1233
+ result = subprocess.run(rclone_link_cmd, shell=True, check=True, capture_output=True, text=True)
1234
+
1235
+ # Log command output for debugging
1236
+ if result.stdout and result.stdout.strip():
1237
+ self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
1238
+ if result.stderr and result.stderr.strip():
1239
+ self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
1240
+
1241
+ self.brand_code_dir_sharing_link = result.stdout.strip()
1242
+ self.logger.info(f"Got organised folder sharing link: {self.brand_code_dir_sharing_link}")
1243
+ except subprocess.CalledProcessError as e:
1244
+ self.logger.error(f"Failed to get organised folder sharing link. Exit code: {e.returncode}")
1245
+ self.logger.error(f"Command output (stdout): {e.stdout}")
1246
+ self.logger.error(f"Command output (stderr): {e.stderr}")
1247
+ self.logger.error(f"Full exception: {e}")
1248
+
1249
+ def get_next_brand_code_server_side(self):
1250
+ """
1251
+ Calculate the next sequence number based on existing directories in the remote organised_dir using rclone.
1252
+ Assumes directories are named with the format: BRAND-XXXX Artist - Title
1253
+ """
1254
+ if not self.organised_dir_rclone_root:
1255
+ raise Exception("organised_dir_rclone_root not configured for server-side brand code generation")
1256
+
1257
+ self.logger.info(f"Getting next brand code from remote organized directory: {self.organised_dir_rclone_root}")
1258
+
1259
+ max_num = 0
1260
+ pattern = re.compile(rf"^{re.escape(self.brand_prefix)}-(\d{{4}})")
1261
+
1262
+ # Use rclone lsf --dirs-only for clean, machine-readable directory listing
1263
+ rclone_list_cmd = f"rclone lsf --dirs-only {shlex.quote(self.organised_dir_rclone_root)}"
1264
+
1265
+ if self.dry_run:
1266
+ self.logger.info(f"DRY RUN: Would run: {rclone_list_cmd}")
1267
+ return f"{self.brand_prefix}-0001"
1268
+
1269
+ try:
1270
+ self.logger.info(f"Running command: {rclone_list_cmd}")
1271
+ result = subprocess.run(rclone_list_cmd, shell=True, check=True, capture_output=True, text=True)
1272
+
1273
+ # Log command output for debugging
1274
+ if result.stdout and result.stdout.strip():
1275
+ self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
1276
+ if result.stderr and result.stderr.strip():
1277
+ self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
1278
+
1279
+ # Parse the output to find matching directories
1280
+ matching_dirs = []
1281
+ for line_num, line in enumerate(result.stdout.strip().split('\n')):
1282
+ if line.strip():
1283
+ # Remove trailing slash and whitespace
1284
+ dir_name = line.strip().rstrip('/')
1285
+
1286
+ # Check if directory matches our brand pattern
1287
+ match = pattern.match(dir_name)
1288
+ if match:
1289
+ num = int(match.group(1))
1290
+ max_num = max(max_num, num)
1291
+ matching_dirs.append((dir_name, num))
1292
+
1293
+ self.logger.info(f"Found {len(matching_dirs)} matching directories with pattern {self.brand_prefix}-XXXX")
1294
+
1295
+ next_seq_number = max_num + 1
1296
+ brand_code = f"{self.brand_prefix}-{next_seq_number:04d}"
1297
+
1298
+ self.logger.info(f"Highest existing number: {max_num}, next sequence number for brand {self.brand_prefix} calculated as: {next_seq_number}")
1299
+ return brand_code
1300
+
1301
+ except subprocess.CalledProcessError as e:
1302
+ self.logger.error(f"Failed to list remote organized directory. Exit code: {e.returncode}")
1303
+ self.logger.error(f"Command output (stdout): {e.stdout}")
1304
+ self.logger.error(f"Command output (stderr): {e.stderr}")
1305
+ raise Exception(f"Failed to get brand code from remote directory: {e}")
1306
+
1307
+ def upload_files_to_organized_folder_server_side(self, brand_code, artist, title):
1308
+ """
1309
+ Upload all files from current directory to the remote organized folder using rclone.
1310
+ Creates a brand-prefixed directory in the remote organized folder.
1311
+ """
1312
+ if not self.organised_dir_rclone_root:
1313
+ raise Exception("organised_dir_rclone_root not configured for server-side file upload")
1314
+
1315
+ self.new_brand_code_dir = f"{brand_code} - {artist} - {title}"
1316
+ remote_dest = f"{self.organised_dir_rclone_root}/{self.new_brand_code_dir}"
1317
+
1318
+ self.logger.info(f"Uploading files to remote organized directory: {remote_dest}")
1319
+
1320
+ # Get current directory path to upload
1321
+ current_dir = os.getcwd()
1322
+
1323
+ # Use rclone copy to upload the entire current directory to the remote destination
1324
+ rclone_upload_cmd = f"rclone copy -v --ignore-existing {shlex.quote(current_dir)} {shlex.quote(remote_dest)}"
1325
+
1326
+ if self.dry_run:
1327
+ self.logger.info(f"DRY RUN: Would upload current directory to: {remote_dest}")
1328
+ self.logger.info(f"DRY RUN: Command: {rclone_upload_cmd}")
1329
+ else:
1330
+ self.execute_command(rclone_upload_cmd, f"Uploading files to organized folder: {remote_dest}")
1331
+
1332
+ # Generate a sharing link for the uploaded folder
1333
+ self.generate_organised_folder_sharing_link_server_side(remote_dest)
1334
+
1335
+ def generate_organised_folder_sharing_link_server_side(self, remote_path):
1336
+ """Generate a sharing link for the remote organized folder using rclone."""
1337
+ self.logger.info(f"Getting sharing link for remote organized folder: {remote_path}")
1338
+
1339
+ rclone_link_cmd = f"rclone link {shlex.quote(remote_path)}"
1340
+
1341
+ if self.dry_run:
1342
+ self.logger.info(f"DRY RUN: Would get sharing link with: {rclone_link_cmd}")
1343
+ self.brand_code_dir_sharing_link = "https://file-sharing-service.com/example"
1344
+ return
1345
+
1346
+ # Add a 10-second delay to allow the remote service to index the folder before generating a link
1347
+ self.logger.info("Waiting 10 seconds before generating link...")
1348
+ time.sleep(10)
1349
+
1350
+ try:
1351
+ self.logger.info(f"Running command: {rclone_link_cmd}")
1352
+ result = subprocess.run(rclone_link_cmd, shell=True, check=True, capture_output=True, text=True)
1353
+
1354
+ # Log command output for debugging
1355
+ if result.stdout and result.stdout.strip():
1356
+ self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
1357
+ if result.stderr and result.stderr.strip():
1358
+ self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
1359
+
1360
+ self.brand_code_dir_sharing_link = result.stdout.strip()
1361
+ self.logger.info(f"Got organized folder sharing link: {self.brand_code_dir_sharing_link}")
1362
+ except subprocess.CalledProcessError as e:
1363
+ self.logger.error(f"Failed to get organized folder sharing link. Exit code: {e.returncode}")
1364
+ self.logger.error(f"Command output (stdout): {e.stdout}")
1365
+ self.logger.error(f"Command output (stderr): {e.stderr}")
1366
+ self.logger.error(f"Full exception: {e}")
1367
+
1368
+ def get_existing_brand_code(self):
1369
+ """Extract brand code from current directory name"""
1370
+ current_dir = os.path.basename(os.getcwd())
1371
+ if " - " not in current_dir:
1372
+ raise Exception(f"Current directory '{current_dir}' does not match expected format 'BRAND-XXXX - Artist - Title'")
1373
+
1374
+ brand_code = current_dir.split(" - ")[0]
1375
+ if not brand_code or "-" not in brand_code:
1376
+ raise Exception(f"Could not extract valid brand code from directory name '{current_dir}'")
1377
+
1378
+ self.logger.info(f"Using existing brand code: {brand_code}")
1379
+ return brand_code
1380
+
1381
+ def execute_optional_features(self, artist, title, base_name, input_files, output_files, replace_existing=False):
1382
+ self.logger.info(f"Executing optional features...")
1383
+
1384
+ if self.youtube_upload_enabled:
1385
+ try:
1386
+ self.upload_final_mp4_to_youtube_with_title_thumbnail(artist, title, input_files, output_files, replace_existing)
1387
+ except Exception as e:
1388
+ self.logger.error(f"Failed to upload video to YouTube: {e}")
1389
+ if not self.non_interactive:
1390
+ print("Please manually upload the video to YouTube.")
1391
+ print()
1392
+ self.youtube_video_id = input("Enter the manually uploaded YouTube video ID: ").strip()
1393
+ self.youtube_url = f"{self.youtube_url_prefix}{self.youtube_video_id}"
1394
+ self.logger.info(f"Using manually provided YouTube video ID: {self.youtube_video_id}")
1395
+ else:
1396
+ self.logger.error("YouTube upload failed in non-interactive mode, skipping")
1397
+
1398
+ # Discord notification - runs independently of YouTube upload
1399
+ # Wrapped in try/except so failures don't crash the entire job
1400
+ if self.discord_notication_enabled:
1401
+ try:
1402
+ self.post_discord_notification()
1403
+ except Exception as e:
1404
+ self.logger.error(f"Failed to send Discord notification: {e}")
1405
+ self.logger.warning("Continuing without Discord notification - this is non-fatal")
1406
+
1407
+ # Handle folder organization - different logic for server-side vs local mode
1408
+ if self.server_side_mode and self.brand_prefix and self.organised_dir_rclone_root:
1409
+ self.logger.info("Executing server-side organization...")
1410
+
1411
+ # Generate brand code from remote directory listing
1412
+ if self.keep_brand_code:
1413
+ self.brand_code = self.get_existing_brand_code()
1414
+ else:
1415
+ self.brand_code = self.get_next_brand_code_server_side()
1416
+
1417
+ # Upload files to organized folder via rclone
1418
+ self.upload_files_to_organized_folder_server_side(self.brand_code, artist, title)
1419
+
1420
+ # Copy files to public share if enabled
1421
+ if self.public_share_copy_enabled:
1422
+ self.copy_final_files_to_public_share_dirs(self.brand_code, base_name, output_files)
1423
+
1424
+ # Sync public share to cloud destination if enabled
1425
+ if self.public_share_rclone_enabled:
1426
+ self.sync_public_share_dir_to_rclone_destination()
1427
+
1428
+ elif self.folder_organisation_enabled:
1429
+ self.logger.info("Executing local folder organization...")
1430
+
1431
+ if self.keep_brand_code:
1432
+ self.brand_code = self.get_existing_brand_code()
1433
+ self.new_brand_code_dir = os.path.basename(os.getcwd())
1434
+ self.new_brand_code_dir_path = os.getcwd()
1435
+ else:
1436
+ self.brand_code = self.get_next_brand_code()
1437
+ self.move_files_to_brand_code_folder(self.brand_code, artist, title, output_files)
1438
+ # Update output file paths after moving
1439
+ for key in output_files:
1440
+ output_files[key] = os.path.join(self.new_brand_code_dir_path, os.path.basename(output_files[key]))
1441
+
1442
+ if self.public_share_copy_enabled:
1443
+ self.copy_final_files_to_public_share_dirs(self.brand_code, base_name, output_files)
1444
+
1445
+ if self.public_share_rclone_enabled:
1446
+ self.sync_public_share_dir_to_rclone_destination()
1447
+
1448
+ self.generate_organised_folder_sharing_link()
1449
+
1450
+ elif self.public_share_copy_enabled or self.public_share_rclone_enabled:
1451
+ # If only public share features are enabled (no folder organization), we still need a brand code
1452
+ self.logger.info("No folder organization enabled, but public share features require brand code...")
1453
+ if self.brand_prefix:
1454
+ if self.server_side_mode and self.organised_dir_rclone_root:
1455
+ self.brand_code = self.get_next_brand_code_server_side()
1456
+ elif not self.server_side_mode and self.organised_dir:
1457
+ self.brand_code = self.get_next_brand_code()
1458
+ else:
1459
+ # Fallback to timestamp-based brand code if no organized directory configured
1460
+ import datetime
1461
+ timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
1462
+ self.brand_code = f"{self.brand_prefix}-{timestamp}"
1463
+ self.logger.warning(f"No organized directory configured, using timestamp-based brand code: {self.brand_code}")
1464
+
1465
+ if self.public_share_copy_enabled:
1466
+ self.copy_final_files_to_public_share_dirs(self.brand_code, base_name, output_files)
1467
+
1468
+ if self.public_share_rclone_enabled:
1469
+ self.sync_public_share_dir_to_rclone_destination()
1470
+
1471
+ def authenticate_gmail(self):
1472
+ """Authenticate and return a Gmail service object."""
1473
+ creds = None
1474
+ gmail_token_file = "/tmp/karaoke-finalise-gmail-token.pickle"
1475
+
1476
+ if os.path.exists(gmail_token_file):
1477
+ with open(gmail_token_file, "rb") as token:
1478
+ creds = pickle.load(token)
1479
+
1480
+ if not creds or not creds.valid:
1481
+ if creds and creds.expired and creds.refresh_token:
1482
+ creds.refresh(Request())
1483
+ else:
1484
+ if self.non_interactive:
1485
+ raise Exception("Gmail authentication required but running in non-interactive mode. Please pre-authenticate or disable email drafts.")
1486
+
1487
+ flow = InstalledAppFlow.from_client_secrets_file(
1488
+ self.youtube_client_secrets_file, ["https://www.googleapis.com/auth/gmail.compose"]
1489
+ )
1490
+ creds = flow.run_local_server(port=0)
1491
+ with open(gmail_token_file, "wb") as token:
1492
+ pickle.dump(creds, token)
1493
+
1494
+ return build("gmail", "v1", credentials=creds)
1495
+
1496
+ def draft_completion_email(self, artist, title, youtube_url, dropbox_url):
1497
+ # Completely disable email drafts in server-side mode
1498
+ if self.server_side_mode:
1499
+ self.logger.info("Server-side mode: skipping email draft creation")
1500
+ return
1501
+
1502
+ if not self.email_template_file:
1503
+ self.logger.info("Email template file not provided, skipping email draft creation.")
1504
+ return
1505
+
1506
+ if not self.youtube_client_secrets_file:
1507
+ self.logger.error("Email template file was provided, but youtube_client_secrets_file is required for Gmail authentication.")
1508
+ self.logger.error("Please provide --youtube_client_secrets_file parameter to enable email draft creation.")
1509
+ self.logger.info("Skipping email draft creation.")
1510
+ return
1511
+
1512
+ with open(self.email_template_file, "r") as f:
1513
+ template = f.read()
1514
+
1515
+ email_body = template.format(youtube_url=youtube_url, dropbox_url=dropbox_url)
1516
+
1517
+ subject = f"{self.brand_code}: {artist} - {title}"
1518
+
1519
+ if self.dry_run:
1520
+ self.logger.info(f"DRY RUN: Would create email draft with subject: {subject}")
1521
+ self.logger.info(f"DRY RUN: Email body:\n{email_body}")
1522
+ else:
1523
+ if not self.gmail_service:
1524
+ self.gmail_service = self.authenticate_gmail()
1525
+
1526
+ message = MIMEText(email_body)
1527
+ message["subject"] = subject
1528
+ raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8")
1529
+ draft = self.gmail_service.users().drafts().create(userId="me", body={"message": {"raw": raw_message}}).execute()
1530
+ self.logger.info(f"Email draft created with ID: {draft['id']}")
1531
+
1532
+ def test_email_template(self):
1533
+ if not self.email_template_file:
1534
+ self.logger.error("Email template file not provided. Use --email_template_file to specify the file path.")
1535
+ return
1536
+
1537
+ fake_artist = "Test Artist"
1538
+ fake_title = "Test Song"
1539
+ fake_youtube_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
1540
+ fake_dropbox_url = "https://www.dropbox.com/sh/fake/folder/link"
1541
+ fake_brand_code = "TEST-0001"
1542
+
1543
+ self.brand_code = fake_brand_code
1544
+ self.draft_completion_email(fake_artist, fake_title, fake_youtube_url, fake_dropbox_url)
1545
+
1546
+ self.logger.info("Email template test complete. Check your Gmail drafts for the test email.")
1547
+
1548
+ def detect_best_aac_codec(self):
1549
+ """Detect the best available AAC codec (aac_at > libfdk_aac > aac)"""
1550
+ self.logger.info("Detecting best available AAC codec...")
1551
+
1552
+ codec_check_command = f"{self.ffmpeg_base_command} -codecs"
1553
+ result = os.popen(codec_check_command).read()
1554
+
1555
+ if "aac_at" in result:
1556
+ self.logger.info("Using aac_at codec (best quality)")
1557
+ return "aac_at"
1558
+ elif "libfdk_aac" in result:
1559
+ self.logger.info("Using libfdk_aac codec (good quality)")
1560
+ return "libfdk_aac"
1561
+ else:
1562
+ self.logger.info("Using built-in aac codec (basic quality)")
1563
+ return "aac"
1564
+
1565
+ def detect_nvenc_support(self):
1566
+ """Detect if NVENC hardware encoding is available."""
1567
+ try:
1568
+ self.logger.info("🔍 Detecting NVENC hardware acceleration...")
1569
+
1570
+ if self.dry_run:
1571
+ self.logger.info(" DRY RUN: Assuming NVENC is available")
1572
+ return True
1573
+
1574
+ import subprocess
1575
+ import os
1576
+ import shutil
1577
+
1578
+ # Check for nvidia-smi (indicates NVIDIA driver presence)
1579
+ try:
1580
+ nvidia_smi_result = subprocess.run(["nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader"],
1581
+ capture_output=True, text=True, timeout=10)
1582
+ if nvidia_smi_result.returncode == 0:
1583
+ gpu_info = nvidia_smi_result.stdout.strip()
1584
+ self.logger.info(f" ✓ NVIDIA GPU detected: {gpu_info}")
1585
+ else:
1586
+ self.logger.debug(f"nvidia-smi failed: {nvidia_smi_result.stderr}")
1587
+ self.logger.info(" ✗ NVENC not available (no NVIDIA GPU)")
1588
+ return False
1589
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.CalledProcessError) as e:
1590
+ self.logger.debug(f"nvidia-smi not available: {e}")
1591
+ self.logger.info(" ✗ NVENC not available (no NVIDIA GPU)")
1592
+ return False
1593
+
1594
+ # Check for NVENC encoders in FFmpeg
1595
+ try:
1596
+ encoders_cmd = f"{self.ffmpeg_base_command} -hide_banner -encoders 2>/dev/null | grep nvenc"
1597
+ encoders_result = subprocess.run(encoders_cmd, shell=True, capture_output=True, text=True, timeout=10)
1598
+ if encoders_result.returncode == 0 and "nvenc" in encoders_result.stdout:
1599
+ nvenc_encoders = [line.strip() for line in encoders_result.stdout.split('\n') if 'nvenc' in line]
1600
+ self.logger.debug(f"Found NVENC encoders: {nvenc_encoders}")
1601
+ else:
1602
+ self.logger.debug("No NVENC encoders found in FFmpeg")
1603
+ self.logger.info(" ✗ NVENC not available (no FFmpeg support)")
1604
+ return False
1605
+ except Exception as e:
1606
+ self.logger.debug(f"Failed to check FFmpeg NVENC encoders: {e}")
1607
+ self.logger.info(" ✗ NVENC not available")
1608
+ return False
1609
+
1610
+ # Check for libcuda.so.1 (critical for NVENC)
1611
+ try:
1612
+ libcuda_check = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True, timeout=10)
1613
+ if libcuda_check.returncode == 0 and "libcuda.so.1" in libcuda_check.stdout:
1614
+ self.logger.debug("libcuda.so.1 found in system libraries")
1615
+ else:
1616
+ self.logger.debug("libcuda.so.1 NOT found - may need nvidia/cuda:*-devel image")
1617
+ self.logger.info(" ✗ NVENC not available (missing CUDA libraries)")
1618
+ return False
1619
+ except Exception as e:
1620
+ self.logger.debug(f"Failed to check for libcuda.so.1: {e}")
1621
+ self.logger.info(" ✗ NVENC not available")
1622
+ return False
1623
+
1624
+ # Test h264_nvenc encoder
1625
+ test_cmd = f"{self.ffmpeg_base_command} -hide_banner -loglevel error -f lavfi -i testsrc=duration=1:size=320x240:rate=1 -c:v h264_nvenc -f null -"
1626
+ self.logger.debug(f"Testing NVENC: {test_cmd}")
1627
+
1628
+ try:
1629
+ result = subprocess.run(test_cmd, shell=True, capture_output=True, text=True, timeout=30)
1630
+
1631
+ if result.returncode == 0:
1632
+ self.logger.info(" ✓ NVENC encoding available")
1633
+ return True
1634
+ else:
1635
+ self.logger.debug(f"NVENC test failed (exit code {result.returncode}): {result.stderr}")
1636
+ self.logger.info(" ✗ NVENC not available")
1637
+ return False
1638
+
1639
+ except subprocess.TimeoutExpired:
1640
+ self.logger.debug("NVENC test timed out")
1641
+ self.logger.info(" ✗ NVENC not available (timeout)")
1642
+ return False
1643
+
1644
+ except Exception as e:
1645
+ self.logger.debug(f"Failed to detect NVENC support: {e}")
1646
+ self.logger.info(" ✗ NVENC not available (error)")
1647
+ return False
1648
+
1649
+ def configure_hardware_acceleration(self):
1650
+ """Configure hardware acceleration settings based on detected capabilities."""
1651
+ if self.nvenc_available:
1652
+ self.video_encoder = "h264_nvenc"
1653
+ # Use simpler hardware acceleration that works with complex filter chains
1654
+ # Remove -hwaccel_output_format cuda as it causes pixel format conversion issues
1655
+ self.hwaccel_decode_flags = "-hwaccel cuda"
1656
+ self.scale_filter = "scale" # Use CPU scaling for complex filter chains
1657
+ self.logger.info("🚀 Using NVENC hardware acceleration for video encoding")
1658
+ else:
1659
+ self.video_encoder = "libx264"
1660
+ self.hwaccel_decode_flags = ""
1661
+ self.scale_filter = "scale"
1662
+ self.logger.info("🔧 Using software encoding (libx264) for video")
1663
+
1664
+ def get_nvenc_quality_settings(self, quality_mode="high"):
1665
+ """Get NVENC settings based on quality requirements."""
1666
+ if quality_mode == "lossless":
1667
+ return "-preset lossless"
1668
+ elif quality_mode == "high":
1669
+ return "-preset p4 -tune hq -cq 18" # High quality
1670
+ elif quality_mode == "medium":
1671
+ return "-preset p4 -cq 23" # Balanced quality/speed
1672
+ elif quality_mode == "fast":
1673
+ return "-preset p1 -tune ll" # Low latency, faster encoding
1674
+ else:
1675
+ return "-preset p4" # Balanced default
1676
+
1677
+ def execute_command_with_fallback(self, gpu_command, cpu_command, description):
1678
+ """Execute GPU command with automatic fallback to CPU if it fails."""
1679
+ self.logger.info(f"{description}")
1680
+
1681
+ if self.dry_run:
1682
+ if self.nvenc_available:
1683
+ self.logger.info(f"DRY RUN: Would run GPU-accelerated command: {gpu_command}")
1684
+ else:
1685
+ self.logger.info(f"DRY RUN: Would run CPU command: {cpu_command}")
1686
+ return
1687
+
1688
+ # Try GPU-accelerated command first if available
1689
+ if self.nvenc_available and gpu_command != cpu_command:
1690
+ self.logger.debug(f"Attempting hardware-accelerated encoding: {gpu_command}")
1691
+ try:
1692
+ result = subprocess.run(gpu_command, shell=True, capture_output=True, text=True, timeout=300)
1693
+
1694
+ if result.returncode == 0:
1695
+ self.logger.info(f"✓ Hardware acceleration successful")
1696
+ return
1697
+ else:
1698
+ self.logger.warning(f"✗ Hardware acceleration failed (exit code {result.returncode})")
1699
+ self.logger.warning(f"GPU Command: {gpu_command}")
1700
+
1701
+ # If we didn't get detailed error info and using fatal loglevel, try again with verbose logging
1702
+ if (not result.stderr or len(result.stderr.strip()) < 10) and "-loglevel fatal" in gpu_command:
1703
+ self.logger.warning("Empty error output detected, retrying with verbose logging...")
1704
+ verbose_gpu_command = gpu_command.replace("-loglevel fatal", "-loglevel error")
1705
+ try:
1706
+ verbose_result = subprocess.run(verbose_gpu_command, shell=True, capture_output=True, text=True, timeout=300)
1707
+ self.logger.warning(f"Verbose GPU Command: {verbose_gpu_command}")
1708
+ if verbose_result.stderr:
1709
+ self.logger.warning(f"FFmpeg STDERR (verbose): {verbose_result.stderr}")
1710
+ if verbose_result.stdout:
1711
+ self.logger.warning(f"FFmpeg STDOUT (verbose): {verbose_result.stdout}")
1712
+ except Exception as e:
1713
+ self.logger.warning(f"Verbose retry failed: {e}")
1714
+
1715
+ if result.stderr:
1716
+ self.logger.warning(f"FFmpeg STDERR: {result.stderr}")
1717
+ else:
1718
+ self.logger.warning("FFmpeg STDERR: (empty)")
1719
+ if result.stdout:
1720
+ self.logger.warning(f"FFmpeg STDOUT: {result.stdout}")
1721
+ else:
1722
+ self.logger.warning("FFmpeg STDOUT: (empty)")
1723
+ self.logger.info("Falling back to software encoding...")
1724
+
1725
+ except subprocess.TimeoutExpired:
1726
+ self.logger.warning("✗ Hardware acceleration timed out, falling back to software encoding")
1727
+ except Exception as e:
1728
+ self.logger.warning(f"✗ Hardware acceleration failed with exception: {e}, falling back to software encoding")
1729
+
1730
+ # Use CPU command (either as fallback or primary method)
1731
+ self.logger.debug(f"Running software encoding: {cpu_command}")
1732
+ try:
1733
+ result = subprocess.run(cpu_command, shell=True, capture_output=True, text=True, timeout=600)
1734
+
1735
+ if result.returncode != 0:
1736
+ error_msg = f"Software encoding failed with exit code {result.returncode}"
1737
+ self.logger.error(error_msg)
1738
+ self.logger.error(f"CPU Command: {cpu_command}")
1739
+ if result.stderr:
1740
+ self.logger.error(f"FFmpeg STDERR: {result.stderr}")
1741
+ else:
1742
+ self.logger.error("FFmpeg STDERR: (empty)")
1743
+ if result.stdout:
1744
+ self.logger.error(f"FFmpeg STDOUT: {result.stdout}")
1745
+ else:
1746
+ self.logger.error("FFmpeg STDOUT: (empty)")
1747
+ raise Exception(f"{error_msg}: {cpu_command}")
1748
+ else:
1749
+ self.logger.info(f"✓ Software encoding successful")
1750
+
1751
+ except subprocess.TimeoutExpired:
1752
+ error_msg = "Software encoding timed out"
1753
+ self.logger.error(error_msg)
1754
+ raise Exception(f"{error_msg}: {cpu_command}")
1755
+ except Exception as e:
1756
+ if "Software encoding failed" not in str(e):
1757
+ error_msg = f"Software encoding failed with exception: {e}"
1758
+ self.logger.error(error_msg)
1759
+ raise Exception(f"{error_msg}: {cpu_command}")
1760
+ else:
1761
+ raise
1762
+
1763
+ def process(self, replace_existing=False):
1764
+ if self.dry_run:
1765
+ self.logger.warning("Dry run enabled. No actions will be performed.")
1766
+
1767
+ self.logger.info("=" * 60)
1768
+ self.logger.info("Starting KaraokeFinalise processing pipeline")
1769
+ self.logger.info("=" * 60)
1770
+
1771
+ # Check required input files and parameters exist, get user to confirm features before proceeding
1772
+ self.validate_input_parameters_for_features()
1773
+
1774
+ with_vocals_file = self.find_with_vocals_file()
1775
+ base_name, artist, title = self.get_names_from_withvocals(with_vocals_file)
1776
+
1777
+ self.logger.info(f"Processing: {artist} - {title}")
1778
+
1779
+ # Use the selected instrumental file if provided, otherwise search for one
1780
+ if self.selected_instrumental_file:
1781
+ if not os.path.isfile(self.selected_instrumental_file):
1782
+ raise Exception(f"Selected instrumental file not found: {self.selected_instrumental_file}")
1783
+ instrumental_audio_file = self.selected_instrumental_file
1784
+ self.logger.info(f"Using pre-selected instrumental file: {instrumental_audio_file}")
1785
+ else:
1786
+ self.logger.info("No instrumental file pre-selected, searching for instrumental files...")
1787
+ instrumental_audio_file = self.choose_instrumental_audio_file(base_name)
1788
+
1789
+ input_files = self.check_input_files_exist(base_name, with_vocals_file, instrumental_audio_file)
1790
+ output_files = self.prepare_output_filenames(base_name)
1791
+
1792
+ if self.enable_cdg:
1793
+ self.logger.info("Creating CDG package...")
1794
+ self.create_cdg_zip_file(input_files, output_files, artist, title)
1795
+ self.logger.info("CDG package created successfully")
1796
+
1797
+ if self.enable_txt:
1798
+ self.logger.info("Creating TXT package...")
1799
+ self.create_txt_zip_file(input_files, output_files)
1800
+ self.logger.info("TXT package created successfully")
1801
+
1802
+ self.logger.info("Starting video encoding (this is the longest step, ~15-20 minutes)...")
1803
+ self.remux_and_encode_output_video_files(with_vocals_file, input_files, output_files)
1804
+ self.logger.info("Video encoding completed successfully")
1805
+
1806
+ self.logger.info("Executing distribution features (YouTube, Dropbox, Discord)...")
1807
+ self.execute_optional_features(artist, title, base_name, input_files, output_files, replace_existing)
1808
+
1809
+ result = {
1810
+ "artist": artist,
1811
+ "title": title,
1812
+ "video_with_vocals": output_files["with_vocals_mp4"],
1813
+ "video_with_instrumental": output_files["karaoke_mp4"],
1814
+ "final_video": output_files["final_karaoke_lossless_mp4"],
1815
+ "final_video_mkv": output_files["final_karaoke_lossless_mkv"],
1816
+ "final_video_lossy": output_files["final_karaoke_lossy_mp4"],
1817
+ "final_video_720p": output_files["final_karaoke_lossy_720p_mp4"],
1818
+ "youtube_url": self.youtube_url,
1819
+ "brand_code": self.brand_code,
1820
+ "new_brand_code_dir_path": self.new_brand_code_dir_path,
1821
+ "brand_code_dir_sharing_link": self.brand_code_dir_sharing_link,
1822
+ }
1823
+
1824
+ if self.enable_cdg:
1825
+ result["final_karaoke_cdg_zip"] = output_files["final_karaoke_cdg_zip"]
1826
+
1827
+ if self.enable_txt:
1828
+ result["final_karaoke_txt_zip"] = output_files["final_karaoke_txt_zip"]
1829
+
1830
+ if self.email_template_file:
1831
+ self.draft_completion_email(artist, title, result["youtube_url"], result["brand_code_dir_sharing_link"])
1832
+
1833
+ return result