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.
@@ -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,80 +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
- exit_code = 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()}")
640
709
 
641
- # Check if command failed (non-zero exit code)
642
- if exit_code != 0:
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
- raise Exception(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
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
- # 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
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
- # fmt: off
660
- 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 = (
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
- # fmt: on
665
- 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")
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
- # fmt: off
670
- 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 = (
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
- # fmt: on
676
- 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")
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
- # 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
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
- # 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
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
- # fmt: off
701
- 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 = (
702
811
  f'{self.ffmpeg_base_command} -i "{input_file}" '
703
- 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 '
704
813
  f'-c:a {self.aac_codec} -b:a 128k {self.mp4_flags} "{output_file}"'
705
814
  )
706
- # fmt: on
707
- 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")
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
- # Prompt user to check final video files before proceeding
759
- self.prompt_user_confirmation_or_raise_exception(
760
- f"Final video files created:\n"
761
- f"- Lossless 4K MP4: {output_files['final_karaoke_lossless_mp4']}\n"
762
- f"- Lossless 4K MKV: {output_files['final_karaoke_lossless_mkv']}\n"
763
- f"- Lossy 4K MP4: {output_files['final_karaoke_lossy_mp4']}\n"
764
- f"- Lossy 720p MP4: {output_files['final_karaoke_lossy_720p_mp4']}\n"
765
- f"Please check them! Proceed?",
766
- "Refusing to proceed without user confirmation they're happy with the Final videos.",
767
- allow_empty=True,
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"Syncing public share directory to rclone destination...")
1057
+ self.logger.info(f"Copying public share directory to rclone destination...")
945
1058
 
946
- # Delete .DS_Store files recursively before syncing
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 sync -v '{self.public_share_dir}' '{self.rclone_destination}'"
955
- 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")
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
- 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
+
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.")
@@ -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.56.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.58)
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 (>=0.23.5,<0.24.0)
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=C2o9iRg5qgWc8qxNVzy8puA8W2ZtKJq28dnxXxS1RMs,56556
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=054zBeYaCFti6lHRfYyHYdioM9YhBrpI52kHsSCh_KI,13363
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.56.0.dist-info/LICENSE,sha256=81R_4XwMZDODHD7JcZeUR8IiCU8AD7Ajl6bmwR9tYDk,1074
20
- karaoke_gen-0.56.0.dist-info/METADATA,sha256=4zBpc8AJtrh7d6vuYczv5vdii4IdW7XjROzysGZji4E,5591
21
- karaoke_gen-0.56.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
22
- karaoke_gen-0.56.0.dist-info/entry_points.txt,sha256=IZY3O8i7m-qkmPuqgpAcxiS2fotNc6hC-CDWvNmoUEY,107
23
- karaoke_gen-0.56.0.dist-info/RECORD,,
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,,