karaoke-gen 0.55.0__tar.gz → 0.57.0__tar.gz

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 (22) hide show
  1. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/PKG-INFO +5 -3
  2. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/audio_processor.py +13 -13
  3. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/file_handler.py +50 -14
  4. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/karaoke_finalise/karaoke_finalise.py +552 -55
  5. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/karaoke_gen.py +11 -4
  6. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/lyrics_processor.py +49 -4
  7. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/metadata.py +71 -21
  8. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/pyproject.toml +6 -4
  9. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/LICENSE +0 -0
  10. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/README.md +0 -0
  11. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/__init__.py +0 -0
  12. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/config.py +0 -0
  13. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/karaoke_finalise/__init__.py +0 -0
  14. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/resources/AvenirNext-Bold.ttf +0 -0
  15. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/resources/Montserrat-Bold.ttf +0 -0
  16. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/resources/Oswald-Bold.ttf +0 -0
  17. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/resources/Oswald-SemiBold.ttf +0 -0
  18. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/resources/Zurich_Cn_BT_Bold.ttf +0 -0
  19. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/utils/__init__.py +0 -0
  20. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/utils/bulk_cli.py +0 -0
  21. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/utils/gen_cli.py +0 -0
  22. {karaoke_gen-0.55.0 → karaoke_gen-0.57.0}/karaoke_gen/video_generator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: karaoke-gen
3
- Version: 0.55.0
3
+ Version: 0.57.0
4
4
  Summary: Generate karaoke videos with synchronized lyrics. Handles the entire process from downloading audio and lyrics to creating the final video with title screens.
5
5
  License: MIT
6
6
  Author: Andrew Beveridge
@@ -25,14 +25,16 @@ Requires-Dist: google-auth-httplib2
25
25
  Requires-Dist: google-auth-oauthlib
26
26
  Requires-Dist: kbputils (>=0.0.16,<0.0.17)
27
27
  Requires-Dist: lyrics-converter (>=0.2.1)
28
- Requires-Dist: lyrics-transcriber (>=0.54)
28
+ Requires-Dist: lyrics-transcriber (>=0.61)
29
29
  Requires-Dist: lyricsgenius (>=3)
30
+ Requires-Dist: modal (>=1.0.5,<2.0.0)
30
31
  Requires-Dist: numpy (>=2)
31
32
  Requires-Dist: pillow (>=10.1)
32
33
  Requires-Dist: psutil (>=7.0.0,<8.0.0)
33
34
  Requires-Dist: pyinstaller (>=6.3)
34
35
  Requires-Dist: pyperclip
35
- Requires-Dist: pytest-asyncio (>=0.23.5,<0.24.0)
36
+ Requires-Dist: pytest-asyncio
37
+ Requires-Dist: python-multipart (>=0.0.20,<0.0.21)
36
38
  Requires-Dist: requests (>=2)
37
39
  Requires-Dist: thefuzz (>=0.22)
38
40
  Requires-Dist: toml (>=0.10)
@@ -72,11 +72,11 @@ class AudioProcessor:
72
72
 
73
73
  for file in output_files:
74
74
  if "(Vocals)" in file:
75
- self.logger.info(f"Renaming Vocals file {file} to {vocals_path}")
76
- os.rename(file, vocals_path)
75
+ self.logger.info(f"Moving Vocals file {file} to {vocals_path}")
76
+ shutil.move(file, vocals_path)
77
77
  elif "(Instrumental)" in file:
78
- self.logger.info(f"Renaming Instrumental file {file} to {instrumental_path}")
79
- os.rename(file, instrumental_path)
78
+ self.logger.info(f"Moving Instrumental file {file} to {instrumental_path}")
79
+ shutil.move(file, instrumental_path)
80
80
  elif model_name in file:
81
81
  # Example filename 1: "Freddie Jackson - All I'll Ever Ask (feat. Najee) (Local)_(Piano)_htdemucs_6s.flac"
82
82
  # Example filename 2: "Freddie Jackson - All I'll Ever Ask (feat. Najee) (Local)_(Guitar)_htdemucs_6s.flac"
@@ -86,8 +86,8 @@ class AudioProcessor:
86
86
  stem_name = stem_name.strip("()") # Remove parentheses if present
87
87
 
88
88
  other_stem_path = os.path.join(track_output_dir, f"{artist_title} ({stem_name} {model_name}).{self.lossless_output_format}")
89
- self.logger.info(f"Renaming other stem file {file} to {other_stem_path}")
90
- os.rename(file, other_stem_path)
89
+ self.logger.info(f"Moving other stem file {file} to {other_stem_path}")
90
+ shutil.move(file, other_stem_path)
91
91
 
92
92
  elif model_name_no_extension in file:
93
93
  # Example filename 1: "Freddie Jackson - All I'll Ever Ask (feat. Najee) (Local)_(Piano)_htdemucs_6s.flac"
@@ -98,8 +98,8 @@ class AudioProcessor:
98
98
  stem_name = stem_name.strip("()") # Remove parentheses if present
99
99
 
100
100
  other_stem_path = os.path.join(track_output_dir, f"{artist_title} ({stem_name} {model_name}).{self.lossless_output_format}")
101
- self.logger.info(f"Renaming other stem file {file} to {other_stem_path}")
102
- os.rename(file, other_stem_path)
101
+ self.logger.info(f"Moving other stem file {file} to {other_stem_path}")
102
+ shutil.move(file, other_stem_path)
103
103
 
104
104
  self.logger.info(f"Separation complete! Output file(s): {vocals_path} {instrumental_path}")
105
105
 
@@ -262,10 +262,10 @@ class AudioProcessor:
262
262
 
263
263
  for file in clean_output_files:
264
264
  if "(Vocals)" in file and not self._file_exists(vocals_path):
265
- os.rename(file, vocals_path)
265
+ shutil.move(file, vocals_path)
266
266
  result["vocals"] = vocals_path
267
267
  elif "(Instrumental)" in file and not self._file_exists(instrumental_path):
268
- os.rename(file, instrumental_path)
268
+ shutil.move(file, instrumental_path)
269
269
  result["instrumental"] = instrumental_path
270
270
  else:
271
271
  result["vocals"] = vocals_path
@@ -298,7 +298,7 @@ class AudioProcessor:
298
298
  new_filename = f"{artist_title} ({stem_name} {model}).{self.lossless_output_format}"
299
299
  other_stem_path = os.path.join(stems_dir, new_filename)
300
300
  if not self._file_exists(other_stem_path):
301
- os.rename(file, other_stem_path)
301
+ shutil.move(file, other_stem_path)
302
302
  result[model][stem_name] = other_stem_path
303
303
 
304
304
  return result
@@ -318,10 +318,10 @@ class AudioProcessor:
318
318
 
319
319
  for file in backing_vocals_output:
320
320
  if "(Vocals)" in file and not self._file_exists(lead_vocals_path):
321
- os.rename(file, lead_vocals_path)
321
+ shutil.move(file, lead_vocals_path)
322
322
  result[model]["lead_vocals"] = lead_vocals_path
323
323
  elif "(Instrumental)" in file and not self._file_exists(backing_vocals_path):
324
- os.rename(file, backing_vocals_path)
324
+ shutil.move(file, backing_vocals_path)
325
325
  result[model]["backing_vocals"] = backing_vocals_path
326
326
  else:
327
327
  result[model]["lead_vocals"] = lead_vocals_path
@@ -39,28 +39,64 @@ class FileHandler:
39
39
 
40
40
  return copied_file_name
41
41
 
42
- def download_video(self, url, output_filename_no_extension):
42
+ def download_video(self, url, output_filename_no_extension, cookies_str=None):
43
43
  self.logger.debug(f"Downloading media from URL {url} to filename {output_filename_no_extension} + (as yet) unknown extension")
44
44
 
45
45
  ydl_opts = {
46
46
  "quiet": True,
47
47
  "format": "bv*+ba/b", # if a combined video + audio format is better than the best video-only format use the combined format
48
48
  "outtmpl": f"{output_filename_no_extension}.%(ext)s",
49
- "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.169 Safari/537.36",
49
+ # Enhanced anti-detection options
50
+ "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
51
+ "referer": "https://www.youtube.com/",
52
+ "sleep_interval": 1,
53
+ "max_sleep_interval": 3,
54
+ "fragment_retries": 3,
55
+ "extractor_retries": 3,
56
+ "retries": 3,
57
+ # Headers to appear more human
58
+ "http_headers": {
59
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
60
+ "Accept-Language": "en-us,en;q=0.5",
61
+ "Accept-Encoding": "gzip, deflate",
62
+ "DNT": "1",
63
+ "Connection": "keep-alive",
64
+ "Upgrade-Insecure-Requests": "1",
65
+ },
50
66
  }
51
67
 
52
- with ydl(ydl_opts) as ydl_instance:
53
- ydl_instance.download([url])
54
-
55
- # Search for the file with any extension
56
- downloaded_files = glob.glob(f"{output_filename_no_extension}.*")
57
- if downloaded_files:
58
- downloaded_file_name = downloaded_files[0] # Assume the first match is the correct one
59
- self.logger.info(f"Download finished, returning downloaded filename: {downloaded_file_name}")
60
- return downloaded_file_name
61
- else:
62
- self.logger.error("No files found matching the download pattern.")
63
- return None
68
+ # Add cookies if provided
69
+ if cookies_str:
70
+ self.logger.info("Using provided cookies for enhanced YouTube download access")
71
+ # Save cookies to a temporary file
72
+ import tempfile
73
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
74
+ f.write(cookies_str)
75
+ ydl_opts['cookiefile'] = f.name
76
+ else:
77
+ self.logger.info("No cookies provided for download - attempting standard download")
78
+
79
+ try:
80
+ with ydl(ydl_opts) as ydl_instance:
81
+ ydl_instance.download([url])
82
+
83
+ # Search for the file with any extension
84
+ downloaded_files = glob.glob(f"{output_filename_no_extension}.*")
85
+ if downloaded_files:
86
+ downloaded_file_name = downloaded_files[0] # Assume the first match is the correct one
87
+ self.logger.info(f"Download finished, returning downloaded filename: {downloaded_file_name}")
88
+ return downloaded_file_name
89
+ else:
90
+ self.logger.error("No files found matching the download pattern.")
91
+ return None
92
+ finally:
93
+ # Clean up temporary cookie file if it was created
94
+ if cookies_str and 'cookiefile' in ydl_opts:
95
+ try:
96
+ import os
97
+ os.unlink(ydl_opts['cookiefile'])
98
+ except:
99
+ pass
64
100
 
65
101
  def extract_still_image_from_video(self, input_filename, output_filename_no_extension):
66
102
  output_filename = output_filename_no_extension + ".png"
@@ -44,6 +44,8 @@ class KaraokeFinalise:
44
44
  cdg_styles=None,
45
45
  keep_brand_code=False,
46
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
47
49
  ):
48
50
  self.log_level = log_level
49
51
  self.log_formatter = log_formatter
@@ -99,6 +101,8 @@ class KaraokeFinalise:
99
101
 
100
102
  self.skip_notifications = False
101
103
  self.non_interactive = non_interactive
104
+ self.user_youtube_credentials = user_youtube_credentials
105
+ self.server_side_mode = server_side_mode
102
106
 
103
107
  self.suffixes = {
104
108
  "title_mov": " (Title).mov",
@@ -146,6 +150,10 @@ class KaraokeFinalise:
146
150
  if self.non_interactive:
147
151
  self.ffmpeg_base_command += " -y"
148
152
 
153
+ # Detect and configure hardware acceleration
154
+ self.nvenc_available = self.detect_nvenc_support()
155
+ self.configure_hardware_acceleration()
156
+
149
157
  def check_input_files_exist(self, base_name, with_vocals_file, instrumental_audio_file):
150
158
  self.logger.info(f"Checking required input files exist...")
151
159
 
@@ -256,12 +264,17 @@ class KaraokeFinalise:
256
264
  self.discord_notication_enabled = True
257
265
 
258
266
  # Enable folder organisation if brand prefix and target directory are provided and target directory is valid
267
+ # In server-side mode, we skip the local folder organization but may still need brand codes
259
268
  if self.brand_prefix is not None and self.organised_dir is not None:
260
- if not os.path.isdir(self.organised_dir):
269
+ if not self.server_side_mode and not os.path.isdir(self.organised_dir):
261
270
  raise Exception(f"Target directory does not exist: {self.organised_dir}")
262
271
 
263
- self.logger.debug(f"Brand prefix and target directory provided, enabling folder organisation")
264
- self.folder_organisation_enabled = True
272
+ if not self.server_side_mode:
273
+ self.logger.debug(f"Brand prefix and target directory provided, enabling local folder organisation")
274
+ self.folder_organisation_enabled = True
275
+ else:
276
+ self.logger.debug(f"Server-side mode: brand prefix provided for remote organization")
277
+ self.folder_organisation_enabled = False # Disable local folder organization in server mode
265
278
 
266
279
  # Enable public share copy if public share directory is provided and is valid directory with MP4 and CDG subdirectories
267
280
  if self.public_share_dir is not None:
@@ -292,18 +305,61 @@ class KaraokeFinalise:
292
305
  self.logger.info(f" Public share copy: {self.public_share_copy_enabled}")
293
306
  self.logger.info(f" Public share rclone: {self.public_share_rclone_enabled}")
294
307
 
295
- self.prompt_user_confirmation_or_raise_exception(
296
- f"Confirm features enabled log messages above match your expectations for finalisation?",
297
- "Refusing to proceed without user confirmation they're happy with enabled features.",
298
- allow_empty=True,
299
- )
308
+ # Skip user confirmation in non-interactive mode for Modal deployment
309
+ if not self.non_interactive:
310
+ self.prompt_user_confirmation_or_raise_exception(
311
+ f"Confirm features enabled log messages above match your expectations for finalisation?",
312
+ "Refusing to proceed without user confirmation they're happy with enabled features.",
313
+ allow_empty=True,
314
+ )
315
+ else:
316
+ self.logger.info("Non-interactive mode: automatically confirming enabled features")
300
317
 
301
318
  def authenticate_youtube(self):
302
- """Authenticate and return a YouTube service object."""
303
- credentials = None
304
- youtube_token_file = "/tmp/karaoke-finalise-youtube-token.pickle"
305
-
319
+ """Authenticate with YouTube and return service object."""
320
+ from google.auth.transport.requests import Request
321
+ from google.oauth2.credentials import Credentials
322
+ from googleapiclient.discovery import build
323
+ from google_auth_oauthlib.flow import InstalledAppFlow
324
+ import pickle
325
+ import os
326
+
327
+ # Check if we have pre-stored credentials (for non-interactive mode)
328
+ if self.user_youtube_credentials and self.non_interactive:
329
+ try:
330
+ # Create credentials object from stored data
331
+ credentials = Credentials(
332
+ token=self.user_youtube_credentials['token'],
333
+ refresh_token=self.user_youtube_credentials.get('refresh_token'),
334
+ token_uri=self.user_youtube_credentials.get('token_uri'),
335
+ client_id=self.user_youtube_credentials.get('client_id'),
336
+ client_secret=self.user_youtube_credentials.get('client_secret'),
337
+ scopes=self.user_youtube_credentials.get('scopes')
338
+ )
339
+
340
+ # Refresh token if needed
341
+ if credentials.expired and credentials.refresh_token:
342
+ credentials.refresh(Request())
343
+
344
+ # Build YouTube service with credentials
345
+ youtube = build('youtube', 'v3', credentials=credentials)
346
+ self.logger.info("Successfully authenticated with YouTube using pre-stored credentials")
347
+ return youtube
348
+
349
+ except Exception as e:
350
+ self.logger.error(f"Failed to authenticate with pre-stored credentials: {str(e)}")
351
+ # Fall through to original authentication if pre-stored credentials fail
352
+
353
+ # Original authentication code for interactive mode
354
+ if self.non_interactive:
355
+ raise Exception("YouTube authentication required but running in non-interactive mode. Please pre-authenticate or disable YouTube upload.")
356
+
306
357
  # Token file stores the user's access and refresh tokens for YouTube.
358
+ youtube_token_file = "/tmp/karaoke-finalise-youtube-token.pickle"
359
+
360
+ credentials = None
361
+
362
+ # Check if we have saved credentials
307
363
  if os.path.exists(youtube_token_file):
308
364
  with open(youtube_token_file, "rb") as token:
309
365
  credentials = pickle.load(token)
@@ -313,6 +369,9 @@ class KaraokeFinalise:
313
369
  if credentials and credentials.expired and credentials.refresh_token:
314
370
  credentials.refresh(Request())
315
371
  else:
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
+
316
375
  flow = InstalledAppFlow.from_client_secrets_file(
317
376
  self.youtube_client_secrets_file, scopes=["https://www.googleapis.com/auth/youtube"]
318
377
  )
@@ -631,74 +690,130 @@ class KaraokeFinalise:
631
690
  return base_name, artist, title
632
691
 
633
692
  def execute_command(self, command, description):
634
- self.logger.info(description)
693
+ """Execute a shell command and log the output. For general commands (rclone, etc.)"""
694
+ self.logger.info(f"{description}")
695
+ self.logger.debug(f"Executing command: {command}")
696
+
635
697
  if self.dry_run:
636
- self.logger.info(f"DRY RUN: Would run command: {command}")
637
- else:
638
- self.logger.info(f"Running command: {command}")
639
- os.system(command)
698
+ self.logger.info(f"DRY RUN: Would execute: {command}")
699
+ return
700
+
701
+ try:
702
+ result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=600)
703
+
704
+ # Log command output for debugging
705
+ if result.stdout and result.stdout.strip():
706
+ self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
707
+ if result.stderr and result.stderr.strip():
708
+ self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
709
+
710
+ if result.returncode != 0:
711
+ error_msg = f"Command failed with exit code {result.returncode}"
712
+ self.logger.error(error_msg)
713
+ self.logger.error(f"Command: {command}")
714
+ if result.stdout:
715
+ self.logger.error(f"STDOUT: {result.stdout}")
716
+ if result.stderr:
717
+ self.logger.error(f"STDERR: {result.stderr}")
718
+ raise Exception(f"{error_msg}: {command}")
719
+ else:
720
+ self.logger.info(f"✓ Command completed successfully")
721
+
722
+ except subprocess.TimeoutExpired:
723
+ error_msg = f"Command timed out after 600 seconds"
724
+ self.logger.error(error_msg)
725
+ raise Exception(f"{error_msg}: {command}")
726
+ except Exception as e:
727
+ if "Command failed" not in str(e):
728
+ error_msg = f"Command failed with exception: {e}"
729
+ self.logger.error(error_msg)
730
+ raise Exception(f"{error_msg}: {command}")
731
+ else:
732
+ raise
640
733
 
641
734
  def remux_with_instrumental(self, with_vocals_file, instrumental_audio, output_file):
642
735
  """Remux the video with instrumental audio to create karaoke version"""
643
- # fmt: off
736
+ # This operation is primarily I/O bound (remuxing), so hardware acceleration doesn't provide significant benefit
737
+ # Keep the existing approach but use the new execute method
644
738
  ffmpeg_command = (
645
739
  f'{self.ffmpeg_base_command} -an -i "{with_vocals_file}" '
646
740
  f'-vn -i "{instrumental_audio}" -c:v copy -c:a pcm_s16le "{output_file}"'
647
741
  )
648
- # fmt: on
649
742
  self.execute_command(ffmpeg_command, "Remuxing video with instrumental audio")
650
743
 
651
744
  def convert_mov_to_mp4(self, input_file, output_file):
652
- """Convert MOV file to MP4 format"""
653
- # fmt: off
654
- ffmpeg_command = (
745
+ """Convert MOV file to MP4 format with hardware acceleration support"""
746
+ # Hardware-accelerated version
747
+ gpu_command = (
748
+ f'{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
749
+ f'-c:v {self.video_encoder} {self.get_nvenc_quality_settings("high")} -c:a {self.aac_codec} {self.mp4_flags} "{output_file}"'
750
+ )
751
+
752
+ # Software fallback version
753
+ cpu_command = (
655
754
  f'{self.ffmpeg_base_command} -i "{input_file}" '
656
755
  f'-c:v libx264 -c:a {self.aac_codec} {self.mp4_flags} "{output_file}"'
657
756
  )
658
- # fmt: on
659
- self.execute_command(ffmpeg_command, "Converting MOV video to MP4")
757
+
758
+ self.execute_command_with_fallback(gpu_command, cpu_command, "Converting MOV video to MP4")
660
759
 
661
760
  def encode_lossless_mp4(self, title_mov_file, karaoke_mp4_file, env_mov_input, ffmpeg_filter, output_file):
662
- """Create the final MP4 with PCM audio (lossless)"""
663
- # fmt: off
664
- ffmpeg_command = (
761
+ """Create the final MP4 with PCM audio (lossless) using hardware acceleration when available"""
762
+ # Hardware-accelerated version
763
+ gpu_command = (
764
+ f"{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i {title_mov_file} "
765
+ f"{self.hwaccel_decode_flags} -i {karaoke_mp4_file} {env_mov_input} "
766
+ f'{ffmpeg_filter} -map "[outv]" -map "[outa]" -c:v {self.video_encoder} '
767
+ f'{self.get_nvenc_quality_settings("lossless")} -c:a pcm_s16le {self.mp4_flags} "{output_file}"'
768
+ )
769
+
770
+ # Software fallback version
771
+ cpu_command = (
665
772
  f"{self.ffmpeg_base_command} -i {title_mov_file} -i {karaoke_mp4_file} {env_mov_input} "
666
773
  f'{ffmpeg_filter} -map "[outv]" -map "[outa]" -c:v libx264 -c:a pcm_s16le '
667
774
  f'{self.mp4_flags} "{output_file}"'
668
775
  )
669
- # fmt: on
670
- self.execute_command(ffmpeg_command, "Creating MP4 version with PCM audio")
776
+
777
+ self.execute_command_with_fallback(gpu_command, cpu_command, "Creating MP4 version with PCM audio")
671
778
 
672
779
  def encode_lossy_mp4(self, input_file, output_file):
673
780
  """Create MP4 with AAC audio (lossy, for wider compatibility)"""
674
- # fmt: off
781
+ # This is primarily an audio re-encoding operation, video is copied
782
+ # Hardware acceleration doesn't provide significant benefit for copy operations
675
783
  ffmpeg_command = (
676
784
  f'{self.ffmpeg_base_command} -i "{input_file}" '
677
785
  f'-c:v copy -c:a {self.aac_codec} -b:a 320k {self.mp4_flags} "{output_file}"'
678
786
  )
679
- # fmt: on
680
787
  self.execute_command(ffmpeg_command, "Creating MP4 version with AAC audio")
681
788
 
682
789
  def encode_lossless_mkv(self, input_file, output_file):
683
790
  """Create MKV with FLAC audio (for YouTube)"""
684
- # fmt: off
791
+ # This is primarily an audio re-encoding operation, video is copied
792
+ # Hardware acceleration doesn't provide significant benefit for copy operations
685
793
  ffmpeg_command = (
686
794
  f'{self.ffmpeg_base_command} -i "{input_file}" '
687
795
  f'-c:v copy -c:a flac "{output_file}"'
688
796
  )
689
- # fmt: on
690
797
  self.execute_command(ffmpeg_command, "Creating MKV version with FLAC audio for YouTube")
691
798
 
692
799
  def encode_720p_version(self, input_file, output_file):
693
- """Create 720p MP4 with AAC audio (for smaller file size)"""
694
- # fmt: off
695
- ffmpeg_command = (
800
+ """Create 720p MP4 with AAC audio (for smaller file size) using hardware acceleration when available"""
801
+ # Hardware-accelerated version with GPU scaling and encoding
802
+ gpu_command = (
803
+ f'{self.ffmpeg_base_command} {self.hwaccel_decode_flags} -i "{input_file}" '
804
+ f'-c:v {self.video_encoder} -vf "{self.scale_filter}=1280:720" '
805
+ f'{self.get_nvenc_quality_settings("medium")} -b:v 2000k '
806
+ f'-c:a {self.aac_codec} -b:a 128k {self.mp4_flags} "{output_file}"'
807
+ )
808
+
809
+ # Software fallback version
810
+ cpu_command = (
696
811
  f'{self.ffmpeg_base_command} -i "{input_file}" '
697
- f'-c:v libx264 -vf "scale=1280:720" -b:v 200k -preset medium -tune animation '
812
+ f'-c:v libx264 -vf "scale=1280:720" -b:v 2000k -preset medium -tune animation '
698
813
  f'-c:a {self.aac_codec} -b:a 128k {self.mp4_flags} "{output_file}"'
699
814
  )
700
- # fmt: on
701
- self.execute_command(ffmpeg_command, "Encoding 720p version of the final video")
815
+
816
+ self.execute_command_with_fallback(gpu_command, cpu_command, "Encoding 720p version of the final video")
702
817
 
703
818
  def prepare_concat_filter(self, input_files):
704
819
  """Prepare the concat filter and additional input for end credits if present"""
@@ -749,17 +864,21 @@ class KaraokeFinalise:
749
864
  self.encode_lossless_mkv(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossless_mkv"])
750
865
  self.encode_720p_version(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossy_720p_mp4"])
751
866
 
752
- # Prompt user to check final video files before proceeding
753
- self.prompt_user_confirmation_or_raise_exception(
754
- f"Final video files created:\n"
755
- f"- Lossless 4K MP4: {output_files['final_karaoke_lossless_mp4']}\n"
756
- f"- Lossless 4K MKV: {output_files['final_karaoke_lossless_mkv']}\n"
757
- f"- Lossy 4K MP4: {output_files['final_karaoke_lossy_mp4']}\n"
758
- f"- Lossy 720p MP4: {output_files['final_karaoke_lossy_720p_mp4']}\n"
759
- f"Please check them! Proceed?",
760
- "Refusing to proceed without user confirmation they're happy with the Final videos.",
761
- allow_empty=True,
762
- )
867
+ # Skip user confirmation in non-interactive mode for Modal deployment
868
+ if not self.non_interactive:
869
+ # Prompt user to check final video files before proceeding
870
+ self.prompt_user_confirmation_or_raise_exception(
871
+ f"Final video files created:\n"
872
+ f"- Lossless 4K MP4: {output_files['final_karaoke_lossless_mp4']}\n"
873
+ f"- Lossless 4K MKV: {output_files['final_karaoke_lossless_mkv']}\n"
874
+ f"- Lossy 4K MP4: {output_files['final_karaoke_lossy_mp4']}\n"
875
+ f"- Lossy 720p MP4: {output_files['final_karaoke_lossy_720p_mp4']}\n"
876
+ f"Please check them! Proceed?",
877
+ "Refusing to proceed without user confirmation they're happy with the Final videos.",
878
+ allow_empty=True,
879
+ )
880
+ else:
881
+ self.logger.info("Non-interactive mode: automatically confirming final video files")
763
882
 
764
883
  def create_cdg_zip_file(self, input_files, output_files, artist, title):
765
884
  self.logger.info(f"Creating CDG and MP3 files, then zipping them...")
@@ -935,9 +1054,9 @@ class KaraokeFinalise:
935
1054
  self.logger.info(f"Copied final files to public share directory")
936
1055
 
937
1056
  def sync_public_share_dir_to_rclone_destination(self):
938
- self.logger.info(f"Syncing public share directory to rclone destination...")
1057
+ self.logger.info(f"Copying public share directory to rclone destination...")
939
1058
 
940
- # Delete .DS_Store files recursively before syncing
1059
+ # Delete .DS_Store files recursively before copying
941
1060
  for root, dirs, files in os.walk(self.public_share_dir):
942
1061
  for file in files:
943
1062
  if file == ".DS_Store":
@@ -945,8 +1064,8 @@ class KaraokeFinalise:
945
1064
  os.remove(file_path)
946
1065
  self.logger.info(f"Deleted .DS_Store file: {file_path}")
947
1066
 
948
- rclone_cmd = f"rclone sync -v '{self.public_share_dir}' '{self.rclone_destination}'"
949
- self.execute_command(rclone_cmd, "Syncing with cloud destination")
1067
+ rclone_cmd = f"rclone copy -v {shlex.quote(self.public_share_dir)} {shlex.quote(self.rclone_destination)}"
1068
+ self.execute_command(rclone_cmd, "Copying to cloud destination")
950
1069
 
951
1070
  def post_discord_notification(self):
952
1071
  self.logger.info(f"Posting Discord notification...")
@@ -980,6 +1099,13 @@ class KaraokeFinalise:
980
1099
  try:
981
1100
  self.logger.info(f"Running command: {rclone_link_cmd}")
982
1101
  result = subprocess.run(rclone_link_cmd, shell=True, check=True, capture_output=True, text=True)
1102
+
1103
+ # Log command output for debugging
1104
+ if result.stdout and result.stdout.strip():
1105
+ self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
1106
+ if result.stderr and result.stderr.strip():
1107
+ self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
1108
+
983
1109
  self.brand_code_dir_sharing_link = result.stdout.strip()
984
1110
  self.logger.info(f"Got organised folder sharing link: {self.brand_code_dir_sharing_link}")
985
1111
  except subprocess.CalledProcessError as e:
@@ -988,6 +1114,125 @@ class KaraokeFinalise:
988
1114
  self.logger.error(f"Command output (stderr): {e.stderr}")
989
1115
  self.logger.error(f"Full exception: {e}")
990
1116
 
1117
+ def get_next_brand_code_server_side(self):
1118
+ """
1119
+ Calculate the next sequence number based on existing directories in the remote organised_dir using rclone.
1120
+ Assumes directories are named with the format: BRAND-XXXX Artist - Title
1121
+ """
1122
+ if not self.organised_dir_rclone_root:
1123
+ raise Exception("organised_dir_rclone_root not configured for server-side brand code generation")
1124
+
1125
+ self.logger.info(f"Getting next brand code from remote organized directory: {self.organised_dir_rclone_root}")
1126
+
1127
+ max_num = 0
1128
+ pattern = re.compile(rf"^{re.escape(self.brand_prefix)}-(\d{{4}})")
1129
+
1130
+ # Use rclone lsf --dirs-only for clean, machine-readable directory listing
1131
+ rclone_list_cmd = f"rclone lsf --dirs-only {shlex.quote(self.organised_dir_rclone_root)}"
1132
+
1133
+ if self.dry_run:
1134
+ self.logger.info(f"DRY RUN: Would run: {rclone_list_cmd}")
1135
+ return f"{self.brand_prefix}-0001"
1136
+
1137
+ try:
1138
+ self.logger.info(f"Running command: {rclone_list_cmd}")
1139
+ result = subprocess.run(rclone_list_cmd, shell=True, check=True, capture_output=True, text=True)
1140
+
1141
+ # Log command output for debugging
1142
+ if result.stdout and result.stdout.strip():
1143
+ self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
1144
+ if result.stderr and result.stderr.strip():
1145
+ self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
1146
+
1147
+ # Parse the output to find matching directories
1148
+ matching_dirs = []
1149
+ for line_num, line in enumerate(result.stdout.strip().split('\n')):
1150
+ if line.strip():
1151
+ # Remove trailing slash and whitespace
1152
+ dir_name = line.strip().rstrip('/')
1153
+
1154
+ # Check if directory matches our brand pattern
1155
+ match = pattern.match(dir_name)
1156
+ if match:
1157
+ num = int(match.group(1))
1158
+ max_num = max(max_num, num)
1159
+ matching_dirs.append((dir_name, num))
1160
+
1161
+ self.logger.info(f"Found {len(matching_dirs)} matching directories with pattern {self.brand_prefix}-XXXX")
1162
+
1163
+ next_seq_number = max_num + 1
1164
+ brand_code = f"{self.brand_prefix}-{next_seq_number:04d}"
1165
+
1166
+ self.logger.info(f"Highest existing number: {max_num}, next sequence number for brand {self.brand_prefix} calculated as: {next_seq_number}")
1167
+ return brand_code
1168
+
1169
+ except subprocess.CalledProcessError as e:
1170
+ self.logger.error(f"Failed to list remote organized directory. Exit code: {e.returncode}")
1171
+ self.logger.error(f"Command output (stdout): {e.stdout}")
1172
+ self.logger.error(f"Command output (stderr): {e.stderr}")
1173
+ raise Exception(f"Failed to get brand code from remote directory: {e}")
1174
+
1175
+ def upload_files_to_organized_folder_server_side(self, brand_code, artist, title):
1176
+ """
1177
+ Upload all files from current directory to the remote organized folder using rclone.
1178
+ Creates a brand-prefixed directory in the remote organized folder.
1179
+ """
1180
+ if not self.organised_dir_rclone_root:
1181
+ raise Exception("organised_dir_rclone_root not configured for server-side file upload")
1182
+
1183
+ self.new_brand_code_dir = f"{brand_code} - {artist} - {title}"
1184
+ remote_dest = f"{self.organised_dir_rclone_root}/{self.new_brand_code_dir}"
1185
+
1186
+ self.logger.info(f"Uploading files to remote organized directory: {remote_dest}")
1187
+
1188
+ # Get current directory path to upload
1189
+ current_dir = os.getcwd()
1190
+
1191
+ # Use rclone copy to upload the entire current directory to the remote destination
1192
+ rclone_upload_cmd = f"rclone copy -v {shlex.quote(current_dir)} {shlex.quote(remote_dest)}"
1193
+
1194
+ if self.dry_run:
1195
+ self.logger.info(f"DRY RUN: Would upload current directory to: {remote_dest}")
1196
+ self.logger.info(f"DRY RUN: Command: {rclone_upload_cmd}")
1197
+ else:
1198
+ self.execute_command(rclone_upload_cmd, f"Uploading files to organized folder: {remote_dest}")
1199
+
1200
+ # Generate a sharing link for the uploaded folder
1201
+ self.generate_organised_folder_sharing_link_server_side(remote_dest)
1202
+
1203
+ def generate_organised_folder_sharing_link_server_side(self, remote_path):
1204
+ """Generate a sharing link for the remote organized folder using rclone."""
1205
+ self.logger.info(f"Getting sharing link for remote organized folder: {remote_path}")
1206
+
1207
+ rclone_link_cmd = f"rclone link {shlex.quote(remote_path)}"
1208
+
1209
+ if self.dry_run:
1210
+ self.logger.info(f"DRY RUN: Would get sharing link with: {rclone_link_cmd}")
1211
+ self.brand_code_dir_sharing_link = "https://file-sharing-service.com/example"
1212
+ return
1213
+
1214
+ # Add a 10-second delay to allow the remote service to index the folder before generating a link
1215
+ self.logger.info("Waiting 10 seconds before generating link...")
1216
+ time.sleep(10)
1217
+
1218
+ try:
1219
+ self.logger.info(f"Running command: {rclone_link_cmd}")
1220
+ result = subprocess.run(rclone_link_cmd, shell=True, check=True, capture_output=True, text=True)
1221
+
1222
+ # Log command output for debugging
1223
+ if result.stdout and result.stdout.strip():
1224
+ self.logger.debug(f"Command STDOUT: {result.stdout.strip()}")
1225
+ if result.stderr and result.stderr.strip():
1226
+ self.logger.debug(f"Command STDERR: {result.stderr.strip()}")
1227
+
1228
+ self.brand_code_dir_sharing_link = result.stdout.strip()
1229
+ self.logger.info(f"Got organized folder sharing link: {self.brand_code_dir_sharing_link}")
1230
+ except subprocess.CalledProcessError as e:
1231
+ self.logger.error(f"Failed to get organized folder sharing link. Exit code: {e.returncode}")
1232
+ self.logger.error(f"Command output (stdout): {e.stdout}")
1233
+ self.logger.error(f"Command output (stderr): {e.stderr}")
1234
+ self.logger.error(f"Full exception: {e}")
1235
+
991
1236
  def get_existing_brand_code(self):
992
1237
  """Extract brand code from current directory name"""
993
1238
  current_dir = os.path.basename(os.getcwd())
@@ -1018,7 +1263,30 @@ class KaraokeFinalise:
1018
1263
  if self.discord_notication_enabled:
1019
1264
  self.post_discord_notification()
1020
1265
 
1021
- if self.folder_organisation_enabled:
1266
+ # Handle folder organization - different logic for server-side vs local mode
1267
+ if self.server_side_mode and self.brand_prefix and self.organised_dir_rclone_root:
1268
+ self.logger.info("Executing server-side organization...")
1269
+
1270
+ # Generate brand code from remote directory listing
1271
+ if self.keep_brand_code:
1272
+ self.brand_code = self.get_existing_brand_code()
1273
+ else:
1274
+ self.brand_code = self.get_next_brand_code_server_side()
1275
+
1276
+ # Upload files to organized folder via rclone
1277
+ self.upload_files_to_organized_folder_server_side(self.brand_code, artist, title)
1278
+
1279
+ # Copy files to public share if enabled
1280
+ if self.public_share_copy_enabled:
1281
+ self.copy_final_files_to_public_share_dirs(self.brand_code, base_name, output_files)
1282
+
1283
+ # Sync public share to cloud destination if enabled
1284
+ if self.public_share_rclone_enabled:
1285
+ self.sync_public_share_dir_to_rclone_destination()
1286
+
1287
+ elif self.folder_organisation_enabled:
1288
+ self.logger.info("Executing local folder organization...")
1289
+
1022
1290
  if self.keep_brand_code:
1023
1291
  self.brand_code = self.get_existing_brand_code()
1024
1292
  self.new_brand_code_dir = os.path.basename(os.getcwd())
@@ -1037,6 +1305,27 @@ class KaraokeFinalise:
1037
1305
  self.sync_public_share_dir_to_rclone_destination()
1038
1306
 
1039
1307
  self.generate_organised_folder_sharing_link()
1308
+
1309
+ elif self.public_share_copy_enabled or self.public_share_rclone_enabled:
1310
+ # If only public share features are enabled (no folder organization), we still need a brand code
1311
+ self.logger.info("No folder organization enabled, but public share features require brand code...")
1312
+ if self.brand_prefix:
1313
+ if self.server_side_mode and self.organised_dir_rclone_root:
1314
+ self.brand_code = self.get_next_brand_code_server_side()
1315
+ elif not self.server_side_mode and self.organised_dir:
1316
+ self.brand_code = self.get_next_brand_code()
1317
+ else:
1318
+ # Fallback to timestamp-based brand code if no organized directory configured
1319
+ import datetime
1320
+ timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
1321
+ self.brand_code = f"{self.brand_prefix}-{timestamp}"
1322
+ self.logger.warning(f"No organized directory configured, using timestamp-based brand code: {self.brand_code}")
1323
+
1324
+ if self.public_share_copy_enabled:
1325
+ self.copy_final_files_to_public_share_dirs(self.brand_code, base_name, output_files)
1326
+
1327
+ if self.public_share_rclone_enabled:
1328
+ self.sync_public_share_dir_to_rclone_destination()
1040
1329
 
1041
1330
  def authenticate_gmail(self):
1042
1331
  """Authenticate and return a Gmail service object."""
@@ -1051,6 +1340,9 @@ class KaraokeFinalise:
1051
1340
  if creds and creds.expired and creds.refresh_token:
1052
1341
  creds.refresh(Request())
1053
1342
  else:
1343
+ if self.non_interactive:
1344
+ raise Exception("Gmail authentication required but running in non-interactive mode. Please pre-authenticate or disable email drafts.")
1345
+
1054
1346
  flow = InstalledAppFlow.from_client_secrets_file(
1055
1347
  self.youtube_client_secrets_file, ["https://www.googleapis.com/auth/gmail.compose"]
1056
1348
  )
@@ -1061,6 +1353,11 @@ class KaraokeFinalise:
1061
1353
  return build("gmail", "v1", credentials=creds)
1062
1354
 
1063
1355
  def draft_completion_email(self, artist, title, youtube_url, dropbox_url):
1356
+ # Completely disable email drafts in server-side mode
1357
+ if self.server_side_mode:
1358
+ self.logger.info("Server-side mode: skipping email draft creation")
1359
+ return
1360
+
1064
1361
  if not self.email_template_file:
1065
1362
  self.logger.info("Email template file not provided, skipping email draft creation.")
1066
1363
  return
@@ -1118,6 +1415,206 @@ class KaraokeFinalise:
1118
1415
  self.logger.info("Using built-in aac codec (basic quality)")
1119
1416
  return "aac"
1120
1417
 
1418
+ def detect_nvenc_support(self):
1419
+ """Detect if NVENC hardware encoding is available with comprehensive checks."""
1420
+ try:
1421
+ self.logger.info("🔍 Detecting NVENC hardware acceleration support...")
1422
+
1423
+ if self.dry_run:
1424
+ self.logger.info("DRY RUN: Assuming NVENC is available")
1425
+ return True
1426
+
1427
+ import subprocess
1428
+ import os
1429
+ import shutil
1430
+
1431
+ # Step 1: Check for nvidia-smi (indicates NVIDIA driver presence)
1432
+ try:
1433
+ nvidia_smi_result = subprocess.run(["nvidia-smi", "--query-gpu=name,driver_version", "--format=csv,noheader"],
1434
+ capture_output=True, text=True, timeout=10)
1435
+ if nvidia_smi_result.returncode == 0:
1436
+ gpu_info = nvidia_smi_result.stdout.strip()
1437
+ self.logger.info(f"✓ NVIDIA GPU detected: {gpu_info}")
1438
+ else:
1439
+ self.logger.warning("⚠️ nvidia-smi not available or no NVIDIA GPU detected")
1440
+ return False
1441
+ except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.CalledProcessError):
1442
+ self.logger.warning("⚠️ nvidia-smi not available or failed")
1443
+ return False
1444
+
1445
+ # Step 2: Check for NVENC encoders in FFmpeg
1446
+ try:
1447
+ encoders_cmd = f"{self.ffmpeg_base_command} -hide_banner -encoders 2>/dev/null | grep nvenc"
1448
+ encoders_result = subprocess.run(encoders_cmd, shell=True, capture_output=True, text=True, timeout=10)
1449
+ if encoders_result.returncode == 0 and "nvenc" in encoders_result.stdout:
1450
+ nvenc_encoders = [line.strip() for line in encoders_result.stdout.split('\n') if 'nvenc' in line]
1451
+ self.logger.info("✓ Found NVENC encoders in FFmpeg:")
1452
+ for encoder in nvenc_encoders:
1453
+ if encoder:
1454
+ self.logger.info(f" {encoder}")
1455
+ else:
1456
+ self.logger.warning("⚠️ No NVENC encoders found in FFmpeg")
1457
+ return False
1458
+ except Exception as e:
1459
+ self.logger.warning(f"⚠️ Failed to check FFmpeg NVENC encoders: {e}")
1460
+ return False
1461
+
1462
+ # Step 3: Check for libcuda.so.1 (critical for NVENC)
1463
+ try:
1464
+ libcuda_check = subprocess.run(["ldconfig", "-p"], capture_output=True, text=True, timeout=10)
1465
+ if libcuda_check.returncode == 0 and "libcuda.so.1" in libcuda_check.stdout:
1466
+ self.logger.info("✅ libcuda.so.1 found in system libraries")
1467
+ else:
1468
+ self.logger.warning("❌ libcuda.so.1 NOT found in system libraries")
1469
+ self.logger.warning("💡 This usually indicates the CUDA runtime image is needed instead of devel")
1470
+ return False
1471
+ except Exception as e:
1472
+ self.logger.warning(f"⚠️ Failed to check for libcuda.so.1: {e}")
1473
+ return False
1474
+
1475
+ # Step 4: Test h264_nvenc encoder with simple test
1476
+ self.logger.info("🧪 Testing h264_nvenc encoder...")
1477
+ test_cmd = f"{self.ffmpeg_base_command} -hide_banner -loglevel warning -f lavfi -i testsrc=duration=1:size=320x240:rate=1 -c:v h264_nvenc -f null -"
1478
+ self.logger.debug(f"Running test command: {test_cmd}")
1479
+
1480
+ try:
1481
+ result = subprocess.run(test_cmd, shell=True, capture_output=True, text=True, timeout=30)
1482
+
1483
+ if result.returncode == 0:
1484
+ self.logger.info("✅ NVENC hardware encoding available for video generation")
1485
+ self.logger.info(f"Test command succeeded. Output: {result.stderr if result.stderr else '...'}")
1486
+ return True
1487
+ else:
1488
+ self.logger.warning(f"❌ NVENC test failed with exit code {result.returncode}")
1489
+ if result.stderr:
1490
+ self.logger.warning(f"Error output: {result.stderr}")
1491
+ if "Cannot load libcuda.so.1" in result.stderr:
1492
+ self.logger.warning("💡 Root cause: libcuda.so.1 cannot be loaded by NVENC")
1493
+ self.logger.warning("💡 Solution: Use nvidia/cuda:*-devel-* image instead of runtime")
1494
+ return False
1495
+
1496
+ except subprocess.TimeoutExpired:
1497
+ self.logger.warning("❌ NVENC test timed out")
1498
+ return False
1499
+
1500
+ except Exception as e:
1501
+ self.logger.warning(f"❌ Failed to detect NVENC support: {e}, falling back to software encoding")
1502
+ return False
1503
+
1504
+ def configure_hardware_acceleration(self):
1505
+ """Configure hardware acceleration settings based on detected capabilities."""
1506
+ if self.nvenc_available:
1507
+ self.video_encoder = "h264_nvenc"
1508
+ # Use simpler hardware acceleration that works with complex filter chains
1509
+ # Remove -hwaccel_output_format cuda as it causes pixel format conversion issues
1510
+ self.hwaccel_decode_flags = "-hwaccel cuda"
1511
+ self.scale_filter = "scale" # Use CPU scaling for complex filter chains
1512
+ self.logger.info("Configured for NVIDIA hardware acceleration (simplified for filter compatibility)")
1513
+ else:
1514
+ self.video_encoder = "libx264"
1515
+ self.hwaccel_decode_flags = ""
1516
+ self.scale_filter = "scale"
1517
+ self.logger.info("Configured for software encoding")
1518
+
1519
+ def get_nvenc_quality_settings(self, quality_mode="high"):
1520
+ """Get NVENC settings based on quality requirements."""
1521
+ if quality_mode == "lossless":
1522
+ return "-preset lossless"
1523
+ elif quality_mode == "high":
1524
+ return "-preset p4 -tune hq -cq 18" # High quality
1525
+ elif quality_mode == "medium":
1526
+ return "-preset p4 -cq 23" # Balanced quality/speed
1527
+ elif quality_mode == "fast":
1528
+ return "-preset p1 -tune ll" # Low latency, faster encoding
1529
+ else:
1530
+ return "-preset p4" # Balanced default
1531
+
1532
+ def execute_command_with_fallback(self, gpu_command, cpu_command, description):
1533
+ """Execute GPU command with automatic fallback to CPU if it fails."""
1534
+ self.logger.info(f"{description}")
1535
+
1536
+ if self.dry_run:
1537
+ if self.nvenc_available:
1538
+ self.logger.info(f"DRY RUN: Would run GPU-accelerated command: {gpu_command}")
1539
+ else:
1540
+ self.logger.info(f"DRY RUN: Would run CPU command: {cpu_command}")
1541
+ return
1542
+
1543
+ # Try GPU-accelerated command first if available
1544
+ if self.nvenc_available and gpu_command != cpu_command:
1545
+ self.logger.debug(f"Attempting hardware-accelerated encoding: {gpu_command}")
1546
+ try:
1547
+ result = subprocess.run(gpu_command, shell=True, capture_output=True, text=True, timeout=300)
1548
+
1549
+ if result.returncode == 0:
1550
+ self.logger.info(f"✓ Hardware acceleration successful")
1551
+ return
1552
+ else:
1553
+ self.logger.warning(f"✗ Hardware acceleration failed (exit code {result.returncode})")
1554
+ self.logger.warning(f"GPU Command: {gpu_command}")
1555
+
1556
+ # If we didn't get detailed error info and using fatal loglevel, try again with verbose logging
1557
+ if (not result.stderr or len(result.stderr.strip()) < 10) and "-loglevel fatal" in gpu_command:
1558
+ self.logger.warning("Empty error output detected, retrying with verbose logging...")
1559
+ verbose_gpu_command = gpu_command.replace("-loglevel fatal", "-loglevel error")
1560
+ try:
1561
+ verbose_result = subprocess.run(verbose_gpu_command, shell=True, capture_output=True, text=True, timeout=300)
1562
+ self.logger.warning(f"Verbose GPU Command: {verbose_gpu_command}")
1563
+ if verbose_result.stderr:
1564
+ self.logger.warning(f"FFmpeg STDERR (verbose): {verbose_result.stderr}")
1565
+ if verbose_result.stdout:
1566
+ self.logger.warning(f"FFmpeg STDOUT (verbose): {verbose_result.stdout}")
1567
+ except Exception as e:
1568
+ self.logger.warning(f"Verbose retry failed: {e}")
1569
+
1570
+ if result.stderr:
1571
+ self.logger.warning(f"FFmpeg STDERR: {result.stderr}")
1572
+ else:
1573
+ self.logger.warning("FFmpeg STDERR: (empty)")
1574
+ if result.stdout:
1575
+ self.logger.warning(f"FFmpeg STDOUT: {result.stdout}")
1576
+ else:
1577
+ self.logger.warning("FFmpeg STDOUT: (empty)")
1578
+ self.logger.info("Falling back to software encoding...")
1579
+
1580
+ except subprocess.TimeoutExpired:
1581
+ self.logger.warning("✗ Hardware acceleration timed out, falling back to software encoding")
1582
+ except Exception as e:
1583
+ self.logger.warning(f"✗ Hardware acceleration failed with exception: {e}, falling back to software encoding")
1584
+
1585
+ # Use CPU command (either as fallback or primary method)
1586
+ self.logger.debug(f"Running software encoding: {cpu_command}")
1587
+ try:
1588
+ result = subprocess.run(cpu_command, shell=True, capture_output=True, text=True, timeout=600)
1589
+
1590
+ if result.returncode != 0:
1591
+ error_msg = f"Software encoding failed with exit code {result.returncode}"
1592
+ self.logger.error(error_msg)
1593
+ self.logger.error(f"CPU Command: {cpu_command}")
1594
+ if result.stderr:
1595
+ self.logger.error(f"FFmpeg STDERR: {result.stderr}")
1596
+ else:
1597
+ self.logger.error("FFmpeg STDERR: (empty)")
1598
+ if result.stdout:
1599
+ self.logger.error(f"FFmpeg STDOUT: {result.stdout}")
1600
+ else:
1601
+ self.logger.error("FFmpeg STDOUT: (empty)")
1602
+ raise Exception(f"{error_msg}: {cpu_command}")
1603
+ else:
1604
+ self.logger.info(f"✓ Software encoding successful")
1605
+
1606
+ except subprocess.TimeoutExpired:
1607
+ error_msg = "Software encoding timed out"
1608
+ self.logger.error(error_msg)
1609
+ raise Exception(f"{error_msg}: {cpu_command}")
1610
+ except Exception as e:
1611
+ if "Software encoding failed" not in str(e):
1612
+ error_msg = f"Software encoding failed with exception: {e}"
1613
+ self.logger.error(error_msg)
1614
+ raise Exception(f"{error_msg}: {cpu_command}")
1615
+ else:
1616
+ raise
1617
+
1121
1618
  def process(self, replace_existing=False):
1122
1619
  if self.dry_run:
1123
1620
  self.logger.warning("Dry run enabled. No actions will be performed.")
@@ -69,6 +69,8 @@ class KaraokePrep:
69
69
  style_params_json=None,
70
70
  # Add the new parameter
71
71
  skip_separation=False,
72
+ # YouTube/Online Configuration
73
+ cookies_str=None,
72
74
  ):
73
75
  self.log_level = log_level
74
76
  self.log_formatter = log_formatter
@@ -124,6 +126,9 @@ class KaraokePrep:
124
126
  self.render_bounding_boxes = render_bounding_boxes # Passed to VideoGenerator
125
127
  self.style_params_json = style_params_json # Passed to LyricsProcessor
126
128
 
129
+ # YouTube/Online Config
130
+ self.cookies_str = cookies_str # Passed to metadata extraction and file download
131
+
127
132
  # Load style parameters using the config module
128
133
  self.style_params = load_style_params(self.style_params_json, self.logger)
129
134
 
@@ -197,7 +202,7 @@ class KaraokePrep:
197
202
  # Compatibility methods for tests - these call the new functions in metadata.py
198
203
  def extract_info_for_online_media(self, input_url=None, input_artist=None, input_title=None):
199
204
  """Compatibility method that calls the function in metadata.py"""
200
- self.extracted_info = extract_info_for_online_media(input_url, input_artist, input_title, self.logger)
205
+ self.extracted_info = extract_info_for_online_media(input_url, input_artist, input_title, self.logger, self.cookies_str)
201
206
  return self.extracted_info
202
207
 
203
208
  def parse_single_track_metadata(self, input_artist, input_title):
@@ -242,7 +247,7 @@ class KaraokePrep:
242
247
  self.logger.warning(f"Input media '{self.input_media}' is not a file and self.url was not set. Attempting to treat as URL.")
243
248
  # This path requires calling extract/parse again, less efficient
244
249
  try:
245
- extracted = extract_info_for_online_media(self.input_media, self.artist, self.title, self.logger)
250
+ extracted = extract_info_for_online_media(self.input_media, self.artist, self.title, self.logger, self.cookies_str)
246
251
  if extracted:
247
252
  metadata_result = parse_track_metadata(
248
253
  extracted, self.artist, self.title, self.persistent_artist, self.logger
@@ -345,7 +350,7 @@ class KaraokePrep:
345
350
 
346
351
  self.logger.info(f"Downloading input media from {self.url}...")
347
352
  # Delegate to FileHandler
348
- processed_track["input_media"] = self.file_handler.download_video(self.url, output_filename_no_extension)
353
+ processed_track["input_media"] = self.file_handler.download_video(self.url, output_filename_no_extension, self.cookies_str)
349
354
 
350
355
  self.logger.info("Extracting still image from downloaded media (if input is video)...")
351
356
  # Delegate to FileHandler
@@ -681,7 +686,7 @@ class KaraokePrep:
681
686
  self.url = self.input_media
682
687
  # Use the imported extract_info_for_online_media function
683
688
  self.extracted_info = extract_info_for_online_media(
684
- input_url=self.url, input_artist=self.artist, input_title=self.title, logger=self.logger
689
+ input_url=self.url, input_artist=self.artist, input_title=self.title, logger=self.logger, cookies_str=self.cookies_str
685
690
  )
686
691
 
687
692
  if self.extracted_info and "playlist_count" in self.extracted_info:
@@ -690,4 +695,6 @@ class KaraokePrep:
690
695
  return await self.process_playlist()
691
696
  else:
692
697
  self.logger.info(f"Input URL is not a playlist, processing single track")
698
+ # Parse metadata to extract artist and title before processing
699
+ self.parse_single_track_metadata(self.artist, self.title)
693
700
  return [await self.prep_single_track()]
@@ -2,6 +2,7 @@ import os
2
2
  import re
3
3
  import logging
4
4
  import shutil
5
+ import json
5
6
  from lyrics_transcriber import LyricsTranscriber, OutputConfig, TranscriberConfig, LyricsConfig
6
7
  from lyrics_transcriber.core.controller import LyricsControllerResult
7
8
  from dotenv import load_dotenv
@@ -173,6 +174,7 @@ class LyricsProcessor:
173
174
  "spotify_cookie": os.getenv("SPOTIFY_COOKIE_SP_DC"),
174
175
  "runpod_api_key": os.getenv("RUNPOD_API_KEY"),
175
176
  "whisper_runpod_id": os.getenv("WHISPER_RUNPOD_ID"),
177
+ "rapidapi_key": os.getenv("RAPIDAPI_KEY"), # Add missing RAPIDAPI_KEY
176
178
  }
177
179
 
178
180
  # Create config objects for LyricsTranscriber
@@ -183,21 +185,51 @@ class LyricsProcessor:
183
185
  lyrics_config = LyricsConfig(
184
186
  genius_api_token=env_config.get("genius_api_token"),
185
187
  spotify_cookie=env_config.get("spotify_cookie"),
188
+ rapidapi_key=env_config.get("rapidapi_key"),
186
189
  lyrics_file=self.lyrics_file,
187
190
  )
188
-
191
+
192
+ # Debug logging for lyrics_config
193
+ self.logger.info(f"LyricsConfig created with:")
194
+ self.logger.info(f" genius_api_token: {env_config.get('genius_api_token')[:3] + '...' if env_config.get('genius_api_token') else 'None'}")
195
+ self.logger.info(f" spotify_cookie: {env_config.get('spotify_cookie')[:3] + '...' if env_config.get('spotify_cookie') else 'None'}")
196
+ self.logger.info(f" rapidapi_key: {env_config.get('rapidapi_key')[:3] + '...' if env_config.get('rapidapi_key') else 'None'}")
197
+ self.logger.info(f" lyrics_file: {self.lyrics_file}")
198
+
199
+ # Detect if we're running in a serverless environment (Modal)
200
+ # Modal sets specific environment variables we can check for
201
+ is_serverless = (
202
+ os.getenv("MODAL_TASK_ID") is not None or
203
+ os.getenv("MODAL_FUNCTION_NAME") is not None or
204
+ os.path.exists("/.modal") # Modal creates this directory in containers
205
+ )
206
+
207
+ # In serverless environment, disable interactive review even if skip_transcription_review=False
208
+ # This preserves CLI behavior while fixing serverless hanging
209
+ enable_review_setting = not self.skip_transcription_review and not is_serverless
210
+
211
+ if is_serverless and not self.skip_transcription_review:
212
+ self.logger.info("Detected serverless environment - disabling interactive review to prevent hanging")
213
+
214
+ # In serverless environment, disable video generation during Phase 1 to save compute
215
+ # Video will be generated in Phase 2 after human review
216
+ serverless_render_video = render_video and not is_serverless
217
+
218
+ if is_serverless and render_video:
219
+ self.logger.info("Detected serverless environment - deferring video generation until after review")
220
+
189
221
  output_config = OutputConfig(
190
222
  output_styles_json=self.style_params_json,
191
223
  output_dir=lyrics_dir,
192
- render_video=render_video,
224
+ render_video=serverless_render_video, # Disable video in serverless Phase 1
193
225
  fetch_lyrics=True,
194
226
  run_transcription=not self.skip_transcription,
195
227
  run_correction=True,
196
228
  generate_plain_text=True,
197
229
  generate_lrc=True,
198
- generate_cdg=True,
230
+ generate_cdg=False, # Also defer CDG generation to Phase 2
199
231
  video_resolution="4k",
200
- enable_review=not self.skip_transcription_review,
232
+ enable_review=enable_review_setting,
201
233
  subtitle_offset_ms=self.subtitle_offset_ms,
202
234
  )
203
235
 
@@ -240,6 +272,19 @@ class LyricsProcessor:
240
272
  )
241
273
  transcriber_outputs["corrected_lyrics_text_filepath"] = results.corrected_txt
242
274
 
275
+ # Save correction data to JSON file for review interface
276
+ # Use the expected filename format: "{artist} - {title} (Lyrics Corrections).json"
277
+ corrections_filename = f"{filename_artist} - {filename_title} (Lyrics Corrections).json"
278
+ corrections_filepath = os.path.join(lyrics_dir, corrections_filename)
279
+
280
+ # Use the CorrectionResult's to_dict() method to serialize
281
+ correction_data = results.transcription_corrected.to_dict()
282
+
283
+ with open(corrections_filepath, 'w') as f:
284
+ json.dump(correction_data, f, indent=2)
285
+
286
+ self.logger.info(f"Saved correction data to {corrections_filepath}")
287
+
243
288
  if transcriber_outputs:
244
289
  self.logger.info(f"*** Transcriber Filepath Outputs: ***")
245
290
  for key, value in transcriber_outputs.items():
@@ -1,30 +1,80 @@
1
1
  import logging
2
2
  import yt_dlp.YoutubeDL as ydl
3
3
 
4
- def extract_info_for_online_media(input_url, input_artist, input_title, logger):
4
+ def extract_info_for_online_media(input_url, input_artist, input_title, logger, cookies_str=None):
5
5
  """Extracts metadata using yt-dlp, either from a URL or via search."""
6
6
  logger.info(f"Extracting info for input_url: {input_url} input_artist: {input_artist} input_title: {input_title}")
7
- extracted_info = None
8
- if input_url is not None:
9
- # If a URL is provided, use it to extract the metadata
10
- with ydl({"quiet": True}) as ydl_instance:
11
- extracted_info = ydl_instance.extract_info(input_url, download=False)
7
+
8
+ # Set up yt-dlp options with enhanced anti-detection
9
+ base_opts = {
10
+ "quiet": True,
11
+ # Anti-detection options
12
+ "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
13
+ "referer": "https://www.youtube.com/",
14
+ "sleep_interval": 1,
15
+ "max_sleep_interval": 3,
16
+ "fragment_retries": 3,
17
+ "extractor_retries": 3,
18
+ "retries": 3,
19
+ # Headers to appear more human
20
+ "http_headers": {
21
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
22
+ "Accept-Language": "en-us,en;q=0.5",
23
+ "Accept-Encoding": "gzip, deflate",
24
+ "DNT": "1",
25
+ "Connection": "keep-alive",
26
+ "Upgrade-Insecure-Requests": "1",
27
+ },
28
+ }
29
+
30
+ # Add cookies if provided
31
+ if cookies_str:
32
+ logger.info("Using provided cookies for enhanced YouTube access")
33
+ # Save cookies to a temporary file
34
+ import tempfile
35
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
36
+ f.write(cookies_str)
37
+ base_opts['cookiefile'] = f.name
12
38
  else:
13
- # If no URL is provided, use the query to search for the top result
14
- ydl_opts = {"quiet": "True", "format": "bestaudio", "noplaylist": "True", "extract_flat": True}
15
- with ydl(ydl_opts) as ydl_instance:
16
- query = f"{input_artist} {input_title}"
17
- search_results = ydl_instance.extract_info(f"ytsearch1:{query}", download=False)
18
- if search_results and "entries" in search_results and search_results["entries"]:
19
- extracted_info = search_results["entries"][0]
20
- else:
21
- # Raise IndexError to match the expected exception in tests
22
- raise IndexError(f"No search results found on YouTube for query: {input_artist} {input_title}")
23
-
24
- if not extracted_info:
25
- raise Exception(f"Failed to extract info for query: {input_artist} {input_title} or URL: {input_url}")
26
-
27
- return extracted_info
39
+ logger.info("No cookies provided - attempting standard extraction")
40
+
41
+ extracted_info = None
42
+ try:
43
+ if input_url is not None:
44
+ # If a URL is provided, use it to extract the metadata
45
+ with ydl(base_opts) as ydl_instance:
46
+ extracted_info = ydl_instance.extract_info(input_url, download=False)
47
+ else:
48
+ # If no URL is provided, use the query to search for the top result
49
+ search_opts = base_opts.copy()
50
+ search_opts.update({
51
+ "format": "bestaudio",
52
+ "noplaylist": "True",
53
+ "extract_flat": True
54
+ })
55
+
56
+ with ydl(search_opts) as ydl_instance:
57
+ query = f"{input_artist} {input_title}"
58
+ search_results = ydl_instance.extract_info(f"ytsearch1:{query}", download=False)
59
+ if search_results and "entries" in search_results and search_results["entries"]:
60
+ extracted_info = search_results["entries"][0]
61
+ else:
62
+ # Raise IndexError to match the expected exception in tests
63
+ raise IndexError(f"No search results found on YouTube for query: {input_artist} {input_title}")
64
+
65
+ if not extracted_info:
66
+ raise Exception(f"Failed to extract info for query: {input_artist} {input_title} or URL: {input_url}")
67
+
68
+ return extracted_info
69
+
70
+ finally:
71
+ # Clean up temporary cookie file if it was created
72
+ if cookies_str and 'cookiefile' in base_opts:
73
+ try:
74
+ import os
75
+ os.unlink(base_opts['cookiefile'])
76
+ except:
77
+ pass
28
78
 
29
79
 
30
80
  def parse_track_metadata(extracted_info, current_artist, current_title, persistent_artist, logger):
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "karaoke-gen"
3
- version = "0.55.0"
3
+ version = "0.57.0"
4
4
  description = "Generate karaoke videos with synchronized lyrics. Handles the entire process from downloading audio and lyrics to creating the final video with title screens."
5
5
  authors = ["Andrew Beveridge <andrew@beveridge.uk>"]
6
6
  license = "MIT"
@@ -28,7 +28,7 @@ thefuzz = ">=0.22"
28
28
  numpy = ">=2"
29
29
  audio-separator = { version = ">=0.34.0", extras = ["cpu"] }
30
30
  lyrics-converter = ">=0.2.1"
31
- lyrics-transcriber = ">=0.54"
31
+ lyrics-transcriber = ">=0.61"
32
32
  kbputils = "^0.0.16"
33
33
  attrs = ">=24.2.0"
34
34
  cattrs = ">=24.1.2"
@@ -36,8 +36,10 @@ toml = ">=0.10"
36
36
  argparse = ">=1.4.0"
37
37
  psutil = "^7.0.0"
38
38
  pyperclip = "*"
39
- pytest-asyncio = "^0.23.5"
39
+ pytest-asyncio = "*"
40
40
  ffmpeg-python = "^0.2.0"
41
+ modal = "^1.0.5"
42
+ python-multipart = "^0.0.20"
41
43
 
42
44
  [tool.poetry.group.dev.dependencies]
43
45
  black = ">=23"
@@ -45,7 +47,7 @@ poetry = "*"
45
47
  pytest = ">=7.0"
46
48
  pytest-cov = ">=4.0"
47
49
  pytest-mock = ">=3.10"
48
- pytest-asyncio = ">=0.21.0"
50
+ pytest-asyncio = "*"
49
51
 
50
52
 
51
53
  [tool.poetry.scripts]
File without changes
File without changes