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.
- backend/api/routes/admin.py +512 -1
- backend/api/routes/audio_search.py +17 -34
- backend/api/routes/file_upload.py +60 -84
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +11 -3
- backend/api/routes/rate_limits.py +428 -0
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +229 -247
- backend/config.py +16 -0
- backend/exceptions.py +66 -0
- backend/main.py +30 -1
- backend/middleware/__init__.py +7 -1
- backend/middleware/tenant.py +192 -0
- backend/models/job.py +19 -3
- backend/models/tenant.py +208 -0
- backend/models/user.py +18 -0
- backend/services/email_service.py +253 -6
- backend/services/email_validation_service.py +646 -0
- backend/services/firestore_service.py +27 -0
- backend/services/job_defaults_service.py +113 -0
- backend/services/job_manager.py +73 -3
- backend/services/rate_limit_service.py +641 -0
- backend/services/stripe_service.py +61 -35
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/conftest.py +7 -1
- backend/tests/emulator/test_made_for_you_integration.py +167 -0
- backend/tests/test_admin_job_files.py +337 -0
- backend/tests/test_admin_job_reset.py +384 -0
- backend/tests/test_admin_job_update.py +326 -0
- backend/tests/test_audio_search.py +12 -8
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_email_validation_service.py +298 -0
- backend/tests/test_file_upload.py +8 -6
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +146 -1
- backend/tests/test_made_for_you.py +2088 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_rate_limit_service.py +396 -0
- backend/tests/test_rate_limits_api.py +392 -0
- backend/tests/test_tenant_api.py +350 -0
- backend/tests/test_tenant_middleware.py +345 -0
- backend/tests/test_tenant_models.py +406 -0
- backend/tests/test_tenant_service.py +418 -0
- backend/workers/video_worker.py +8 -3
- backend/workers/video_worker_orchestrator.py +26 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
backend/api/routes/admin.py
CHANGED
|
@@ -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
|
|
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 =
|
|
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}")
|