Moves every remaining hand-built error response onto ApiError: ~300
sites across faces, image, video, files, memories, versions,
duplicates, favorites, libraries, auth, clip/unified search, prefs,
reels, tts, insight chat, persona chat, knowledge and personas.
- 500s: the body was the error's text (anyhow / diesel / {:?}); now
ApiError::internal logs it and sends a generic message.
- Each 4xx/502/503 has a stable code and a message written for users;
statuses are unchanged. Shared helpers per module (faces_unavailable,
person_not_found, unknown_library, insight_not_found, ...).
- Pre-format top-level fields kept where clients read them: upload 409
duplicate_of / content_hash / library_name.
- PersonaChatError converts into ApiError in one place.
- Multipart upload errors use the format (error::multipart_config).
- Guard test: no non-test code builds HttpResponse::<error>() by hand.
Closes #202
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1170 lines
44 KiB
Rust
1170 lines
44 KiB
Rust
//! Video-related endpoints: HLS playlist generation, segment streaming,
|
|
//! and the short-clip preview pipeline.
|
|
|
|
use crate::error::ApiError;
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
use std::sync::Mutex;
|
|
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
|
|
|
use actix_files::NamedFile;
|
|
use actix_web::{
|
|
HttpMessage, HttpRequest, HttpResponse, Responder,
|
|
body::MessageBody,
|
|
dev::{ServiceRequest, ServiceResponse},
|
|
get,
|
|
middleware::Next,
|
|
post,
|
|
web::{self, Data},
|
|
};
|
|
use log::{debug, error, info, warn};
|
|
use opentelemetry::KeyValue;
|
|
use opentelemetry::trace::Status;
|
|
use serde::Serialize;
|
|
|
|
use crate::content_hash;
|
|
use crate::data::{
|
|
Claims, PreviewClipRequest, PreviewStatusItem, PreviewStatusRequest, PreviewStatusResponse,
|
|
ThumbnailRequest,
|
|
};
|
|
use crate::database::{ExifDao, PreviewDao};
|
|
use crate::file_types;
|
|
use crate::files::is_valid_full_path;
|
|
use crate::libraries;
|
|
use crate::state::AppState;
|
|
use crate::video::actors::{
|
|
GeneratePreviewClipMessage, QueueVideosMessage, VideoToQueue, probe_video_stream_meta,
|
|
};
|
|
use crate::video::hls_paths;
|
|
use crate::video::original_url;
|
|
|
|
/// Response body for `POST /video/generate`. Clients consume
|
|
/// `playlist_url` (hash-keyed, stable across libraries and renames)
|
|
/// and poll for readiness via the URL itself.
|
|
#[derive(Serialize, Debug)]
|
|
struct GenerateVideoResponse {
|
|
/// Hash-keyed URL to the HLS playlist. Resolves to
|
|
/// `$VIDEO_PATH/<shard>/<hash>/playlist.m3u8` server-side. Relative
|
|
/// segment refs inside the playlist resolve correctly because the
|
|
/// browser appends to this URL's path.
|
|
playlist_url: String,
|
|
/// blake3 content hash of the source video. Stable per byte content,
|
|
/// so duplicate uploads / archive ingests share one set of HLS
|
|
/// output.
|
|
content_hash: String,
|
|
/// `true` iff the playlist file is already on disk. `false` means a
|
|
/// transcode was queued; clients should retry the URL after a short
|
|
/// delay (or rely on HLS.js's own retry policy).
|
|
ready: bool,
|
|
/// Source-video frame rate in Hz, probed via ffprobe. `None` when the
|
|
/// probe failed or ffprobe couldn't parse either rate field — clients
|
|
/// fall back to their own default (typically 30) for frame stepping.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
frame_rate: Option<f32>,
|
|
}
|
|
|
|
#[post("/video/generate")]
|
|
#[tracing::instrument(name = "generate_video", skip_all)]
|
|
pub async fn generate_video(
|
|
_claims: Claims,
|
|
app_state: Data<AppState>,
|
|
exif_dao: Data<std::sync::Mutex<Box<dyn ExifDao>>>,
|
|
body: web::Json<ThumbnailRequest>,
|
|
) -> impl Responder {
|
|
let span = tracing::Span::current();
|
|
|
|
let resolved = resolve_request_path(&app_state, &body.path, body.library.as_deref());
|
|
let Some((resolved_library_id, resolved_root, full_path)) = resolved else {
|
|
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
|
return invalid_path().into_response();
|
|
};
|
|
|
|
// Build the rel_path used to look up the row. Forward-slash normalized
|
|
// so the lookup matches DB rows on Windows — see `rel_path_for_lookup`.
|
|
let full_path_str = full_path.to_string_lossy().to_string();
|
|
let rel_path = rel_path_for_lookup(&full_path_str, &resolved_root);
|
|
|
|
// DB lookup first. Cheap and avoids re-reading the file off disk for
|
|
// already-ingested videos.
|
|
let hash_from_db: Option<String> = {
|
|
let mut dao = exif_dao.lock().expect("Unable to lock ExifDao");
|
|
match dao.get_exif_batch(Some(resolved_library_id), std::slice::from_ref(&rel_path)) {
|
|
Ok(rows) => rows.into_iter().next().and_then(|r| r.content_hash),
|
|
Err(e) => {
|
|
warn!(
|
|
"exif_dao.get_exif_batch failed for {} (lib {}): {:?}",
|
|
rel_path, resolved_library_id, e
|
|
);
|
|
None
|
|
}
|
|
}
|
|
};
|
|
|
|
// Best-effort fallback: compute on-the-fly when the DB row hasn't
|
|
// been written or is mid-backfill. Read-only — no library mutation.
|
|
let content_hash_str = match hash_from_db {
|
|
Some(h) => h,
|
|
None => match content_hash::compute(&full_path) {
|
|
Ok(id) => id.content_hash,
|
|
Err(e) => {
|
|
error!(
|
|
"Failed to compute content_hash for {}: {}",
|
|
full_path.display(),
|
|
e
|
|
);
|
|
span.set_status(Status::error(format!("hash compute failed: {}", e)));
|
|
return ApiError::internal("internal error").into_response();
|
|
}
|
|
},
|
|
};
|
|
|
|
let video_dir = std::path::Path::new(&app_state.video_path);
|
|
let playlist_path = hls_paths::playlist_for_hash(video_dir, &content_hash_str);
|
|
let sentinel_path = hls_paths::sentinel_for_hash(video_dir, &content_hash_str);
|
|
let ready = playlist_path.exists();
|
|
|
|
if !ready && !sentinel_path.exists() {
|
|
// Kick off generation via the existing actor pipeline. Fire-and-
|
|
// forget — the playlist appears at `playlist_path` once ffmpeg
|
|
// + rename complete. The client polls the URL.
|
|
info!(
|
|
"/video/generate: queueing playlist for {} (hash={})",
|
|
full_path.display(),
|
|
&content_hash_str[..content_hash_str.len().min(16)]
|
|
);
|
|
app_state.playlist_manager.do_send(QueueVideosMessage {
|
|
videos: vec![VideoToQueue {
|
|
video_path: full_path.clone(),
|
|
content_hash: content_hash_str.clone(),
|
|
}],
|
|
});
|
|
span.add_event(
|
|
"playlist_queued",
|
|
vec![KeyValue::new("content_hash", content_hash_str.clone())],
|
|
);
|
|
} else if ready {
|
|
span.add_event(
|
|
"playlist_already_present",
|
|
vec![KeyValue::new("content_hash", content_hash_str.clone())],
|
|
);
|
|
} else {
|
|
// Sentinel present — past transcode attempt failed. Return the
|
|
// URL anyway (it'll 404 / 5xx at fetch time) so the client gets
|
|
// a deterministic answer. Operator must delete the sentinel to
|
|
// force a retry.
|
|
warn!(
|
|
"/video/generate: unsupported sentinel present for {} (hash={}); not re-queueing",
|
|
full_path.display(),
|
|
&content_hash_str[..content_hash_str.len().min(16)]
|
|
);
|
|
}
|
|
|
|
let playlist_url = format!(
|
|
"/video/hls/{}/{}",
|
|
content_hash_str,
|
|
hls_paths::PLAYLIST_FILENAME
|
|
);
|
|
|
|
// Probe the source for frame rate so the mobile scrubber can step at
|
|
// the right interval. Cheap (~tens of ms) and only runs once per video
|
|
// open. Probe failures degrade silently — clients have a fallback.
|
|
let frame_rate = probe_video_stream_meta(&full_path.to_string_lossy())
|
|
.await
|
|
.frame_rate;
|
|
|
|
span.set_status(Status::Ok);
|
|
HttpResponse::Ok().json(GenerateVideoResponse {
|
|
playlist_url,
|
|
content_hash: content_hash_str,
|
|
ready,
|
|
frame_rate,
|
|
})
|
|
}
|
|
|
|
/// Serve HLS playlist or segment files under the hash-keyed layout
|
|
/// `$VIDEO_PATH/<shard>/<hash>/<file>`. The matched `{file}` must be
|
|
/// either `playlist.m3u8` or a `segment_NNN.ts` style segment; any other
|
|
/// shape is 400'd to defend against operators stashing other content in
|
|
/// the hash dir.
|
|
#[get("/video/hls/{hash}/{file}")]
|
|
#[tracing::instrument(name = "stream_hls_file", skip_all)]
|
|
pub async fn stream_hls_file(
|
|
request: HttpRequest,
|
|
_: Claims,
|
|
path: web::Path<(String, String)>,
|
|
app_state: Data<AppState>,
|
|
) -> impl Responder {
|
|
let span = tracing::Span::current();
|
|
|
|
let (hash, file) = path.into_inner();
|
|
if !is_valid_hash(&hash) {
|
|
span.set_status(Status::error("invalid hash"));
|
|
return ApiError::bad_request("invalid_path", "Invalid video stream address")
|
|
.into_response();
|
|
}
|
|
if !is_allowed_hls_filename(&file) {
|
|
span.set_status(Status::error("invalid file"));
|
|
return ApiError::bad_request("invalid_path", "Invalid video stream address")
|
|
.into_response();
|
|
}
|
|
|
|
let shard = &hash[..2];
|
|
let file_path = PathBuf::from(&app_state.video_path)
|
|
.join(shard)
|
|
.join(&hash)
|
|
.join(&file);
|
|
|
|
// Path-traversal guard: canonicalize both sides and require the file
|
|
// to live under `app_state.video_path`. `is_valid_hash` /
|
|
// `is_allowed_hls_filename` already block dangerous strings, but
|
|
// belt-and-suspenders here is cheap.
|
|
let canonical_base = match std::fs::canonicalize(&app_state.video_path) {
|
|
Ok(p) => p,
|
|
Err(e) => {
|
|
error!("Failed to canonicalize VIDEO_PATH: {:?}", e);
|
|
span.set_status(Status::error("VIDEO_PATH not canonicalisable"));
|
|
return ApiError::internal("internal error").into_response();
|
|
}
|
|
};
|
|
let canonical_file = match std::fs::canonicalize(&file_path) {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
debug!("HLS file not found: {}", file_path.display());
|
|
span.set_status(Status::error("not found"));
|
|
return stream_not_found().into_response();
|
|
}
|
|
};
|
|
if !canonical_file.starts_with(&canonical_base) {
|
|
warn!(
|
|
"Path traversal attempt: {} resolved outside VIDEO_PATH",
|
|
file_path.display()
|
|
);
|
|
span.set_status(Status::error("traversal"));
|
|
return ApiError::forbidden("invalid_path", "Invalid video stream address").into_response();
|
|
}
|
|
|
|
match NamedFile::open(&canonical_file) {
|
|
Ok(f) => {
|
|
span.set_status(Status::Ok);
|
|
f.into_response(&request)
|
|
}
|
|
Err(_) => {
|
|
span.set_status(Status::error("not found"));
|
|
stream_not_found().into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 64 lowercase-or-upper hex chars. Strict so we don't accept arbitrary
|
|
/// strings that might canonicalize into trouble.
|
|
fn is_valid_hash(s: &str) -> bool {
|
|
s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
|
}
|
|
|
|
/// Compute the forward-slash `rel_path` used to look up a video's
|
|
/// `image_exif` row, from its absolute path string and the library root.
|
|
///
|
|
/// Normalizing to forward slashes is essential on Windows: `file_scan`
|
|
/// stores rel_paths forward-slash regardless of OS, but a raw strip of a
|
|
/// backslash Windows path (`Z:\...\pic\album\clip.mp4`) yields
|
|
/// `album\clip.mp4`. `get_exif_batch` does an exact match with no
|
|
/// normalization, so the backslash form misses and the handler falls back
|
|
/// to re-hashing the entire file on every request.
|
|
/// Response body for `POST /video/original`.
|
|
#[derive(Serialize, Debug)]
|
|
struct OriginalVideoUrlResponse {
|
|
/// `/video/original/stream?token=…` — works without an Authorization
|
|
/// header, so it can go straight into a native player.
|
|
url: String,
|
|
/// Unix seconds. Clients should mint a fresh URL rather than reuse one
|
|
/// past this.
|
|
expires_at: i64,
|
|
}
|
|
|
|
/// Mint a short-lived URL for streaming the original bytes of a video.
|
|
/// HLS (`/video/generate`) stays the default playback path; this is the
|
|
/// opt-in full-quality path for clients on a fast network.
|
|
#[post("/video/original")]
|
|
#[tracing::instrument(name = "original_video_url", skip_all)]
|
|
pub async fn original_video_url(
|
|
claims: Claims,
|
|
app_state: Data<AppState>,
|
|
body: web::Json<ThumbnailRequest>,
|
|
) -> impl Responder {
|
|
let span = tracing::Span::current();
|
|
|
|
let Some((library_id, root, full_path)) =
|
|
resolve_request_path(&app_state, &body.path, body.library.as_deref())
|
|
else {
|
|
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
|
return invalid_path().into_response();
|
|
};
|
|
if !file_types::is_video_file(&full_path) {
|
|
span.set_status(Status::error("not a video"));
|
|
return ApiError::bad_request("not_a_video", "That file isn't a video").into_response();
|
|
}
|
|
|
|
let rel_path = rel_path_for_lookup(&full_path.to_string_lossy(), &root);
|
|
let (token, expires_at) = original_url::sign(&claims.sub, library_id, &rel_path);
|
|
|
|
span.set_status(Status::Ok);
|
|
HttpResponse::Ok().json(OriginalVideoUrlResponse {
|
|
url: format!("{}?token={}", ORIGINAL_STREAM_PATH, token),
|
|
expires_at,
|
|
})
|
|
}
|
|
|
|
const ORIGINAL_STREAM_PATH: &str = "/video/original/stream";
|
|
|
|
/// A stream token lifted out of the request URI by
|
|
/// [`redact_original_stream_token`].
|
|
struct OriginalStreamToken(String);
|
|
|
|
/// Moves `token=` off `/video/original/stream` requests into request
|
|
/// extensions and rewrites the URI to `token=redacted`, so neither the
|
|
/// access log nor the tracing span's `http.target` (exported to OTel)
|
|
/// records a credential for a possibly sensitive file. Must be the
|
|
/// outermost `.wrap()` so it runs before both loggers; without it the
|
|
/// stream handler finds no token and 401s (fails closed).
|
|
pub async fn redact_original_stream_token(
|
|
mut req: ServiceRequest,
|
|
next: Next<impl MessageBody>,
|
|
) -> Result<ServiceResponse<impl MessageBody>, actix_web::Error> {
|
|
if req.path() == ORIGINAL_STREAM_PATH
|
|
&& let Some((token, redacted_query)) = take_token_param(req.query_string())
|
|
&& let Ok(uri) = format!("{}?{}", ORIGINAL_STREAM_PATH, redacted_query).parse()
|
|
{
|
|
req.head_mut().uri = uri;
|
|
req.extensions_mut().insert(OriginalStreamToken(token));
|
|
}
|
|
next.call(req).await
|
|
}
|
|
|
|
/// Split the first `token=` value out of a query string, returning it and
|
|
/// the query with that value replaced by `redacted`.
|
|
fn take_token_param(query: &str) -> Option<(String, String)> {
|
|
let mut token = None;
|
|
let redacted: Vec<&str> = query
|
|
.split('&')
|
|
.map(|pair| match pair.strip_prefix("token=") {
|
|
Some(value) if token.is_none() => {
|
|
token = Some(value.to_string());
|
|
"token=redacted"
|
|
}
|
|
_ => pair,
|
|
})
|
|
.collect();
|
|
token.map(|t| (t, redacted.join("&")))
|
|
}
|
|
|
|
/// Stream an original video named by a token from `POST /video/original`.
|
|
/// Authenticated by the token alone (no Claims), which
|
|
/// [`redact_original_stream_token`] has already moved out of the URI.
|
|
/// `NamedFile` honours Range, so players can seek.
|
|
#[get("/video/original/stream")]
|
|
#[tracing::instrument(name = "stream_original_video", skip_all)]
|
|
pub async fn stream_original_video(
|
|
request: HttpRequest,
|
|
app_state: Data<AppState>,
|
|
) -> impl Responder {
|
|
let span = tracing::Span::current();
|
|
|
|
let claims = request
|
|
.extensions()
|
|
.get::<OriginalStreamToken>()
|
|
.and_then(|t| original_url::verify(&t.0));
|
|
let Some(claims) = claims else {
|
|
span.set_status(Status::error("bad token"));
|
|
return ApiError::unauthorized(
|
|
"stream_link_expired",
|
|
"This video link has expired. Open the video again.",
|
|
)
|
|
.into_response();
|
|
};
|
|
let Some(path) = app_state
|
|
.libraries
|
|
.iter()
|
|
.find(|lib| lib.id == claims.lib)
|
|
.and_then(|lib| is_valid_full_path(&lib.root_path, &claims.path, false))
|
|
.filter(|p| p.is_file())
|
|
else {
|
|
span.set_status(Status::error("not found"));
|
|
return file_not_found().into_response();
|
|
};
|
|
|
|
match NamedFile::open(&path) {
|
|
Ok(f) => {
|
|
span.set_status(Status::Ok);
|
|
f.use_etag(true)
|
|
.use_last_modified(true)
|
|
.into_response(&request)
|
|
}
|
|
Err(_) => {
|
|
span.set_status(Status::error("not found"));
|
|
stream_not_found().into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Resolve a client `path` + optional `library` to `(library_id, root,
|
|
/// full_path)`. Tries the requested library first, then falls back to any
|
|
/// other library that actually contains the file — handles union-mode
|
|
/// requests where the mobile client passes no library but the file lives
|
|
/// in a non-primary library.
|
|
fn resolve_request_path(
|
|
app_state: &AppState,
|
|
path: &str,
|
|
library: Option<&str>,
|
|
) -> Option<(i32, String, PathBuf)> {
|
|
let preferred_library = libraries::resolve_library_param_state(app_state, library)
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_else(|| app_state.primary_library());
|
|
|
|
is_valid_full_path(&preferred_library.root_path, &path.to_string(), false)
|
|
.filter(|p| p.exists())
|
|
.map(|p| (preferred_library.id, preferred_library.root_path.clone(), p))
|
|
.or_else(|| {
|
|
app_state.libraries.iter().find_map(|lib| {
|
|
if lib.id == preferred_library.id {
|
|
return None;
|
|
}
|
|
is_valid_full_path(&lib.root_path, &path.to_string(), false)
|
|
.filter(|p| p.exists())
|
|
.map(|p| (lib.id, lib.root_path.clone(), p))
|
|
})
|
|
})
|
|
}
|
|
|
|
fn rel_path_for_lookup(full_path_str: &str, resolved_root: &str) -> String {
|
|
full_path_str
|
|
.strip_prefix(resolved_root)
|
|
.unwrap_or(full_path_str)
|
|
.trim_start_matches(['/', '\\'])
|
|
.replace('\\', "/")
|
|
}
|
|
|
|
/// Allowed file names inside a hash dir. `playlist.m3u8` plus segment
|
|
/// files matching the `segment_NNN.ts` template that `PlaylistGenerator`
|
|
/// writes via `hls_paths::SEGMENT_TEMPLATE`. Anything else (including
|
|
/// `.tmp`, `.unsupported`, dotfiles) returns 400 — these are internal
|
|
/// artifacts the client should never request.
|
|
fn is_allowed_hls_filename(name: &str) -> bool {
|
|
if name == hls_paths::PLAYLIST_FILENAME {
|
|
return true;
|
|
}
|
|
if let Some(rest) = name.strip_prefix("segment_")
|
|
&& let Some(num) = rest.strip_suffix(".ts")
|
|
&& !num.is_empty()
|
|
&& num.bytes().all(|b| b.is_ascii_digit())
|
|
{
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
#[get("/video/preview")]
|
|
#[tracing::instrument(name = "get_video_preview", skip_all)]
|
|
pub async fn get_video_preview(
|
|
_claims: Claims,
|
|
request: HttpRequest,
|
|
req: web::Query<PreviewClipRequest>,
|
|
app_state: Data<AppState>,
|
|
preview_dao: Data<Mutex<Box<dyn PreviewDao>>>,
|
|
) -> impl Responder {
|
|
let span = tracing::Span::current();
|
|
|
|
// Validate path
|
|
let full_path = match is_valid_full_path(&app_state.base_path, &req.path, true) {
|
|
Some(path) => path,
|
|
None => {
|
|
span.set_status(Status::error("Invalid path"));
|
|
return invalid_path().into_response();
|
|
}
|
|
};
|
|
|
|
let full_path_str = full_path.to_string_lossy().to_string();
|
|
|
|
// Use relative path (from BASE_PATH) for DB storage, consistent with EXIF convention
|
|
let relative_path = full_path_str
|
|
.strip_prefix(&app_state.base_path)
|
|
.unwrap_or(&full_path_str)
|
|
.trim_start_matches(['/', '\\'])
|
|
.to_string();
|
|
|
|
// Check preview status in DB
|
|
let preview = {
|
|
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
|
dao.get_preview(&relative_path)
|
|
};
|
|
|
|
match preview {
|
|
Ok(Some(clip)) => match clip.status.as_str() {
|
|
"complete" => {
|
|
let preview_path = PathBuf::from(&app_state.preview_clips_path)
|
|
.join(&relative_path)
|
|
.with_extension("mp4");
|
|
|
|
match NamedFile::open(&preview_path) {
|
|
Ok(file) => {
|
|
span.set_status(Status::Ok);
|
|
file.into_response(&request)
|
|
}
|
|
Err(_) => {
|
|
// File missing on disk but DB says complete - reset and regenerate
|
|
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
|
let _ = dao.update_status(&relative_path, "pending", None, None, None);
|
|
app_state
|
|
.preview_clip_generator
|
|
.do_send(GeneratePreviewClipMessage {
|
|
video_path: full_path_str,
|
|
});
|
|
span.set_status(Status::Ok);
|
|
HttpResponse::Accepted().json(serde_json::json!({
|
|
"status": "processing",
|
|
"path": req.path
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
"processing" => {
|
|
span.set_status(Status::Ok);
|
|
HttpResponse::Accepted().json(serde_json::json!({
|
|
"status": "processing",
|
|
"path": req.path
|
|
}))
|
|
}
|
|
"failed" => {
|
|
let error_msg = clip
|
|
.error_message
|
|
.unwrap_or_else(|| "Unknown error".to_string());
|
|
span.set_status(Status::error(format!("Generation failed: {}", error_msg)));
|
|
ApiError::internal(format!("Generation failed: {}", error_msg)).into_response()
|
|
}
|
|
_ => {
|
|
// pending or unknown status - trigger generation
|
|
app_state
|
|
.preview_clip_generator
|
|
.do_send(GeneratePreviewClipMessage {
|
|
video_path: full_path_str,
|
|
});
|
|
span.set_status(Status::Ok);
|
|
HttpResponse::Accepted().json(serde_json::json!({
|
|
"status": "processing",
|
|
"path": req.path
|
|
}))
|
|
}
|
|
},
|
|
Ok(None) => {
|
|
// No record exists - insert as pending and trigger generation
|
|
{
|
|
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
|
let _ = dao.insert_preview(&relative_path, "pending");
|
|
}
|
|
app_state
|
|
.preview_clip_generator
|
|
.do_send(GeneratePreviewClipMessage {
|
|
video_path: full_path_str,
|
|
});
|
|
span.set_status(Status::Ok);
|
|
HttpResponse::Accepted().json(serde_json::json!({
|
|
"status": "processing",
|
|
"path": req.path
|
|
}))
|
|
}
|
|
Err(_) => {
|
|
span.set_status(Status::error("Database error"));
|
|
ApiError::internal("Database error").into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[post("/video/preview/status")]
|
|
#[tracing::instrument(name = "get_preview_status", skip_all)]
|
|
pub async fn get_preview_status(
|
|
_claims: Claims,
|
|
body: web::Json<PreviewStatusRequest>,
|
|
app_state: Data<AppState>,
|
|
preview_dao: Data<Mutex<Box<dyn PreviewDao>>>,
|
|
) -> impl Responder {
|
|
let span = tracing::Span::current();
|
|
|
|
// Limit to 200 paths per request
|
|
if body.paths.len() > 200 {
|
|
span.set_status(Status::error("Too many paths"));
|
|
return ApiError::bad_request("too_many_files", "At most 200 videos per request")
|
|
.with_details(serde_json::json!({ "max": 200 }))
|
|
.into_response();
|
|
}
|
|
|
|
let previews = {
|
|
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
|
dao.get_previews_batch(&body.paths)
|
|
};
|
|
|
|
match previews {
|
|
Ok(clips) => {
|
|
// Build a map of file_path -> VideoPreviewClip for quick lookup
|
|
let clip_map: HashMap<String, _> = clips
|
|
.into_iter()
|
|
.map(|clip| (clip.file_path.clone(), clip))
|
|
.collect();
|
|
|
|
let mut items: Vec<PreviewStatusItem> = Vec::with_capacity(body.paths.len());
|
|
|
|
for path in &body.paths {
|
|
if let Some(clip) = clip_map.get(path) {
|
|
// Re-queue generation for stale pending/failed records
|
|
if clip.status == "pending" || clip.status == "failed" {
|
|
let full_path = format!(
|
|
"{}/{}",
|
|
app_state.base_path.trim_end_matches(['/', '\\']),
|
|
path.trim_start_matches(['/', '\\'])
|
|
);
|
|
app_state
|
|
.preview_clip_generator
|
|
.do_send(GeneratePreviewClipMessage {
|
|
video_path: full_path,
|
|
});
|
|
}
|
|
|
|
items.push(PreviewStatusItem {
|
|
path: path.clone(),
|
|
status: clip.status.clone(),
|
|
preview_url: if clip.status == "complete" {
|
|
Some(format!("/video/preview?path={}", urlencoding::encode(path)))
|
|
} else {
|
|
None
|
|
},
|
|
});
|
|
} else {
|
|
// No record exists — insert as pending and trigger generation
|
|
{
|
|
let mut dao = preview_dao.lock().expect("Unable to lock PreviewDao");
|
|
let _ = dao.insert_preview(path, "pending");
|
|
}
|
|
|
|
// Build full path for ffmpeg (actor needs the absolute path for input)
|
|
let full_path = format!(
|
|
"{}/{}",
|
|
app_state.base_path.trim_end_matches(['/', '\\']),
|
|
path.trim_start_matches(['/', '\\'])
|
|
);
|
|
|
|
info!("Triggering preview generation for '{}'", path);
|
|
app_state
|
|
.preview_clip_generator
|
|
.do_send(GeneratePreviewClipMessage {
|
|
video_path: full_path,
|
|
});
|
|
|
|
items.push(PreviewStatusItem {
|
|
path: path.clone(),
|
|
status: "pending".to_string(),
|
|
preview_url: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
span.set_status(Status::Ok);
|
|
HttpResponse::Ok().json(PreviewStatusResponse { previews: items })
|
|
}
|
|
Err(_) => {
|
|
span.set_status(Status::error("Database error"));
|
|
ApiError::internal("Database error").into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The path names no file in the library.
|
|
fn file_not_found() -> ApiError {
|
|
ApiError::not_found("file_not_found", "That file doesn't exist")
|
|
}
|
|
|
|
/// The path isn't a file in the library (400, as it has always been here).
|
|
fn invalid_path() -> ApiError {
|
|
ApiError::bad_request("invalid_path", "That file isn't in the library")
|
|
}
|
|
|
|
/// An HLS playlist/segment that isn't (or is no longer) generated.
|
|
fn stream_not_found() -> ApiError {
|
|
ApiError::not_found("stream_not_found", "This video isn't ready to stream yet")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::data::Claims;
|
|
use crate::database::PreviewDao;
|
|
use crate::testhelpers::TestPreviewDao;
|
|
use actix_web::App;
|
|
|
|
#[test]
|
|
fn is_valid_hash_requires_64_ascii_hex() {
|
|
assert!(is_valid_hash(&"a".repeat(64)));
|
|
assert!(is_valid_hash(&"F".repeat(64)));
|
|
assert!(is_valid_hash(&format!("ab{}", "0".repeat(62))));
|
|
|
|
assert!(!is_valid_hash(&"a".repeat(63)));
|
|
assert!(!is_valid_hash(&"a".repeat(65)));
|
|
// Anything outside the hex alphabet — including '/', '.', '..' —
|
|
// is rejected up front so the path-traversal canonicalisation
|
|
// never has to defend the boundary alone.
|
|
assert!(!is_valid_hash(&format!("/{}", "a".repeat(63))));
|
|
assert!(!is_valid_hash(&format!("..{}", "a".repeat(62))));
|
|
assert!(!is_valid_hash(&"g".repeat(64)));
|
|
}
|
|
|
|
#[test]
|
|
fn is_allowed_hls_filename_accepts_only_playlist_and_segments() {
|
|
assert!(is_allowed_hls_filename("playlist.m3u8"));
|
|
assert!(is_allowed_hls_filename("segment_000.ts"));
|
|
assert!(is_allowed_hls_filename("segment_999.ts"));
|
|
assert!(is_allowed_hls_filename("segment_0.ts"));
|
|
|
|
// Internal artifacts the client should never request.
|
|
assert!(!is_allowed_hls_filename("playlist.m3u8.tmp"));
|
|
assert!(!is_allowed_hls_filename("playlist.unsupported"));
|
|
// Traversal / path components — defence in depth alongside
|
|
// the actix path matcher itself.
|
|
assert!(!is_allowed_hls_filename(".."));
|
|
assert!(!is_allowed_hls_filename("../etc/passwd"));
|
|
assert!(!is_allowed_hls_filename("segment_abc.ts"));
|
|
assert!(!is_allowed_hls_filename("segment_.ts"));
|
|
assert!(!is_allowed_hls_filename(""));
|
|
}
|
|
|
|
#[test]
|
|
fn rel_path_for_lookup_normalizes_windows_separators() {
|
|
// Windows: backslash root + backslash full path. The stored row is
|
|
// forward-slash (`album/clip.mp4`), so without normalization the
|
|
// lookup misses and the handler re-hashes the whole file.
|
|
assert_eq!(
|
|
rel_path_for_lookup(r"Z:\Media\pic\album\clip.mp4", r"Z:\Media\pic"),
|
|
"album/clip.mp4"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rel_path_for_lookup_handles_unix_separators() {
|
|
assert_eq!(
|
|
rel_path_for_lookup("/media/pic/album/clip.mp4", "/media/pic"),
|
|
"album/clip.mp4"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rel_path_for_lookup_file_at_root_has_no_separator() {
|
|
// A file directly in the library root has no internal separator, so
|
|
// the bug never manifested here — guard against a regression anyway.
|
|
assert_eq!(
|
|
rel_path_for_lookup(r"Z:\Media\pic\clip.mp4", r"Z:\Media\pic"),
|
|
"clip.mp4"
|
|
);
|
|
assert_eq!(
|
|
rel_path_for_lookup("/media/pic/clip.mp4", "/media/pic"),
|
|
"clip.mp4"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rel_path_for_lookup_strips_leading_separators() {
|
|
// Both separator styles are trimmed from the front after the root
|
|
// is stripped, regardless of which form the join produced.
|
|
assert_eq!(
|
|
rel_path_for_lookup(r"Z:\Media\pic\sub\a.mp4", r"Z:\Media\pic"),
|
|
"sub/a.mp4"
|
|
);
|
|
assert_eq!(
|
|
rel_path_for_lookup("/media/pic//sub/a.mp4", "/media/pic"),
|
|
"sub/a.mp4"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rel_path_for_lookup_falls_back_when_root_does_not_match() {
|
|
// If the root doesn't prefix the path (e.g. a stale mount), we keep
|
|
// the whole path but still normalize separators rather than panic.
|
|
assert_eq!(
|
|
rel_path_for_lookup(r"D:\other\album\clip.mp4", r"Z:\Media\pic"),
|
|
"D:/other/album/clip.mp4"
|
|
);
|
|
}
|
|
|
|
fn make_token() -> String {
|
|
let claims = Claims::valid_user("1".to_string());
|
|
jsonwebtoken::encode(
|
|
&jsonwebtoken::Header::default(),
|
|
&claims,
|
|
&jsonwebtoken::EncodingKey::from_secret(b"test_key"),
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn write_library_file(app_state: &AppState, rel: &str, bytes: &[u8]) {
|
|
let path = std::path::Path::new(&app_state.primary_library().root_path).join(rel);
|
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
std::fs::write(path, bytes).unwrap();
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn original_video_url_streams_with_range_and_no_auth_header() {
|
|
let app_state = Data::new(AppState::test_state());
|
|
write_library_file(&app_state, "clips/a.mp4", b"0123456789");
|
|
let app = actix_web::test::init_service(
|
|
App::new()
|
|
.wrap(actix_web::middleware::from_fn(redact_original_stream_token))
|
|
.service(original_video_url)
|
|
.service(stream_original_video)
|
|
.app_data(app_state),
|
|
)
|
|
.await;
|
|
|
|
let req = actix_web::test::TestRequest::post()
|
|
.uri("/video/original")
|
|
.insert_header(("Authorization", format!("Bearer {}", make_token())))
|
|
.set_json(serde_json::json!({"path": "clips/a.mp4"}))
|
|
.to_request();
|
|
let resp = actix_web::test::call_service(&app, req).await;
|
|
assert_eq!(resp.status(), 200);
|
|
let body: serde_json::Value = actix_web::test::read_body_json(resp).await;
|
|
let url = body["url"].as_str().unwrap().to_string();
|
|
assert!(url.starts_with("/video/original/stream?token="));
|
|
assert!(body["expires_at"].as_i64().unwrap() > chrono::Utc::now().timestamp());
|
|
|
|
// No Authorization header: the token alone grants access.
|
|
let req = actix_web::test::TestRequest::get()
|
|
.uri(&url)
|
|
.insert_header(("Range", "bytes=2-5"))
|
|
.to_request();
|
|
let resp = actix_web::test::call_service(&app, req).await;
|
|
assert_eq!(resp.status(), 206);
|
|
assert_eq!(actix_web::test::read_body(resp).await.as_ref(), b"2345");
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn original_video_url_rejects_non_video_and_unauthenticated() {
|
|
let app_state = Data::new(AppState::test_state());
|
|
write_library_file(&app_state, "notes.txt", b"hi");
|
|
let app = actix_web::test::init_service(
|
|
App::new().service(original_video_url).app_data(app_state),
|
|
)
|
|
.await;
|
|
|
|
let req = actix_web::test::TestRequest::post()
|
|
.uri("/video/original")
|
|
.insert_header(("Authorization", format!("Bearer {}", make_token())))
|
|
.set_json(serde_json::json!({"path": "notes.txt"}))
|
|
.to_request();
|
|
assert_eq!(actix_web::test::call_service(&app, req).await.status(), 400);
|
|
|
|
let req = actix_web::test::TestRequest::post()
|
|
.uri("/video/original")
|
|
.set_json(serde_json::json!({"path": "notes.txt"}))
|
|
.to_request();
|
|
assert_eq!(actix_web::test::call_service(&app, req).await.status(), 401);
|
|
}
|
|
|
|
#[test]
|
|
fn take_token_param_redacts_only_the_token() {
|
|
assert_eq!(
|
|
take_token_param("token=abc.def.ghi"),
|
|
Some(("abc.def.ghi".into(), "token=redacted".into()))
|
|
);
|
|
assert_eq!(
|
|
take_token_param("x=1&token=abc&y=2"),
|
|
Some(("abc".into(), "x=1&token=redacted&y=2".into()))
|
|
);
|
|
assert_eq!(take_token_param("x=1&tokens=abc"), None);
|
|
assert_eq!(take_token_param(""), None);
|
|
}
|
|
|
|
/// What the loggers (and handler) see after the middleware runs.
|
|
#[get("/video/original/stream")]
|
|
async fn echo_uri_and_token(request: HttpRequest) -> HttpResponse {
|
|
let token = request
|
|
.extensions()
|
|
.get::<OriginalStreamToken>()
|
|
.map(|t| t.0.clone())
|
|
.unwrap_or_default();
|
|
HttpResponse::Ok().body(format!("{} {}", request.uri(), token))
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn redact_middleware_strips_token_from_uri() {
|
|
let app = actix_web::test::init_service(
|
|
App::new()
|
|
.wrap(actix_web::middleware::from_fn(redact_original_stream_token))
|
|
.service(echo_uri_and_token),
|
|
)
|
|
.await;
|
|
let req = actix_web::test::TestRequest::get()
|
|
.uri("/video/original/stream?token=secret.jwt.value")
|
|
.to_request();
|
|
let body = actix_web::test::call_and_read_body(&app, req).await;
|
|
assert_eq!(
|
|
std::str::from_utf8(&body).unwrap(),
|
|
"/video/original/stream?token=redacted secret.jwt.value"
|
|
);
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn stream_original_video_fails_closed_without_middleware() {
|
|
let app_state = Data::new(AppState::test_state());
|
|
write_library_file(&app_state, "clips/a.mp4", b"0123456789");
|
|
let app = actix_web::test::init_service(
|
|
App::new()
|
|
.service(stream_original_video)
|
|
.app_data(app_state),
|
|
)
|
|
.await;
|
|
let (token, _) =
|
|
original_url::sign("1", crate::libraries::PRIMARY_LIBRARY_ID, "clips/a.mp4");
|
|
let req = actix_web::test::TestRequest::get()
|
|
.uri(&format!("/video/original/stream?token={}", token))
|
|
.to_request();
|
|
assert_eq!(actix_web::test::call_service(&app, req).await.status(), 401);
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn stream_original_video_rejects_bad_tokens() {
|
|
let app_state = Data::new(AppState::test_state());
|
|
write_library_file(&app_state, "clips/a.mp4", b"0123456789");
|
|
let app = actix_web::test::init_service(
|
|
App::new()
|
|
.wrap(actix_web::middleware::from_fn(redact_original_stream_token))
|
|
.service(stream_original_video)
|
|
.app_data(app_state),
|
|
)
|
|
.await;
|
|
|
|
// A login JWT is not a stream token.
|
|
let req = actix_web::test::TestRequest::get()
|
|
.uri(&format!("/video/original/stream?token={}", make_token()))
|
|
.to_request();
|
|
assert_eq!(actix_web::test::call_service(&app, req).await.status(), 401);
|
|
|
|
// A valid token for a path that escapes the library root.
|
|
let (token, _) = original_url::sign(
|
|
"1",
|
|
crate::libraries::PRIMARY_LIBRARY_ID,
|
|
"../../etc/passwd",
|
|
);
|
|
let req = actix_web::test::TestRequest::get()
|
|
.uri(&format!("/video/original/stream?token={}", token))
|
|
.to_request();
|
|
assert_eq!(actix_web::test::call_service(&app, req).await.status(), 404);
|
|
}
|
|
|
|
fn make_preview_dao(dao: TestPreviewDao) -> Data<Mutex<Box<dyn PreviewDao>>> {
|
|
Data::new(Mutex::new(Box::new(dao) as Box<dyn PreviewDao>))
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn test_get_preview_status_returns_pending_for_unknown() {
|
|
let dao = TestPreviewDao::new();
|
|
let preview_dao = make_preview_dao(dao);
|
|
let app_state = Data::new(AppState::test_state());
|
|
let token = make_token();
|
|
|
|
let app = actix_web::test::init_service(
|
|
App::new()
|
|
.service(get_preview_status)
|
|
.app_data(app_state)
|
|
.app_data(preview_dao.clone()),
|
|
)
|
|
.await;
|
|
|
|
let req = actix_web::test::TestRequest::post()
|
|
.uri("/video/preview/status")
|
|
.insert_header(("Authorization", format!("Bearer {}", token)))
|
|
.set_json(serde_json::json!({"paths": ["photos/new_video.mp4"]}))
|
|
.to_request();
|
|
|
|
let resp = actix_web::test::call_service(&app, req).await;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = actix_web::test::read_body_json(resp).await;
|
|
let previews = body["previews"].as_array().unwrap();
|
|
assert_eq!(previews.len(), 1);
|
|
assert_eq!(previews[0]["status"], "pending");
|
|
|
|
// Verify the DAO now has a pending record
|
|
let mut dao_lock = preview_dao.lock().unwrap();
|
|
let clip = dao_lock.get_preview("photos/new_video.mp4").unwrap();
|
|
assert!(clip.is_some());
|
|
assert_eq!(clip.unwrap().status, "pending");
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn test_get_preview_status_returns_complete_with_url() {
|
|
let mut dao = TestPreviewDao::new();
|
|
dao.insert_preview("photos/done.mp4", "pending").unwrap();
|
|
dao.update_status("photos/done.mp4", "complete", Some(9.5), Some(500000), None)
|
|
.unwrap();
|
|
|
|
let preview_dao = make_preview_dao(dao);
|
|
let app_state = Data::new(AppState::test_state());
|
|
let token = make_token();
|
|
|
|
let app = actix_web::test::init_service(
|
|
App::new()
|
|
.service(get_preview_status)
|
|
.app_data(app_state)
|
|
.app_data(preview_dao),
|
|
)
|
|
.await;
|
|
|
|
let req = actix_web::test::TestRequest::post()
|
|
.uri("/video/preview/status")
|
|
.insert_header(("Authorization", format!("Bearer {}", token)))
|
|
.set_json(serde_json::json!({"paths": ["photos/done.mp4"]}))
|
|
.to_request();
|
|
|
|
let resp = actix_web::test::call_service(&app, req).await;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = actix_web::test::read_body_json(resp).await;
|
|
let previews = body["previews"].as_array().unwrap();
|
|
assert_eq!(previews.len(), 1);
|
|
assert_eq!(previews[0]["status"], "complete");
|
|
assert!(
|
|
previews[0]["preview_url"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("photos%2Fdone.mp4")
|
|
);
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn test_get_preview_status_rejects_over_200_paths() {
|
|
let dao = TestPreviewDao::new();
|
|
let preview_dao = make_preview_dao(dao);
|
|
let app_state = Data::new(AppState::test_state());
|
|
let token = make_token();
|
|
|
|
let app = actix_web::test::init_service(
|
|
App::new()
|
|
.service(get_preview_status)
|
|
.app_data(app_state)
|
|
.app_data(preview_dao),
|
|
)
|
|
.await;
|
|
|
|
let paths: Vec<String> = (0..201).map(|i| format!("video_{}.mp4", i)).collect();
|
|
let req = actix_web::test::TestRequest::post()
|
|
.uri("/video/preview/status")
|
|
.insert_header(("Authorization", format!("Bearer {}", token)))
|
|
.set_json(serde_json::json!({"paths": paths}))
|
|
.to_request();
|
|
|
|
let resp = actix_web::test::call_service(&app, req).await;
|
|
assert_eq!(resp.status(), 400);
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn test_get_preview_status_mixed_statuses() {
|
|
let mut dao = TestPreviewDao::new();
|
|
dao.insert_preview("a.mp4", "pending").unwrap();
|
|
dao.insert_preview("b.mp4", "pending").unwrap();
|
|
dao.update_status("b.mp4", "complete", Some(10.0), Some(100000), None)
|
|
.unwrap();
|
|
|
|
let preview_dao = make_preview_dao(dao);
|
|
let app_state = Data::new(AppState::test_state());
|
|
let token = make_token();
|
|
|
|
let app = actix_web::test::init_service(
|
|
App::new()
|
|
.service(get_preview_status)
|
|
.app_data(app_state)
|
|
.app_data(preview_dao),
|
|
)
|
|
.await;
|
|
|
|
let req = actix_web::test::TestRequest::post()
|
|
.uri("/video/preview/status")
|
|
.insert_header(("Authorization", format!("Bearer {}", token)))
|
|
.set_json(serde_json::json!({"paths": ["a.mp4", "b.mp4", "c.mp4"]}))
|
|
.to_request();
|
|
|
|
let resp = actix_web::test::call_service(&app, req).await;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = actix_web::test::read_body_json(resp).await;
|
|
let previews = body["previews"].as_array().unwrap();
|
|
assert_eq!(previews.len(), 3);
|
|
|
|
// a.mp4 is pending
|
|
assert_eq!(previews[0]["path"], "a.mp4");
|
|
assert_eq!(previews[0]["status"], "pending");
|
|
|
|
// b.mp4 is complete with URL
|
|
assert_eq!(previews[1]["path"], "b.mp4");
|
|
assert_eq!(previews[1]["status"], "complete");
|
|
assert!(previews[1]["preview_url"].is_string());
|
|
|
|
// c.mp4 was not found — handler inserts pending
|
|
assert_eq!(previews[2]["path"], "c.mp4");
|
|
assert_eq!(previews[2]["status"], "pending");
|
|
}
|
|
|
|
/// Verifies that the status endpoint re-queues generation for stale
|
|
/// "pending" and "failed" records (e.g., after a server restart or
|
|
/// when clip files were deleted). The do_send to the actor exercises
|
|
/// the re-queue code path; the actor runs against temp dirs so it
|
|
/// won't panic.
|
|
#[actix_rt::test]
|
|
async fn test_get_preview_status_requeues_pending_and_failed() {
|
|
let mut dao = TestPreviewDao::new();
|
|
|
|
// Simulate stale records left from a previous server run
|
|
dao.insert_preview("stale/pending.mp4", "pending").unwrap();
|
|
dao.insert_preview("stale/failed.mp4", "pending").unwrap();
|
|
dao.update_status(
|
|
"stale/failed.mp4",
|
|
"failed",
|
|
None,
|
|
None,
|
|
Some("ffmpeg error"),
|
|
)
|
|
.unwrap();
|
|
|
|
let preview_dao = make_preview_dao(dao);
|
|
let app_state = Data::new(AppState::test_state());
|
|
let token = make_token();
|
|
|
|
let app = actix_web::test::init_service(
|
|
App::new()
|
|
.service(get_preview_status)
|
|
.app_data(app_state)
|
|
.app_data(preview_dao),
|
|
)
|
|
.await;
|
|
|
|
let req = actix_web::test::TestRequest::post()
|
|
.uri("/video/preview/status")
|
|
.insert_header(("Authorization", format!("Bearer {}", token)))
|
|
.set_json(serde_json::json!({
|
|
"paths": ["stale/pending.mp4", "stale/failed.mp4"]
|
|
}))
|
|
.to_request();
|
|
|
|
let resp = actix_web::test::call_service(&app, req).await;
|
|
assert_eq!(resp.status(), 200);
|
|
|
|
let body: serde_json::Value = actix_web::test::read_body_json(resp).await;
|
|
let previews = body["previews"].as_array().unwrap();
|
|
assert_eq!(previews.len(), 2);
|
|
|
|
// Both records are returned with their current status
|
|
assert_eq!(previews[0]["path"], "stale/pending.mp4");
|
|
assert_eq!(previews[0]["status"], "pending");
|
|
assert!(previews[0].get("preview_url").is_none());
|
|
|
|
assert_eq!(previews[1]["path"], "stale/failed.mp4");
|
|
assert_eq!(previews[1]["status"], "failed");
|
|
assert!(previews[1].get("preview_url").is_none());
|
|
}
|
|
}
|