feat(l3): optimistic locking for VTT edits (ETag / 409 Conflict)
Backend:
- VttContentResponse gets etag field (SHA1 of captions+AD content)
- VttUpdateRequest gets if_match field (optional)
- GET /jobs/{id}/vtt: computes and returns etag
- PATCH /jobs/{id}/vtt: if if_match present, fetches current content, recomputes
hash, returns 409 Conflict if mismatch
Frontend:
- VttContentResponse type + VttUpdateRequest type updated
- QCDetail stores vttEtag from GET response
- All updateVttMutation calls pass if_match: vttEtag
- 409 responses show specific "Conflict: another user has modified" message
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bb751033c0
commit
dc1cfd01dc
4 changed files with 56 additions and 9 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import hashlib
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
|
@ -1259,6 +1260,10 @@ async def get_job_vtt_content(
|
|||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch retimed captions VTT: {e}")
|
||||
|
||||
# Compute ETag for optimistic locking — SHA1 of combined VTT content
|
||||
combined = (response.captions_vtt or "") + "|" + (response.audio_description_vtt or "")
|
||||
response.etag = hashlib.sha1(combined.encode()).hexdigest()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
|
@ -1301,6 +1306,31 @@ async def update_job_vtt_content(
|
|||
outputs = job_doc.get("outputs", {})
|
||||
lang_output = outputs.get(target_language, {})
|
||||
|
||||
# Optimistic locking — check If-Match before any mutation
|
||||
if request.if_match:
|
||||
current_caps = ""
|
||||
current_ad = ""
|
||||
if "captions_vtt_gcs" in lang_output:
|
||||
try:
|
||||
blob_path = lang_output["captions_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
|
||||
blob = gcs_service.bucket.blob(blob_path)
|
||||
current_caps = blob.download_as_text()
|
||||
except Exception:
|
||||
pass
|
||||
if "ad_vtt_gcs" in lang_output:
|
||||
try:
|
||||
blob_path = lang_output["ad_vtt_gcs"].replace(f"gs://{settings.gcs_bucket}/", "")
|
||||
blob = gcs_service.bucket.blob(blob_path)
|
||||
current_ad = blob.download_as_text()
|
||||
except Exception:
|
||||
pass
|
||||
current_etag = hashlib.sha1((current_caps + "|" + current_ad).encode()).hexdigest()
|
||||
if current_etag != request.if_match:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Conflict: another user has modified this VTT since you last loaded it. Reload to see their changes.",
|
||||
)
|
||||
|
||||
# Validate and update captions VTT
|
||||
if request.captions_vtt: # treat empty string same as None — nothing to update
|
||||
# Validate VTT format
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ class VttUpdateRequest(BaseModel):
|
|||
captions_vtt: Optional[str] = None
|
||||
audio_description_vtt: Optional[str] = None
|
||||
language: Optional[str] = None # If None, defaults to source language
|
||||
if_match: Optional[str] = None # Optimistic locking — SHA1 of expected current content
|
||||
|
||||
|
||||
class VttTimingAdjustRequest(BaseModel):
|
||||
|
|
@ -92,6 +93,7 @@ class VttContentResponse(BaseModel):
|
|||
captions_vtt: Optional[str] = None
|
||||
audio_description_vtt: Optional[str] = None
|
||||
retimed_captions_vtt: Optional[str] = None # Re-timed captions for accessible videos
|
||||
etag: Optional[str] = None # SHA1 hash for optimistic locking (If-Match on PATCH)
|
||||
|
||||
|
||||
class AssetValidationResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -376,11 +376,14 @@ export function QCDetail() {
|
|||
(downloads.downloads[selectedLanguage] as { accessible_video_mp4?: string }).accessible_video_mp4
|
||||
) || '';
|
||||
|
||||
const [vttEtag, setVttEtag] = useState<string | undefined>(undefined);
|
||||
|
||||
// Load VTT content when fetched
|
||||
useEffect(() => {
|
||||
if (vttContent) {
|
||||
setCaptionsVtt(vttContent.captions_vtt || '');
|
||||
setAdVtt(vttContent.audio_description_vtt || '');
|
||||
setVttEtag(vttContent.etag);
|
||||
}
|
||||
}, [vttContent]);
|
||||
|
||||
|
|
@ -468,22 +471,25 @@ export function QCDetail() {
|
|||
if (type === 'captions') {
|
||||
await updateVttMutation.mutateAsync({
|
||||
id,
|
||||
data: { captions_vtt: content, audio_description_vtt: adVtt || undefined, language: selectedLanguage },
|
||||
data: { captions_vtt: content, audio_description_vtt: adVtt || undefined, language: selectedLanguage, if_match: vttEtag },
|
||||
});
|
||||
setCaptionsVtt(content);
|
||||
toast.toastOnly.success('Captions VTT replaced from file');
|
||||
} else {
|
||||
await updateVttMutation.mutateAsync({
|
||||
id,
|
||||
data: { captions_vtt: captionsVtt || undefined, audio_description_vtt: content, language: selectedLanguage },
|
||||
data: { captions_vtt: captionsVtt || undefined, audio_description_vtt: content, language: selectedLanguage, if_match: vttEtag },
|
||||
});
|
||||
setAdVtt(content);
|
||||
setAdVttUploaded(true);
|
||||
toast.toastOnly.success('Audio Description VTT replaced from file');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to upload VTT file:', error);
|
||||
toast.toastOnly.error('Failed to save VTT file. Please try again.');
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 409) {
|
||||
toast.toastOnly.error('Conflict: another user has modified this VTT. Reload to see their changes.');
|
||||
} else {
|
||||
toast.toastOnly.error('Failed to save VTT file. Please try again.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -497,11 +503,16 @@ export function QCDetail() {
|
|||
captions_vtt: captionsVtt || undefined,
|
||||
audio_description_vtt: adVtt || undefined,
|
||||
language: selectedLanguage,
|
||||
if_match: vttEtag,
|
||||
}
|
||||
});
|
||||
toast.toastOnly.success('VTT saved');
|
||||
} catch {
|
||||
toast.toastOnly.error('Failed to save VTT');
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 409) {
|
||||
toast.toastOnly.error('Conflict: another user has modified this VTT. Reload to see their changes.');
|
||||
} else {
|
||||
toast.toastOnly.error('Failed to save VTT');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -515,7 +526,8 @@ export function QCDetail() {
|
|||
data: {
|
||||
captions_vtt: vttContent,
|
||||
audio_description_vtt: adVtt || undefined,
|
||||
language: selectedLanguage
|
||||
language: selectedLanguage,
|
||||
if_match: vttEtag,
|
||||
}
|
||||
});
|
||||
toast.toastOnly.success(`Caption cue ${cueIndex + 1} saved`);
|
||||
|
|
@ -532,7 +544,8 @@ export function QCDetail() {
|
|||
data: {
|
||||
captions_vtt: captionsVtt || undefined,
|
||||
audio_description_vtt: vttContent,
|
||||
language: selectedLanguage
|
||||
language: selectedLanguage,
|
||||
if_match: vttEtag,
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -358,12 +358,14 @@ export interface VttContentResponse {
|
|||
captions_vtt?: string;
|
||||
audio_description_vtt?: string;
|
||||
retimed_captions_vtt?: string; // Re-timed captions for accessible videos
|
||||
etag?: string; // SHA1 hash for optimistic locking
|
||||
}
|
||||
|
||||
export interface VttUpdateRequest {
|
||||
captions_vtt?: string;
|
||||
audio_description_vtt?: string;
|
||||
language?: string; // If not specified, defaults to source language
|
||||
if_match?: string; // Optimistic locking — etag from last GET
|
||||
}
|
||||
|
||||
export interface AssetValidationResponse {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue