karaoke-gen 0.71.27__py3-none-any.whl → 0.75.16__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 (39) hide show
  1. karaoke_gen/__init__.py +32 -1
  2. karaoke_gen/audio_fetcher.py +476 -56
  3. karaoke_gen/audio_processor.py +11 -3
  4. karaoke_gen/file_handler.py +192 -0
  5. karaoke_gen/instrumental_review/__init__.py +45 -0
  6. karaoke_gen/instrumental_review/analyzer.py +408 -0
  7. karaoke_gen/instrumental_review/editor.py +322 -0
  8. karaoke_gen/instrumental_review/models.py +171 -0
  9. karaoke_gen/instrumental_review/server.py +475 -0
  10. karaoke_gen/instrumental_review/static/index.html +1506 -0
  11. karaoke_gen/instrumental_review/waveform.py +409 -0
  12. karaoke_gen/karaoke_finalise/karaoke_finalise.py +62 -1
  13. karaoke_gen/karaoke_gen.py +114 -1
  14. karaoke_gen/lyrics_processor.py +81 -4
  15. karaoke_gen/utils/bulk_cli.py +3 -0
  16. karaoke_gen/utils/cli_args.py +9 -2
  17. karaoke_gen/utils/gen_cli.py +379 -2
  18. karaoke_gen/utils/remote_cli.py +1126 -77
  19. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/METADATA +7 -1
  20. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/RECORD +38 -26
  21. lyrics_transcriber/correction/anchor_sequence.py +226 -350
  22. lyrics_transcriber/frontend/package.json +1 -1
  23. lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
  24. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
  25. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
  26. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
  27. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
  28. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
  29. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
  30. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
  31. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  32. lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-COYImAcx.js} +1722 -489
  33. lyrics_transcriber/frontend/web_assets/assets/index-COYImAcx.js.map +1 -0
  34. lyrics_transcriber/frontend/web_assets/index.html +1 -1
  35. lyrics_transcriber/review/server.py +5 -5
  36. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
  37. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/WHEEL +0 -0
  38. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/entry_points.txt +0 -0
  39. {karaoke_gen-0.71.27.dist-info → karaoke_gen-0.75.16.dist-info}/licenses/LICENSE +0 -0
@@ -36,11 +36,18 @@ from typing import Any, Dict, Optional
36
36
  import requests
37
37
 
38
38
  from .cli_args import create_parser, process_style_overrides, is_url, is_file
39
+ # Use flacfetch's shared display functions for consistent formatting
40
+ from flacfetch import print_releases, Release
39
41
 
40
42
 
41
43
  class JobStatus(str, Enum):
42
44
  """Job status values (matching backend)."""
43
45
  PENDING = "pending"
46
+ # Audio search states (Batch 5)
47
+ SEARCHING_AUDIO = "searching_audio"
48
+ AWAITING_AUDIO_SELECTION = "awaiting_audio_selection"
49
+ DOWNLOADING_AUDIO = "downloading_audio"
50
+ # Main workflow
44
51
  DOWNLOADING = "downloading"
45
52
  SEPARATING_STAGE1 = "separating_stage1"
46
53
  SEPARATING_STAGE2 = "separating_stage2"
@@ -62,6 +69,7 @@ class JobStatus(str, Enum):
62
69
  UPLOADING = "uploading"
63
70
  NOTIFYING = "notifying"
64
71
  COMPLETE = "complete"
72
+ PREP_COMPLETE = "prep_complete" # Batch 6: Prep-only jobs stop here
65
73
  FAILED = "failed"
66
74
  CANCELLED = "cancelled"
67
75
  ERROR = "error"
@@ -247,6 +255,132 @@ class RemoteKaraokeClient:
247
255
 
248
256
  return asset_files
249
257
 
258
+ def submit_job_from_url(
259
+ self,
260
+ url: str,
261
+ artist: Optional[str] = None,
262
+ title: Optional[str] = None,
263
+ enable_cdg: bool = True,
264
+ enable_txt: bool = True,
265
+ brand_prefix: Optional[str] = None,
266
+ discord_webhook_url: Optional[str] = None,
267
+ youtube_description: Optional[str] = None,
268
+ organised_dir_rclone_root: Optional[str] = None,
269
+ enable_youtube_upload: bool = False,
270
+ dropbox_path: Optional[str] = None,
271
+ gdrive_folder_id: Optional[str] = None,
272
+ lyrics_artist: Optional[str] = None,
273
+ lyrics_title: Optional[str] = None,
274
+ subtitle_offset_ms: int = 0,
275
+ clean_instrumental_model: Optional[str] = None,
276
+ backing_vocals_models: Optional[list] = None,
277
+ other_stems_models: Optional[list] = None,
278
+ # Two-phase workflow (Batch 6)
279
+ prep_only: bool = False,
280
+ keep_brand_code: Optional[str] = None,
281
+ ) -> Dict[str, Any]:
282
+ """
283
+ Submit a new karaoke generation job from a YouTube/online URL.
284
+
285
+ The backend will download the audio from the URL and process it.
286
+ Artist and title will be auto-detected from the URL if not provided.
287
+
288
+ Note: Custom style configuration is not supported for URL-based jobs.
289
+ If you need custom styles, download the audio locally first and use
290
+ the regular file upload flow with submit_job().
291
+
292
+ Args:
293
+ url: YouTube or other video URL to download audio from
294
+ artist: Artist name (optional - auto-detected if not provided)
295
+ title: Song title (optional - auto-detected if not provided)
296
+ enable_cdg: Generate CDG+MP3 package
297
+ enable_txt: Generate TXT+MP3 package
298
+ brand_prefix: Brand code prefix (e.g., "NOMAD")
299
+ discord_webhook_url: Discord webhook for notifications
300
+ youtube_description: YouTube video description
301
+ organised_dir_rclone_root: Legacy rclone path (deprecated)
302
+ enable_youtube_upload: Enable YouTube upload
303
+ dropbox_path: Dropbox folder path for organized output (native API)
304
+ gdrive_folder_id: Google Drive folder ID for public share (native API)
305
+ lyrics_artist: Override artist name for lyrics search
306
+ lyrics_title: Override title for lyrics search
307
+ subtitle_offset_ms: Subtitle timing offset in milliseconds
308
+ clean_instrumental_model: Model for clean instrumental separation
309
+ backing_vocals_models: List of models for backing vocals separation
310
+ other_stems_models: List of models for other stems (bass, drums, etc.)
311
+ """
312
+ self.logger.info(f"Submitting URL-based job: {url}")
313
+
314
+ # Build request payload
315
+ create_request = {
316
+ 'url': url,
317
+ 'enable_cdg': enable_cdg,
318
+ 'enable_txt': enable_txt,
319
+ }
320
+
321
+ if artist:
322
+ create_request['artist'] = artist
323
+ if title:
324
+ create_request['title'] = title
325
+ if brand_prefix:
326
+ create_request['brand_prefix'] = brand_prefix
327
+ if discord_webhook_url:
328
+ create_request['discord_webhook_url'] = discord_webhook_url
329
+ if youtube_description:
330
+ create_request['youtube_description'] = youtube_description
331
+ if enable_youtube_upload:
332
+ create_request['enable_youtube_upload'] = enable_youtube_upload
333
+ if dropbox_path:
334
+ create_request['dropbox_path'] = dropbox_path
335
+ if gdrive_folder_id:
336
+ create_request['gdrive_folder_id'] = gdrive_folder_id
337
+ if organised_dir_rclone_root:
338
+ create_request['organised_dir_rclone_root'] = organised_dir_rclone_root
339
+ if lyrics_artist:
340
+ create_request['lyrics_artist'] = lyrics_artist
341
+ if lyrics_title:
342
+ create_request['lyrics_title'] = lyrics_title
343
+ if subtitle_offset_ms != 0:
344
+ create_request['subtitle_offset_ms'] = subtitle_offset_ms
345
+ if clean_instrumental_model:
346
+ create_request['clean_instrumental_model'] = clean_instrumental_model
347
+ if backing_vocals_models:
348
+ create_request['backing_vocals_models'] = backing_vocals_models
349
+ if other_stems_models:
350
+ create_request['other_stems_models'] = other_stems_models
351
+ # Two-phase workflow (Batch 6)
352
+ if prep_only:
353
+ create_request['prep_only'] = prep_only
354
+ if keep_brand_code:
355
+ create_request['keep_brand_code'] = keep_brand_code
356
+
357
+ self.logger.info(f"Creating URL-based job at {self.config.service_url}/api/jobs/create-from-url")
358
+
359
+ response = self._request('POST', '/api/jobs/create-from-url', json=create_request)
360
+
361
+ if response.status_code != 200:
362
+ try:
363
+ error_detail = response.json()
364
+ except Exception:
365
+ error_detail = response.text
366
+ raise RuntimeError(f"Error creating job from URL: {error_detail}")
367
+
368
+ result = response.json()
369
+ if result.get('status') != 'success':
370
+ raise RuntimeError(f"Error creating job from URL: {result}")
371
+
372
+ job_id = result['job_id']
373
+ detected_artist = result.get('detected_artist')
374
+ detected_title = result.get('detected_title')
375
+
376
+ self.logger.info(f"Job {job_id} created from URL")
377
+ if detected_artist:
378
+ self.logger.info(f" Artist: {detected_artist}")
379
+ if detected_title:
380
+ self.logger.info(f" Title: {detected_title}")
381
+
382
+ return result
383
+
250
384
  def submit_job(
251
385
  self,
252
386
  filepath: str,
@@ -274,6 +408,9 @@ class RemoteKaraokeClient:
274
408
  other_stems_models: Optional[list] = None,
275
409
  # Existing instrumental (Batch 3)
276
410
  existing_instrumental: Optional[str] = None,
411
+ # Two-phase workflow (Batch 6)
412
+ prep_only: bool = False,
413
+ keep_brand_code: Optional[str] = None,
277
414
  ) -> Dict[str, Any]:
278
415
  """
279
416
  Submit a new karaoke generation job with optional style configuration.
@@ -418,6 +555,11 @@ class RemoteKaraokeClient:
418
555
  create_request['backing_vocals_models'] = backing_vocals_models
419
556
  if other_stems_models:
420
557
  create_request['other_stems_models'] = other_stems_models
558
+ # Two-phase workflow (Batch 6)
559
+ if prep_only:
560
+ create_request['prep_only'] = prep_only
561
+ if keep_brand_code:
562
+ create_request['keep_brand_code'] = keep_brand_code
421
563
 
422
564
  response = self._request('POST', '/api/jobs/create-with-upload-urls', json=create_request)
423
565
 
@@ -505,6 +647,255 @@ class RemoteKaraokeClient:
505
647
 
506
648
  return result
507
649
 
650
+ def submit_finalise_only_job(
651
+ self,
652
+ prep_folder: str,
653
+ artist: str,
654
+ title: str,
655
+ enable_cdg: bool = True,
656
+ enable_txt: bool = True,
657
+ brand_prefix: Optional[str] = None,
658
+ keep_brand_code: Optional[str] = None,
659
+ discord_webhook_url: Optional[str] = None,
660
+ youtube_description: Optional[str] = None,
661
+ enable_youtube_upload: bool = False,
662
+ dropbox_path: Optional[str] = None,
663
+ gdrive_folder_id: Optional[str] = None,
664
+ ) -> Dict[str, Any]:
665
+ """
666
+ Submit a finalise-only job with prep output files.
667
+
668
+ This is used when the user previously ran --prep-only and now wants
669
+ to continue with the finalisation phase using cloud resources.
670
+
671
+ Args:
672
+ prep_folder: Path to the prep output folder containing stems, screens, etc.
673
+ artist: Artist name
674
+ title: Song title
675
+ enable_cdg: Generate CDG+MP3 package
676
+ enable_txt: Generate TXT+MP3 package
677
+ brand_prefix: Brand code prefix (e.g., "NOMAD")
678
+ keep_brand_code: Preserve existing brand code from folder name
679
+ discord_webhook_url: Discord webhook for notifications
680
+ youtube_description: YouTube video description
681
+ enable_youtube_upload: Enable YouTube upload
682
+ dropbox_path: Dropbox folder path for organized output
683
+ gdrive_folder_id: Google Drive folder ID for public share
684
+ """
685
+ prep_path = Path(prep_folder)
686
+
687
+ if not prep_path.exists() or not prep_path.is_dir():
688
+ raise FileNotFoundError(f"Prep folder not found: {prep_folder}")
689
+
690
+ # Detect files in prep folder
691
+ files_info = []
692
+ local_files = {} # file_type -> local_path
693
+
694
+ base_name = f"{artist} - {title}"
695
+
696
+ # Required files - with_vocals video
697
+ for ext in ['.mkv', '.mov', '.mp4']:
698
+ with_vocals_path = prep_path / f"{base_name} (With Vocals){ext}"
699
+ if with_vocals_path.exists():
700
+ files_info.append({
701
+ 'filename': with_vocals_path.name,
702
+ 'content_type': f'video/{ext[1:]}',
703
+ 'file_type': 'with_vocals'
704
+ })
705
+ local_files['with_vocals'] = str(with_vocals_path)
706
+ break
707
+
708
+ if 'with_vocals' not in local_files:
709
+ raise FileNotFoundError(f"with_vocals video not found in {prep_folder}")
710
+
711
+ # Title screen
712
+ for ext in ['.mov', '.mkv', '.mp4']:
713
+ title_path = prep_path / f"{base_name} (Title){ext}"
714
+ if title_path.exists():
715
+ files_info.append({
716
+ 'filename': title_path.name,
717
+ 'content_type': f'video/{ext[1:]}',
718
+ 'file_type': 'title_screen'
719
+ })
720
+ local_files['title_screen'] = str(title_path)
721
+ break
722
+
723
+ if 'title_screen' not in local_files:
724
+ raise FileNotFoundError(f"title_screen video not found in {prep_folder}")
725
+
726
+ # End screen
727
+ for ext in ['.mov', '.mkv', '.mp4']:
728
+ end_path = prep_path / f"{base_name} (End){ext}"
729
+ if end_path.exists():
730
+ files_info.append({
731
+ 'filename': end_path.name,
732
+ 'content_type': f'video/{ext[1:]}',
733
+ 'file_type': 'end_screen'
734
+ })
735
+ local_files['end_screen'] = str(end_path)
736
+ break
737
+
738
+ if 'end_screen' not in local_files:
739
+ raise FileNotFoundError(f"end_screen video not found in {prep_folder}")
740
+
741
+ # Instrumentals (at least one required)
742
+ stems_dir = prep_path / 'stems'
743
+ if stems_dir.exists():
744
+ for stem_file in stems_dir.iterdir():
745
+ if 'Instrumental' in stem_file.name and stem_file.suffix.lower() == '.flac':
746
+ if '+BV' not in stem_file.name:
747
+ if 'instrumental_clean' not in local_files:
748
+ files_info.append({
749
+ 'filename': stem_file.name,
750
+ 'content_type': 'audio/flac',
751
+ 'file_type': 'instrumental_clean'
752
+ })
753
+ local_files['instrumental_clean'] = str(stem_file)
754
+ elif '+BV' in stem_file.name:
755
+ if 'instrumental_backing' not in local_files:
756
+ files_info.append({
757
+ 'filename': stem_file.name,
758
+ 'content_type': 'audio/flac',
759
+ 'file_type': 'instrumental_backing'
760
+ })
761
+ local_files['instrumental_backing'] = str(stem_file)
762
+
763
+ # Also check root for instrumental files
764
+ for stem_file in prep_path.iterdir():
765
+ if 'Instrumental' in stem_file.name and stem_file.suffix.lower() == '.flac':
766
+ if '+BV' not in stem_file.name and 'instrumental_clean' not in local_files:
767
+ files_info.append({
768
+ 'filename': stem_file.name,
769
+ 'content_type': 'audio/flac',
770
+ 'file_type': 'instrumental_clean'
771
+ })
772
+ local_files['instrumental_clean'] = str(stem_file)
773
+ elif '+BV' in stem_file.name and 'instrumental_backing' not in local_files:
774
+ files_info.append({
775
+ 'filename': stem_file.name,
776
+ 'content_type': 'audio/flac',
777
+ 'file_type': 'instrumental_backing'
778
+ })
779
+ local_files['instrumental_backing'] = str(stem_file)
780
+
781
+ if 'instrumental_clean' not in local_files and 'instrumental_backing' not in local_files:
782
+ raise FileNotFoundError(f"No instrumental file found in {prep_folder}")
783
+
784
+ # Optional files - LRC
785
+ lrc_path = prep_path / f"{base_name} (Karaoke).lrc"
786
+ if lrc_path.exists():
787
+ files_info.append({
788
+ 'filename': lrc_path.name,
789
+ 'content_type': 'text/plain',
790
+ 'file_type': 'lrc'
791
+ })
792
+ local_files['lrc'] = str(lrc_path)
793
+
794
+ # Optional - Title/End JPG/PNG
795
+ for img_type, file_type in [('Title', 'title'), ('End', 'end')]:
796
+ for ext in ['.jpg', '.png']:
797
+ img_path = prep_path / f"{base_name} ({img_type}){ext}"
798
+ if img_path.exists():
799
+ files_info.append({
800
+ 'filename': img_path.name,
801
+ 'content_type': f'image/{ext[1:]}',
802
+ 'file_type': f'{file_type}_{ext[1:]}'
803
+ })
804
+ local_files[f'{file_type}_{ext[1:]}'] = str(img_path)
805
+
806
+ self.logger.info(f"Found {len(files_info)} files in prep folder")
807
+ for file_type in local_files:
808
+ self.logger.info(f" {file_type}: {Path(local_files[file_type]).name}")
809
+
810
+ # Create finalise-only job
811
+ create_request = {
812
+ 'artist': artist,
813
+ 'title': title,
814
+ 'files': files_info,
815
+ 'enable_cdg': enable_cdg,
816
+ 'enable_txt': enable_txt,
817
+ }
818
+
819
+ if brand_prefix:
820
+ create_request['brand_prefix'] = brand_prefix
821
+ if keep_brand_code:
822
+ create_request['keep_brand_code'] = keep_brand_code
823
+ if discord_webhook_url:
824
+ create_request['discord_webhook_url'] = discord_webhook_url
825
+ if youtube_description:
826
+ create_request['youtube_description'] = youtube_description
827
+ if enable_youtube_upload:
828
+ create_request['enable_youtube_upload'] = enable_youtube_upload
829
+ if dropbox_path:
830
+ create_request['dropbox_path'] = dropbox_path
831
+ if gdrive_folder_id:
832
+ create_request['gdrive_folder_id'] = gdrive_folder_id
833
+
834
+ self.logger.info(f"Creating finalise-only job at {self.config.service_url}/api/jobs/create-finalise-only")
835
+
836
+ response = self._request('POST', '/api/jobs/create-finalise-only', json=create_request)
837
+
838
+ if response.status_code != 200:
839
+ try:
840
+ error_detail = response.json()
841
+ except Exception:
842
+ error_detail = response.text
843
+ raise RuntimeError(f"Error creating finalise-only job: {error_detail}")
844
+
845
+ create_result = response.json()
846
+ if create_result.get('status') != 'success':
847
+ raise RuntimeError(f"Error creating finalise-only job: {create_result}")
848
+
849
+ job_id = create_result['job_id']
850
+ upload_urls = create_result['upload_urls']
851
+
852
+ self.logger.info(f"Job {job_id} created. Uploading {len(upload_urls)} files directly to storage...")
853
+
854
+ # Upload each file
855
+ uploaded_files = []
856
+ for url_info in upload_urls:
857
+ file_type = url_info['file_type']
858
+ signed_url = url_info['upload_url']
859
+ content_type = url_info['content_type']
860
+ local_path = local_files.get(file_type)
861
+
862
+ if not local_path:
863
+ self.logger.warning(f"No local file found for file_type: {file_type}")
864
+ continue
865
+
866
+ file_size = os.path.getsize(local_path)
867
+ file_size_mb = file_size / (1024 * 1024)
868
+ self.logger.info(f" Uploading {file_type} ({file_size_mb:.1f} MB)...")
869
+
870
+ success = self._upload_file_to_signed_url(signed_url, local_path, content_type)
871
+ if not success:
872
+ raise RuntimeError(f"Failed to upload {file_type} to storage")
873
+
874
+ uploaded_files.append(file_type)
875
+ self.logger.info(f" ✓ Uploaded {file_type}")
876
+
877
+ # Mark uploads complete
878
+ self.logger.info(f"Notifying backend that uploads are complete...")
879
+
880
+ complete_request = {
881
+ 'uploaded_files': uploaded_files
882
+ }
883
+
884
+ response = self._request('POST', f'/api/jobs/{job_id}/finalise-uploads-complete', json=complete_request)
885
+
886
+ if response.status_code != 200:
887
+ try:
888
+ error_detail = response.json()
889
+ except Exception:
890
+ error_detail = response.text
891
+ raise RuntimeError(f"Error completing finalise-only uploads: {error_detail}")
892
+
893
+ result = response.json()
894
+ if result.get('status') != 'success':
895
+ raise RuntimeError(f"Error completing finalise-only uploads: {result}")
896
+
897
+ return result
898
+
508
899
  def get_job(self, job_id: str) -> Dict[str, Any]:
509
900
  """Get job status and details."""
510
901
  response = self._request('GET', f'/api/jobs/{job_id}')
@@ -645,7 +1036,18 @@ class RemoteKaraokeClient:
645
1036
  error_detail = response.text
646
1037
  raise RuntimeError(f"Error getting instrumental options: {error_detail}")
647
1038
  return response.json()
648
-
1039
+
1040
+ def get_instrumental_analysis(self, job_id: str) -> Dict[str, Any]:
1041
+ """Get instrumental analysis data including backing vocals detection."""
1042
+ response = self._request('GET', f'/api/jobs/{job_id}/instrumental-analysis')
1043
+ if response.status_code != 200:
1044
+ try:
1045
+ error_detail = response.json()
1046
+ except Exception:
1047
+ error_detail = response.text
1048
+ raise RuntimeError(f"Error getting instrumental analysis: {error_detail}")
1049
+ return response.json()
1050
+
649
1051
  def select_instrumental(self, job_id: str, selection: str) -> Dict[str, Any]:
650
1052
  """Submit instrumental selection."""
651
1053
  response = self._request(
@@ -757,6 +1159,122 @@ class RemoteKaraokeClient:
757
1159
  error_detail = response.text
758
1160
  raise RuntimeError(f"Error completing review: {error_detail}")
759
1161
  return response.json()
1162
+
1163
+ def search_audio(
1164
+ self,
1165
+ artist: str,
1166
+ title: str,
1167
+ auto_download: bool = False,
1168
+ style_params_path: Optional[str] = None,
1169
+ enable_cdg: bool = True,
1170
+ enable_txt: bool = True,
1171
+ brand_prefix: Optional[str] = None,
1172
+ discord_webhook_url: Optional[str] = None,
1173
+ youtube_description: Optional[str] = None,
1174
+ enable_youtube_upload: bool = False,
1175
+ dropbox_path: Optional[str] = None,
1176
+ gdrive_folder_id: Optional[str] = None,
1177
+ lyrics_artist: Optional[str] = None,
1178
+ lyrics_title: Optional[str] = None,
1179
+ subtitle_offset_ms: int = 0,
1180
+ clean_instrumental_model: Optional[str] = None,
1181
+ backing_vocals_models: Optional[list] = None,
1182
+ other_stems_models: Optional[list] = None,
1183
+ ) -> Dict[str, Any]:
1184
+ """
1185
+ Search for audio by artist and title (Batch 5 - Flacfetch integration).
1186
+
1187
+ This creates a job and searches for audio sources. If auto_download is True,
1188
+ it automatically selects the best source. Otherwise, it returns search results
1189
+ for user selection.
1190
+
1191
+ Args:
1192
+ artist: Artist name to search for
1193
+ title: Song title to search for
1194
+ auto_download: Automatically select best audio source (skip interactive selection)
1195
+ ... other args same as submit_job()
1196
+
1197
+ Returns:
1198
+ Dict with job_id, status, and optionally search results
1199
+ """
1200
+ self.logger.info(f"Searching for audio: {artist} - {title}")
1201
+
1202
+ request_data = {
1203
+ 'artist': artist,
1204
+ 'title': title,
1205
+ 'auto_download': auto_download,
1206
+ 'enable_cdg': enable_cdg,
1207
+ 'enable_txt': enable_txt,
1208
+ }
1209
+
1210
+ if brand_prefix:
1211
+ request_data['brand_prefix'] = brand_prefix
1212
+ if discord_webhook_url:
1213
+ request_data['discord_webhook_url'] = discord_webhook_url
1214
+ if youtube_description:
1215
+ request_data['youtube_description'] = youtube_description
1216
+ if enable_youtube_upload:
1217
+ request_data['enable_youtube_upload'] = enable_youtube_upload
1218
+ if dropbox_path:
1219
+ request_data['dropbox_path'] = dropbox_path
1220
+ if gdrive_folder_id:
1221
+ request_data['gdrive_folder_id'] = gdrive_folder_id
1222
+ if lyrics_artist:
1223
+ request_data['lyrics_artist'] = lyrics_artist
1224
+ if lyrics_title:
1225
+ request_data['lyrics_title'] = lyrics_title
1226
+ if subtitle_offset_ms != 0:
1227
+ request_data['subtitle_offset_ms'] = subtitle_offset_ms
1228
+ if clean_instrumental_model:
1229
+ request_data['clean_instrumental_model'] = clean_instrumental_model
1230
+ if backing_vocals_models:
1231
+ request_data['backing_vocals_models'] = backing_vocals_models
1232
+ if other_stems_models:
1233
+ request_data['other_stems_models'] = other_stems_models
1234
+
1235
+ response = self._request('POST', '/api/audio-search/search', json=request_data)
1236
+
1237
+ if response.status_code == 404:
1238
+ try:
1239
+ error_detail = response.json()
1240
+ except Exception:
1241
+ error_detail = response.text
1242
+ raise ValueError(f"No audio sources found: {error_detail}")
1243
+
1244
+ if response.status_code != 200:
1245
+ try:
1246
+ error_detail = response.json()
1247
+ except Exception:
1248
+ error_detail = response.text
1249
+ raise RuntimeError(f"Error searching for audio: {error_detail}")
1250
+
1251
+ return response.json()
1252
+
1253
+ def get_audio_search_results(self, job_id: str) -> Dict[str, Any]:
1254
+ """Get audio search results for a job awaiting selection."""
1255
+ response = self._request('GET', f'/api/audio-search/{job_id}/results')
1256
+ if response.status_code != 200:
1257
+ try:
1258
+ error_detail = response.json()
1259
+ except Exception:
1260
+ error_detail = response.text
1261
+ raise RuntimeError(f"Error getting search results: {error_detail}")
1262
+ return response.json()
1263
+
1264
+ def select_audio_source(self, job_id: str, selection_index: int) -> Dict[str, Any]:
1265
+ """Select an audio source and start processing."""
1266
+ response = self._request(
1267
+ 'POST',
1268
+ f'/api/audio-search/{job_id}/select',
1269
+ json={'selection_index': selection_index}
1270
+ )
1271
+ if response.status_code != 200:
1272
+ try:
1273
+ error_detail = response.json()
1274
+ except Exception:
1275
+ error_detail = response.text
1276
+ raise RuntimeError(f"Error selecting audio: {error_detail}")
1277
+ return response.json()
760
1278
 
761
1279
 
762
1280
  class JobMonitor:
@@ -768,6 +1286,7 @@ class JobMonitor:
768
1286
  self.logger = logger
769
1287
  self._review_opened = False
770
1288
  self._instrumental_prompted = False
1289
+ self._audio_selection_prompted = False # Batch 5: audio source selection
771
1290
  self._last_timeline_index = 0
772
1291
  self._last_log_index = 0
773
1292
  self._show_worker_logs = True # Enable worker log display
@@ -777,6 +1296,11 @@ class JobMonitor:
777
1296
  # Status descriptions for user-friendly logging
778
1297
  STATUS_DESCRIPTIONS = {
779
1298
  'pending': 'Job queued, waiting to start',
1299
+ # Audio search states (Batch 5)
1300
+ 'searching_audio': 'Searching for audio sources',
1301
+ 'awaiting_audio_selection': 'Waiting for audio source selection',
1302
+ 'downloading_audio': 'Downloading selected audio',
1303
+ # Main workflow
780
1304
  'downloading': 'Downloading and preparing input files',
781
1305
  'separating_stage1': 'AI audio separation (stage 1 of 2)',
782
1306
  'separating_stage2': 'AI audio separation (stage 2 of 2)',
@@ -798,6 +1322,7 @@ class JobMonitor:
798
1322
  'uploading': 'Uploading to distribution services',
799
1323
  'notifying': 'Sending notifications',
800
1324
  'complete': 'All processing complete',
1325
+ 'prep_complete': 'Prep phase complete - ready for local finalisation',
801
1326
  'failed': 'Job failed',
802
1327
  'cancelled': 'Job cancelled',
803
1328
  }
@@ -806,6 +1331,54 @@ class JobMonitor:
806
1331
  """Get user-friendly description for a status."""
807
1332
  return self.STATUS_DESCRIPTIONS.get(status, status)
808
1333
 
1334
+ def _show_download_progress(self, job_data: Dict[str, Any]) -> None:
1335
+ """Show detailed download progress during audio download."""
1336
+ try:
1337
+ # Get provider from job state_data
1338
+ state_data = job_data.get('state_data', {})
1339
+ provider = state_data.get('selected_audio_provider', 'unknown')
1340
+
1341
+ # For non-torrent providers (YouTube), just show simple message
1342
+ if provider.lower() == 'youtube':
1343
+ self.logger.info(f" [Downloading from YouTube...]")
1344
+ return
1345
+
1346
+ # Query health endpoint for transmission status (torrent providers)
1347
+ health_url = f"{self.config.service_url}/api/health/detailed"
1348
+ response = requests.get(health_url, timeout=5)
1349
+
1350
+ if response.status_code == 200:
1351
+ health = response.json()
1352
+ transmission = health.get('dependencies', {}).get('transmission', {})
1353
+
1354
+ if transmission.get('available'):
1355
+ torrents = transmission.get('torrents', [])
1356
+ if torrents:
1357
+ # Show info about active torrents
1358
+ for t in torrents:
1359
+ progress = t.get('progress', 0)
1360
+ peers = t.get('peers', 0)
1361
+ speed = t.get('download_speed', 0)
1362
+ stalled = t.get('stalled', False)
1363
+
1364
+ if stalled:
1365
+ self.logger.info(f" [Downloading from {provider}] {progress:.1f}% - STALLED (no peers)")
1366
+ elif progress < 100:
1367
+ self.logger.info(f" [Downloading from {provider}] {progress:.1f}% @ {speed:.1f} KB/s ({peers} peers)")
1368
+ else:
1369
+ self.logger.info(f" [Downloading from {provider}] Complete, processing...")
1370
+ else:
1371
+ # No torrents - might be starting or YouTube download
1372
+ self.logger.info(f" [Downloading from {provider}] Starting download...")
1373
+ else:
1374
+ self.logger.info(f" [Downloading from {provider}] Transmission not available - download may fail")
1375
+ else:
1376
+ self.logger.info(f" [Downloading from {provider}]...")
1377
+
1378
+ except Exception as e:
1379
+ # Fall back to simple message
1380
+ self.logger.info(f" [Downloading audio...]")
1381
+
809
1382
  def open_browser(self, url: str) -> None:
810
1383
  """Open URL in the default browser."""
811
1384
  system = platform.system()
@@ -891,39 +1464,97 @@ class JobMonitor:
891
1464
  time.sleep(self.config.poll_interval)
892
1465
 
893
1466
  def handle_instrumental_selection(self, job_id: str) -> None:
894
- """Handle instrumental selection interaction."""
1467
+ """Handle instrumental selection interaction with analysis-based recommendations."""
895
1468
  self.logger.info("=" * 60)
896
1469
  self.logger.info("INSTRUMENTAL SELECTION NEEDED")
897
1470
  self.logger.info("=" * 60)
898
1471
 
899
- # In non-interactive mode, auto-select clean instrumental
900
- if self.config.non_interactive:
901
- self.logger.info("Non-interactive mode: Auto-selecting clean instrumental")
902
- selection = 'clean'
903
- else:
904
- self.logger.info("")
905
- self.logger.info("Choose which instrumental track to use for the final video:")
906
- self.logger.info("")
907
- self.logger.info(" 1) Clean Instrumental (no backing vocals)")
908
- self.logger.info(" Best for songs where you want ONLY the lead vocal removed")
1472
+ # Try to get analysis data for smart recommendations
1473
+ analysis_data = None
1474
+ try:
1475
+ analysis_data = self.client.get_instrumental_analysis(job_id)
1476
+ analysis = analysis_data.get('analysis', {})
1477
+
1478
+ # Display analysis summary
909
1479
  self.logger.info("")
910
- self.logger.info(" 2) Instrumental with Backing Vocals")
911
- self.logger.info(" Best for songs where backing vocals add to the karaoke experience")
1480
+ self.logger.info("=== Backing Vocals Analysis ===")
1481
+ if analysis.get('has_audible_content'):
1482
+ self.logger.info(f" Backing vocals detected: YES")
1483
+ self.logger.info(f" Audible segments: {len(analysis.get('audible_segments', []))}")
1484
+ self.logger.info(f" Audible duration: {analysis.get('total_audible_duration_seconds', 0):.1f}s "
1485
+ f"({analysis.get('audible_percentage', 0):.1f}% of track)")
1486
+ else:
1487
+ self.logger.info(f" Backing vocals detected: NO")
1488
+ self.logger.info(f" Recommendation: {analysis.get('recommended_selection', 'review_needed')}")
912
1489
  self.logger.info("")
1490
+ except Exception as e:
1491
+ self.logger.warning(f"Could not fetch analysis data: {e}")
1492
+ self.logger.info("Falling back to manual selection...")
1493
+
1494
+ # In non-interactive mode, use analysis recommendation or default to clean
1495
+ if self.config.non_interactive:
1496
+ if analysis_data and analysis_data.get('analysis', {}).get('recommended_selection') == 'clean':
1497
+ self.logger.info("Non-interactive mode: Auto-selecting clean instrumental (recommended)")
1498
+ selection = 'clean'
1499
+ else:
1500
+ self.logger.info("Non-interactive mode: Auto-selecting clean instrumental (default)")
1501
+ selection = 'clean'
1502
+ else:
1503
+ # Check if we should recommend clean based on analysis
1504
+ recommend_clean = (
1505
+ analysis_data and
1506
+ not analysis_data.get('analysis', {}).get('has_audible_content', True)
1507
+ )
913
1508
 
914
- selection = ""
915
- while not selection:
1509
+ if recommend_clean:
1510
+ self.logger.info("No backing vocals detected - recommending clean instrumental.")
1511
+ self.logger.info("")
1512
+ self.logger.info("Options:")
1513
+ self.logger.info(" 1) Accept recommendation (clean instrumental)")
1514
+ self.logger.info(" 2) Open browser to review and select")
1515
+ self.logger.info("")
1516
+
916
1517
  try:
917
1518
  choice = input("Enter your choice (1 or 2): ").strip()
918
1519
  if choice == '1':
919
1520
  selection = 'clean'
920
- elif choice == '2':
921
- selection = 'with_backing'
922
1521
  else:
923
- self.logger.error("Invalid choice. Please enter 1 or 2.")
1522
+ self._open_instrumental_review_and_wait(job_id)
1523
+ return # Selection will be submitted via browser
924
1524
  except KeyboardInterrupt:
925
1525
  print()
926
1526
  raise
1527
+ else:
1528
+ # Backing vocals detected or analysis unavailable - offer browser review
1529
+ self.logger.info("Choose how to select your instrumental:")
1530
+ self.logger.info("")
1531
+ self.logger.info(" 1) Clean Instrumental (no backing vocals)")
1532
+ self.logger.info(" Best for songs where you want ONLY the lead vocal removed")
1533
+ self.logger.info("")
1534
+ self.logger.info(" 2) Instrumental with Backing Vocals")
1535
+ self.logger.info(" Best for songs where backing vocals add to the karaoke experience")
1536
+ self.logger.info("")
1537
+ self.logger.info(" 3) Open Browser for Advanced Review")
1538
+ self.logger.info(" Listen to audio, view waveform, and optionally mute sections")
1539
+ self.logger.info(" to create a custom instrumental")
1540
+ self.logger.info("")
1541
+
1542
+ selection = ""
1543
+ while not selection:
1544
+ try:
1545
+ choice = input("Enter your choice (1, 2, or 3): ").strip()
1546
+ if choice == '1':
1547
+ selection = 'clean'
1548
+ elif choice == '2':
1549
+ selection = 'with_backing'
1550
+ elif choice == '3':
1551
+ self._open_instrumental_review_and_wait(job_id)
1552
+ return # Selection will be submitted via browser
1553
+ else:
1554
+ self.logger.error("Invalid choice. Please enter 1, 2, or 3.")
1555
+ except KeyboardInterrupt:
1556
+ print()
1557
+ raise
927
1558
 
928
1559
  self.logger.info(f"Submitting selection: {selection}")
929
1560
 
@@ -936,6 +1567,153 @@ class JobMonitor:
936
1567
  except Exception as e:
937
1568
  self.logger.error(f"Error submitting selection: {e}")
938
1569
 
1570
+ def _convert_api_result_to_release_dict(self, result: dict) -> dict:
1571
+ """
1572
+ Convert API search result to a dict compatible with flacfetch's Release.from_dict().
1573
+
1574
+ This enables using flacfetch's shared display functions for consistent,
1575
+ rich formatting between local and remote CLIs.
1576
+ """
1577
+ # Build quality dict from API response
1578
+ quality_data = result.get('quality_data') or {
1579
+ "format": "OTHER",
1580
+ "media": "OTHER",
1581
+ }
1582
+
1583
+ return {
1584
+ "title": result.get('title', ''),
1585
+ "artist": result.get('artist', ''),
1586
+ "source_name": result.get('provider', 'Unknown'),
1587
+ "download_url": result.get('url'),
1588
+ "info_hash": result.get('source_id'),
1589
+ "size_bytes": result.get('size_bytes'),
1590
+ "year": result.get('year'),
1591
+ "edition_info": result.get('edition_info'),
1592
+ "label": result.get('label'),
1593
+ "release_type": result.get('release_type'),
1594
+ "seeders": result.get('seeders'),
1595
+ "channel": result.get('channel'),
1596
+ "view_count": result.get('view_count'),
1597
+ "duration_seconds": result.get('duration'),
1598
+ "target_file": result.get('target_file'),
1599
+ "target_file_size": result.get('target_file_size'),
1600
+ "track_pattern": result.get('track_pattern'),
1601
+ "match_score": result.get('match_score', 0.0),
1602
+ "quality": quality_data,
1603
+ # Pre-computed fields
1604
+ "formatted_size": result.get('formatted_size'),
1605
+ "formatted_duration": result.get('formatted_duration'),
1606
+ "formatted_views": result.get('formatted_views'),
1607
+ "is_lossless": result.get('is_lossless', False),
1608
+ "quality_str": result.get('quality_str') or result.get('quality', ''),
1609
+ }
1610
+
1611
+ def handle_audio_selection(self, job_id: str) -> None:
1612
+ """Handle audio source selection interaction (Batch 5)."""
1613
+ self.logger.info("=" * 60)
1614
+ self.logger.info("AUDIO SOURCE SELECTION NEEDED")
1615
+ self.logger.info("=" * 60)
1616
+
1617
+ try:
1618
+ # Get search results
1619
+ results_data = self.client.get_audio_search_results(job_id)
1620
+ results = results_data.get('results', [])
1621
+ artist = results_data.get('artist', 'Unknown')
1622
+
1623
+ if not results:
1624
+ self.logger.error("No search results available")
1625
+ return
1626
+
1627
+ # In non-interactive mode, auto-select first result
1628
+ if self.config.non_interactive:
1629
+ self.logger.info("Non-interactive mode: Auto-selecting first result")
1630
+ selection_index = 0
1631
+ else:
1632
+ # Convert API results to Release-compatible dicts for flacfetch display
1633
+ # This gives us the same rich, colorized output as the local CLI
1634
+ release_dicts = [self._convert_api_result_to_release_dict(r) for r in results]
1635
+
1636
+ # Use flacfetch's shared display function
1637
+ print_releases(release_dicts, target_artist=artist, use_colors=True)
1638
+
1639
+ selection_index = -1
1640
+ while selection_index < 0:
1641
+ try:
1642
+ choice = input(f"\nSelect a release (1-{len(results)}, 0 to cancel): ").strip()
1643
+ if choice == "0":
1644
+ self.logger.info("Selection cancelled by user")
1645
+ raise KeyboardInterrupt
1646
+ choice_num = int(choice)
1647
+ if 1 <= choice_num <= len(results):
1648
+ selection_index = choice_num - 1
1649
+ else:
1650
+ print(f"Please enter a number between 0 and {len(results)}")
1651
+ except ValueError:
1652
+ print("Please enter a valid number")
1653
+ except KeyboardInterrupt:
1654
+ print()
1655
+ raise
1656
+
1657
+ selected = results[selection_index]
1658
+ self.logger.info(f"Selected: [{selected.get('provider')}] {selected.get('artist')} - {selected.get('title')}")
1659
+ self.logger.info("")
1660
+
1661
+ # Submit selection
1662
+ result = self.client.select_audio_source(job_id, selection_index)
1663
+ if result.get('status') == 'success':
1664
+ self.logger.info(f"Selection submitted successfully")
1665
+ else:
1666
+ self.logger.error(f"Error submitting selection: {result}")
1667
+
1668
+ except Exception as e:
1669
+ self.logger.error(f"Error handling audio selection: {e}")
1670
+
1671
+ def _open_instrumental_review_and_wait(self, job_id: str) -> None:
1672
+ """Open browser to instrumental review UI and wait for selection."""
1673
+ review_url = f"{self.config.review_ui_url}/jobs/{job_id}/instrumental-review"
1674
+
1675
+ self.logger.info("")
1676
+ self.logger.info("=" * 60)
1677
+ self.logger.info("OPENING BROWSER FOR INSTRUMENTAL REVIEW")
1678
+ self.logger.info("=" * 60)
1679
+ self.logger.info(f"Review URL: {review_url}")
1680
+ self.logger.info("")
1681
+ self.logger.info("In the browser you can:")
1682
+ self.logger.info(" - View the backing vocals waveform")
1683
+ self.logger.info(" - Listen to clean instrumental, backing vocals, or combined")
1684
+ self.logger.info(" - Select regions to mute and create a custom instrumental")
1685
+ self.logger.info(" - Submit your final selection")
1686
+ self.logger.info("")
1687
+ self.logger.info("Waiting for selection to be submitted...")
1688
+ self.logger.info("(Press Ctrl+C to cancel)")
1689
+ self.logger.info("")
1690
+
1691
+ # Open browser
1692
+ webbrowser.open(review_url)
1693
+
1694
+ # Poll until job status changes from awaiting_instrumental_selection
1695
+ while True:
1696
+ try:
1697
+ job_data = self.client.get_job(job_id)
1698
+ current_status = job_data.get('status')
1699
+
1700
+ if current_status != 'awaiting_instrumental_selection':
1701
+ selection = job_data.get('state_data', {}).get('instrumental_selection', 'unknown')
1702
+ self.logger.info(f"Selection received: {selection}")
1703
+ self.logger.info(f"Job status: {current_status}")
1704
+ return
1705
+
1706
+ time.sleep(self.config.poll_interval)
1707
+
1708
+ except KeyboardInterrupt:
1709
+ print()
1710
+ self.logger.info("Cancelled. You can resume this job later with --resume")
1711
+ raise
1712
+ except Exception as e:
1713
+ self.logger.warning(f"Error checking status: {e}")
1714
+ time.sleep(self.config.poll_interval)
1715
+
1716
+
939
1717
  def download_outputs(self, job_id: str, job_data: Dict[str, Any]) -> None:
940
1718
  """
941
1719
  Download all output files for a completed job.
@@ -1337,13 +2115,26 @@ class JobMonitor:
1337
2115
  self._polls_without_updates = 0
1338
2116
  else:
1339
2117
  self._polls_without_updates += 1
1340
- if self._polls_without_updates >= self._heartbeat_interval:
1341
- description = self._get_status_description(status)
1342
- self.logger.info(f" [Still processing: {description}]")
2118
+ # More frequent updates during audio download (every poll)
2119
+ heartbeat_threshold = 1 if status == 'downloading_audio' else self._heartbeat_interval
2120
+ if self._polls_without_updates >= heartbeat_threshold:
2121
+ if status == 'downloading_audio':
2122
+ # Show detailed download progress including transmission status
2123
+ self._show_download_progress(job_data)
2124
+ else:
2125
+ description = self._get_status_description(status)
2126
+ self.logger.info(f" [Still processing: {description}]")
1343
2127
  self._polls_without_updates = 0
1344
2128
 
1345
2129
  # Handle human interaction points
1346
- if status in ['awaiting_review', 'in_review']:
2130
+ if status == 'awaiting_audio_selection':
2131
+ if not self._audio_selection_prompted:
2132
+ self.logger.info("")
2133
+ self.handle_audio_selection(job_id)
2134
+ self._audio_selection_prompted = True
2135
+ self._last_timeline_index = 0 # Reset to catch any events
2136
+
2137
+ elif status in ['awaiting_review', 'in_review']:
1347
2138
  if not self._review_opened:
1348
2139
  self.logger.info("")
1349
2140
  self.handle_review(job_id)
@@ -1368,6 +2159,24 @@ class JobMonitor:
1368
2159
  self.download_outputs(job_id, job_data)
1369
2160
  return 0
1370
2161
 
2162
+ elif status == 'prep_complete':
2163
+ self.logger.info("")
2164
+ self.logger.info("=" * 60)
2165
+ self.logger.info("PREP PHASE COMPLETE!")
2166
+ self.logger.info("=" * 60)
2167
+ self.logger.info(f"Track: {artist} - {title}")
2168
+ self.logger.info("")
2169
+ self.logger.info("Downloading all prep outputs...")
2170
+ self.download_outputs(job_id, job_data)
2171
+ self.logger.info("")
2172
+ self.logger.info("To continue with finalisation, run:")
2173
+ # Use shlex.quote for proper shell escaping of artist/title
2174
+ import shlex
2175
+ escaped_artist = shlex.quote(artist)
2176
+ escaped_title = shlex.quote(title)
2177
+ self.logger.info(f" karaoke-gen-remote --finalise-only ./<output_folder> {escaped_artist} {escaped_title}")
2178
+ return 0
2179
+
1371
2180
  elif status in ['failed', 'error']:
1372
2181
  self.logger.info("")
1373
2182
  self.logger.error("=" * 60)
@@ -1756,10 +2565,112 @@ def main():
1756
2565
  logger.error(f"Error deleting job: {e}")
1757
2566
  return 1
1758
2567
 
1759
- # Warn about unsupported features
2568
+ # Handle finalise-only mode (Batch 6)
1760
2569
  if args.finalise_only:
1761
- logger.error("--finalise-only is not supported in remote mode")
1762
- return 1
2570
+ logger.info("=" * 60)
2571
+ logger.info("Karaoke Generator (Remote) - Finalise Only Mode")
2572
+ logger.info("=" * 60)
2573
+
2574
+ # For finalise-only, we expect the current directory to be the prep output folder
2575
+ # OR a folder path as the first argument
2576
+ prep_folder = "."
2577
+ artist_arg_idx = 0
2578
+
2579
+ if args.args:
2580
+ # Check if first argument is a directory
2581
+ if os.path.isdir(args.args[0]):
2582
+ prep_folder = args.args[0]
2583
+ artist_arg_idx = 1
2584
+
2585
+ # Get artist and title from arguments
2586
+ if len(args.args) > artist_arg_idx + 1:
2587
+ artist = args.args[artist_arg_idx]
2588
+ title = args.args[artist_arg_idx + 1]
2589
+ elif len(args.args) > artist_arg_idx:
2590
+ logger.error("Finalise-only mode requires both Artist and Title")
2591
+ return 1
2592
+ else:
2593
+ # Try to extract from folder name
2594
+ folder_name = os.path.basename(os.path.abspath(prep_folder))
2595
+ parts = folder_name.split(" - ", 2)
2596
+ if len(parts) >= 2:
2597
+ # Format: "BRAND-XXXX - Artist - Title" or "Artist - Title"
2598
+ if "-" in parts[0] and parts[0].split("-")[1].isdigit():
2599
+ # Has brand code
2600
+ artist = parts[1] if len(parts) > 2 else "Unknown"
2601
+ title = parts[2] if len(parts) > 2 else parts[1]
2602
+ else:
2603
+ artist = parts[0]
2604
+ title = parts[1]
2605
+ logger.info(f"Extracted from folder name: {artist} - {title}")
2606
+ else:
2607
+ logger.error("Could not extract Artist and Title from folder name")
2608
+ logger.error("Please provide: karaoke-gen-remote --finalise-only <folder> \"Artist\" \"Title\"")
2609
+ return 1
2610
+ else:
2611
+ logger.error("Finalise-only mode requires folder path and/or Artist and Title")
2612
+ return 1
2613
+
2614
+ # Extract brand code from folder name if --keep-brand-code is set
2615
+ keep_brand_code = None
2616
+ if getattr(args, 'keep_brand_code', False):
2617
+ folder_name = os.path.basename(os.path.abspath(prep_folder))
2618
+ parts = folder_name.split(" - ", 1)
2619
+ if parts and "-" in parts[0]:
2620
+ # Check if it's a brand code format (e.g., "NOMAD-1234")
2621
+ potential_brand = parts[0]
2622
+ brand_parts = potential_brand.split("-")
2623
+ if len(brand_parts) == 2 and brand_parts[1].isdigit():
2624
+ keep_brand_code = potential_brand
2625
+ logger.info(f"Preserving brand code: {keep_brand_code}")
2626
+
2627
+ logger.info(f"Prep folder: {os.path.abspath(prep_folder)}")
2628
+ logger.info(f"Artist: {artist}")
2629
+ logger.info(f"Title: {title}")
2630
+ if keep_brand_code:
2631
+ logger.info(f"Brand Code: {keep_brand_code} (preserved)")
2632
+ logger.info("")
2633
+
2634
+ # Read youtube description from file if provided
2635
+ youtube_description = None
2636
+ if args.youtube_description_file and os.path.isfile(args.youtube_description_file):
2637
+ try:
2638
+ with open(args.youtube_description_file, 'r') as f:
2639
+ youtube_description = f.read()
2640
+ except Exception as e:
2641
+ logger.warning(f"Failed to read YouTube description file: {e}")
2642
+
2643
+ try:
2644
+ result = client.submit_finalise_only_job(
2645
+ prep_folder=prep_folder,
2646
+ artist=artist,
2647
+ title=title,
2648
+ enable_cdg=args.enable_cdg,
2649
+ enable_txt=args.enable_txt,
2650
+ brand_prefix=args.brand_prefix,
2651
+ keep_brand_code=keep_brand_code,
2652
+ discord_webhook_url=args.discord_webhook_url,
2653
+ youtube_description=youtube_description,
2654
+ enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
2655
+ dropbox_path=getattr(args, 'dropbox_path', None),
2656
+ gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
2657
+ )
2658
+ job_id = result.get('job_id')
2659
+ logger.info(f"Finalise-only job submitted: {job_id}")
2660
+ logger.info("")
2661
+
2662
+ # Monitor job
2663
+ return monitor.monitor(job_id)
2664
+
2665
+ except FileNotFoundError as e:
2666
+ logger.error(str(e))
2667
+ return 1
2668
+ except RuntimeError as e:
2669
+ logger.error(str(e))
2670
+ return 1
2671
+ except Exception as e:
2672
+ logger.error(f"Error: {e}")
2673
+ return 1
1763
2674
 
1764
2675
  if args.edit_lyrics:
1765
2676
  logger.error("--edit-lyrics is not yet supported in remote mode")
@@ -1771,8 +2682,7 @@ def main():
1771
2682
 
1772
2683
  # Warn about features that are not yet supported in remote mode
1773
2684
  ignored_features = []
1774
- if args.prep_only:
1775
- ignored_features.append("--prep-only")
2685
+ # Note: --prep-only is now supported in remote mode (Batch 6)
1776
2686
  if args.skip_separation:
1777
2687
  ignored_features.append("--skip-separation")
1778
2688
  if args.skip_transcription:
@@ -1781,8 +2691,7 @@ def main():
1781
2691
  ignored_features.append("--lyrics-only")
1782
2692
  if args.background_video:
1783
2693
  ignored_features.append("--background_video")
1784
- if getattr(args, 'auto_download', False):
1785
- ignored_features.append("--auto-download (audio search not yet supported)")
2694
+ # --auto-download is now supported (Batch 5)
1786
2695
  # These are now supported but server-side handling may be partial
1787
2696
  if args.organised_dir:
1788
2697
  ignored_features.append("--organised_dir (local-only)")
@@ -1803,6 +2712,8 @@ def main():
1803
2712
 
1804
2713
  # Handle new job submission - parse input arguments same as gen_cli
1805
2714
  input_media, artist, title, filename_pattern = None, None, None, None
2715
+ use_audio_search = False # Batch 5: audio search mode
2716
+ is_url_input = False
1806
2717
 
1807
2718
  if not args.args:
1808
2719
  parser.print_help()
@@ -1810,52 +2721,137 @@ def main():
1810
2721
 
1811
2722
  # Allow 3 forms of positional arguments:
1812
2723
  # 1. URL or Media File only
1813
- # 2. Artist and Title only
1814
- # 3. URL, Artist, and Title
2724
+ # 2. Artist and Title only (audio search mode - Batch 5)
2725
+ # 3. URL/File, Artist, and Title
1815
2726
  if args.args and (is_url(args.args[0]) or is_file(args.args[0])):
1816
2727
  input_media = args.args[0]
2728
+ is_url_input = is_url(args.args[0])
1817
2729
  if len(args.args) > 2:
1818
2730
  artist = args.args[1]
1819
2731
  title = args.args[2]
1820
2732
  elif len(args.args) > 1:
1821
2733
  artist = args.args[1]
1822
2734
  else:
1823
- logger.error("Input media provided without Artist and Title")
1824
- return 1
2735
+ # For URLs, artist/title can be auto-detected
2736
+ if is_url_input:
2737
+ logger.info("URL provided without Artist and Title - will be auto-detected from video metadata")
2738
+ else:
2739
+ logger.error("Input media provided without Artist and Title")
2740
+ return 1
1825
2741
  elif os.path.isdir(args.args[0]):
1826
2742
  logger.error("Folder processing is not yet supported in remote mode")
1827
2743
  return 1
1828
2744
  elif len(args.args) > 1:
2745
+ # Audio search mode: artist + title without file (Batch 5)
1829
2746
  artist = args.args[0]
1830
2747
  title = args.args[1]
1831
- logger.error("Audio search (artist+title) is not yet supported in remote mode.")
1832
- logger.error("Please provide a local audio file path instead.")
1833
- logger.error("")
1834
- logger.error("For local flacfetch search, use karaoke-gen instead:")
1835
- logger.error(f" karaoke-gen \"{artist}\" \"{title}\"")
1836
- return 1
2748
+ use_audio_search = True
1837
2749
  else:
1838
2750
  parser.print_help()
1839
2751
  return 1
1840
2752
 
1841
- # For now, remote mode only supports file uploads
1842
- if not input_media or not os.path.isfile(input_media):
1843
- logger.error("Remote mode currently only supports local file uploads")
1844
- logger.error("Please provide a path to an audio file (mp3, wav, flac, m4a, ogg, aac)")
1845
- return 1
1846
-
1847
2753
  # Validate artist and title are provided
1848
2754
  if not artist or not title:
1849
2755
  logger.error("Artist and Title are required")
1850
2756
  parser.print_help()
1851
2757
  return 1
1852
2758
 
2759
+ # For file/URL input modes, validate input exists
2760
+ if not use_audio_search:
2761
+ if not input_media:
2762
+ logger.error("No input media or URL provided")
2763
+ return 1
2764
+
2765
+ # For file input (not URL), validate file exists
2766
+ if not is_url_input and not os.path.isfile(input_media):
2767
+ logger.error(f"File not found: {input_media}")
2768
+ logger.error("Please provide a valid path to an audio file (mp3, wav, flac, m4a, ogg, aac)")
2769
+ return 1
2770
+
2771
+ # Handle audio search mode (Batch 5)
2772
+ if use_audio_search:
2773
+ logger.info("=" * 60)
2774
+ logger.info("Karaoke Generator (Remote) - Audio Search Mode")
2775
+ logger.info("=" * 60)
2776
+ logger.info(f"Searching for: {artist} - {title}")
2777
+ if getattr(args, 'auto_download', False) or config.non_interactive:
2778
+ logger.info(f"Auto-download: enabled (will auto-select best source)")
2779
+ if args.style_params_json:
2780
+ logger.info(f"Style: {args.style_params_json}")
2781
+ logger.info(f"CDG: {args.enable_cdg}, TXT: {args.enable_txt}")
2782
+ if args.brand_prefix:
2783
+ logger.info(f"Brand: {args.brand_prefix}")
2784
+ logger.info(f"Service URL: {config.service_url}")
2785
+ logger.info("")
2786
+
2787
+ # Read youtube description from file if provided
2788
+ youtube_description = None
2789
+ if args.youtube_description_file and os.path.isfile(args.youtube_description_file):
2790
+ try:
2791
+ with open(args.youtube_description_file, 'r') as f:
2792
+ youtube_description = f.read()
2793
+ logger.info(f"Loaded YouTube description from: {args.youtube_description_file}")
2794
+ except Exception as e:
2795
+ logger.warning(f"Failed to read YouTube description file: {e}")
2796
+
2797
+ try:
2798
+ # Determine auto_download mode
2799
+ auto_download = getattr(args, 'auto_download', False) or config.non_interactive
2800
+
2801
+ result = client.search_audio(
2802
+ artist=artist,
2803
+ title=title,
2804
+ auto_download=auto_download,
2805
+ enable_cdg=args.enable_cdg,
2806
+ enable_txt=args.enable_txt,
2807
+ brand_prefix=args.brand_prefix,
2808
+ discord_webhook_url=args.discord_webhook_url,
2809
+ youtube_description=youtube_description,
2810
+ enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
2811
+ dropbox_path=getattr(args, 'dropbox_path', None),
2812
+ gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
2813
+ lyrics_artist=getattr(args, 'lyrics_artist', None),
2814
+ lyrics_title=getattr(args, 'lyrics_title', None),
2815
+ subtitle_offset_ms=getattr(args, 'subtitle_offset_ms', 0) or 0,
2816
+ clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
2817
+ backing_vocals_models=getattr(args, 'backing_vocals_models', None),
2818
+ other_stems_models=getattr(args, 'other_stems_models', None),
2819
+ )
2820
+
2821
+ job_id = result.get('job_id')
2822
+ results_count = result.get('results_count', 0)
2823
+ server_version = result.get('server_version', 'unknown')
2824
+
2825
+ logger.info(f"Job created: {job_id}")
2826
+ logger.info(f"Server version: {server_version}")
2827
+ logger.info(f"Audio sources found: {results_count}")
2828
+ logger.info("")
2829
+
2830
+ # Monitor job
2831
+ return monitor.monitor(job_id)
2832
+
2833
+ except ValueError as e:
2834
+ logger.error(str(e))
2835
+ return 1
2836
+ except Exception as e:
2837
+ logger.error(f"Error: {e}")
2838
+ logger.exception("Full error details:")
2839
+ return 1
2840
+
2841
+ # File upload mode (original flow)
1853
2842
  logger.info("=" * 60)
1854
2843
  logger.info("Karaoke Generator (Remote) - Job Submission")
1855
2844
  logger.info("=" * 60)
1856
- logger.info(f"File: {input_media}")
1857
- logger.info(f"Artist: {artist}")
1858
- logger.info(f"Title: {title}")
2845
+ if is_url_input:
2846
+ logger.info(f"URL: {input_media}")
2847
+ else:
2848
+ logger.info(f"File: {input_media}")
2849
+ if artist:
2850
+ logger.info(f"Artist: {artist}")
2851
+ if title:
2852
+ logger.info(f"Title: {title}")
2853
+ if not artist and not title and is_url_input:
2854
+ logger.info(f"Artist/Title: (will be auto-detected from URL)")
1859
2855
  if args.style_params_json:
1860
2856
  logger.info(f"Style: {args.style_params_json}")
1861
2857
  logger.info(f"CDG: {args.enable_cdg}, TXT: {args.enable_txt}")
@@ -1891,6 +2887,8 @@ def main():
1891
2887
  logger.info(f"Other Stems Models: {args.other_stems_models}")
1892
2888
  if getattr(args, 'existing_instrumental', None):
1893
2889
  logger.info(f"Existing Instrumental: {args.existing_instrumental}")
2890
+ if getattr(args, 'prep_only', False):
2891
+ logger.info(f"Mode: prep-only (will stop after review)")
1894
2892
  logger.info(f"Service URL: {config.service_url}")
1895
2893
  logger.info(f"Review UI: {config.review_ui_url}")
1896
2894
  if config.non_interactive:
@@ -1907,35 +2905,86 @@ def main():
1907
2905
  except Exception as e:
1908
2906
  logger.warning(f"Failed to read YouTube description file: {e}")
1909
2907
 
2908
+ # Extract brand code from current directory if --keep-brand-code is set
2909
+ keep_brand_code_value = None
2910
+ if getattr(args, 'keep_brand_code', False):
2911
+ cwd_name = os.path.basename(os.getcwd())
2912
+ parts = cwd_name.split(" - ", 1)
2913
+ if parts and "-" in parts[0]:
2914
+ potential_brand = parts[0]
2915
+ brand_parts = potential_brand.split("-")
2916
+ if len(brand_parts) == 2 and brand_parts[1].isdigit():
2917
+ keep_brand_code_value = potential_brand
2918
+ logger.info(f"Preserving brand code: {keep_brand_code_value}")
2919
+
1910
2920
  try:
1911
- # Submit job with all options
1912
- result = client.submit_job(
1913
- filepath=input_media,
1914
- artist=artist,
1915
- title=title,
1916
- style_params_path=args.style_params_json,
1917
- enable_cdg=args.enable_cdg,
1918
- enable_txt=args.enable_txt,
1919
- brand_prefix=args.brand_prefix,
1920
- discord_webhook_url=args.discord_webhook_url,
1921
- youtube_description=youtube_description,
1922
- organised_dir_rclone_root=args.organised_dir_rclone_root,
1923
- enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
1924
- # Native API distribution (preferred for remote CLI)
1925
- dropbox_path=getattr(args, 'dropbox_path', None),
1926
- gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
1927
- # Lyrics configuration
1928
- lyrics_artist=getattr(args, 'lyrics_artist', None),
1929
- lyrics_title=getattr(args, 'lyrics_title', None),
1930
- lyrics_file=getattr(args, 'lyrics_file', None),
1931
- subtitle_offset_ms=getattr(args, 'subtitle_offset_ms', 0) or 0,
1932
- # Audio separation model configuration
1933
- clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
1934
- backing_vocals_models=getattr(args, 'backing_vocals_models', None),
1935
- other_stems_models=getattr(args, 'other_stems_models', None),
1936
- # Existing instrumental (Batch 3)
1937
- existing_instrumental=getattr(args, 'existing_instrumental', None),
1938
- )
2921
+ # Submit job - different endpoint for URL vs file
2922
+ if is_url_input:
2923
+ # URL-based job submission
2924
+ # Note: style_params_path is not supported for URL-based jobs
2925
+ # If custom styles are needed, download the audio locally first
2926
+ if args.style_params_json:
2927
+ logger.warning("Custom styles (--style_params_json) are not supported for URL-based jobs. "
2928
+ "Download the audio locally first and use file upload for custom styles.")
2929
+
2930
+ result = client.submit_job_from_url(
2931
+ url=input_media,
2932
+ artist=artist,
2933
+ title=title,
2934
+ enable_cdg=args.enable_cdg,
2935
+ enable_txt=args.enable_txt,
2936
+ brand_prefix=args.brand_prefix,
2937
+ discord_webhook_url=args.discord_webhook_url,
2938
+ youtube_description=youtube_description,
2939
+ organised_dir_rclone_root=args.organised_dir_rclone_root,
2940
+ enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
2941
+ # Native API distribution (preferred for remote CLI)
2942
+ dropbox_path=getattr(args, 'dropbox_path', None),
2943
+ gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
2944
+ # Lyrics configuration
2945
+ lyrics_artist=getattr(args, 'lyrics_artist', None),
2946
+ lyrics_title=getattr(args, 'lyrics_title', None),
2947
+ subtitle_offset_ms=getattr(args, 'subtitle_offset_ms', 0) or 0,
2948
+ # Audio separation model configuration
2949
+ clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
2950
+ backing_vocals_models=getattr(args, 'backing_vocals_models', None),
2951
+ other_stems_models=getattr(args, 'other_stems_models', None),
2952
+ # Two-phase workflow (Batch 6)
2953
+ prep_only=getattr(args, 'prep_only', False),
2954
+ keep_brand_code=keep_brand_code_value,
2955
+ )
2956
+ else:
2957
+ # File-based job submission
2958
+ result = client.submit_job(
2959
+ filepath=input_media,
2960
+ artist=artist,
2961
+ title=title,
2962
+ style_params_path=args.style_params_json,
2963
+ enable_cdg=args.enable_cdg,
2964
+ enable_txt=args.enable_txt,
2965
+ brand_prefix=args.brand_prefix,
2966
+ discord_webhook_url=args.discord_webhook_url,
2967
+ youtube_description=youtube_description,
2968
+ organised_dir_rclone_root=args.organised_dir_rclone_root,
2969
+ enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
2970
+ # Native API distribution (preferred for remote CLI)
2971
+ dropbox_path=getattr(args, 'dropbox_path', None),
2972
+ gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
2973
+ # Lyrics configuration
2974
+ lyrics_artist=getattr(args, 'lyrics_artist', None),
2975
+ lyrics_title=getattr(args, 'lyrics_title', None),
2976
+ lyrics_file=getattr(args, 'lyrics_file', None),
2977
+ subtitle_offset_ms=getattr(args, 'subtitle_offset_ms', 0) or 0,
2978
+ # Audio separation model configuration
2979
+ clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
2980
+ backing_vocals_models=getattr(args, 'backing_vocals_models', None),
2981
+ other_stems_models=getattr(args, 'other_stems_models', None),
2982
+ # Existing instrumental (Batch 3)
2983
+ existing_instrumental=getattr(args, 'existing_instrumental', None),
2984
+ # Two-phase workflow (Batch 6)
2985
+ prep_only=getattr(args, 'prep_only', False),
2986
+ keep_brand_code=keep_brand_code_value,
2987
+ )
1939
2988
  job_id = result.get('job_id')
1940
2989
  style_assets = result.get('style_assets_uploaded', [])
1941
2990
  server_version = result.get('server_version', 'unknown')