pinakes/crates/pinakes-server/src/routes/photos.rs
NotAShelf 3aa1503441
pinakes-server: relativize media paths against configured root directories
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9f113e6402030c46ad97f636985b5d6c6a6a6964
2026-03-11 21:30:48 +03:00

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))
}