karaoke-gen 0.90.1__py3-none-any.whl → 0.99.3__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.
- backend/.coveragerc +20 -0
- backend/.gitignore +37 -0
- backend/Dockerfile +43 -0
- backend/Dockerfile.base +74 -0
- backend/README.md +242 -0
- backend/__init__.py +0 -0
- backend/api/__init__.py +0 -0
- backend/api/dependencies.py +457 -0
- backend/api/routes/__init__.py +0 -0
- backend/api/routes/admin.py +835 -0
- backend/api/routes/audio_search.py +913 -0
- backend/api/routes/auth.py +348 -0
- backend/api/routes/file_upload.py +2112 -0
- backend/api/routes/health.py +409 -0
- backend/api/routes/internal.py +435 -0
- backend/api/routes/jobs.py +1629 -0
- backend/api/routes/review.py +652 -0
- backend/api/routes/themes.py +162 -0
- backend/api/routes/users.py +1513 -0
- backend/config.py +172 -0
- backend/main.py +157 -0
- backend/middleware/__init__.py +5 -0
- backend/middleware/audit_logging.py +124 -0
- backend/models/__init__.py +0 -0
- backend/models/job.py +519 -0
- backend/models/requests.py +123 -0
- backend/models/theme.py +153 -0
- backend/models/user.py +254 -0
- backend/models/worker_log.py +164 -0
- backend/pyproject.toml +29 -0
- backend/quick-check.sh +93 -0
- backend/requirements.txt +29 -0
- backend/run_tests.sh +60 -0
- backend/services/__init__.py +0 -0
- backend/services/audio_analysis_service.py +243 -0
- backend/services/audio_editing_service.py +278 -0
- backend/services/audio_search_service.py +702 -0
- backend/services/auth_service.py +630 -0
- backend/services/credential_manager.py +792 -0
- backend/services/discord_service.py +172 -0
- backend/services/dropbox_service.py +301 -0
- backend/services/email_service.py +1093 -0
- backend/services/encoding_interface.py +454 -0
- backend/services/encoding_service.py +502 -0
- backend/services/firestore_service.py +512 -0
- backend/services/flacfetch_client.py +573 -0
- backend/services/gce_encoding/README.md +72 -0
- backend/services/gce_encoding/__init__.py +22 -0
- backend/services/gce_encoding/main.py +589 -0
- backend/services/gce_encoding/requirements.txt +16 -0
- backend/services/gdrive_service.py +356 -0
- backend/services/job_logging.py +258 -0
- backend/services/job_manager.py +853 -0
- backend/services/job_notification_service.py +271 -0
- backend/services/langfuse_preloader.py +98 -0
- backend/services/local_encoding_service.py +590 -0
- backend/services/local_preview_encoding_service.py +407 -0
- backend/services/lyrics_cache_service.py +216 -0
- backend/services/metrics.py +413 -0
- backend/services/nltk_preloader.py +122 -0
- backend/services/packaging_service.py +287 -0
- backend/services/rclone_service.py +106 -0
- backend/services/spacy_preloader.py +65 -0
- backend/services/storage_service.py +209 -0
- backend/services/stripe_service.py +371 -0
- backend/services/structured_logging.py +254 -0
- backend/services/template_service.py +330 -0
- backend/services/theme_service.py +469 -0
- backend/services/tracing.py +543 -0
- backend/services/user_service.py +721 -0
- backend/services/worker_service.py +558 -0
- backend/services/youtube_service.py +112 -0
- backend/services/youtube_upload_service.py +445 -0
- backend/tests/__init__.py +4 -0
- backend/tests/conftest.py +224 -0
- backend/tests/emulator/__init__.py +7 -0
- backend/tests/emulator/conftest.py +109 -0
- backend/tests/emulator/test_e2e_cli_backend.py +1053 -0
- backend/tests/emulator/test_emulator_integration.py +356 -0
- backend/tests/emulator/test_style_loading_direct.py +436 -0
- backend/tests/emulator/test_worker_logs_direct.py +229 -0
- backend/tests/emulator/test_worker_logs_subcollection.py +443 -0
- backend/tests/requirements-test.txt +10 -0
- backend/tests/requirements.txt +6 -0
- backend/tests/test_admin_email_endpoints.py +411 -0
- backend/tests/test_api_integration.py +460 -0
- backend/tests/test_api_routes.py +93 -0
- backend/tests/test_audio_analysis_service.py +294 -0
- backend/tests/test_audio_editing_service.py +386 -0
- backend/tests/test_audio_search.py +1398 -0
- backend/tests/test_audio_services.py +378 -0
- backend/tests/test_auth_firestore.py +231 -0
- backend/tests/test_config_extended.py +68 -0
- backend/tests/test_credential_manager.py +377 -0
- backend/tests/test_dependencies.py +54 -0
- backend/tests/test_discord_service.py +244 -0
- backend/tests/test_distribution_services.py +820 -0
- backend/tests/test_dropbox_service.py +472 -0
- backend/tests/test_email_service.py +492 -0
- backend/tests/test_emulator_integration.py +322 -0
- backend/tests/test_encoding_interface.py +412 -0
- backend/tests/test_file_upload.py +1739 -0
- backend/tests/test_flacfetch_client.py +632 -0
- backend/tests/test_gdrive_service.py +524 -0
- backend/tests/test_instrumental_api.py +431 -0
- backend/tests/test_internal_api.py +343 -0
- backend/tests/test_job_creation_regression.py +583 -0
- backend/tests/test_job_manager.py +356 -0
- backend/tests/test_job_manager_notifications.py +329 -0
- backend/tests/test_job_notification_service.py +443 -0
- backend/tests/test_jobs_api.py +283 -0
- backend/tests/test_local_encoding_service.py +423 -0
- backend/tests/test_local_preview_encoding_service.py +567 -0
- backend/tests/test_main.py +87 -0
- backend/tests/test_models.py +918 -0
- backend/tests/test_packaging_service.py +382 -0
- backend/tests/test_requests.py +201 -0
- backend/tests/test_routes_jobs.py +282 -0
- backend/tests/test_routes_review.py +337 -0
- backend/tests/test_services.py +556 -0
- backend/tests/test_services_extended.py +112 -0
- backend/tests/test_spacy_preloader.py +119 -0
- backend/tests/test_storage_service.py +448 -0
- backend/tests/test_style_upload.py +261 -0
- backend/tests/test_template_service.py +295 -0
- backend/tests/test_theme_service.py +516 -0
- backend/tests/test_unicode_sanitization.py +522 -0
- backend/tests/test_upload_api.py +256 -0
- backend/tests/test_validate.py +156 -0
- backend/tests/test_video_worker_orchestrator.py +847 -0
- backend/tests/test_worker_log_subcollection.py +509 -0
- backend/tests/test_worker_logging.py +365 -0
- backend/tests/test_workers.py +1116 -0
- backend/tests/test_workers_extended.py +178 -0
- backend/tests/test_youtube_service.py +247 -0
- backend/tests/test_youtube_upload_service.py +568 -0
- backend/utils/test_data.py +27 -0
- backend/validate.py +173 -0
- backend/version.py +27 -0
- backend/workers/README.md +597 -0
- backend/workers/__init__.py +11 -0
- backend/workers/audio_worker.py +618 -0
- backend/workers/lyrics_worker.py +683 -0
- backend/workers/render_video_worker.py +483 -0
- backend/workers/screens_worker.py +535 -0
- backend/workers/style_helper.py +198 -0
- backend/workers/video_worker.py +1277 -0
- backend/workers/video_worker_orchestrator.py +701 -0
- backend/workers/worker_logging.py +278 -0
- karaoke_gen/instrumental_review/static/index.html +7 -4
- karaoke_gen/karaoke_finalise/karaoke_finalise.py +6 -1
- karaoke_gen/utils/__init__.py +163 -8
- karaoke_gen/video_background_processor.py +9 -4
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/METADATA +1 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/RECORD +196 -46
- lyrics_transcriber/correction/agentic/agent.py +17 -6
- lyrics_transcriber/correction/agentic/providers/config.py +9 -5
- lyrics_transcriber/correction/agentic/providers/langchain_bridge.py +96 -93
- lyrics_transcriber/correction/agentic/providers/model_factory.py +27 -6
- lyrics_transcriber/correction/anchor_sequence.py +151 -37
- lyrics_transcriber/correction/corrector.py +192 -130
- lyrics_transcriber/correction/handlers/syllables_match.py +44 -2
- lyrics_transcriber/correction/operations.py +24 -9
- lyrics_transcriber/correction/phrase_analyzer.py +18 -0
- lyrics_transcriber/frontend/package-lock.json +2 -2
- lyrics_transcriber/frontend/package.json +1 -1
- lyrics_transcriber/frontend/src/components/AIFeedbackModal.tsx +1 -1
- lyrics_transcriber/frontend/src/components/CorrectedWordWithActions.tsx +11 -7
- lyrics_transcriber/frontend/src/components/EditActionBar.tsx +31 -5
- lyrics_transcriber/frontend/src/components/EditModal.tsx +28 -10
- lyrics_transcriber/frontend/src/components/EditTimelineSection.tsx +123 -27
- lyrics_transcriber/frontend/src/components/EditWordList.tsx +112 -60
- lyrics_transcriber/frontend/src/components/Header.tsx +90 -76
- lyrics_transcriber/frontend/src/components/LyricsAnalyzer.tsx +53 -31
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/SyncControls.tsx +44 -13
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/TimelineCanvas.tsx +66 -50
- lyrics_transcriber/frontend/src/components/LyricsSynchronizer/index.tsx +124 -30
- lyrics_transcriber/frontend/src/components/ReferenceView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/TimelineEditor.tsx +12 -5
- lyrics_transcriber/frontend/src/components/TimingOffsetModal.tsx +3 -3
- lyrics_transcriber/frontend/src/components/TranscriptionView.tsx +1 -1
- lyrics_transcriber/frontend/src/components/WordDivider.tsx +11 -7
- lyrics_transcriber/frontend/src/components/shared/components/Word.tsx +4 -2
- lyrics_transcriber/frontend/src/hooks/useManualSync.ts +103 -1
- lyrics_transcriber/frontend/src/theme.ts +42 -15
- lyrics_transcriber/frontend/tsconfig.tsbuildinfo +1 -1
- lyrics_transcriber/frontend/vite.config.js +5 -0
- lyrics_transcriber/frontend/web_assets/assets/{index-BECn1o8Q.js → index-BSMgOq4Z.js} +6959 -5782
- lyrics_transcriber/frontend/web_assets/assets/index-BSMgOq4Z.js.map +1 -0
- lyrics_transcriber/frontend/web_assets/index.html +6 -2
- lyrics_transcriber/frontend/web_assets/nomad-karaoke-logo.svg +5 -0
- lyrics_transcriber/output/generator.py +17 -3
- lyrics_transcriber/output/video.py +60 -95
- lyrics_transcriber/frontend/web_assets/assets/index-BECn1o8Q.js.map +0 -1
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.90.1.dist-info → karaoke_gen-0.99.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Audio search API routes for artist+title search mode.
|
|
3
|
+
|
|
4
|
+
This module provides endpoints for:
|
|
5
|
+
1. Creating a job with audio search (artist+title without file)
|
|
6
|
+
2. Getting search results for a job
|
|
7
|
+
3. Selecting an audio source to download
|
|
8
|
+
|
|
9
|
+
The flow is:
|
|
10
|
+
1. POST /api/audio-search/search - Create job and search for audio
|
|
11
|
+
2. GET /api/audio-search/{job_id}/results - Get search results
|
|
12
|
+
3. POST /api/audio-search/{job_id}/select - Select audio source
|
|
13
|
+
"""
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import tempfile
|
|
18
|
+
from typing import Optional, List, Dict, Any, Tuple
|
|
19
|
+
|
|
20
|
+
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request, Depends
|
|
21
|
+
from pydantic import BaseModel, Field, validator
|
|
22
|
+
|
|
23
|
+
from backend.models.job import JobCreate, JobStatus
|
|
24
|
+
from karaoke_gen.utils import normalize_text
|
|
25
|
+
from backend.services.job_manager import JobManager
|
|
26
|
+
from backend.services.storage_service import StorageService
|
|
27
|
+
from backend.services.worker_service import get_worker_service
|
|
28
|
+
from backend.services.credential_manager import get_credential_manager, CredentialStatus
|
|
29
|
+
from backend.services.audio_search_service import (
|
|
30
|
+
get_audio_search_service,
|
|
31
|
+
AudioSearchResult,
|
|
32
|
+
AudioSearchError,
|
|
33
|
+
NoResultsError,
|
|
34
|
+
DownloadError,
|
|
35
|
+
)
|
|
36
|
+
from backend.services.theme_service import get_theme_service
|
|
37
|
+
from backend.config import get_settings
|
|
38
|
+
from backend.version import VERSION
|
|
39
|
+
from backend.api.dependencies import require_auth
|
|
40
|
+
from backend.services.auth_service import UserType, AuthResult
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
# Valid style file types (from file_upload.py)
|
|
46
|
+
STYLE_FILE_TYPES = {
|
|
47
|
+
'style_params': {'.json'},
|
|
48
|
+
'style_intro_background': {'.jpg', '.jpeg', '.png'},
|
|
49
|
+
'style_karaoke_background': {'.jpg', '.jpeg', '.png'},
|
|
50
|
+
'style_end_background': {'.jpg', '.jpeg', '.png'},
|
|
51
|
+
'style_font': {'.ttf', '.otf'},
|
|
52
|
+
'style_cdg_instrumental_background': {'.jpg', '.jpeg', '.png'},
|
|
53
|
+
'style_cdg_title_background': {'.jpg', '.jpeg', '.png'},
|
|
54
|
+
'style_cdg_outro_background': {'.jpg', '.jpeg', '.png'},
|
|
55
|
+
}
|
|
56
|
+
router = APIRouter(tags=["audio-search"])
|
|
57
|
+
|
|
58
|
+
# Initialize services
|
|
59
|
+
job_manager = JobManager()
|
|
60
|
+
storage_service = StorageService()
|
|
61
|
+
worker_service = get_worker_service()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ============================================================================
|
|
65
|
+
# Pydantic models
|
|
66
|
+
# ============================================================================
|
|
67
|
+
|
|
68
|
+
class StyleFileRequest(BaseModel):
|
|
69
|
+
"""Information about a style file to be uploaded."""
|
|
70
|
+
filename: str = Field(..., description="Original filename with extension")
|
|
71
|
+
content_type: str = Field(..., description="MIME type of the file")
|
|
72
|
+
file_type: str = Field(..., description="Type of file: 'style_params', 'style_intro_background', 'style_karaoke_background', 'style_end_background', 'style_font', 'style_cdg_instrumental_background', 'style_cdg_title_background', 'style_cdg_outro_background'")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class StyleUploadUrl(BaseModel):
|
|
76
|
+
"""Signed URL for uploading a style file."""
|
|
77
|
+
file_type: str = Field(..., description="Type of file being uploaded")
|
|
78
|
+
gcs_path: str = Field(..., description="Destination path in GCS")
|
|
79
|
+
upload_url: str = Field(..., description="Signed URL to PUT the file to")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class AudioSearchRequest(BaseModel):
|
|
83
|
+
"""Request to search for audio by artist and title."""
|
|
84
|
+
artist: str = Field(..., description="Artist name to search for")
|
|
85
|
+
title: str = Field(..., description="Song title to search for")
|
|
86
|
+
|
|
87
|
+
# Auto-download mode
|
|
88
|
+
auto_download: bool = Field(False, description="Automatically select best result and download")
|
|
89
|
+
|
|
90
|
+
# Theme configuration
|
|
91
|
+
theme_id: Optional[str] = Field(None, description="Theme ID to use (e.g., 'nomad', 'default'). If set, CDG/TXT are enabled by default.")
|
|
92
|
+
color_overrides: Optional[Dict[str, str]] = Field(None, description="Color overrides: artist_color, title_color, sung_lyrics_color, unsung_lyrics_color (hex #RRGGBB)")
|
|
93
|
+
|
|
94
|
+
# Processing options (CDG/TXT require style config or theme, disabled by default unless theme is set)
|
|
95
|
+
enable_cdg: Optional[bool] = Field(None, description="Generate CDG+MP3 package. Default: True if theme_id set, False otherwise")
|
|
96
|
+
enable_txt: Optional[bool] = Field(None, description="Generate TXT+MP3 package. Default: True if theme_id set, False otherwise")
|
|
97
|
+
|
|
98
|
+
# Finalisation options
|
|
99
|
+
brand_prefix: Optional[str] = Field(None, description="Brand code prefix (e.g., NOMAD)")
|
|
100
|
+
enable_youtube_upload: Optional[bool] = Field(None, description="Upload to YouTube. None = use server default")
|
|
101
|
+
youtube_description: Optional[str] = Field(None, description="YouTube video description text")
|
|
102
|
+
discord_webhook_url: Optional[str] = Field(None, description="Discord webhook URL for notifications")
|
|
103
|
+
|
|
104
|
+
# Distribution options
|
|
105
|
+
dropbox_path: Optional[str] = Field(None, description="Dropbox folder path for organized output")
|
|
106
|
+
gdrive_folder_id: Optional[str] = Field(None, description="Google Drive folder ID for public share uploads")
|
|
107
|
+
|
|
108
|
+
# Lyrics configuration
|
|
109
|
+
lyrics_artist: Optional[str] = Field(None, description="Override artist name for lyrics search")
|
|
110
|
+
lyrics_title: Optional[str] = Field(None, description="Override title for lyrics search")
|
|
111
|
+
subtitle_offset_ms: int = Field(0, description="Subtitle timing offset in milliseconds")
|
|
112
|
+
|
|
113
|
+
# Display overrides (optional - if empty, search values are used for display)
|
|
114
|
+
display_artist: Optional[str] = Field(None, description="Artist name for title screens/filenames. If empty, uses search artist.")
|
|
115
|
+
display_title: Optional[str] = Field(None, description="Title for title screens/filenames. If empty, uses search title.")
|
|
116
|
+
|
|
117
|
+
# Audio separation model configuration
|
|
118
|
+
clean_instrumental_model: Optional[str] = Field(None, description="Model for clean instrumental separation")
|
|
119
|
+
backing_vocals_models: Optional[List[str]] = Field(None, description="Models for backing vocals separation")
|
|
120
|
+
other_stems_models: Optional[List[str]] = Field(None, description="Models for other stems")
|
|
121
|
+
|
|
122
|
+
# Style file uploads (optional)
|
|
123
|
+
# Style params JSON and related assets (background images, fonts)
|
|
124
|
+
style_files: Optional[List[StyleFileRequest]] = Field(None, description="Style files to upload (style_params JSON, backgrounds, fonts)")
|
|
125
|
+
|
|
126
|
+
# Non-interactive mode
|
|
127
|
+
non_interactive: bool = Field(False, description="Skip interactive steps (lyrics review, instrumental selection)")
|
|
128
|
+
|
|
129
|
+
@validator('artist', 'title', 'lyrics_artist', 'lyrics_title', 'display_artist', 'display_title')
|
|
130
|
+
def normalize_text_fields(cls, v):
|
|
131
|
+
"""Normalize text fields to standardize Unicode characters."""
|
|
132
|
+
if v is not None and isinstance(v, str):
|
|
133
|
+
return normalize_text(v)
|
|
134
|
+
return v
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class AudioSearchResultResponse(BaseModel):
|
|
138
|
+
"""A single audio search result.
|
|
139
|
+
|
|
140
|
+
Contains all fields needed for rich display using flacfetch's
|
|
141
|
+
shared formatting functions.
|
|
142
|
+
"""
|
|
143
|
+
index: int
|
|
144
|
+
title: str
|
|
145
|
+
artist: str
|
|
146
|
+
provider: str # Maps to source_name in Release
|
|
147
|
+
url: Optional[str] = None # Maps to download_url in Release (may be None for remote)
|
|
148
|
+
duration: Optional[int] = None # Maps to duration_seconds in Release
|
|
149
|
+
quality: Optional[str] = None # Stringified quality
|
|
150
|
+
source_id: Optional[str] = None # Maps to info_hash in Release
|
|
151
|
+
seeders: Optional[int] = None
|
|
152
|
+
target_file: Optional[str] = None
|
|
153
|
+
# Additional fields for rich display (from Release.to_dict())
|
|
154
|
+
year: Optional[int] = None
|
|
155
|
+
label: Optional[str] = None
|
|
156
|
+
edition_info: Optional[str] = None
|
|
157
|
+
release_type: Optional[str] = None
|
|
158
|
+
channel: Optional[str] = None # For YouTube
|
|
159
|
+
view_count: Optional[int] = None # For YouTube
|
|
160
|
+
size_bytes: Optional[int] = None
|
|
161
|
+
target_file_size: Optional[int] = None
|
|
162
|
+
track_pattern: Optional[str] = None
|
|
163
|
+
match_score: Optional[float] = None
|
|
164
|
+
# Pre-computed display fields
|
|
165
|
+
formatted_size: Optional[str] = None
|
|
166
|
+
formatted_duration: Optional[str] = None
|
|
167
|
+
formatted_views: Optional[str] = None
|
|
168
|
+
is_lossless: Optional[bool] = None
|
|
169
|
+
quality_str: Optional[str] = None
|
|
170
|
+
# Full quality object for Release.from_dict()
|
|
171
|
+
quality_data: Optional[Dict[str, Any]] = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class AudioSearchResponse(BaseModel):
|
|
175
|
+
"""Response from audio search."""
|
|
176
|
+
status: str
|
|
177
|
+
job_id: str
|
|
178
|
+
message: str
|
|
179
|
+
results: Optional[List[AudioSearchResultResponse]] = None
|
|
180
|
+
results_count: int = 0
|
|
181
|
+
auto_download: bool = False
|
|
182
|
+
server_version: str
|
|
183
|
+
# Style file upload URLs (when style_files are specified in request)
|
|
184
|
+
style_upload_urls: Optional[List[StyleUploadUrl]] = None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class AudioSelectRequest(BaseModel):
|
|
188
|
+
"""Request to select an audio source."""
|
|
189
|
+
selection_index: int = Field(..., description="Index of the selected audio source from search results")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class AudioSelectResponse(BaseModel):
|
|
193
|
+
"""Response from audio source selection."""
|
|
194
|
+
status: str
|
|
195
|
+
job_id: str
|
|
196
|
+
message: str
|
|
197
|
+
selected_index: int
|
|
198
|
+
selected_title: str
|
|
199
|
+
selected_artist: str
|
|
200
|
+
selected_provider: str
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _resolve_cdg_txt_defaults(
|
|
204
|
+
theme_id: Optional[str],
|
|
205
|
+
enable_cdg: Optional[bool],
|
|
206
|
+
enable_txt: Optional[bool]
|
|
207
|
+
) -> Tuple[bool, bool]:
|
|
208
|
+
"""
|
|
209
|
+
Resolve CDG/TXT settings based on theme and explicit settings.
|
|
210
|
+
|
|
211
|
+
When a theme is selected, CDG and TXT are enabled by default.
|
|
212
|
+
Explicit True/False values always override the default.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
theme_id: Theme identifier (if any)
|
|
216
|
+
enable_cdg: Explicit CDG setting (None means use default)
|
|
217
|
+
enable_txt: Explicit TXT setting (None means use default)
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Tuple of (resolved_enable_cdg, resolved_enable_txt)
|
|
221
|
+
"""
|
|
222
|
+
# Default based on whether theme is set
|
|
223
|
+
default_enabled = theme_id is not None
|
|
224
|
+
|
|
225
|
+
# Explicit values override defaults, None uses default
|
|
226
|
+
resolved_cdg = enable_cdg if enable_cdg is not None else default_enabled
|
|
227
|
+
resolved_txt = enable_txt if enable_txt is not None else default_enabled
|
|
228
|
+
|
|
229
|
+
return resolved_cdg, resolved_txt
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def extract_request_metadata(request: Request, created_from: str = "audio_search") -> Dict[str, Any]:
|
|
233
|
+
"""Extract metadata from request for job tracking."""
|
|
234
|
+
headers = dict(request.headers)
|
|
235
|
+
|
|
236
|
+
client_ip = headers.get('x-forwarded-for', '').split(',')[0].strip()
|
|
237
|
+
if not client_ip and request.client:
|
|
238
|
+
client_ip = request.client.host
|
|
239
|
+
|
|
240
|
+
user_agent = headers.get('user-agent', '')
|
|
241
|
+
environment = headers.get('x-environment', '')
|
|
242
|
+
client_id = headers.get('x-client-id', '')
|
|
243
|
+
|
|
244
|
+
custom_headers = {}
|
|
245
|
+
for key, value in headers.items():
|
|
246
|
+
if key.lower().startswith('x-') and key.lower() not in ('x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host'):
|
|
247
|
+
custom_headers[key] = value
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
'client_ip': client_ip,
|
|
251
|
+
'user_agent': user_agent,
|
|
252
|
+
'environment': environment,
|
|
253
|
+
'client_id': client_id,
|
|
254
|
+
'server_version': VERSION,
|
|
255
|
+
'created_from': created_from,
|
|
256
|
+
'custom_headers': custom_headers,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def _trigger_workers_parallel(job_id: str) -> None:
|
|
261
|
+
"""Trigger both audio and lyrics workers in parallel."""
|
|
262
|
+
await asyncio.gather(
|
|
263
|
+
worker_service.trigger_audio_worker(job_id),
|
|
264
|
+
worker_service.trigger_lyrics_worker(job_id)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def _download_and_start_processing(
|
|
269
|
+
job_id: str,
|
|
270
|
+
selection_index: int,
|
|
271
|
+
audio_search_service,
|
|
272
|
+
background_tasks: BackgroundTasks,
|
|
273
|
+
) -> Dict[str, Any]:
|
|
274
|
+
"""
|
|
275
|
+
Download selected audio and start job processing.
|
|
276
|
+
|
|
277
|
+
This is called either:
|
|
278
|
+
- Immediately after search if auto_download=True
|
|
279
|
+
- When user calls the select endpoint
|
|
280
|
+
|
|
281
|
+
For remote flacfetch downloads (torrent sources), the file is downloaded
|
|
282
|
+
on the flacfetch VM and uploaded directly to GCS. For local downloads
|
|
283
|
+
(YouTube), the file is downloaded locally and then uploaded to GCS.
|
|
284
|
+
"""
|
|
285
|
+
job = job_manager.get_job(job_id)
|
|
286
|
+
if not job:
|
|
287
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
288
|
+
|
|
289
|
+
# Get search results from state_data
|
|
290
|
+
search_results = job.state_data.get('audio_search_results', [])
|
|
291
|
+
if not search_results:
|
|
292
|
+
raise HTTPException(status_code=400, detail="No search results available")
|
|
293
|
+
|
|
294
|
+
if selection_index < 0 or selection_index >= len(search_results):
|
|
295
|
+
raise HTTPException(
|
|
296
|
+
status_code=400,
|
|
297
|
+
detail=f"Invalid selection index {selection_index}. Valid range: 0-{len(search_results)-1}"
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
selected = search_results[selection_index]
|
|
301
|
+
|
|
302
|
+
# Get remote_search_id from state_data (stored during initial search)
|
|
303
|
+
remote_search_id = job.state_data.get('remote_search_id')
|
|
304
|
+
|
|
305
|
+
# Transition to downloading state
|
|
306
|
+
job_manager.transition_to_state(
|
|
307
|
+
job_id=job_id,
|
|
308
|
+
new_status=JobStatus.DOWNLOADING_AUDIO,
|
|
309
|
+
progress=10,
|
|
310
|
+
message=f"Downloading from {selected['provider']}: {selected['artist']} - {selected['title']}",
|
|
311
|
+
state_data_updates={
|
|
312
|
+
'selected_audio_index': selection_index,
|
|
313
|
+
'selected_audio_provider': selected['provider'],
|
|
314
|
+
}
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
# Determine if this is a remote torrent download
|
|
319
|
+
is_torrent_source = selected.get('provider') in ['RED', 'OPS']
|
|
320
|
+
is_remote_enabled = audio_search_service.is_remote_enabled()
|
|
321
|
+
|
|
322
|
+
# Check if we can use download_by_id (preferred - avoids re-searching)
|
|
323
|
+
source_id = selected.get('source_id')
|
|
324
|
+
source_name = selected.get('provider')
|
|
325
|
+
target_file = selected.get('target_file')
|
|
326
|
+
download_url = selected.get('url')
|
|
327
|
+
|
|
328
|
+
# For remote torrent downloads, have flacfetch VM upload directly to GCS
|
|
329
|
+
if is_torrent_source and is_remote_enabled:
|
|
330
|
+
# Generate GCS path for remote upload
|
|
331
|
+
gcs_destination = f"uploads/{job_id}/audio/"
|
|
332
|
+
|
|
333
|
+
# Use download_by_id if we have source_id (preferred - no re-search needed)
|
|
334
|
+
if source_id and source_name:
|
|
335
|
+
logger.info(f"Using download_by_id for {source_name} ID={source_id} with GCS upload to: {gcs_destination}")
|
|
336
|
+
|
|
337
|
+
result = audio_search_service.download_by_id(
|
|
338
|
+
source_name=source_name,
|
|
339
|
+
source_id=source_id,
|
|
340
|
+
output_dir="", # Not used for remote
|
|
341
|
+
target_file=target_file,
|
|
342
|
+
download_url=download_url,
|
|
343
|
+
gcs_path=gcs_destination,
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
# Fallback to search-based download (requires re-search)
|
|
347
|
+
logger.info(f"No source_id available, falling back to search-based download to: {gcs_destination}")
|
|
348
|
+
|
|
349
|
+
result = audio_search_service.download(
|
|
350
|
+
result_index=selection_index,
|
|
351
|
+
output_dir="", # Not used for remote
|
|
352
|
+
gcs_path=gcs_destination,
|
|
353
|
+
remote_search_id=remote_search_id,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# For remote downloads, filepath is already the GCS path
|
|
357
|
+
if result.filepath.startswith("gs://"):
|
|
358
|
+
# Extract the path portion after the bucket name
|
|
359
|
+
# Format: gs://bucket/uploads/job_id/audio/filename.flac
|
|
360
|
+
parts = result.filepath.replace("gs://", "").split("/", 1)
|
|
361
|
+
if len(parts) == 2:
|
|
362
|
+
audio_gcs_path = parts[1]
|
|
363
|
+
else:
|
|
364
|
+
audio_gcs_path = result.filepath
|
|
365
|
+
filename = os.path.basename(result.filepath)
|
|
366
|
+
else:
|
|
367
|
+
# Fallback: treat as local path (shouldn't happen for remote)
|
|
368
|
+
filename = os.path.basename(result.filepath)
|
|
369
|
+
audio_gcs_path = f"uploads/{job_id}/audio/{filename}"
|
|
370
|
+
|
|
371
|
+
logger.warning(f"Remote download returned local path: {result.filepath}, uploading manually")
|
|
372
|
+
with open(result.filepath, 'rb') as f:
|
|
373
|
+
storage_service.upload_fileobj(f, audio_gcs_path, content_type='audio/flac')
|
|
374
|
+
|
|
375
|
+
logger.info(f"Remote download complete, GCS path: {audio_gcs_path}")
|
|
376
|
+
else:
|
|
377
|
+
# Local download (YouTube or fallback)
|
|
378
|
+
temp_dir = tempfile.mkdtemp(prefix=f"audio_download_{job_id}_")
|
|
379
|
+
|
|
380
|
+
# Use download_by_id if we have source_id and remote is enabled
|
|
381
|
+
if source_id and source_name and is_remote_enabled:
|
|
382
|
+
logger.info(f"Using download_by_id for local download: {source_name} ID={source_id}")
|
|
383
|
+
|
|
384
|
+
result = audio_search_service.download_by_id(
|
|
385
|
+
source_name=source_name,
|
|
386
|
+
source_id=source_id,
|
|
387
|
+
output_dir=temp_dir,
|
|
388
|
+
target_file=target_file,
|
|
389
|
+
download_url=download_url,
|
|
390
|
+
)
|
|
391
|
+
elif source_name == 'YouTube' and download_url:
|
|
392
|
+
# For YouTube without remote, download directly using URL from state_data
|
|
393
|
+
# This avoids relying on in-memory cache which doesn't persist across instances
|
|
394
|
+
logger.info(f"Downloading YouTube audio directly from URL: {download_url}")
|
|
395
|
+
from backend.workers.audio_worker import download_from_url
|
|
396
|
+
|
|
397
|
+
local_path = await download_from_url(
|
|
398
|
+
download_url,
|
|
399
|
+
temp_dir,
|
|
400
|
+
selected.get('artist'),
|
|
401
|
+
selected.get('title')
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
if not local_path or not os.path.exists(local_path):
|
|
405
|
+
raise DownloadError(f"Failed to download from YouTube: {download_url}")
|
|
406
|
+
|
|
407
|
+
# Create a result-like object
|
|
408
|
+
class DownloadResult:
|
|
409
|
+
def __init__(self, filepath):
|
|
410
|
+
self.filepath = filepath
|
|
411
|
+
|
|
412
|
+
result = DownloadResult(local_path)
|
|
413
|
+
else:
|
|
414
|
+
# Fallback to search-based download (may fail if cache is empty)
|
|
415
|
+
logger.warning(f"Falling back to cache-based download for {source_name} - this may fail on multi-instance deployments")
|
|
416
|
+
result = audio_search_service.download(
|
|
417
|
+
result_index=selection_index,
|
|
418
|
+
output_dir=temp_dir,
|
|
419
|
+
remote_search_id=remote_search_id, # Pass for potential fallback scenarios
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
# Upload to GCS
|
|
423
|
+
filename = os.path.basename(result.filepath)
|
|
424
|
+
audio_gcs_path = f"uploads/{job_id}/audio/{filename}"
|
|
425
|
+
|
|
426
|
+
with open(result.filepath, 'rb') as f:
|
|
427
|
+
storage_service.upload_fileobj(
|
|
428
|
+
f,
|
|
429
|
+
audio_gcs_path,
|
|
430
|
+
content_type='audio/flac' # flacfetch typically returns FLAC
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
logger.info(f"Uploaded audio to GCS: {audio_gcs_path}")
|
|
434
|
+
|
|
435
|
+
# Clean up temp file
|
|
436
|
+
try:
|
|
437
|
+
os.remove(result.filepath)
|
|
438
|
+
os.rmdir(temp_dir)
|
|
439
|
+
except Exception as e:
|
|
440
|
+
logger.warning(f"Failed to clean up temp files: {e}")
|
|
441
|
+
|
|
442
|
+
# Update job with GCS path and transition to DOWNLOADING
|
|
443
|
+
job_manager.update_job(job_id, {
|
|
444
|
+
'input_media_gcs_path': audio_gcs_path,
|
|
445
|
+
'filename': filename,
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
job_manager.transition_to_state(
|
|
449
|
+
job_id=job_id,
|
|
450
|
+
new_status=JobStatus.DOWNLOADING,
|
|
451
|
+
progress=15,
|
|
452
|
+
message="Audio downloaded, starting processing"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
# Trigger workers
|
|
456
|
+
background_tasks.add_task(_trigger_workers_parallel, job_id)
|
|
457
|
+
|
|
458
|
+
return {
|
|
459
|
+
'selected_index': selection_index,
|
|
460
|
+
'selected_title': selected['title'],
|
|
461
|
+
'selected_artist': selected['artist'],
|
|
462
|
+
'selected_provider': selected['provider'],
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
except DownloadError as e:
|
|
466
|
+
job_manager.fail_job(job_id, f"Audio download failed: {e}")
|
|
467
|
+
raise HTTPException(status_code=500, detail=f"Download failed: {e}")
|
|
468
|
+
except Exception as e:
|
|
469
|
+
job_manager.fail_job(job_id, f"Audio download failed: {e}")
|
|
470
|
+
raise HTTPException(status_code=500, detail=f"Download failed: {e}")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@router.post("/audio-search/search", response_model=AudioSearchResponse)
|
|
474
|
+
async def search_audio(
|
|
475
|
+
request: Request,
|
|
476
|
+
background_tasks: BackgroundTasks,
|
|
477
|
+
body: AudioSearchRequest,
|
|
478
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
479
|
+
):
|
|
480
|
+
"""
|
|
481
|
+
Search for audio by artist and title, creating a new job.
|
|
482
|
+
|
|
483
|
+
This endpoint:
|
|
484
|
+
1. Creates a job in PENDING state
|
|
485
|
+
2. Searches for audio using flacfetch
|
|
486
|
+
3. Either returns search results for user selection, or
|
|
487
|
+
4. If auto_download=True, automatically selects best and starts processing
|
|
488
|
+
|
|
489
|
+
Use cases:
|
|
490
|
+
- Interactive mode (default): Returns results, user calls /select endpoint
|
|
491
|
+
- Auto mode (auto_download=True): Automatically selects and downloads best
|
|
492
|
+
"""
|
|
493
|
+
try:
|
|
494
|
+
# Apply default distribution settings
|
|
495
|
+
settings = get_settings()
|
|
496
|
+
effective_dropbox_path = body.dropbox_path or settings.default_dropbox_path
|
|
497
|
+
effective_gdrive_folder_id = body.gdrive_folder_id or settings.default_gdrive_folder_id
|
|
498
|
+
effective_discord_webhook_url = body.discord_webhook_url or settings.default_discord_webhook_url
|
|
499
|
+
|
|
500
|
+
# Apply defaults for YouTube/Dropbox distribution (for web service)
|
|
501
|
+
# Use explicit value if provided, otherwise fall back to server default
|
|
502
|
+
effective_enable_youtube_upload = body.enable_youtube_upload if body.enable_youtube_upload is not None else settings.default_enable_youtube_upload
|
|
503
|
+
effective_brand_prefix = body.brand_prefix or settings.default_brand_prefix
|
|
504
|
+
effective_youtube_description = body.youtube_description or settings.default_youtube_description
|
|
505
|
+
|
|
506
|
+
# Validate credentials if distribution services are requested
|
|
507
|
+
invalid_services = []
|
|
508
|
+
credential_manager = get_credential_manager()
|
|
509
|
+
|
|
510
|
+
if effective_enable_youtube_upload:
|
|
511
|
+
result = credential_manager.check_youtube_credentials()
|
|
512
|
+
if result.status != CredentialStatus.VALID:
|
|
513
|
+
invalid_services.append(f"youtube ({result.message})")
|
|
514
|
+
|
|
515
|
+
if effective_dropbox_path:
|
|
516
|
+
result = credential_manager.check_dropbox_credentials()
|
|
517
|
+
if result.status != CredentialStatus.VALID:
|
|
518
|
+
invalid_services.append(f"dropbox ({result.message})")
|
|
519
|
+
|
|
520
|
+
if effective_gdrive_folder_id:
|
|
521
|
+
result = credential_manager.check_gdrive_credentials()
|
|
522
|
+
if result.status != CredentialStatus.VALID:
|
|
523
|
+
invalid_services.append(f"gdrive ({result.message})")
|
|
524
|
+
|
|
525
|
+
if invalid_services:
|
|
526
|
+
raise HTTPException(
|
|
527
|
+
status_code=400,
|
|
528
|
+
detail={
|
|
529
|
+
"error": "credentials_invalid",
|
|
530
|
+
"message": f"Distribution services need re-authorization: {', '.join(invalid_services)}",
|
|
531
|
+
"invalid_services": invalid_services,
|
|
532
|
+
}
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# Extract request metadata
|
|
536
|
+
request_metadata = extract_request_metadata(request, created_from="audio_search")
|
|
537
|
+
|
|
538
|
+
# Apply default theme if none specified
|
|
539
|
+
# This ensures all karaoke videos use the Nomad theme by default
|
|
540
|
+
effective_theme_id = body.theme_id
|
|
541
|
+
if effective_theme_id is None:
|
|
542
|
+
theme_service = get_theme_service()
|
|
543
|
+
effective_theme_id = theme_service.get_default_theme_id()
|
|
544
|
+
if effective_theme_id:
|
|
545
|
+
logger.info(f"Applying default theme: {effective_theme_id}")
|
|
546
|
+
|
|
547
|
+
# Resolve CDG/TXT defaults based on theme
|
|
548
|
+
resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
|
|
549
|
+
effective_theme_id, body.enable_cdg, body.enable_txt
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Use authenticated user's email
|
|
553
|
+
effective_user_email = auth_result.user_email
|
|
554
|
+
|
|
555
|
+
# Determine display values - use display_* if provided, otherwise fall back to search values
|
|
556
|
+
# Display values are used for title screens, filenames, YouTube, etc.
|
|
557
|
+
# Search values (body.artist, body.title) are used for audio search
|
|
558
|
+
effective_display_artist = body.display_artist.strip() if body.display_artist else body.artist
|
|
559
|
+
effective_display_title = body.display_title.strip() if body.display_title else body.title
|
|
560
|
+
|
|
561
|
+
# Create job
|
|
562
|
+
job_create = JobCreate(
|
|
563
|
+
artist=effective_display_artist, # Display value for title screens, filenames
|
|
564
|
+
title=effective_display_title, # Display value for title screens, filenames
|
|
565
|
+
theme_id=effective_theme_id,
|
|
566
|
+
color_overrides=body.color_overrides or {},
|
|
567
|
+
enable_cdg=resolved_cdg,
|
|
568
|
+
enable_txt=resolved_txt,
|
|
569
|
+
brand_prefix=effective_brand_prefix,
|
|
570
|
+
enable_youtube_upload=effective_enable_youtube_upload,
|
|
571
|
+
youtube_description=effective_youtube_description,
|
|
572
|
+
youtube_description_template=effective_youtube_description, # video_worker reads this field
|
|
573
|
+
discord_webhook_url=effective_discord_webhook_url,
|
|
574
|
+
dropbox_path=effective_dropbox_path,
|
|
575
|
+
gdrive_folder_id=effective_gdrive_folder_id,
|
|
576
|
+
user_email=effective_user_email,
|
|
577
|
+
lyrics_artist=body.lyrics_artist,
|
|
578
|
+
lyrics_title=body.lyrics_title,
|
|
579
|
+
subtitle_offset_ms=body.subtitle_offset_ms,
|
|
580
|
+
clean_instrumental_model=body.clean_instrumental_model,
|
|
581
|
+
backing_vocals_models=body.backing_vocals_models,
|
|
582
|
+
other_stems_models=body.other_stems_models,
|
|
583
|
+
audio_search_artist=body.artist,
|
|
584
|
+
audio_search_title=body.title,
|
|
585
|
+
auto_download=body.auto_download,
|
|
586
|
+
request_metadata=request_metadata,
|
|
587
|
+
non_interactive=body.non_interactive,
|
|
588
|
+
)
|
|
589
|
+
job = job_manager.create_job(job_create)
|
|
590
|
+
job_id = job.job_id
|
|
591
|
+
|
|
592
|
+
logger.info(f"Created job {job_id} for audio search: {body.artist} - {body.title}")
|
|
593
|
+
|
|
594
|
+
# Update job with audio search fields
|
|
595
|
+
job_manager.update_job(job_id, {
|
|
596
|
+
'audio_search_artist': body.artist,
|
|
597
|
+
'audio_search_title': body.title,
|
|
598
|
+
'auto_download': body.auto_download,
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
# If theme is set and no custom style files are being uploaded, prepare theme style now
|
|
602
|
+
# This copies the theme's style_params.json to the job folder so LyricsTranscriber
|
|
603
|
+
# can access the style configuration for preview videos
|
|
604
|
+
if effective_theme_id and not body.style_files:
|
|
605
|
+
from backend.api.routes.file_upload import _prepare_theme_for_job
|
|
606
|
+
try:
|
|
607
|
+
style_params_path, theme_style_assets, youtube_desc = _prepare_theme_for_job(
|
|
608
|
+
job_id, effective_theme_id, body.color_overrides
|
|
609
|
+
)
|
|
610
|
+
theme_update = {
|
|
611
|
+
'style_params_gcs_path': style_params_path,
|
|
612
|
+
'style_assets': theme_style_assets,
|
|
613
|
+
}
|
|
614
|
+
if youtube_desc and not effective_youtube_description:
|
|
615
|
+
theme_update['youtube_description_template'] = youtube_desc
|
|
616
|
+
job_manager.update_job(job_id, theme_update)
|
|
617
|
+
logger.info(f"Applied theme '{effective_theme_id}' to job {job_id}")
|
|
618
|
+
except Exception as e:
|
|
619
|
+
logger.warning(f"Failed to prepare theme '{effective_theme_id}' for job {job_id}: {e}")
|
|
620
|
+
# Continue without theme - job can still be processed with defaults
|
|
621
|
+
|
|
622
|
+
# Handle style file uploads if provided
|
|
623
|
+
style_upload_urls: List[StyleUploadUrl] = []
|
|
624
|
+
style_assets = {}
|
|
625
|
+
|
|
626
|
+
if body.style_files:
|
|
627
|
+
logger.info(f"Processing {len(body.style_files)} style file uploads for job {job_id}")
|
|
628
|
+
|
|
629
|
+
for file_info in body.style_files:
|
|
630
|
+
# Validate file type
|
|
631
|
+
if file_info.file_type not in STYLE_FILE_TYPES:
|
|
632
|
+
raise HTTPException(
|
|
633
|
+
status_code=400,
|
|
634
|
+
detail=f"Invalid file type '{file_info.file_type}'. Must be one of: {', '.join(STYLE_FILE_TYPES.keys())}"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# Validate extension
|
|
638
|
+
ext = Path(file_info.filename).suffix.lower()
|
|
639
|
+
allowed_exts = STYLE_FILE_TYPES[file_info.file_type]
|
|
640
|
+
if ext not in allowed_exts:
|
|
641
|
+
raise HTTPException(
|
|
642
|
+
status_code=400,
|
|
643
|
+
detail=f"Invalid extension '{ext}' for {file_info.file_type}. Allowed: {', '.join(allowed_exts)}"
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
# Generate GCS path
|
|
647
|
+
if file_info.file_type == 'style_params':
|
|
648
|
+
gcs_path = f"uploads/{job_id}/style/style_params.json"
|
|
649
|
+
else:
|
|
650
|
+
# style_intro_background -> intro_background, etc.
|
|
651
|
+
asset_key = file_info.file_type.replace('style_', '')
|
|
652
|
+
gcs_path = f"uploads/{job_id}/style/{asset_key}{ext}"
|
|
653
|
+
|
|
654
|
+
# Generate signed upload URL
|
|
655
|
+
signed_url = storage_service.generate_signed_upload_url(
|
|
656
|
+
gcs_path,
|
|
657
|
+
content_type=file_info.content_type,
|
|
658
|
+
expiration_minutes=60
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
style_upload_urls.append(StyleUploadUrl(
|
|
662
|
+
file_type=file_info.file_type,
|
|
663
|
+
gcs_path=gcs_path,
|
|
664
|
+
upload_url=signed_url
|
|
665
|
+
))
|
|
666
|
+
|
|
667
|
+
# Track the expected asset paths
|
|
668
|
+
if file_info.file_type == 'style_params':
|
|
669
|
+
style_assets['style_params'] = gcs_path
|
|
670
|
+
else:
|
|
671
|
+
asset_key = file_info.file_type.replace('style_', '')
|
|
672
|
+
style_assets[asset_key] = gcs_path
|
|
673
|
+
|
|
674
|
+
# Update job with style asset expectations
|
|
675
|
+
style_update = {'style_assets': style_assets}
|
|
676
|
+
if 'style_params' in style_assets:
|
|
677
|
+
style_update['style_params_gcs_path'] = style_assets['style_params']
|
|
678
|
+
job_manager.update_job(job_id, style_update)
|
|
679
|
+
|
|
680
|
+
logger.info(f"Generated {len(style_upload_urls)} style upload URLs for job {job_id}")
|
|
681
|
+
|
|
682
|
+
# Transition to searching state
|
|
683
|
+
job_manager.transition_to_state(
|
|
684
|
+
job_id=job_id,
|
|
685
|
+
new_status=JobStatus.SEARCHING_AUDIO,
|
|
686
|
+
progress=5,
|
|
687
|
+
message=f"Searching for: {body.artist} - {body.title}"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# Perform search
|
|
691
|
+
audio_search_service = get_audio_search_service()
|
|
692
|
+
|
|
693
|
+
try:
|
|
694
|
+
search_results = audio_search_service.search(body.artist, body.title)
|
|
695
|
+
except NoResultsError as e:
|
|
696
|
+
job_manager.fail_job(job_id, f"No audio sources found for: {body.artist} - {body.title}")
|
|
697
|
+
raise HTTPException(
|
|
698
|
+
status_code=404,
|
|
699
|
+
detail={
|
|
700
|
+
"error": "no_results",
|
|
701
|
+
"message": str(e),
|
|
702
|
+
"job_id": job_id,
|
|
703
|
+
}
|
|
704
|
+
)
|
|
705
|
+
except AudioSearchError as e:
|
|
706
|
+
job_manager.fail_job(job_id, f"Audio search failed: {e}")
|
|
707
|
+
raise HTTPException(status_code=500, detail=f"Search failed: {e}")
|
|
708
|
+
|
|
709
|
+
# Store results in job state_data, including remote_search_id if available
|
|
710
|
+
results_dicts = [r.to_dict() for r in search_results]
|
|
711
|
+
state_data_update = {
|
|
712
|
+
'audio_search_results': results_dicts,
|
|
713
|
+
'audio_search_count': len(results_dicts),
|
|
714
|
+
}
|
|
715
|
+
# Store remote_search_id for use during download (important for concurrent requests)
|
|
716
|
+
if audio_search_service.last_remote_search_id:
|
|
717
|
+
state_data_update['remote_search_id'] = audio_search_service.last_remote_search_id
|
|
718
|
+
job_manager.update_job(job_id, {
|
|
719
|
+
'state_data': state_data_update
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
# If auto_download, select best and start processing
|
|
723
|
+
if body.auto_download:
|
|
724
|
+
best_index = audio_search_service.select_best(search_results)
|
|
725
|
+
|
|
726
|
+
logger.info(f"Auto-download enabled, selecting result {best_index}")
|
|
727
|
+
|
|
728
|
+
selection_info = await _download_and_start_processing(
|
|
729
|
+
job_id=job_id,
|
|
730
|
+
selection_index=best_index,
|
|
731
|
+
audio_search_service=audio_search_service,
|
|
732
|
+
background_tasks=background_tasks,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
return AudioSearchResponse(
|
|
736
|
+
status="success",
|
|
737
|
+
job_id=job_id,
|
|
738
|
+
message=f"Audio found and download started: {selection_info['selected_artist']} - {selection_info['selected_title']} ({selection_info['selected_provider']})",
|
|
739
|
+
results=None, # Don't return results in auto mode
|
|
740
|
+
results_count=len(search_results),
|
|
741
|
+
auto_download=True,
|
|
742
|
+
server_version=VERSION,
|
|
743
|
+
style_upload_urls=style_upload_urls if style_upload_urls else None,
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Interactive mode: return results for user selection
|
|
747
|
+
job_manager.transition_to_state(
|
|
748
|
+
job_id=job_id,
|
|
749
|
+
new_status=JobStatus.AWAITING_AUDIO_SELECTION,
|
|
750
|
+
progress=10,
|
|
751
|
+
message=f"Found {len(search_results)} audio sources. Waiting for selection."
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# Convert to response format with full Release data for rich display
|
|
755
|
+
result_responses = []
|
|
756
|
+
for r in search_results:
|
|
757
|
+
# Get full serialized data from raw_result if available
|
|
758
|
+
# raw_result can be either:
|
|
759
|
+
# - A dict (from remote flacfetch API)
|
|
760
|
+
# - A Release object (from local flacfetch)
|
|
761
|
+
raw_dict = {}
|
|
762
|
+
if r.raw_result:
|
|
763
|
+
if isinstance(r.raw_result, dict):
|
|
764
|
+
# Remote flacfetch API returns dicts directly
|
|
765
|
+
raw_dict = r.raw_result
|
|
766
|
+
else:
|
|
767
|
+
# Local flacfetch returns Release objects
|
|
768
|
+
try:
|
|
769
|
+
raw_dict = r.raw_result.to_dict()
|
|
770
|
+
except AttributeError:
|
|
771
|
+
pass # Not a Release object
|
|
772
|
+
|
|
773
|
+
result_responses.append(
|
|
774
|
+
AudioSearchResultResponse(
|
|
775
|
+
index=r.index,
|
|
776
|
+
title=r.title,
|
|
777
|
+
artist=r.artist,
|
|
778
|
+
provider=r.provider,
|
|
779
|
+
url=r.url,
|
|
780
|
+
duration=r.duration,
|
|
781
|
+
quality=r.quality,
|
|
782
|
+
source_id=r.source_id,
|
|
783
|
+
seeders=raw_dict.get('seeders') or r.seeders,
|
|
784
|
+
target_file=raw_dict.get('target_file') or r.target_file,
|
|
785
|
+
# Additional fields for rich display
|
|
786
|
+
year=raw_dict.get('year'),
|
|
787
|
+
label=raw_dict.get('label'),
|
|
788
|
+
edition_info=raw_dict.get('edition_info'),
|
|
789
|
+
release_type=raw_dict.get('release_type'),
|
|
790
|
+
channel=raw_dict.get('channel'),
|
|
791
|
+
view_count=raw_dict.get('view_count'),
|
|
792
|
+
size_bytes=raw_dict.get('size_bytes'),
|
|
793
|
+
target_file_size=raw_dict.get('target_file_size'),
|
|
794
|
+
track_pattern=raw_dict.get('track_pattern'),
|
|
795
|
+
match_score=raw_dict.get('match_score'),
|
|
796
|
+
# Pre-computed display fields
|
|
797
|
+
formatted_size=raw_dict.get('formatted_size'),
|
|
798
|
+
formatted_duration=raw_dict.get('formatted_duration'),
|
|
799
|
+
formatted_views=raw_dict.get('formatted_views'),
|
|
800
|
+
is_lossless=raw_dict.get('is_lossless'),
|
|
801
|
+
quality_str=raw_dict.get('quality_str'),
|
|
802
|
+
# Full quality object for Release.from_dict()
|
|
803
|
+
# Remote API uses 'quality_data', local uses 'quality' for the dict
|
|
804
|
+
quality_data=raw_dict.get('quality_data') or raw_dict.get('quality'),
|
|
805
|
+
)
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
return AudioSearchResponse(
|
|
809
|
+
status="awaiting_selection",
|
|
810
|
+
job_id=job_id,
|
|
811
|
+
message=f"Found {len(search_results)} audio sources. Call /api/audio-search/{job_id}/select to choose one.",
|
|
812
|
+
results=result_responses,
|
|
813
|
+
results_count=len(search_results),
|
|
814
|
+
auto_download=False,
|
|
815
|
+
server_version=VERSION,
|
|
816
|
+
style_upload_urls=style_upload_urls if style_upload_urls else None,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
except HTTPException:
|
|
820
|
+
raise
|
|
821
|
+
except Exception as e:
|
|
822
|
+
logger.error(f"Error in audio search: {e}", exc_info=True)
|
|
823
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
@router.get("/audio-search/{job_id}/results")
|
|
827
|
+
async def get_audio_search_results(
|
|
828
|
+
job_id: str,
|
|
829
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
830
|
+
):
|
|
831
|
+
"""
|
|
832
|
+
Get audio search results for a job.
|
|
833
|
+
|
|
834
|
+
Returns the cached search results so user can select one.
|
|
835
|
+
"""
|
|
836
|
+
job = job_manager.get_job(job_id)
|
|
837
|
+
if not job:
|
|
838
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
839
|
+
|
|
840
|
+
search_results = job.state_data.get('audio_search_results', [])
|
|
841
|
+
|
|
842
|
+
if not search_results:
|
|
843
|
+
raise HTTPException(
|
|
844
|
+
status_code=400,
|
|
845
|
+
detail="No search results available for this job"
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
return {
|
|
849
|
+
"status": "success",
|
|
850
|
+
"job_id": job_id,
|
|
851
|
+
"job_status": job.status,
|
|
852
|
+
"artist": job.audio_search_artist or job.artist,
|
|
853
|
+
"title": job.audio_search_title or job.title,
|
|
854
|
+
"results": search_results,
|
|
855
|
+
"results_count": len(search_results),
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
@router.post("/audio-search/{job_id}/select", response_model=AudioSelectResponse)
|
|
860
|
+
async def select_audio_source(
|
|
861
|
+
job_id: str,
|
|
862
|
+
background_tasks: BackgroundTasks,
|
|
863
|
+
body: AudioSelectRequest,
|
|
864
|
+
auth_result: AuthResult = Depends(require_auth)
|
|
865
|
+
):
|
|
866
|
+
"""
|
|
867
|
+
Select an audio source and start job processing.
|
|
868
|
+
|
|
869
|
+
This endpoint:
|
|
870
|
+
1. Validates the job is awaiting selection
|
|
871
|
+
2. Downloads the selected audio
|
|
872
|
+
3. Uploads to GCS
|
|
873
|
+
4. Triggers audio and lyrics workers
|
|
874
|
+
"""
|
|
875
|
+
job = job_manager.get_job(job_id)
|
|
876
|
+
if not job:
|
|
877
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
878
|
+
|
|
879
|
+
# Verify job is awaiting selection
|
|
880
|
+
if job.status != JobStatus.AWAITING_AUDIO_SELECTION:
|
|
881
|
+
raise HTTPException(
|
|
882
|
+
status_code=400,
|
|
883
|
+
detail=f"Job is not awaiting audio selection (status: {job.status})"
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
# Get search service instance
|
|
887
|
+
# Note: With download_by_id, we no longer need to re-search to populate the cache.
|
|
888
|
+
# The source_id stored in job.state_data['audio_search_results'] is sufficient.
|
|
889
|
+
audio_search_service = get_audio_search_service()
|
|
890
|
+
|
|
891
|
+
# Validate search results exist in job state_data
|
|
892
|
+
search_results = job.state_data.get('audio_search_results', [])
|
|
893
|
+
if not search_results:
|
|
894
|
+
raise HTTPException(status_code=400, detail="No search results cached for this job")
|
|
895
|
+
|
|
896
|
+
selection_info = await _download_and_start_processing(
|
|
897
|
+
job_id=job_id,
|
|
898
|
+
selection_index=body.selection_index,
|
|
899
|
+
audio_search_service=audio_search_service,
|
|
900
|
+
background_tasks=background_tasks,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
return AudioSelectResponse(
|
|
904
|
+
status="success",
|
|
905
|
+
job_id=job_id,
|
|
906
|
+
message="Audio selected and download started",
|
|
907
|
+
selected_index=selection_info['selected_index'],
|
|
908
|
+
selected_title=selection_info['selected_title'],
|
|
909
|
+
selected_artist=selection_info['selected_artist'],
|
|
910
|
+
selected_provider=selection_info['selected_provider'],
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
|