karaoke-gen 0.71.42__py3-none-any.whl → 0.75.53__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 (38) hide show
  1. karaoke_gen/__init__.py +32 -1
  2. karaoke_gen/audio_fetcher.py +1220 -67
  3. karaoke_gen/audio_processor.py +15 -3
  4. karaoke_gen/instrumental_review/server.py +154 -860
  5. karaoke_gen/instrumental_review/static/index.html +1529 -0
  6. karaoke_gen/karaoke_finalise/karaoke_finalise.py +87 -2
  7. karaoke_gen/karaoke_gen.py +131 -14
  8. karaoke_gen/lyrics_processor.py +172 -4
  9. karaoke_gen/utils/bulk_cli.py +3 -0
  10. karaoke_gen/utils/cli_args.py +7 -4
  11. karaoke_gen/utils/gen_cli.py +221 -5
  12. karaoke_gen/utils/remote_cli.py +786 -43
  13. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/METADATA +109 -4
  14. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/RECORD +37 -31
  15. lyrics_transcriber/core/controller.py +76 -2
  16. lyrics_transcriber/frontend/package.json +1 -1
  17. lyrics_transcriber/frontend/src/App.tsx +6 -4
  18. lyrics_transcriber/frontend/src/api.ts +25 -10
  19. lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
  20. lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
  21. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
  22. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
  23. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
  24. lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
  25. lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
  26. lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
  27. lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
  28. lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-BECn1o8Q.js} +1802 -553
  29. lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
  30. lyrics_transcriber/frontend/web_assets/index.html +1 -1
  31. lyrics_transcriber/output/countdown_processor.py +39 -0
  32. lyrics_transcriber/review/server.py +5 -5
  33. lyrics_transcriber/transcribers/audioshake.py +96 -7
  34. lyrics_transcriber/types.py +14 -12
  35. lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
  36. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/WHEEL +0 -0
  37. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/entry_points.txt +0 -0
  38. {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/licenses/LICENSE +0 -0
@@ -31,11 +31,16 @@ import webbrowser
31
31
  from dataclasses import dataclass
32
32
  from enum import Enum
33
33
  from pathlib import Path
34
- from typing import Any, Dict, Optional
34
+ from typing import Any, Dict, List, Optional
35
35
 
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
41
+ from flacfetch.core.categorize import categorize_releases
42
+ from flacfetch.core.models import TrackQuery
43
+ from flacfetch.interface.cli import print_categorized_releases
39
44
 
40
45
 
41
46
  class JobStatus(str, Enum):
@@ -67,6 +72,7 @@ class JobStatus(str, Enum):
67
72
  UPLOADING = "uploading"
68
73
  NOTIFYING = "notifying"
69
74
  COMPLETE = "complete"
75
+ PREP_COMPLETE = "prep_complete" # Batch 6: Prep-only jobs stop here
70
76
  FAILED = "failed"
71
77
  CANCELLED = "cancelled"
72
78
  ERROR = "error"
@@ -134,7 +140,18 @@ class RemoteKaraokeClient:
134
140
  return None
135
141
 
136
142
  def refresh_auth(self) -> bool:
137
- """Refresh authentication token."""
143
+ """Refresh authentication token.
144
+
145
+ Only refreshes if we're using a gcloud-based token. If the user
146
+ provided a static token via KARAOKE_GEN_AUTH_TOKEN, we keep that
147
+ since it doesn't expire like gcloud identity tokens.
148
+ """
149
+ # Don't refresh if using a static admin token from env
150
+ if os.environ.get('KARAOKE_GEN_AUTH_TOKEN'):
151
+ # Already have a valid static token, no need to refresh
152
+ return True
153
+
154
+ # Try to refresh gcloud identity token
138
155
  token = self._get_auth_token_from_gcloud()
139
156
  if token:
140
157
  self.config.auth_token = token
@@ -272,6 +289,9 @@ class RemoteKaraokeClient:
272
289
  clean_instrumental_model: Optional[str] = None,
273
290
  backing_vocals_models: Optional[list] = None,
274
291
  other_stems_models: Optional[list] = None,
292
+ # Two-phase workflow (Batch 6)
293
+ prep_only: bool = False,
294
+ keep_brand_code: Optional[str] = None,
275
295
  ) -> Dict[str, Any]:
276
296
  """
277
297
  Submit a new karaoke generation job from a YouTube/online URL.
@@ -342,6 +362,11 @@ class RemoteKaraokeClient:
342
362
  create_request['backing_vocals_models'] = backing_vocals_models
343
363
  if other_stems_models:
344
364
  create_request['other_stems_models'] = other_stems_models
365
+ # Two-phase workflow (Batch 6)
366
+ if prep_only:
367
+ create_request['prep_only'] = prep_only
368
+ if keep_brand_code:
369
+ create_request['keep_brand_code'] = keep_brand_code
345
370
 
346
371
  self.logger.info(f"Creating URL-based job at {self.config.service_url}/api/jobs/create-from-url")
347
372
 
@@ -397,6 +422,9 @@ class RemoteKaraokeClient:
397
422
  other_stems_models: Optional[list] = None,
398
423
  # Existing instrumental (Batch 3)
399
424
  existing_instrumental: Optional[str] = None,
425
+ # Two-phase workflow (Batch 6)
426
+ prep_only: bool = False,
427
+ keep_brand_code: Optional[str] = None,
400
428
  ) -> Dict[str, Any]:
401
429
  """
402
430
  Submit a new karaoke generation job with optional style configuration.
@@ -541,6 +569,11 @@ class RemoteKaraokeClient:
541
569
  create_request['backing_vocals_models'] = backing_vocals_models
542
570
  if other_stems_models:
543
571
  create_request['other_stems_models'] = other_stems_models
572
+ # Two-phase workflow (Batch 6)
573
+ if prep_only:
574
+ create_request['prep_only'] = prep_only
575
+ if keep_brand_code:
576
+ create_request['keep_brand_code'] = keep_brand_code
544
577
 
545
578
  response = self._request('POST', '/api/jobs/create-with-upload-urls', json=create_request)
546
579
 
@@ -628,6 +661,255 @@ class RemoteKaraokeClient:
628
661
 
629
662
  return result
630
663
 
664
+ def submit_finalise_only_job(
665
+ self,
666
+ prep_folder: str,
667
+ artist: str,
668
+ title: str,
669
+ enable_cdg: bool = True,
670
+ enable_txt: bool = True,
671
+ brand_prefix: Optional[str] = None,
672
+ keep_brand_code: Optional[str] = None,
673
+ discord_webhook_url: Optional[str] = None,
674
+ youtube_description: Optional[str] = None,
675
+ enable_youtube_upload: bool = False,
676
+ dropbox_path: Optional[str] = None,
677
+ gdrive_folder_id: Optional[str] = None,
678
+ ) -> Dict[str, Any]:
679
+ """
680
+ Submit a finalise-only job with prep output files.
681
+
682
+ This is used when the user previously ran --prep-only and now wants
683
+ to continue with the finalisation phase using cloud resources.
684
+
685
+ Args:
686
+ prep_folder: Path to the prep output folder containing stems, screens, etc.
687
+ artist: Artist name
688
+ title: Song title
689
+ enable_cdg: Generate CDG+MP3 package
690
+ enable_txt: Generate TXT+MP3 package
691
+ brand_prefix: Brand code prefix (e.g., "NOMAD")
692
+ keep_brand_code: Preserve existing brand code from folder name
693
+ discord_webhook_url: Discord webhook for notifications
694
+ youtube_description: YouTube video description
695
+ enable_youtube_upload: Enable YouTube upload
696
+ dropbox_path: Dropbox folder path for organized output
697
+ gdrive_folder_id: Google Drive folder ID for public share
698
+ """
699
+ prep_path = Path(prep_folder)
700
+
701
+ if not prep_path.exists() or not prep_path.is_dir():
702
+ raise FileNotFoundError(f"Prep folder not found: {prep_folder}")
703
+
704
+ # Detect files in prep folder
705
+ files_info = []
706
+ local_files = {} # file_type -> local_path
707
+
708
+ base_name = f"{artist} - {title}"
709
+
710
+ # Required files - with_vocals video
711
+ for ext in ['.mkv', '.mov', '.mp4']:
712
+ with_vocals_path = prep_path / f"{base_name} (With Vocals){ext}"
713
+ if with_vocals_path.exists():
714
+ files_info.append({
715
+ 'filename': with_vocals_path.name,
716
+ 'content_type': f'video/{ext[1:]}',
717
+ 'file_type': 'with_vocals'
718
+ })
719
+ local_files['with_vocals'] = str(with_vocals_path)
720
+ break
721
+
722
+ if 'with_vocals' not in local_files:
723
+ raise FileNotFoundError(f"with_vocals video not found in {prep_folder}")
724
+
725
+ # Title screen
726
+ for ext in ['.mov', '.mkv', '.mp4']:
727
+ title_path = prep_path / f"{base_name} (Title){ext}"
728
+ if title_path.exists():
729
+ files_info.append({
730
+ 'filename': title_path.name,
731
+ 'content_type': f'video/{ext[1:]}',
732
+ 'file_type': 'title_screen'
733
+ })
734
+ local_files['title_screen'] = str(title_path)
735
+ break
736
+
737
+ if 'title_screen' not in local_files:
738
+ raise FileNotFoundError(f"title_screen video not found in {prep_folder}")
739
+
740
+ # End screen
741
+ for ext in ['.mov', '.mkv', '.mp4']:
742
+ end_path = prep_path / f"{base_name} (End){ext}"
743
+ if end_path.exists():
744
+ files_info.append({
745
+ 'filename': end_path.name,
746
+ 'content_type': f'video/{ext[1:]}',
747
+ 'file_type': 'end_screen'
748
+ })
749
+ local_files['end_screen'] = str(end_path)
750
+ break
751
+
752
+ if 'end_screen' not in local_files:
753
+ raise FileNotFoundError(f"end_screen video not found in {prep_folder}")
754
+
755
+ # Instrumentals (at least one required)
756
+ stems_dir = prep_path / 'stems'
757
+ if stems_dir.exists():
758
+ for stem_file in stems_dir.iterdir():
759
+ if 'Instrumental' in stem_file.name and stem_file.suffix.lower() == '.flac':
760
+ if '+BV' not in stem_file.name:
761
+ if 'instrumental_clean' not in local_files:
762
+ files_info.append({
763
+ 'filename': stem_file.name,
764
+ 'content_type': 'audio/flac',
765
+ 'file_type': 'instrumental_clean'
766
+ })
767
+ local_files['instrumental_clean'] = str(stem_file)
768
+ elif '+BV' in stem_file.name:
769
+ if 'instrumental_backing' not in local_files:
770
+ files_info.append({
771
+ 'filename': stem_file.name,
772
+ 'content_type': 'audio/flac',
773
+ 'file_type': 'instrumental_backing'
774
+ })
775
+ local_files['instrumental_backing'] = str(stem_file)
776
+
777
+ # Also check root for instrumental files
778
+ for stem_file in prep_path.iterdir():
779
+ if 'Instrumental' in stem_file.name and stem_file.suffix.lower() == '.flac':
780
+ if '+BV' not in stem_file.name and 'instrumental_clean' not in local_files:
781
+ files_info.append({
782
+ 'filename': stem_file.name,
783
+ 'content_type': 'audio/flac',
784
+ 'file_type': 'instrumental_clean'
785
+ })
786
+ local_files['instrumental_clean'] = str(stem_file)
787
+ elif '+BV' in stem_file.name and 'instrumental_backing' not in local_files:
788
+ files_info.append({
789
+ 'filename': stem_file.name,
790
+ 'content_type': 'audio/flac',
791
+ 'file_type': 'instrumental_backing'
792
+ })
793
+ local_files['instrumental_backing'] = str(stem_file)
794
+
795
+ if 'instrumental_clean' not in local_files and 'instrumental_backing' not in local_files:
796
+ raise FileNotFoundError(f"No instrumental file found in {prep_folder}")
797
+
798
+ # Optional files - LRC
799
+ lrc_path = prep_path / f"{base_name} (Karaoke).lrc"
800
+ if lrc_path.exists():
801
+ files_info.append({
802
+ 'filename': lrc_path.name,
803
+ 'content_type': 'text/plain',
804
+ 'file_type': 'lrc'
805
+ })
806
+ local_files['lrc'] = str(lrc_path)
807
+
808
+ # Optional - Title/End JPG/PNG
809
+ for img_type, file_type in [('Title', 'title'), ('End', 'end')]:
810
+ for ext in ['.jpg', '.png']:
811
+ img_path = prep_path / f"{base_name} ({img_type}){ext}"
812
+ if img_path.exists():
813
+ files_info.append({
814
+ 'filename': img_path.name,
815
+ 'content_type': f'image/{ext[1:]}',
816
+ 'file_type': f'{file_type}_{ext[1:]}'
817
+ })
818
+ local_files[f'{file_type}_{ext[1:]}'] = str(img_path)
819
+
820
+ self.logger.info(f"Found {len(files_info)} files in prep folder")
821
+ for file_type in local_files:
822
+ self.logger.info(f" {file_type}: {Path(local_files[file_type]).name}")
823
+
824
+ # Create finalise-only job
825
+ create_request = {
826
+ 'artist': artist,
827
+ 'title': title,
828
+ 'files': files_info,
829
+ 'enable_cdg': enable_cdg,
830
+ 'enable_txt': enable_txt,
831
+ }
832
+
833
+ if brand_prefix:
834
+ create_request['brand_prefix'] = brand_prefix
835
+ if keep_brand_code:
836
+ create_request['keep_brand_code'] = keep_brand_code
837
+ if discord_webhook_url:
838
+ create_request['discord_webhook_url'] = discord_webhook_url
839
+ if youtube_description:
840
+ create_request['youtube_description'] = youtube_description
841
+ if enable_youtube_upload:
842
+ create_request['enable_youtube_upload'] = enable_youtube_upload
843
+ if dropbox_path:
844
+ create_request['dropbox_path'] = dropbox_path
845
+ if gdrive_folder_id:
846
+ create_request['gdrive_folder_id'] = gdrive_folder_id
847
+
848
+ self.logger.info(f"Creating finalise-only job at {self.config.service_url}/api/jobs/create-finalise-only")
849
+
850
+ response = self._request('POST', '/api/jobs/create-finalise-only', json=create_request)
851
+
852
+ if response.status_code != 200:
853
+ try:
854
+ error_detail = response.json()
855
+ except Exception:
856
+ error_detail = response.text
857
+ raise RuntimeError(f"Error creating finalise-only job: {error_detail}")
858
+
859
+ create_result = response.json()
860
+ if create_result.get('status') != 'success':
861
+ raise RuntimeError(f"Error creating finalise-only job: {create_result}")
862
+
863
+ job_id = create_result['job_id']
864
+ upload_urls = create_result['upload_urls']
865
+
866
+ self.logger.info(f"Job {job_id} created. Uploading {len(upload_urls)} files directly to storage...")
867
+
868
+ # Upload each file
869
+ uploaded_files = []
870
+ for url_info in upload_urls:
871
+ file_type = url_info['file_type']
872
+ signed_url = url_info['upload_url']
873
+ content_type = url_info['content_type']
874
+ local_path = local_files.get(file_type)
875
+
876
+ if not local_path:
877
+ self.logger.warning(f"No local file found for file_type: {file_type}")
878
+ continue
879
+
880
+ file_size = os.path.getsize(local_path)
881
+ file_size_mb = file_size / (1024 * 1024)
882
+ self.logger.info(f" Uploading {file_type} ({file_size_mb:.1f} MB)...")
883
+
884
+ success = self._upload_file_to_signed_url(signed_url, local_path, content_type)
885
+ if not success:
886
+ raise RuntimeError(f"Failed to upload {file_type} to storage")
887
+
888
+ uploaded_files.append(file_type)
889
+ self.logger.info(f" ✓ Uploaded {file_type}")
890
+
891
+ # Mark uploads complete
892
+ self.logger.info(f"Notifying backend that uploads are complete...")
893
+
894
+ complete_request = {
895
+ 'uploaded_files': uploaded_files
896
+ }
897
+
898
+ response = self._request('POST', f'/api/jobs/{job_id}/finalise-uploads-complete', json=complete_request)
899
+
900
+ if response.status_code != 200:
901
+ try:
902
+ error_detail = response.json()
903
+ except Exception:
904
+ error_detail = response.text
905
+ raise RuntimeError(f"Error completing finalise-only uploads: {error_detail}")
906
+
907
+ result = response.json()
908
+ if result.get('status') != 'success':
909
+ raise RuntimeError(f"Error completing finalise-only uploads: {result}")
910
+
911
+ return result
912
+
631
913
  def get_job(self, job_id: str) -> Dict[str, Any]:
632
914
  """Get job status and details."""
633
915
  response = self._request('GET', f'/api/jobs/{job_id}')
@@ -813,7 +1095,8 @@ class RemoteKaraokeClient:
813
1095
  if url.startswith('/'):
814
1096
  url = f"{self.config.service_url}{url}"
815
1097
 
816
- response = requests.get(url, stream=True, timeout=600)
1098
+ # Use session headers (includes Authorization) for authenticated downloads
1099
+ response = self.session.get(url, stream=True, timeout=600)
817
1100
  if response.status_code != 200:
818
1101
  return False
819
1102
 
@@ -924,6 +1207,7 @@ class RemoteKaraokeClient:
924
1207
  artist: Artist name to search for
925
1208
  title: Song title to search for
926
1209
  auto_download: Automatically select best audio source (skip interactive selection)
1210
+ style_params_path: Path to style_params.json (optional)
927
1211
  ... other args same as submit_job()
928
1212
 
929
1213
  Returns:
@@ -964,6 +1248,40 @@ class RemoteKaraokeClient:
964
1248
  if other_stems_models:
965
1249
  request_data['other_stems_models'] = other_stems_models
966
1250
 
1251
+ # Prepare style files for upload if provided
1252
+ style_files = []
1253
+ local_style_files: Dict[str, str] = {} # file_type -> local_path
1254
+
1255
+ if style_params_path and os.path.isfile(style_params_path):
1256
+ self.logger.info(f"Parsing style configuration: {style_params_path}")
1257
+
1258
+ # Add the style_params.json itself
1259
+ style_files.append({
1260
+ 'filename': Path(style_params_path).name,
1261
+ 'content_type': 'application/json',
1262
+ 'file_type': 'style_params'
1263
+ })
1264
+ local_style_files['style_params'] = style_params_path
1265
+
1266
+ # Parse style params to find referenced files (backgrounds, fonts)
1267
+ style_assets = self._parse_style_params(style_params_path)
1268
+
1269
+ for asset_key, asset_path in style_assets.items():
1270
+ if os.path.isfile(asset_path):
1271
+ # Use full path for content type detection (not just extension)
1272
+ content_type = self._get_content_type(asset_path)
1273
+ style_files.append({
1274
+ 'filename': Path(asset_path).name,
1275
+ 'content_type': content_type,
1276
+ 'file_type': asset_key # e.g., 'style_intro_background'
1277
+ })
1278
+ local_style_files[asset_key] = asset_path
1279
+ self.logger.info(f" Will upload style asset: {asset_key}")
1280
+
1281
+ if style_files:
1282
+ request_data['style_files'] = style_files
1283
+ self.logger.info(f"Including {len(style_files)} style files in request")
1284
+
967
1285
  response = self._request('POST', '/api/audio-search/search', json=request_data)
968
1286
 
969
1287
  if response.status_code == 404:
@@ -980,7 +1298,52 @@ class RemoteKaraokeClient:
980
1298
  error_detail = response.text
981
1299
  raise RuntimeError(f"Error searching for audio: {error_detail}")
982
1300
 
983
- return response.json()
1301
+ result = response.json()
1302
+
1303
+ # Upload style files if we have signed URLs
1304
+ style_upload_urls = result.get('style_upload_urls', [])
1305
+ if style_upload_urls and local_style_files:
1306
+ self.logger.info(f"Uploading {len(style_upload_urls)} style files...")
1307
+
1308
+ for url_info in style_upload_urls:
1309
+ file_type = url_info['file_type']
1310
+ upload_url = url_info['upload_url']
1311
+
1312
+ local_path = local_style_files.get(file_type)
1313
+ if not local_path:
1314
+ self.logger.warning(f"No local file for {file_type}, skipping upload")
1315
+ continue
1316
+
1317
+ self.logger.info(f" Uploading {file_type}: {Path(local_path).name}")
1318
+
1319
+ try:
1320
+ with open(local_path, 'rb') as f:
1321
+ file_content = f.read()
1322
+
1323
+ # Use the content type from the original file info, not re-derived
1324
+ # This ensures it matches the signed URL which was generated with
1325
+ # the same content type we specified in the request
1326
+ content_type = self._get_content_type(local_path)
1327
+
1328
+ # Use PUT to upload directly to signed URL
1329
+ upload_response = requests.put(
1330
+ upload_url,
1331
+ data=file_content,
1332
+ headers={'Content-Type': content_type},
1333
+ timeout=60
1334
+ )
1335
+
1336
+ if upload_response.status_code not in (200, 201):
1337
+ self.logger.error(f"Failed to upload {file_type}: {upload_response.status_code}")
1338
+ else:
1339
+ self.logger.info(f" ✓ Uploaded {file_type}")
1340
+
1341
+ except Exception as e:
1342
+ self.logger.error(f"Error uploading {file_type}: {e}")
1343
+
1344
+ self.logger.info("Style file uploads complete")
1345
+
1346
+ return result
984
1347
 
985
1348
  def get_audio_search_results(self, job_id: str) -> Dict[str, Any]:
986
1349
  """Get audio search results for a job awaiting selection."""
@@ -1054,6 +1417,7 @@ class JobMonitor:
1054
1417
  'uploading': 'Uploading to distribution services',
1055
1418
  'notifying': 'Sending notifications',
1056
1419
  'complete': 'All processing complete',
1420
+ 'prep_complete': 'Prep phase complete - ready for local finalisation',
1057
1421
  'failed': 'Job failed',
1058
1422
  'cancelled': 'Job cancelled',
1059
1423
  }
@@ -1062,6 +1426,54 @@ class JobMonitor:
1062
1426
  """Get user-friendly description for a status."""
1063
1427
  return self.STATUS_DESCRIPTIONS.get(status, status)
1064
1428
 
1429
+ def _show_download_progress(self, job_data: Dict[str, Any]) -> None:
1430
+ """Show detailed download progress during audio download."""
1431
+ try:
1432
+ # Get provider from job state_data
1433
+ state_data = job_data.get('state_data', {})
1434
+ provider = state_data.get('selected_audio_provider', 'unknown')
1435
+
1436
+ # For non-torrent providers (YouTube), just show simple message
1437
+ if provider.lower() == 'youtube':
1438
+ self.logger.info(f" [Downloading from YouTube...]")
1439
+ return
1440
+
1441
+ # Query health endpoint for transmission status (torrent providers)
1442
+ health_url = f"{self.config.service_url}/api/health/detailed"
1443
+ response = requests.get(health_url, timeout=5)
1444
+
1445
+ if response.status_code == 200:
1446
+ health = response.json()
1447
+ transmission = health.get('dependencies', {}).get('transmission', {})
1448
+
1449
+ if transmission.get('available'):
1450
+ torrents = transmission.get('torrents', [])
1451
+ if torrents:
1452
+ # Show info about active torrents
1453
+ for t in torrents:
1454
+ progress = t.get('progress', 0)
1455
+ peers = t.get('peers', 0)
1456
+ speed = t.get('download_speed', 0)
1457
+ stalled = t.get('stalled', False)
1458
+
1459
+ if stalled:
1460
+ self.logger.info(f" [Downloading from {provider}] {progress:.1f}% - STALLED (no peers)")
1461
+ elif progress < 100:
1462
+ self.logger.info(f" [Downloading from {provider}] {progress:.1f}% @ {speed:.1f} KB/s ({peers} peers)")
1463
+ else:
1464
+ self.logger.info(f" [Downloading from {provider}] Complete, processing...")
1465
+ else:
1466
+ # No torrents - might be starting or YouTube download
1467
+ self.logger.info(f" [Downloading from {provider}] Starting download...")
1468
+ else:
1469
+ self.logger.info(f" [Downloading from {provider}] Transmission not available - download may fail")
1470
+ else:
1471
+ self.logger.info(f" [Downloading from {provider}]...")
1472
+
1473
+ except Exception as e:
1474
+ # Fall back to simple message
1475
+ self.logger.info(f" [Downloading audio...]")
1476
+
1065
1477
  def open_browser(self, url: str) -> None:
1066
1478
  """Open URL in the default browser."""
1067
1479
  system = platform.system()
@@ -1081,16 +1493,21 @@ class JobMonitor:
1081
1493
  base_api_url = f"{self.config.service_url}/api/review/{job_id}"
1082
1494
  encoded_api_url = urllib.parse.quote(base_api_url, safe='')
1083
1495
 
1084
- # Try to get audio hash from job data
1496
+ # Try to get audio hash and review token from job data
1497
+ audio_hash = ''
1498
+ review_token = ''
1085
1499
  try:
1086
1500
  job_data = self.client.get_job(job_id)
1087
1501
  audio_hash = job_data.get('audio_hash', '')
1502
+ review_token = job_data.get('review_token', '')
1088
1503
  except Exception:
1089
- audio_hash = ''
1504
+ pass
1090
1505
 
1091
1506
  url = f"{self.config.review_ui_url}/?baseApiUrl={encoded_api_url}"
1092
1507
  if audio_hash:
1093
1508
  url += f"&audioHash={audio_hash}"
1509
+ if review_token:
1510
+ url += f"&reviewToken={review_token}"
1094
1511
 
1095
1512
  self.logger.info(f"Opening lyrics review UI: {url}")
1096
1513
  self.open_browser(url)
@@ -1250,8 +1667,75 @@ class JobMonitor:
1250
1667
  except Exception as e:
1251
1668
  self.logger.error(f"Error submitting selection: {e}")
1252
1669
 
1670
+ def _convert_api_result_to_release_dict(self, result: dict) -> dict:
1671
+ """
1672
+ Convert API search result to a dict compatible with flacfetch's Release.from_dict().
1673
+
1674
+ This enables using flacfetch's shared display functions for consistent,
1675
+ rich formatting between local and remote CLIs.
1676
+ """
1677
+ # Build quality dict from API response
1678
+ quality_data = result.get('quality_data') or {
1679
+ "format": "OTHER",
1680
+ "media": "OTHER",
1681
+ }
1682
+
1683
+ return {
1684
+ "title": result.get('title', ''),
1685
+ "artist": result.get('artist', ''),
1686
+ "source_name": result.get('provider', 'Unknown'),
1687
+ "download_url": result.get('url'),
1688
+ "info_hash": result.get('source_id'),
1689
+ "size_bytes": result.get('size_bytes'),
1690
+ "year": result.get('year'),
1691
+ "edition_info": result.get('edition_info'),
1692
+ "label": result.get('label'),
1693
+ "release_type": result.get('release_type'),
1694
+ "seeders": result.get('seeders'),
1695
+ "channel": result.get('channel'),
1696
+ "view_count": result.get('view_count'),
1697
+ "duration_seconds": result.get('duration'),
1698
+ "target_file": result.get('target_file'),
1699
+ "target_file_size": result.get('target_file_size'),
1700
+ "track_pattern": result.get('track_pattern'),
1701
+ "match_score": result.get('match_score', 0.0),
1702
+ "quality": quality_data,
1703
+ # Pre-computed fields
1704
+ "formatted_size": result.get('formatted_size'),
1705
+ "formatted_duration": result.get('formatted_duration'),
1706
+ "formatted_views": result.get('formatted_views'),
1707
+ "is_lossless": result.get('is_lossless', False),
1708
+ "quality_str": result.get('quality_str') or result.get('quality', ''),
1709
+ }
1710
+
1711
+ def _convert_to_release_objects(self, release_dicts: List[Dict[str, Any]]) -> List[Release]:
1712
+ """
1713
+ Convert API result dicts to Release objects for categorization.
1714
+
1715
+ Used by handle_audio_selection() to enable categorized display
1716
+ for large result sets (10+ results).
1717
+
1718
+ Args:
1719
+ release_dicts: List of dicts in Release-compatible format
1720
+
1721
+ Returns:
1722
+ List of Release objects (skipping any that fail to convert)
1723
+ """
1724
+ releases = []
1725
+ for d in release_dicts:
1726
+ try:
1727
+ releases.append(Release.from_dict(d))
1728
+ except Exception as e:
1729
+ self.logger.debug(f"Failed to convert result to Release: {e}")
1730
+ return releases
1731
+
1253
1732
  def handle_audio_selection(self, job_id: str) -> None:
1254
- """Handle audio source selection interaction (Batch 5)."""
1733
+ """Handle audio source selection interaction (Batch 5).
1734
+
1735
+ For 10+ results, uses categorized display (grouped by Top Seeded,
1736
+ Album Releases, Hi-Res, etc.) with a 'more' command to show full list.
1737
+ For smaller result sets, uses flat list display.
1738
+ """
1255
1739
  self.logger.info("=" * 60)
1256
1740
  self.logger.info("AUDIO SOURCE SELECTION NEEDED")
1257
1741
  self.logger.info("=" * 60)
@@ -1260,6 +1744,8 @@ class JobMonitor:
1260
1744
  # Get search results
1261
1745
  results_data = self.client.get_audio_search_results(job_id)
1262
1746
  results = results_data.get('results', [])
1747
+ artist = results_data.get('artist', 'Unknown')
1748
+ title = results_data.get('title', 'Unknown')
1263
1749
 
1264
1750
  if not results:
1265
1751
  self.logger.error("No search results available")
@@ -1270,42 +1756,75 @@ class JobMonitor:
1270
1756
  self.logger.info("Non-interactive mode: Auto-selecting first result")
1271
1757
  selection_index = 0
1272
1758
  else:
1273
- self.logger.info("")
1274
- self.logger.info("Choose which audio source to download:")
1275
- self.logger.info("")
1759
+ # Convert API results to Release-compatible dicts for flacfetch display
1760
+ # This gives us the same rich, colorized output as the local CLI
1761
+ release_dicts = [self._convert_api_result_to_release_dict(r) for r in results]
1276
1762
 
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}")
1763
+ # Convert to Release objects for categorization
1764
+ release_objects = self._convert_to_release_objects(release_dicts)
1295
1765
 
1296
- self.logger.info("")
1766
+ # Use categorized display for large result sets (10+)
1767
+ # This groups results into categories: Top Seeded, Album Releases, Hi-Res, etc.
1768
+ use_categorized = len(release_objects) >= 10
1769
+
1770
+ if use_categorized:
1771
+ # Create query for categorization
1772
+ query = TrackQuery(artist=artist, title=title)
1773
+ categorized = categorize_releases(release_objects, query)
1774
+ # print_categorized_releases returns the flattened list of displayed releases
1775
+ display_releases = print_categorized_releases(categorized, target_artist=artist, use_colors=True)
1776
+ showing_categorized = True
1777
+ else:
1778
+ # Small result set - use simple flat list
1779
+ print_releases(release_dicts, target_artist=artist, use_colors=True)
1780
+ display_releases = release_objects
1781
+ showing_categorized = False
1297
1782
 
1298
1783
  selection_index = -1
1299
1784
  while selection_index < 0:
1300
1785
  try:
1301
- choice = input(f"Enter your choice (1-{len(results)}): ").strip()
1786
+ if showing_categorized:
1787
+ prompt = f"\nSelect (1-{len(display_releases)}), 'more' for full list, 0 to cancel: "
1788
+ else:
1789
+ prompt = f"\nSelect a release (1-{len(display_releases)}, 0 to cancel): "
1790
+
1791
+ choice = input(prompt).strip().lower()
1792
+
1793
+ if choice == "0":
1794
+ self.logger.info("Selection cancelled by user")
1795
+ raise KeyboardInterrupt
1796
+
1797
+ # Handle 'more' command to show full flat list
1798
+ if choice in ('more', 'm', 'all', 'a') and showing_categorized:
1799
+ print("\n" + "=" * 60)
1800
+ print("FULL LIST (all results)")
1801
+ print("=" * 60 + "\n")
1802
+ print_releases(release_dicts, target_artist=artist, use_colors=True)
1803
+ display_releases = release_objects
1804
+ showing_categorized = False
1805
+ continue
1806
+
1302
1807
  choice_num = int(choice)
1303
- if 1 <= choice_num <= len(results):
1304
- selection_index = choice_num - 1
1808
+ if 1 <= choice_num <= len(display_releases):
1809
+ # Map selection back to original results index for API call
1810
+ selected_release = display_releases[choice_num - 1]
1811
+
1812
+ # Find matching index in original results by download_url
1813
+ selection_index = self._find_original_index(
1814
+ selected_release, results, release_objects
1815
+ )
1816
+
1817
+ if selection_index < 0:
1818
+ # Fallback: use display index if mapping fails
1819
+ self.logger.warning("Could not map selection to original index, using display index")
1820
+ selection_index = choice_num - 1
1305
1821
  else:
1306
- self.logger.error(f"Please enter a number between 1 and {len(results)}")
1822
+ print(f"Please enter a number between 0 and {len(display_releases)}")
1307
1823
  except ValueError:
1308
- self.logger.error("Please enter a valid number")
1824
+ if showing_categorized:
1825
+ print("Please enter a number or 'more'")
1826
+ else:
1827
+ print("Please enter a valid number")
1309
1828
  except KeyboardInterrupt:
1310
1829
  print()
1311
1830
  raise
@@ -1323,10 +1842,80 @@ class JobMonitor:
1323
1842
 
1324
1843
  except Exception as e:
1325
1844
  self.logger.error(f"Error handling audio selection: {e}")
1845
+
1846
+ def _find_original_index(
1847
+ self,
1848
+ selected_release: Release,
1849
+ original_results: List[Dict[str, Any]],
1850
+ release_objects: List[Release],
1851
+ ) -> int:
1852
+ """
1853
+ Map a selected Release back to its index in the original API results.
1854
+
1855
+ This is needed because categorized display may reorder results,
1856
+ but the API selection endpoint needs the original index.
1857
+
1858
+ Args:
1859
+ selected_release: The Release object user selected
1860
+ original_results: Original API results (list of dicts)
1861
+ release_objects: Release objects in same order as original_results
1862
+
1863
+ Returns:
1864
+ Index in original_results, or -1 if not found
1865
+ """
1866
+ # First try: match by object identity in release_objects
1867
+ for i, release in enumerate(release_objects):
1868
+ if release is selected_release:
1869
+ return i
1870
+
1871
+ # Second try: match by download_url
1872
+ selected_url = getattr(selected_release, 'download_url', None)
1873
+ if selected_url:
1874
+ for i, r in enumerate(original_results):
1875
+ if r.get('url') == selected_url:
1876
+ return i
1877
+
1878
+ # Third try: match by info_hash (for torrent sources)
1879
+ selected_hash = getattr(selected_release, 'info_hash', None)
1880
+ if selected_hash:
1881
+ for i, r in enumerate(original_results):
1882
+ if r.get('source_id') == selected_hash:
1883
+ return i
1884
+
1885
+ # Fourth try: match by title + artist + provider
1886
+ selected_title = getattr(selected_release, 'title', '')
1887
+ selected_artist = getattr(selected_release, 'artist', '')
1888
+ selected_source = getattr(selected_release, 'source_name', '')
1889
+
1890
+ for i, r in enumerate(original_results):
1891
+ if (r.get('title') == selected_title and
1892
+ r.get('artist') == selected_artist and
1893
+ r.get('provider') == selected_source):
1894
+ return i
1895
+
1896
+ return -1
1326
1897
 
1327
1898
  def _open_instrumental_review_and_wait(self, job_id: str) -> None:
1328
1899
  """Open browser to instrumental review UI and wait for selection."""
1329
- review_url = f"{self.config.review_ui_url}/jobs/{job_id}/instrumental-review"
1900
+ # Get instrumental token from job data
1901
+ instrumental_token = ''
1902
+ try:
1903
+ job_data = self.client.get_job(job_id)
1904
+ instrumental_token = job_data.get('instrumental_token', '')
1905
+ except Exception:
1906
+ pass
1907
+
1908
+ # Build the review URL with API endpoint and token
1909
+ # The instrumental UI is hosted at /instrumental/ on the frontend domain
1910
+ base_api_url = f"{self.config.service_url}/api/jobs/{job_id}"
1911
+ encoded_api_url = urllib.parse.quote(base_api_url, safe='')
1912
+
1913
+ # Use /instrumental/ path on the frontend (same domain as review_ui_url but different path)
1914
+ # review_ui_url is like https://gen.nomadkaraoke.com/lyrics, we want /instrumental/
1915
+ frontend_base = self.config.review_ui_url.rsplit('/', 1)[0] # Remove /lyrics
1916
+ review_url = f"{frontend_base}/instrumental/?baseApiUrl={encoded_api_url}"
1917
+ if instrumental_token:
1918
+ review_url += f"&instrumentalToken={instrumental_token}"
1330
1919
 
1331
1920
  self.logger.info("")
1332
1921
  self.logger.info("=" * 60)
@@ -1771,9 +2360,15 @@ class JobMonitor:
1771
2360
  self._polls_without_updates = 0
1772
2361
  else:
1773
2362
  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}]")
2363
+ # More frequent updates during audio download (every poll)
2364
+ heartbeat_threshold = 1 if status == 'downloading_audio' else self._heartbeat_interval
2365
+ if self._polls_without_updates >= heartbeat_threshold:
2366
+ if status == 'downloading_audio':
2367
+ # Show detailed download progress including transmission status
2368
+ self._show_download_progress(job_data)
2369
+ else:
2370
+ description = self._get_status_description(status)
2371
+ self.logger.info(f" [Still processing: {description}]")
1777
2372
  self._polls_without_updates = 0
1778
2373
 
1779
2374
  # Handle human interaction points
@@ -1799,6 +2394,14 @@ class JobMonitor:
1799
2394
  self.handle_instrumental_selection(job_id)
1800
2395
  self._instrumental_prompted = True
1801
2396
 
2397
+ elif status == 'instrumental_selected':
2398
+ # Check if this was auto-selected due to existing instrumental
2399
+ selection = job_data.get('state_data', {}).get('instrumental_selection', '')
2400
+ if selection == 'custom' and not self._instrumental_prompted:
2401
+ self.logger.info("")
2402
+ self.logger.info("Using user-provided instrumental (--existing_instrumental)")
2403
+ self._instrumental_prompted = True
2404
+
1802
2405
  elif status == 'complete':
1803
2406
  self.logger.info("")
1804
2407
  self.logger.info("=" * 60)
@@ -1809,6 +2412,24 @@ class JobMonitor:
1809
2412
  self.download_outputs(job_id, job_data)
1810
2413
  return 0
1811
2414
 
2415
+ elif status == 'prep_complete':
2416
+ self.logger.info("")
2417
+ self.logger.info("=" * 60)
2418
+ self.logger.info("PREP PHASE COMPLETE!")
2419
+ self.logger.info("=" * 60)
2420
+ self.logger.info(f"Track: {artist} - {title}")
2421
+ self.logger.info("")
2422
+ self.logger.info("Downloading all prep outputs...")
2423
+ self.download_outputs(job_id, job_data)
2424
+ self.logger.info("")
2425
+ self.logger.info("To continue with finalisation, run:")
2426
+ # Use shlex.quote for proper shell escaping of artist/title
2427
+ import shlex
2428
+ escaped_artist = shlex.quote(artist)
2429
+ escaped_title = shlex.quote(title)
2430
+ self.logger.info(f" karaoke-gen-remote --finalise-only ./<output_folder> {escaped_artist} {escaped_title}")
2431
+ return 0
2432
+
1812
2433
  elif status in ['failed', 'error']:
1813
2434
  self.logger.info("")
1814
2435
  self.logger.error("=" * 60)
@@ -2197,10 +2818,112 @@ def main():
2197
2818
  logger.error(f"Error deleting job: {e}")
2198
2819
  return 1
2199
2820
 
2200
- # Warn about unsupported features
2821
+ # Handle finalise-only mode (Batch 6)
2201
2822
  if args.finalise_only:
2202
- logger.error("--finalise-only is not supported in remote mode")
2203
- return 1
2823
+ logger.info("=" * 60)
2824
+ logger.info("Karaoke Generator (Remote) - Finalise Only Mode")
2825
+ logger.info("=" * 60)
2826
+
2827
+ # For finalise-only, we expect the current directory to be the prep output folder
2828
+ # OR a folder path as the first argument
2829
+ prep_folder = "."
2830
+ artist_arg_idx = 0
2831
+
2832
+ if args.args:
2833
+ # Check if first argument is a directory
2834
+ if os.path.isdir(args.args[0]):
2835
+ prep_folder = args.args[0]
2836
+ artist_arg_idx = 1
2837
+
2838
+ # Get artist and title from arguments
2839
+ if len(args.args) > artist_arg_idx + 1:
2840
+ artist = args.args[artist_arg_idx]
2841
+ title = args.args[artist_arg_idx + 1]
2842
+ elif len(args.args) > artist_arg_idx:
2843
+ logger.error("Finalise-only mode requires both Artist and Title")
2844
+ return 1
2845
+ else:
2846
+ # Try to extract from folder name
2847
+ folder_name = os.path.basename(os.path.abspath(prep_folder))
2848
+ parts = folder_name.split(" - ", 2)
2849
+ if len(parts) >= 2:
2850
+ # Format: "BRAND-XXXX - Artist - Title" or "Artist - Title"
2851
+ if "-" in parts[0] and parts[0].split("-")[1].isdigit():
2852
+ # Has brand code
2853
+ artist = parts[1] if len(parts) > 2 else "Unknown"
2854
+ title = parts[2] if len(parts) > 2 else parts[1]
2855
+ else:
2856
+ artist = parts[0]
2857
+ title = parts[1]
2858
+ logger.info(f"Extracted from folder name: {artist} - {title}")
2859
+ else:
2860
+ logger.error("Could not extract Artist and Title from folder name")
2861
+ logger.error("Please provide: karaoke-gen-remote --finalise-only <folder> \"Artist\" \"Title\"")
2862
+ return 1
2863
+ else:
2864
+ logger.error("Finalise-only mode requires folder path and/or Artist and Title")
2865
+ return 1
2866
+
2867
+ # Extract brand code from folder name if --keep-brand-code is set
2868
+ keep_brand_code = None
2869
+ if getattr(args, 'keep_brand_code', False):
2870
+ folder_name = os.path.basename(os.path.abspath(prep_folder))
2871
+ parts = folder_name.split(" - ", 1)
2872
+ if parts and "-" in parts[0]:
2873
+ # Check if it's a brand code format (e.g., "NOMAD-1234")
2874
+ potential_brand = parts[0]
2875
+ brand_parts = potential_brand.split("-")
2876
+ if len(brand_parts) == 2 and brand_parts[1].isdigit():
2877
+ keep_brand_code = potential_brand
2878
+ logger.info(f"Preserving brand code: {keep_brand_code}")
2879
+
2880
+ logger.info(f"Prep folder: {os.path.abspath(prep_folder)}")
2881
+ logger.info(f"Artist: {artist}")
2882
+ logger.info(f"Title: {title}")
2883
+ if keep_brand_code:
2884
+ logger.info(f"Brand Code: {keep_brand_code} (preserved)")
2885
+ logger.info("")
2886
+
2887
+ # Read youtube description from file if provided
2888
+ youtube_description = None
2889
+ if args.youtube_description_file and os.path.isfile(args.youtube_description_file):
2890
+ try:
2891
+ with open(args.youtube_description_file, 'r') as f:
2892
+ youtube_description = f.read()
2893
+ except Exception as e:
2894
+ logger.warning(f"Failed to read YouTube description file: {e}")
2895
+
2896
+ try:
2897
+ result = client.submit_finalise_only_job(
2898
+ prep_folder=prep_folder,
2899
+ artist=artist,
2900
+ title=title,
2901
+ enable_cdg=args.enable_cdg,
2902
+ enable_txt=args.enable_txt,
2903
+ brand_prefix=args.brand_prefix,
2904
+ keep_brand_code=keep_brand_code,
2905
+ discord_webhook_url=args.discord_webhook_url,
2906
+ youtube_description=youtube_description,
2907
+ enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
2908
+ dropbox_path=getattr(args, 'dropbox_path', None),
2909
+ gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
2910
+ )
2911
+ job_id = result.get('job_id')
2912
+ logger.info(f"Finalise-only job submitted: {job_id}")
2913
+ logger.info("")
2914
+
2915
+ # Monitor job
2916
+ return monitor.monitor(job_id)
2917
+
2918
+ except FileNotFoundError as e:
2919
+ logger.error(str(e))
2920
+ return 1
2921
+ except RuntimeError as e:
2922
+ logger.error(str(e))
2923
+ return 1
2924
+ except Exception as e:
2925
+ logger.error(f"Error: {e}")
2926
+ return 1
2204
2927
 
2205
2928
  if args.edit_lyrics:
2206
2929
  logger.error("--edit-lyrics is not yet supported in remote mode")
@@ -2212,8 +2935,7 @@ def main():
2212
2935
 
2213
2936
  # Warn about features that are not yet supported in remote mode
2214
2937
  ignored_features = []
2215
- if args.prep_only:
2216
- ignored_features.append("--prep-only")
2938
+ # Note: --prep-only is now supported in remote mode (Batch 6)
2217
2939
  if args.skip_separation:
2218
2940
  ignored_features.append("--skip-separation")
2219
2941
  if args.skip_transcription:
@@ -2333,6 +3055,7 @@ def main():
2333
3055
  artist=artist,
2334
3056
  title=title,
2335
3057
  auto_download=auto_download,
3058
+ style_params_path=args.style_params_json,
2336
3059
  enable_cdg=args.enable_cdg,
2337
3060
  enable_txt=args.enable_txt,
2338
3061
  brand_prefix=args.brand_prefix,
@@ -2418,6 +3141,8 @@ def main():
2418
3141
  logger.info(f"Other Stems Models: {args.other_stems_models}")
2419
3142
  if getattr(args, 'existing_instrumental', None):
2420
3143
  logger.info(f"Existing Instrumental: {args.existing_instrumental}")
3144
+ if getattr(args, 'prep_only', False):
3145
+ logger.info(f"Mode: prep-only (will stop after review)")
2421
3146
  logger.info(f"Service URL: {config.service_url}")
2422
3147
  logger.info(f"Review UI: {config.review_ui_url}")
2423
3148
  if config.non_interactive:
@@ -2434,6 +3159,18 @@ def main():
2434
3159
  except Exception as e:
2435
3160
  logger.warning(f"Failed to read YouTube description file: {e}")
2436
3161
 
3162
+ # Extract brand code from current directory if --keep-brand-code is set
3163
+ keep_brand_code_value = None
3164
+ if getattr(args, 'keep_brand_code', False):
3165
+ cwd_name = os.path.basename(os.getcwd())
3166
+ parts = cwd_name.split(" - ", 1)
3167
+ if parts and "-" in parts[0]:
3168
+ potential_brand = parts[0]
3169
+ brand_parts = potential_brand.split("-")
3170
+ if len(brand_parts) == 2 and brand_parts[1].isdigit():
3171
+ keep_brand_code_value = potential_brand
3172
+ logger.info(f"Preserving brand code: {keep_brand_code_value}")
3173
+
2437
3174
  try:
2438
3175
  # Submit job - different endpoint for URL vs file
2439
3176
  if is_url_input:
@@ -2466,6 +3203,9 @@ def main():
2466
3203
  clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
2467
3204
  backing_vocals_models=getattr(args, 'backing_vocals_models', None),
2468
3205
  other_stems_models=getattr(args, 'other_stems_models', None),
3206
+ # Two-phase workflow (Batch 6)
3207
+ prep_only=getattr(args, 'prep_only', False),
3208
+ keep_brand_code=keep_brand_code_value,
2469
3209
  )
2470
3210
  else:
2471
3211
  # File-based job submission
@@ -2495,6 +3235,9 @@ def main():
2495
3235
  other_stems_models=getattr(args, 'other_stems_models', None),
2496
3236
  # Existing instrumental (Batch 3)
2497
3237
  existing_instrumental=getattr(args, 'existing_instrumental', None),
3238
+ # Two-phase workflow (Batch 6)
3239
+ prep_only=getattr(args, 'prep_only', False),
3240
+ keep_brand_code=keep_brand_code_value,
2498
3241
  )
2499
3242
  job_id = result.get('job_id')
2500
3243
  style_assets = result.get('style_assets_uploaded', [])