Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I28cf5b7b7cff8e90e123d624d97cf9656a6a6964
247 lines
6.7 KiB
Rust
247 lines
6.7 KiB
Rust
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, utoipa::ToSchema)]
|
|
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, utoipa::ToSchema)]
|
|
pub struct MapMarker {
|
|
pub id: String,
|
|
pub latitude: f64,
|
|
pub longitude: f64,
|
|
pub thumbnail_url: Option<String>,
|
|
pub date_taken: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
#[utoipa::path(
|
|
get,
|
|
path = "/api/v1/photos/timeline",
|
|
tag = "photos",
|
|
params(
|
|
("group_by" = Option<String>, Query, description = "Grouping: day, month, year"),
|
|
("year" = Option<i32>, Query, description = "Filter by year"),
|
|
("month" = Option<u32>, Query, description = "Filter by month"),
|
|
("limit" = Option<u64>, Query, description = "Max items (default 10000)"),
|
|
),
|
|
responses(
|
|
(status = 200, description = "Photo timeline groups", body = Vec<TimelineGroup>),
|
|
(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<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: rustc_hash::FxHashMap<
|
|
String,
|
|
Vec<pinakes_core::model::MediaItem>,
|
|
> = 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<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))
|
|
}
|
|
|
|
#[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<MapMarker>),
|
|
(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<AppState>,
|
|
Query(query): Query<MapQuery>,
|
|
) -> Result<impl IntoResponse, ApiError> {
|
|
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<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))
|
|
}
|