karaoke-gen 0.99.3__py3-none-any.whl → 0.103.1__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.
Files changed (55) hide show
  1. backend/api/routes/admin.py +512 -1
  2. backend/api/routes/audio_search.py +17 -34
  3. backend/api/routes/file_upload.py +60 -84
  4. backend/api/routes/internal.py +6 -0
  5. backend/api/routes/jobs.py +11 -3
  6. backend/api/routes/rate_limits.py +428 -0
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +229 -247
  10. backend/config.py +16 -0
  11. backend/exceptions.py +66 -0
  12. backend/main.py +30 -1
  13. backend/middleware/__init__.py +7 -1
  14. backend/middleware/tenant.py +192 -0
  15. backend/models/job.py +19 -3
  16. backend/models/tenant.py +208 -0
  17. backend/models/user.py +18 -0
  18. backend/services/email_service.py +253 -6
  19. backend/services/email_validation_service.py +646 -0
  20. backend/services/firestore_service.py +27 -0
  21. backend/services/job_defaults_service.py +113 -0
  22. backend/services/job_manager.py +73 -3
  23. backend/services/rate_limit_service.py +641 -0
  24. backend/services/stripe_service.py +61 -35
  25. backend/services/tenant_service.py +285 -0
  26. backend/services/user_service.py +85 -7
  27. backend/tests/conftest.py +7 -1
  28. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  29. backend/tests/test_admin_job_files.py +337 -0
  30. backend/tests/test_admin_job_reset.py +384 -0
  31. backend/tests/test_admin_job_update.py +326 -0
  32. backend/tests/test_audio_search.py +12 -8
  33. backend/tests/test_email_service.py +233 -0
  34. backend/tests/test_email_validation_service.py +298 -0
  35. backend/tests/test_file_upload.py +8 -6
  36. backend/tests/test_impersonation.py +223 -0
  37. backend/tests/test_job_creation_regression.py +4 -0
  38. backend/tests/test_job_manager.py +146 -1
  39. backend/tests/test_made_for_you.py +2088 -0
  40. backend/tests/test_models.py +139 -0
  41. backend/tests/test_rate_limit_service.py +396 -0
  42. backend/tests/test_rate_limits_api.py +392 -0
  43. backend/tests/test_tenant_api.py +350 -0
  44. backend/tests/test_tenant_middleware.py +345 -0
  45. backend/tests/test_tenant_models.py +406 -0
  46. backend/tests/test_tenant_service.py +418 -0
  47. backend/workers/video_worker.py +8 -3
  48. backend/workers/video_worker_orchestrator.py +26 -0
  49. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
  50. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
  51. lyrics_transcriber/frontend/src/api.ts +13 -5
  52. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  53. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
  54. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
  55. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
@@ -15,10 +15,11 @@ from fastapi import APIRouter, Depends, HTTPException
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from backend.api.dependencies import require_admin
18
- from backend.services.auth_service import UserType
18
+ from backend.services.auth_service import UserType, AuthResult
19
19
  from backend.services.user_service import get_user_service, UserService, USERS_COLLECTION
20
20
  from backend.services.job_manager import JobManager
21
21
  from backend.services.flacfetch_client import get_flacfetch_client, FlacfetchServiceError
22
+ from backend.services.storage_service import StorageService
22
23
  from backend.models.job import JobStatus
23
24
  from backend.utils.test_data import is_test_email
24
25
  from karaoke_gen.utils import sanitize_filename
@@ -56,6 +57,74 @@ class AdminStatsOverview(BaseModel):
56
57
  total_beta_testers: int
57
58
 
58
59
 
60
+ class FileInfo(BaseModel):
61
+ """Information about a single file with signed download URL."""
62
+ name: str
63
+ path: str # GCS path (gs://bucket/...)
64
+ download_url: str # Signed URL for download
65
+ category: str # e.g., "stems", "lyrics", "finals"
66
+ file_key: str # e.g., "instrumental_clean", "lrc"
67
+
68
+
69
+ class JobFilesResponse(BaseModel):
70
+ """Response containing all files for a job with signed download URLs."""
71
+ job_id: str
72
+ artist: Optional[str]
73
+ title: Optional[str]
74
+ files: List[FileInfo]
75
+ total_files: int
76
+
77
+
78
+ class JobUpdateRequest(BaseModel):
79
+ """Request model for updating job fields."""
80
+ # Editable text fields
81
+ artist: Optional[str] = None
82
+ title: Optional[str] = None
83
+ user_email: Optional[str] = None
84
+ theme_id: Optional[str] = None
85
+ brand_prefix: Optional[str] = None
86
+ discord_webhook_url: Optional[str] = None
87
+ youtube_description: Optional[str] = None
88
+ youtube_description_template: Optional[str] = None
89
+ customer_email: Optional[str] = None
90
+ customer_notes: Optional[str] = None
91
+
92
+ # Editable boolean fields
93
+ enable_cdg: Optional[bool] = None
94
+ enable_txt: Optional[bool] = None
95
+ enable_youtube_upload: Optional[bool] = None
96
+ non_interactive: Optional[bool] = None
97
+ prep_only: Optional[bool] = None
98
+
99
+
100
+ class JobUpdateResponse(BaseModel):
101
+ """Response from job update endpoint."""
102
+ status: str
103
+ job_id: str
104
+ updated_fields: List[str]
105
+ message: str
106
+
107
+
108
+ # Fields that are allowed to be updated via PATCH endpoint
109
+ EDITABLE_JOB_FIELDS = {
110
+ "artist",
111
+ "title",
112
+ "user_email",
113
+ "theme_id",
114
+ "brand_prefix",
115
+ "discord_webhook_url",
116
+ "youtube_description",
117
+ "youtube_description_template",
118
+ "customer_email",
119
+ "customer_notes",
120
+ "enable_cdg",
121
+ "enable_txt",
122
+ "enable_youtube_upload",
123
+ "non_interactive",
124
+ "prep_only",
125
+ }
126
+
127
+
59
128
  # =============================================================================
60
129
  # Admin Stats Endpoints
61
130
  # =============================================================================
@@ -702,6 +771,374 @@ class SendCompletionEmailResponse(BaseModel):
702
771
  message: str
703
772
 
704
773
 
774
+ # =============================================================================
775
+ # Job Files Endpoint
776
+ # =============================================================================
777
+
778
+ def _extract_files_recursive(
779
+ file_urls: Dict[str, Any],
780
+ storage: StorageService,
781
+ category: str = "",
782
+ expiration_minutes: int = 120,
783
+ ) -> List[FileInfo]:
784
+ """
785
+ Recursively extract files from nested file_urls structure.
786
+
787
+ Only includes entries that are GCS paths (gs://...).
788
+ Skips non-GCS entries like YouTube URLs.
789
+
790
+ Args:
791
+ file_urls: Dictionary of file URLs (may be nested)
792
+ storage: StorageService instance for generating signed URLs
793
+ category: Current category name (for nested calls)
794
+ expiration_minutes: How long signed URLs should be valid
795
+
796
+ Returns:
797
+ List of FileInfo objects with signed download URLs
798
+ """
799
+ files = []
800
+
801
+ for key, value in file_urls.items():
802
+ if isinstance(value, dict):
803
+ # Nested structure - recurse with key as category
804
+ nested_files = _extract_files_recursive(
805
+ value,
806
+ storage,
807
+ category=key if not category else f"{category}.{key}",
808
+ expiration_minutes=expiration_minutes,
809
+ )
810
+ files.extend(nested_files)
811
+ elif isinstance(value, str) and value.startswith("gs://"):
812
+ # GCS path - generate signed URL
813
+ try:
814
+ signed_url = storage.generate_signed_url(value, expiration_minutes=expiration_minutes)
815
+ # Extract filename from path
816
+ name = value.split("/")[-1] if "/" in value else value
817
+ files.append(FileInfo(
818
+ name=name,
819
+ path=value,
820
+ download_url=signed_url,
821
+ category=category,
822
+ file_key=key,
823
+ ))
824
+ except Exception as e:
825
+ # Log but don't fail - file might not exist
826
+ logger.warning(f"Failed to generate signed URL for {value}: {e}")
827
+ # Skip non-GCS values (e.g., youtube URLs, video IDs)
828
+
829
+ return files
830
+
831
+
832
+ @router.get("/jobs/{job_id}/files", response_model=JobFilesResponse)
833
+ async def get_job_files(
834
+ job_id: str,
835
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
836
+ ):
837
+ """
838
+ Get all files for a job with signed download URLs.
839
+
840
+ Returns a list of all files associated with the job, including:
841
+ - Input audio file
842
+ - Stem separation results (vocals, instrumentals, etc.)
843
+ - Lyrics files (LRC, ASS, corrections JSON)
844
+ - Screen files (title, end screens)
845
+ - Video files (with/without vocals)
846
+ - Final output files (various formats)
847
+ - Package files (CDG, TXT zips)
848
+
849
+ Each file includes a signed URL that's valid for 2 hours.
850
+ Non-GCS entries (like YouTube URLs) are excluded.
851
+
852
+ Requires admin authentication.
853
+ """
854
+ job_manager = JobManager()
855
+ job = job_manager.get_job(job_id)
856
+
857
+ if not job:
858
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
859
+
860
+ # Extract all files with signed URLs
861
+ storage = StorageService()
862
+ file_urls = job.file_urls or {}
863
+
864
+ files = _extract_files_recursive(file_urls, storage)
865
+
866
+ return JobFilesResponse(
867
+ job_id=job.job_id,
868
+ artist=job.artist,
869
+ title=job.title,
870
+ files=files,
871
+ total_files=len(files),
872
+ )
873
+
874
+
875
+ @router.patch("/jobs/{job_id}", response_model=JobUpdateResponse)
876
+ async def update_job(
877
+ job_id: str,
878
+ request: Dict[str, Any],
879
+ auth_data: AuthResult = Depends(require_admin),
880
+ ):
881
+ """
882
+ Update editable fields of a job (admin only).
883
+
884
+ This endpoint allows admins to update certain job fields without
885
+ affecting the job's processing state. It's useful for:
886
+ - Correcting artist/title typos
887
+ - Changing user assignment
888
+ - Updating delivery settings (email, theme, etc.)
889
+
890
+ Editable fields:
891
+ - artist, title: Track metadata
892
+ - user_email: Job owner
893
+ - theme_id: Visual theme
894
+ - enable_cdg, enable_txt, enable_youtube_upload: Output options
895
+ - customer_email, customer_notes: Made-for-you order info
896
+ - brand_prefix: Brand code prefix
897
+ - non_interactive, prep_only: Workflow options
898
+ - discord_webhook_url: Notification URL
899
+ - youtube_description, youtube_description_template: YouTube settings
900
+
901
+ Non-editable fields (will return 400 error):
902
+ - job_id, status, progress: System-managed
903
+ - created_at, updated_at: Timestamps
904
+ - state_data, file_urls, timeline: Processing state
905
+ - worker_logs, worker_ids: Audit/tracking data
906
+
907
+ For status changes, use the reset endpoint instead.
908
+ """
909
+ admin_email = auth_data.user_email or "unknown"
910
+
911
+ # Check for non-editable fields in request
912
+ non_editable_fields = set(request.keys()) - EDITABLE_JOB_FIELDS
913
+ if non_editable_fields:
914
+ raise HTTPException(
915
+ status_code=400,
916
+ detail=f"The following fields are not editable: {', '.join(sorted(non_editable_fields))}. "
917
+ f"Editable fields are: {', '.join(sorted(EDITABLE_JOB_FIELDS))}"
918
+ )
919
+
920
+ # Filter to only include provided fields (non-None values)
921
+ updates = {k: v for k, v in request.items() if v is not None}
922
+
923
+ if not updates:
924
+ raise HTTPException(
925
+ status_code=400,
926
+ detail="No valid fields provided for update. "
927
+ f"Editable fields are: {', '.join(sorted(EDITABLE_JOB_FIELDS))}"
928
+ )
929
+
930
+ job_manager = JobManager()
931
+ job = job_manager.get_job(job_id)
932
+
933
+ if not job:
934
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
935
+
936
+ # Perform the update
937
+ success = job_manager.update_job(job_id, updates)
938
+
939
+ if not success:
940
+ raise HTTPException(
941
+ status_code=500,
942
+ detail="Failed to update job. Please try again."
943
+ )
944
+
945
+ # Log the admin action
946
+ logger.info(
947
+ f"Admin {admin_email} updated job {job_id}. "
948
+ f"Updated fields: {list(updates.keys())}"
949
+ )
950
+
951
+ return JobUpdateResponse(
952
+ status="success",
953
+ job_id=job_id,
954
+ updated_fields=list(updates.keys()),
955
+ message=f"Successfully updated {len(updates)} field(s)",
956
+ )
957
+
958
+
959
+ # =============================================================================
960
+ # Job Reset Endpoint
961
+ # =============================================================================
962
+
963
+ class JobResetRequest(BaseModel):
964
+ """Request model for resetting a job to a specific state."""
965
+ target_state: str
966
+
967
+
968
+ class JobResetResponse(BaseModel):
969
+ """Response from job reset endpoint."""
970
+ status: str
971
+ job_id: str
972
+ previous_status: str
973
+ new_status: str
974
+ message: str
975
+ cleared_data: List[str]
976
+
977
+
978
+ # States that are allowed as reset targets
979
+ ALLOWED_RESET_STATES = {
980
+ "pending",
981
+ "awaiting_audio_selection",
982
+ "awaiting_review",
983
+ "awaiting_instrumental_selection",
984
+ }
985
+
986
+ # State data keys to clear for each reset target
987
+ # Keys not in this mapping are preserved
988
+ STATE_DATA_CLEAR_KEYS = {
989
+ "pending": [
990
+ "audio_search_results",
991
+ "audio_search_count",
992
+ "remote_search_id",
993
+ "audio_selection",
994
+ "review_complete",
995
+ "corrected_lyrics",
996
+ "instrumental_selection",
997
+ "video_progress",
998
+ "render_progress",
999
+ "screens_progress",
1000
+ ],
1001
+ "awaiting_audio_selection": [
1002
+ "audio_selection",
1003
+ "review_complete",
1004
+ "corrected_lyrics",
1005
+ "instrumental_selection",
1006
+ "video_progress",
1007
+ "render_progress",
1008
+ "screens_progress",
1009
+ ],
1010
+ "awaiting_review": [
1011
+ "review_complete",
1012
+ "corrected_lyrics",
1013
+ "instrumental_selection",
1014
+ "video_progress",
1015
+ "render_progress",
1016
+ "screens_progress",
1017
+ ],
1018
+ "awaiting_instrumental_selection": [
1019
+ "instrumental_selection",
1020
+ "video_progress",
1021
+ "render_progress",
1022
+ "screens_progress",
1023
+ ],
1024
+ }
1025
+
1026
+
1027
+ @router.post("/jobs/{job_id}/reset", response_model=JobResetResponse)
1028
+ async def reset_job(
1029
+ job_id: str,
1030
+ request: JobResetRequest,
1031
+ auth_data: AuthResult = Depends(require_admin),
1032
+ user_service: UserService = Depends(get_user_service),
1033
+ ):
1034
+ """
1035
+ Reset a job to a specific state for re-processing (admin only).
1036
+
1037
+ This endpoint allows admins to reset a job back to specific workflow
1038
+ checkpoints to re-do parts of the processing. This is useful for:
1039
+ - Re-running audio search after flacfetch updates
1040
+ - Re-reviewing lyrics after corrections
1041
+ - Re-selecting instrumental after hearing the result
1042
+ - Restarting a failed job from the beginning
1043
+
1044
+ Allowed target states:
1045
+ - pending: Restart from the beginning (clears all processing data)
1046
+ - awaiting_audio_selection: Re-select audio source
1047
+ - awaiting_review: Re-review lyrics (preserves audio stems)
1048
+ - awaiting_instrumental_selection: Re-select instrumental (preserves review)
1049
+
1050
+ State data is cleared based on the target state to ensure a clean
1051
+ re-processing from that point forward.
1052
+ """
1053
+ admin_email = auth_data.user_email or "unknown"
1054
+ target_state = request.target_state.lower()
1055
+
1056
+ # Validate target state
1057
+ if target_state not in ALLOWED_RESET_STATES:
1058
+ raise HTTPException(
1059
+ status_code=400,
1060
+ detail=f"Invalid target state '{target_state}'. "
1061
+ f"Allowed states are: {', '.join(sorted(ALLOWED_RESET_STATES))}"
1062
+ )
1063
+
1064
+ job_manager = JobManager()
1065
+ job = job_manager.get_job(job_id)
1066
+
1067
+ if not job:
1068
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
1069
+
1070
+ previous_status = job.status
1071
+
1072
+ # Build update payload
1073
+ updates = {
1074
+ "status": target_state,
1075
+ "progress": 0,
1076
+ "message": f"Job reset to {target_state} by admin",
1077
+ "updated_at": datetime.utcnow().isoformat(),
1078
+ }
1079
+
1080
+ # Clear state data keys based on target state
1081
+ keys_to_clear = STATE_DATA_CLEAR_KEYS.get(target_state, [])
1082
+ cleared_keys = []
1083
+ current_state_data = job.state_data or {}
1084
+
1085
+ for key in keys_to_clear:
1086
+ if key in current_state_data:
1087
+ cleared_keys.append(key)
1088
+
1089
+ # Add timeline event
1090
+ timeline_event = {
1091
+ "status": target_state,
1092
+ "timestamp": datetime.utcnow().isoformat(),
1093
+ "message": f"Admin reset from {previous_status} to {target_state}",
1094
+ }
1095
+
1096
+ # Perform the update with state_data clearing
1097
+ # We need to set the cleared keys to DELETE_FIELD
1098
+ success = job_manager.update_job(job_id, updates)
1099
+
1100
+ if not success:
1101
+ raise HTTPException(
1102
+ status_code=500,
1103
+ detail="Failed to reset job. Please try again."
1104
+ )
1105
+
1106
+ # Clear the state data keys separately using direct Firestore update
1107
+ from google.cloud.firestore_v1 import DELETE_FIELD, ArrayUnion
1108
+
1109
+ job_ref = user_service.db.collection("jobs").document(job_id)
1110
+
1111
+ if cleared_keys:
1112
+ clear_updates = {}
1113
+ for key in cleared_keys:
1114
+ clear_updates[f"state_data.{key}"] = DELETE_FIELD
1115
+
1116
+ # Add timeline event
1117
+ clear_updates["timeline"] = ArrayUnion([timeline_event])
1118
+
1119
+ job_ref.update(clear_updates)
1120
+ else:
1121
+ # Just add timeline event
1122
+ job_ref.update({
1123
+ "timeline": ArrayUnion([timeline_event])
1124
+ })
1125
+
1126
+ # Log the admin action
1127
+ logger.info(
1128
+ f"Admin {admin_email} reset job {job_id} from {previous_status} to {target_state}. "
1129
+ f"Cleared state_data keys: {cleared_keys}"
1130
+ )
1131
+
1132
+ return JobResetResponse(
1133
+ status="success",
1134
+ job_id=job_id,
1135
+ previous_status=previous_status,
1136
+ new_status=target_state,
1137
+ message=f"Job reset from {previous_status} to {target_state}",
1138
+ cleared_data=cleared_keys,
1139
+ )
1140
+
1141
+
705
1142
  @router.get("/jobs/{job_id}/completion-message", response_model=CompletionMessageResponse)
706
1143
  async def get_job_completion_message(
707
1144
  job_id: str,
@@ -833,3 +1270,77 @@ async def send_job_completion_email(
833
1270
  status_code=500,
834
1271
  detail="Failed to send email. Check email service configuration."
835
1272
  )
1273
+
1274
+
1275
+ # =============================================================================
1276
+ # User Impersonation
1277
+ # =============================================================================
1278
+
1279
+ class ImpersonateUserResponse(BaseModel):
1280
+ """Response from impersonate user endpoint."""
1281
+ session_token: str
1282
+ user_email: str
1283
+ message: str
1284
+
1285
+
1286
+ @router.post("/users/{email}/impersonate", response_model=ImpersonateUserResponse)
1287
+ async def impersonate_user(
1288
+ email: str,
1289
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1290
+ user_service: UserService = Depends(get_user_service),
1291
+ ):
1292
+ """
1293
+ Create a session token to impersonate a user (admin only).
1294
+
1295
+ This allows admins to view the application exactly as a specific user would see it.
1296
+ The admin's original session remains valid and can be restored client-side.
1297
+
1298
+ Security:
1299
+ - Only admins can impersonate
1300
+ - Creates a real session (auditable in Firestore)
1301
+ - Impersonation is logged for security audit
1302
+
1303
+ Args:
1304
+ email: Email of the user to impersonate
1305
+
1306
+ Returns:
1307
+ session_token: A valid session token for the target user
1308
+ user_email: The impersonated user's email
1309
+ message: Success message
1310
+ """
1311
+ admin_email = auth_data[0]
1312
+ target_email = email.lower()
1313
+
1314
+ # Cannot impersonate yourself
1315
+ if target_email == admin_email.lower():
1316
+ raise HTTPException(
1317
+ status_code=400,
1318
+ detail="Cannot impersonate yourself"
1319
+ )
1320
+
1321
+ # Verify target user exists
1322
+ target_user = user_service.get_user(target_email)
1323
+ if not target_user:
1324
+ raise HTTPException(
1325
+ status_code=404,
1326
+ detail=f"User {target_email} not found"
1327
+ )
1328
+
1329
+ # Create a real session for the target user
1330
+ session = user_service.create_session(
1331
+ user_email=target_email,
1332
+ ip_address=None, # Not tracking IP for impersonation
1333
+ user_agent=f"Impersonation by {admin_email}",
1334
+ )
1335
+
1336
+ # Log impersonation for audit trail
1337
+ logger.info(
1338
+ f"IMPERSONATION: Admin {admin_email} started impersonating user {target_email}. "
1339
+ f"Session token prefix: {session.token[:12]}..."
1340
+ )
1341
+
1342
+ return ImpersonateUserResponse(
1343
+ session_token=session.token,
1344
+ user_email=target_email,
1345
+ message=f"Now impersonating {target_email}",
1346
+ )
@@ -15,7 +15,7 @@ import asyncio
15
15
  import logging
16
16
  import os
17
17
  import tempfile
18
- from typing import Optional, List, Dict, Any, Tuple
18
+ from typing import Optional, List, Dict, Any
19
19
 
20
20
  from fastapi import APIRouter, HTTPException, BackgroundTasks, Request, Depends
21
21
  from pydantic import BaseModel, Field, validator
@@ -34,10 +34,12 @@ from backend.services.audio_search_service import (
34
34
  DownloadError,
35
35
  )
36
36
  from backend.services.theme_service import get_theme_service
37
+ from backend.services.job_defaults_service import resolve_cdg_txt_defaults
37
38
  from backend.config import get_settings
38
39
  from backend.version import VERSION
39
40
  from backend.api.dependencies import require_auth
40
41
  from backend.services.auth_service import UserType, AuthResult
42
+ from backend.middleware.tenant import get_tenant_config_from_request
41
43
  from pathlib import Path
42
44
 
43
45
  logger = logging.getLogger(__name__)
@@ -200,35 +202,6 @@ class AudioSelectResponse(BaseModel):
200
202
  selected_provider: str
201
203
 
202
204
 
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
205
  def extract_request_metadata(request: Request, created_from: str = "audio_search") -> Dict[str, Any]:
233
206
  """Extract metadata from request for job tracking."""
234
207
  headers = dict(request.headers)
@@ -479,17 +452,25 @@ async def search_audio(
479
452
  ):
480
453
  """
481
454
  Search for audio by artist and title, creating a new job.
482
-
455
+
483
456
  This endpoint:
484
457
  1. Creates a job in PENDING state
485
458
  2. Searches for audio using flacfetch
486
459
  3. Either returns search results for user selection, or
487
460
  4. If auto_download=True, automatically selects best and starts processing
488
-
461
+
489
462
  Use cases:
490
463
  - Interactive mode (default): Returns results, user calls /select endpoint
491
464
  - Auto mode (auto_download=True): Automatically selects and downloads best
492
465
  """
466
+ # Check tenant feature flag
467
+ tenant_config = get_tenant_config_from_request(request)
468
+ if tenant_config and not tenant_config.features.audio_search:
469
+ raise HTTPException(
470
+ status_code=403,
471
+ detail="Audio search is not available for this portal"
472
+ )
473
+
493
474
  try:
494
475
  # Apply default distribution settings
495
476
  settings = get_settings()
@@ -545,7 +526,7 @@ async def search_audio(
545
526
  logger.info(f"Applying default theme: {effective_theme_id}")
546
527
 
547
528
  # Resolve CDG/TXT defaults based on theme
548
- resolved_cdg, resolved_txt = _resolve_cdg_txt_defaults(
529
+ resolved_cdg, resolved_txt = resolve_cdg_txt_defaults(
549
530
  effective_theme_id, body.enable_cdg, body.enable_txt
550
531
  )
551
532
 
@@ -585,8 +566,10 @@ async def search_audio(
585
566
  auto_download=body.auto_download,
586
567
  request_metadata=request_metadata,
587
568
  non_interactive=body.non_interactive,
569
+ # Tenant scoping
570
+ tenant_id=tenant_config.id if tenant_config else None,
588
571
  )
589
- job = job_manager.create_job(job_create)
572
+ job = job_manager.create_job(job_create, is_admin=auth_result.is_admin)
590
573
  job_id = job.job_id
591
574
 
592
575
  logger.info(f"Created job {job_id} for audio search: {body.artist} - {body.title}")