karaoke-gen 0.75.54__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.
Potentially problematic release.
This version of karaoke-gen might be problematic. Click here for more details.
- karaoke_gen/__init__.py +38 -0
- karaoke_gen/audio_fetcher.py +1614 -0
- karaoke_gen/audio_processor.py +790 -0
- karaoke_gen/config.py +83 -0
- karaoke_gen/file_handler.py +387 -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 +1529 -0
- karaoke_gen/instrumental_review/waveform.py +409 -0
- karaoke_gen/karaoke_finalise/__init__.py +1 -0
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +1833 -0
- karaoke_gen/karaoke_gen.py +1026 -0
- karaoke_gen/lyrics_processor.py +474 -0
- karaoke_gen/metadata.py +160 -0
- karaoke_gen/pipeline/__init__.py +87 -0
- karaoke_gen/pipeline/base.py +215 -0
- karaoke_gen/pipeline/context.py +230 -0
- karaoke_gen/pipeline/executors/__init__.py +21 -0
- karaoke_gen/pipeline/executors/local.py +159 -0
- karaoke_gen/pipeline/executors/remote.py +257 -0
- karaoke_gen/pipeline/stages/__init__.py +27 -0
- karaoke_gen/pipeline/stages/finalize.py +202 -0
- karaoke_gen/pipeline/stages/render.py +165 -0
- karaoke_gen/pipeline/stages/screens.py +139 -0
- karaoke_gen/pipeline/stages/separation.py +191 -0
- karaoke_gen/pipeline/stages/transcription.py +191 -0
- karaoke_gen/resources/AvenirNext-Bold.ttf +0 -0
- karaoke_gen/resources/Montserrat-Bold.ttf +0 -0
- karaoke_gen/resources/Oswald-Bold.ttf +0 -0
- karaoke_gen/resources/Oswald-SemiBold.ttf +0 -0
- karaoke_gen/resources/Zurich_Cn_BT_Bold.ttf +0 -0
- karaoke_gen/style_loader.py +531 -0
- karaoke_gen/utils/__init__.py +18 -0
- karaoke_gen/utils/bulk_cli.py +492 -0
- karaoke_gen/utils/cli_args.py +432 -0
- karaoke_gen/utils/gen_cli.py +978 -0
- karaoke_gen/utils/remote_cli.py +3268 -0
- karaoke_gen/video_background_processor.py +351 -0
- karaoke_gen/video_generator.py +424 -0
- karaoke_gen-0.75.54.dist-info/METADATA +718 -0
- karaoke_gen-0.75.54.dist-info/RECORD +287 -0
- karaoke_gen-0.75.54.dist-info/WHEEL +4 -0
- karaoke_gen-0.75.54.dist-info/entry_points.txt +5 -0
- karaoke_gen-0.75.54.dist-info/licenses/LICENSE +21 -0
- lyrics_transcriber/__init__.py +10 -0
- lyrics_transcriber/cli/__init__.py +0 -0
- lyrics_transcriber/cli/cli_main.py +285 -0
- lyrics_transcriber/core/__init__.py +0 -0
- lyrics_transcriber/core/config.py +50 -0
- lyrics_transcriber/core/controller.py +594 -0
- lyrics_transcriber/correction/__init__.py +0 -0
- lyrics_transcriber/correction/agentic/__init__.py +9 -0
- lyrics_transcriber/correction/agentic/adapter.py +71 -0
- lyrics_transcriber/correction/agentic/agent.py +313 -0
- lyrics_transcriber/correction/agentic/feedback/aggregator.py +12 -0
- lyrics_transcriber/correction/agentic/feedback/collector.py +17 -0
- lyrics_transcriber/correction/agentic/feedback/retention.py +24 -0
- lyrics_transcriber/correction/agentic/feedback/store.py +76 -0
- lyrics_transcriber/correction/agentic/handlers/__init__.py +24 -0
- lyrics_transcriber/correction/agentic/handlers/ambiguous.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/background_vocals.py +68 -0
- lyrics_transcriber/correction/agentic/handlers/base.py +51 -0
- lyrics_transcriber/correction/agentic/handlers/complex_multi_error.py +46 -0
- lyrics_transcriber/correction/agentic/handlers/extra_words.py +74 -0
- lyrics_transcriber/correction/agentic/handlers/no_error.py +42 -0
- lyrics_transcriber/correction/agentic/handlers/punctuation.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/registry.py +60 -0
- lyrics_transcriber/correction/agentic/handlers/repeated_section.py +44 -0
- lyrics_transcriber/correction/agentic/handlers/sound_alike.py +126 -0
- lyrics_transcriber/correction/agentic/models/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/models/ai_correction.py +31 -0
- lyrics_transcriber/correction/agentic/models/correction_session.py +30 -0
- lyrics_transcriber/correction/agentic/models/enums.py +38 -0
- lyrics_transcriber/correction/agentic/models/human_feedback.py +30 -0
- lyrics_transcriber/correction/agentic/models/learning_data.py +26 -0
- lyrics_transcriber/correction/agentic/models/observability_metrics.py +28 -0
- lyrics_transcriber/correction/agentic/models/schemas.py +46 -0
- lyrics_transcriber/correction/agentic/models/utils.py +19 -0
- lyrics_transcriber/correction/agentic/observability/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/observability/langfuse_integration.py +35 -0
- lyrics_transcriber/correction/agentic/observability/metrics.py +46 -0
- lyrics_transcriber/correction/agentic/observability/performance.py +19 -0
- lyrics_transcriber/correction/agentic/prompts/__init__.py +2 -0
- lyrics_transcriber/correction/agentic/prompts/classifier.py +227 -0
- lyrics_transcriber/correction/agentic/providers/__init__.py +6 -0
- lyrics_transcriber/correction/agentic/providers/base.py +36 -0
- lyrics_transcriber/correction/agentic/providers/circuit_breaker.py +145 -0
- lyrics_transcriber/correction/agentic/providers/config.py +73 -0
- lyrics_transcriber/correction/agentic/providers/constants.py +24 -0
- lyrics_transcriber/correction/agentic/providers/health.py +28 -0
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +212 -0
- lyrics_transcriber/correction/agentic/providers/model_factory.py +209 -0
- lyrics_transcriber/correction/agentic/providers/response_cache.py +218 -0
- lyrics_transcriber/correction/agentic/providers/response_parser.py +111 -0
- lyrics_transcriber/correction/agentic/providers/retry_executor.py +127 -0
- lyrics_transcriber/correction/agentic/router.py +35 -0
- lyrics_transcriber/correction/agentic/workflows/__init__.py +5 -0
- lyrics_transcriber/correction/agentic/workflows/consensus_workflow.py +24 -0
- lyrics_transcriber/correction/agentic/workflows/correction_graph.py +59 -0
- lyrics_transcriber/correction/agentic/workflows/feedback_workflow.py +24 -0
- lyrics_transcriber/correction/anchor_sequence.py +919 -0
- lyrics_transcriber/correction/corrector.py +760 -0
- lyrics_transcriber/correction/feedback/__init__.py +2 -0
- lyrics_transcriber/correction/feedback/schemas.py +107 -0
- lyrics_transcriber/correction/feedback/store.py +236 -0
- lyrics_transcriber/correction/handlers/__init__.py +0 -0
- lyrics_transcriber/correction/handlers/base.py +52 -0
- lyrics_transcriber/correction/handlers/extend_anchor.py +149 -0
- lyrics_transcriber/correction/handlers/levenshtein.py +189 -0
- lyrics_transcriber/correction/handlers/llm.py +293 -0
- lyrics_transcriber/correction/handlers/llm_providers.py +60 -0
- lyrics_transcriber/correction/handlers/no_space_punct_match.py +154 -0
- lyrics_transcriber/correction/handlers/relaxed_word_count_match.py +85 -0
- lyrics_transcriber/correction/handlers/repeat.py +88 -0
- lyrics_transcriber/correction/handlers/sound_alike.py +259 -0
- lyrics_transcriber/correction/handlers/syllables_match.py +252 -0
- lyrics_transcriber/correction/handlers/word_count_match.py +80 -0
- lyrics_transcriber/correction/handlers/word_operations.py +187 -0
- lyrics_transcriber/correction/operations.py +352 -0
- lyrics_transcriber/correction/phrase_analyzer.py +435 -0
- lyrics_transcriber/correction/text_utils.py +30 -0
- lyrics_transcriber/frontend/.gitignore +23 -0
- lyrics_transcriber/frontend/.yarn/releases/yarn-4.7.0.cjs +935 -0
- lyrics_transcriber/frontend/.yarnrc.yml +3 -0
- lyrics_transcriber/frontend/README.md +50 -0
- lyrics_transcriber/frontend/REPLACE_ALL_FUNCTIONALITY.md +210 -0
- lyrics_transcriber/frontend/__init__.py +25 -0
- lyrics_transcriber/frontend/eslint.config.js +28 -0
- lyrics_transcriber/frontend/index.html +18 -0
- lyrics_transcriber/frontend/package.json +42 -0
- lyrics_transcriber/frontend/public/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/public/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/public/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/public/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/public/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/public/favicon.ico +0 -0
- lyrics_transcriber/frontend/public/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/src/App.tsx +214 -0
- lyrics_transcriber/frontend/src/api.ts +254 -0
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +77 -0
- lyrics_transcriber/frontend/src/components/AddLyricsModal.tsx +114 -0
- lyrics_transcriber/frontend/src/components/AgenticCorrectionMetrics.tsx +204 -0
- lyrics_transcriber/frontend/src/components/AudioPlayer.tsx +180 -0
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +167 -0
- lyrics_transcriber/frontend/src/components/CorrectionAnnotationModal.tsx +359 -0
- lyrics_transcriber/frontend/src/components/CorrectionDetailCard.tsx +281 -0
- lyrics_transcriber/frontend/src/components/CorrectionMetrics.tsx +162 -0
- lyrics_transcriber/frontend/src/components/DurationTimelineView.tsx +257 -0
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +68 -0
- lyrics_transcriber/frontend/src/components/EditModal.tsx +702 -0
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +496 -0
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +379 -0
- lyrics_transcriber/frontend/src/components/FileUpload.tsx +77 -0
- lyrics_transcriber/frontend/src/components/FindReplaceModal.tsx +467 -0
- lyrics_transcriber/frontend/src/components/Header.tsx +413 -0
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +1387 -0
- 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/MetricsDashboard.tsx +51 -0
- lyrics_transcriber/frontend/src/components/ModeSelectionModal.tsx +127 -0
- lyrics_transcriber/frontend/src/components/ModeSelector.tsx +67 -0
- lyrics_transcriber/frontend/src/components/ModelSelector.tsx +23 -0
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +144 -0
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +268 -0
- lyrics_transcriber/frontend/src/components/ReplaceAllLyricsModal.tsx +336 -0
- lyrics_transcriber/frontend/src/components/ReviewChangesModal.tsx +354 -0
- lyrics_transcriber/frontend/src/components/SegmentDetailsModal.tsx +64 -0
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +376 -0
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +131 -0
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +256 -0
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +187 -0
- lyrics_transcriber/frontend/src/components/shared/components/HighlightedText.tsx +379 -0
- lyrics_transcriber/frontend/src/components/shared/components/SourceSelector.tsx +56 -0
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +87 -0
- lyrics_transcriber/frontend/src/components/shared/constants.ts +20 -0
- lyrics_transcriber/frontend/src/components/shared/hooks/useWordClick.ts +180 -0
- lyrics_transcriber/frontend/src/components/shared/styles.ts +13 -0
- lyrics_transcriber/frontend/src/components/shared/types.js +2 -0
- lyrics_transcriber/frontend/src/components/shared/types.ts +129 -0
- lyrics_transcriber/frontend/src/components/shared/utils/keyboardHandlers.ts +177 -0
- lyrics_transcriber/frontend/src/components/shared/utils/localStorage.ts +78 -0
- lyrics_transcriber/frontend/src/components/shared/utils/referenceLineCalculator.ts +75 -0
- lyrics_transcriber/frontend/src/components/shared/utils/segmentOperations.ts +360 -0
- lyrics_transcriber/frontend/src/components/shared/utils/timingUtils.ts +110 -0
- lyrics_transcriber/frontend/src/components/shared/utils/wordUtils.ts +22 -0
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +435 -0
- lyrics_transcriber/frontend/src/main.tsx +17 -0
- lyrics_transcriber/frontend/src/theme.ts +177 -0
- lyrics_transcriber/frontend/src/types/global.d.ts +9 -0
- lyrics_transcriber/frontend/src/types.js +2 -0
- lyrics_transcriber/frontend/src/types.ts +199 -0
- lyrics_transcriber/frontend/src/validation.ts +132 -0
- lyrics_transcriber/frontend/src/vite-env.d.ts +1 -0
- lyrics_transcriber/frontend/tsconfig.app.json +26 -0
- lyrics_transcriber/frontend/tsconfig.json +25 -0
- lyrics_transcriber/frontend/tsconfig.node.json +23 -0
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -0
- lyrics_transcriber/frontend/update_version.js +11 -0
- lyrics_transcriber/frontend/vite.config.d.ts +2 -0
- lyrics_transcriber/frontend/vite.config.js +10 -0
- lyrics_transcriber/frontend/vite.config.ts +11 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-192x192.png +0 -0
- lyrics_transcriber/frontend/web_assets/android-chrome-512x512.png +0 -0
- lyrics_transcriber/frontend/web_assets/apple-touch-icon.png +0 -0
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js +43288 -0
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/favicon-16x16.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon-32x32.png +0 -0
- lyrics_transcriber/frontend/web_assets/favicon.ico +0 -0
- lyrics_transcriber/frontend/web_assets/index.html +18 -0
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.png +0 -0
- lyrics_transcriber/frontend/yarn.lock +3752 -0
- lyrics_transcriber/lyrics/__init__.py +0 -0
- lyrics_transcriber/lyrics/base_lyrics_provider.py +211 -0
- lyrics_transcriber/lyrics/file_provider.py +95 -0
- lyrics_transcriber/lyrics/genius.py +384 -0
- lyrics_transcriber/lyrics/lrclib.py +231 -0
- lyrics_transcriber/lyrics/musixmatch.py +156 -0
- lyrics_transcriber/lyrics/spotify.py +290 -0
- lyrics_transcriber/lyrics/user_input_provider.py +44 -0
- lyrics_transcriber/output/__init__.py +0 -0
- lyrics_transcriber/output/ass/__init__.py +21 -0
- lyrics_transcriber/output/ass/ass.py +2088 -0
- lyrics_transcriber/output/ass/ass_specs.txt +732 -0
- lyrics_transcriber/output/ass/config.py +180 -0
- lyrics_transcriber/output/ass/constants.py +23 -0
- lyrics_transcriber/output/ass/event.py +94 -0
- lyrics_transcriber/output/ass/formatters.py +132 -0
- lyrics_transcriber/output/ass/lyrics_line.py +265 -0
- lyrics_transcriber/output/ass/lyrics_screen.py +252 -0
- lyrics_transcriber/output/ass/section_detector.py +89 -0
- lyrics_transcriber/output/ass/section_screen.py +106 -0
- lyrics_transcriber/output/ass/style.py +187 -0
- lyrics_transcriber/output/cdg.py +619 -0
- lyrics_transcriber/output/cdgmaker/__init__.py +0 -0
- lyrics_transcriber/output/cdgmaker/cdg.py +262 -0
- lyrics_transcriber/output/cdgmaker/composer.py +2260 -0
- lyrics_transcriber/output/cdgmaker/config.py +151 -0
- lyrics_transcriber/output/cdgmaker/images/instrumental.png +0 -0
- lyrics_transcriber/output/cdgmaker/images/intro.png +0 -0
- lyrics_transcriber/output/cdgmaker/pack.py +507 -0
- lyrics_transcriber/output/cdgmaker/render.py +346 -0
- lyrics_transcriber/output/cdgmaker/transitions/centertexttoplogobottomtext.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circlein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/circleout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/fizzle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/largecentertexttoplogo.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/rectangle.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/spiral.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/topleftmusicalnotes.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipein.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeleft.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wipeout.png +0 -0
- lyrics_transcriber/output/cdgmaker/transitions/wiperight.png +0 -0
- lyrics_transcriber/output/cdgmaker/utils.py +132 -0
- lyrics_transcriber/output/countdown_processor.py +306 -0
- lyrics_transcriber/output/fonts/AvenirNext-Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSans-VariableFont_opsz,wght.ttf +0 -0
- lyrics_transcriber/output/fonts/DMSerifDisplay-Regular.ttf +0 -0
- lyrics_transcriber/output/fonts/Oswald-SemiBold.ttf +0 -0
- lyrics_transcriber/output/fonts/Zurich_Cn_BT_Bold.ttf +0 -0
- lyrics_transcriber/output/fonts/arial.ttf +0 -0
- lyrics_transcriber/output/fonts/georgia.ttf +0 -0
- lyrics_transcriber/output/fonts/verdana.ttf +0 -0
- lyrics_transcriber/output/generator.py +257 -0
- lyrics_transcriber/output/lrc_to_cdg.py +61 -0
- lyrics_transcriber/output/lyrics_file.py +102 -0
- lyrics_transcriber/output/plain_text.py +96 -0
- lyrics_transcriber/output/segment_resizer.py +431 -0
- lyrics_transcriber/output/subtitles.py +397 -0
- lyrics_transcriber/output/video.py +544 -0
- lyrics_transcriber/review/__init__.py +0 -0
- lyrics_transcriber/review/server.py +676 -0
- lyrics_transcriber/storage/__init__.py +0 -0
- lyrics_transcriber/storage/dropbox.py +225 -0
- lyrics_transcriber/transcribers/__init__.py +0 -0
- lyrics_transcriber/transcribers/audioshake.py +379 -0
- lyrics_transcriber/transcribers/base_transcriber.py +157 -0
- lyrics_transcriber/transcribers/whisper.py +330 -0
- lyrics_transcriber/types.py +650 -0
- lyrics_transcriber/utils/__init__.py +0 -0
- lyrics_transcriber/utils/word_utils.py +27 -0
|
@@ -0,0 +1,3268 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""
|
|
3
|
+
Remote CLI for karaoke-gen - Submit jobs to a cloud-hosted backend.
|
|
4
|
+
|
|
5
|
+
This CLI provides the same interface as karaoke-gen but processes jobs on a cloud backend.
|
|
6
|
+
Set KARAOKE_GEN_URL environment variable to your cloud backend URL.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
karaoke-gen-remote <filepath> <artist> <title>
|
|
10
|
+
karaoke-gen-remote --resume <job_id>
|
|
11
|
+
karaoke-gen-remote --retry <job_id>
|
|
12
|
+
karaoke-gen-remote --list
|
|
13
|
+
karaoke-gen-remote --cancel <job_id>
|
|
14
|
+
karaoke-gen-remote --delete <job_id>
|
|
15
|
+
"""
|
|
16
|
+
# Suppress SyntaxWarnings from third-party dependencies (pydub, syrics)
|
|
17
|
+
# that have invalid escape sequences in regex patterns (not yet fixed for Python 3.12+)
|
|
18
|
+
import warnings
|
|
19
|
+
warnings.filterwarnings("ignore", category=SyntaxWarning, module="pydub")
|
|
20
|
+
warnings.filterwarnings("ignore", category=SyntaxWarning, module="syrics")
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
import platform
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
import urllib.parse
|
|
30
|
+
import webbrowser
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from enum import Enum
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Dict, List, Optional
|
|
35
|
+
|
|
36
|
+
import requests
|
|
37
|
+
|
|
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
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JobStatus(str, Enum):
|
|
47
|
+
"""Job status values (matching backend)."""
|
|
48
|
+
PENDING = "pending"
|
|
49
|
+
# Audio search states (Batch 5)
|
|
50
|
+
SEARCHING_AUDIO = "searching_audio"
|
|
51
|
+
AWAITING_AUDIO_SELECTION = "awaiting_audio_selection"
|
|
52
|
+
DOWNLOADING_AUDIO = "downloading_audio"
|
|
53
|
+
# Main workflow
|
|
54
|
+
DOWNLOADING = "downloading"
|
|
55
|
+
SEPARATING_STAGE1 = "separating_stage1"
|
|
56
|
+
SEPARATING_STAGE2 = "separating_stage2"
|
|
57
|
+
AUDIO_COMPLETE = "audio_complete"
|
|
58
|
+
TRANSCRIBING = "transcribing"
|
|
59
|
+
CORRECTING = "correcting"
|
|
60
|
+
LYRICS_COMPLETE = "lyrics_complete"
|
|
61
|
+
GENERATING_SCREENS = "generating_screens"
|
|
62
|
+
APPLYING_PADDING = "applying_padding"
|
|
63
|
+
AWAITING_REVIEW = "awaiting_review"
|
|
64
|
+
IN_REVIEW = "in_review"
|
|
65
|
+
REVIEW_COMPLETE = "review_complete"
|
|
66
|
+
RENDERING_VIDEO = "rendering_video"
|
|
67
|
+
AWAITING_INSTRUMENTAL_SELECTION = "awaiting_instrumental_selection"
|
|
68
|
+
INSTRUMENTAL_SELECTED = "instrumental_selected"
|
|
69
|
+
GENERATING_VIDEO = "generating_video"
|
|
70
|
+
ENCODING = "encoding"
|
|
71
|
+
PACKAGING = "packaging"
|
|
72
|
+
UPLOADING = "uploading"
|
|
73
|
+
NOTIFYING = "notifying"
|
|
74
|
+
COMPLETE = "complete"
|
|
75
|
+
PREP_COMPLETE = "prep_complete" # Batch 6: Prep-only jobs stop here
|
|
76
|
+
FAILED = "failed"
|
|
77
|
+
CANCELLED = "cancelled"
|
|
78
|
+
ERROR = "error"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Config:
|
|
83
|
+
"""Configuration for the remote CLI."""
|
|
84
|
+
service_url: str
|
|
85
|
+
review_ui_url: str
|
|
86
|
+
poll_interval: int
|
|
87
|
+
output_dir: str
|
|
88
|
+
auth_token: Optional[str] = None
|
|
89
|
+
non_interactive: bool = False # Auto-accept defaults for testing
|
|
90
|
+
# Job tracking metadata (sent as headers for filtering/tracking)
|
|
91
|
+
environment: str = "" # test/production/development
|
|
92
|
+
client_id: str = "" # Customer/user identifier
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class RemoteKaraokeClient:
|
|
96
|
+
"""Client for interacting with the karaoke-gen cloud backend."""
|
|
97
|
+
|
|
98
|
+
ALLOWED_AUDIO_EXTENSIONS = {'.mp3', '.wav', '.flac', '.m4a', '.ogg', '.aac'}
|
|
99
|
+
ALLOWED_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp'}
|
|
100
|
+
ALLOWED_FONT_EXTENSIONS = {'.ttf', '.otf', '.woff', '.woff2'}
|
|
101
|
+
|
|
102
|
+
def __init__(self, config: Config, logger: logging.Logger):
|
|
103
|
+
self.config = config
|
|
104
|
+
self.logger = logger
|
|
105
|
+
self.session = requests.Session()
|
|
106
|
+
self._setup_auth()
|
|
107
|
+
|
|
108
|
+
def _setup_auth(self) -> None:
|
|
109
|
+
"""Set up authentication and tracking headers."""
|
|
110
|
+
if self.config.auth_token:
|
|
111
|
+
self.session.headers['Authorization'] = f'Bearer {self.config.auth_token}'
|
|
112
|
+
|
|
113
|
+
# Set up job tracking headers (used for filtering and operational management)
|
|
114
|
+
if self.config.environment:
|
|
115
|
+
self.session.headers['X-Environment'] = self.config.environment
|
|
116
|
+
if self.config.client_id:
|
|
117
|
+
self.session.headers['X-Client-ID'] = self.config.client_id
|
|
118
|
+
|
|
119
|
+
# Always include CLI version as user-agent
|
|
120
|
+
from importlib import metadata
|
|
121
|
+
try:
|
|
122
|
+
version = metadata.version("karaoke-gen")
|
|
123
|
+
except metadata.PackageNotFoundError:
|
|
124
|
+
version = "unknown"
|
|
125
|
+
self.session.headers['User-Agent'] = f'karaoke-gen-remote/{version}'
|
|
126
|
+
|
|
127
|
+
def _get_auth_token_from_gcloud(self) -> Optional[str]:
|
|
128
|
+
"""Get auth token from gcloud CLI."""
|
|
129
|
+
try:
|
|
130
|
+
result = subprocess.run(
|
|
131
|
+
['gcloud', 'auth', 'print-identity-token'],
|
|
132
|
+
capture_output=True,
|
|
133
|
+
text=True,
|
|
134
|
+
check=True
|
|
135
|
+
)
|
|
136
|
+
return result.stdout.strip()
|
|
137
|
+
except subprocess.CalledProcessError:
|
|
138
|
+
return None
|
|
139
|
+
except FileNotFoundError:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
def refresh_auth(self) -> bool:
|
|
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
|
|
155
|
+
token = self._get_auth_token_from_gcloud()
|
|
156
|
+
if token:
|
|
157
|
+
self.config.auth_token = token
|
|
158
|
+
self.session.headers['Authorization'] = f'Bearer {token}'
|
|
159
|
+
return True
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
|
163
|
+
"""Make an authenticated request."""
|
|
164
|
+
url = f"{self.config.service_url}{endpoint}"
|
|
165
|
+
response = self.session.request(method, url, **kwargs)
|
|
166
|
+
return response
|
|
167
|
+
|
|
168
|
+
def _upload_file_to_signed_url(self, signed_url: str, file_path: str, content_type: str) -> bool:
|
|
169
|
+
"""
|
|
170
|
+
Upload a file directly to GCS using a signed URL.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
signed_url: The signed URL from the backend
|
|
174
|
+
file_path: Local path to the file to upload
|
|
175
|
+
content_type: MIME type for the Content-Type header
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
True if upload succeeded, False otherwise
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
with open(file_path, 'rb') as f:
|
|
182
|
+
# Use a fresh requests session (not self.session) because
|
|
183
|
+
# signed URLs should not have our auth headers
|
|
184
|
+
response = requests.put(
|
|
185
|
+
signed_url,
|
|
186
|
+
data=f,
|
|
187
|
+
headers={'Content-Type': content_type},
|
|
188
|
+
timeout=600 # 10 minutes for large files
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
if response.status_code in (200, 201):
|
|
192
|
+
return True
|
|
193
|
+
else:
|
|
194
|
+
self.logger.error(f"Failed to upload to signed URL: HTTP {response.status_code} - {response.text}")
|
|
195
|
+
return False
|
|
196
|
+
except Exception as e:
|
|
197
|
+
self.logger.error(f"Error uploading to signed URL: {e}")
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
def _get_content_type(self, file_path: str) -> str:
|
|
201
|
+
"""Get the MIME content type for a file based on its extension."""
|
|
202
|
+
ext = Path(file_path).suffix.lower()
|
|
203
|
+
|
|
204
|
+
content_types = {
|
|
205
|
+
# Audio
|
|
206
|
+
'.mp3': 'audio/mpeg',
|
|
207
|
+
'.wav': 'audio/wav',
|
|
208
|
+
'.flac': 'audio/flac',
|
|
209
|
+
'.m4a': 'audio/mp4',
|
|
210
|
+
'.ogg': 'audio/ogg',
|
|
211
|
+
'.aac': 'audio/aac',
|
|
212
|
+
# Images
|
|
213
|
+
'.png': 'image/png',
|
|
214
|
+
'.jpg': 'image/jpeg',
|
|
215
|
+
'.jpeg': 'image/jpeg',
|
|
216
|
+
'.gif': 'image/gif',
|
|
217
|
+
'.webp': 'image/webp',
|
|
218
|
+
# Fonts
|
|
219
|
+
'.ttf': 'font/ttf',
|
|
220
|
+
'.otf': 'font/otf',
|
|
221
|
+
'.woff': 'font/woff',
|
|
222
|
+
'.woff2': 'font/woff2',
|
|
223
|
+
# Other
|
|
224
|
+
'.json': 'application/json',
|
|
225
|
+
'.txt': 'text/plain',
|
|
226
|
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
227
|
+
'.rtf': 'application/rtf',
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return content_types.get(ext, 'application/octet-stream')
|
|
231
|
+
|
|
232
|
+
def _parse_style_params(self, style_params_path: str) -> Dict[str, str]:
|
|
233
|
+
"""
|
|
234
|
+
Parse style_params.json and extract file paths that need to be uploaded.
|
|
235
|
+
|
|
236
|
+
Returns a dict mapping asset_key -> local_file_path for files that exist.
|
|
237
|
+
"""
|
|
238
|
+
asset_files = {}
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
with open(style_params_path, 'r') as f:
|
|
242
|
+
style_params = json.load(f)
|
|
243
|
+
except Exception as e:
|
|
244
|
+
self.logger.warning(f"Failed to parse style_params.json: {e}")
|
|
245
|
+
return asset_files
|
|
246
|
+
|
|
247
|
+
# Map of style param paths to asset keys
|
|
248
|
+
file_mappings = [
|
|
249
|
+
('intro', 'background_image', 'style_intro_background'),
|
|
250
|
+
('intro', 'font', 'style_font'),
|
|
251
|
+
('karaoke', 'background_image', 'style_karaoke_background'),
|
|
252
|
+
('karaoke', 'font_path', 'style_font'),
|
|
253
|
+
('end', 'background_image', 'style_end_background'),
|
|
254
|
+
('end', 'font', 'style_font'),
|
|
255
|
+
('cdg', 'font_path', 'style_font'),
|
|
256
|
+
('cdg', 'instrumental_background', 'style_cdg_instrumental_background'),
|
|
257
|
+
('cdg', 'title_screen_background', 'style_cdg_title_background'),
|
|
258
|
+
('cdg', 'outro_background', 'style_cdg_outro_background'),
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
for section, key, asset_key in file_mappings:
|
|
262
|
+
if section in style_params and key in style_params[section]:
|
|
263
|
+
file_path = style_params[section][key]
|
|
264
|
+
if file_path and os.path.isfile(file_path):
|
|
265
|
+
# Don't duplicate font uploads
|
|
266
|
+
if asset_key not in asset_files:
|
|
267
|
+
asset_files[asset_key] = file_path
|
|
268
|
+
self.logger.info(f" Found style asset: {asset_key} -> {file_path}")
|
|
269
|
+
|
|
270
|
+
return asset_files
|
|
271
|
+
|
|
272
|
+
def submit_job_from_url(
|
|
273
|
+
self,
|
|
274
|
+
url: str,
|
|
275
|
+
artist: Optional[str] = None,
|
|
276
|
+
title: Optional[str] = None,
|
|
277
|
+
enable_cdg: bool = True,
|
|
278
|
+
enable_txt: bool = True,
|
|
279
|
+
brand_prefix: Optional[str] = None,
|
|
280
|
+
discord_webhook_url: Optional[str] = None,
|
|
281
|
+
youtube_description: Optional[str] = None,
|
|
282
|
+
organised_dir_rclone_root: Optional[str] = None,
|
|
283
|
+
enable_youtube_upload: bool = False,
|
|
284
|
+
dropbox_path: Optional[str] = None,
|
|
285
|
+
gdrive_folder_id: Optional[str] = None,
|
|
286
|
+
lyrics_artist: Optional[str] = None,
|
|
287
|
+
lyrics_title: Optional[str] = None,
|
|
288
|
+
subtitle_offset_ms: int = 0,
|
|
289
|
+
clean_instrumental_model: Optional[str] = None,
|
|
290
|
+
backing_vocals_models: Optional[list] = None,
|
|
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,
|
|
295
|
+
) -> Dict[str, Any]:
|
|
296
|
+
"""
|
|
297
|
+
Submit a new karaoke generation job from a YouTube/online URL.
|
|
298
|
+
|
|
299
|
+
The backend will download the audio from the URL and process it.
|
|
300
|
+
Artist and title will be auto-detected from the URL if not provided.
|
|
301
|
+
|
|
302
|
+
Note: Custom style configuration is not supported for URL-based jobs.
|
|
303
|
+
If you need custom styles, download the audio locally first and use
|
|
304
|
+
the regular file upload flow with submit_job().
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
url: YouTube or other video URL to download audio from
|
|
308
|
+
artist: Artist name (optional - auto-detected if not provided)
|
|
309
|
+
title: Song title (optional - auto-detected if not provided)
|
|
310
|
+
enable_cdg: Generate CDG+MP3 package
|
|
311
|
+
enable_txt: Generate TXT+MP3 package
|
|
312
|
+
brand_prefix: Brand code prefix (e.g., "NOMAD")
|
|
313
|
+
discord_webhook_url: Discord webhook for notifications
|
|
314
|
+
youtube_description: YouTube video description
|
|
315
|
+
organised_dir_rclone_root: Legacy rclone path (deprecated)
|
|
316
|
+
enable_youtube_upload: Enable YouTube upload
|
|
317
|
+
dropbox_path: Dropbox folder path for organized output (native API)
|
|
318
|
+
gdrive_folder_id: Google Drive folder ID for public share (native API)
|
|
319
|
+
lyrics_artist: Override artist name for lyrics search
|
|
320
|
+
lyrics_title: Override title for lyrics search
|
|
321
|
+
subtitle_offset_ms: Subtitle timing offset in milliseconds
|
|
322
|
+
clean_instrumental_model: Model for clean instrumental separation
|
|
323
|
+
backing_vocals_models: List of models for backing vocals separation
|
|
324
|
+
other_stems_models: List of models for other stems (bass, drums, etc.)
|
|
325
|
+
"""
|
|
326
|
+
self.logger.info(f"Submitting URL-based job: {url}")
|
|
327
|
+
|
|
328
|
+
# Build request payload
|
|
329
|
+
create_request = {
|
|
330
|
+
'url': url,
|
|
331
|
+
'enable_cdg': enable_cdg,
|
|
332
|
+
'enable_txt': enable_txt,
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if artist:
|
|
336
|
+
create_request['artist'] = artist
|
|
337
|
+
if title:
|
|
338
|
+
create_request['title'] = title
|
|
339
|
+
if brand_prefix:
|
|
340
|
+
create_request['brand_prefix'] = brand_prefix
|
|
341
|
+
if discord_webhook_url:
|
|
342
|
+
create_request['discord_webhook_url'] = discord_webhook_url
|
|
343
|
+
if youtube_description:
|
|
344
|
+
create_request['youtube_description'] = youtube_description
|
|
345
|
+
if enable_youtube_upload:
|
|
346
|
+
create_request['enable_youtube_upload'] = enable_youtube_upload
|
|
347
|
+
if dropbox_path:
|
|
348
|
+
create_request['dropbox_path'] = dropbox_path
|
|
349
|
+
if gdrive_folder_id:
|
|
350
|
+
create_request['gdrive_folder_id'] = gdrive_folder_id
|
|
351
|
+
if organised_dir_rclone_root:
|
|
352
|
+
create_request['organised_dir_rclone_root'] = organised_dir_rclone_root
|
|
353
|
+
if lyrics_artist:
|
|
354
|
+
create_request['lyrics_artist'] = lyrics_artist
|
|
355
|
+
if lyrics_title:
|
|
356
|
+
create_request['lyrics_title'] = lyrics_title
|
|
357
|
+
if subtitle_offset_ms != 0:
|
|
358
|
+
create_request['subtitle_offset_ms'] = subtitle_offset_ms
|
|
359
|
+
if clean_instrumental_model:
|
|
360
|
+
create_request['clean_instrumental_model'] = clean_instrumental_model
|
|
361
|
+
if backing_vocals_models:
|
|
362
|
+
create_request['backing_vocals_models'] = backing_vocals_models
|
|
363
|
+
if other_stems_models:
|
|
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
|
|
370
|
+
|
|
371
|
+
self.logger.info(f"Creating URL-based job at {self.config.service_url}/api/jobs/create-from-url")
|
|
372
|
+
|
|
373
|
+
response = self._request('POST', '/api/jobs/create-from-url', json=create_request)
|
|
374
|
+
|
|
375
|
+
if response.status_code != 200:
|
|
376
|
+
try:
|
|
377
|
+
error_detail = response.json()
|
|
378
|
+
except Exception:
|
|
379
|
+
error_detail = response.text
|
|
380
|
+
raise RuntimeError(f"Error creating job from URL: {error_detail}")
|
|
381
|
+
|
|
382
|
+
result = response.json()
|
|
383
|
+
if result.get('status') != 'success':
|
|
384
|
+
raise RuntimeError(f"Error creating job from URL: {result}")
|
|
385
|
+
|
|
386
|
+
job_id = result['job_id']
|
|
387
|
+
detected_artist = result.get('detected_artist')
|
|
388
|
+
detected_title = result.get('detected_title')
|
|
389
|
+
|
|
390
|
+
self.logger.info(f"Job {job_id} created from URL")
|
|
391
|
+
if detected_artist:
|
|
392
|
+
self.logger.info(f" Artist: {detected_artist}")
|
|
393
|
+
if detected_title:
|
|
394
|
+
self.logger.info(f" Title: {detected_title}")
|
|
395
|
+
|
|
396
|
+
return result
|
|
397
|
+
|
|
398
|
+
def submit_job(
|
|
399
|
+
self,
|
|
400
|
+
filepath: str,
|
|
401
|
+
artist: str,
|
|
402
|
+
title: str,
|
|
403
|
+
style_params_path: Optional[str] = None,
|
|
404
|
+
enable_cdg: bool = True,
|
|
405
|
+
enable_txt: bool = True,
|
|
406
|
+
brand_prefix: Optional[str] = None,
|
|
407
|
+
discord_webhook_url: Optional[str] = None,
|
|
408
|
+
youtube_description: Optional[str] = None,
|
|
409
|
+
organised_dir_rclone_root: Optional[str] = None,
|
|
410
|
+
enable_youtube_upload: bool = False,
|
|
411
|
+
# Native API distribution (uses server-side credentials)
|
|
412
|
+
dropbox_path: Optional[str] = None,
|
|
413
|
+
gdrive_folder_id: Optional[str] = None,
|
|
414
|
+
# Lyrics configuration
|
|
415
|
+
lyrics_artist: Optional[str] = None,
|
|
416
|
+
lyrics_title: Optional[str] = None,
|
|
417
|
+
lyrics_file: Optional[str] = None,
|
|
418
|
+
subtitle_offset_ms: int = 0,
|
|
419
|
+
# Audio separation model configuration
|
|
420
|
+
clean_instrumental_model: Optional[str] = None,
|
|
421
|
+
backing_vocals_models: Optional[list] = None,
|
|
422
|
+
other_stems_models: Optional[list] = None,
|
|
423
|
+
# Existing instrumental (Batch 3)
|
|
424
|
+
existing_instrumental: Optional[str] = None,
|
|
425
|
+
# Two-phase workflow (Batch 6)
|
|
426
|
+
prep_only: bool = False,
|
|
427
|
+
keep_brand_code: Optional[str] = None,
|
|
428
|
+
) -> Dict[str, Any]:
|
|
429
|
+
"""
|
|
430
|
+
Submit a new karaoke generation job with optional style configuration.
|
|
431
|
+
|
|
432
|
+
Uses signed URL upload flow to bypass Cloud Run's 32MB request body limit:
|
|
433
|
+
1. Create job and get signed upload URLs from backend
|
|
434
|
+
2. Upload files directly to GCS using signed URLs
|
|
435
|
+
3. Notify backend that uploads are complete to start processing
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
filepath: Path to audio file
|
|
439
|
+
artist: Artist name
|
|
440
|
+
title: Song title
|
|
441
|
+
style_params_path: Path to style_params.json (optional)
|
|
442
|
+
enable_cdg: Generate CDG+MP3 package
|
|
443
|
+
enable_txt: Generate TXT+MP3 package
|
|
444
|
+
brand_prefix: Brand code prefix (e.g., "NOMAD")
|
|
445
|
+
discord_webhook_url: Discord webhook for notifications
|
|
446
|
+
youtube_description: YouTube video description
|
|
447
|
+
organised_dir_rclone_root: Legacy rclone path (deprecated)
|
|
448
|
+
enable_youtube_upload: Enable YouTube upload
|
|
449
|
+
dropbox_path: Dropbox folder path for organized output (native API)
|
|
450
|
+
gdrive_folder_id: Google Drive folder ID for public share (native API)
|
|
451
|
+
lyrics_artist: Override artist name for lyrics search
|
|
452
|
+
lyrics_title: Override title for lyrics search
|
|
453
|
+
lyrics_file: Path to user-provided lyrics file
|
|
454
|
+
subtitle_offset_ms: Subtitle timing offset in milliseconds
|
|
455
|
+
clean_instrumental_model: Model for clean instrumental separation
|
|
456
|
+
backing_vocals_models: List of models for backing vocals separation
|
|
457
|
+
other_stems_models: List of models for other stems (bass, drums, etc.)
|
|
458
|
+
existing_instrumental: Path to existing instrumental file to use instead of AI separation
|
|
459
|
+
"""
|
|
460
|
+
file_path = Path(filepath)
|
|
461
|
+
|
|
462
|
+
if not file_path.exists():
|
|
463
|
+
raise FileNotFoundError(f"File not found: {filepath}")
|
|
464
|
+
|
|
465
|
+
ext = file_path.suffix.lower()
|
|
466
|
+
if ext not in self.ALLOWED_AUDIO_EXTENSIONS:
|
|
467
|
+
raise ValueError(
|
|
468
|
+
f"Unsupported file type: {ext}. "
|
|
469
|
+
f"Allowed: {', '.join(self.ALLOWED_AUDIO_EXTENSIONS)}"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
# Step 1: Build list of files to upload
|
|
473
|
+
files_info = []
|
|
474
|
+
local_files = {} # file_type -> local_path
|
|
475
|
+
|
|
476
|
+
# Main audio file
|
|
477
|
+
audio_content_type = self._get_content_type(filepath)
|
|
478
|
+
files_info.append({
|
|
479
|
+
'filename': file_path.name,
|
|
480
|
+
'content_type': audio_content_type,
|
|
481
|
+
'file_type': 'audio'
|
|
482
|
+
})
|
|
483
|
+
local_files['audio'] = filepath
|
|
484
|
+
self.logger.info(f"Will upload audio: {filepath}")
|
|
485
|
+
|
|
486
|
+
# Parse style params and find referenced files
|
|
487
|
+
style_assets = {}
|
|
488
|
+
if style_params_path and os.path.isfile(style_params_path):
|
|
489
|
+
self.logger.info(f"Parsing style configuration: {style_params_path}")
|
|
490
|
+
style_assets = self._parse_style_params(style_params_path)
|
|
491
|
+
|
|
492
|
+
# Add style_params.json
|
|
493
|
+
files_info.append({
|
|
494
|
+
'filename': Path(style_params_path).name,
|
|
495
|
+
'content_type': 'application/json',
|
|
496
|
+
'file_type': 'style_params'
|
|
497
|
+
})
|
|
498
|
+
local_files['style_params'] = style_params_path
|
|
499
|
+
self.logger.info(f" Will upload style_params.json")
|
|
500
|
+
|
|
501
|
+
# Add each style asset file
|
|
502
|
+
for asset_key, asset_path in style_assets.items():
|
|
503
|
+
if os.path.isfile(asset_path):
|
|
504
|
+
content_type = self._get_content_type(asset_path)
|
|
505
|
+
files_info.append({
|
|
506
|
+
'filename': Path(asset_path).name,
|
|
507
|
+
'content_type': content_type,
|
|
508
|
+
'file_type': asset_key # e.g., 'style_intro_background'
|
|
509
|
+
})
|
|
510
|
+
local_files[asset_key] = asset_path
|
|
511
|
+
self.logger.info(f" Will upload {asset_key}: {asset_path}")
|
|
512
|
+
|
|
513
|
+
# Add lyrics file if provided
|
|
514
|
+
if lyrics_file and os.path.isfile(lyrics_file):
|
|
515
|
+
content_type = self._get_content_type(lyrics_file)
|
|
516
|
+
files_info.append({
|
|
517
|
+
'filename': Path(lyrics_file).name,
|
|
518
|
+
'content_type': content_type,
|
|
519
|
+
'file_type': 'lyrics_file'
|
|
520
|
+
})
|
|
521
|
+
local_files['lyrics_file'] = lyrics_file
|
|
522
|
+
self.logger.info(f"Will upload lyrics file: {lyrics_file}")
|
|
523
|
+
|
|
524
|
+
# Add existing instrumental file if provided (Batch 3)
|
|
525
|
+
if existing_instrumental and os.path.isfile(existing_instrumental):
|
|
526
|
+
content_type = self._get_content_type(existing_instrumental)
|
|
527
|
+
files_info.append({
|
|
528
|
+
'filename': Path(existing_instrumental).name,
|
|
529
|
+
'content_type': content_type,
|
|
530
|
+
'file_type': 'existing_instrumental'
|
|
531
|
+
})
|
|
532
|
+
local_files['existing_instrumental'] = existing_instrumental
|
|
533
|
+
self.logger.info(f"Will upload existing instrumental: {existing_instrumental}")
|
|
534
|
+
|
|
535
|
+
# Step 2: Create job and get signed upload URLs
|
|
536
|
+
self.logger.info(f"Creating job at {self.config.service_url}/api/jobs/create-with-upload-urls")
|
|
537
|
+
|
|
538
|
+
create_request = {
|
|
539
|
+
'artist': artist,
|
|
540
|
+
'title': title,
|
|
541
|
+
'files': files_info,
|
|
542
|
+
'enable_cdg': enable_cdg,
|
|
543
|
+
'enable_txt': enable_txt,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if brand_prefix:
|
|
547
|
+
create_request['brand_prefix'] = brand_prefix
|
|
548
|
+
if discord_webhook_url:
|
|
549
|
+
create_request['discord_webhook_url'] = discord_webhook_url
|
|
550
|
+
if youtube_description:
|
|
551
|
+
create_request['youtube_description'] = youtube_description
|
|
552
|
+
if enable_youtube_upload:
|
|
553
|
+
create_request['enable_youtube_upload'] = enable_youtube_upload
|
|
554
|
+
if dropbox_path:
|
|
555
|
+
create_request['dropbox_path'] = dropbox_path
|
|
556
|
+
if gdrive_folder_id:
|
|
557
|
+
create_request['gdrive_folder_id'] = gdrive_folder_id
|
|
558
|
+
if organised_dir_rclone_root:
|
|
559
|
+
create_request['organised_dir_rclone_root'] = organised_dir_rclone_root
|
|
560
|
+
if lyrics_artist:
|
|
561
|
+
create_request['lyrics_artist'] = lyrics_artist
|
|
562
|
+
if lyrics_title:
|
|
563
|
+
create_request['lyrics_title'] = lyrics_title
|
|
564
|
+
if subtitle_offset_ms != 0:
|
|
565
|
+
create_request['subtitle_offset_ms'] = subtitle_offset_ms
|
|
566
|
+
if clean_instrumental_model:
|
|
567
|
+
create_request['clean_instrumental_model'] = clean_instrumental_model
|
|
568
|
+
if backing_vocals_models:
|
|
569
|
+
create_request['backing_vocals_models'] = backing_vocals_models
|
|
570
|
+
if other_stems_models:
|
|
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
|
|
577
|
+
|
|
578
|
+
response = self._request('POST', '/api/jobs/create-with-upload-urls', json=create_request)
|
|
579
|
+
|
|
580
|
+
if response.status_code != 200:
|
|
581
|
+
try:
|
|
582
|
+
error_detail = response.json()
|
|
583
|
+
except Exception:
|
|
584
|
+
error_detail = response.text
|
|
585
|
+
raise RuntimeError(f"Error creating job: {error_detail}")
|
|
586
|
+
|
|
587
|
+
create_result = response.json()
|
|
588
|
+
if create_result.get('status') != 'success':
|
|
589
|
+
raise RuntimeError(f"Error creating job: {create_result}")
|
|
590
|
+
|
|
591
|
+
job_id = create_result['job_id']
|
|
592
|
+
upload_urls = create_result['upload_urls']
|
|
593
|
+
|
|
594
|
+
self.logger.info(f"Job {job_id} created. Uploading {len(upload_urls)} files directly to storage...")
|
|
595
|
+
|
|
596
|
+
# Step 3: Upload each file directly to GCS using signed URLs
|
|
597
|
+
uploaded_files = []
|
|
598
|
+
for url_info in upload_urls:
|
|
599
|
+
file_type = url_info['file_type']
|
|
600
|
+
signed_url = url_info['upload_url']
|
|
601
|
+
content_type = url_info['content_type']
|
|
602
|
+
local_path = local_files.get(file_type)
|
|
603
|
+
|
|
604
|
+
if not local_path:
|
|
605
|
+
self.logger.warning(f"No local file found for file_type: {file_type}")
|
|
606
|
+
continue
|
|
607
|
+
|
|
608
|
+
# Calculate file size for logging
|
|
609
|
+
file_size = os.path.getsize(local_path)
|
|
610
|
+
file_size_mb = file_size / (1024 * 1024)
|
|
611
|
+
self.logger.info(f" Uploading {file_type} ({file_size_mb:.1f} MB)...")
|
|
612
|
+
|
|
613
|
+
success = self._upload_file_to_signed_url(signed_url, local_path, content_type)
|
|
614
|
+
if not success:
|
|
615
|
+
raise RuntimeError(f"Failed to upload {file_type} to storage")
|
|
616
|
+
|
|
617
|
+
uploaded_files.append(file_type)
|
|
618
|
+
self.logger.info(f" ✓ Uploaded {file_type}")
|
|
619
|
+
|
|
620
|
+
# Step 4: Notify backend that uploads are complete
|
|
621
|
+
self.logger.info(f"Notifying backend that uploads are complete...")
|
|
622
|
+
|
|
623
|
+
complete_request = {
|
|
624
|
+
'uploaded_files': uploaded_files
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
response = self._request('POST', f'/api/jobs/{job_id}/uploads-complete', json=complete_request)
|
|
628
|
+
|
|
629
|
+
if response.status_code != 200:
|
|
630
|
+
try:
|
|
631
|
+
error_detail = response.json()
|
|
632
|
+
except Exception:
|
|
633
|
+
error_detail = response.text
|
|
634
|
+
raise RuntimeError(f"Error completing uploads: {error_detail}")
|
|
635
|
+
|
|
636
|
+
result = response.json()
|
|
637
|
+
if result.get('status') != 'success':
|
|
638
|
+
raise RuntimeError(f"Error completing uploads: {result}")
|
|
639
|
+
|
|
640
|
+
# Log distribution services info if available
|
|
641
|
+
if 'distribution_services' in result:
|
|
642
|
+
dist_services = result['distribution_services']
|
|
643
|
+
self.logger.info("")
|
|
644
|
+
self.logger.info("Distribution Services:")
|
|
645
|
+
|
|
646
|
+
for service_name, service_info in dist_services.items():
|
|
647
|
+
if service_info.get('enabled'):
|
|
648
|
+
status = "✓" if service_info.get('credentials_valid', True) else "✗"
|
|
649
|
+
default_note = " (default)" if service_info.get('using_default') else ""
|
|
650
|
+
|
|
651
|
+
if service_name == 'dropbox':
|
|
652
|
+
path = service_info.get('path', '')
|
|
653
|
+
self.logger.info(f" {status} Dropbox: {path}{default_note}")
|
|
654
|
+
elif service_name == 'gdrive':
|
|
655
|
+
folder_id = service_info.get('folder_id', '')
|
|
656
|
+
self.logger.info(f" {status} Google Drive: folder {folder_id}{default_note}")
|
|
657
|
+
elif service_name == 'youtube':
|
|
658
|
+
self.logger.info(f" {status} YouTube: enabled")
|
|
659
|
+
elif service_name == 'discord':
|
|
660
|
+
self.logger.info(f" {status} Discord: notifications{default_note}")
|
|
661
|
+
|
|
662
|
+
return result
|
|
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
|
+
|
|
913
|
+
def get_job(self, job_id: str) -> Dict[str, Any]:
|
|
914
|
+
"""Get job status and details."""
|
|
915
|
+
response = self._request('GET', f'/api/jobs/{job_id}')
|
|
916
|
+
if response.status_code == 404:
|
|
917
|
+
raise ValueError(f"Job not found: {job_id}")
|
|
918
|
+
if response.status_code != 200:
|
|
919
|
+
raise RuntimeError(f"Error getting job: {response.text}")
|
|
920
|
+
return response.json()
|
|
921
|
+
|
|
922
|
+
def cancel_job(self, job_id: str, reason: str = "User requested") -> Dict[str, Any]:
|
|
923
|
+
"""Cancel a running job. Stops processing but keeps the job record."""
|
|
924
|
+
response = self._request(
|
|
925
|
+
'POST',
|
|
926
|
+
f'/api/jobs/{job_id}/cancel',
|
|
927
|
+
json={'reason': reason}
|
|
928
|
+
)
|
|
929
|
+
if response.status_code == 404:
|
|
930
|
+
raise ValueError(f"Job not found: {job_id}")
|
|
931
|
+
if response.status_code == 400:
|
|
932
|
+
try:
|
|
933
|
+
error_detail = response.json().get('detail', response.text)
|
|
934
|
+
except Exception:
|
|
935
|
+
error_detail = response.text
|
|
936
|
+
raise RuntimeError(f"Cannot cancel job: {error_detail}")
|
|
937
|
+
if response.status_code != 200:
|
|
938
|
+
raise RuntimeError(f"Error cancelling job: {response.text}")
|
|
939
|
+
return response.json()
|
|
940
|
+
|
|
941
|
+
def delete_job(self, job_id: str, delete_files: bool = True) -> Dict[str, Any]:
|
|
942
|
+
"""Delete a job and optionally its files. Permanent removal."""
|
|
943
|
+
response = self._request(
|
|
944
|
+
'DELETE',
|
|
945
|
+
f'/api/jobs/{job_id}',
|
|
946
|
+
params={'delete_files': str(delete_files).lower()}
|
|
947
|
+
)
|
|
948
|
+
if response.status_code == 404:
|
|
949
|
+
raise ValueError(f"Job not found: {job_id}")
|
|
950
|
+
if response.status_code != 200:
|
|
951
|
+
raise RuntimeError(f"Error deleting job: {response.text}")
|
|
952
|
+
return response.json()
|
|
953
|
+
|
|
954
|
+
def retry_job(self, job_id: str) -> Dict[str, Any]:
|
|
955
|
+
"""Retry a failed job from the last successful checkpoint."""
|
|
956
|
+
response = self._request(
|
|
957
|
+
'POST',
|
|
958
|
+
f'/api/jobs/{job_id}/retry'
|
|
959
|
+
)
|
|
960
|
+
if response.status_code == 404:
|
|
961
|
+
raise ValueError(f"Job not found: {job_id}")
|
|
962
|
+
if response.status_code == 400:
|
|
963
|
+
try:
|
|
964
|
+
error_detail = response.json().get('detail', response.text)
|
|
965
|
+
except Exception:
|
|
966
|
+
error_detail = response.text
|
|
967
|
+
raise RuntimeError(f"Cannot retry job: {error_detail}")
|
|
968
|
+
if response.status_code != 200:
|
|
969
|
+
raise RuntimeError(f"Error retrying job: {response.text}")
|
|
970
|
+
return response.json()
|
|
971
|
+
|
|
972
|
+
def list_jobs(
|
|
973
|
+
self,
|
|
974
|
+
status: Optional[str] = None,
|
|
975
|
+
environment: Optional[str] = None,
|
|
976
|
+
client_id: Optional[str] = None,
|
|
977
|
+
limit: int = 100
|
|
978
|
+
) -> list:
|
|
979
|
+
"""
|
|
980
|
+
List all jobs with optional filters.
|
|
981
|
+
|
|
982
|
+
Args:
|
|
983
|
+
status: Filter by job status
|
|
984
|
+
environment: Filter by request_metadata.environment
|
|
985
|
+
client_id: Filter by request_metadata.client_id
|
|
986
|
+
limit: Maximum number of jobs to return
|
|
987
|
+
"""
|
|
988
|
+
params = {'limit': limit}
|
|
989
|
+
if status:
|
|
990
|
+
params['status'] = status
|
|
991
|
+
if environment:
|
|
992
|
+
params['environment'] = environment
|
|
993
|
+
if client_id:
|
|
994
|
+
params['client_id'] = client_id
|
|
995
|
+
response = self._request('GET', '/api/jobs', params=params)
|
|
996
|
+
if response.status_code != 200:
|
|
997
|
+
raise RuntimeError(f"Error listing jobs: {response.text}")
|
|
998
|
+
return response.json()
|
|
999
|
+
|
|
1000
|
+
def bulk_delete_jobs(
|
|
1001
|
+
self,
|
|
1002
|
+
environment: Optional[str] = None,
|
|
1003
|
+
client_id: Optional[str] = None,
|
|
1004
|
+
status: Optional[str] = None,
|
|
1005
|
+
confirm: bool = False,
|
|
1006
|
+
delete_files: bool = True
|
|
1007
|
+
) -> Dict[str, Any]:
|
|
1008
|
+
"""
|
|
1009
|
+
Delete multiple jobs matching filter criteria.
|
|
1010
|
+
|
|
1011
|
+
Args:
|
|
1012
|
+
environment: Delete jobs with this environment
|
|
1013
|
+
client_id: Delete jobs from this client
|
|
1014
|
+
status: Delete jobs with this status
|
|
1015
|
+
confirm: Must be True to execute deletion
|
|
1016
|
+
delete_files: Also delete GCS files
|
|
1017
|
+
|
|
1018
|
+
Returns:
|
|
1019
|
+
Dict with deletion results or preview
|
|
1020
|
+
"""
|
|
1021
|
+
params = {
|
|
1022
|
+
'confirm': str(confirm).lower(),
|
|
1023
|
+
'delete_files': str(delete_files).lower(),
|
|
1024
|
+
}
|
|
1025
|
+
if environment:
|
|
1026
|
+
params['environment'] = environment
|
|
1027
|
+
if client_id:
|
|
1028
|
+
params['client_id'] = client_id
|
|
1029
|
+
if status:
|
|
1030
|
+
params['status'] = status
|
|
1031
|
+
|
|
1032
|
+
response = self._request('DELETE', '/api/jobs', params=params)
|
|
1033
|
+
if response.status_code == 400:
|
|
1034
|
+
try:
|
|
1035
|
+
error_detail = response.json().get('detail', response.text)
|
|
1036
|
+
except Exception:
|
|
1037
|
+
error_detail = response.text
|
|
1038
|
+
raise RuntimeError(f"Error: {error_detail}")
|
|
1039
|
+
if response.status_code != 200:
|
|
1040
|
+
raise RuntimeError(f"Error bulk deleting jobs: {response.text}")
|
|
1041
|
+
return response.json()
|
|
1042
|
+
|
|
1043
|
+
def get_instrumental_options(self, job_id: str) -> Dict[str, Any]:
|
|
1044
|
+
"""Get instrumental options for selection."""
|
|
1045
|
+
response = self._request('GET', f'/api/jobs/{job_id}/instrumental-options')
|
|
1046
|
+
if response.status_code != 200:
|
|
1047
|
+
try:
|
|
1048
|
+
error_detail = response.json()
|
|
1049
|
+
except Exception:
|
|
1050
|
+
error_detail = response.text
|
|
1051
|
+
raise RuntimeError(f"Error getting instrumental options: {error_detail}")
|
|
1052
|
+
return response.json()
|
|
1053
|
+
|
|
1054
|
+
def get_instrumental_analysis(self, job_id: str) -> Dict[str, Any]:
|
|
1055
|
+
"""Get instrumental analysis data including backing vocals detection."""
|
|
1056
|
+
response = self._request('GET', f'/api/jobs/{job_id}/instrumental-analysis')
|
|
1057
|
+
if response.status_code != 200:
|
|
1058
|
+
try:
|
|
1059
|
+
error_detail = response.json()
|
|
1060
|
+
except Exception:
|
|
1061
|
+
error_detail = response.text
|
|
1062
|
+
raise RuntimeError(f"Error getting instrumental analysis: {error_detail}")
|
|
1063
|
+
return response.json()
|
|
1064
|
+
|
|
1065
|
+
def select_instrumental(self, job_id: str, selection: str) -> Dict[str, Any]:
|
|
1066
|
+
"""Submit instrumental selection."""
|
|
1067
|
+
response = self._request(
|
|
1068
|
+
'POST',
|
|
1069
|
+
f'/api/jobs/{job_id}/select-instrumental',
|
|
1070
|
+
json={'selection': selection}
|
|
1071
|
+
)
|
|
1072
|
+
if response.status_code != 200:
|
|
1073
|
+
try:
|
|
1074
|
+
error_detail = response.json()
|
|
1075
|
+
except Exception:
|
|
1076
|
+
error_detail = response.text
|
|
1077
|
+
raise RuntimeError(f"Error selecting instrumental: {error_detail}")
|
|
1078
|
+
return response.json()
|
|
1079
|
+
|
|
1080
|
+
def get_download_urls(self, job_id: str) -> Dict[str, Any]:
|
|
1081
|
+
"""Get signed download URLs for all job output files."""
|
|
1082
|
+
response = self._request('GET', f'/api/jobs/{job_id}/download-urls')
|
|
1083
|
+
if response.status_code != 200:
|
|
1084
|
+
try:
|
|
1085
|
+
error_detail = response.json()
|
|
1086
|
+
except Exception:
|
|
1087
|
+
error_detail = response.text
|
|
1088
|
+
raise RuntimeError(f"Error getting download URLs: {error_detail}")
|
|
1089
|
+
return response.json()
|
|
1090
|
+
|
|
1091
|
+
def download_file_via_url(self, url: str, local_path: str) -> bool:
|
|
1092
|
+
"""Download file from a URL via HTTP."""
|
|
1093
|
+
try:
|
|
1094
|
+
# Handle relative URLs by prepending service URL
|
|
1095
|
+
if url.startswith('/'):
|
|
1096
|
+
url = f"{self.config.service_url}{url}"
|
|
1097
|
+
|
|
1098
|
+
# Use session headers (includes Authorization) for authenticated downloads
|
|
1099
|
+
response = self.session.get(url, stream=True, timeout=600)
|
|
1100
|
+
if response.status_code != 200:
|
|
1101
|
+
return False
|
|
1102
|
+
|
|
1103
|
+
# Ensure parent directory exists
|
|
1104
|
+
os.makedirs(os.path.dirname(local_path), exist_ok=True)
|
|
1105
|
+
|
|
1106
|
+
with open(local_path, 'wb') as f:
|
|
1107
|
+
for chunk in response.iter_content(chunk_size=8192):
|
|
1108
|
+
f.write(chunk)
|
|
1109
|
+
return True
|
|
1110
|
+
except Exception:
|
|
1111
|
+
return False
|
|
1112
|
+
|
|
1113
|
+
def download_file_via_gsutil(self, gcs_path: str, local_path: str) -> bool:
|
|
1114
|
+
"""Download file from GCS using gsutil (fallback method)."""
|
|
1115
|
+
try:
|
|
1116
|
+
bucket_name = os.environ.get('KARAOKE_GEN_BUCKET', 'karaoke-gen-storage-nomadkaraoke')
|
|
1117
|
+
gcs_uri = f"gs://{bucket_name}/{gcs_path}"
|
|
1118
|
+
|
|
1119
|
+
result = subprocess.run(
|
|
1120
|
+
['gsutil', 'cp', gcs_uri, local_path],
|
|
1121
|
+
capture_output=True,
|
|
1122
|
+
text=True
|
|
1123
|
+
)
|
|
1124
|
+
return result.returncode == 0
|
|
1125
|
+
except FileNotFoundError:
|
|
1126
|
+
return False
|
|
1127
|
+
|
|
1128
|
+
def get_worker_logs(self, job_id: str, since_index: int = 0) -> Dict[str, Any]:
|
|
1129
|
+
"""
|
|
1130
|
+
Get worker logs for debugging.
|
|
1131
|
+
|
|
1132
|
+
Args:
|
|
1133
|
+
job_id: Job ID
|
|
1134
|
+
since_index: Return only logs after this index (for pagination/polling)
|
|
1135
|
+
|
|
1136
|
+
Returns:
|
|
1137
|
+
{
|
|
1138
|
+
"logs": [{"timestamp": "...", "level": "INFO", "worker": "audio", "message": "..."}],
|
|
1139
|
+
"next_index": 42,
|
|
1140
|
+
"total_logs": 42
|
|
1141
|
+
}
|
|
1142
|
+
"""
|
|
1143
|
+
response = self._request(
|
|
1144
|
+
'GET',
|
|
1145
|
+
f'/api/jobs/{job_id}/logs',
|
|
1146
|
+
params={'since_index': since_index}
|
|
1147
|
+
)
|
|
1148
|
+
if response.status_code != 200:
|
|
1149
|
+
return {"logs": [], "next_index": since_index, "total_logs": 0}
|
|
1150
|
+
return response.json()
|
|
1151
|
+
|
|
1152
|
+
def get_review_data(self, job_id: str) -> Dict[str, Any]:
|
|
1153
|
+
"""Get the current review/correction data for a job."""
|
|
1154
|
+
response = self._request('GET', f'/api/review/{job_id}/correction-data')
|
|
1155
|
+
if response.status_code != 200:
|
|
1156
|
+
try:
|
|
1157
|
+
error_detail = response.json()
|
|
1158
|
+
except Exception:
|
|
1159
|
+
error_detail = response.text
|
|
1160
|
+
raise RuntimeError(f"Error getting review data: {error_detail}")
|
|
1161
|
+
return response.json()
|
|
1162
|
+
|
|
1163
|
+
def complete_review(self, job_id: str, updated_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
1164
|
+
"""Submit the review completion with corrected data."""
|
|
1165
|
+
response = self._request(
|
|
1166
|
+
'POST',
|
|
1167
|
+
f'/api/review/{job_id}/complete',
|
|
1168
|
+
json=updated_data
|
|
1169
|
+
)
|
|
1170
|
+
if response.status_code != 200:
|
|
1171
|
+
try:
|
|
1172
|
+
error_detail = response.json()
|
|
1173
|
+
except Exception:
|
|
1174
|
+
error_detail = response.text
|
|
1175
|
+
raise RuntimeError(f"Error completing review: {error_detail}")
|
|
1176
|
+
return response.json()
|
|
1177
|
+
|
|
1178
|
+
def search_audio(
|
|
1179
|
+
self,
|
|
1180
|
+
artist: str,
|
|
1181
|
+
title: str,
|
|
1182
|
+
auto_download: bool = False,
|
|
1183
|
+
style_params_path: Optional[str] = None,
|
|
1184
|
+
enable_cdg: bool = True,
|
|
1185
|
+
enable_txt: bool = True,
|
|
1186
|
+
brand_prefix: Optional[str] = None,
|
|
1187
|
+
discord_webhook_url: Optional[str] = None,
|
|
1188
|
+
youtube_description: Optional[str] = None,
|
|
1189
|
+
enable_youtube_upload: bool = False,
|
|
1190
|
+
dropbox_path: Optional[str] = None,
|
|
1191
|
+
gdrive_folder_id: Optional[str] = None,
|
|
1192
|
+
lyrics_artist: Optional[str] = None,
|
|
1193
|
+
lyrics_title: Optional[str] = None,
|
|
1194
|
+
subtitle_offset_ms: int = 0,
|
|
1195
|
+
clean_instrumental_model: Optional[str] = None,
|
|
1196
|
+
backing_vocals_models: Optional[list] = None,
|
|
1197
|
+
other_stems_models: Optional[list] = None,
|
|
1198
|
+
) -> Dict[str, Any]:
|
|
1199
|
+
"""
|
|
1200
|
+
Search for audio by artist and title (Batch 5 - Flacfetch integration).
|
|
1201
|
+
|
|
1202
|
+
This creates a job and searches for audio sources. If auto_download is True,
|
|
1203
|
+
it automatically selects the best source. Otherwise, it returns search results
|
|
1204
|
+
for user selection.
|
|
1205
|
+
|
|
1206
|
+
Args:
|
|
1207
|
+
artist: Artist name to search for
|
|
1208
|
+
title: Song title to search for
|
|
1209
|
+
auto_download: Automatically select best audio source (skip interactive selection)
|
|
1210
|
+
style_params_path: Path to style_params.json (optional)
|
|
1211
|
+
... other args same as submit_job()
|
|
1212
|
+
|
|
1213
|
+
Returns:
|
|
1214
|
+
Dict with job_id, status, and optionally search results
|
|
1215
|
+
"""
|
|
1216
|
+
self.logger.info(f"Searching for audio: {artist} - {title}")
|
|
1217
|
+
|
|
1218
|
+
request_data = {
|
|
1219
|
+
'artist': artist,
|
|
1220
|
+
'title': title,
|
|
1221
|
+
'auto_download': auto_download,
|
|
1222
|
+
'enable_cdg': enable_cdg,
|
|
1223
|
+
'enable_txt': enable_txt,
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
if brand_prefix:
|
|
1227
|
+
request_data['brand_prefix'] = brand_prefix
|
|
1228
|
+
if discord_webhook_url:
|
|
1229
|
+
request_data['discord_webhook_url'] = discord_webhook_url
|
|
1230
|
+
if youtube_description:
|
|
1231
|
+
request_data['youtube_description'] = youtube_description
|
|
1232
|
+
if enable_youtube_upload:
|
|
1233
|
+
request_data['enable_youtube_upload'] = enable_youtube_upload
|
|
1234
|
+
if dropbox_path:
|
|
1235
|
+
request_data['dropbox_path'] = dropbox_path
|
|
1236
|
+
if gdrive_folder_id:
|
|
1237
|
+
request_data['gdrive_folder_id'] = gdrive_folder_id
|
|
1238
|
+
if lyrics_artist:
|
|
1239
|
+
request_data['lyrics_artist'] = lyrics_artist
|
|
1240
|
+
if lyrics_title:
|
|
1241
|
+
request_data['lyrics_title'] = lyrics_title
|
|
1242
|
+
if subtitle_offset_ms != 0:
|
|
1243
|
+
request_data['subtitle_offset_ms'] = subtitle_offset_ms
|
|
1244
|
+
if clean_instrumental_model:
|
|
1245
|
+
request_data['clean_instrumental_model'] = clean_instrumental_model
|
|
1246
|
+
if backing_vocals_models:
|
|
1247
|
+
request_data['backing_vocals_models'] = backing_vocals_models
|
|
1248
|
+
if other_stems_models:
|
|
1249
|
+
request_data['other_stems_models'] = other_stems_models
|
|
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
|
+
|
|
1285
|
+
response = self._request('POST', '/api/audio-search/search', json=request_data)
|
|
1286
|
+
|
|
1287
|
+
if response.status_code == 404:
|
|
1288
|
+
try:
|
|
1289
|
+
error_detail = response.json()
|
|
1290
|
+
except Exception:
|
|
1291
|
+
error_detail = response.text
|
|
1292
|
+
raise ValueError(f"No audio sources found: {error_detail}")
|
|
1293
|
+
|
|
1294
|
+
if response.status_code != 200:
|
|
1295
|
+
try:
|
|
1296
|
+
error_detail = response.json()
|
|
1297
|
+
except Exception:
|
|
1298
|
+
error_detail = response.text
|
|
1299
|
+
raise RuntimeError(f"Error searching for audio: {error_detail}")
|
|
1300
|
+
|
|
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
|
|
1347
|
+
|
|
1348
|
+
def get_audio_search_results(self, job_id: str) -> Dict[str, Any]:
|
|
1349
|
+
"""Get audio search results for a job awaiting selection."""
|
|
1350
|
+
response = self._request('GET', f'/api/audio-search/{job_id}/results')
|
|
1351
|
+
if response.status_code != 200:
|
|
1352
|
+
try:
|
|
1353
|
+
error_detail = response.json()
|
|
1354
|
+
except Exception:
|
|
1355
|
+
error_detail = response.text
|
|
1356
|
+
raise RuntimeError(f"Error getting search results: {error_detail}")
|
|
1357
|
+
return response.json()
|
|
1358
|
+
|
|
1359
|
+
def select_audio_source(self, job_id: str, selection_index: int) -> Dict[str, Any]:
|
|
1360
|
+
"""Select an audio source and start processing."""
|
|
1361
|
+
response = self._request(
|
|
1362
|
+
'POST',
|
|
1363
|
+
f'/api/audio-search/{job_id}/select',
|
|
1364
|
+
json={'selection_index': selection_index}
|
|
1365
|
+
)
|
|
1366
|
+
if response.status_code != 200:
|
|
1367
|
+
try:
|
|
1368
|
+
error_detail = response.json()
|
|
1369
|
+
except Exception:
|
|
1370
|
+
error_detail = response.text
|
|
1371
|
+
raise RuntimeError(f"Error selecting audio: {error_detail}")
|
|
1372
|
+
return response.json()
|
|
1373
|
+
|
|
1374
|
+
|
|
1375
|
+
class JobMonitor:
|
|
1376
|
+
"""Monitor job progress with verbose logging."""
|
|
1377
|
+
|
|
1378
|
+
def __init__(self, client: RemoteKaraokeClient, config: Config, logger: logging.Logger):
|
|
1379
|
+
self.client = client
|
|
1380
|
+
self.config = config
|
|
1381
|
+
self.logger = logger
|
|
1382
|
+
self._review_opened = False
|
|
1383
|
+
self._instrumental_prompted = False
|
|
1384
|
+
self._audio_selection_prompted = False # Batch 5: audio source selection
|
|
1385
|
+
self._last_timeline_index = 0
|
|
1386
|
+
self._last_log_index = 0
|
|
1387
|
+
self._show_worker_logs = True # Enable worker log display
|
|
1388
|
+
self._polls_without_updates = 0 # Track polling activity for heartbeat
|
|
1389
|
+
self._heartbeat_interval = 6 # Show heartbeat every N polls without updates (~30s with 5s poll)
|
|
1390
|
+
|
|
1391
|
+
# Status descriptions for user-friendly logging
|
|
1392
|
+
STATUS_DESCRIPTIONS = {
|
|
1393
|
+
'pending': 'Job queued, waiting to start',
|
|
1394
|
+
# Audio search states (Batch 5)
|
|
1395
|
+
'searching_audio': 'Searching for audio sources',
|
|
1396
|
+
'awaiting_audio_selection': 'Waiting for audio source selection',
|
|
1397
|
+
'downloading_audio': 'Downloading selected audio',
|
|
1398
|
+
# Main workflow
|
|
1399
|
+
'downloading': 'Downloading and preparing input files',
|
|
1400
|
+
'separating_stage1': 'AI audio separation (stage 1 of 2)',
|
|
1401
|
+
'separating_stage2': 'AI audio separation (stage 2 of 2)',
|
|
1402
|
+
'audio_complete': 'Audio separation complete',
|
|
1403
|
+
'transcribing': 'Transcribing lyrics from audio',
|
|
1404
|
+
'correcting': 'Auto-correcting lyrics against reference sources',
|
|
1405
|
+
'lyrics_complete': 'Lyrics processing complete',
|
|
1406
|
+
'generating_screens': 'Creating title and end screens',
|
|
1407
|
+
'applying_padding': 'Adding intro/outro padding',
|
|
1408
|
+
'awaiting_review': 'Waiting for lyrics review',
|
|
1409
|
+
'in_review': 'Lyrics review in progress',
|
|
1410
|
+
'review_complete': 'Review complete, preparing video render',
|
|
1411
|
+
'rendering_video': 'Rendering karaoke video with lyrics',
|
|
1412
|
+
'awaiting_instrumental_selection': 'Waiting for instrumental selection',
|
|
1413
|
+
'instrumental_selected': 'Instrumental selected, preparing final encoding',
|
|
1414
|
+
'generating_video': 'Downloading files for final video encoding',
|
|
1415
|
+
'encoding': 'Encoding final videos (15-20 min, 4 formats)',
|
|
1416
|
+
'packaging': 'Creating CDG/TXT packages',
|
|
1417
|
+
'uploading': 'Uploading to distribution services',
|
|
1418
|
+
'notifying': 'Sending notifications',
|
|
1419
|
+
'complete': 'All processing complete',
|
|
1420
|
+
'prep_complete': 'Prep phase complete - ready for local finalisation',
|
|
1421
|
+
'failed': 'Job failed',
|
|
1422
|
+
'cancelled': 'Job cancelled',
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
def _get_status_description(self, status: str) -> str:
|
|
1426
|
+
"""Get user-friendly description for a status."""
|
|
1427
|
+
return self.STATUS_DESCRIPTIONS.get(status, status)
|
|
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
|
+
|
|
1477
|
+
def open_browser(self, url: str) -> None:
|
|
1478
|
+
"""Open URL in the default browser."""
|
|
1479
|
+
system = platform.system()
|
|
1480
|
+
try:
|
|
1481
|
+
if system == 'Darwin':
|
|
1482
|
+
subprocess.run(['open', url], check=True)
|
|
1483
|
+
elif system == 'Linux':
|
|
1484
|
+
subprocess.run(['xdg-open', url], check=True, stderr=subprocess.DEVNULL)
|
|
1485
|
+
else:
|
|
1486
|
+
webbrowser.open(url)
|
|
1487
|
+
except Exception:
|
|
1488
|
+
self.logger.info(f"Please open in browser: {url}")
|
|
1489
|
+
|
|
1490
|
+
def open_review_ui(self, job_id: str) -> None:
|
|
1491
|
+
"""Open the lyrics review UI in browser."""
|
|
1492
|
+
# Build the review URL with the API endpoint
|
|
1493
|
+
base_api_url = f"{self.config.service_url}/api/review/{job_id}"
|
|
1494
|
+
encoded_api_url = urllib.parse.quote(base_api_url, safe='')
|
|
1495
|
+
|
|
1496
|
+
# Try to get audio hash and review token from job data
|
|
1497
|
+
audio_hash = ''
|
|
1498
|
+
review_token = ''
|
|
1499
|
+
try:
|
|
1500
|
+
job_data = self.client.get_job(job_id)
|
|
1501
|
+
audio_hash = job_data.get('audio_hash', '')
|
|
1502
|
+
review_token = job_data.get('review_token', '')
|
|
1503
|
+
except Exception:
|
|
1504
|
+
pass
|
|
1505
|
+
|
|
1506
|
+
url = f"{self.config.review_ui_url}/?baseApiUrl={encoded_api_url}"
|
|
1507
|
+
if audio_hash:
|
|
1508
|
+
url += f"&audioHash={audio_hash}"
|
|
1509
|
+
if review_token:
|
|
1510
|
+
url += f"&reviewToken={review_token}"
|
|
1511
|
+
|
|
1512
|
+
self.logger.info(f"Opening lyrics review UI: {url}")
|
|
1513
|
+
self.open_browser(url)
|
|
1514
|
+
|
|
1515
|
+
def handle_review(self, job_id: str) -> None:
|
|
1516
|
+
"""Handle the lyrics review interaction."""
|
|
1517
|
+
self.logger.info("=" * 60)
|
|
1518
|
+
self.logger.info("LYRICS REVIEW NEEDED")
|
|
1519
|
+
self.logger.info("=" * 60)
|
|
1520
|
+
|
|
1521
|
+
# In non-interactive mode, auto-accept the current corrections
|
|
1522
|
+
if self.config.non_interactive:
|
|
1523
|
+
self.logger.info("Non-interactive mode: Auto-accepting current corrections")
|
|
1524
|
+
try:
|
|
1525
|
+
# Get current review data
|
|
1526
|
+
review_data = self.client.get_review_data(job_id)
|
|
1527
|
+
self.logger.info("Retrieved current correction data")
|
|
1528
|
+
|
|
1529
|
+
# Submit as-is to complete the review
|
|
1530
|
+
result = self.client.complete_review(job_id, review_data)
|
|
1531
|
+
if result.get('status') == 'success':
|
|
1532
|
+
self.logger.info("Review auto-completed successfully")
|
|
1533
|
+
return
|
|
1534
|
+
else:
|
|
1535
|
+
self.logger.error(f"Failed to auto-complete review: {result}")
|
|
1536
|
+
# In non-interactive mode, raise exception instead of falling back to manual
|
|
1537
|
+
raise RuntimeError(f"Failed to auto-complete review: {result}")
|
|
1538
|
+
except Exception as e:
|
|
1539
|
+
self.logger.error(f"Error auto-completing review: {e}")
|
|
1540
|
+
# In non-interactive mode, we can't fall back to manual - raise the error
|
|
1541
|
+
raise RuntimeError(f"Non-interactive review failed: {e}")
|
|
1542
|
+
|
|
1543
|
+
# Interactive mode - open browser and wait
|
|
1544
|
+
self.logger.info("The transcription is ready for review.")
|
|
1545
|
+
self.logger.info("Please review and correct the lyrics in the browser.")
|
|
1546
|
+
|
|
1547
|
+
self.open_review_ui(job_id)
|
|
1548
|
+
|
|
1549
|
+
self.logger.info(f"Waiting for review completion (polling every {self.config.poll_interval}s)...")
|
|
1550
|
+
|
|
1551
|
+
# Poll until status changes from review states
|
|
1552
|
+
while True:
|
|
1553
|
+
try:
|
|
1554
|
+
job_data = self.client.get_job(job_id)
|
|
1555
|
+
current_status = job_data.get('status', 'unknown')
|
|
1556
|
+
|
|
1557
|
+
if current_status in ['awaiting_review', 'in_review']:
|
|
1558
|
+
time.sleep(self.config.poll_interval)
|
|
1559
|
+
else:
|
|
1560
|
+
self.logger.info(f"Review completed, status: {current_status}")
|
|
1561
|
+
return
|
|
1562
|
+
except Exception as e:
|
|
1563
|
+
self.logger.warning(f"Error checking review status: {e}")
|
|
1564
|
+
time.sleep(self.config.poll_interval)
|
|
1565
|
+
|
|
1566
|
+
def handle_instrumental_selection(self, job_id: str) -> None:
|
|
1567
|
+
"""Handle instrumental selection interaction with analysis-based recommendations."""
|
|
1568
|
+
self.logger.info("=" * 60)
|
|
1569
|
+
self.logger.info("INSTRUMENTAL SELECTION NEEDED")
|
|
1570
|
+
self.logger.info("=" * 60)
|
|
1571
|
+
|
|
1572
|
+
# Try to get analysis data for smart recommendations
|
|
1573
|
+
analysis_data = None
|
|
1574
|
+
try:
|
|
1575
|
+
analysis_data = self.client.get_instrumental_analysis(job_id)
|
|
1576
|
+
analysis = analysis_data.get('analysis', {})
|
|
1577
|
+
|
|
1578
|
+
# Display analysis summary
|
|
1579
|
+
self.logger.info("")
|
|
1580
|
+
self.logger.info("=== Backing Vocals Analysis ===")
|
|
1581
|
+
if analysis.get('has_audible_content'):
|
|
1582
|
+
self.logger.info(f" Backing vocals detected: YES")
|
|
1583
|
+
self.logger.info(f" Audible segments: {len(analysis.get('audible_segments', []))}")
|
|
1584
|
+
self.logger.info(f" Audible duration: {analysis.get('total_audible_duration_seconds', 0):.1f}s "
|
|
1585
|
+
f"({analysis.get('audible_percentage', 0):.1f}% of track)")
|
|
1586
|
+
else:
|
|
1587
|
+
self.logger.info(f" Backing vocals detected: NO")
|
|
1588
|
+
self.logger.info(f" Recommendation: {analysis.get('recommended_selection', 'review_needed')}")
|
|
1589
|
+
self.logger.info("")
|
|
1590
|
+
except Exception as e:
|
|
1591
|
+
self.logger.warning(f"Could not fetch analysis data: {e}")
|
|
1592
|
+
self.logger.info("Falling back to manual selection...")
|
|
1593
|
+
|
|
1594
|
+
# In non-interactive mode, use analysis recommendation or default to clean
|
|
1595
|
+
if self.config.non_interactive:
|
|
1596
|
+
if analysis_data and analysis_data.get('analysis', {}).get('recommended_selection') == 'clean':
|
|
1597
|
+
self.logger.info("Non-interactive mode: Auto-selecting clean instrumental (recommended)")
|
|
1598
|
+
selection = 'clean'
|
|
1599
|
+
else:
|
|
1600
|
+
self.logger.info("Non-interactive mode: Auto-selecting clean instrumental (default)")
|
|
1601
|
+
selection = 'clean'
|
|
1602
|
+
else:
|
|
1603
|
+
# Check if we should recommend clean based on analysis
|
|
1604
|
+
recommend_clean = (
|
|
1605
|
+
analysis_data and
|
|
1606
|
+
not analysis_data.get('analysis', {}).get('has_audible_content', True)
|
|
1607
|
+
)
|
|
1608
|
+
|
|
1609
|
+
if recommend_clean:
|
|
1610
|
+
self.logger.info("No backing vocals detected - recommending clean instrumental.")
|
|
1611
|
+
self.logger.info("")
|
|
1612
|
+
self.logger.info("Options:")
|
|
1613
|
+
self.logger.info(" 1) Accept recommendation (clean instrumental)")
|
|
1614
|
+
self.logger.info(" 2) Open browser to review and select")
|
|
1615
|
+
self.logger.info("")
|
|
1616
|
+
|
|
1617
|
+
try:
|
|
1618
|
+
choice = input("Enter your choice (1 or 2): ").strip()
|
|
1619
|
+
if choice == '1':
|
|
1620
|
+
selection = 'clean'
|
|
1621
|
+
else:
|
|
1622
|
+
self._open_instrumental_review_and_wait(job_id)
|
|
1623
|
+
return # Selection will be submitted via browser
|
|
1624
|
+
except KeyboardInterrupt:
|
|
1625
|
+
print()
|
|
1626
|
+
raise
|
|
1627
|
+
else:
|
|
1628
|
+
# Backing vocals detected or analysis unavailable - offer browser review
|
|
1629
|
+
self.logger.info("Choose how to select your instrumental:")
|
|
1630
|
+
self.logger.info("")
|
|
1631
|
+
self.logger.info(" 1) Clean Instrumental (no backing vocals)")
|
|
1632
|
+
self.logger.info(" Best for songs where you want ONLY the lead vocal removed")
|
|
1633
|
+
self.logger.info("")
|
|
1634
|
+
self.logger.info(" 2) Instrumental with Backing Vocals")
|
|
1635
|
+
self.logger.info(" Best for songs where backing vocals add to the karaoke experience")
|
|
1636
|
+
self.logger.info("")
|
|
1637
|
+
self.logger.info(" 3) Open Browser for Advanced Review")
|
|
1638
|
+
self.logger.info(" Listen to audio, view waveform, and optionally mute sections")
|
|
1639
|
+
self.logger.info(" to create a custom instrumental")
|
|
1640
|
+
self.logger.info("")
|
|
1641
|
+
|
|
1642
|
+
selection = ""
|
|
1643
|
+
while not selection:
|
|
1644
|
+
try:
|
|
1645
|
+
choice = input("Enter your choice (1, 2, or 3): ").strip()
|
|
1646
|
+
if choice == '1':
|
|
1647
|
+
selection = 'clean'
|
|
1648
|
+
elif choice == '2':
|
|
1649
|
+
selection = 'with_backing'
|
|
1650
|
+
elif choice == '3':
|
|
1651
|
+
self._open_instrumental_review_and_wait(job_id)
|
|
1652
|
+
return # Selection will be submitted via browser
|
|
1653
|
+
else:
|
|
1654
|
+
self.logger.error("Invalid choice. Please enter 1, 2, or 3.")
|
|
1655
|
+
except KeyboardInterrupt:
|
|
1656
|
+
print()
|
|
1657
|
+
raise
|
|
1658
|
+
|
|
1659
|
+
self.logger.info(f"Submitting selection: {selection}")
|
|
1660
|
+
|
|
1661
|
+
try:
|
|
1662
|
+
result = self.client.select_instrumental(job_id, selection)
|
|
1663
|
+
if result.get('status') == 'success':
|
|
1664
|
+
self.logger.info(f"Selection submitted successfully: {selection}")
|
|
1665
|
+
else:
|
|
1666
|
+
self.logger.error(f"Error submitting selection: {result}")
|
|
1667
|
+
except Exception as e:
|
|
1668
|
+
self.logger.error(f"Error submitting selection: {e}")
|
|
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
|
+
|
|
1732
|
+
def handle_audio_selection(self, job_id: str) -> None:
|
|
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
|
+
"""
|
|
1739
|
+
self.logger.info("=" * 60)
|
|
1740
|
+
self.logger.info("AUDIO SOURCE SELECTION NEEDED")
|
|
1741
|
+
self.logger.info("=" * 60)
|
|
1742
|
+
|
|
1743
|
+
try:
|
|
1744
|
+
# Get search results
|
|
1745
|
+
results_data = self.client.get_audio_search_results(job_id)
|
|
1746
|
+
results = results_data.get('results', [])
|
|
1747
|
+
artist = results_data.get('artist', 'Unknown')
|
|
1748
|
+
title = results_data.get('title', 'Unknown')
|
|
1749
|
+
|
|
1750
|
+
if not results:
|
|
1751
|
+
self.logger.error("No search results available")
|
|
1752
|
+
return
|
|
1753
|
+
|
|
1754
|
+
# In non-interactive mode, auto-select first result
|
|
1755
|
+
if self.config.non_interactive:
|
|
1756
|
+
self.logger.info("Non-interactive mode: Auto-selecting first result")
|
|
1757
|
+
selection_index = 0
|
|
1758
|
+
else:
|
|
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]
|
|
1762
|
+
|
|
1763
|
+
# Convert to Release objects for categorization
|
|
1764
|
+
release_objects = self._convert_to_release_objects(release_dicts)
|
|
1765
|
+
|
|
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
|
|
1782
|
+
|
|
1783
|
+
selection_index = -1
|
|
1784
|
+
while selection_index < 0:
|
|
1785
|
+
try:
|
|
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
|
+
|
|
1807
|
+
choice_num = int(choice)
|
|
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
|
|
1821
|
+
else:
|
|
1822
|
+
print(f"Please enter a number between 0 and {len(display_releases)}")
|
|
1823
|
+
except ValueError:
|
|
1824
|
+
if showing_categorized:
|
|
1825
|
+
print("Please enter a number or 'more'")
|
|
1826
|
+
else:
|
|
1827
|
+
print("Please enter a valid number")
|
|
1828
|
+
except KeyboardInterrupt:
|
|
1829
|
+
print()
|
|
1830
|
+
raise
|
|
1831
|
+
|
|
1832
|
+
selected = results[selection_index]
|
|
1833
|
+
self.logger.info(f"Selected: [{selected.get('provider')}] {selected.get('artist')} - {selected.get('title')}")
|
|
1834
|
+
self.logger.info("")
|
|
1835
|
+
|
|
1836
|
+
# Submit selection
|
|
1837
|
+
result = self.client.select_audio_source(job_id, selection_index)
|
|
1838
|
+
if result.get('status') == 'success':
|
|
1839
|
+
self.logger.info(f"Selection submitted successfully")
|
|
1840
|
+
else:
|
|
1841
|
+
self.logger.error(f"Error submitting selection: {result}")
|
|
1842
|
+
|
|
1843
|
+
except Exception as e:
|
|
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
|
|
1897
|
+
|
|
1898
|
+
def _open_instrumental_review_and_wait(self, job_id: str) -> None:
|
|
1899
|
+
"""Open browser to instrumental review UI and wait for selection."""
|
|
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}"
|
|
1919
|
+
|
|
1920
|
+
self.logger.info("")
|
|
1921
|
+
self.logger.info("=" * 60)
|
|
1922
|
+
self.logger.info("OPENING BROWSER FOR INSTRUMENTAL REVIEW")
|
|
1923
|
+
self.logger.info("=" * 60)
|
|
1924
|
+
self.logger.info(f"Review URL: {review_url}")
|
|
1925
|
+
self.logger.info("")
|
|
1926
|
+
self.logger.info("In the browser you can:")
|
|
1927
|
+
self.logger.info(" - View the backing vocals waveform")
|
|
1928
|
+
self.logger.info(" - Listen to clean instrumental, backing vocals, or combined")
|
|
1929
|
+
self.logger.info(" - Select regions to mute and create a custom instrumental")
|
|
1930
|
+
self.logger.info(" - Submit your final selection")
|
|
1931
|
+
self.logger.info("")
|
|
1932
|
+
self.logger.info("Waiting for selection to be submitted...")
|
|
1933
|
+
self.logger.info("(Press Ctrl+C to cancel)")
|
|
1934
|
+
self.logger.info("")
|
|
1935
|
+
|
|
1936
|
+
# Open browser
|
|
1937
|
+
webbrowser.open(review_url)
|
|
1938
|
+
|
|
1939
|
+
# Poll until job status changes from awaiting_instrumental_selection
|
|
1940
|
+
while True:
|
|
1941
|
+
try:
|
|
1942
|
+
job_data = self.client.get_job(job_id)
|
|
1943
|
+
current_status = job_data.get('status')
|
|
1944
|
+
|
|
1945
|
+
if current_status != 'awaiting_instrumental_selection':
|
|
1946
|
+
selection = job_data.get('state_data', {}).get('instrumental_selection', 'unknown')
|
|
1947
|
+
self.logger.info(f"Selection received: {selection}")
|
|
1948
|
+
self.logger.info(f"Job status: {current_status}")
|
|
1949
|
+
return
|
|
1950
|
+
|
|
1951
|
+
time.sleep(self.config.poll_interval)
|
|
1952
|
+
|
|
1953
|
+
except KeyboardInterrupt:
|
|
1954
|
+
print()
|
|
1955
|
+
self.logger.info("Cancelled. You can resume this job later with --resume")
|
|
1956
|
+
raise
|
|
1957
|
+
except Exception as e:
|
|
1958
|
+
self.logger.warning(f"Error checking status: {e}")
|
|
1959
|
+
time.sleep(self.config.poll_interval)
|
|
1960
|
+
|
|
1961
|
+
|
|
1962
|
+
def download_outputs(self, job_id: str, job_data: Dict[str, Any]) -> None:
|
|
1963
|
+
"""
|
|
1964
|
+
Download all output files for a completed job.
|
|
1965
|
+
|
|
1966
|
+
Downloads all files to match local CLI output structure:
|
|
1967
|
+
- Final videos (4 formats)
|
|
1968
|
+
- CDG/TXT ZIP packages (and extracts individual files)
|
|
1969
|
+
- Lyrics files (.ass, .lrc, .txt)
|
|
1970
|
+
- Audio stems with descriptive names
|
|
1971
|
+
- Title/End screen files (.mov, .jpg, .png)
|
|
1972
|
+
- With Vocals intermediate video
|
|
1973
|
+
"""
|
|
1974
|
+
artist = job_data.get('artist', 'Unknown')
|
|
1975
|
+
title = job_data.get('title', 'Unknown')
|
|
1976
|
+
brand_code = job_data.get('state_data', {}).get('brand_code')
|
|
1977
|
+
|
|
1978
|
+
# Use brand code in folder name if available
|
|
1979
|
+
if brand_code:
|
|
1980
|
+
folder_name = f"{brand_code} - {artist} - {title}"
|
|
1981
|
+
else:
|
|
1982
|
+
folder_name = f"{artist} - {title}"
|
|
1983
|
+
|
|
1984
|
+
# Sanitize folder name
|
|
1985
|
+
folder_name = "".join(c for c in folder_name if c.isalnum() or c in " -_").strip()
|
|
1986
|
+
|
|
1987
|
+
output_dir = Path(self.config.output_dir) / folder_name
|
|
1988
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
1989
|
+
|
|
1990
|
+
self.logger.info(f"Downloading output files to: {output_dir}")
|
|
1991
|
+
|
|
1992
|
+
# Get signed download URLs from the API
|
|
1993
|
+
try:
|
|
1994
|
+
download_data = self.client.get_download_urls(job_id)
|
|
1995
|
+
download_urls = download_data.get('download_urls', {})
|
|
1996
|
+
except Exception as e:
|
|
1997
|
+
self.logger.warning(f"Could not get signed download URLs: {e}")
|
|
1998
|
+
self.logger.warning("Falling back to gsutil (requires gcloud auth)")
|
|
1999
|
+
download_urls = {}
|
|
2000
|
+
|
|
2001
|
+
file_urls = job_data.get('file_urls', {})
|
|
2002
|
+
base_name = f"{artist} - {title}"
|
|
2003
|
+
|
|
2004
|
+
def download_file(category: str, key: str, local_path: Path, filename: str) -> bool:
|
|
2005
|
+
"""Helper to download a file using signed URL or gsutil fallback."""
|
|
2006
|
+
# Try signed URL first
|
|
2007
|
+
signed_url = download_urls.get(category, {}).get(key)
|
|
2008
|
+
if signed_url:
|
|
2009
|
+
if self.client.download_file_via_url(signed_url, str(local_path)):
|
|
2010
|
+
return True
|
|
2011
|
+
|
|
2012
|
+
# Fall back to gsutil
|
|
2013
|
+
gcs_path = file_urls.get(category, {}).get(key)
|
|
2014
|
+
if gcs_path:
|
|
2015
|
+
return self.client.download_file_via_gsutil(gcs_path, str(local_path))
|
|
2016
|
+
return False
|
|
2017
|
+
|
|
2018
|
+
# Download final videos
|
|
2019
|
+
finals = file_urls.get('finals', {})
|
|
2020
|
+
if finals:
|
|
2021
|
+
self.logger.info("Downloading final videos...")
|
|
2022
|
+
for key, blob_path in finals.items():
|
|
2023
|
+
if blob_path:
|
|
2024
|
+
# Use descriptive filename
|
|
2025
|
+
if 'lossless_4k_mp4' in key:
|
|
2026
|
+
filename = f"{base_name} (Final Karaoke Lossless 4k).mp4"
|
|
2027
|
+
elif 'lossless_4k_mkv' in key:
|
|
2028
|
+
filename = f"{base_name} (Final Karaoke Lossless 4k).mkv"
|
|
2029
|
+
elif 'lossy_4k' in key:
|
|
2030
|
+
filename = f"{base_name} (Final Karaoke Lossy 4k).mp4"
|
|
2031
|
+
elif 'lossy_720p' in key:
|
|
2032
|
+
filename = f"{base_name} (Final Karaoke Lossy 720p).mp4"
|
|
2033
|
+
else:
|
|
2034
|
+
filename = Path(blob_path).name
|
|
2035
|
+
|
|
2036
|
+
local_path = output_dir / filename
|
|
2037
|
+
self.logger.info(f" Downloading {filename}...")
|
|
2038
|
+
if download_file('finals', key, local_path, filename):
|
|
2039
|
+
self.logger.info(f" OK: {local_path}")
|
|
2040
|
+
else:
|
|
2041
|
+
self.logger.warning(f" FAILED: {filename}")
|
|
2042
|
+
|
|
2043
|
+
# Download CDG/TXT packages
|
|
2044
|
+
packages = file_urls.get('packages', {})
|
|
2045
|
+
if packages:
|
|
2046
|
+
self.logger.info("Downloading karaoke packages...")
|
|
2047
|
+
for key, blob_path in packages.items():
|
|
2048
|
+
if blob_path:
|
|
2049
|
+
if 'cdg' in key.lower():
|
|
2050
|
+
filename = f"{base_name} (Final Karaoke CDG).zip"
|
|
2051
|
+
elif 'txt' in key.lower():
|
|
2052
|
+
filename = f"{base_name} (Final Karaoke TXT).zip"
|
|
2053
|
+
else:
|
|
2054
|
+
filename = Path(blob_path).name
|
|
2055
|
+
|
|
2056
|
+
local_path = output_dir / filename
|
|
2057
|
+
self.logger.info(f" Downloading {filename}...")
|
|
2058
|
+
if download_file('packages', key, local_path, filename):
|
|
2059
|
+
self.logger.info(f" OK: {local_path}")
|
|
2060
|
+
|
|
2061
|
+
# Extract CDG files to match local CLI (individual .cdg and .mp3 at root)
|
|
2062
|
+
if 'cdg' in key.lower():
|
|
2063
|
+
self._extract_cdg_files(local_path, output_dir, base_name)
|
|
2064
|
+
else:
|
|
2065
|
+
self.logger.warning(f" FAILED: {filename}")
|
|
2066
|
+
|
|
2067
|
+
# Download lyrics files
|
|
2068
|
+
lyrics = file_urls.get('lyrics', {})
|
|
2069
|
+
if lyrics:
|
|
2070
|
+
self.logger.info("Downloading lyrics files...")
|
|
2071
|
+
for key in ['ass', 'lrc', 'corrected_txt']:
|
|
2072
|
+
blob_path = lyrics.get(key)
|
|
2073
|
+
if blob_path:
|
|
2074
|
+
ext = Path(blob_path).suffix
|
|
2075
|
+
filename = f"{base_name} (Karaoke){ext}"
|
|
2076
|
+
local_path = output_dir / filename
|
|
2077
|
+
self.logger.info(f" Downloading {filename}...")
|
|
2078
|
+
if download_file('lyrics', key, local_path, filename):
|
|
2079
|
+
self.logger.info(f" OK: {local_path}")
|
|
2080
|
+
else:
|
|
2081
|
+
self.logger.warning(f" FAILED: {filename}")
|
|
2082
|
+
|
|
2083
|
+
# Download title/end screen files (video + images)
|
|
2084
|
+
screens = file_urls.get('screens', {})
|
|
2085
|
+
if screens:
|
|
2086
|
+
self.logger.info("Downloading title/end screens...")
|
|
2087
|
+
screen_mappings = {
|
|
2088
|
+
'title': f"{base_name} (Title).mov",
|
|
2089
|
+
'title_jpg': f"{base_name} (Title).jpg",
|
|
2090
|
+
'title_png': f"{base_name} (Title).png",
|
|
2091
|
+
'end': f"{base_name} (End).mov",
|
|
2092
|
+
'end_jpg': f"{base_name} (End).jpg",
|
|
2093
|
+
'end_png': f"{base_name} (End).png",
|
|
2094
|
+
}
|
|
2095
|
+
for key, filename in screen_mappings.items():
|
|
2096
|
+
blob_path = screens.get(key)
|
|
2097
|
+
if blob_path:
|
|
2098
|
+
local_path = output_dir / filename
|
|
2099
|
+
self.logger.info(f" Downloading {filename}...")
|
|
2100
|
+
if download_file('screens', key, local_path, filename):
|
|
2101
|
+
self.logger.info(f" OK: {local_path}")
|
|
2102
|
+
else:
|
|
2103
|
+
self.logger.warning(f" FAILED: {filename}")
|
|
2104
|
+
|
|
2105
|
+
# Download with_vocals intermediate video
|
|
2106
|
+
videos = file_urls.get('videos', {})
|
|
2107
|
+
if videos:
|
|
2108
|
+
self.logger.info("Downloading intermediate videos...")
|
|
2109
|
+
if videos.get('with_vocals'):
|
|
2110
|
+
filename = f"{base_name} (With Vocals).mkv"
|
|
2111
|
+
local_path = output_dir / filename
|
|
2112
|
+
self.logger.info(f" Downloading {filename}...")
|
|
2113
|
+
if download_file('videos', 'with_vocals', local_path, filename):
|
|
2114
|
+
self.logger.info(f" OK: {local_path}")
|
|
2115
|
+
else:
|
|
2116
|
+
self.logger.warning(f" FAILED: {filename}")
|
|
2117
|
+
|
|
2118
|
+
# Download stems with descriptive names
|
|
2119
|
+
stems = file_urls.get('stems', {})
|
|
2120
|
+
if stems:
|
|
2121
|
+
stems_dir = output_dir / 'stems'
|
|
2122
|
+
stems_dir.mkdir(exist_ok=True)
|
|
2123
|
+
self.logger.info("Downloading audio stems...")
|
|
2124
|
+
|
|
2125
|
+
# Map backend stem names to local CLI naming convention
|
|
2126
|
+
stem_name_mappings = {
|
|
2127
|
+
'instrumental_clean': f"{base_name} (Instrumental model_bs_roformer_ep_317_sdr_12.9755.ckpt).flac",
|
|
2128
|
+
'instrumental_with_backing': f"{base_name} (Instrumental +BV mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt).flac",
|
|
2129
|
+
'vocals_clean': f"{base_name} (Vocals model_bs_roformer_ep_317_sdr_12.9755.ckpt).flac",
|
|
2130
|
+
'lead_vocals': f"{base_name} (Lead Vocals mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt).flac",
|
|
2131
|
+
'backing_vocals': f"{base_name} (Backing Vocals mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt).flac",
|
|
2132
|
+
'bass': f"{base_name} (Bass htdemucs_6s.yaml).flac",
|
|
2133
|
+
'drums': f"{base_name} (Drums htdemucs_6s.yaml).flac",
|
|
2134
|
+
'guitar': f"{base_name} (Guitar htdemucs_6s.yaml).flac",
|
|
2135
|
+
'piano': f"{base_name} (Piano htdemucs_6s.yaml).flac",
|
|
2136
|
+
'other': f"{base_name} (Other htdemucs_6s.yaml).flac",
|
|
2137
|
+
'vocals': f"{base_name} (Vocals htdemucs_6s.yaml).flac",
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
for key, blob_path in stems.items():
|
|
2141
|
+
if blob_path:
|
|
2142
|
+
# Use descriptive filename if available, otherwise use GCS filename
|
|
2143
|
+
filename = stem_name_mappings.get(key, Path(blob_path).name)
|
|
2144
|
+
local_path = stems_dir / filename
|
|
2145
|
+
self.logger.info(f" Downloading {filename}...")
|
|
2146
|
+
if download_file('stems', key, local_path, filename):
|
|
2147
|
+
self.logger.info(f" OK: {local_path}")
|
|
2148
|
+
else:
|
|
2149
|
+
self.logger.warning(f" FAILED: {filename}")
|
|
2150
|
+
|
|
2151
|
+
# Also copy instrumental files to root directory (matching local CLI)
|
|
2152
|
+
for src_key, dest_suffix in [
|
|
2153
|
+
('instrumental_clean', 'Instrumental model_bs_roformer_ep_317_sdr_12.9755.ckpt'),
|
|
2154
|
+
('instrumental_with_backing', 'Instrumental +BV mel_band_roformer_karaoke_aufr33_viperx_sdr_10.1956.ckpt'),
|
|
2155
|
+
]:
|
|
2156
|
+
if stems.get(src_key):
|
|
2157
|
+
stem_file = stems_dir / stem_name_mappings.get(src_key, '')
|
|
2158
|
+
if stem_file.exists():
|
|
2159
|
+
dest_file = output_dir / f"{base_name} ({dest_suffix}).flac"
|
|
2160
|
+
try:
|
|
2161
|
+
import shutil
|
|
2162
|
+
shutil.copy2(stem_file, dest_file)
|
|
2163
|
+
self.logger.info(f" Copied to root: {dest_file.name}")
|
|
2164
|
+
except Exception as e:
|
|
2165
|
+
self.logger.warning(f" Failed to copy {dest_file.name}: {e}")
|
|
2166
|
+
|
|
2167
|
+
self.logger.info("")
|
|
2168
|
+
self.logger.info(f"All files downloaded to: {output_dir}")
|
|
2169
|
+
|
|
2170
|
+
# Show summary
|
|
2171
|
+
state_data = job_data.get('state_data', {})
|
|
2172
|
+
if brand_code:
|
|
2173
|
+
self.logger.info(f"Brand Code: {brand_code}")
|
|
2174
|
+
|
|
2175
|
+
youtube_url = state_data.get('youtube_url')
|
|
2176
|
+
if youtube_url:
|
|
2177
|
+
self.logger.info(f"YouTube URL: {youtube_url}")
|
|
2178
|
+
|
|
2179
|
+
# List downloaded files with sizes
|
|
2180
|
+
self.logger.info("")
|
|
2181
|
+
self.logger.info("Downloaded files:")
|
|
2182
|
+
total_size = 0
|
|
2183
|
+
for file_path in sorted(output_dir.rglob('*')):
|
|
2184
|
+
if file_path.is_file():
|
|
2185
|
+
size = file_path.stat().st_size
|
|
2186
|
+
total_size += size
|
|
2187
|
+
if size > 1024 * 1024:
|
|
2188
|
+
size_str = f"{size / (1024 * 1024):.1f} MB"
|
|
2189
|
+
elif size > 1024:
|
|
2190
|
+
size_str = f"{size / 1024:.1f} KB"
|
|
2191
|
+
else:
|
|
2192
|
+
size_str = f"{size} B"
|
|
2193
|
+
rel_path = file_path.relative_to(output_dir)
|
|
2194
|
+
self.logger.info(f" {rel_path} ({size_str})")
|
|
2195
|
+
|
|
2196
|
+
if total_size > 1024 * 1024 * 1024:
|
|
2197
|
+
total_str = f"{total_size / (1024 * 1024 * 1024):.2f} GB"
|
|
2198
|
+
elif total_size > 1024 * 1024:
|
|
2199
|
+
total_str = f"{total_size / (1024 * 1024):.1f} MB"
|
|
2200
|
+
else:
|
|
2201
|
+
total_str = f"{total_size / 1024:.1f} KB"
|
|
2202
|
+
self.logger.info(f"Total: {total_str}")
|
|
2203
|
+
|
|
2204
|
+
def _extract_cdg_files(self, zip_path: Path, output_dir: Path, base_name: str) -> None:
|
|
2205
|
+
"""
|
|
2206
|
+
Extract individual .cdg and .mp3 files from CDG ZIP to match local CLI output.
|
|
2207
|
+
|
|
2208
|
+
Local CLI produces both:
|
|
2209
|
+
- Artist - Title (Final Karaoke CDG).zip (containing .cdg + .mp3)
|
|
2210
|
+
- Artist - Title (Karaoke).cdg (individual file at root)
|
|
2211
|
+
- Artist - Title (Karaoke).mp3 (individual file at root)
|
|
2212
|
+
|
|
2213
|
+
Args:
|
|
2214
|
+
zip_path: Path to the CDG ZIP file
|
|
2215
|
+
output_dir: Output directory for extracted files
|
|
2216
|
+
base_name: Base name for output files (Artist - Title)
|
|
2217
|
+
"""
|
|
2218
|
+
import zipfile
|
|
2219
|
+
|
|
2220
|
+
try:
|
|
2221
|
+
with zipfile.ZipFile(zip_path, 'r') as zf:
|
|
2222
|
+
for member in zf.namelist():
|
|
2223
|
+
ext = Path(member).suffix.lower()
|
|
2224
|
+
if ext in ['.cdg', '.mp3']:
|
|
2225
|
+
# Extract with correct naming
|
|
2226
|
+
filename = f"{base_name} (Karaoke){ext}"
|
|
2227
|
+
extract_path = output_dir / filename
|
|
2228
|
+
|
|
2229
|
+
# Read from zip and write to destination
|
|
2230
|
+
with zf.open(member) as src:
|
|
2231
|
+
with open(extract_path, 'wb') as dst:
|
|
2232
|
+
dst.write(src.read())
|
|
2233
|
+
|
|
2234
|
+
self.logger.info(f" Extracted: {filename}")
|
|
2235
|
+
except Exception as e:
|
|
2236
|
+
self.logger.warning(f" Failed to extract CDG files: {e}")
|
|
2237
|
+
|
|
2238
|
+
def log_timeline_updates(self, job_data: Dict[str, Any]) -> None:
|
|
2239
|
+
"""Log any new timeline events."""
|
|
2240
|
+
timeline = job_data.get('timeline', [])
|
|
2241
|
+
|
|
2242
|
+
# Log any new events since last check
|
|
2243
|
+
for i, event in enumerate(timeline):
|
|
2244
|
+
if i >= self._last_timeline_index:
|
|
2245
|
+
timestamp = event.get('timestamp', '')
|
|
2246
|
+
status = event.get('status', '')
|
|
2247
|
+
message = event.get('message', '')
|
|
2248
|
+
progress = event.get('progress', '')
|
|
2249
|
+
|
|
2250
|
+
# Format timestamp if present
|
|
2251
|
+
if timestamp:
|
|
2252
|
+
# Truncate to just time portion if it's a full ISO timestamp
|
|
2253
|
+
if 'T' in timestamp:
|
|
2254
|
+
timestamp = timestamp.split('T')[1][:8]
|
|
2255
|
+
|
|
2256
|
+
log_parts = []
|
|
2257
|
+
if timestamp:
|
|
2258
|
+
log_parts.append(f"[{timestamp}]")
|
|
2259
|
+
if status:
|
|
2260
|
+
log_parts.append(f"[{status}]")
|
|
2261
|
+
if progress:
|
|
2262
|
+
log_parts.append(f"[{progress}%]")
|
|
2263
|
+
if message:
|
|
2264
|
+
log_parts.append(message)
|
|
2265
|
+
|
|
2266
|
+
if log_parts:
|
|
2267
|
+
self.logger.info(" ".join(log_parts))
|
|
2268
|
+
|
|
2269
|
+
self._last_timeline_index = len(timeline)
|
|
2270
|
+
|
|
2271
|
+
def log_worker_logs(self, job_id: str) -> None:
|
|
2272
|
+
"""Fetch and display any new worker logs."""
|
|
2273
|
+
if not self._show_worker_logs:
|
|
2274
|
+
return
|
|
2275
|
+
|
|
2276
|
+
try:
|
|
2277
|
+
result = self.client.get_worker_logs(job_id, since_index=self._last_log_index)
|
|
2278
|
+
logs = result.get('logs', [])
|
|
2279
|
+
|
|
2280
|
+
for log_entry in logs:
|
|
2281
|
+
timestamp = log_entry.get('timestamp', '')
|
|
2282
|
+
level = log_entry.get('level', 'INFO')
|
|
2283
|
+
worker = log_entry.get('worker', 'worker')
|
|
2284
|
+
message = log_entry.get('message', '')
|
|
2285
|
+
|
|
2286
|
+
# Format timestamp (just time portion)
|
|
2287
|
+
if timestamp and 'T' in timestamp:
|
|
2288
|
+
timestamp = timestamp.split('T')[1][:8]
|
|
2289
|
+
|
|
2290
|
+
# Color-code by level (using ASCII codes for terminal)
|
|
2291
|
+
if level == 'ERROR':
|
|
2292
|
+
level_prefix = f"\033[91m{level}\033[0m" # Red
|
|
2293
|
+
elif level == 'WARNING':
|
|
2294
|
+
level_prefix = f"\033[93m{level}\033[0m" # Yellow
|
|
2295
|
+
else:
|
|
2296
|
+
level_prefix = level
|
|
2297
|
+
|
|
2298
|
+
# Format: [HH:MM:SS] [worker:level] message
|
|
2299
|
+
log_line = f" [{timestamp}] [{worker}:{level_prefix}] {message}"
|
|
2300
|
+
|
|
2301
|
+
# Use appropriate log level
|
|
2302
|
+
if level == 'ERROR':
|
|
2303
|
+
self.logger.error(log_line)
|
|
2304
|
+
elif level == 'WARNING':
|
|
2305
|
+
self.logger.warning(log_line)
|
|
2306
|
+
else:
|
|
2307
|
+
self.logger.info(log_line)
|
|
2308
|
+
|
|
2309
|
+
# Update index for next poll
|
|
2310
|
+
self._last_log_index = result.get('next_index', self._last_log_index)
|
|
2311
|
+
|
|
2312
|
+
except Exception as e:
|
|
2313
|
+
# Log the error but don't fail
|
|
2314
|
+
self.logger.debug(f"Error fetching worker logs: {e}")
|
|
2315
|
+
|
|
2316
|
+
def monitor(self, job_id: str) -> int:
|
|
2317
|
+
"""Monitor job progress until completion."""
|
|
2318
|
+
last_status = ""
|
|
2319
|
+
|
|
2320
|
+
self.logger.info(f"Monitoring job: {job_id}")
|
|
2321
|
+
self.logger.info(f"Service URL: {self.config.service_url}")
|
|
2322
|
+
self.logger.info(f"Polling every {self.config.poll_interval} seconds...")
|
|
2323
|
+
self.logger.info("")
|
|
2324
|
+
|
|
2325
|
+
while True:
|
|
2326
|
+
try:
|
|
2327
|
+
job_data = self.client.get_job(job_id)
|
|
2328
|
+
|
|
2329
|
+
status = job_data.get('status', 'unknown')
|
|
2330
|
+
artist = job_data.get('artist', '')
|
|
2331
|
+
title = job_data.get('title', '')
|
|
2332
|
+
|
|
2333
|
+
# Track whether we got any new updates this poll
|
|
2334
|
+
had_updates = False
|
|
2335
|
+
prev_timeline_index = self._last_timeline_index
|
|
2336
|
+
prev_log_index = self._last_log_index
|
|
2337
|
+
|
|
2338
|
+
# Log timeline updates (shows status changes and progress)
|
|
2339
|
+
self.log_timeline_updates(job_data)
|
|
2340
|
+
if self._last_timeline_index > prev_timeline_index:
|
|
2341
|
+
had_updates = True
|
|
2342
|
+
|
|
2343
|
+
# Log worker logs (shows detailed worker output for debugging)
|
|
2344
|
+
self.log_worker_logs(job_id)
|
|
2345
|
+
if self._last_log_index > prev_log_index:
|
|
2346
|
+
had_updates = True
|
|
2347
|
+
|
|
2348
|
+
# Log status changes with user-friendly descriptions
|
|
2349
|
+
if status != last_status:
|
|
2350
|
+
description = self._get_status_description(status)
|
|
2351
|
+
if last_status:
|
|
2352
|
+
self.logger.info(f"Status: {status} - {description}")
|
|
2353
|
+
else:
|
|
2354
|
+
self.logger.info(f"Current status: {status} - {description}")
|
|
2355
|
+
last_status = status
|
|
2356
|
+
had_updates = True
|
|
2357
|
+
|
|
2358
|
+
# Heartbeat: if no updates for a while, show we're still alive
|
|
2359
|
+
if had_updates:
|
|
2360
|
+
self._polls_without_updates = 0
|
|
2361
|
+
else:
|
|
2362
|
+
self._polls_without_updates += 1
|
|
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}]")
|
|
2372
|
+
self._polls_without_updates = 0
|
|
2373
|
+
|
|
2374
|
+
# Handle human interaction points
|
|
2375
|
+
if status == 'awaiting_audio_selection':
|
|
2376
|
+
if not self._audio_selection_prompted:
|
|
2377
|
+
self.logger.info("")
|
|
2378
|
+
self.handle_audio_selection(job_id)
|
|
2379
|
+
self._audio_selection_prompted = True
|
|
2380
|
+
self._last_timeline_index = 0 # Reset to catch any events
|
|
2381
|
+
|
|
2382
|
+
elif status in ['awaiting_review', 'in_review']:
|
|
2383
|
+
if not self._review_opened:
|
|
2384
|
+
self.logger.info("")
|
|
2385
|
+
self.handle_review(job_id)
|
|
2386
|
+
self._review_opened = True
|
|
2387
|
+
self._last_timeline_index = 0 # Reset to catch any events during review
|
|
2388
|
+
# Refresh auth token after potentially long review
|
|
2389
|
+
self.client.refresh_auth()
|
|
2390
|
+
|
|
2391
|
+
elif status == 'awaiting_instrumental_selection':
|
|
2392
|
+
if not self._instrumental_prompted:
|
|
2393
|
+
self.logger.info("")
|
|
2394
|
+
self.handle_instrumental_selection(job_id)
|
|
2395
|
+
self._instrumental_prompted = True
|
|
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
|
+
|
|
2405
|
+
elif status == 'complete':
|
|
2406
|
+
self.logger.info("")
|
|
2407
|
+
self.logger.info("=" * 60)
|
|
2408
|
+
self.logger.info("JOB COMPLETE!")
|
|
2409
|
+
self.logger.info("=" * 60)
|
|
2410
|
+
self.logger.info(f"Track: {artist} - {title}")
|
|
2411
|
+
self.logger.info("")
|
|
2412
|
+
self.download_outputs(job_id, job_data)
|
|
2413
|
+
return 0
|
|
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
|
+
|
|
2433
|
+
elif status in ['failed', 'error']:
|
|
2434
|
+
self.logger.info("")
|
|
2435
|
+
self.logger.error("=" * 60)
|
|
2436
|
+
self.logger.error("JOB FAILED")
|
|
2437
|
+
self.logger.error("=" * 60)
|
|
2438
|
+
error_message = job_data.get('error_message', 'Unknown error')
|
|
2439
|
+
self.logger.error(f"Error: {error_message}")
|
|
2440
|
+
error_details = job_data.get('error_details')
|
|
2441
|
+
if error_details:
|
|
2442
|
+
self.logger.error(f"Details: {json.dumps(error_details, indent=2)}")
|
|
2443
|
+
return 1
|
|
2444
|
+
|
|
2445
|
+
elif status == 'cancelled':
|
|
2446
|
+
self.logger.info("")
|
|
2447
|
+
self.logger.warning("Job was cancelled")
|
|
2448
|
+
return 1
|
|
2449
|
+
|
|
2450
|
+
time.sleep(self.config.poll_interval)
|
|
2451
|
+
|
|
2452
|
+
except KeyboardInterrupt:
|
|
2453
|
+
self.logger.info("")
|
|
2454
|
+
self.logger.warning(f"Monitoring interrupted. Job ID: {job_id}")
|
|
2455
|
+
self.logger.info(f"Resume with: karaoke-gen-remote --resume {job_id}")
|
|
2456
|
+
return 130
|
|
2457
|
+
except Exception as e:
|
|
2458
|
+
self.logger.warning(f"Error polling job status: {e}")
|
|
2459
|
+
time.sleep(self.config.poll_interval)
|
|
2460
|
+
|
|
2461
|
+
|
|
2462
|
+
def check_prerequisites(logger: logging.Logger) -> bool:
|
|
2463
|
+
"""Check that required tools are available."""
|
|
2464
|
+
# Check for gcloud
|
|
2465
|
+
try:
|
|
2466
|
+
subprocess.run(['gcloud', '--version'], capture_output=True, check=True)
|
|
2467
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
2468
|
+
logger.warning("gcloud CLI not found. Authentication may be limited.")
|
|
2469
|
+
|
|
2470
|
+
# Check for gsutil
|
|
2471
|
+
try:
|
|
2472
|
+
subprocess.run(['gsutil', 'version'], capture_output=True, check=True)
|
|
2473
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
2474
|
+
logger.warning("gsutil not found. File downloads may fail. Install with: pip install gsutil")
|
|
2475
|
+
|
|
2476
|
+
return True
|
|
2477
|
+
|
|
2478
|
+
|
|
2479
|
+
def get_auth_token(logger: logging.Logger) -> Optional[str]:
|
|
2480
|
+
"""Get authentication token from environment or gcloud."""
|
|
2481
|
+
# Check environment variable first
|
|
2482
|
+
token = os.environ.get('KARAOKE_GEN_AUTH_TOKEN')
|
|
2483
|
+
if token:
|
|
2484
|
+
return token
|
|
2485
|
+
|
|
2486
|
+
# Try gcloud
|
|
2487
|
+
try:
|
|
2488
|
+
result = subprocess.run(
|
|
2489
|
+
['gcloud', 'auth', 'print-identity-token'],
|
|
2490
|
+
capture_output=True,
|
|
2491
|
+
text=True,
|
|
2492
|
+
check=True
|
|
2493
|
+
)
|
|
2494
|
+
return result.stdout.strip()
|
|
2495
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
2496
|
+
return None
|
|
2497
|
+
|
|
2498
|
+
|
|
2499
|
+
def main():
|
|
2500
|
+
"""Main entry point for the remote CLI."""
|
|
2501
|
+
# Set up logging - same format as gen_cli.py
|
|
2502
|
+
logger = logging.getLogger(__name__)
|
|
2503
|
+
log_handler = logging.StreamHandler()
|
|
2504
|
+
log_formatter = logging.Formatter(
|
|
2505
|
+
fmt="%(asctime)s.%(msecs)03d - %(levelname)s - %(module)s - %(message)s",
|
|
2506
|
+
datefmt="%Y-%m-%d %H:%M:%S"
|
|
2507
|
+
)
|
|
2508
|
+
log_handler.setFormatter(log_formatter)
|
|
2509
|
+
logger.addHandler(log_handler)
|
|
2510
|
+
|
|
2511
|
+
# Use shared CLI parser
|
|
2512
|
+
parser = create_parser(prog="karaoke-gen-remote")
|
|
2513
|
+
args = parser.parse_args()
|
|
2514
|
+
|
|
2515
|
+
# Set log level
|
|
2516
|
+
log_level = getattr(logging, args.log_level.upper())
|
|
2517
|
+
logger.setLevel(log_level)
|
|
2518
|
+
|
|
2519
|
+
# Check for KARAOKE_GEN_URL - this is REQUIRED for remote mode
|
|
2520
|
+
if not args.service_url:
|
|
2521
|
+
logger.error("KARAOKE_GEN_URL environment variable is required for karaoke-gen-remote")
|
|
2522
|
+
logger.error("")
|
|
2523
|
+
logger.error("Please set it to your cloud backend URL:")
|
|
2524
|
+
logger.error(" export KARAOKE_GEN_URL=https://your-backend.run.app")
|
|
2525
|
+
logger.error("")
|
|
2526
|
+
logger.error("Or pass it via command line:")
|
|
2527
|
+
logger.error(" karaoke-gen-remote --service-url https://your-backend.run.app ...")
|
|
2528
|
+
return 1
|
|
2529
|
+
|
|
2530
|
+
# Check prerequisites
|
|
2531
|
+
check_prerequisites(logger)
|
|
2532
|
+
|
|
2533
|
+
# Get auth token from environment variable
|
|
2534
|
+
auth_token = get_auth_token(logger)
|
|
2535
|
+
|
|
2536
|
+
# Create config
|
|
2537
|
+
config = Config(
|
|
2538
|
+
service_url=args.service_url.rstrip('/'),
|
|
2539
|
+
review_ui_url=args.review_ui_url.rstrip('/'),
|
|
2540
|
+
poll_interval=args.poll_interval,
|
|
2541
|
+
output_dir=args.output_dir,
|
|
2542
|
+
auth_token=auth_token,
|
|
2543
|
+
non_interactive=getattr(args, 'yes', False), # -y / --yes flag
|
|
2544
|
+
# Job tracking metadata
|
|
2545
|
+
environment=getattr(args, 'environment', ''),
|
|
2546
|
+
client_id=getattr(args, 'client_id', ''),
|
|
2547
|
+
)
|
|
2548
|
+
|
|
2549
|
+
# Create client
|
|
2550
|
+
client = RemoteKaraokeClient(config, logger)
|
|
2551
|
+
monitor = JobMonitor(client, config, logger)
|
|
2552
|
+
|
|
2553
|
+
# Handle resume mode
|
|
2554
|
+
if args.resume:
|
|
2555
|
+
logger.info("=" * 60)
|
|
2556
|
+
logger.info("Karaoke Generator (Remote) - Resume Job")
|
|
2557
|
+
logger.info("=" * 60)
|
|
2558
|
+
logger.info(f"Job ID: {args.resume}")
|
|
2559
|
+
|
|
2560
|
+
try:
|
|
2561
|
+
# Verify job exists
|
|
2562
|
+
job_data = client.get_job(args.resume)
|
|
2563
|
+
artist = job_data.get('artist', 'Unknown')
|
|
2564
|
+
title = job_data.get('title', 'Unknown')
|
|
2565
|
+
status = job_data.get('status', 'unknown')
|
|
2566
|
+
|
|
2567
|
+
logger.info(f"Artist: {artist}")
|
|
2568
|
+
logger.info(f"Title: {title}")
|
|
2569
|
+
logger.info(f"Current status: {status}")
|
|
2570
|
+
logger.info("")
|
|
2571
|
+
|
|
2572
|
+
return monitor.monitor(args.resume)
|
|
2573
|
+
except ValueError as e:
|
|
2574
|
+
logger.error(str(e))
|
|
2575
|
+
return 1
|
|
2576
|
+
except Exception as e:
|
|
2577
|
+
logger.error(f"Error resuming job: {e}")
|
|
2578
|
+
return 1
|
|
2579
|
+
|
|
2580
|
+
# Handle bulk delete mode
|
|
2581
|
+
if getattr(args, 'bulk_delete', False):
|
|
2582
|
+
filter_env = getattr(args, 'filter_environment', None)
|
|
2583
|
+
filter_client = getattr(args, 'filter_client_id', None)
|
|
2584
|
+
|
|
2585
|
+
if not filter_env and not filter_client:
|
|
2586
|
+
logger.error("Bulk delete requires at least one filter: --filter-environment or --filter-client-id")
|
|
2587
|
+
return 1
|
|
2588
|
+
|
|
2589
|
+
logger.info("=" * 60)
|
|
2590
|
+
logger.info("Karaoke Generator (Remote) - Bulk Delete Jobs")
|
|
2591
|
+
logger.info("=" * 60)
|
|
2592
|
+
if filter_env:
|
|
2593
|
+
logger.info(f"Environment filter: {filter_env}")
|
|
2594
|
+
if filter_client:
|
|
2595
|
+
logger.info(f"Client ID filter: {filter_client}")
|
|
2596
|
+
logger.info("")
|
|
2597
|
+
|
|
2598
|
+
try:
|
|
2599
|
+
# First get preview
|
|
2600
|
+
result = client.bulk_delete_jobs(
|
|
2601
|
+
environment=filter_env,
|
|
2602
|
+
client_id=filter_client,
|
|
2603
|
+
confirm=False
|
|
2604
|
+
)
|
|
2605
|
+
|
|
2606
|
+
jobs_to_delete = result.get('jobs_to_delete', 0)
|
|
2607
|
+
sample_jobs = result.get('sample_jobs', [])
|
|
2608
|
+
|
|
2609
|
+
if jobs_to_delete == 0:
|
|
2610
|
+
logger.info("No jobs match the specified filters.")
|
|
2611
|
+
return 0
|
|
2612
|
+
|
|
2613
|
+
logger.info(f"Found {jobs_to_delete} jobs matching filters:")
|
|
2614
|
+
logger.info("")
|
|
2615
|
+
|
|
2616
|
+
# Show sample
|
|
2617
|
+
for job in sample_jobs:
|
|
2618
|
+
logger.info(f" {job.get('job_id', 'unknown')[:10]}: {job.get('artist', 'Unknown')} - {job.get('title', 'Unknown')} ({job.get('status', 'unknown')})")
|
|
2619
|
+
|
|
2620
|
+
if len(sample_jobs) < jobs_to_delete:
|
|
2621
|
+
logger.info(f" ... and {jobs_to_delete - len(sample_jobs)} more")
|
|
2622
|
+
|
|
2623
|
+
logger.info("")
|
|
2624
|
+
|
|
2625
|
+
# Confirm unless -y flag is set
|
|
2626
|
+
if not config.non_interactive:
|
|
2627
|
+
confirm = input(f"Are you sure you want to delete {jobs_to_delete} jobs and all their files? [y/N]: ")
|
|
2628
|
+
if confirm.lower() != 'y':
|
|
2629
|
+
logger.info("Bulk deletion cancelled.")
|
|
2630
|
+
return 0
|
|
2631
|
+
|
|
2632
|
+
# Execute deletion
|
|
2633
|
+
result = client.bulk_delete_jobs(
|
|
2634
|
+
environment=filter_env,
|
|
2635
|
+
client_id=filter_client,
|
|
2636
|
+
confirm=True
|
|
2637
|
+
)
|
|
2638
|
+
|
|
2639
|
+
logger.info(f"✓ Deleted {result.get('jobs_deleted', 0)} jobs")
|
|
2640
|
+
if result.get('files_deleted'):
|
|
2641
|
+
logger.info(f"✓ Cleaned up files from {result.get('files_deleted', 0)} jobs")
|
|
2642
|
+
return 0
|
|
2643
|
+
|
|
2644
|
+
except Exception as e:
|
|
2645
|
+
logger.error(f"Error bulk deleting jobs: {e}")
|
|
2646
|
+
return 1
|
|
2647
|
+
|
|
2648
|
+
# Handle list jobs mode
|
|
2649
|
+
if getattr(args, 'list_jobs', False):
|
|
2650
|
+
filter_env = getattr(args, 'filter_environment', None)
|
|
2651
|
+
filter_client = getattr(args, 'filter_client_id', None)
|
|
2652
|
+
|
|
2653
|
+
logger.info("=" * 60)
|
|
2654
|
+
logger.info("Karaoke Generator (Remote) - List Jobs")
|
|
2655
|
+
logger.info("=" * 60)
|
|
2656
|
+
if filter_env:
|
|
2657
|
+
logger.info(f"Environment filter: {filter_env}")
|
|
2658
|
+
if filter_client:
|
|
2659
|
+
logger.info(f"Client ID filter: {filter_client}")
|
|
2660
|
+
logger.info("")
|
|
2661
|
+
|
|
2662
|
+
try:
|
|
2663
|
+
jobs = client.list_jobs(
|
|
2664
|
+
environment=filter_env,
|
|
2665
|
+
client_id=filter_client,
|
|
2666
|
+
limit=100
|
|
2667
|
+
)
|
|
2668
|
+
|
|
2669
|
+
if not jobs:
|
|
2670
|
+
logger.info("No jobs found.")
|
|
2671
|
+
return 0
|
|
2672
|
+
|
|
2673
|
+
# Print header - include environment/client if available
|
|
2674
|
+
logger.info(f"{'JOB ID':<12} {'STATUS':<25} {'ENV':<8} {'ARTIST':<18} {'TITLE':<25}")
|
|
2675
|
+
logger.info("-" * 92)
|
|
2676
|
+
|
|
2677
|
+
# Print each job
|
|
2678
|
+
for job in jobs:
|
|
2679
|
+
# Use 'or' to handle None values (not just missing keys)
|
|
2680
|
+
job_id = (job.get('job_id') or 'unknown')[:10]
|
|
2681
|
+
status = (job.get('status') or 'unknown')[:23]
|
|
2682
|
+
artist = (job.get('artist') or 'Unknown')[:16]
|
|
2683
|
+
title = (job.get('title') or 'Unknown')[:23]
|
|
2684
|
+
# Get environment from request_metadata
|
|
2685
|
+
req_metadata = job.get('request_metadata') or {}
|
|
2686
|
+
env = (req_metadata.get('environment') or '-')[:6]
|
|
2687
|
+
logger.info(f"{job_id:<12} {status:<25} {env:<8} {artist:<18} {title:<25}")
|
|
2688
|
+
|
|
2689
|
+
logger.info("")
|
|
2690
|
+
logger.info(f"Total: {len(jobs)} jobs")
|
|
2691
|
+
logger.info("")
|
|
2692
|
+
logger.info("To retry a failed job: karaoke-gen-remote --retry <JOB_ID>")
|
|
2693
|
+
logger.info("To delete a job: karaoke-gen-remote --delete <JOB_ID>")
|
|
2694
|
+
logger.info("To bulk delete: karaoke-gen-remote --bulk-delete --filter-environment=test")
|
|
2695
|
+
logger.info("To cancel a job: karaoke-gen-remote --cancel <JOB_ID>")
|
|
2696
|
+
return 0
|
|
2697
|
+
|
|
2698
|
+
except Exception as e:
|
|
2699
|
+
logger.error(f"Error listing jobs: {e}")
|
|
2700
|
+
return 1
|
|
2701
|
+
|
|
2702
|
+
# Handle cancel job mode
|
|
2703
|
+
if args.cancel:
|
|
2704
|
+
logger.info("=" * 60)
|
|
2705
|
+
logger.info("Karaoke Generator (Remote) - Cancel Job")
|
|
2706
|
+
logger.info("=" * 60)
|
|
2707
|
+
logger.info(f"Job ID: {args.cancel}")
|
|
2708
|
+
|
|
2709
|
+
try:
|
|
2710
|
+
# Get job info first
|
|
2711
|
+
job_data = client.get_job(args.cancel)
|
|
2712
|
+
artist = job_data.get('artist', 'Unknown')
|
|
2713
|
+
title = job_data.get('title', 'Unknown')
|
|
2714
|
+
status = job_data.get('status', 'unknown')
|
|
2715
|
+
|
|
2716
|
+
logger.info(f"Artist: {artist}")
|
|
2717
|
+
logger.info(f"Title: {title}")
|
|
2718
|
+
logger.info(f"Current status: {status}")
|
|
2719
|
+
logger.info("")
|
|
2720
|
+
|
|
2721
|
+
# Cancel the job
|
|
2722
|
+
result = client.cancel_job(args.cancel)
|
|
2723
|
+
logger.info(f"✓ Job cancelled successfully")
|
|
2724
|
+
return 0
|
|
2725
|
+
|
|
2726
|
+
except ValueError as e:
|
|
2727
|
+
logger.error(str(e))
|
|
2728
|
+
return 1
|
|
2729
|
+
except RuntimeError as e:
|
|
2730
|
+
logger.error(str(e))
|
|
2731
|
+
return 1
|
|
2732
|
+
except Exception as e:
|
|
2733
|
+
logger.error(f"Error cancelling job: {e}")
|
|
2734
|
+
return 1
|
|
2735
|
+
|
|
2736
|
+
# Handle retry job mode
|
|
2737
|
+
if args.retry:
|
|
2738
|
+
logger.info("=" * 60)
|
|
2739
|
+
logger.info("Karaoke Generator (Remote) - Retry Failed Job")
|
|
2740
|
+
logger.info("=" * 60)
|
|
2741
|
+
logger.info(f"Job ID: {args.retry}")
|
|
2742
|
+
|
|
2743
|
+
try:
|
|
2744
|
+
# Get job info first
|
|
2745
|
+
job_data = client.get_job(args.retry)
|
|
2746
|
+
artist = job_data.get('artist', 'Unknown')
|
|
2747
|
+
title = job_data.get('title', 'Unknown')
|
|
2748
|
+
status = job_data.get('status', 'unknown')
|
|
2749
|
+
error_message = job_data.get('error_message', 'No error message')
|
|
2750
|
+
|
|
2751
|
+
logger.info(f"Artist: {artist}")
|
|
2752
|
+
logger.info(f"Title: {title}")
|
|
2753
|
+
logger.info(f"Current status: {status}")
|
|
2754
|
+
if status == 'failed':
|
|
2755
|
+
logger.info(f"Error: {error_message}")
|
|
2756
|
+
logger.info("")
|
|
2757
|
+
|
|
2758
|
+
if status != 'failed':
|
|
2759
|
+
logger.error(f"Only failed jobs can be retried (current status: {status})")
|
|
2760
|
+
return 1
|
|
2761
|
+
|
|
2762
|
+
# Retry the job
|
|
2763
|
+
result = client.retry_job(args.retry)
|
|
2764
|
+
retry_stage = result.get('retry_stage', 'unknown')
|
|
2765
|
+
logger.info(f"✓ Job retry started from stage: {retry_stage}")
|
|
2766
|
+
logger.info("")
|
|
2767
|
+
logger.info(f"Monitoring job progress...")
|
|
2768
|
+
logger.info("")
|
|
2769
|
+
|
|
2770
|
+
# Monitor the retried job
|
|
2771
|
+
return monitor.monitor(args.retry)
|
|
2772
|
+
|
|
2773
|
+
except ValueError as e:
|
|
2774
|
+
logger.error(str(e))
|
|
2775
|
+
return 1
|
|
2776
|
+
except RuntimeError as e:
|
|
2777
|
+
logger.error(str(e))
|
|
2778
|
+
return 1
|
|
2779
|
+
except Exception as e:
|
|
2780
|
+
logger.error(f"Error retrying job: {e}")
|
|
2781
|
+
return 1
|
|
2782
|
+
|
|
2783
|
+
# Handle delete job mode
|
|
2784
|
+
if args.delete:
|
|
2785
|
+
logger.info("=" * 60)
|
|
2786
|
+
logger.info("Karaoke Generator (Remote) - Delete Job")
|
|
2787
|
+
logger.info("=" * 60)
|
|
2788
|
+
logger.info(f"Job ID: {args.delete}")
|
|
2789
|
+
|
|
2790
|
+
try:
|
|
2791
|
+
# Get job info first
|
|
2792
|
+
job_data = client.get_job(args.delete)
|
|
2793
|
+
artist = job_data.get('artist', 'Unknown')
|
|
2794
|
+
title = job_data.get('title', 'Unknown')
|
|
2795
|
+
status = job_data.get('status', 'unknown')
|
|
2796
|
+
|
|
2797
|
+
logger.info(f"Artist: {artist}")
|
|
2798
|
+
logger.info(f"Title: {title}")
|
|
2799
|
+
logger.info(f"Status: {status}")
|
|
2800
|
+
logger.info("")
|
|
2801
|
+
|
|
2802
|
+
# Confirm deletion unless -y flag is set
|
|
2803
|
+
if not config.non_interactive:
|
|
2804
|
+
confirm = input("Are you sure you want to delete this job and all its files? [y/N]: ")
|
|
2805
|
+
if confirm.lower() != 'y':
|
|
2806
|
+
logger.info("Deletion cancelled.")
|
|
2807
|
+
return 0
|
|
2808
|
+
|
|
2809
|
+
# Delete the job
|
|
2810
|
+
result = client.delete_job(args.delete, delete_files=True)
|
|
2811
|
+
logger.info(f"✓ Job deleted successfully (including all files)")
|
|
2812
|
+
return 0
|
|
2813
|
+
|
|
2814
|
+
except ValueError as e:
|
|
2815
|
+
logger.error(str(e))
|
|
2816
|
+
return 1
|
|
2817
|
+
except Exception as e:
|
|
2818
|
+
logger.error(f"Error deleting job: {e}")
|
|
2819
|
+
return 1
|
|
2820
|
+
|
|
2821
|
+
# Handle finalise-only mode (Batch 6)
|
|
2822
|
+
if args.finalise_only:
|
|
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
|
|
2927
|
+
|
|
2928
|
+
if args.edit_lyrics:
|
|
2929
|
+
logger.error("--edit-lyrics is not yet supported in remote mode")
|
|
2930
|
+
return 1
|
|
2931
|
+
|
|
2932
|
+
if args.test_email_template:
|
|
2933
|
+
logger.error("--test_email_template is not supported in remote mode")
|
|
2934
|
+
return 1
|
|
2935
|
+
|
|
2936
|
+
# Warn about features that are not yet supported in remote mode
|
|
2937
|
+
ignored_features = []
|
|
2938
|
+
# Note: --prep-only is now supported in remote mode (Batch 6)
|
|
2939
|
+
if args.skip_separation:
|
|
2940
|
+
ignored_features.append("--skip-separation")
|
|
2941
|
+
if args.skip_transcription:
|
|
2942
|
+
ignored_features.append("--skip-transcription")
|
|
2943
|
+
if args.lyrics_only:
|
|
2944
|
+
ignored_features.append("--lyrics-only")
|
|
2945
|
+
if args.background_video:
|
|
2946
|
+
ignored_features.append("--background_video")
|
|
2947
|
+
# --auto-download is now supported (Batch 5)
|
|
2948
|
+
# These are now supported but server-side handling may be partial
|
|
2949
|
+
if args.organised_dir:
|
|
2950
|
+
ignored_features.append("--organised_dir (local-only)")
|
|
2951
|
+
# organised_dir_rclone_root is now supported in remote mode
|
|
2952
|
+
if args.public_share_dir:
|
|
2953
|
+
ignored_features.append("--public_share_dir (local-only)")
|
|
2954
|
+
if args.youtube_client_secrets_file:
|
|
2955
|
+
ignored_features.append("--youtube_client_secrets_file (not yet implemented)")
|
|
2956
|
+
if args.rclone_destination:
|
|
2957
|
+
ignored_features.append("--rclone_destination (local-only)")
|
|
2958
|
+
if args.email_template_file:
|
|
2959
|
+
ignored_features.append("--email_template_file (not yet implemented)")
|
|
2960
|
+
|
|
2961
|
+
if ignored_features:
|
|
2962
|
+
logger.warning(f"The following options are not yet supported in remote mode and will be ignored:")
|
|
2963
|
+
for feature in ignored_features:
|
|
2964
|
+
logger.warning(f" - {feature}")
|
|
2965
|
+
|
|
2966
|
+
# Handle new job submission - parse input arguments same as gen_cli
|
|
2967
|
+
input_media, artist, title, filename_pattern = None, None, None, None
|
|
2968
|
+
use_audio_search = False # Batch 5: audio search mode
|
|
2969
|
+
is_url_input = False
|
|
2970
|
+
|
|
2971
|
+
if not args.args:
|
|
2972
|
+
parser.print_help()
|
|
2973
|
+
return 1
|
|
2974
|
+
|
|
2975
|
+
# Allow 3 forms of positional arguments:
|
|
2976
|
+
# 1. URL or Media File only
|
|
2977
|
+
# 2. Artist and Title only (audio search mode - Batch 5)
|
|
2978
|
+
# 3. URL/File, Artist, and Title
|
|
2979
|
+
if args.args and (is_url(args.args[0]) or is_file(args.args[0])):
|
|
2980
|
+
input_media = args.args[0]
|
|
2981
|
+
is_url_input = is_url(args.args[0])
|
|
2982
|
+
if len(args.args) > 2:
|
|
2983
|
+
artist = args.args[1]
|
|
2984
|
+
title = args.args[2]
|
|
2985
|
+
elif len(args.args) > 1:
|
|
2986
|
+
artist = args.args[1]
|
|
2987
|
+
else:
|
|
2988
|
+
# For URLs, artist/title can be auto-detected
|
|
2989
|
+
if is_url_input:
|
|
2990
|
+
logger.info("URL provided without Artist and Title - will be auto-detected from video metadata")
|
|
2991
|
+
else:
|
|
2992
|
+
logger.error("Input media provided without Artist and Title")
|
|
2993
|
+
return 1
|
|
2994
|
+
elif os.path.isdir(args.args[0]):
|
|
2995
|
+
logger.error("Folder processing is not yet supported in remote mode")
|
|
2996
|
+
return 1
|
|
2997
|
+
elif len(args.args) > 1:
|
|
2998
|
+
# Audio search mode: artist + title without file (Batch 5)
|
|
2999
|
+
artist = args.args[0]
|
|
3000
|
+
title = args.args[1]
|
|
3001
|
+
use_audio_search = True
|
|
3002
|
+
else:
|
|
3003
|
+
parser.print_help()
|
|
3004
|
+
return 1
|
|
3005
|
+
|
|
3006
|
+
# Validate artist and title are provided
|
|
3007
|
+
if not artist or not title:
|
|
3008
|
+
logger.error("Artist and Title are required")
|
|
3009
|
+
parser.print_help()
|
|
3010
|
+
return 1
|
|
3011
|
+
|
|
3012
|
+
# For file/URL input modes, validate input exists
|
|
3013
|
+
if not use_audio_search:
|
|
3014
|
+
if not input_media:
|
|
3015
|
+
logger.error("No input media or URL provided")
|
|
3016
|
+
return 1
|
|
3017
|
+
|
|
3018
|
+
# For file input (not URL), validate file exists
|
|
3019
|
+
if not is_url_input and not os.path.isfile(input_media):
|
|
3020
|
+
logger.error(f"File not found: {input_media}")
|
|
3021
|
+
logger.error("Please provide a valid path to an audio file (mp3, wav, flac, m4a, ogg, aac)")
|
|
3022
|
+
return 1
|
|
3023
|
+
|
|
3024
|
+
# Handle audio search mode (Batch 5)
|
|
3025
|
+
if use_audio_search:
|
|
3026
|
+
logger.info("=" * 60)
|
|
3027
|
+
logger.info("Karaoke Generator (Remote) - Audio Search Mode")
|
|
3028
|
+
logger.info("=" * 60)
|
|
3029
|
+
logger.info(f"Searching for: {artist} - {title}")
|
|
3030
|
+
if getattr(args, 'auto_download', False) or config.non_interactive:
|
|
3031
|
+
logger.info(f"Auto-download: enabled (will auto-select best source)")
|
|
3032
|
+
if args.style_params_json:
|
|
3033
|
+
logger.info(f"Style: {args.style_params_json}")
|
|
3034
|
+
logger.info(f"CDG: {args.enable_cdg}, TXT: {args.enable_txt}")
|
|
3035
|
+
if args.brand_prefix:
|
|
3036
|
+
logger.info(f"Brand: {args.brand_prefix}")
|
|
3037
|
+
logger.info(f"Service URL: {config.service_url}")
|
|
3038
|
+
logger.info("")
|
|
3039
|
+
|
|
3040
|
+
# Read youtube description from file if provided
|
|
3041
|
+
youtube_description = None
|
|
3042
|
+
if args.youtube_description_file and os.path.isfile(args.youtube_description_file):
|
|
3043
|
+
try:
|
|
3044
|
+
with open(args.youtube_description_file, 'r') as f:
|
|
3045
|
+
youtube_description = f.read()
|
|
3046
|
+
logger.info(f"Loaded YouTube description from: {args.youtube_description_file}")
|
|
3047
|
+
except Exception as e:
|
|
3048
|
+
logger.warning(f"Failed to read YouTube description file: {e}")
|
|
3049
|
+
|
|
3050
|
+
try:
|
|
3051
|
+
# Determine auto_download mode
|
|
3052
|
+
auto_download = getattr(args, 'auto_download', False) or config.non_interactive
|
|
3053
|
+
|
|
3054
|
+
result = client.search_audio(
|
|
3055
|
+
artist=artist,
|
|
3056
|
+
title=title,
|
|
3057
|
+
auto_download=auto_download,
|
|
3058
|
+
style_params_path=args.style_params_json,
|
|
3059
|
+
enable_cdg=args.enable_cdg,
|
|
3060
|
+
enable_txt=args.enable_txt,
|
|
3061
|
+
brand_prefix=args.brand_prefix,
|
|
3062
|
+
discord_webhook_url=args.discord_webhook_url,
|
|
3063
|
+
youtube_description=youtube_description,
|
|
3064
|
+
enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
|
|
3065
|
+
dropbox_path=getattr(args, 'dropbox_path', None),
|
|
3066
|
+
gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
|
|
3067
|
+
lyrics_artist=getattr(args, 'lyrics_artist', None),
|
|
3068
|
+
lyrics_title=getattr(args, 'lyrics_title', None),
|
|
3069
|
+
subtitle_offset_ms=getattr(args, 'subtitle_offset_ms', 0) or 0,
|
|
3070
|
+
clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
|
|
3071
|
+
backing_vocals_models=getattr(args, 'backing_vocals_models', None),
|
|
3072
|
+
other_stems_models=getattr(args, 'other_stems_models', None),
|
|
3073
|
+
)
|
|
3074
|
+
|
|
3075
|
+
job_id = result.get('job_id')
|
|
3076
|
+
results_count = result.get('results_count', 0)
|
|
3077
|
+
server_version = result.get('server_version', 'unknown')
|
|
3078
|
+
|
|
3079
|
+
logger.info(f"Job created: {job_id}")
|
|
3080
|
+
logger.info(f"Server version: {server_version}")
|
|
3081
|
+
logger.info(f"Audio sources found: {results_count}")
|
|
3082
|
+
logger.info("")
|
|
3083
|
+
|
|
3084
|
+
# Monitor job
|
|
3085
|
+
return monitor.monitor(job_id)
|
|
3086
|
+
|
|
3087
|
+
except ValueError as e:
|
|
3088
|
+
logger.error(str(e))
|
|
3089
|
+
return 1
|
|
3090
|
+
except Exception as e:
|
|
3091
|
+
logger.error(f"Error: {e}")
|
|
3092
|
+
logger.exception("Full error details:")
|
|
3093
|
+
return 1
|
|
3094
|
+
|
|
3095
|
+
# File upload mode (original flow)
|
|
3096
|
+
logger.info("=" * 60)
|
|
3097
|
+
logger.info("Karaoke Generator (Remote) - Job Submission")
|
|
3098
|
+
logger.info("=" * 60)
|
|
3099
|
+
if is_url_input:
|
|
3100
|
+
logger.info(f"URL: {input_media}")
|
|
3101
|
+
else:
|
|
3102
|
+
logger.info(f"File: {input_media}")
|
|
3103
|
+
if artist:
|
|
3104
|
+
logger.info(f"Artist: {artist}")
|
|
3105
|
+
if title:
|
|
3106
|
+
logger.info(f"Title: {title}")
|
|
3107
|
+
if not artist and not title and is_url_input:
|
|
3108
|
+
logger.info(f"Artist/Title: (will be auto-detected from URL)")
|
|
3109
|
+
if args.style_params_json:
|
|
3110
|
+
logger.info(f"Style: {args.style_params_json}")
|
|
3111
|
+
logger.info(f"CDG: {args.enable_cdg}, TXT: {args.enable_txt}")
|
|
3112
|
+
if args.brand_prefix:
|
|
3113
|
+
logger.info(f"Brand: {args.brand_prefix}")
|
|
3114
|
+
if getattr(args, 'enable_youtube_upload', False):
|
|
3115
|
+
logger.info(f"YouTube Upload: enabled (server-side)")
|
|
3116
|
+
# Native API distribution (preferred for remote CLI)
|
|
3117
|
+
if getattr(args, 'dropbox_path', None):
|
|
3118
|
+
logger.info(f"Dropbox (native): {args.dropbox_path}")
|
|
3119
|
+
if getattr(args, 'gdrive_folder_id', None):
|
|
3120
|
+
logger.info(f"Google Drive (native): {args.gdrive_folder_id}")
|
|
3121
|
+
# Legacy rclone distribution
|
|
3122
|
+
if args.organised_dir_rclone_root:
|
|
3123
|
+
logger.info(f"Dropbox (rclone): {args.organised_dir_rclone_root}")
|
|
3124
|
+
if args.discord_webhook_url:
|
|
3125
|
+
logger.info(f"Discord: enabled")
|
|
3126
|
+
# Lyrics configuration
|
|
3127
|
+
if getattr(args, 'lyrics_artist', None):
|
|
3128
|
+
logger.info(f"Lyrics Artist Override: {args.lyrics_artist}")
|
|
3129
|
+
if getattr(args, 'lyrics_title', None):
|
|
3130
|
+
logger.info(f"Lyrics Title Override: {args.lyrics_title}")
|
|
3131
|
+
if getattr(args, 'lyrics_file', None):
|
|
3132
|
+
logger.info(f"Lyrics File: {args.lyrics_file}")
|
|
3133
|
+
if getattr(args, 'subtitle_offset_ms', 0):
|
|
3134
|
+
logger.info(f"Subtitle Offset: {args.subtitle_offset_ms}ms")
|
|
3135
|
+
# Audio model configuration
|
|
3136
|
+
if getattr(args, 'clean_instrumental_model', None):
|
|
3137
|
+
logger.info(f"Clean Instrumental Model: {args.clean_instrumental_model}")
|
|
3138
|
+
if getattr(args, 'backing_vocals_models', None):
|
|
3139
|
+
logger.info(f"Backing Vocals Models: {args.backing_vocals_models}")
|
|
3140
|
+
if getattr(args, 'other_stems_models', None):
|
|
3141
|
+
logger.info(f"Other Stems Models: {args.other_stems_models}")
|
|
3142
|
+
if getattr(args, 'existing_instrumental', None):
|
|
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)")
|
|
3146
|
+
logger.info(f"Service URL: {config.service_url}")
|
|
3147
|
+
logger.info(f"Review UI: {config.review_ui_url}")
|
|
3148
|
+
if config.non_interactive:
|
|
3149
|
+
logger.info(f"Non-interactive mode: enabled (will auto-accept defaults)")
|
|
3150
|
+
logger.info("")
|
|
3151
|
+
|
|
3152
|
+
# Read youtube description from file if provided
|
|
3153
|
+
youtube_description = None
|
|
3154
|
+
if args.youtube_description_file and os.path.isfile(args.youtube_description_file):
|
|
3155
|
+
try:
|
|
3156
|
+
with open(args.youtube_description_file, 'r') as f:
|
|
3157
|
+
youtube_description = f.read()
|
|
3158
|
+
logger.info(f"Loaded YouTube description from: {args.youtube_description_file}")
|
|
3159
|
+
except Exception as e:
|
|
3160
|
+
logger.warning(f"Failed to read YouTube description file: {e}")
|
|
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
|
+
|
|
3174
|
+
try:
|
|
3175
|
+
# Submit job - different endpoint for URL vs file
|
|
3176
|
+
if is_url_input:
|
|
3177
|
+
# URL-based job submission
|
|
3178
|
+
# Note: style_params_path is not supported for URL-based jobs
|
|
3179
|
+
# If custom styles are needed, download the audio locally first
|
|
3180
|
+
if args.style_params_json:
|
|
3181
|
+
logger.warning("Custom styles (--style_params_json) are not supported for URL-based jobs. "
|
|
3182
|
+
"Download the audio locally first and use file upload for custom styles.")
|
|
3183
|
+
|
|
3184
|
+
result = client.submit_job_from_url(
|
|
3185
|
+
url=input_media,
|
|
3186
|
+
artist=artist,
|
|
3187
|
+
title=title,
|
|
3188
|
+
enable_cdg=args.enable_cdg,
|
|
3189
|
+
enable_txt=args.enable_txt,
|
|
3190
|
+
brand_prefix=args.brand_prefix,
|
|
3191
|
+
discord_webhook_url=args.discord_webhook_url,
|
|
3192
|
+
youtube_description=youtube_description,
|
|
3193
|
+
organised_dir_rclone_root=args.organised_dir_rclone_root,
|
|
3194
|
+
enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
|
|
3195
|
+
# Native API distribution (preferred for remote CLI)
|
|
3196
|
+
dropbox_path=getattr(args, 'dropbox_path', None),
|
|
3197
|
+
gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
|
|
3198
|
+
# Lyrics configuration
|
|
3199
|
+
lyrics_artist=getattr(args, 'lyrics_artist', None),
|
|
3200
|
+
lyrics_title=getattr(args, 'lyrics_title', None),
|
|
3201
|
+
subtitle_offset_ms=getattr(args, 'subtitle_offset_ms', 0) or 0,
|
|
3202
|
+
# Audio separation model configuration
|
|
3203
|
+
clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
|
|
3204
|
+
backing_vocals_models=getattr(args, 'backing_vocals_models', None),
|
|
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,
|
|
3209
|
+
)
|
|
3210
|
+
else:
|
|
3211
|
+
# File-based job submission
|
|
3212
|
+
result = client.submit_job(
|
|
3213
|
+
filepath=input_media,
|
|
3214
|
+
artist=artist,
|
|
3215
|
+
title=title,
|
|
3216
|
+
style_params_path=args.style_params_json,
|
|
3217
|
+
enable_cdg=args.enable_cdg,
|
|
3218
|
+
enable_txt=args.enable_txt,
|
|
3219
|
+
brand_prefix=args.brand_prefix,
|
|
3220
|
+
discord_webhook_url=args.discord_webhook_url,
|
|
3221
|
+
youtube_description=youtube_description,
|
|
3222
|
+
organised_dir_rclone_root=args.organised_dir_rclone_root,
|
|
3223
|
+
enable_youtube_upload=getattr(args, 'enable_youtube_upload', False),
|
|
3224
|
+
# Native API distribution (preferred for remote CLI)
|
|
3225
|
+
dropbox_path=getattr(args, 'dropbox_path', None),
|
|
3226
|
+
gdrive_folder_id=getattr(args, 'gdrive_folder_id', None),
|
|
3227
|
+
# Lyrics configuration
|
|
3228
|
+
lyrics_artist=getattr(args, 'lyrics_artist', None),
|
|
3229
|
+
lyrics_title=getattr(args, 'lyrics_title', None),
|
|
3230
|
+
lyrics_file=getattr(args, 'lyrics_file', None),
|
|
3231
|
+
subtitle_offset_ms=getattr(args, 'subtitle_offset_ms', 0) or 0,
|
|
3232
|
+
# Audio separation model configuration
|
|
3233
|
+
clean_instrumental_model=getattr(args, 'clean_instrumental_model', None),
|
|
3234
|
+
backing_vocals_models=getattr(args, 'backing_vocals_models', None),
|
|
3235
|
+
other_stems_models=getattr(args, 'other_stems_models', None),
|
|
3236
|
+
# Existing instrumental (Batch 3)
|
|
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,
|
|
3241
|
+
)
|
|
3242
|
+
job_id = result.get('job_id')
|
|
3243
|
+
style_assets = result.get('style_assets_uploaded', [])
|
|
3244
|
+
server_version = result.get('server_version', 'unknown')
|
|
3245
|
+
|
|
3246
|
+
logger.info(f"Job submitted successfully: {job_id}")
|
|
3247
|
+
logger.info(f"Server version: {server_version}")
|
|
3248
|
+
if style_assets:
|
|
3249
|
+
logger.info(f"Style assets uploaded: {', '.join(style_assets)}")
|
|
3250
|
+
logger.info("")
|
|
3251
|
+
|
|
3252
|
+
# Monitor job
|
|
3253
|
+
return monitor.monitor(job_id)
|
|
3254
|
+
|
|
3255
|
+
except FileNotFoundError as e:
|
|
3256
|
+
logger.error(str(e))
|
|
3257
|
+
return 1
|
|
3258
|
+
except ValueError as e:
|
|
3259
|
+
logger.error(str(e))
|
|
3260
|
+
return 1
|
|
3261
|
+
except Exception as e:
|
|
3262
|
+
logger.error(f"Error: {e}")
|
|
3263
|
+
logger.exception("Full error details:")
|
|
3264
|
+
return 1
|
|
3265
|
+
|
|
3266
|
+
|
|
3267
|
+
if __name__ == "__main__":
|
|
3268
|
+
sys.exit(main())
|