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.
- karaoke_gen/__init__.py +32 -1
- karaoke_gen/audio_fetcher.py +476 -56
- karaoke_gen/audio_processor.py +11 -3
- karaoke_gen/instrumental_review/server.py +154 -860
- karaoke_gen/instrumental_review/static/index.html +1506 -0
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +62 -1
- karaoke_gen/karaoke_gen.py +114 -1
- karaoke_gen/lyrics_processor.py +81 -4
- karaoke_gen/utils/bulk_cli.py +3 -0
- karaoke_gen/utils/cli_args.py +4 -2
- karaoke_gen/utils/gen_cli.py +196 -5
- karaoke_gen/utils/remote_cli.py +523 -34
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/METADATA +4 -1
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/RECORD +31 -25
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/Header.tsx +38 -12
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +17 -3
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +185 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +704 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/UpcomingWordsBar.tsx +80 -0
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +905 -0
- lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +190 -542
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/web_assets/assets/{index-DdJTDWH3.js → index-COYImAcx.js} +1722 -489
- lyrics_transcriber/frontend/web_assets/assets/index-COYImAcx.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +1 -1
- lyrics_transcriber/review/server.py +5 -5
- lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.16.dist-info}/licenses/LICENSE +0 -0
karaoke_gen/utils/remote_cli.py
CHANGED
|
@@ -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
|
-
|
|
1274
|
-
|
|
1275
|
-
self.
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
1650
|
+
print(f"Please enter a number between 0 and {len(results)}")
|
|
1307
1651
|
except ValueError:
|
|
1308
|
-
|
|
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
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
-
#
|
|
2568
|
+
# Handle finalise-only mode (Batch 6)
|
|
2201
2569
|
if args.finalise_only:
|
|
2202
|
-
logger.
|
|
2203
|
-
|
|
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
|
-
|
|
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', [])
|