use axum::{ Json, Router, extract::{Query, State}, response::IntoResponse, routing::get, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::{dto::MediaResponse, error::ApiError, state::AppState}; /// Timeline grouping mode #[derive(Debug, Deserialize, Default)] #[serde(rename_all = "lowercase")] pub enum GroupBy { #[default] Day, Month, Year, } /// Timeline query parameters #[derive(Debug, Deserialize)] pub struct TimelineQuery { #[serde(default)] pub group_by: GroupBy, pub year: Option, pub month: Option, #[serde(default = "default_timeline_limit")] pub limit: u64, } const fn default_timeline_limit() -> u64 { 10000 } /// Timeline group response #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct TimelineGroup { pub date: String, pub count: usize, pub cover_id: Option, pub items: Vec, } /// Map query parameters #[derive(Debug, Deserialize)] pub struct MapQuery { pub lat1: f64, pub lon1: f64, pub lat2: f64, pub lon2: f64, } /// Map marker response #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct MapMarker { pub id: String, pub latitude: f64, pub longitude: f64, pub thumbnail_url: Option, pub date_taken: Option>, } #[utoipa::path( get, path = "/api/v1/photos/timeline", tag = "photos", params( ("group_by" = Option, Query, description = "Grouping: day, month, year"), ("year" = Option, Query, description = "Filter by year"), ("month" = Option, Query, description = "Filter by month"), ("limit" = Option, Query, description = "Max items (default 10000)"), ), responses( (status = 200, description = "Photo timeline groups", body = Vec), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] /// Get timeline of photos grouped by date pub async fn get_timeline( State(state): State, Query(query): Query, ) -> Result { // Query photos with date_taken (limit is configurable, defaults to 10000) let all_media = state .storage .list_media(&pinakes_core::model::Pagination { offset: 0, limit: query.limit.min(50000), // Cap at 50000 for safety sort: Some("date_taken DESC".to_string()), }) .await?; // Filter to only photos with date_taken let photos: Vec<_> = all_media .into_iter() .filter(|item| { item.date_taken.is_some() && item.media_type.category() == pinakes_core::media_type::MediaCategory::Image }) .collect(); // Group by the requested period let mut groups: rustc_hash::FxHashMap< String, Vec, > = rustc_hash::FxHashMap::default(); for photo in photos { if let Some(date_taken) = photo.date_taken { use chrono::Datelike; // Filter by year/month if specified if let Some(y) = query.year && date_taken.year() != y { continue; } if let Some(m) = query.month && date_taken.month() != m { continue; } let key = match query.group_by { GroupBy::Day => date_taken.format("%Y-%m-%d").to_string(), GroupBy::Month => date_taken.format("%Y-%m").to_string(), GroupBy::Year => date_taken.format("%Y").to_string(), }; groups.entry(key).or_default().push(photo); } } // Convert to response format let roots = state.config.read().await.directories.roots.clone(); let mut timeline: Vec = groups .into_iter() .map(|(date, items)| { let cover_id = items.first().map(|i| i.id.0.to_string()); let count = items.len(); let items: Vec = items .into_iter() .map(|item| MediaResponse::new(item, &roots)) .collect(); TimelineGroup { date, count, cover_id, items, } }) .collect(); // Sort by date descending timeline.sort_by(|a, b| b.date.cmp(&a.date)); Ok(Json(timeline)) } #[utoipa::path( get, path = "/api/v1/photos/map", tag = "photos", params( ("lat1" = f64, Query, description = "Bounding box latitude 1"), ("lon1" = f64, Query, description = "Bounding box longitude 1"), ("lat2" = f64, Query, description = "Bounding box latitude 2"), ("lon2" = f64, Query, description = "Bounding box longitude 2"), ), responses( (status = 200, description = "Map markers", body = Vec), (status = 400, description = "Bad request"), (status = 401, description = "Unauthorized"), (status = 500, description = "Internal server error"), ), security(("bearer_auth" = [])) )] /// Get photos in a bounding box for map view pub async fn get_map_photos( State(state): State, Query(query): Query, ) -> Result { let valid_lat = |v: f64| v.is_finite() && (-90.0..=90.0).contains(&v); let valid_lon = |v: f64| v.is_finite() && (-180.0..=180.0).contains(&v); if !valid_lat(query.lat1) || !valid_lat(query.lat2) { return Err(ApiError::bad_request("latitude must be in [-90, 90]")); } if !valid_lon(query.lon1) || !valid_lon(query.lon2) { return Err(ApiError::bad_request("longitude must be in [-180, 180]")); } // Validate bounding box let min_lat = query.lat1.min(query.lat2); let max_lat = query.lat1.max(query.lat2); let min_lon = query.lon1.min(query.lon2); let max_lon = query.lon1.max(query.lon2); // Query all media (we'll filter in-memory for now - could optimize with DB // query) let all_media = state .storage .list_media(&pinakes_core::model::Pagination { offset: 0, limit: 10000, sort: None, }) .await?; // Filter to photos with GPS coordinates in the bounding box let markers: Vec = all_media .into_iter() .filter_map(|item| { if let (Some(lat), Some(lon)) = (item.latitude, item.longitude) && lat >= min_lat && lat <= max_lat && lon >= min_lon && lon <= max_lon { return Some(MapMarker { id: item.id.0.to_string(), latitude: lat, longitude: lon, thumbnail_url: item .thumbnail_path .map(|_p| format!("/api/v1/media/{}/thumbnail", item.id.0)), date_taken: item.date_taken, }); } None }) .collect(); Ok(Json(markers)) } /// Photo routes pub fn routes() -> Router { Router::new() .route("/timeline", get(get_timeline)) .route("/map", get(get_map_photos)) }