karaoke-gen 0.50.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.

Potentially problematic release.


This version of karaoke-gen might be problematic. Click here for more details.

@@ -0,0 +1,687 @@
1
+ import os
2
+ import sys
3
+ import re
4
+ import glob
5
+ import logging
6
+ import tempfile
7
+ import shutil
8
+ import asyncio
9
+ import signal
10
+ import time
11
+ import fcntl
12
+ import errno
13
+ import psutil
14
+ from datetime import datetime
15
+ import importlib.resources as pkg_resources
16
+ import json
17
+ from dotenv import load_dotenv
18
+ from .config import (
19
+ load_style_params,
20
+ setup_title_format,
21
+ setup_end_format,
22
+ get_video_durations,
23
+ get_existing_images,
24
+ setup_ffmpeg_command,
25
+ )
26
+ from .metadata import extract_info_for_online_media, parse_track_metadata
27
+ from .file_handler import FileHandler
28
+ from .audio_processor import AudioProcessor
29
+ from .lyrics_processor import LyricsProcessor
30
+ from .video_generator import VideoGenerator
31
+
32
+
33
+ class KaraokePrep:
34
+ def __init__(
35
+ self,
36
+ # Basic inputs
37
+ input_media=None,
38
+ artist=None,
39
+ title=None,
40
+ filename_pattern=None,
41
+ # Logging & Debugging
42
+ dry_run=False,
43
+ logger=None,
44
+ log_level=logging.DEBUG,
45
+ log_formatter=None,
46
+ render_bounding_boxes=False,
47
+ # Input/Output Configuration
48
+ output_dir=".",
49
+ create_track_subfolders=False,
50
+ lossless_output_format="FLAC",
51
+ output_png=True,
52
+ output_jpg=True,
53
+ # Audio Processing Configuration
54
+ clean_instrumental_model="model_bs_roformer_ep_317_sdr_12.9755.ckpt",
55
+ backing_vocals_models=["mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt"],
56
+ other_stems_models=["htdemucs_6s.yaml"],
57
+ model_file_dir=os.path.join(tempfile.gettempdir(), "audio-separator-models"),
58
+ existing_instrumental=None,
59
+ # Lyrics Configuration
60
+ lyrics_artist=None,
61
+ lyrics_title=None,
62
+ lyrics_file=None,
63
+ skip_lyrics=False,
64
+ skip_transcription=False,
65
+ skip_transcription_review=False,
66
+ render_video=True,
67
+ subtitle_offset_ms=0,
68
+ # Style Configuration
69
+ style_params_json=None,
70
+ # Add the new parameter
71
+ skip_separation=False,
72
+ ):
73
+ self.log_level = log_level
74
+ self.log_formatter = log_formatter
75
+
76
+ if logger is None:
77
+ self.logger = logging.getLogger(__name__)
78
+ self.logger.setLevel(log_level)
79
+
80
+ self.log_handler = logging.StreamHandler()
81
+
82
+ if self.log_formatter is None:
83
+ self.log_formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(module)s - %(message)s")
84
+
85
+ self.log_handler.setFormatter(self.log_formatter)
86
+ self.logger.addHandler(self.log_handler)
87
+ else:
88
+ self.logger = logger
89
+
90
+ self.logger.debug(f"KaraokePrep instantiating with input_media: {input_media} artist: {artist} title: {title}")
91
+
92
+ self.dry_run = dry_run
93
+ self.extractor = None # Will be set later based on source (Original or yt-dlp extractor)
94
+ self.media_id = None # Will be set by parse_track_metadata if applicable
95
+ self.url = None # Will be set by parse_track_metadata if applicable
96
+ self.input_media = input_media
97
+ self.artist = artist
98
+ self.title = title
99
+ self.filename_pattern = filename_pattern
100
+
101
+ # Input/Output - Keep these as they might be needed for logic outside handlers or passed to multiple handlers
102
+ self.output_dir = output_dir
103
+ self.lossless_output_format = lossless_output_format.lower()
104
+ self.create_track_subfolders = create_track_subfolders
105
+ self.output_png = output_png
106
+ self.output_jpg = output_jpg
107
+
108
+ # Lyrics Config - Keep needed ones
109
+ self.lyrics_artist = lyrics_artist
110
+ self.lyrics_title = lyrics_title
111
+ self.lyrics_file = lyrics_file # Passed to LyricsProcessor
112
+ self.skip_lyrics = skip_lyrics # Used in prep_single_track logic
113
+ self.skip_transcription = skip_transcription # Passed to LyricsProcessor
114
+ self.skip_transcription_review = skip_transcription_review # Passed to LyricsProcessor
115
+ self.render_video = render_video # Passed to LyricsProcessor
116
+ self.subtitle_offset_ms = subtitle_offset_ms # Passed to LyricsProcessor
117
+
118
+ # Audio Config - Keep needed ones
119
+ self.existing_instrumental = existing_instrumental # Used in prep_single_track logic
120
+ self.skip_separation = skip_separation # Used in prep_single_track logic
121
+ self.model_file_dir = model_file_dir # Passed to AudioProcessor
122
+
123
+ # Style Config - Keep needed ones
124
+ self.render_bounding_boxes = render_bounding_boxes # Passed to VideoGenerator
125
+ self.style_params_json = style_params_json # Passed to LyricsProcessor
126
+
127
+ # Load style parameters using the config module
128
+ self.style_params = load_style_params(self.style_params_json, self.logger)
129
+
130
+ # Set up title and end formats using the config module
131
+ self.title_format = setup_title_format(self.style_params)
132
+ self.end_format = setup_end_format(self.style_params)
133
+
134
+ # Get video durations and existing images using the config module
135
+ self.intro_video_duration, self.end_video_duration = get_video_durations(self.style_params)
136
+ self.existing_title_image, self.existing_end_image = get_existing_images(self.style_params)
137
+
138
+ # Set up ffmpeg command using the config module
139
+ self.ffmpeg_base_command = setup_ffmpeg_command(self.log_level)
140
+
141
+ # Instantiate Handlers
142
+ self.file_handler = FileHandler(
143
+ logger=self.logger,
144
+ ffmpeg_base_command=self.ffmpeg_base_command,
145
+ create_track_subfolders=self.create_track_subfolders,
146
+ dry_run=self.dry_run,
147
+ )
148
+
149
+ self.audio_processor = AudioProcessor(
150
+ logger=self.logger,
151
+ log_level=self.log_level,
152
+ log_formatter=self.log_formatter,
153
+ model_file_dir=self.model_file_dir,
154
+ lossless_output_format=self.lossless_output_format,
155
+ clean_instrumental_model=clean_instrumental_model, # Passed directly from args
156
+ backing_vocals_models=backing_vocals_models, # Passed directly from args
157
+ other_stems_models=other_stems_models, # Passed directly from args
158
+ ffmpeg_base_command=self.ffmpeg_base_command,
159
+ )
160
+
161
+ self.lyrics_processor = LyricsProcessor(
162
+ logger=self.logger,
163
+ style_params_json=self.style_params_json,
164
+ lyrics_file=self.lyrics_file,
165
+ skip_transcription=self.skip_transcription,
166
+ skip_transcription_review=self.skip_transcription_review,
167
+ render_video=self.render_video,
168
+ subtitle_offset_ms=self.subtitle_offset_ms,
169
+ )
170
+
171
+ self.video_generator = VideoGenerator(
172
+ logger=self.logger,
173
+ ffmpeg_base_command=self.ffmpeg_base_command,
174
+ render_bounding_boxes=self.render_bounding_boxes,
175
+ output_png=self.output_png,
176
+ output_jpg=self.output_jpg,
177
+ )
178
+
179
+ self.logger.debug(f"Initialized title_format with extra_text: {self.title_format['extra_text']}")
180
+ self.logger.debug(f"Initialized title_format with extra_text_region: {self.title_format['extra_text_region']}")
181
+
182
+ self.logger.debug(f"Initialized end_format with extra_text: {self.end_format['extra_text']}")
183
+ self.logger.debug(f"Initialized end_format with extra_text_region: {self.end_format['extra_text_region']}")
184
+
185
+ self.extracted_info = None # Will be populated by extract_info_for_online_media if needed
186
+ self.persistent_artist = None # Used for playlists
187
+
188
+ self.logger.debug(f"KaraokePrep lossless_output_format: {self.lossless_output_format}")
189
+
190
+ # Use FileHandler method to check/create output dir
191
+ if not os.path.exists(self.output_dir):
192
+ self.logger.debug(f"Overall output dir {self.output_dir} did not exist, creating")
193
+ os.makedirs(self.output_dir)
194
+ else:
195
+ self.logger.debug(f"Overall output dir {self.output_dir} already exists")
196
+
197
+ # Compatibility methods for tests - these call the new functions in metadata.py
198
+ def extract_info_for_online_media(self, input_url=None, input_artist=None, input_title=None):
199
+ """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)
201
+ return self.extracted_info
202
+
203
+ def parse_single_track_metadata(self, input_artist, input_title):
204
+ """Compatibility method that calls the function in metadata.py"""
205
+ metadata_result = parse_track_metadata(self.extracted_info, input_artist, input_title, self.persistent_artist, self.logger)
206
+ self.url = metadata_result["url"]
207
+ self.extractor = metadata_result["extractor"]
208
+ self.media_id = metadata_result["media_id"]
209
+ self.artist = metadata_result["artist"]
210
+ self.title = metadata_result["title"]
211
+
212
+ async def prep_single_track(self):
213
+ # Add signal handler at the start
214
+ loop = asyncio.get_running_loop()
215
+ for sig in (signal.SIGINT, signal.SIGTERM):
216
+ loop.add_signal_handler(sig, lambda s=sig: asyncio.create_task(self.shutdown(s)))
217
+
218
+ try:
219
+ self.logger.info(f"Preparing single track: {self.artist} - {self.title}")
220
+
221
+ # Determine extractor early based on input type
222
+ # Assume self.extractor, self.url, self.media_id etc. are set by process() before calling this
223
+ if self.input_media and os.path.isfile(self.input_media):
224
+ if not self.extractor: # If extractor wasn't somehow set before (e.g., direct call)
225
+ self.extractor = "Original"
226
+ elif self.url: # If it's a URL (set by process)
227
+ if not self.extractor: # Should have been set by parse_track_metadata in process()
228
+ self.logger.warning("Extractor not set before prep_single_track for URL, attempting fallback logic.")
229
+ # Fallback logic (less ideal, relies on potentially missing info)
230
+ if self.extracted_info and self.extracted_info.get('extractor'):
231
+ self.extractor = self.extracted_info['extractor']
232
+ elif self.media_id: # Try to guess based on ID format
233
+ # Basic youtube id check
234
+ if re.match(r'^[a-zA-Z0-9_-]{11}$', self.media_id):
235
+ self.extractor = "youtube"
236
+ else:
237
+ self.extractor = "UnknownSource" # Fallback if ID doesn't look like youtube
238
+ else:
239
+ self.extractor = "UnknownSource" # Final fallback
240
+ self.logger.info(f"Fallback extractor set to: {self.extractor}")
241
+ elif self.input_media: # Not a file, not a URL -> maybe a direct URL string?
242
+ 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
+ # This path requires calling extract/parse again, less efficient
244
+ try:
245
+ extracted = extract_info_for_online_media(self.input_media, self.artist, self.title, self.logger)
246
+ if extracted:
247
+ metadata_result = parse_track_metadata(
248
+ extracted, self.artist, self.title, self.persistent_artist, self.logger
249
+ )
250
+ self.url = metadata_result["url"]
251
+ self.extractor = metadata_result["extractor"]
252
+ self.media_id = metadata_result["media_id"]
253
+ self.artist = metadata_result["artist"]
254
+ self.title = metadata_result["title"]
255
+ self.logger.info(f"Successfully extracted metadata within prep_single_track for {self.input_media}")
256
+ else:
257
+ self.logger.error(f"Could not extract info for {self.input_media} within prep_single_track.")
258
+ self.extractor = "ErrorExtracting"
259
+ return None # Cannot proceed without metadata
260
+ except Exception as meta_exc:
261
+ self.logger.error(f"Error during metadata extraction/parsing within prep_single_track: {meta_exc}")
262
+ self.extractor = "ErrorParsing"
263
+ return None # Cannot proceed
264
+ else:
265
+ # If it's neither file nor URL, and input_media is None, check for existing files
266
+ # This path is mainly for the case where files exist from previous run
267
+ # We still need artist/title for filename generation
268
+ if not self.artist or not self.title:
269
+ self.logger.error("Cannot determine output path without artist/title when input_media is None and not a URL.")
270
+ return None
271
+ self.logger.info("Input media is None, assuming check for existing files based on artist/title.")
272
+ # We need a nominal extractor for filename matching if files exist
273
+ # Let's default to 'UnknownExisting' or try to infer if possible later
274
+ if not self.extractor:
275
+ self.extractor = "UnknownExisting"
276
+
277
+ if not self.extractor:
278
+ self.logger.error("Could not determine extractor for the track.")
279
+ return None
280
+
281
+ # Now self.extractor should be set correctly for path generation etc.
282
+
283
+ self.logger.info(f"Preparing output path for track: {self.title} by {self.artist} (Extractor: {self.extractor})")
284
+ if self.dry_run:
285
+ return None
286
+
287
+ # Delegate to FileHandler
288
+ track_output_dir, artist_title = self.file_handler.setup_output_paths(self.output_dir, self.artist, self.title)
289
+
290
+ processed_track = {
291
+ "track_output_dir": track_output_dir,
292
+ "artist": self.artist,
293
+ "title": self.title,
294
+ "extractor": self.extractor,
295
+ "extracted_info": self.extracted_info,
296
+ "lyrics": None,
297
+ "processed_lyrics": None,
298
+ "separated_audio": {},
299
+ }
300
+
301
+ processed_track["input_media"] = None
302
+ processed_track["input_still_image"] = None
303
+ processed_track["input_audio_wav"] = None
304
+
305
+ if self.input_media and os.path.isfile(self.input_media):
306
+ # --- Local File Input Handling ---
307
+ input_wav_filename_pattern = os.path.join(track_output_dir, f"{artist_title} ({self.extractor}*).wav")
308
+ input_wav_glob = glob.glob(input_wav_filename_pattern)
309
+
310
+ if input_wav_glob:
311
+ processed_track["input_audio_wav"] = input_wav_glob[0]
312
+ self.logger.info(f"Input media WAV file already exists, skipping conversion: {processed_track['input_audio_wav']}")
313
+ else:
314
+ output_filename_no_extension = os.path.join(track_output_dir, f"{artist_title} ({self.extractor})")
315
+
316
+ self.logger.info(f"Copying input media from {self.input_media} to new directory...")
317
+ # Delegate to FileHandler
318
+ processed_track["input_media"] = self.file_handler.copy_input_media(self.input_media, output_filename_no_extension)
319
+
320
+ self.logger.info("Converting input media to WAV for audio processing...")
321
+ # Delegate to FileHandler
322
+ processed_track["input_audio_wav"] = self.file_handler.convert_to_wav(processed_track["input_media"], output_filename_no_extension)
323
+
324
+ else:
325
+ # --- URL or Existing Files Handling ---
326
+ # Construct patterns using the determined extractor
327
+ base_pattern = os.path.join(track_output_dir, f"{artist_title} ({self.extractor}*)")
328
+ input_media_glob = glob.glob(f"{base_pattern}.*webm") + glob.glob(f"{base_pattern}.*mp4") # Add other common formats if needed
329
+ input_png_glob = glob.glob(f"{base_pattern}.png")
330
+ input_wav_glob = glob.glob(f"{base_pattern}.wav")
331
+
332
+ if input_media_glob and input_png_glob and input_wav_glob:
333
+ # Existing files found
334
+ processed_track["input_media"] = input_media_glob[0]
335
+ processed_track["input_still_image"] = input_png_glob[0]
336
+ processed_track["input_audio_wav"] = input_wav_glob[0]
337
+ self.logger.info(f"Found existing media files matching extractor '{self.extractor}', skipping download/conversion.")
338
+ # Ensure self.extractor reflects the found files if it was a fallback
339
+ # Extract the actual extractor string from the filename if needed, though it should match
340
+
341
+ elif self.url: # URL provided and files not found, proceed with download
342
+ # Use media_id if available for better uniqueness
343
+ filename_suffix = f"{self.extractor} {self.media_id}" if self.media_id else self.extractor
344
+ output_filename_no_extension = os.path.join(track_output_dir, f"{artist_title} ({filename_suffix})")
345
+
346
+ self.logger.info(f"Downloading input media from {self.url}...")
347
+ # Delegate to FileHandler
348
+ processed_track["input_media"] = self.file_handler.download_video(self.url, output_filename_no_extension)
349
+
350
+ self.logger.info("Extracting still image from downloaded media (if input is video)...")
351
+ # Delegate to FileHandler
352
+ processed_track["input_still_image"] = self.file_handler.extract_still_image_from_video(
353
+ processed_track["input_media"], output_filename_no_extension
354
+ )
355
+
356
+ self.logger.info("Converting downloaded video to WAV for audio processing...")
357
+ # Delegate to FileHandler
358
+ processed_track["input_audio_wav"] = self.file_handler.convert_to_wav(
359
+ processed_track["input_media"], output_filename_no_extension
360
+ )
361
+ else:
362
+ # This case means input_media was None, not a URL, and no existing files found
363
+ self.logger.error(f"Cannot proceed: No input file, no URL, and no existing files found for {artist_title}.")
364
+ return None
365
+
366
+ if self.skip_lyrics:
367
+ self.logger.info("Skipping lyrics fetch as requested.")
368
+ processed_track["lyrics"] = None
369
+ processed_track["processed_lyrics"] = None
370
+ else:
371
+ lyrics_artist = self.lyrics_artist or self.artist
372
+ lyrics_title = self.lyrics_title or self.title
373
+
374
+ # Create futures for both operations
375
+ transcription_future = None
376
+ separation_future = None
377
+
378
+ self.logger.info("=== Starting Parallel Processing ===")
379
+
380
+ if not self.skip_lyrics:
381
+ self.logger.info("Creating transcription future...")
382
+ # Run transcription in a separate thread
383
+ transcription_future = asyncio.create_task(
384
+ asyncio.to_thread(
385
+ # Delegate to LyricsProcessor
386
+ self.lyrics_processor.transcribe_lyrics, processed_track["input_audio_wav"], lyrics_artist, lyrics_title, track_output_dir
387
+ )
388
+ )
389
+ self.logger.info(f"Transcription future created, type: {type(transcription_future)}")
390
+
391
+ # Default to a placeholder future if separation won't run
392
+ separation_future = asyncio.sleep(0)
393
+
394
+ # Only create real separation future if not skipping AND no existing instrumental provided
395
+ if not self.skip_separation and not self.existing_instrumental:
396
+ self.logger.info("Creating separation future (not skipping and no existing instrumental)...")
397
+ # Run separation in a separate thread
398
+ separation_future = asyncio.create_task(
399
+ asyncio.to_thread(
400
+ # Delegate to AudioProcessor
401
+ self.audio_processor.process_audio_separation,
402
+ audio_file=processed_track["input_audio_wav"],
403
+ artist_title=artist_title,
404
+ track_output_dir=track_output_dir,
405
+ )
406
+ )
407
+ self.logger.info(f"Separation future created, type: {type(separation_future)}")
408
+ elif self.existing_instrumental:
409
+ self.logger.info(f"Skipping separation future creation because existing instrumental was provided: {self.existing_instrumental}")
410
+ elif self.skip_separation: # Check this condition explicitly for clarity
411
+ self.logger.info("Skipping separation future creation because skip_separation is True.")
412
+
413
+ self.logger.info("About to await both operations with asyncio.gather...")
414
+ # Wait for both operations to complete
415
+ try:
416
+ results = await asyncio.gather(
417
+ transcription_future if transcription_future else asyncio.sleep(0), # Use placeholder if None
418
+ separation_future, # Already defaults to placeholder if not created
419
+ return_exceptions=True,
420
+ )
421
+ except asyncio.CancelledError:
422
+ self.logger.info("Received cancellation request, cleaning up...")
423
+ # Cancel any running futures
424
+ if transcription_future and not transcription_future.done():
425
+ transcription_future.cancel()
426
+ if separation_future and not separation_future.done() and not isinstance(separation_future, asyncio.Task): # Check if it's a real task
427
+ # Don't try to cancel the asyncio.sleep(0) placeholder
428
+ separation_future.cancel()
429
+
430
+ # Wait for futures to complete cancellation
431
+ await asyncio.gather(
432
+ transcription_future if transcription_future else asyncio.sleep(0),
433
+ separation_future if separation_future else asyncio.sleep(0), # Use placeholder if None/Placeholder
434
+ return_exceptions=True,
435
+ )
436
+ raise
437
+
438
+ # Handle transcription results
439
+ if transcription_future:
440
+ self.logger.info("Processing transcription results...")
441
+ try:
442
+ # Index 0 corresponds to transcription_future in gather
443
+ transcriber_outputs = results[0]
444
+ # Check if the result is an exception or the actual output
445
+ if isinstance(transcriber_outputs, Exception):
446
+ self.logger.error(f"Error during lyrics transcription: {transcriber_outputs}")
447
+ # Optionally log traceback: self.logger.exception("Transcription error:")
448
+ raise transcriber_outputs # Re-raise the exception
449
+ elif transcriber_outputs is not None and not isinstance(transcriber_outputs, asyncio.futures.Future): # Ensure it's not the placeholder future
450
+ self.logger.info(f"Successfully received transcription outputs: {type(transcriber_outputs)}")
451
+ # Ensure transcriber_outputs is a dictionary before calling .get()
452
+ if isinstance(transcriber_outputs, dict):
453
+ self.lyrics = transcriber_outputs.get("corrected_lyrics_text")
454
+ processed_track["lyrics"] = transcriber_outputs.get("corrected_lyrics_text_filepath")
455
+ else:
456
+ self.logger.warning(f"Unexpected type for transcriber_outputs: {type(transcriber_outputs)}, value: {transcriber_outputs}")
457
+ else:
458
+ self.logger.info("Transcription task did not return results (possibly skipped or placeholder).")
459
+ except Exception as e:
460
+ self.logger.error(f"Error processing transcription results: {e}")
461
+ self.logger.exception("Full traceback:")
462
+ raise # Re-raise the exception
463
+
464
+ # Handle separation results only if a real future was created and ran
465
+ # Check if separation_future was the placeholder or a real task
466
+ # The result index in `results` depends on whether transcription_future existed
467
+ separation_result_index = 1 if transcription_future else 0
468
+ if separation_future is not None and not isinstance(separation_future, asyncio.Task) and len(results) > separation_result_index:
469
+ self.logger.info("Processing separation results...")
470
+ try:
471
+ separation_results = results[separation_result_index]
472
+ # Check if the result is an exception or the actual output
473
+ if isinstance(separation_results, Exception):
474
+ self.logger.error(f"Error during audio separation: {separation_results}")
475
+ # Optionally log traceback: self.logger.exception("Separation error:")
476
+ # Decide if you want to raise here or just log
477
+ elif separation_results is not None and not isinstance(separation_results, asyncio.futures.Future): # Ensure it's not the placeholder future
478
+ self.logger.info(f"Successfully received separation results: {type(separation_results)}")
479
+ if isinstance(separation_results, dict):
480
+ processed_track["separated_audio"] = separation_results
481
+ else:
482
+ self.logger.warning(f"Unexpected type for separation_results: {type(separation_results)}, value: {separation_results}")
483
+ else:
484
+ self.logger.info("Separation task did not return results (possibly skipped or placeholder).")
485
+ except Exception as e:
486
+ self.logger.error(f"Error processing separation results: {e}")
487
+ self.logger.exception("Full traceback:")
488
+ # Decide if you want to raise here or just log
489
+ elif not self.skip_separation and not self.existing_instrumental:
490
+ # This case means separation was supposed to run but didn't return results properly
491
+ self.logger.warning("Separation task was expected but did not yield results or resulted in an error captured earlier.")
492
+ else:
493
+ # This case means separation was intentionally skipped
494
+ self.logger.info("Skipping processing of separation results as separation was not run.")
495
+
496
+ self.logger.info("=== Parallel Processing Complete ===")
497
+
498
+ output_image_filepath_noext = os.path.join(track_output_dir, f"{artist_title} (Title)")
499
+ processed_track["title_image_png"] = f"{output_image_filepath_noext}.png"
500
+ processed_track["title_image_jpg"] = f"{output_image_filepath_noext}.jpg"
501
+ processed_track["title_video"] = os.path.join(track_output_dir, f"{artist_title} (Title).mov")
502
+
503
+ # Use FileHandler._file_exists
504
+ if not self.file_handler._file_exists(processed_track["title_video"]) and not os.environ.get("KARAOKE_PREP_SKIP_TITLE_END_SCREENS"):
505
+ self.logger.info(f"Creating title video...")
506
+ # Delegate to VideoGenerator
507
+ self.video_generator.create_title_video(
508
+ artist=self.artist,
509
+ title=self.title,
510
+ format=self.title_format,
511
+ output_image_filepath_noext=output_image_filepath_noext,
512
+ output_video_filepath=processed_track["title_video"],
513
+ existing_title_image=self.existing_title_image,
514
+ intro_video_duration=self.intro_video_duration,
515
+ )
516
+
517
+ output_image_filepath_noext = os.path.join(track_output_dir, f"{artist_title} (End)")
518
+ processed_track["end_image_png"] = f"{output_image_filepath_noext}.png"
519
+ processed_track["end_image_jpg"] = f"{output_image_filepath_noext}.jpg"
520
+ processed_track["end_video"] = os.path.join(track_output_dir, f"{artist_title} (End).mov")
521
+
522
+ # Use FileHandler._file_exists
523
+ if not self.file_handler._file_exists(processed_track["end_video"]) and not os.environ.get("KARAOKE_PREP_SKIP_TITLE_END_SCREENS"):
524
+ self.logger.info(f"Creating end screen video...")
525
+ # Delegate to VideoGenerator
526
+ self.video_generator.create_end_video(
527
+ artist=self.artist,
528
+ title=self.title,
529
+ format=self.end_format,
530
+ output_image_filepath_noext=output_image_filepath_noext,
531
+ output_video_filepath=processed_track["end_video"],
532
+ existing_end_image=self.existing_end_image,
533
+ end_video_duration=self.end_video_duration,
534
+ )
535
+
536
+ if self.skip_separation:
537
+ self.logger.info("Skipping audio separation as requested.")
538
+ processed_track["separated_audio"] = {
539
+ "clean_instrumental": {},
540
+ "backing_vocals": {},
541
+ "other_stems": {},
542
+ "combined_instrumentals": {},
543
+ }
544
+ elif self.existing_instrumental:
545
+ self.logger.info(f"Using existing instrumental file: {self.existing_instrumental}")
546
+ existing_instrumental_extension = os.path.splitext(self.existing_instrumental)[1]
547
+
548
+ instrumental_path = os.path.join(track_output_dir, f"{artist_title} (Instrumental Custom){existing_instrumental_extension}")
549
+
550
+ # Use FileHandler._file_exists
551
+ if not self.file_handler._file_exists(instrumental_path):
552
+ shutil.copy2(self.existing_instrumental, instrumental_path)
553
+
554
+ processed_track["separated_audio"]["Custom"] = {
555
+ "instrumental": instrumental_path,
556
+ "vocals": None,
557
+ }
558
+ else:
559
+ # Only run separation if not skipped
560
+ if not self.skip_separation:
561
+ self.logger.info(f"Separating audio for track: {self.title} by {self.artist}")
562
+ # Delegate to AudioProcessor (called directly, not in thread here)
563
+ separation_results = self.audio_processor.process_audio_separation(
564
+ audio_file=processed_track["input_audio_wav"], artist_title=artist_title, track_output_dir=track_output_dir
565
+ )
566
+ processed_track["separated_audio"] = separation_results
567
+ # We don't need an else here, if skip_separation is true, separated_audio remains the default empty dict
568
+
569
+ self.logger.info("Script finished, audio downloaded, lyrics fetched and audio separated!")
570
+
571
+ return processed_track
572
+
573
+ except Exception as e:
574
+ self.logger.error(f"Error in prep_single_track: {e}")
575
+ raise
576
+ finally:
577
+ # Remove signal handlers
578
+ for sig in (signal.SIGINT, signal.SIGTERM):
579
+ loop.remove_signal_handler(sig)
580
+
581
+ async def shutdown(self, signal):
582
+ """Handle shutdown signals gracefully."""
583
+ self.logger.info(f"Received exit signal {signal.name}...")
584
+
585
+ # Get all running tasks
586
+ tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
587
+
588
+ if tasks:
589
+ self.logger.info(f"Cancelling {len(tasks)} outstanding tasks")
590
+ # Cancel all running tasks
591
+ for task in tasks:
592
+ task.cancel()
593
+
594
+ self.logger.info("Received cancellation request, cleaning up...")
595
+
596
+ # Wait for all tasks to complete with cancellation
597
+ try:
598
+ await asyncio.gather(*tasks, return_exceptions=True)
599
+ except asyncio.CancelledError:
600
+ pass
601
+
602
+ # Force exit after cleanup
603
+ self.logger.info("Cleanup complete, exiting...")
604
+ sys.exit(0) # Add this line to force exit
605
+
606
+ async def process_playlist(self):
607
+ if self.artist is None or self.title is None:
608
+ raise Exception("Error: Artist and Title are required for processing a local file.")
609
+
610
+ if "entries" in self.extracted_info:
611
+ track_results = []
612
+ self.logger.info(f"Found {len(self.extracted_info['entries'])} entries in playlist, processing each invididually...")
613
+ for entry in self.extracted_info["entries"]:
614
+ self.extracted_info = entry
615
+ self.logger.info(f"Processing playlist entry with title: {self.extracted_info['title']}")
616
+ if not self.dry_run:
617
+ track_results.append(await self.prep_single_track())
618
+ self.artist = self.persistent_artist
619
+ self.title = None
620
+ return track_results
621
+ else:
622
+ raise Exception(f"Failed to find 'entries' in playlist, cannot process")
623
+
624
+ async def process_folder(self):
625
+ if self.filename_pattern is None or self.artist is None:
626
+ raise Exception("Error: Filename pattern and artist are required for processing a folder.")
627
+
628
+ folder_path = self.input_media
629
+ output_folder_path = os.path.join(os.getcwd(), os.path.basename(folder_path))
630
+
631
+ if not os.path.exists(output_folder_path):
632
+ if not self.dry_run:
633
+ self.logger.info(f"DRY RUN: Would create output folder: {output_folder_path}")
634
+ os.makedirs(output_folder_path)
635
+ else:
636
+ self.logger.info(f"Output folder already exists: {output_folder_path}")
637
+
638
+ pattern = re.compile(self.filename_pattern)
639
+ tracks = []
640
+
641
+ for filename in sorted(os.listdir(folder_path)):
642
+ match = pattern.match(filename)
643
+ if match:
644
+ title = match.group("title")
645
+ file_path = os.path.join(folder_path, filename)
646
+ self.input_media = file_path
647
+ self.title = title
648
+
649
+ track_index = match.group("index") if "index" in match.groupdict() else None
650
+
651
+ self.logger.info(f"Processing track: {track_index} with title: {title} from file: {filename}")
652
+
653
+ track_output_dir = os.path.join(output_folder_path, f"{track_index} - {self.artist} - {title}")
654
+
655
+ if not self.dry_run:
656
+ track = await self.prep_single_track()
657
+ tracks.append(track)
658
+
659
+ # Move the track folder to the output folder
660
+ track_folder = track["track_output_dir"]
661
+ shutil.move(track_folder, track_output_dir)
662
+ else:
663
+ self.logger.info(f"DRY RUN: Would move track folder to: {os.path.basename(track_output_dir)}")
664
+
665
+ return tracks
666
+
667
+ async def process(self):
668
+ if self.input_media is not None and os.path.isdir(self.input_media):
669
+ self.logger.info(f"Input media {self.input_media} is a local folder, processing each file individually...")
670
+ return await self.process_folder()
671
+ elif self.input_media is not None and os.path.isfile(self.input_media):
672
+ self.logger.info(f"Input media {self.input_media} is a local file, youtube logic will be skipped")
673
+ return [await self.prep_single_track()]
674
+ else:
675
+ self.url = self.input_media
676
+ # Use the imported extract_info_for_online_media function
677
+ self.extracted_info = extract_info_for_online_media(
678
+ input_url=self.url, input_artist=self.artist, input_title=self.title, logger=self.logger
679
+ )
680
+
681
+ if self.extracted_info and "playlist_count" in self.extracted_info:
682
+ self.persistent_artist = self.artist
683
+ self.logger.info(f"Input URL is a playlist, beginning batch operation with persistent artist: {self.persistent_artist}")
684
+ return await self.process_playlist()
685
+ else:
686
+ self.logger.info(f"Input URL is not a playlist, processing single track")
687
+ return [await self.prep_single_track()]