karaoke-gen 0.71.42__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 (32) 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/instrumental_review/server.py +154 -860
  5. karaoke_gen/instrumental_review/static/index.html +1506 -0
  6. karaoke_gen/karaoke_finalise/karaoke_finalise.py +62 -1
  7. karaoke_gen/karaoke_gen.py +114 -1
  8. karaoke_gen/lyrics_processor.py +81 -4
  9. karaoke_gen/utils/bulk_cli.py +3 -0
  10. karaoke_gen/utils/cli_args.py +4 -2
  11. karaoke_gen/utils/gen_cli.py +196 -5
  12. karaoke_gen/utils/remote_cli.py +523 -34
  13. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/METADATA +4 -1
  14. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/RECORD +31 -25
  15. lyrics_transcriber/frontend/package.json +1 -1
  16. lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
  17. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
  18. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
  19. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
  20. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
  21. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
  22. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
  23. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
  24. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  25. lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-COYImAcx.js} +1722 -489
  26. lyrics_transcriber/frontend/web_assets/assets/index-COYImAcx.js.map +1 -0
  27. lyrics_transcriber/frontend/web_assets/index.html +1 -1
  28. lyrics_transcriber/review/server.py +5 -5
  29. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
  30. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/WHEEL +0 -0
  31. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/entry_points.txt +0 -0
  32. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/licenses/LICENSE +0 -0
@@ -36,6 +36,8 @@ 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):
@@ -67,6 +69,7 @@ class JobStatus(str, Enum):
67
69
  UPLOADING = "uploading"
68
70
  NOTIFYING = "notifying"
69
71
  COMPLETE = "complete"
72
+ PREP_COMPLETE = "prep_complete" # Batch 6: Prep-only jobs stop here
70
73
  FAILED = "failed"
71
74
  CANCELLED = "cancelled"
72
75
  ERROR = "error"
@@ -272,6 +275,9 @@ class RemoteKaraokeClient:
272
275
  clean_instrumental_model: Optional[str] = None,
273
276
  backing_vocals_models: Optional[list] = None,
274
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,
275
281
  ) -> Dict[str, Any]:
276
282
  """
277
283
  Submit a new karaoke generation job from a YouTube/online URL.
@@ -342,6 +348,11 @@ class RemoteKaraokeClient:
342
348
  create_request['backing_vocals_models'] = backing_vocals_models
343
349
  if other_stems_models:
344
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
345
356
 
346
357
  self.logger.info(f"Creating URL-based job at {self.config.service_url}/api/jobs/create-from-url")
347
358
 
@@ -397,6 +408,9 @@ class RemoteKaraokeClient:
397
408
  other_stems_models: Optional[list] = None,
398
409
  # Existing instrumental (Batch 3)
399
410
  existing_instrumental: Optional[str] = None,
411
+ # Two-phase workflow (Batch 6)
412
+ prep_only: bool = False,
413
+ keep_brand_code: Optional[str] = None,
400
414
  ) -> Dict[str, Any]:
401
415
  """
402
416
  Submit a new karaoke generation job with optional style configuration.
@@ -541,6 +555,11 @@ class RemoteKaraokeClient:
541
555
  create_request['backing_vocals_models'] = backing_vocals_models
542
556
  if other_stems_models:
543
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
544
563
 
545
564
  response = self._request('POST', '/api/jobs/create-with-upload-urls', json=create_request)
546
565
 
@@ -628,6 +647,255 @@ class RemoteKaraokeClient:
628
647
 
629
648
  return result
630
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
+
631
899
  def get_job(self, job_id: str) -> Dict[str, Any]:
632
900
  """Get job status and details."""
633
901
  response = self._request('GET', f'/api/jobs/{job_id}')
@@ -1054,6 +1322,7 @@ class JobMonitor:
1054
1322
  'uploading': 'Uploading to distribution services',
1055
1323
  'notifying': 'Sending notifications',
1056
1324
  'complete': 'All processing complete',
1325
+ 'prep_complete': 'Prep phase complete - ready for local finalisation',
1057
1326
  'failed': 'Job failed',
1058
1327
  'cancelled': 'Job cancelled',
1059
1328
  }
@@ -1062,6 +1331,54 @@ class JobMonitor:
1062
1331
  """Get user-friendly description for a status."""
1063
1332
  return self.STATUS_DESCRIPTIONS.get(status, status)
1064
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
+
1065
1382
  def open_browser(self, url: str) -> None:
1066
1383
  """Open URL in the default browser."""
1067
1384
  system = platform.system()
@@ -1250,6 +1567,47 @@ class JobMonitor:
1250
1567
  except Exception as e:
1251
1568
  self.logger.error(f"Error submitting selection: {e}")
1252
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
+
1253
1611
  def handle_audio_selection(self, job_id: str) -> None:
1254
1612
  """Handle audio source selection interaction (Batch 5)."""
1255
1613
  self.logger.info("=" * 60)
@@ -1260,6 +1618,7 @@ class JobMonitor:
1260
1618
  # Get search results
1261
1619
  results_data = self.client.get_audio_search_results(job_id)
1262
1620
  results = results_data.get('results', [])
1621
+ artist = results_data.get('artist', 'Unknown')
1263
1622
 
1264
1623
  if not results:
1265
1624
  self.logger.error("No search results available")
@@ -1270,42 +1629,27 @@ class JobMonitor:
1270
1629
  self.logger.info("Non-interactive mode: Auto-selecting first result")
1271
1630
  selection_index = 0
1272
1631
  else:
1273
- self.logger.info("")
1274
- self.logger.info("Choose which audio source to download:")
1275
- self.logger.info("")
1276
-
1277
- for result in results:
1278
- index = result.get('index', 0)
1279
- provider = result.get('provider', 'Unknown')
1280
- artist = result.get('artist', 'Unknown')
1281
- title = result.get('title', 'Unknown')
1282
- quality = result.get('quality', '')
1283
- duration = result.get('duration')
1284
-
1285
- # Format duration if available
1286
- duration_str = ""
1287
- if duration:
1288
- minutes = duration // 60
1289
- seconds = duration % 60
1290
- duration_str = f" [{minutes}:{seconds:02d}]"
1291
-
1292
- quality_str = f" ({quality})" if quality else ""
1293
-
1294
- self.logger.info(f" {index + 1}) [{provider}] {artist} - {title}{quality_str}{duration_str}")
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]
1295
1635
 
1296
- self.logger.info("")
1636
+ # Use flacfetch's shared display function
1637
+ print_releases(release_dicts, target_artist=artist, use_colors=True)
1297
1638
 
1298
1639
  selection_index = -1
1299
1640
  while selection_index < 0:
1300
1641
  try:
1301
- choice = input(f"Enter your choice (1-{len(results)}): ").strip()
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
1302
1646
  choice_num = int(choice)
1303
1647
  if 1 <= choice_num <= len(results):
1304
1648
  selection_index = choice_num - 1
1305
1649
  else:
1306
- self.logger.error(f"Please enter a number between 1 and {len(results)}")
1650
+ print(f"Please enter a number between 0 and {len(results)}")
1307
1651
  except ValueError:
1308
- self.logger.error("Please enter a valid number")
1652
+ print("Please enter a valid number")
1309
1653
  except KeyboardInterrupt:
1310
1654
  print()
1311
1655
  raise
@@ -1771,9 +2115,15 @@ class JobMonitor:
1771
2115
  self._polls_without_updates = 0
1772
2116
  else:
1773
2117
  self._polls_without_updates += 1
1774
- if self._polls_without_updates >= self._heartbeat_interval:
1775
- description = self._get_status_description(status)
1776
- 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}]")
1777
2127
  self._polls_without_updates = 0
1778
2128
 
1779
2129
  # Handle human interaction points
@@ -1809,6 +2159,24 @@ class JobMonitor:
1809
2159
  self.download_outputs(job_id, job_data)
1810
2160
  return 0
1811
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
+
1812
2180
  elif status in ['failed', 'error']:
1813
2181
  self.logger.info("")
1814
2182
  self.logger.error("=" * 60)
@@ -2197,10 +2565,112 @@ def main():
2197
2565
  logger.error(f"Error deleting job: {e}")
2198
2566
  return 1
2199
2567
 
2200
- # Warn about unsupported features
2568
+ # Handle finalise-only mode (Batch 6)
2201
2569
  if args.finalise_only:
2202
- logger.error("--finalise-only is not supported in remote mode")
2203
- 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
2204
2674
 
2205
2675
  if args.edit_lyrics:
2206
2676
  logger.error("--edit-lyrics is not yet supported in remote mode")
@@ -2212,8 +2682,7 @@ def main():
2212
2682
 
2213
2683
  # Warn about features that are not yet supported in remote mode
2214
2684
  ignored_features = []
2215
- if args.prep_only:
2216
- ignored_features.append("--prep-only")
2685
+ # Note: --prep-only is now supported in remote mode (Batch 6)
2217
2686
  if args.skip_separation:
2218
2687
  ignored_features.append("--skip-separation")
2219
2688
  if args.skip_transcription:
@@ -2418,6 +2887,8 @@ def main():
2418
2887
  logger.info(f"Other Stems Models: {args.other_stems_models}")
2419
2888
  if getattr(args, 'existing_instrumental', None):
2420
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)")
2421
2892
  logger.info(f"Service URL: {config.service_url}")
2422
2893
  logger.info(f"Review UI: {config.review_ui_url}")
2423
2894
  if config.non_interactive:
@@ -2434,6 +2905,18 @@ def main():
2434
2905
  except Exception as e:
2435
2906
  logger.warning(f"Failed to read YouTube description file: {e}")
2436
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
+
2437
2920
  try:
2438
2921
  # Submit job - different endpoint for URL vs file
2439
2922
  if is_url_input:
@@ -2466,6 +2949,9 @@ def main():
2466
2949
  clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
2467
2950
  backing_vocals_models=getattr(args, 'backing_vocals_models', None),
2468
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,
2469
2955
  )
2470
2956
  else:
2471
2957
  # File-based job submission
@@ -2495,6 +2981,9 @@ def main():
2495
2981
  other_stems_models=getattr(args, 'other_stems_models', None),
2496
2982
  # Existing instrumental (Batch 3)
2497
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,
2498
2987
  )
2499
2988
  job_id = result.get('job_id')
2500
2989
  style_assets = result.get('style_assets_uploaded', [])