diff --git a/server/src/main.rs b/server/src/main.rs index e4f5cf4..31a7e1c 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -566,6 +566,31 @@ async fn list_videos(State(s): State, user: AuthUser, AxPath(id): AxPa Ok(Json(rows)) } +/// Ensure an annotations export doc carries a `video` identity block. Some ingested / +/// pushed docs omit it, which makes a bulk export impossible to tie back to a file. +/// Injects `{ file_name, rel_path }` when absent; leaves an existing block untouched. +fn ensure_video_block(mut doc: serde_json::Value, file_name: &str, rel_path: &str) -> serde_json::Value { + if let Some(obj) = doc.as_object_mut() { + let has_video = obj.get("video").map(|v| !v.is_null()).unwrap_or(false); + if !has_video { + obj.insert("video".into(), json!({ "file_name": file_name, "rel_path": rel_path })); + } + } + doc +} + +/// One entry in the bulk project export. Serialized as a struct (NOT the `json!` macro) +/// so the video identity fields come FIRST — serde_json orders `json!` object keys +/// alphabetically, which otherwise buries `file_name` far below the huge `annotations` +/// array and makes it look missing. +#[derive(serde::Serialize)] +struct ExportedVideo { + file_name: String, + rel_path: String, + video_id: i32, + annotations: serde_json::Value, +} + /// `GET /api/videos/:id/export` — download one video's annotations as the canonical /// export JSON (`videos.raw_json`, the doc pushed by the worker / ingested from NAS). /// Auth required (via `?token=`); served as a file attachment. @@ -573,14 +598,19 @@ async fn export_video(State(s): State, Query(q): Query, Ax if auth::validate_token(&s, q.token.as_deref().unwrap_or("")).await.is_none() { return Err((StatusCode::UNAUTHORIZED, "missing or invalid token".into())); } - let row: Option<(String, Option)> = - sqlx::query_as("SELECT file_name, raw_json FROM videos WHERE id=$1") + let row: Option<(String, String, Option)> = + sqlx::query_as("SELECT file_name, rel_path, raw_json FROM videos WHERE id=$1") .bind(id) .fetch_optional(&s.db) .await .map_err(err)?; - let (file_name, raw_json) = row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?; - let doc = raw_json.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })); + let (file_name, rel_path, raw_json) = row.ok_or((StatusCode::NOT_FOUND, "video not found".to_string()))?; + // Embed the video identity inside the doc so the file's name/path travel with it. + let doc = ensure_video_block( + raw_json.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })), + &file_name, + &rel_path, + ); let body = serde_json::to_vec_pretty(&doc).map_err(err)?; let stem = file_name.rsplit_once('.').map(|(a, _)| a).unwrap_or(&file_name); let fname = format!("{stem}_annotations.json"); @@ -604,9 +634,18 @@ async fn export_project(State(s): State, Query(q): Query, .fetch_all(&s.db) .await .map_err(err)?; - let videos: Vec = rows + let videos: Vec = rows .into_iter() - .map(|(vid, fname, rel, raw)| json!({ "video_id": vid, "file_name": fname, "rel_path": rel, "annotations": raw })) + .map(|(vid, fname, rel, raw)| { + // file_name first (struct order) AND embedded in the annotations doc, so the + // name is both immediately visible per entry and self-contained with the data. + let annotations = ensure_video_block( + raw.unwrap_or_else(|| json!({ "fixed_annotations": [], "range_annotations": [] })), + &fname, + &rel, + ); + ExportedVideo { file_name: fname, rel_path: rel, video_id: vid, annotations } + }) .collect(); let doc = json!({ "project_id": id, "video_count": videos.len(), "videos": videos }); let body = serde_json::to_vec_pretty(&doc).map_err(err)?;