use std::collections::HashMap; 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)] 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)] pub struct MapMarker { pub id: String, pub latitude: f64, pub longitude: f64, pub thumbnail_url: Option, pub date_taken: Option>, } /// 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: HashMap> = HashMap::new(); 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)) } /// Get photos in a bounding box for map view pub async fn get_map_photos( State(state): State, Query(query): Query, ) -> Result { // 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)) }