Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9f113e6402030c46ad97f636985b5d6c6a6a6964
204 lines
4.9 KiB
Rust
204 lines
4.9 KiB
Rust
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<i32>,
|
|
pub month: Option<u32>,
|
|
#[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<String>,
|
|
pub items: Vec<MediaResponse>,
|
|
}
|
|
|
|
/// 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<String>,
|
|
pub date_taken: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
/// Get timeline of photos grouped by date
|
|
pub async fn get_timeline(
|
|
State(state): State<AppState>,
|
|
Query(query): Query<TimelineQuery>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
// 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<String, Vec<pinakes_core::model::MediaItem>> =
|
|
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<TimelineGroup> = 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<MediaResponse> = 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<AppState>,
|
|
Query(query): Query<MapQuery>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
// 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<MapMarker> = 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<AppState> {
|
|
Router::new()
|
|
.route("/timeline", get(get_timeline))
|
|
.route("/map", get(get_map_photos))
|
|
}
|