pinakes-core: improve media management features; various configuration improvements
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I2d1f04f13970d21c36067f30bc04a9176a6a6964
This commit is contained in:
parent
cfdc3d0622
commit
e02c15490e
31 changed files with 1167 additions and 197 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
189
crates/pinakes-server/src/routes/photos.rs
Normal file
189
crates/pinakes-server/src/routes/photos.rs
Normal 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))
|
||||
}
|
||||
|
|
@ -11,9 +11,9 @@ use tower::ServiceExt;
|
|||
use pinakes_core::cache::CacheLayer;
|
||||
use pinakes_core::config::{
|
||||
AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig,
|
||||
JobsConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, StorageBackendType,
|
||||
StorageConfig, ThumbnailConfig, TlsConfig, TranscodingConfig, UiConfig, UserAccount, UserRole,
|
||||
WebhookConfig,
|
||||
JobsConfig, PhotoConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig,
|
||||
StorageBackendType, StorageConfig, ThumbnailConfig, TlsConfig, TranscodingConfig, UiConfig,
|
||||
UserAccount, UserRole, WebhookConfig,
|
||||
};
|
||||
use pinakes_core::jobs::JobQueue;
|
||||
use pinakes_core::storage::StorageBackend;
|
||||
|
|
@ -126,6 +126,7 @@ fn default_config() -> Config {
|
|||
enrichment: EnrichmentConfig::default(),
|
||||
cloud: CloudConfig::default(),
|
||||
analytics: AnalyticsConfig::default(),
|
||||
photos: PhotoConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ use tower::ServiceExt;
|
|||
use pinakes_core::cache::CacheLayer;
|
||||
use pinakes_core::config::{
|
||||
AccountsConfig, AnalyticsConfig, CloudConfig, Config, DirectoryConfig, EnrichmentConfig,
|
||||
JobsConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig, StorageBackendType,
|
||||
StorageConfig, ThumbnailConfig, TlsConfig, TranscodingConfig, UiConfig, WebhookConfig,
|
||||
JobsConfig, PhotoConfig, PluginsConfig, ScanningConfig, ServerConfig, SqliteConfig,
|
||||
StorageBackendType, StorageConfig, ThumbnailConfig, TlsConfig, TranscodingConfig, UiConfig,
|
||||
WebhookConfig,
|
||||
};
|
||||
use pinakes_core::jobs::JobQueue;
|
||||
use pinakes_core::plugin::PluginManager;
|
||||
|
|
@ -91,6 +92,7 @@ async fn setup_app_with_plugins() -> (axum::Router, Arc<PluginManager>, tempfile
|
|||
enrichment: EnrichmentConfig::default(),
|
||||
cloud: CloudConfig::default(),
|
||||
analytics: AnalyticsConfig::default(),
|
||||
photos: PhotoConfig::default(),
|
||||
};
|
||||
|
||||
let job_queue = JobQueue::new(1, |_id, _kind, _cancel, _jobs| tokio::spawn(async {}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue