pinakes-core: improve media management features; various configuration improvements

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2d1f04f13970d21c36067f30bc04a9176a6a6964
This commit is contained in:
raf 2026-02-05 00:54:10 +03:00
commit e02c15490e
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
31 changed files with 1167 additions and 197 deletions

View file

@ -102,6 +102,8 @@ pub fn create_router_with_tls(
.route("/media/{media_id}/tags", get(routes::tags::get_media_tags))
// Books API
.nest("/books", routes::books::routes())
// Photos API
.nest("/photos", routes::photos::routes())
.route("/tags", get(routes::tags::list_tags))
.route("/tags/{id}", get(routes::tags::get_tag))
.route("/collections", get(routes::collections::list_collections))

View file

@ -23,6 +23,15 @@ pub struct MediaResponse {
pub description: Option<String>,
pub has_thumbnail: bool,
pub custom_fields: HashMap<String, CustomFieldResponse>,
// Photo-specific metadata
pub date_taken: Option<DateTime<Utc>>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub camera_make: Option<String>,
pub camera_model: Option<String>,
pub rating: Option<i32>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@ -509,6 +518,15 @@ impl From<pinakes_core::model::MediaItem> for MediaResponse {
)
})
.collect(),
// Photo-specific metadata
date_taken: item.date_taken,
latitude: item.latitude,
longitude: item.longitude,
camera_make: item.camera_make,
camera_model: item.camera_model,
rating: item.rating,
created_at: item.created_at,
updated_at: item.updated_at,
}

View file

@ -90,9 +90,17 @@ async fn main() -> Result<()> {
}
let config_path = resolve_config_path(cli.config.as_deref());
info!(path = %config_path.display(), "loading configuration");
let mut config = Config::load_or_default(&config_path)?;
let mut config = if config_path.exists() {
info!(path = %config_path.display(), "loading configuration from file");
Config::from_file(&config_path)?
} else {
info!(
"using default configuration (no config file found at {})",
config_path.display()
);
Config::default()
};
config.ensure_dirs()?;
config
.validate()

View file

@ -12,6 +12,7 @@ pub mod health;
pub mod integrity;
pub mod jobs;
pub mod media;
pub mod photos;
pub mod playlists;
pub mod plugins;
pub mod saved_searches;

View file

@ -0,0 +1,189 @@
use axum::{
Json, Router,
extract::{Query, State},
response::IntoResponse,
routing::get,
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
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>,
}
/// 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
let all_media = state
.storage
.list_media(&pinakes_core::model::Pagination {
offset: 0,
limit: 10000, // TODO: Make this more efficient with streaming
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 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(MediaResponse::from).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))
}