karaoke-gen 0.56.0__py3-none-any.whl → 0.57.0__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.
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +550 -59
- karaoke_gen/lyrics_processor.py +9 -0
- {karaoke_gen-0.56.0.dist-info → karaoke_gen-0.57.0.dist-info}/METADATA +3 -3
- {karaoke_gen-0.56.0.dist-info → karaoke_gen-0.57.0.dist-info}/RECORD +7 -7
- {karaoke_gen-0.56.0.dist-info → karaoke_gen-0.57.0.dist-info}/LICENSE +0 -0
- {karaoke_gen-0.56.0.dist-info → karaoke_gen-0.57.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.56.0.dist-info → karaoke_gen-0.57.0.dist-info}/entry_points.txt +0 -0
|
@@ -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.
|
|
264
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
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,80 +690,130 @@ class KaraokeFinalise:
|
|
|
631
690
|
return base_name, artist, title
|
|
632
691
|
|
|
633
692
|
def execute_command(self, command, description):
|
|
634
|
-
|
|
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
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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()}")
|
|
640
709
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
error_msg = f"Command failed with exit code {exit_code}: {command}"
|
|
710
|
+
if result.returncode != 0:
|
|
711
|
+
error_msg = f"Command failed with exit code {result.returncode}"
|
|
644
712
|
self.logger.error(error_msg)
|
|
645
|
-
|
|
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
|
|
646
733
|
|
|
647
734
|
def remux_with_instrumental(self, with_vocals_file, instrumental_audio, output_file):
|
|
648
735
|
"""Remux the video with instrumental audio to create karaoke version"""
|
|
649
|
-
#
|
|
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
|
|
650
738
|
ffmpeg_command = (
|
|
651
739
|
f'{self.ffmpeg_base_command} -an -i "{with_vocals_file}" '
|
|
652
740
|
f'-vn -i "{instrumental_audio}" -c:v copy -c:a pcm_s16le "{output_file}"'
|
|
653
741
|
)
|
|
654
|
-
# fmt: on
|
|
655
742
|
self.execute_command(ffmpeg_command, "Remuxing video with instrumental audio")
|
|
656
743
|
|
|
657
744
|
def convert_mov_to_mp4(self, input_file, output_file):
|
|
658
|
-
"""Convert MOV file to MP4 format"""
|
|
659
|
-
#
|
|
660
|
-
|
|
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 = (
|
|
661
754
|
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
662
755
|
f'-c:v libx264 -c:a {self.aac_codec} {self.mp4_flags} "{output_file}"'
|
|
663
756
|
)
|
|
664
|
-
|
|
665
|
-
self.
|
|
757
|
+
|
|
758
|
+
self.execute_command_with_fallback(gpu_command, cpu_command, "Converting MOV video to MP4")
|
|
666
759
|
|
|
667
760
|
def encode_lossless_mp4(self, title_mov_file, karaoke_mp4_file, env_mov_input, ffmpeg_filter, output_file):
|
|
668
|
-
"""Create the final MP4 with PCM audio (lossless)"""
|
|
669
|
-
#
|
|
670
|
-
|
|
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 = (
|
|
671
772
|
f"{self.ffmpeg_base_command} -i {title_mov_file} -i {karaoke_mp4_file} {env_mov_input} "
|
|
672
773
|
f'{ffmpeg_filter} -map "[outv]" -map "[outa]" -c:v libx264 -c:a pcm_s16le '
|
|
673
774
|
f'{self.mp4_flags} "{output_file}"'
|
|
674
775
|
)
|
|
675
|
-
|
|
676
|
-
self.
|
|
776
|
+
|
|
777
|
+
self.execute_command_with_fallback(gpu_command, cpu_command, "Creating MP4 version with PCM audio")
|
|
677
778
|
|
|
678
779
|
def encode_lossy_mp4(self, input_file, output_file):
|
|
679
780
|
"""Create MP4 with AAC audio (lossy, for wider compatibility)"""
|
|
680
|
-
#
|
|
781
|
+
# This is primarily an audio re-encoding operation, video is copied
|
|
782
|
+
# Hardware acceleration doesn't provide significant benefit for copy operations
|
|
681
783
|
ffmpeg_command = (
|
|
682
784
|
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
683
785
|
f'-c:v copy -c:a {self.aac_codec} -b:a 320k {self.mp4_flags} "{output_file}"'
|
|
684
786
|
)
|
|
685
|
-
# fmt: on
|
|
686
787
|
self.execute_command(ffmpeg_command, "Creating MP4 version with AAC audio")
|
|
687
788
|
|
|
688
789
|
def encode_lossless_mkv(self, input_file, output_file):
|
|
689
790
|
"""Create MKV with FLAC audio (for YouTube)"""
|
|
690
|
-
#
|
|
791
|
+
# This is primarily an audio re-encoding operation, video is copied
|
|
792
|
+
# Hardware acceleration doesn't provide significant benefit for copy operations
|
|
691
793
|
ffmpeg_command = (
|
|
692
794
|
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
693
795
|
f'-c:v copy -c:a flac "{output_file}"'
|
|
694
796
|
)
|
|
695
|
-
# fmt: on
|
|
696
797
|
self.execute_command(ffmpeg_command, "Creating MKV version with FLAC audio for YouTube")
|
|
697
798
|
|
|
698
799
|
def encode_720p_version(self, input_file, output_file):
|
|
699
|
-
"""Create 720p MP4 with AAC audio (for smaller file size)"""
|
|
700
|
-
#
|
|
701
|
-
|
|
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 = (
|
|
702
811
|
f'{self.ffmpeg_base_command} -i "{input_file}" '
|
|
703
|
-
f'-c:v libx264 -vf "scale=1280:720" -b:v
|
|
812
|
+
f'-c:v libx264 -vf "scale=1280:720" -b:v 2000k -preset medium -tune animation '
|
|
704
813
|
f'-c:a {self.aac_codec} -b:a 128k {self.mp4_flags} "{output_file}"'
|
|
705
814
|
)
|
|
706
|
-
|
|
707
|
-
self.
|
|
815
|
+
|
|
816
|
+
self.execute_command_with_fallback(gpu_command, cpu_command, "Encoding 720p version of the final video")
|
|
708
817
|
|
|
709
818
|
def prepare_concat_filter(self, input_files):
|
|
710
819
|
"""Prepare the concat filter and additional input for end credits if present"""
|
|
@@ -755,17 +864,21 @@ class KaraokeFinalise:
|
|
|
755
864
|
self.encode_lossless_mkv(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossless_mkv"])
|
|
756
865
|
self.encode_720p_version(output_files["final_karaoke_lossless_mp4"], output_files["final_karaoke_lossy_720p_mp4"])
|
|
757
866
|
|
|
758
|
-
#
|
|
759
|
-
self.
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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")
|
|
769
882
|
|
|
770
883
|
def create_cdg_zip_file(self, input_files, output_files, artist, title):
|
|
771
884
|
self.logger.info(f"Creating CDG and MP3 files, then zipping them...")
|
|
@@ -941,9 +1054,9 @@ class KaraokeFinalise:
|
|
|
941
1054
|
self.logger.info(f"Copied final files to public share directory")
|
|
942
1055
|
|
|
943
1056
|
def sync_public_share_dir_to_rclone_destination(self):
|
|
944
|
-
self.logger.info(f"
|
|
1057
|
+
self.logger.info(f"Copying public share directory to rclone destination...")
|
|
945
1058
|
|
|
946
|
-
# Delete .DS_Store files recursively before
|
|
1059
|
+
# Delete .DS_Store files recursively before copying
|
|
947
1060
|
for root, dirs, files in os.walk(self.public_share_dir):
|
|
948
1061
|
for file in files:
|
|
949
1062
|
if file == ".DS_Store":
|
|
@@ -951,8 +1064,8 @@ class KaraokeFinalise:
|
|
|
951
1064
|
os.remove(file_path)
|
|
952
1065
|
self.logger.info(f"Deleted .DS_Store file: {file_path}")
|
|
953
1066
|
|
|
954
|
-
rclone_cmd = f"rclone
|
|
955
|
-
self.execute_command(rclone_cmd, "
|
|
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")
|
|
956
1069
|
|
|
957
1070
|
def post_discord_notification(self):
|
|
958
1071
|
self.logger.info(f"Posting Discord notification...")
|
|
@@ -986,6 +1099,13 @@ class KaraokeFinalise:
|
|
|
986
1099
|
try:
|
|
987
1100
|
self.logger.info(f"Running command: {rclone_link_cmd}")
|
|
988
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
|
+
|
|
989
1109
|
self.brand_code_dir_sharing_link = result.stdout.strip()
|
|
990
1110
|
self.logger.info(f"Got organised folder sharing link: {self.brand_code_dir_sharing_link}")
|
|
991
1111
|
except subprocess.CalledProcessError as e:
|
|
@@ -994,6 +1114,125 @@ class KaraokeFinalise:
|
|
|
994
1114
|
self.logger.error(f"Command output (stderr): {e.stderr}")
|
|
995
1115
|
self.logger.error(f"Full exception: {e}")
|
|
996
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
|
+
|
|
997
1236
|
def get_existing_brand_code(self):
|
|
998
1237
|
"""Extract brand code from current directory name"""
|
|
999
1238
|
current_dir = os.path.basename(os.getcwd())
|
|
@@ -1024,7 +1263,30 @@ class KaraokeFinalise:
|
|
|
1024
1263
|
if self.discord_notication_enabled:
|
|
1025
1264
|
self.post_discord_notification()
|
|
1026
1265
|
|
|
1027
|
-
|
|
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
|
+
|
|
1028
1290
|
if self.keep_brand_code:
|
|
1029
1291
|
self.brand_code = self.get_existing_brand_code()
|
|
1030
1292
|
self.new_brand_code_dir = os.path.basename(os.getcwd())
|
|
@@ -1043,6 +1305,27 @@ class KaraokeFinalise:
|
|
|
1043
1305
|
self.sync_public_share_dir_to_rclone_destination()
|
|
1044
1306
|
|
|
1045
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()
|
|
1046
1329
|
|
|
1047
1330
|
def authenticate_gmail(self):
|
|
1048
1331
|
"""Authenticate and return a Gmail service object."""
|
|
@@ -1057,6 +1340,9 @@ class KaraokeFinalise:
|
|
|
1057
1340
|
if creds and creds.expired and creds.refresh_token:
|
|
1058
1341
|
creds.refresh(Request())
|
|
1059
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
|
+
|
|
1060
1346
|
flow = InstalledAppFlow.from_client_secrets_file(
|
|
1061
1347
|
self.youtube_client_secrets_file, ["https://www.googleapis.com/auth/gmail.compose"]
|
|
1062
1348
|
)
|
|
@@ -1067,6 +1353,11 @@ class KaraokeFinalise:
|
|
|
1067
1353
|
return build("gmail", "v1", credentials=creds)
|
|
1068
1354
|
|
|
1069
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
|
+
|
|
1070
1361
|
if not self.email_template_file:
|
|
1071
1362
|
self.logger.info("Email template file not provided, skipping email draft creation.")
|
|
1072
1363
|
return
|
|
@@ -1124,6 +1415,206 @@ class KaraokeFinalise:
|
|
|
1124
1415
|
self.logger.info("Using built-in aac codec (basic quality)")
|
|
1125
1416
|
return "aac"
|
|
1126
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
|
+
|
|
1127
1618
|
def process(self, replace_existing=False):
|
|
1128
1619
|
if self.dry_run:
|
|
1129
1620
|
self.logger.warning("Dry run enabled. No actions will be performed.")
|
karaoke_gen/lyrics_processor.py
CHANGED
|
@@ -174,6 +174,7 @@ class LyricsProcessor:
|
|
|
174
174
|
"spotify_cookie": os.getenv("SPOTIFY_COOKIE_SP_DC"),
|
|
175
175
|
"runpod_api_key": os.getenv("RUNPOD_API_KEY"),
|
|
176
176
|
"whisper_runpod_id": os.getenv("WHISPER_RUNPOD_ID"),
|
|
177
|
+
"rapidapi_key": os.getenv("RAPIDAPI_KEY"), # Add missing RAPIDAPI_KEY
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
# Create config objects for LyricsTranscriber
|
|
@@ -184,8 +185,16 @@ class LyricsProcessor:
|
|
|
184
185
|
lyrics_config = LyricsConfig(
|
|
185
186
|
genius_api_token=env_config.get("genius_api_token"),
|
|
186
187
|
spotify_cookie=env_config.get("spotify_cookie"),
|
|
188
|
+
rapidapi_key=env_config.get("rapidapi_key"),
|
|
187
189
|
lyrics_file=self.lyrics_file,
|
|
188
190
|
)
|
|
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}")
|
|
189
198
|
|
|
190
199
|
# Detect if we're running in a serverless environment (Modal)
|
|
191
200
|
# Modal sets specific environment variables we can check for
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: karaoke-gen
|
|
3
|
-
Version: 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,7 +25,7 @@ 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.
|
|
28
|
+
Requires-Dist: lyrics-transcriber (>=0.61)
|
|
29
29
|
Requires-Dist: lyricsgenius (>=3)
|
|
30
30
|
Requires-Dist: modal (>=1.0.5,<2.0.0)
|
|
31
31
|
Requires-Dist: numpy (>=2)
|
|
@@ -33,7 +33,7 @@ Requires-Dist: pillow (>=10.1)
|
|
|
33
33
|
Requires-Dist: psutil (>=7.0.0,<8.0.0)
|
|
34
34
|
Requires-Dist: pyinstaller (>=6.3)
|
|
35
35
|
Requires-Dist: pyperclip
|
|
36
|
-
Requires-Dist: pytest-asyncio
|
|
36
|
+
Requires-Dist: pytest-asyncio
|
|
37
37
|
Requires-Dist: python-multipart (>=0.0.20,<0.0.21)
|
|
38
38
|
Requires-Dist: requests (>=2)
|
|
39
39
|
Requires-Dist: thefuzz (>=0.22)
|
|
@@ -3,9 +3,9 @@ karaoke_gen/audio_processor.py,sha256=gqQo8dsG_4SEO5kwyT76DiU4jCNyiGpi6TT1R3imdG
|
|
|
3
3
|
karaoke_gen/config.py,sha256=I3h-940ZXvbrCNq_xcWHPMIB76cl-VNQYcK7-qgB-YI,6833
|
|
4
4
|
karaoke_gen/file_handler.py,sha256=c86-rGF7Fusl0uEIZFnreT7PJfK7lmUaEgauU8BBzjY,10024
|
|
5
5
|
karaoke_gen/karaoke_finalise/__init__.py,sha256=HqZ7TIhgt_tYZ-nb_NNCaejWAcF_aK-7wJY5TaW_keM,46
|
|
6
|
-
karaoke_gen/karaoke_finalise/karaoke_finalise.py,sha256=
|
|
6
|
+
karaoke_gen/karaoke_finalise/karaoke_finalise.py,sha256=UbHduu0yrUsGxBX37WfYkln58ur3pLni3RHKQNZC96Y,83987
|
|
7
7
|
karaoke_gen/karaoke_gen.py,sha256=NEyb-AWLdqJL4nXg21o_YbutZVVbKt08TTPAYgSBDao,38052
|
|
8
|
-
karaoke_gen/lyrics_processor.py,sha256=
|
|
8
|
+
karaoke_gen/lyrics_processor.py,sha256=ucTBR3pGiBP4sOqTe6oMiQVibzg_iB5kwX15xUySNdA,14099
|
|
9
9
|
karaoke_gen/metadata.py,sha256=TprFzWj-iJ7ghrXlHFMPzzqzuHzWeNvs3zGaND-z9Ds,6503
|
|
10
10
|
karaoke_gen/resources/AvenirNext-Bold.ttf,sha256=YxgKz2OP46lwLPCpIZhVa8COi_9KRDSXw4n8dIHHQSs,327048
|
|
11
11
|
karaoke_gen/resources/Montserrat-Bold.ttf,sha256=mLFIaBDC7M-qF9RhCoPBJ5TAeY716etBrqA4eUKSoYc,198120
|
|
@@ -16,8 +16,8 @@ karaoke_gen/utils/__init__.py,sha256=FpOHyeBRB06f3zMoLBUJHTDZACrabg-DoyBTxNKYyNY
|
|
|
16
16
|
karaoke_gen/utils/bulk_cli.py,sha256=uqAHnlidY-f_RhsQIHqZDnrznWRKhqpEDX2uiR1CUQs,18841
|
|
17
17
|
karaoke_gen/utils/gen_cli.py,sha256=sAZ-sau_3dI2hNBOZfiZqJjRf_cJFtuvZLy1V6URcxM,35688
|
|
18
18
|
karaoke_gen/video_generator.py,sha256=B7BQBrjkyvk3L3sctnPXnvr1rzkw0NYx5UCAl0ZiVx0,18464
|
|
19
|
-
karaoke_gen-0.
|
|
20
|
-
karaoke_gen-0.
|
|
21
|
-
karaoke_gen-0.
|
|
22
|
-
karaoke_gen-0.
|
|
23
|
-
karaoke_gen-0.
|
|
19
|
+
karaoke_gen-0.57.0.dist-info/LICENSE,sha256=81R_4XwMZDODHD7JcZeUR8IiCU8AD7Ajl6bmwR9tYDk,1074
|
|
20
|
+
karaoke_gen-0.57.0.dist-info/METADATA,sha256=nkarB_Xa20xio11PHrTO4cCnxJMCj9nKTNHvXpef5QM,5572
|
|
21
|
+
karaoke_gen-0.57.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
22
|
+
karaoke_gen-0.57.0.dist-info/entry_points.txt,sha256=IZY3O8i7m-qkmPuqgpAcxiS2fotNc6hC-CDWvNmoUEY,107
|
|
23
|
+
karaoke_gen-0.57.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|