karaoke-gen 0.75.16__py3-none-any.whl → 0.76.20__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.
Files changed (47) hide show
  1. karaoke_gen/audio_fetcher.py +984 -33
  2. karaoke_gen/audio_processor.py +4 -0
  3. karaoke_gen/instrumental_review/static/index.html +37 -14
  4. karaoke_gen/karaoke_finalise/karaoke_finalise.py +25 -1
  5. karaoke_gen/karaoke_gen.py +208 -39
  6. karaoke_gen/lyrics_processor.py +111 -31
  7. karaoke_gen/utils/__init__.py +26 -0
  8. karaoke_gen/utils/cli_args.py +15 -6
  9. karaoke_gen/utils/gen_cli.py +30 -5
  10. karaoke_gen/utils/remote_cli.py +301 -20
  11. {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/METADATA +107 -5
  12. {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/RECORD +47 -43
  13. lyrics_transcriber/core/controller.py +76 -2
  14. lyrics_transcriber/frontend/index.html +5 -1
  15. lyrics_transcriber/frontend/package-lock.json +4553 -0
  16. lyrics_transcriber/frontend/package.json +4 -1
  17. lyrics_transcriber/frontend/playwright.config.ts +69 -0
  18. lyrics_transcriber/frontend/public/nomad-karaoke-logo.svg +5 -0
  19. lyrics_transcriber/frontend/src/App.tsx +94 -63
  20. lyrics_transcriber/frontend/src/api.ts +25 -10
  21. lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +55 -21
  22. lyrics_transcriber/frontend/src/components/AppHeader.tsx +65 -0
  23. lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +5 -5
  24. lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +9 -9
  25. lyrics_transcriber/frontend/src/components/EditModal.tsx +1 -1
  26. lyrics_transcriber/frontend/src/components/EditWordList.tsx +1 -1
  27. lyrics_transcriber/frontend/src/components/Header.tsx +34 -48
  28. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +22 -21
  29. lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
  30. lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
  31. lyrics_transcriber/frontend/src/components/WordDivider.tsx +3 -3
  32. lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +2 -2
  33. lyrics_transcriber/frontend/src/components/shared/constants.ts +15 -5
  34. lyrics_transcriber/frontend/src/main.tsx +1 -7
  35. lyrics_transcriber/frontend/src/theme.ts +337 -135
  36. lyrics_transcriber/frontend/vite.config.ts +5 -0
  37. lyrics_transcriber/frontend/web_assets/assets/{index-COYImAcx.js → index-BECn1o8Q.js} +38 -22
  38. lyrics_transcriber/frontend/web_assets/assets/{index-COYImAcx.js.map → index-BECn1o8Q.js.map} +1 -1
  39. lyrics_transcriber/frontend/web_assets/index.html +1 -1
  40. lyrics_transcriber/frontend/yarn.lock +1005 -1046
  41. lyrics_transcriber/output/countdown_processor.py +39 -0
  42. lyrics_transcriber/review/server.py +1 -1
  43. lyrics_transcriber/transcribers/audioshake.py +96 -7
  44. lyrics_transcriber/types.py +14 -12
  45. {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/WHEEL +0 -0
  46. {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/entry_points.txt +0 -0
  47. {karaoke_gen-0.75.16.dist-info → karaoke_gen-0.76.20.dist-info}/licenses/LICENSE +0 -0
@@ -771,6 +771,10 @@ class AudioProcessor:
771
771
  padded_result["other_stems"] = separation_result.get("other_stems", {})
772
772
  padded_result["backing_vocals"] = separation_result.get("backing_vocals", {})
773
773
 
774
+ # Preserve Custom instrumental if present (already padded in karaoke_gen.py)
775
+ if "Custom" in separation_result:
776
+ padded_result["Custom"] = separation_result["Custom"]
777
+
774
778
  # Count actual padded files (don't assume clean instrumental was padded)
775
779
  padded_count = (1 if padded_result["clean_instrumental"].get("instrumental") else 0) + len(padded_result["combined_instrumentals"])
776
780
 
@@ -598,7 +598,22 @@
598
598
  let animationFrameId = null;
599
599
  let currentAudioElement = null; // Track audio element reference for listener management
600
600
 
601
- const API_BASE = '/api/jobs/local';
601
+ // Parse URL parameters for cloud mode
602
+ const urlParams = new URLSearchParams(window.location.search);
603
+ const encodedBaseApiUrl = urlParams.get('baseApiUrl');
604
+ const instrumentalToken = urlParams.get('instrumentalToken');
605
+
606
+ // Determine API base URL - cloud mode uses provided URL, local mode uses default
607
+ const API_BASE = encodedBaseApiUrl
608
+ ? decodeURIComponent(encodedBaseApiUrl)
609
+ : '/api/jobs/local';
610
+
611
+ // Helper to add token to URL if available
612
+ function addTokenToUrl(url) {
613
+ if (!instrumentalToken) return url;
614
+ const separator = url.includes('?') ? '&' : '?';
615
+ return `${url}${separator}instrumental_token=${encodeURIComponent(instrumentalToken)}`;
616
+ }
602
617
 
603
618
  // HTML escape helper to prevent XSS
604
619
  function escapeHtml(str) {
@@ -617,8 +632,8 @@
617
632
  async function init() {
618
633
  try {
619
634
  const [analysisRes, waveformRes] = await Promise.all([
620
- fetch(`${API_BASE}/instrumental-analysis`),
621
- fetch(`${API_BASE}/waveform-data?num_points=1000`)
635
+ fetch(addTokenToUrl(`${API_BASE}/instrumental-analysis`)),
636
+ fetch(addTokenToUrl(`${API_BASE}/waveform-data?num_points=1000`))
622
637
  ]);
623
638
 
624
639
  if (!analysisRes.ok) throw new Error('Failed to load analysis');
@@ -1120,15 +1135,23 @@
1120
1135
  }
1121
1136
 
1122
1137
  function getAudioUrl() {
1123
- const urls = {
1124
- original: '/api/audio/original',
1125
- backing: '/api/audio/backing_vocals',
1126
- clean: '/api/audio/clean_instrumental',
1127
- with_backing: '/api/audio/with_backing',
1128
- custom: '/api/audio/custom_instrumental',
1129
- uploaded: '/api/audio/uploaded_instrumental'
1138
+ const stemTypes = {
1139
+ original: 'original',
1140
+ backing: 'backing_vocals',
1141
+ clean: 'clean_instrumental',
1142
+ with_backing: 'with_backing',
1143
+ custom: 'custom_instrumental',
1144
+ uploaded: 'uploaded_instrumental'
1130
1145
  };
1131
- return urls[activeAudio] || urls.backing;
1146
+ const stemType = stemTypes[activeAudio] || stemTypes.backing;
1147
+
1148
+ // Cloud mode uses /audio-stream/{stem_type}, local mode uses /api/audio/{stem_type}
1149
+ const isCloudMode = !!encodedBaseApiUrl;
1150
+ const url = isCloudMode
1151
+ ? `${API_BASE}/audio-stream/${stemType}`
1152
+ : `/api/audio/${stemType}`;
1153
+
1154
+ return addTokenToUrl(url);
1132
1155
  }
1133
1156
 
1134
1157
  function formatTime(seconds) {
@@ -1295,7 +1318,7 @@
1295
1318
  const formData = new FormData();
1296
1319
  formData.append('file', file);
1297
1320
 
1298
- const response = await fetch(`${API_BASE}/upload-instrumental`, {
1321
+ const response = await fetch(addTokenToUrl(`${API_BASE}/upload-instrumental`), {
1299
1322
  method: 'POST',
1300
1323
  body: formData
1301
1324
  });
@@ -1354,7 +1377,7 @@
1354
1377
  }
1355
1378
 
1356
1379
  try {
1357
- const response = await fetch(`${API_BASE}/create-custom-instrumental`, {
1380
+ const response = await fetch(addTokenToUrl(`${API_BASE}/create-custom-instrumental`), {
1358
1381
  method: 'POST',
1359
1382
  headers: { 'Content-Type': 'application/json' },
1360
1383
  body: JSON.stringify({ mute_regions: muteRegions })
@@ -1404,7 +1427,7 @@
1404
1427
  }
1405
1428
 
1406
1429
  try {
1407
- const response = await fetch(`${API_BASE}/select-instrumental`, {
1430
+ const response = await fetch(addTokenToUrl(`${API_BASE}/select-instrumental`), {
1408
1431
  method: 'POST',
1409
1432
  headers: { 'Content-Type': 'application/json' },
1410
1433
  body: JSON.stringify({ selection: selectedOption })
@@ -654,7 +654,31 @@ class KaraokeFinalise:
654
654
  else:
655
655
  self.logger.warning(f"Unsupported file extension: {current_ext}")
656
656
 
657
- raise Exception("No suitable files found for processing.")
657
+ raise Exception(
658
+ "No suitable files found for processing.\n"
659
+ "\n"
660
+ "WHAT THIS MEANS:\n"
661
+ "The finalisation step requires a '(With Vocals).mkv' video file, which is created "
662
+ "during the lyrics transcription phase. This file contains the karaoke video with "
663
+ "synchronized lyrics overlay.\n"
664
+ "\n"
665
+ "COMMON CAUSES:\n"
666
+ "1. Transcription provider not configured - No AUDIOSHAKE_API_TOKEN or RUNPOD_API_KEY set\n"
667
+ "2. Transcription failed - Check logs above for API errors or timeout messages\n"
668
+ "3. Invalid API credentials - Verify your API tokens are correct and active\n"
669
+ "4. Network issues - Unable to reach transcription service\n"
670
+ "5. Running in wrong directory - Make sure you're in the track output folder\n"
671
+ "\n"
672
+ "TROUBLESHOOTING STEPS:\n"
673
+ "1. Check environment variables:\n"
674
+ " - AUDIOSHAKE_API_TOKEN (for AudioShake transcription)\n"
675
+ " - RUNPOD_API_KEY + WHISPER_RUNPOD_ID (for Whisper transcription)\n"
676
+ "2. Review the log output above for transcription errors\n"
677
+ "3. Try running with --log_level debug for more detailed output\n"
678
+ "4. If you don't need synchronized lyrics, use --skip-lyrics for instrumental-only karaoke\n"
679
+ "\n"
680
+ "See README.md 'Transcription Providers' and 'Troubleshooting' sections for more details."
681
+ )
658
682
 
659
683
  def choose_instrumental_audio_file(self, base_name):
660
684
  self.logger.info(f"Choosing instrumental audio file to use as karaoke audio...")
@@ -31,6 +31,12 @@ from .video_generator import VideoGenerator
31
31
  from .video_background_processor import VideoBackgroundProcessor
32
32
  from .audio_fetcher import create_audio_fetcher, AudioFetcherError, NoResultsError, UserCancelledError
33
33
 
34
+ # Import lyrics_transcriber components for post-review countdown and video rendering
35
+ from lyrics_transcriber.output.countdown_processor import CountdownProcessor
36
+ from lyrics_transcriber.output.generator import OutputGenerator
37
+ from lyrics_transcriber.types import CorrectionResult
38
+ from lyrics_transcriber.core.config import OutputConfig as LyricsOutputConfig
39
+
34
40
 
35
41
  class KaraokePrep:
36
42
  def __init__(
@@ -74,7 +80,7 @@ class KaraokePrep:
74
80
  skip_separation=False,
75
81
  # Video Background Configuration
76
82
  background_video=None,
77
- background_video_darkness=0,
83
+ background_video_darkness=50,
78
84
  # Audio Fetcher Configuration
79
85
  auto_download=False,
80
86
  ):
@@ -482,41 +488,56 @@ class KaraokePrep:
482
488
  self.logger.info(f"Found existing media files matching extractor '{self.extractor}', skipping download/conversion.")
483
489
 
484
490
  elif getattr(self, '_use_audio_fetcher', False):
485
- # Use flacfetch to search and download audio
486
- self.logger.info(f"Using flacfetch to search and download: {self.artist} - {self.title}")
487
-
488
491
  try:
489
- # Search and download audio using the AudioFetcher
490
- fetch_result = self.audio_fetcher.search_and_download(
491
- artist=self.artist,
492
- title=self.title,
493
- output_dir=track_output_dir,
494
- output_filename=f"{artist_title} (flacfetch)",
495
- auto_select=self.auto_download,
496
- )
497
-
498
- # Update extractor to reflect the actual provider used
499
- self.extractor = f"flacfetch-{fetch_result.provider}"
500
-
492
+ # Check if this is a URL download or search+download
493
+ if getattr(self, '_use_url_download', False):
494
+ # Direct URL download (e.g., YouTube URL)
495
+ self.logger.info(f"Using flacfetch to download from URL: {self.url}")
496
+
497
+ fetch_result = self.audio_fetcher.download_from_url(
498
+ url=self.url,
499
+ output_dir=track_output_dir,
500
+ output_filename=f"{artist_title} (youtube)" if artist_title != "Unknown - Unknown" else None,
501
+ artist=self.artist,
502
+ title=self.title,
503
+ )
504
+
505
+ # Update extractor to reflect the source
506
+ self.extractor = "youtube"
507
+ else:
508
+ # Use flacfetch to search and download audio
509
+ self.logger.info(f"Using flacfetch to search and download: {self.artist} - {self.title}")
510
+
511
+ fetch_result = self.audio_fetcher.search_and_download(
512
+ artist=self.artist,
513
+ title=self.title,
514
+ output_dir=track_output_dir,
515
+ output_filename=f"{artist_title} (flacfetch)",
516
+ auto_select=self.auto_download,
517
+ )
518
+
519
+ # Update extractor to reflect the actual provider used
520
+ self.extractor = f"flacfetch-{fetch_result.provider}"
521
+
501
522
  # Set up the output paths
502
523
  output_filename_no_extension = os.path.join(track_output_dir, f"{artist_title} ({self.extractor})")
503
-
524
+
504
525
  # Copy/move the downloaded file to the expected location
505
526
  processed_track["input_media"] = self.file_handler.download_audio_from_fetcher_result(
506
527
  fetch_result.filepath, output_filename_no_extension
507
528
  )
508
-
529
+
509
530
  self.logger.info(f"Audio downloaded from {fetch_result.provider}: {processed_track['input_media']}")
510
-
531
+
511
532
  # Convert to WAV for audio processing
512
533
  self.logger.info("Converting downloaded audio to WAV for processing...")
513
534
  processed_track["input_audio_wav"] = self.file_handler.convert_to_wav(
514
535
  processed_track["input_media"], output_filename_no_extension
515
536
  )
516
-
537
+
517
538
  # No still image for audio-only downloads
518
539
  processed_track["input_still_image"] = None
519
-
540
+
520
541
  except UserCancelledError:
521
542
  # User cancelled - propagate up to CLI for graceful exit
522
543
  raise
@@ -692,6 +713,112 @@ class KaraokePrep:
692
713
 
693
714
  self.logger.info("=== Parallel Processing Complete ===")
694
715
 
716
+ # === POST-TRANSCRIPTION: Add countdown and render video ===
717
+ # Since lyrics_processor.py now always defers countdown and video rendering,
718
+ # we handle it here after human review is complete. This ensures the review UI
719
+ # shows accurate, unshifted timestamps (same behavior as cloud backend).
720
+ if processed_track.get("lyrics") and self.render_video:
721
+ self.logger.info("=== Processing Countdown and Video Rendering ===")
722
+
723
+ from .utils import sanitize_filename
724
+ sanitized_artist = sanitize_filename(self.artist)
725
+ sanitized_title = sanitize_filename(self.title)
726
+ lyrics_dir = os.path.join(track_output_dir, "lyrics")
727
+
728
+ # Find the corrections JSON file
729
+ corrections_filename = f"{sanitized_artist} - {sanitized_title} (Lyrics Corrections).json"
730
+ corrections_filepath = os.path.join(lyrics_dir, corrections_filename)
731
+
732
+ if os.path.exists(corrections_filepath):
733
+ self.logger.info(f"Loading corrections from: {corrections_filepath}")
734
+
735
+ with open(corrections_filepath, 'r', encoding='utf-8') as f:
736
+ corrections_data = json.load(f)
737
+
738
+ # Convert to CorrectionResult
739
+ correction_result = CorrectionResult.from_dict(corrections_data)
740
+ self.logger.info(f"Loaded CorrectionResult with {len(correction_result.corrected_segments)} segments")
741
+
742
+ # Get the audio file path
743
+ audio_path = processed_track["input_audio_wav"]
744
+
745
+ # Add countdown intro if needed (songs that start within 3 seconds)
746
+ self.logger.info("Processing countdown intro (if needed)...")
747
+ cache_dir = os.path.join(track_output_dir, "cache")
748
+ os.makedirs(cache_dir, exist_ok=True)
749
+
750
+ countdown_processor = CountdownProcessor(
751
+ cache_dir=cache_dir,
752
+ logger=self.logger,
753
+ )
754
+
755
+ correction_result, audio_path, padding_added, padding_seconds = countdown_processor.process(
756
+ correction_result=correction_result,
757
+ audio_filepath=audio_path,
758
+ )
759
+
760
+ # Update processed_track with countdown info
761
+ processed_track["countdown_padding_added"] = padding_added
762
+ processed_track["countdown_padding_seconds"] = padding_seconds
763
+ if padding_added:
764
+ processed_track["padded_vocals_audio"] = audio_path
765
+ self.logger.info(
766
+ f"=== COUNTDOWN PADDING ADDED ===\n"
767
+ f"Added {padding_seconds}s padding to audio and shifted timestamps.\n"
768
+ f"Instrumental tracks will be padded after separation to maintain sync."
769
+ )
770
+ else:
771
+ self.logger.info("No countdown needed - song starts after 3 seconds")
772
+
773
+ # Save the updated corrections with countdown timestamps
774
+ updated_corrections_data = correction_result.to_dict()
775
+ with open(corrections_filepath, 'w', encoding='utf-8') as f:
776
+ json.dump(updated_corrections_data, f, indent=2)
777
+ self.logger.info(f"Saved countdown-adjusted corrections to: {corrections_filepath}")
778
+
779
+ # Render video with lyrics
780
+ self.logger.info("Rendering karaoke video with synchronized lyrics...")
781
+
782
+ output_config = LyricsOutputConfig(
783
+ output_dir=lyrics_dir,
784
+ cache_dir=cache_dir,
785
+ output_styles_json=self.style_params_json,
786
+ render_video=True,
787
+ generate_cdg=False,
788
+ generate_plain_text=True,
789
+ generate_lrc=True,
790
+ video_resolution="4k",
791
+ subtitle_offset_ms=self.subtitle_offset_ms,
792
+ )
793
+
794
+ output_generator = OutputGenerator(output_config, self.logger)
795
+ output_prefix = f"{sanitized_artist} - {sanitized_title}"
796
+
797
+ outputs = output_generator.generate_outputs(
798
+ transcription_corrected=correction_result,
799
+ audio_filepath=audio_path,
800
+ output_prefix=output_prefix,
801
+ )
802
+
803
+ # Copy video to expected location in parent directory
804
+ if outputs and outputs.get("video_filepath"):
805
+ source_video = outputs["video_filepath"]
806
+ dest_video = os.path.join(track_output_dir, f"{artist_title} (With Vocals).mkv")
807
+ shutil.copy2(source_video, dest_video)
808
+ self.logger.info(f"Video rendered successfully: {dest_video}")
809
+ processed_track["with_vocals_video"] = dest_video
810
+
811
+ # Update ASS filepath for video background processing
812
+ if outputs.get("ass_filepath"):
813
+ processed_track["ass_filepath"] = outputs["ass_filepath"]
814
+ else:
815
+ self.logger.warning("Video rendering did not produce expected output")
816
+ else:
817
+ self.logger.warning(f"Corrections file not found: {corrections_filepath}")
818
+ self.logger.warning("Skipping countdown processing and video rendering")
819
+ elif not self.render_video:
820
+ self.logger.info("Video rendering disabled - skipping countdown and video generation")
821
+
695
822
  # Apply video background if requested and lyrics were processed
696
823
  if self.video_background_processor and processed_track.get("lyrics"):
697
824
  self.logger.info("=== Processing Video Background ===")
@@ -864,15 +991,21 @@ class KaraokePrep:
864
991
 
865
992
  # If separated_audio is empty (e.g., transcription was skipped but existing files have countdown),
866
993
  # scan the directory for existing instrumental files
994
+ # Note: also check for Custom instrumental (provided via --existing_instrumental)
867
995
  has_instrumentals = (
868
996
  processed_track["separated_audio"].get("clean_instrumental", {}).get("instrumental") or
869
- processed_track["separated_audio"].get("combined_instrumentals")
997
+ processed_track["separated_audio"].get("combined_instrumentals") or
998
+ processed_track["separated_audio"].get("Custom", {}).get("instrumental")
870
999
  )
871
1000
  if not has_instrumentals:
872
1001
  self.logger.info("No instrumentals in separated_audio, scanning directory for existing files...")
1002
+ # Preserve existing Custom key if present before overwriting
1003
+ custom_backup = processed_track["separated_audio"].get("Custom")
873
1004
  processed_track["separated_audio"] = self._scan_directory_for_instrumentals(
874
1005
  track_output_dir, artist_title
875
1006
  )
1007
+ if custom_backup:
1008
+ processed_track["separated_audio"]["Custom"] = custom_backup
876
1009
 
877
1010
  # Apply padding using AudioProcessor
878
1011
  padded_separation_result = self.audio_processor.apply_countdown_padding_to_instrumentals(
@@ -901,11 +1034,11 @@ class KaraokePrep:
901
1034
  for sig in (signal.SIGINT, signal.SIGTERM):
902
1035
  loop.remove_signal_handler(sig)
903
1036
 
904
- async def shutdown(self, signal):
1037
+ async def shutdown(self, signal_received):
905
1038
  """Handle shutdown signals gracefully."""
906
- self.logger.info(f"Received exit signal {signal.name}...")
1039
+ self.logger.info(f"Received exit signal {signal_received.name}...")
907
1040
 
908
- # Get all running tasks
1041
+ # Get all running tasks except the current shutdown task
909
1042
  tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
910
1043
 
911
1044
  if tasks:
@@ -914,17 +1047,15 @@ class KaraokePrep:
914
1047
  for task in tasks:
915
1048
  task.cancel()
916
1049
 
917
- self.logger.info("Received cancellation request, cleaning up...")
918
-
919
1050
  # Wait for all tasks to complete with cancellation
920
- try:
921
- await asyncio.gather(*tasks, return_exceptions=True)
922
- except asyncio.CancelledError:
923
- pass
1051
+ # Use return_exceptions=True to gather all results without raising
1052
+ await asyncio.gather(*tasks, return_exceptions=True)
924
1053
 
925
- # Force exit after cleanup
926
- self.logger.info("Cleanup complete, exiting...")
927
- sys.exit(0) # Add this line to force exit
1054
+ self.logger.info("Cleanup complete")
1055
+
1056
+ # Raise KeyboardInterrupt to propagate the cancellation up the call stack
1057
+ # This allows the main event loop to exit cleanly
1058
+ raise KeyboardInterrupt()
928
1059
 
929
1060
  async def process_playlist(self):
930
1061
  if self.artist is None or self.title is None:
@@ -987,17 +1118,56 @@ class KaraokePrep:
987
1118
 
988
1119
  return tracks
989
1120
 
1121
+ def _is_url(self, string: str) -> bool:
1122
+ """Check if a string is a URL."""
1123
+ return string is not None and (string.startswith("http://") or string.startswith("https://"))
1124
+
990
1125
  async def process(self):
991
1126
  if self.input_media is not None and os.path.isdir(self.input_media):
992
1127
  self.logger.info(f"Input media {self.input_media} is a local folder, processing each file individually...")
993
1128
  return await self.process_folder()
994
1129
  elif self.input_media is not None and os.path.isfile(self.input_media):
995
1130
  self.logger.info(f"Input media {self.input_media} is a local file, audio download will be skipped")
1131
+ return [await self.prep_single_track()]
1132
+ elif self.input_media is not None and self._is_url(self.input_media):
1133
+ # URL provided - download directly via flacfetch
1134
+ self.logger.info(f"Input media {self.input_media} is a URL, downloading via flacfetch...")
1135
+
1136
+ # Extract video ID for metadata if it's a YouTube URL
1137
+ video_id = None
1138
+ youtube_patterns = [
1139
+ r'(?:youtube\.com/watch\?v=|youtu\.be/)([a-zA-Z0-9_-]{11})',
1140
+ r'youtube\.com/embed/([a-zA-Z0-9_-]{11})',
1141
+ r'youtube\.com/v/([a-zA-Z0-9_-]{11})',
1142
+ ]
1143
+ for pattern in youtube_patterns:
1144
+ match = re.search(pattern, self.input_media)
1145
+ if match:
1146
+ video_id = match.group(1)
1147
+ break
1148
+
1149
+ # Set up the extracted_info for metadata consistency
1150
+ self.extracted_info = {
1151
+ "title": f"{self.artist} - {self.title}" if self.artist and self.title else video_id or "Unknown",
1152
+ "artist": self.artist or "",
1153
+ "track_title": self.title or "",
1154
+ "extractor_key": "youtube",
1155
+ "id": video_id or self.input_media,
1156
+ "url": self.input_media,
1157
+ "source": "youtube",
1158
+ }
1159
+ self.extractor = "youtube"
1160
+ self.url = self.input_media
1161
+
1162
+ # Mark that we need to use audio fetcher for URL download
1163
+ self._use_audio_fetcher = True
1164
+ self._use_url_download = True # New flag for URL-based download
1165
+
996
1166
  return [await self.prep_single_track()]
997
1167
  elif self.artist and self.title:
998
1168
  # No input file provided - use flacfetch to search and download audio
999
1169
  self.logger.info(f"No input file provided, using flacfetch to search for: {self.artist} - {self.title}")
1000
-
1170
+
1001
1171
  # Set up the extracted_info for metadata consistency
1002
1172
  self.extracted_info = {
1003
1173
  "title": f"{self.artist} - {self.title}",
@@ -1010,13 +1180,12 @@ class KaraokePrep:
1010
1180
  }
1011
1181
  self.extractor = "flacfetch"
1012
1182
  self.url = None # URL will be determined by flacfetch
1013
-
1183
+
1014
1184
  # Mark that we need to use audio fetcher for download
1015
1185
  self._use_audio_fetcher = True
1016
-
1186
+
1017
1187
  return [await self.prep_single_track()]
1018
1188
  else:
1019
1189
  raise ValueError(
1020
- "Either a local file path or both artist and title must be provided. "
1021
- "URL-based input has been replaced with flacfetch audio fetching."
1190
+ "Either a local file path, a URL, or both artist and title must be provided."
1022
1191
  )