Add GPS summary sorting
Run cargo fmt/clippy
This commit is contained in:
@@ -710,8 +710,14 @@ impl ExifDao for SqliteExifDao {
|
||||
let filtered: Vec<(String, f64, f64, Option<i64>)> = results
|
||||
.into_iter()
|
||||
.filter_map(|exif| {
|
||||
if let (Some(lat_val), Some(lon_val)) = (exif.gps_latitude, exif.gps_longitude) {
|
||||
Some((exif.file_path, lat_val as f64, lon_val as f64, exif.date_taken))
|
||||
if let (Some(lat_val), Some(lon_val)) = (exif.gps_latitude, exif.gps_longitude)
|
||||
{
|
||||
Some((
|
||||
exif.file_path,
|
||||
lat_val as f64,
|
||||
lon_val as f64,
|
||||
exif.date_taken,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
129
src/files.rs
129
src/files.rs
@@ -187,10 +187,10 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
_: Claims,
|
||||
request: HttpRequest,
|
||||
req: Query<FilesRequest>,
|
||||
app_state: web::Data<AppState>,
|
||||
file_system: web::Data<FS>,
|
||||
tag_dao: web::Data<Mutex<TagD>>,
|
||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||
app_state: Data<AppState>,
|
||||
file_system: Data<FS>,
|
||||
tag_dao: Data<Mutex<TagD>>,
|
||||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> HttpResponse {
|
||||
let search_path = &req.path;
|
||||
|
||||
@@ -756,7 +756,7 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||
fn sort(mut files: Vec<FileWithTagCount>, sort_type: SortType) -> Vec<String> {
|
||||
match sort_type {
|
||||
SortType::Shuffle => files.shuffle(&mut thread_rng()),
|
||||
SortType::NameAsc => {
|
||||
NameAsc => {
|
||||
files.sort_by(|l, r| l.file_name.cmp(&r.file_name));
|
||||
}
|
||||
SortType::NameDesc => {
|
||||
@@ -786,7 +786,7 @@ fn sort(mut files: Vec<FileWithTagCount>, sort_type: SortType) -> Vec<String> {
|
||||
fn sort_with_metadata(mut files: Vec<FileWithMetadata>, sort_type: SortType) -> Vec<String> {
|
||||
match sort_type {
|
||||
SortType::Shuffle => files.shuffle(&mut thread_rng()),
|
||||
SortType::NameAsc => {
|
||||
NameAsc => {
|
||||
files.sort_by(|l, r| l.file_name.cmp(&r.file_name));
|
||||
}
|
||||
SortType::NameDesc => {
|
||||
@@ -956,8 +956,8 @@ fn is_path_above_base_dir<P: AsRef<Path> + Debug>(
|
||||
pub async fn get_gps_summary(
|
||||
_: Claims,
|
||||
request: HttpRequest,
|
||||
req: web::Query<FilesRequest>,
|
||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
||||
req: Query<FilesRequest>,
|
||||
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||||
) -> Result<HttpResponse, actix_web::Error> {
|
||||
use crate::data::{GpsPhotoSummary, GpsPhotosResponse};
|
||||
|
||||
@@ -968,7 +968,10 @@ pub async fn get_gps_summary(
|
||||
.start_with_context(&tracer, &parent_cx);
|
||||
|
||||
span.set_attribute(KeyValue::new("path", req.path.clone()));
|
||||
span.set_attribute(KeyValue::new("recursive", req.recursive.unwrap_or(false).to_string()));
|
||||
span.set_attribute(KeyValue::new(
|
||||
"recursive",
|
||||
req.recursive.unwrap_or(false).to_string(),
|
||||
));
|
||||
|
||||
let cx = opentelemetry::Context::current_with_span(span);
|
||||
|
||||
@@ -989,41 +992,76 @@ pub async fn get_gps_summary(
|
||||
};
|
||||
|
||||
let recursive = req.recursive.unwrap_or(false);
|
||||
info!("Fetching GPS photos for path='{}' recursive={}", requested_path, recursive);
|
||||
info!(
|
||||
"Fetching GPS photos for path='{}' recursive={}",
|
||||
requested_path, recursive
|
||||
);
|
||||
|
||||
// Query database for all photos with GPS
|
||||
let mut exif_dao_guard = exif_dao.lock().expect("Unable to get ExifDao");
|
||||
match exif_dao_guard.get_all_with_gps(&cx, requested_path, recursive) {
|
||||
Ok(gps_data) => {
|
||||
let photos: Vec<GpsPhotoSummary> = gps_data
|
||||
.into_iter()
|
||||
.map(|(path, lat, lon, date_taken)| GpsPhotoSummary {
|
||||
path,
|
||||
lat,
|
||||
lon,
|
||||
date_taken,
|
||||
})
|
||||
.collect();
|
||||
Ok(gps_data) => {
|
||||
let mut photos: Vec<GpsPhotoSummary> = gps_data
|
||||
.into_iter()
|
||||
.map(|(path, lat, lon, date_taken)| GpsPhotoSummary {
|
||||
path,
|
||||
lat,
|
||||
lon,
|
||||
date_taken,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total = photos.len();
|
||||
cx.span().set_attribute(KeyValue::new("result_count", total as i64));
|
||||
cx.span().set_status(Status::Ok);
|
||||
// Sort by date_taken based on request, defaulting to ascending (oldest to newest)
|
||||
use crate::data::SortType;
|
||||
let sort_type = req.sort.unwrap_or(SortType::DateTakenAsc);
|
||||
|
||||
Ok(HttpResponse::Ok().json(GpsPhotosResponse { photos, total }))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error querying GPS data: {:?}", e);
|
||||
cx.span().set_status(Status::error(format!("Database error: {:?}", e)));
|
||||
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to query GPS data"
|
||||
})))
|
||||
match sort_type {
|
||||
SortType::DateTakenDesc => {
|
||||
photos.sort_by(|a, b| match (a.date_taken, b.date_taken) {
|
||||
(Some(date_a), Some(date_b)) => date_b.cmp(&date_a),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => a.path.cmp(&b.path),
|
||||
});
|
||||
}
|
||||
NameAsc => {
|
||||
photos.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
}
|
||||
SortType::NameDesc => {
|
||||
photos.sort_by(|a, b| b.path.cmp(&a.path));
|
||||
}
|
||||
_ => {
|
||||
// Default: DateTakenAsc
|
||||
photos.sort_by(|a, b| match (a.date_taken, b.date_taken) {
|
||||
(Some(date_a), Some(date_b)) => date_a.cmp(&date_b),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(None, None) => a.path.cmp(&b.path),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let total = photos.len();
|
||||
cx.span()
|
||||
.set_attribute(KeyValue::new("result_count", total as i64));
|
||||
cx.span().set_status(Status::Ok);
|
||||
|
||||
Ok(HttpResponse::Ok().json(GpsPhotosResponse { photos, total }))
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error querying GPS data: {:?}", e);
|
||||
cx.span()
|
||||
.set_status(Status::error(format!("Database error: {:?}", e)));
|
||||
Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "Failed to query GPS data"
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn move_file<FS: FileSystemAccess>(
|
||||
_: Claims,
|
||||
file_system: web::Data<FS>,
|
||||
file_system: Data<FS>,
|
||||
app_state: Data<AppState>,
|
||||
request: web::Json<MoveFileRequest>,
|
||||
) -> HttpResponse {
|
||||
@@ -1223,7 +1261,7 @@ mod tests {
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
_: &str,
|
||||
) -> Result<Option<crate::database::models::ImageExif>, crate::database::DbError> {
|
||||
) -> Result<Option<crate::database::models::ImageExif>, DbError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -1231,7 +1269,7 @@ mod tests {
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
data: crate::database::models::InsertImageExif,
|
||||
) -> Result<crate::database::models::ImageExif, crate::database::DbError> {
|
||||
) -> Result<crate::database::models::ImageExif, DbError> {
|
||||
// Return a dummy ImageExif for tests
|
||||
Ok(crate::database::models::ImageExif {
|
||||
id: 1,
|
||||
@@ -1259,14 +1297,14 @@ mod tests {
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
_: &str,
|
||||
) -> Result<(), crate::database::DbError> {
|
||||
) -> Result<(), DbError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_all_with_date_taken(
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
) -> Result<Vec<(String, i64)>, crate::database::DbError> {
|
||||
) -> Result<Vec<(String, i64)>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
@@ -1274,7 +1312,7 @@ mod tests {
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
_: &[String],
|
||||
) -> Result<Vec<crate::database::models::ImageExif>, crate::database::DbError> {
|
||||
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
@@ -1287,14 +1325,14 @@ mod tests {
|
||||
_: Option<(f64, f64, f64, f64)>,
|
||||
_: Option<i64>,
|
||||
_: Option<i64>,
|
||||
) -> Result<Vec<crate::database::models::ImageExif>, crate::database::DbError> {
|
||||
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn get_camera_makes(
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
) -> Result<Vec<(String, i64)>, crate::database::DbError> {
|
||||
) -> Result<Vec<(String, i64)>, DbError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
@@ -1327,7 +1365,12 @@ mod tests {
|
||||
Ok((file_paths.to_vec(), count))
|
||||
}
|
||||
|
||||
fn get_all_with_gps(&mut self, context: &opentelemetry::Context, base_path: &str, recursive: bool) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError> {
|
||||
fn get_all_with_gps(
|
||||
&mut self,
|
||||
_context: &opentelemetry::Context,
|
||||
_base_path: &str,
|
||||
_recursive: bool,
|
||||
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
@@ -1429,7 +1472,7 @@ mod tests {
|
||||
Data::new(RealFileSystem::new(temp_dir.to_str().unwrap().to_string())),
|
||||
Data::new(Mutex::new(SqliteTagDao::default())),
|
||||
Data::new(Mutex::new(
|
||||
Box::new(MockExifDao) as Box<dyn crate::database::ExifDao>
|
||||
Box::new(MockExifDao) as Box<dyn ExifDao>
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
@@ -1476,7 +1519,7 @@ mod tests {
|
||||
Data::new(FakeFileSystem::new(HashMap::new())),
|
||||
Data::new(Mutex::new(tag_dao)),
|
||||
Data::new(Mutex::new(
|
||||
Box::new(MockExifDao) as Box<dyn crate::database::ExifDao>
|
||||
Box::new(MockExifDao) as Box<dyn ExifDao>
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
@@ -1539,7 +1582,7 @@ mod tests {
|
||||
Data::new(FakeFileSystem::new(HashMap::new())),
|
||||
Data::new(Mutex::new(tag_dao)),
|
||||
Data::new(Mutex::new(
|
||||
Box::new(MockExifDao) as Box<dyn crate::database::ExifDao>
|
||||
Box::new(MockExifDao) as Box<dyn ExifDao>
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
|
||||
59
src/main.rs
59
src/main.rs
@@ -118,7 +118,7 @@ async fn get_image(
|
||||
|
||||
// Handle circular thumbnail request
|
||||
if req.shape == Some(ThumbnailShape::Circle) {
|
||||
match create_circular_thumbnail(&thumb_path, &thumbs).await {
|
||||
match create_circular_thumbnail(&thumb_path, thumbs).await {
|
||||
Ok(circular_path) => {
|
||||
if let Ok(file) = NamedFile::open(&circular_path) {
|
||||
span.set_status(Status::Ok);
|
||||
@@ -173,8 +173,11 @@ fn is_video_file(path: &Path) -> bool {
|
||||
file_types::is_video_file(path)
|
||||
}
|
||||
|
||||
async fn create_circular_thumbnail(thumb_path: &Path, thumbs_dir: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
use image::{ImageBuffer, Rgba, GenericImageView};
|
||||
async fn create_circular_thumbnail(
|
||||
thumb_path: &Path,
|
||||
thumbs_dir: &str,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
use image::{GenericImageView, ImageBuffer, Rgba};
|
||||
|
||||
// Create circular thumbnails directory
|
||||
let circular_dir = Path::new(thumbs_dir).join("_circular");
|
||||
@@ -1040,12 +1043,11 @@ fn cleanup_orphaned_playlists() {
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
{
|
||||
if let Some(entry_stem) = entry.path().file_stem() {
|
||||
if entry_stem == filename && is_video_file(entry.path()) {
|
||||
if let Some(entry_stem) = entry.path().file_stem()
|
||||
&& entry_stem == filename && is_video_file(entry.path()) {
|
||||
video_exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !video_exists {
|
||||
@@ -1056,7 +1058,11 @@ fn cleanup_orphaned_playlists() {
|
||||
|
||||
// Delete the playlist file
|
||||
if let Err(e) = std::fs::remove_file(&playlist_path) {
|
||||
warn!("Failed to delete playlist {}: {}", playlist_path.display(), e);
|
||||
warn!(
|
||||
"Failed to delete playlist {}: {}",
|
||||
playlist_path.display(),
|
||||
e
|
||||
);
|
||||
error_count += 1;
|
||||
} else {
|
||||
deleted_count += 1;
|
||||
@@ -1071,13 +1077,14 @@ fn cleanup_orphaned_playlists() {
|
||||
.filter(|e| e.file_type().is_file())
|
||||
{
|
||||
let entry_path = entry.path();
|
||||
if let Some(ext) = entry_path.extension() {
|
||||
if ext.eq_ignore_ascii_case("ts") {
|
||||
if let Some(ext) = entry_path.extension()
|
||||
&& ext.eq_ignore_ascii_case("ts") {
|
||||
// Check if this .ts file belongs to our playlist
|
||||
if let Some(ts_stem) = entry_path.file_stem() {
|
||||
let ts_name = ts_stem.to_string_lossy();
|
||||
if ts_name.starts_with(&*video_filename) {
|
||||
if let Err(e) = std::fs::remove_file(entry_path) {
|
||||
if let Err(e) = std::fs::remove_file(entry_path)
|
||||
{
|
||||
debug!(
|
||||
"Failed to delete segment {}: {}",
|
||||
entry_path.display(),
|
||||
@@ -1092,7 +1099,6 @@ fn cleanup_orphaned_playlists() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1154,7 +1160,12 @@ fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
|
||||
|
||||
if is_full_scan {
|
||||
info!("Running full scan (scan #{})", scan_count);
|
||||
process_new_files(&base_path, Arc::clone(&exif_dao), None, playlist_manager.clone());
|
||||
process_new_files(
|
||||
&base_path,
|
||||
Arc::clone(&exif_dao),
|
||||
None,
|
||||
playlist_manager.clone(),
|
||||
);
|
||||
last_full_scan = now;
|
||||
} else {
|
||||
debug!(
|
||||
@@ -1165,7 +1176,12 @@ fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
|
||||
let check_since = last_quick_scan
|
||||
.checked_sub(Duration::from_secs(10))
|
||||
.unwrap_or(last_quick_scan);
|
||||
process_new_files(&base_path, Arc::clone(&exif_dao), Some(check_since), playlist_manager.clone());
|
||||
process_new_files(
|
||||
&base_path,
|
||||
Arc::clone(&exif_dao),
|
||||
Some(check_since),
|
||||
playlist_manager.clone(),
|
||||
);
|
||||
}
|
||||
|
||||
last_quick_scan = now;
|
||||
@@ -1190,13 +1206,12 @@ fn playlist_needs_generation(video_path: &Path, playlist_path: &Path) -> bool {
|
||||
if let (Ok(video_meta), Ok(playlist_meta)) = (
|
||||
std::fs::metadata(video_path),
|
||||
std::fs::metadata(playlist_path),
|
||||
) {
|
||||
if let (Ok(video_modified), Ok(playlist_modified)) =
|
||||
)
|
||||
&& let (Ok(video_modified), Ok(playlist_modified)) =
|
||||
(video_meta.modified(), playlist_meta.modified())
|
||||
{
|
||||
return video_modified > playlist_modified;
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't determine, assume it needs generation
|
||||
true
|
||||
@@ -1276,7 +1291,7 @@ fn process_new_files(
|
||||
let needs_thumbnail = !thumb_path.exists();
|
||||
|
||||
// Check if EXIF data exists (for supported files)
|
||||
let needs_exif = if exif::supports_exif(&file_path) {
|
||||
let needs_exif = if exif::supports_exif(file_path) {
|
||||
!existing_exif_paths.contains_key(relative_path)
|
||||
} else {
|
||||
false
|
||||
@@ -1349,16 +1364,14 @@ fn process_new_files(
|
||||
let mut videos_needing_playlists = Vec::new();
|
||||
|
||||
for (file_path, _relative_path) in &files {
|
||||
if is_video_file(&file_path) {
|
||||
if is_video_file(file_path) {
|
||||
// Construct expected playlist path
|
||||
let playlist_filename = format!(
|
||||
"{}.m3u8",
|
||||
file_path.file_name().unwrap().to_string_lossy()
|
||||
);
|
||||
let playlist_filename =
|
||||
format!("{}.m3u8", file_path.file_name().unwrap().to_string_lossy());
|
||||
let playlist_path = Path::new(&video_path_base).join(&playlist_filename);
|
||||
|
||||
// Check if playlist needs (re)generation
|
||||
if playlist_needs_generation(&file_path, &playlist_path) {
|
||||
if playlist_needs_generation(file_path, &playlist_path) {
|
||||
videos_needing_playlists.push(file_path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,10 +384,14 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
||||
let use_copy = is_h264 && !has_rotation;
|
||||
|
||||
if has_rotation {
|
||||
info!("Video {} has rotation metadata ({}°), transcoding to apply rotation", video_file, rotation);
|
||||
span.add_event("Transcoding due to rotation", vec![
|
||||
KeyValue::new("rotation_degrees", rotation as i64)
|
||||
]);
|
||||
info!(
|
||||
"Video {} has rotation metadata ({}°), transcoding to apply rotation",
|
||||
video_file, rotation
|
||||
);
|
||||
span.add_event(
|
||||
"Transcoding due to rotation",
|
||||
vec![KeyValue::new("rotation_degrees", rotation as i64)],
|
||||
);
|
||||
} else if use_copy {
|
||||
info!("Video {} is already h264, using stream copy", video_file);
|
||||
span.add_event("Using stream copy (h264 detected)", vec![]);
|
||||
|
||||
Reference in New Issue
Block a user