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