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:
Vadym Samoilenko 2026-04-29 19:01:57 +01:00
parent bb751033c0
commit dc1cfd01dc
4 changed files with 56 additions and 9 deletions

View file

@ -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

View file

@ -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):

View file

@ -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,
}
});

View file

@ -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 {