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.
- karaoke_gen/__init__.py +32 -1
- karaoke_gen/audio_fetcher.py +1220 -67
- karaoke_gen/audio_processor.py +15 -3
- karaoke_gen/instrumental_review/server.py +154 -860
- karaoke_gen/instrumental_review/static/index.html +1529 -0
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +87 -2
- karaoke_gen/karaoke_gen.py +131 -14
- karaoke_gen/lyrics_processor.py +172 -4
- karaoke_gen/utils/bulk_cli.py +3 -0
- karaoke_gen/utils/cli_args.py +7 -4
- karaoke_gen/utils/gen_cli.py +221 -5
- karaoke_gen/utils/remote_cli.py +786 -43
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/METADATA +109 -4
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/RECORD +37 -31
- lyrics_transcriber/core/controller.py +76 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/App.tsx +6 -4
- lyrics_transcriber/frontend/src/api.ts +25 -10
- 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-BECn1o8Q.js} +1802 -553
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +1 -1
- lyrics_transcriber/output/countdown_processor.py +39 -0
- lyrics_transcriber/review/server.py +5 -5
- lyrics_transcriber/transcribers/audioshake.py +96 -7
- lyrics_transcriber/types.py +14 -12
- lyrics_transcriber/frontend/web_assets/assets/index-DdJTDWH3.js.map +0 -1
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.71.42.dist-info → karaoke_gen-0.75.53.dist-info}/licenses/LICENSE +0 -0
karaoke_gen/utils/remote_cli.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
1275
|
-
self.
|
|
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
|
-
|
|
1278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1304
|
-
|
|
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
|
-
|
|
1822
|
+
print(f"Please enter a number between 0 and {len(display_releases)}")
|
|
1307
1823
|
except ValueError:
|
|
1308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
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
|
-
#
|
|
2821
|
+
# Handle finalise-only mode (Batch 6)
|
|
2201
2822
|
if args.finalise_only:
|
|
2202
|
-
logger.
|
|
2203
|
-
|
|
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
|
-
|
|
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', [])
|