Merge pull request #485 from AtalayaLabs/claude/zealous-faraday-58s1at
feat: Photos evolution — Places (map) & People (faces) + gallery polish
This commit is contained in:
@@ -16,6 +16,7 @@ pub mod grant_handler;
|
||||
pub mod i18n_handler;
|
||||
pub mod magic_link_handler;
|
||||
pub mod music_handler;
|
||||
pub mod people_handler;
|
||||
pub mod photos_handler;
|
||||
pub mod recent_handler;
|
||||
pub mod search_handler;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! HTTP handlers for the People (faces) feature.
|
||||
//!
|
||||
//! Every route is mounted only when `OXICLOUD_ENABLE_FACES` is on (the service
|
||||
//! is present in `AppState`); each handler is also defensive. All work is
|
||||
//! strictly caller-scoped by `PeopleService` (the repository filters by user).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
fn disabled() -> Response {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "People feature is disabled" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn bad_id() -> Response {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "invalid id" })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
/// GET /api/people — identity clusters for the caller.
|
||||
pub async fn list_people(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
match svc.list_people(auth_user.id).await {
|
||||
Ok(people) => Json(people).into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/people/{id}/photos — file ids of a person's photos.
|
||||
pub async fn person_photos(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
let Ok(person_id) = Uuid::parse_str(&id) else {
|
||||
return bad_id();
|
||||
};
|
||||
match svc.person_photos(auth_user.id, person_id).await {
|
||||
Ok(files) => Json(files).into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RenameBody {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// PATCH /api/people/{id} — name (or clear the name of) a person.
|
||||
pub async fn rename_person(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<RenameBody>,
|
||||
) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
let Ok(person_id) = Uuid::parse_str(&id) else {
|
||||
return bad_id();
|
||||
};
|
||||
match svc.rename_person(auth_user.id, person_id, body.name).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HideBody {
|
||||
pub hidden: bool,
|
||||
}
|
||||
|
||||
/// POST /api/people/{id}/hide — hide/unhide a person from the grid.
|
||||
pub async fn hide_person(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(id): Path<String>,
|
||||
Json(body): Json<HideBody>,
|
||||
) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
let Ok(person_id) = Uuid::parse_str(&id) else {
|
||||
return bad_id();
|
||||
};
|
||||
match svc.set_hidden(auth_user.id, person_id, body.hidden).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct MergeBody {
|
||||
pub into: String,
|
||||
pub from: String,
|
||||
}
|
||||
|
||||
/// POST /api/people/merge — merge `from` into `into`.
|
||||
pub async fn merge_people(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Json(body): Json<MergeBody>,
|
||||
) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
let (Ok(into), Ok(from)) = (Uuid::parse_str(&body.into), Uuid::parse_str(&body.from)) else {
|
||||
return bad_id();
|
||||
};
|
||||
match svc.merge(auth_user.id, into, from).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /api/people/recluster — re-run identity clustering for the caller.
|
||||
pub async fn recluster(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
match svc.recluster(auth_user.id).await {
|
||||
Ok(n) => Json(serde_json::json!({ "persons_created": n })).into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// DELETE /api/people/data — erase all of the caller's face data.
|
||||
pub async fn delete_all(State(state): State<Arc<AppState>>, auth_user: AuthUser) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
match svc.delete_all(auth_user.id).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/people/faces/{file_id} — face boxes within a photo (lightbox tags).
|
||||
pub async fn faces_for_file(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Path(file_id): Path<String>,
|
||||
) -> Response {
|
||||
let Some(svc) = state.people_service.as_ref() else {
|
||||
return disabled();
|
||||
};
|
||||
let Ok(fid) = Uuid::parse_str(&file_id) else {
|
||||
return bad_id();
|
||||
};
|
||||
match svc.faces_for_file(auth_user.id, fid).await {
|
||||
Ok(boxes) => Json(boxes).into_response(),
|
||||
Err(e) => AppError::from(e).into_response(),
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::geo_dto::GeoBounds;
|
||||
use crate::common::di::AppState;
|
||||
use crate::interfaces::middleware::auth::AuthUser;
|
||||
|
||||
@@ -21,6 +22,20 @@ pub struct PhotosQueryParams {
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// Photos-timeline item: a `FileDto` plus the image's original pixel
|
||||
/// dimensions (from EXIF/metadata), flattened into the same JSON shape so
|
||||
/// the gallery can lay tiles out at their true aspect ratio without a
|
||||
/// second per-file metadata round-trip.
|
||||
#[derive(Serialize)]
|
||||
struct PhotoDto {
|
||||
#[serde(flatten)]
|
||||
file: FileDto,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
width: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
height: Option<u32>,
|
||||
}
|
||||
|
||||
/// Lists all image/video files for the authenticated user, sorted by
|
||||
/// capture date (EXIF DateTimeOriginal) falling back to upload date.
|
||||
///
|
||||
@@ -55,17 +70,22 @@ pub async fn list_photos(
|
||||
.list_media_files(user_id, params.before, limit)
|
||||
.await
|
||||
{
|
||||
Ok((files, sort_dates)) => {
|
||||
Ok((files, sort_dates, dims)) => {
|
||||
info!("Photos: returned {} media files for user", files.len());
|
||||
|
||||
// Convert to DTOs with sort_date populated
|
||||
let dtos: Vec<FileDto> = files
|
||||
// Convert to DTOs with sort_date + pixel dimensions populated.
|
||||
let dtos: Vec<PhotoDto> = files
|
||||
.into_iter()
|
||||
.zip(sort_dates.iter())
|
||||
.map(|(file, &sd)| {
|
||||
.zip(dims.iter())
|
||||
.map(|((file, &sd), &(w, h))| {
|
||||
let mut dto = FileDto::from(file);
|
||||
dto.sort_date = Some(sd as u64);
|
||||
dto
|
||||
PhotoDto {
|
||||
file: dto,
|
||||
width: w.map(|v| v.max(0) as u32),
|
||||
height: h.map(|v| v.max(0) as u32),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -91,3 +111,76 @@ pub async fn list_photos(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Query parameters for the photos map (clustered) endpoint.
|
||||
#[derive(Deserialize)]
|
||||
pub struct GeoQueryParams {
|
||||
/// Bounding box as `west,south,east,north` (decimal degrees).
|
||||
pub bbox: String,
|
||||
/// Slippy-map zoom level (0–20); controls cluster granularity.
|
||||
pub zoom: Option<u8>,
|
||||
}
|
||||
|
||||
/// Lists the caller's geotagged photos aggregated into map clusters within a
|
||||
/// bounding box. Gated on `OXICLOUD_ENABLE_PLACES` (the route is only mounted
|
||||
/// when the Places service is present).
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/photos/geo",
|
||||
params(
|
||||
("bbox" = String, Query, description = "Bounding box 'west,south,east,north' (decimal degrees)"),
|
||||
("zoom" = Option<u8>, Query, description = "Map zoom level (0-20), controls cluster size")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Geotagged photos aggregated into map clusters"),
|
||||
(status = 400, description = "Invalid bounding box"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "photos"
|
||||
)]
|
||||
pub async fn list_photos_geo(
|
||||
State(state): State<Arc<AppState>>,
|
||||
auth_user: AuthUser,
|
||||
Query(params): Query<GeoQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let Some(places) = state.places_service.as_ref() else {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({ "error": "Places feature is disabled" })),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
|
||||
let coords: Vec<f64> = params
|
||||
.bbox
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse::<f64>().ok())
|
||||
.collect();
|
||||
if coords.len() != 4 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": "bbox must be 'west,south,east,north'" })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let bounds = GeoBounds {
|
||||
west: coords[0],
|
||||
south: coords[1],
|
||||
east: coords[2],
|
||||
north: coords[3],
|
||||
};
|
||||
let zoom = params.zoom.unwrap_or(3);
|
||||
|
||||
match places.clusters(auth_user.id, bounds, zoom).await {
|
||||
Ok(clusters) => Json(clusters).into_response(),
|
||||
Err(err) => {
|
||||
error!("Error listing photo geo clusters: {}", err);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(serde_json::json!({ "error": format!("{}", err) })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user