diff --git a/src/handlers/video.rs b/src/handlers/video.rs index 42bc204..f9f4e64 100644 --- a/src/handlers/video.rs +++ b/src/handlers/video.rs @@ -96,13 +96,10 @@ pub async fn generate_video( return HttpResponse::BadRequest().finish(); }; - // Build the rel_path used to look up the row. + // 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 = full_path_str - .strip_prefix(&resolved_root) - .unwrap_or(full_path_str.as_str()) - .trim_start_matches(['/', '\\']) - .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. @@ -284,6 +281,23 @@ 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\Melissa\clip.mp4`) yields +/// `Melissa\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. +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 @@ -570,6 +584,63 @@ mod tests { 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 (`Melissa/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\Melissa\clip.mp4", r"Z:\Media\pic"), + "Melissa/clip.mp4" + ); + } + + #[test] + fn rel_path_for_lookup_handles_unix_separators() { + assert_eq!( + rel_path_for_lookup("/media/pic/Melissa/clip.mp4", "/media/pic"), + "Melissa/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\Melissa\clip.mp4", r"Z:\Media\pic"), + "D:/other/Melissa/clip.mp4" + ); + } + fn make_token() -> String { let claims = Claims::valid_user("1".to_string()); jsonwebtoken::encode(