Merge pull request 'feature/gps-map-support' (#48) from feature/gps-map-support into master
Reviewed-on: #48
This commit was merged in pull request #48.
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1797,7 +1797,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "image-api"
|
name = "image-api"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
"actix-cors",
|
"actix-cors",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "image-api"
|
name = "image-api"
|
||||||
version = "0.5.1"
|
version = "0.5.2"
|
||||||
authors = ["Cameron Cordes <cameronc.dev@gmail.com>"]
|
authors = ["Cameron Cordes <cameronc.dev@gmail.com>"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ pub struct ThumbnailRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[allow(dead_code)] // Part of API contract, may be used in future
|
#[allow(dead_code)] // Part of API contract, may be used in future
|
||||||
pub(crate) format: Option<ThumbnailFormat>,
|
pub(crate) format: Option<ThumbnailFormat>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub(crate) shape: Option<ThumbnailShape>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, PartialEq)]
|
#[derive(Debug, Deserialize, PartialEq)]
|
||||||
@@ -196,6 +198,14 @@ pub enum ThumbnailFormat {
|
|||||||
Image,
|
Image,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, PartialEq)]
|
||||||
|
pub enum ThumbnailShape {
|
||||||
|
#[serde(rename = "circle")]
|
||||||
|
Circle,
|
||||||
|
#[serde(rename = "square")]
|
||||||
|
Square,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct LoginRequest {
|
pub struct LoginRequest {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -347,6 +357,20 @@ pub struct GetTagsRequest {
|
|||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct GpsPhotoSummary {
|
||||||
|
pub path: String,
|
||||||
|
pub lat: f64,
|
||||||
|
pub lon: f64,
|
||||||
|
pub date_taken: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct GpsPhotosResponse {
|
||||||
|
pub photos: Vec<GpsPhotoSummary>,
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::Claims;
|
use super::Claims;
|
||||||
|
|||||||
@@ -307,6 +307,15 @@ pub trait ExifDao: Sync + Send {
|
|||||||
limit: Option<i64>,
|
limit: Option<i64>,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<(Vec<String>, i64), DbError>;
|
) -> Result<(Vec<String>, i64), DbError>;
|
||||||
|
|
||||||
|
/// Get all photos with GPS coordinates
|
||||||
|
/// Returns Vec<(file_path, latitude, longitude, date_taken)>
|
||||||
|
fn get_all_with_gps(
|
||||||
|
&mut self,
|
||||||
|
context: &opentelemetry::Context,
|
||||||
|
base_path: &str,
|
||||||
|
recursive: bool,
|
||||||
|
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SqliteExifDao {
|
pub struct SqliteExifDao {
|
||||||
@@ -653,4 +662,72 @@ impl ExifDao for SqliteExifDao {
|
|||||||
})
|
})
|
||||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn get_all_with_gps(
|
||||||
|
&mut self,
|
||||||
|
context: &opentelemetry::Context,
|
||||||
|
base_path: &str,
|
||||||
|
recursive: bool,
|
||||||
|
) -> Result<Vec<(String, f64, f64, Option<i64>)>, DbError> {
|
||||||
|
trace_db_call(context, "query", "get_all_with_gps", |span| {
|
||||||
|
use opentelemetry::KeyValue;
|
||||||
|
use opentelemetry::trace::Span;
|
||||||
|
use schema::image_exif::dsl::*;
|
||||||
|
|
||||||
|
span.set_attributes(vec![
|
||||||
|
KeyValue::new("base_path", base_path.to_string()),
|
||||||
|
KeyValue::new("recursive", recursive.to_string()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let connection = &mut *self.connection.lock().unwrap();
|
||||||
|
|
||||||
|
// Query all photos with non-null GPS coordinates
|
||||||
|
let mut query = image_exif
|
||||||
|
.filter(gps_latitude.is_not_null().and(gps_longitude.is_not_null()))
|
||||||
|
.into_boxed();
|
||||||
|
|
||||||
|
// Apply path filtering
|
||||||
|
// If base_path is empty or "/", return all GPS photos (no filter)
|
||||||
|
// Otherwise filter by path prefix
|
||||||
|
if !base_path.is_empty() && base_path != "/" {
|
||||||
|
// Match base path as prefix (with wildcard)
|
||||||
|
query = query.filter(file_path.like(format!("{}%", base_path)));
|
||||||
|
|
||||||
|
span.set_attribute(KeyValue::new("path_filter_applied", true));
|
||||||
|
} else {
|
||||||
|
span.set_attribute(KeyValue::new("path_filter_applied", false));
|
||||||
|
span.set_attribute(KeyValue::new("returning_all_gps_photos", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load full ImageExif records
|
||||||
|
let results: Vec<ImageExif> = query
|
||||||
|
.load::<ImageExif>(connection)
|
||||||
|
.map_err(|e| anyhow::anyhow!("GPS query error: {}", e))?;
|
||||||
|
|
||||||
|
// Convert to tuple format (path, lat, lon, date_taken)
|
||||||
|
// Filter out any rows where GPS is still None (shouldn't happen due to filter)
|
||||||
|
// Cast f32 GPS values to f64 for API compatibility
|
||||||
|
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,
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
span.set_attribute(KeyValue::new("result_count", filtered.len() as i64));
|
||||||
|
|
||||||
|
Ok(filtered)
|
||||||
|
})
|
||||||
|
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
151
src/files.rs
151
src/files.rs
@@ -187,10 +187,10 @@ pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
|||||||
_: Claims,
|
_: Claims,
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
req: Query<FilesRequest>,
|
req: Query<FilesRequest>,
|
||||||
app_state: web::Data<AppState>,
|
app_state: Data<AppState>,
|
||||||
file_system: web::Data<FS>,
|
file_system: Data<FS>,
|
||||||
tag_dao: web::Data<Mutex<TagD>>,
|
tag_dao: Data<Mutex<TagD>>,
|
||||||
exif_dao: web::Data<Mutex<Box<dyn ExifDao>>>,
|
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let search_path = &req.path;
|
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> {
|
fn sort(mut files: Vec<FileWithTagCount>, sort_type: SortType) -> Vec<String> {
|
||||||
match sort_type {
|
match sort_type {
|
||||||
SortType::Shuffle => files.shuffle(&mut thread_rng()),
|
SortType::Shuffle => files.shuffle(&mut thread_rng()),
|
||||||
SortType::NameAsc => {
|
NameAsc => {
|
||||||
files.sort_by(|l, r| l.file_name.cmp(&r.file_name));
|
files.sort_by(|l, r| l.file_name.cmp(&r.file_name));
|
||||||
}
|
}
|
||||||
SortType::NameDesc => {
|
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> {
|
fn sort_with_metadata(mut files: Vec<FileWithMetadata>, sort_type: SortType) -> Vec<String> {
|
||||||
match sort_type {
|
match sort_type {
|
||||||
SortType::Shuffle => files.shuffle(&mut thread_rng()),
|
SortType::Shuffle => files.shuffle(&mut thread_rng()),
|
||||||
SortType::NameAsc => {
|
NameAsc => {
|
||||||
files.sort_by(|l, r| l.file_name.cmp(&r.file_name));
|
files.sort_by(|l, r| l.file_name.cmp(&r.file_name));
|
||||||
}
|
}
|
||||||
SortType::NameDesc => {
|
SortType::NameDesc => {
|
||||||
@@ -951,9 +951,117 @@ fn is_path_above_base_dir<P: AsRef<Path> + Debug>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handler for GPS summary endpoint
|
||||||
|
/// Returns lightweight GPS data for all photos with coordinates
|
||||||
|
pub async fn get_gps_summary(
|
||||||
|
_: Claims,
|
||||||
|
request: HttpRequest,
|
||||||
|
req: Query<FilesRequest>,
|
||||||
|
exif_dao: Data<Mutex<Box<dyn ExifDao>>>,
|
||||||
|
) -> Result<HttpResponse, actix_web::Error> {
|
||||||
|
use crate::data::{GpsPhotoSummary, GpsPhotosResponse};
|
||||||
|
|
||||||
|
let parent_cx = extract_context_from_request(&request);
|
||||||
|
let tracer = global_tracer();
|
||||||
|
let mut span = tracer
|
||||||
|
.span_builder("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(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let cx = opentelemetry::Context::current_with_span(span);
|
||||||
|
|
||||||
|
// The database stores relative paths, so we use the path as-is
|
||||||
|
// Normalize empty path or "/" to return all GPS photos
|
||||||
|
let requested_path = if req.path.is_empty() || req.path == "/" {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
// Just do basic validation to prevent path traversal
|
||||||
|
if req.path.contains("..") {
|
||||||
|
warn!("Path traversal attempt: {}", req.path);
|
||||||
|
cx.span().set_status(Status::error("Invalid path"));
|
||||||
|
return Ok(HttpResponse::Forbidden().json(serde_json::json!({
|
||||||
|
"error": "Invalid path"
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
req.path.as_str()
|
||||||
|
};
|
||||||
|
|
||||||
|
let recursive = req.recursive.unwrap_or(false);
|
||||||
|
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 mut photos: Vec<GpsPhotoSummary> = gps_data
|
||||||
|
.into_iter()
|
||||||
|
.map(|(path, lat, lon, date_taken)| GpsPhotoSummary {
|
||||||
|
path,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
date_taken,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
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>(
|
pub async fn move_file<FS: FileSystemAccess>(
|
||||||
_: Claims,
|
_: Claims,
|
||||||
file_system: web::Data<FS>,
|
file_system: Data<FS>,
|
||||||
app_state: Data<AppState>,
|
app_state: Data<AppState>,
|
||||||
request: web::Json<MoveFileRequest>,
|
request: web::Json<MoveFileRequest>,
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
@@ -1153,7 +1261,7 @@ mod tests {
|
|||||||
&mut self,
|
&mut self,
|
||||||
_context: &opentelemetry::Context,
|
_context: &opentelemetry::Context,
|
||||||
_: &str,
|
_: &str,
|
||||||
) -> Result<Option<crate::database::models::ImageExif>, crate::database::DbError> {
|
) -> Result<Option<crate::database::models::ImageExif>, DbError> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1161,7 +1269,7 @@ mod tests {
|
|||||||
&mut self,
|
&mut self,
|
||||||
_context: &opentelemetry::Context,
|
_context: &opentelemetry::Context,
|
||||||
data: crate::database::models::InsertImageExif,
|
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
|
// Return a dummy ImageExif for tests
|
||||||
Ok(crate::database::models::ImageExif {
|
Ok(crate::database::models::ImageExif {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -1189,14 +1297,14 @@ mod tests {
|
|||||||
&mut self,
|
&mut self,
|
||||||
_context: &opentelemetry::Context,
|
_context: &opentelemetry::Context,
|
||||||
_: &str,
|
_: &str,
|
||||||
) -> Result<(), crate::database::DbError> {
|
) -> Result<(), DbError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_all_with_date_taken(
|
fn get_all_with_date_taken(
|
||||||
&mut self,
|
&mut self,
|
||||||
_context: &opentelemetry::Context,
|
_context: &opentelemetry::Context,
|
||||||
) -> Result<Vec<(String, i64)>, crate::database::DbError> {
|
) -> Result<Vec<(String, i64)>, DbError> {
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,7 +1312,7 @@ mod tests {
|
|||||||
&mut self,
|
&mut self,
|
||||||
_context: &opentelemetry::Context,
|
_context: &opentelemetry::Context,
|
||||||
_: &[String],
|
_: &[String],
|
||||||
) -> Result<Vec<crate::database::models::ImageExif>, crate::database::DbError> {
|
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1217,14 +1325,14 @@ mod tests {
|
|||||||
_: Option<(f64, f64, f64, f64)>,
|
_: Option<(f64, f64, f64, f64)>,
|
||||||
_: Option<i64>,
|
_: Option<i64>,
|
||||||
_: Option<i64>,
|
_: Option<i64>,
|
||||||
) -> Result<Vec<crate::database::models::ImageExif>, crate::database::DbError> {
|
) -> Result<Vec<crate::database::models::ImageExif>, DbError> {
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_camera_makes(
|
fn get_camera_makes(
|
||||||
&mut self,
|
&mut self,
|
||||||
_context: &opentelemetry::Context,
|
_context: &opentelemetry::Context,
|
||||||
) -> Result<Vec<(String, i64)>, crate::database::DbError> {
|
) -> Result<Vec<(String, i64)>, DbError> {
|
||||||
Ok(Vec::new())
|
Ok(Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1256,6 +1364,15 @@ mod tests {
|
|||||||
let count = file_paths.len() as i64;
|
let count = file_paths.len() as i64;
|
||||||
Ok((file_paths.to_vec(), count))
|
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> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mod api {
|
mod api {
|
||||||
@@ -1355,7 +1472,7 @@ mod tests {
|
|||||||
Data::new(RealFileSystem::new(temp_dir.to_str().unwrap().to_string())),
|
Data::new(RealFileSystem::new(temp_dir.to_str().unwrap().to_string())),
|
||||||
Data::new(Mutex::new(SqliteTagDao::default())),
|
Data::new(Mutex::new(SqliteTagDao::default())),
|
||||||
Data::new(Mutex::new(
|
Data::new(Mutex::new(
|
||||||
Box::new(MockExifDao) as Box<dyn crate::database::ExifDao>
|
Box::new(MockExifDao) as Box<dyn ExifDao>
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1402,7 +1519,7 @@ mod tests {
|
|||||||
Data::new(FakeFileSystem::new(HashMap::new())),
|
Data::new(FakeFileSystem::new(HashMap::new())),
|
||||||
Data::new(Mutex::new(tag_dao)),
|
Data::new(Mutex::new(tag_dao)),
|
||||||
Data::new(Mutex::new(
|
Data::new(Mutex::new(
|
||||||
Box::new(MockExifDao) as Box<dyn crate::database::ExifDao>
|
Box::new(MockExifDao) as Box<dyn ExifDao>
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1465,7 +1582,7 @@ mod tests {
|
|||||||
Data::new(FakeFileSystem::new(HashMap::new())),
|
Data::new(FakeFileSystem::new(HashMap::new())),
|
||||||
Data::new(Mutex::new(tag_dao)),
|
Data::new(Mutex::new(tag_dao)),
|
||||||
Data::new(Mutex::new(
|
Data::new(Mutex::new(
|
||||||
Box::new(MockExifDao) as Box<dyn crate::database::ExifDao>
|
Box::new(MockExifDao) as Box<dyn ExifDao>
|
||||||
)),
|
)),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
136
src/main.rs
136
src/main.rs
@@ -116,6 +116,26 @@ async fn get_image(
|
|||||||
thumb_path.set_extension("gif");
|
thumb_path.set_extension("gif");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle circular thumbnail request
|
||||||
|
if req.shape == Some(ThumbnailShape::Circle) {
|
||||||
|
match create_circular_thumbnail(&thumb_path, thumbs).await {
|
||||||
|
Ok(circular_path) => {
|
||||||
|
if let Ok(file) = NamedFile::open(&circular_path) {
|
||||||
|
span.set_status(Status::Ok);
|
||||||
|
return file
|
||||||
|
.use_etag(true)
|
||||||
|
.use_last_modified(true)
|
||||||
|
.prefer_utf8(true)
|
||||||
|
.into_response(&request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to create circular thumbnail: {:?}", e);
|
||||||
|
// Fall through to serve square thumbnail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
trace!("Thumbnail path: {:?}", thumb_path);
|
trace!("Thumbnail path: {:?}", thumb_path);
|
||||||
if let Ok(file) = NamedFile::open(&thumb_path) {
|
if let Ok(file) = NamedFile::open(&thumb_path) {
|
||||||
span.set_status(Status::Ok);
|
span.set_status(Status::Ok);
|
||||||
@@ -153,6 +173,68 @@ fn is_video_file(path: &Path) -> bool {
|
|||||||
file_types::is_video_file(path)
|
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::{GenericImageView, ImageBuffer, Rgba};
|
||||||
|
|
||||||
|
// Create circular thumbnails directory
|
||||||
|
let circular_dir = Path::new(thumbs_dir).join("_circular");
|
||||||
|
|
||||||
|
// Get relative path from thumbs_dir to create same structure
|
||||||
|
let relative_to_thumbs = thumb_path.strip_prefix(thumbs_dir)?;
|
||||||
|
let circular_path = circular_dir.join(relative_to_thumbs).with_extension("png");
|
||||||
|
|
||||||
|
// Check if circular thumbnail already exists
|
||||||
|
if circular_path.exists() {
|
||||||
|
return Ok(circular_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parent directory if needed
|
||||||
|
if let Some(parent) = circular_path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the square thumbnail
|
||||||
|
let img = image::open(thumb_path)?;
|
||||||
|
let (width, height) = img.dimensions();
|
||||||
|
|
||||||
|
// Fixed output size for consistency
|
||||||
|
let output_size = 80u32;
|
||||||
|
let radius = output_size as f32 / 2.0;
|
||||||
|
|
||||||
|
// Calculate crop area to get square center of original image
|
||||||
|
let crop_size = width.min(height);
|
||||||
|
let crop_x = (width - crop_size) / 2;
|
||||||
|
let crop_y = (height - crop_size) / 2;
|
||||||
|
|
||||||
|
// Create a new RGBA image with transparency
|
||||||
|
let output = ImageBuffer::from_fn(output_size, output_size, |x, y| {
|
||||||
|
let dx = x as f32 - radius;
|
||||||
|
let dy = y as f32 - radius;
|
||||||
|
let distance = (dx * dx + dy * dy).sqrt();
|
||||||
|
|
||||||
|
if distance <= radius {
|
||||||
|
// Inside circle - map to cropped source area
|
||||||
|
// Scale from output coordinates to crop coordinates
|
||||||
|
let scale = crop_size as f32 / output_size as f32;
|
||||||
|
let src_x = crop_x + (x as f32 * scale) as u32;
|
||||||
|
let src_y = crop_y + (y as f32 * scale) as u32;
|
||||||
|
let pixel = img.get_pixel(src_x, src_y);
|
||||||
|
Rgba([pixel[0], pixel[1], pixel[2], 255])
|
||||||
|
} else {
|
||||||
|
// Outside circle - transparent
|
||||||
|
Rgba([0, 0, 0, 0])
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save as PNG (supports transparency)
|
||||||
|
output.save(&circular_path)?;
|
||||||
|
|
||||||
|
Ok(circular_path)
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/image/metadata")]
|
#[get("/image/metadata")]
|
||||||
async fn get_file_metadata(
|
async fn get_file_metadata(
|
||||||
_: Claims,
|
_: Claims,
|
||||||
@@ -853,6 +935,10 @@ fn main() -> std::io::Result<()> {
|
|||||||
web::resource("/photos")
|
web::resource("/photos")
|
||||||
.route(web::get().to(files::list_photos::<SqliteTagDao, RealFileSystem>)),
|
.route(web::get().to(files::list_photos::<SqliteTagDao, RealFileSystem>)),
|
||||||
)
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/photos/gps-summary")
|
||||||
|
.route(web::get().to(files::get_gps_summary)),
|
||||||
|
)
|
||||||
.service(web::resource("/file/move").post(move_file::<RealFileSystem>))
|
.service(web::resource("/file/move").post(move_file::<RealFileSystem>))
|
||||||
.service(get_image)
|
.service(get_image)
|
||||||
.service(upload_image)
|
.service(upload_image)
|
||||||
@@ -957,12 +1043,11 @@ fn cleanup_orphaned_playlists() {
|
|||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.filter(|e| e.file_type().is_file())
|
.filter(|e| e.file_type().is_file())
|
||||||
{
|
{
|
||||||
if let Some(entry_stem) = entry.path().file_stem() {
|
if let Some(entry_stem) = entry.path().file_stem()
|
||||||
if entry_stem == filename && is_video_file(entry.path()) {
|
&& entry_stem == filename && is_video_file(entry.path()) {
|
||||||
video_exists = true;
|
video_exists = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !video_exists {
|
if !video_exists {
|
||||||
@@ -973,7 +1058,11 @@ fn cleanup_orphaned_playlists() {
|
|||||||
|
|
||||||
// Delete the playlist file
|
// Delete the playlist file
|
||||||
if let Err(e) = std::fs::remove_file(&playlist_path) {
|
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;
|
error_count += 1;
|
||||||
} else {
|
} else {
|
||||||
deleted_count += 1;
|
deleted_count += 1;
|
||||||
@@ -988,13 +1077,14 @@ fn cleanup_orphaned_playlists() {
|
|||||||
.filter(|e| e.file_type().is_file())
|
.filter(|e| e.file_type().is_file())
|
||||||
{
|
{
|
||||||
let entry_path = entry.path();
|
let entry_path = entry.path();
|
||||||
if let Some(ext) = entry_path.extension() {
|
if let Some(ext) = entry_path.extension()
|
||||||
if ext.eq_ignore_ascii_case("ts") {
|
&& ext.eq_ignore_ascii_case("ts") {
|
||||||
// Check if this .ts file belongs to our playlist
|
// Check if this .ts file belongs to our playlist
|
||||||
if let Some(ts_stem) = entry_path.file_stem() {
|
if let Some(ts_stem) = entry_path.file_stem() {
|
||||||
let ts_name = ts_stem.to_string_lossy();
|
let ts_name = ts_stem.to_string_lossy();
|
||||||
if ts_name.starts_with(&*video_filename) {
|
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!(
|
debug!(
|
||||||
"Failed to delete segment {}: {}",
|
"Failed to delete segment {}: {}",
|
||||||
entry_path.display(),
|
entry_path.display(),
|
||||||
@@ -1009,7 +1099,6 @@ fn cleanup_orphaned_playlists() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1071,7 +1160,12 @@ fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
|
|||||||
|
|
||||||
if is_full_scan {
|
if is_full_scan {
|
||||||
info!("Running full scan (scan #{})", scan_count);
|
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;
|
last_full_scan = now;
|
||||||
} else {
|
} else {
|
||||||
debug!(
|
debug!(
|
||||||
@@ -1082,7 +1176,12 @@ fn watch_files(playlist_manager: Addr<VideoPlaylistManager>) {
|
|||||||
let check_since = last_quick_scan
|
let check_since = last_quick_scan
|
||||||
.checked_sub(Duration::from_secs(10))
|
.checked_sub(Duration::from_secs(10))
|
||||||
.unwrap_or(last_quick_scan);
|
.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;
|
last_quick_scan = now;
|
||||||
@@ -1107,13 +1206,12 @@ fn playlist_needs_generation(video_path: &Path, playlist_path: &Path) -> bool {
|
|||||||
if let (Ok(video_meta), Ok(playlist_meta)) = (
|
if let (Ok(video_meta), Ok(playlist_meta)) = (
|
||||||
std::fs::metadata(video_path),
|
std::fs::metadata(video_path),
|
||||||
std::fs::metadata(playlist_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())
|
(video_meta.modified(), playlist_meta.modified())
|
||||||
{
|
{
|
||||||
return video_modified > playlist_modified;
|
return video_modified > playlist_modified;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If we can't determine, assume it needs generation
|
// If we can't determine, assume it needs generation
|
||||||
true
|
true
|
||||||
@@ -1193,7 +1291,7 @@ fn process_new_files(
|
|||||||
let needs_thumbnail = !thumb_path.exists();
|
let needs_thumbnail = !thumb_path.exists();
|
||||||
|
|
||||||
// Check if EXIF data exists (for supported files)
|
// 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)
|
!existing_exif_paths.contains_key(relative_path)
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
@@ -1266,16 +1364,14 @@ fn process_new_files(
|
|||||||
let mut videos_needing_playlists = Vec::new();
|
let mut videos_needing_playlists = Vec::new();
|
||||||
|
|
||||||
for (file_path, _relative_path) in &files {
|
for (file_path, _relative_path) in &files {
|
||||||
if is_video_file(&file_path) {
|
if is_video_file(file_path) {
|
||||||
// Construct expected playlist path
|
// Construct expected playlist path
|
||||||
let playlist_filename = format!(
|
let playlist_filename =
|
||||||
"{}.m3u8",
|
format!("{}.m3u8", file_path.file_name().unwrap().to_string_lossy());
|
||||||
file_path.file_name().unwrap().to_string_lossy()
|
|
||||||
);
|
|
||||||
let playlist_path = Path::new(&video_path_base).join(&playlist_filename);
|
let playlist_path = Path::new(&video_path_base).join(&playlist_filename);
|
||||||
|
|
||||||
// Check if playlist needs (re)generation
|
// 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());
|
videos_needing_playlists.push(file_path.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -567,13 +567,13 @@ pub async fn list_memories(
|
|||||||
match span_mode {
|
match span_mode {
|
||||||
// Sort by absolute time for a more 'overview'
|
// Sort by absolute time for a more 'overview'
|
||||||
MemoriesSpan::Month => memories_with_dates.sort_by(|a, b| a.1.cmp(&b.1)),
|
MemoriesSpan::Month => memories_with_dates.sort_by(|a, b| a.1.cmp(&b.1)),
|
||||||
// For week span, sort by day of month, then by full timestamp (oldest first)
|
// For week span, sort by full date + timestamp (chronological)
|
||||||
MemoriesSpan::Week => {
|
MemoriesSpan::Week => {
|
||||||
memories_with_dates.sort_by(|a, b| {
|
memories_with_dates.sort_by(|a, b| {
|
||||||
// First, sort by day of month
|
// First, sort by full date (year, month, day)
|
||||||
let day_cmp = a.1.day().cmp(&b.1.day());
|
let date_cmp = a.1.cmp(&b.1);
|
||||||
if day_cmp != std::cmp::Ordering::Equal {
|
if date_cmp != std::cmp::Ordering::Equal {
|
||||||
return day_cmp;
|
return date_cmp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then sort by full created timestamp (oldest to newest)
|
// Then sort by full created timestamp (oldest to newest)
|
||||||
|
|||||||
@@ -384,10 +384,14 @@ impl Handler<GeneratePlaylistMessage> for PlaylistGenerator {
|
|||||||
let use_copy = is_h264 && !has_rotation;
|
let use_copy = is_h264 && !has_rotation;
|
||||||
|
|
||||||
if has_rotation {
|
if has_rotation {
|
||||||
info!("Video {} has rotation metadata ({}°), transcoding to apply rotation", video_file, rotation);
|
info!(
|
||||||
span.add_event("Transcoding due to rotation", vec![
|
"Video {} has rotation metadata ({}°), transcoding to apply rotation",
|
||||||
KeyValue::new("rotation_degrees", rotation as i64)
|
video_file, rotation
|
||||||
]);
|
);
|
||||||
|
span.add_event(
|
||||||
|
"Transcoding due to rotation",
|
||||||
|
vec![KeyValue::new("rotation_degrees", rotation as i64)],
|
||||||
|
);
|
||||||
} else if use_copy {
|
} else if use_copy {
|
||||||
info!("Video {} is already h264, using stream copy", video_file);
|
info!("Video {} is already h264, using stream copy", video_file);
|
||||||
span.add_event("Using stream copy (h264 detected)", vec![]);
|
span.add_event("Using stream copy (h264 detected)", vec![]);
|
||||||
|
|||||||
Reference in New Issue
Block a user