pinakes-server: add utoipa annotations to all routes; fix tests

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I28cf5b7b7cff8e90e123d624d97cf9656a6a6964
This commit is contained in:
raf 2026-03-21 02:17:55 +03:00
commit 9d58927cb4
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
60 changed files with 3493 additions and 242 deletions

View file

@ -32,6 +32,9 @@ rand = { workspace = true }
percent-encoding = { workspace = true } percent-encoding = { workspace = true }
http = { workspace = true } http = { workspace = true }
rustc-hash = { workspace = true } rustc-hash = { workspace = true }
utoipa = { workspace = true }
utoipa-axum = { workspace = true }
utoipa-swagger-ui = { workspace = true }
[lints] [lints]
workspace = true workspace = true

View file

@ -0,0 +1,486 @@
use utoipa::OpenApi;
/// Central OpenAPI document registry.
/// Handler functions and schemas are added here as route modules are annotated.
#[derive(OpenApi)]
#[openapi(
info(
title = "Pinakes API",
version = env!("CARGO_PKG_VERSION"),
description = "Media cataloging and library management API"
),
paths(
// analytics
crate::routes::analytics::get_most_viewed,
crate::routes::analytics::get_recently_viewed,
crate::routes::analytics::record_event,
crate::routes::analytics::get_watch_progress,
crate::routes::analytics::update_watch_progress,
// audit
crate::routes::audit::list_audit,
// auth
crate::routes::auth::login,
crate::routes::auth::logout,
crate::routes::auth::me,
crate::routes::auth::refresh,
crate::routes::auth::revoke_all_sessions,
crate::routes::auth::list_active_sessions,
// backup
crate::routes::backup::create_backup,
// books
crate::routes::books::get_book_metadata,
crate::routes::books::list_books,
crate::routes::books::list_series,
crate::routes::books::get_series_books,
crate::routes::books::list_authors,
crate::routes::books::get_author_books,
crate::routes::books::get_reading_progress,
crate::routes::books::update_reading_progress,
crate::routes::books::get_reading_list,
// collections
crate::routes::collections::create_collection,
crate::routes::collections::list_collections,
crate::routes::collections::get_collection,
crate::routes::collections::delete_collection,
crate::routes::collections::add_member,
crate::routes::collections::remove_member,
crate::routes::collections::get_members,
// config
crate::routes::config::get_config,
crate::routes::config::get_ui_config,
crate::routes::config::update_ui_config,
crate::routes::config::update_scanning_config,
crate::routes::config::add_root,
crate::routes::config::remove_root,
// database
crate::routes::database::database_stats,
crate::routes::database::vacuum_database,
crate::routes::database::clear_database,
// duplicates
crate::routes::duplicates::list_duplicates,
// enrichment
crate::routes::enrichment::trigger_enrichment,
crate::routes::enrichment::get_external_metadata,
crate::routes::enrichment::batch_enrich,
// export
crate::routes::export::trigger_export,
crate::routes::export::trigger_export_with_options,
// health
crate::routes::health::health,
crate::routes::health::liveness,
crate::routes::health::readiness,
crate::routes::health::health_detailed,
// integrity
crate::routes::integrity::trigger_orphan_detection,
crate::routes::integrity::trigger_verify_integrity,
crate::routes::integrity::trigger_cleanup_thumbnails,
crate::routes::integrity::generate_all_thumbnails,
crate::routes::integrity::resolve_orphans,
// jobs
crate::routes::jobs::list_jobs,
crate::routes::jobs::get_job,
crate::routes::jobs::cancel_job,
// media
crate::routes::media::import_media,
crate::routes::media::list_media,
crate::routes::media::get_media,
crate::routes::media::update_media,
crate::routes::media::delete_media,
crate::routes::media::open_media,
crate::routes::media::import_with_options,
crate::routes::media::batch_import,
crate::routes::media::import_directory_endpoint,
crate::routes::media::preview_directory,
crate::routes::media::set_custom_field,
crate::routes::media::delete_custom_field,
crate::routes::media::batch_tag,
crate::routes::media::delete_all_media,
crate::routes::media::batch_delete,
crate::routes::media::batch_add_to_collection,
crate::routes::media::batch_update,
crate::routes::media::get_thumbnail,
crate::routes::media::get_media_count,
crate::routes::media::rename_media,
crate::routes::media::move_media_endpoint,
crate::routes::media::batch_move_media,
crate::routes::media::soft_delete_media,
crate::routes::media::restore_media,
crate::routes::media::list_trash,
crate::routes::media::trash_info,
crate::routes::media::empty_trash,
crate::routes::media::permanent_delete_media,
crate::routes::media::stream_media,
// notes
crate::routes::notes::get_backlinks,
crate::routes::notes::get_outgoing_links,
crate::routes::notes::get_graph,
crate::routes::notes::reindex_links,
crate::routes::notes::resolve_links,
crate::routes::notes::get_unresolved_count,
// photos
crate::routes::photos::get_timeline,
crate::routes::photos::get_map_photos,
// playlists
crate::routes::playlists::create_playlist,
crate::routes::playlists::list_playlists,
crate::routes::playlists::get_playlist,
crate::routes::playlists::update_playlist,
crate::routes::playlists::delete_playlist,
crate::routes::playlists::add_item,
crate::routes::playlists::remove_item,
crate::routes::playlists::list_items,
crate::routes::playlists::reorder_item,
crate::routes::playlists::shuffle_playlist,
// plugins
crate::routes::plugins::list_plugins,
crate::routes::plugins::get_plugin,
crate::routes::plugins::install_plugin,
crate::routes::plugins::uninstall_plugin,
crate::routes::plugins::toggle_plugin,
crate::routes::plugins::list_plugin_ui_pages,
crate::routes::plugins::list_plugin_ui_widgets,
crate::routes::plugins::emit_plugin_event,
crate::routes::plugins::list_plugin_ui_theme_extensions,
crate::routes::plugins::reload_plugin,
// saved_searches
crate::routes::saved_searches::create_saved_search,
crate::routes::saved_searches::list_saved_searches,
crate::routes::saved_searches::delete_saved_search,
// scan
crate::routes::scan::trigger_scan,
crate::routes::scan::scan_status,
// scheduled_tasks
crate::routes::scheduled_tasks::list_scheduled_tasks,
crate::routes::scheduled_tasks::toggle_scheduled_task,
crate::routes::scheduled_tasks::run_scheduled_task_now,
// search
crate::routes::search::search,
crate::routes::search::search_post,
// shares
crate::routes::shares::create_share,
crate::routes::shares::list_outgoing,
crate::routes::shares::list_incoming,
crate::routes::shares::get_share,
crate::routes::shares::update_share,
crate::routes::shares::delete_share,
crate::routes::shares::batch_delete,
crate::routes::shares::access_shared,
crate::routes::shares::get_activity,
crate::routes::shares::get_notifications,
crate::routes::shares::mark_notification_read,
crate::routes::shares::mark_all_read,
// social
crate::routes::social::rate_media,
crate::routes::social::get_media_ratings,
crate::routes::social::add_comment,
crate::routes::social::get_media_comments,
crate::routes::social::add_favorite,
crate::routes::social::remove_favorite,
crate::routes::social::list_favorites,
crate::routes::social::create_share_link,
crate::routes::social::access_shared_media,
// statistics
crate::routes::statistics::library_statistics,
// streaming
crate::routes::streaming::hls_master_playlist,
crate::routes::streaming::hls_variant_playlist,
crate::routes::streaming::hls_segment,
crate::routes::streaming::dash_manifest,
crate::routes::streaming::dash_segment,
// subtitles
crate::routes::subtitles::list_subtitles,
crate::routes::subtitles::add_subtitle,
crate::routes::subtitles::delete_subtitle,
crate::routes::subtitles::get_subtitle_content,
crate::routes::subtitles::update_offset,
// sync
crate::routes::sync::register_device,
crate::routes::sync::list_devices,
crate::routes::sync::get_device,
crate::routes::sync::update_device,
crate::routes::sync::delete_device,
crate::routes::sync::regenerate_token,
crate::routes::sync::get_changes,
crate::routes::sync::report_changes,
crate::routes::sync::acknowledge_changes,
crate::routes::sync::list_conflicts,
crate::routes::sync::resolve_conflict,
crate::routes::sync::create_upload,
crate::routes::sync::upload_chunk,
crate::routes::sync::get_upload_status,
crate::routes::sync::complete_upload,
crate::routes::sync::cancel_upload,
crate::routes::sync::download_file,
// tags
crate::routes::tags::create_tag,
crate::routes::tags::list_tags,
crate::routes::tags::get_tag,
crate::routes::tags::delete_tag,
crate::routes::tags::tag_media,
crate::routes::tags::untag_media,
crate::routes::tags::get_media_tags,
// transcode
crate::routes::transcode::start_transcode,
crate::routes::transcode::get_session,
crate::routes::transcode::list_sessions,
crate::routes::transcode::cancel_session,
// upload
crate::routes::upload::upload_file,
crate::routes::upload::download_file,
crate::routes::upload::move_to_managed,
crate::routes::upload::managed_stats,
// users
crate::routes::users::list_users,
crate::routes::users::create_user,
crate::routes::users::get_user,
crate::routes::users::update_user,
crate::routes::users::delete_user,
crate::routes::users::get_user_libraries,
crate::routes::users::grant_library_access,
crate::routes::users::revoke_library_access,
// webhooks
crate::routes::webhooks::list_webhooks,
crate::routes::webhooks::test_webhook,
),
components(
schemas(
// analytics DTOs
crate::dto::UsageEventResponse,
crate::dto::RecordUsageEventRequest,
// audit DTOs
crate::dto::AuditEntryResponse,
// auth local types
crate::routes::auth::SessionListResponse,
crate::routes::auth::SessionInfo,
// batch DTOs
crate::dto::BatchTagRequest,
crate::dto::BatchCollectionRequest,
crate::dto::BatchDeleteRequest,
crate::dto::BatchUpdateRequest,
crate::dto::BatchOperationResponse,
// books local types
crate::routes::books::BookMetadataResponse,
crate::routes::books::AuthorResponse,
crate::routes::books::ReadingProgressResponse,
crate::routes::books::UpdateProgressRequest,
crate::routes::books::SeriesSummary,
crate::routes::books::AuthorSummary,
// collections DTOs
crate::dto::CollectionResponse,
crate::dto::CreateCollectionRequest,
crate::dto::AddMemberRequest,
// config DTOs
crate::dto::ConfigResponse,
crate::dto::ScanningConfigResponse,
crate::dto::ServerConfigResponse,
crate::dto::UpdateScanningRequest,
crate::dto::RootDirRequest,
crate::dto::UiConfigResponse,
crate::dto::UpdateUiConfigRequest,
// database DTOs
crate::dto::DatabaseStatsResponse,
// duplicate DTOs
crate::dto::DuplicateGroupResponse,
// enrichment DTOs
crate::dto::ExternalMetadataResponse,
// export local types
crate::routes::export::ExportRequest,
// health local types
crate::routes::health::HealthResponse,
crate::routes::health::DatabaseHealth,
crate::routes::health::FilesystemHealth,
crate::routes::health::CacheHealth,
crate::routes::health::DetailedHealthResponse,
crate::routes::health::JobsHealth,
// integrity local types
crate::routes::integrity::OrphanResolveRequest,
crate::routes::integrity::VerifyIntegrityRequest,
crate::routes::integrity::GenerateThumbnailsRequest,
// media DTOs
crate::dto::MediaResponse,
crate::dto::CustomFieldResponse,
crate::dto::ImportRequest,
crate::dto::ImportWithOptionsRequest,
crate::dto::DirectoryImportRequest,
crate::dto::DirectoryPreviewResponse,
crate::dto::UpdateMediaRequest,
crate::dto::MoveMediaRequest,
crate::dto::RenameMediaRequest,
crate::dto::BatchMoveRequest,
crate::dto::BatchImportRequest,
crate::dto::SetCustomFieldRequest,
crate::dto::MediaCountResponse,
crate::dto::TrashInfoResponse,
crate::dto::ImportResponse,
crate::dto::TrashResponse,
crate::dto::EmptyTrashResponse,
crate::dto::BatchImportResponse,
crate::dto::BatchImportItemResult,
crate::dto::DirectoryPreviewFile,
crate::dto::UpdateMediaFullRequest,
crate::dto::OpenRequest,
crate::dto::WatchProgressRequest,
crate::dto::WatchProgressResponse,
// notes local types
crate::routes::notes::BacklinksResponse,
crate::routes::notes::BacklinkItem,
crate::routes::notes::OutgoingLinksResponse,
crate::routes::notes::OutgoingLinkItem,
crate::routes::notes::GraphResponse,
crate::routes::notes::GraphNodeResponse,
crate::routes::notes::GraphEdgeResponse,
crate::routes::notes::ReindexResponse,
crate::routes::notes::ResolveLinksResponse,
crate::routes::notes::UnresolvedLinksResponse,
// photos local types
crate::routes::photos::TimelineGroup,
crate::routes::photos::MapMarker,
// playlists DTOs
crate::dto::PlaylistResponse,
crate::dto::CreatePlaylistRequest,
crate::dto::UpdatePlaylistRequest,
crate::dto::PlaylistItemRequest,
crate::dto::ReorderPlaylistRequest,
// plugins DTOs
crate::dto::PluginResponse,
crate::dto::InstallPluginRequest,
crate::dto::TogglePluginRequest,
crate::dto::PluginUiPageEntry,
crate::dto::PluginUiWidgetEntry,
crate::dto::PluginEventRequest,
// saved_searches local types
crate::routes::saved_searches::CreateSavedSearchRequest,
crate::routes::saved_searches::SavedSearchResponse,
// scan DTOs
crate::dto::ScanRequest,
crate::dto::ScanResponse,
crate::dto::ScanJobResponse,
crate::dto::ScanStatusResponse,
// search DTOs
crate::dto::SearchParams,
crate::dto::SearchResponse,
crate::dto::SearchRequestBody,
crate::dto::PaginationParams,
// sharing DTOs
crate::dto::CreateShareRequest,
crate::dto::UpdateShareRequest,
crate::dto::ShareResponse,
crate::dto::SharePermissionsRequest,
crate::dto::BatchDeleteSharesRequest,
crate::dto::AccessSharedRequest,
crate::dto::SharedContentResponse,
crate::dto::ShareActivityResponse,
crate::dto::ShareNotificationResponse,
// social DTOs
crate::dto::RatingResponse,
crate::dto::CreateRatingRequest,
crate::dto::CommentResponse,
crate::dto::CreateCommentRequest,
crate::dto::FavoriteRequest,
crate::dto::CreateShareLinkRequest,
crate::dto::ShareLinkResponse,
// statistics DTOs
crate::dto::LibraryStatisticsResponse,
crate::dto::TypeCountResponse,
crate::dto::ScheduledTaskResponse,
// subtitles DTOs
crate::dto::SubtitleResponse,
crate::dto::AddSubtitleRequest,
crate::dto::UpdateSubtitleOffsetRequest,
crate::dto::SubtitleListResponse,
crate::dto::SubtitleTrackInfoResponse,
// sync DTOs
crate::dto::RegisterDeviceRequest,
crate::dto::DeviceResponse,
crate::dto::DeviceRegistrationResponse,
crate::dto::UpdateDeviceRequest,
crate::dto::GetChangesParams,
crate::dto::SyncChangeResponse,
crate::dto::ChangesResponse,
crate::dto::ReportChangesRequest,
crate::dto::ReportChangesResponse,
crate::dto::AcknowledgeChangesRequest,
crate::dto::ConflictResponse,
crate::dto::ResolveConflictRequest,
crate::dto::CreateUploadSessionRequest,
crate::dto::UploadSessionResponse,
crate::dto::ChunkUploadedResponse,
crate::dto::MostViewedResponse,
// tags DTOs
crate::dto::TagResponse,
crate::dto::CreateTagRequest,
crate::dto::TagMediaRequest,
// transcode DTOs
crate::dto::TranscodeSessionResponse,
crate::dto::CreateTranscodeRequest,
// upload DTOs
crate::dto::UploadResponse,
crate::dto::ManagedStorageStatsResponse,
// users DTOs
crate::dto::UserResponse,
crate::dto::UserLibraryResponse,
crate::dto::GrantLibraryAccessRequest,
crate::dto::RevokeLibraryAccessRequest,
// webhooks local types
crate::routes::webhooks::WebhookInfo,
)
),
tags(
(name = "analytics", description = "Usage analytics and viewing history"),
(name = "audit", description = "Audit log entries"),
(name = "auth", description = "Authentication and session management"),
(name = "backup", description = "Database backup"),
(name = "books", description = "Book metadata, series, authors, and reading progress"),
(name = "collections", description = "Media collections"),
(name = "config", description = "Server configuration"),
(name = "database", description = "Database administration"),
(name = "duplicates", description = "Duplicate media detection"),
(name = "enrichment", description = "External metadata enrichment"),
(name = "export", description = "Media library export"),
(name = "health", description = "Server health checks"),
(name = "integrity", description = "Library integrity checks and repairs"),
(name = "jobs", description = "Background job management"),
(name = "media", description = "Media item management"),
(name = "notes", description = "Markdown notes link graph"),
(name = "photos", description = "Photo timeline and map view"),
(name = "playlists", description = "Media playlists"),
(name = "plugins", description = "Plugin management"),
(name = "saved_searches", description = "Saved search queries"),
(name = "scan", description = "Directory scanning"),
(name = "scheduled_tasks", description = "Scheduled background tasks"),
(name = "search", description = "Full-text media search"),
(name = "shares", description = "Media sharing and notifications"),
(name = "social", description = "Ratings, comments, favorites, and share links"),
(name = "statistics", description = "Library statistics"),
(name = "streaming", description = "HLS and DASH adaptive streaming"),
(name = "subtitles", description = "Media subtitle management"),
(name = "sync", description = "Multi-device library synchronization"),
(name = "tags", description = "Media tag management"),
(name = "transcode", description = "Video transcoding sessions"),
(name = "upload", description = "File upload and managed storage"),
(name = "users", description = "User and library access management"),
(name = "webhooks", description = "Webhook configuration"),
),
security(
("bearer_auth" = [])
),
modifiers(&SecurityAddon)
)]
pub struct ApiDoc;
struct SecurityAddon;
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"bearer_auth",
utoipa::openapi::security::SecurityScheme::Http(
utoipa::openapi::security::Http::new(
utoipa::openapi::security::HttpAuthScheme::Bearer,
),
),
);
}
}
}

View file

@ -14,8 +14,10 @@ use tower_http::{
set_header::SetResponseHeaderLayer, set_header::SetResponseHeaderLayer,
trace::TraceLayer, trace::TraceLayer,
}; };
use utoipa::OpenApi as _;
use utoipa_swagger_ui::SwaggerUi;
use crate::{auth, routes, state::AppState}; use crate::{api_doc::ApiDoc, auth, routes, state::AppState};
/// Create the router with optional TLS configuration for HSTS headers /// Create the router with optional TLS configuration for HSTS headers
pub fn create_router( pub fn create_router(
@ -51,6 +53,11 @@ pub fn create_router_with_tls(
rate_limits: &pinakes_core::config::RateLimitConfig, rate_limits: &pinakes_core::config::RateLimitConfig,
tls_config: Option<&pinakes_core::config::TlsConfig>, tls_config: Option<&pinakes_core::config::TlsConfig>,
) -> Router { ) -> Router {
let swagger_ui_enabled = state
.config
.try_read()
.map_or(false, |cfg| cfg.server.swagger_ui);
let global_governor = build_governor( let global_governor = build_governor(
rate_limits.global_per_second, rate_limits.global_per_second,
rate_limits.global_burst_size, rate_limits.global_burst_size,
@ -605,7 +612,7 @@ pub fn create_router_with_tls(
HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"), HeaderValue::from_static("default-src 'none'; frame-ancestors 'none'"),
)); ));
let router = Router::new() let base_router = Router::new()
.nest("/api/v1", full_api) .nest("/api/v1", full_api)
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)) .layer(DefaultBodyLimit::max(10 * 1024 * 1024))
.layer(GovernorLayer::new(global_governor)) .layer(GovernorLayer::new(global_governor))
@ -613,6 +620,14 @@ pub fn create_router_with_tls(
.layer(cors) .layer(cors)
.layer(security_headers); .layer(security_headers);
let router = if swagger_ui_enabled {
base_router.merge(
SwaggerUi::new("/api/docs").url("/api/openapi.json", ApiDoc::openapi()),
)
} else {
base_router
};
// Add HSTS header when TLS is enabled // Add HSTS header when TLS is enabled
if let Some(tls) = tls_config { if let Some(tls) = tls_config {
if tls.enabled && tls.hsts_enabled { if tls.enabled && tls.hsts_enabled {

View file

@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UsageEventResponse { pub struct UsageEventResponse {
pub id: String, pub id: String,
pub media_id: Option<String>, pub media_id: Option<String>,
@ -25,10 +25,11 @@ impl From<pinakes_core::analytics::UsageEvent> for UsageEventResponse {
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct RecordUsageEventRequest { pub struct RecordUsageEventRequest {
pub media_id: Option<Uuid>, pub media_id: Option<Uuid>,
pub event_type: String, pub event_type: String,
pub duration_secs: Option<f64>, pub duration_secs: Option<f64>,
#[schema(value_type = Option<Object>)]
pub context: Option<serde_json::Value>, pub context: Option<serde_json::Value>,
} }

View file

@ -1,7 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct AuditEntryResponse { pub struct AuditEntryResponse {
pub id: String, pub id: String,
pub media_id: Option<String>, pub media_id: Option<String>,

View file

@ -1,24 +1,24 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct BatchTagRequest { pub struct BatchTagRequest {
pub media_ids: Vec<Uuid>, pub media_ids: Vec<Uuid>,
pub tag_ids: Vec<Uuid>, pub tag_ids: Vec<Uuid>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct BatchCollectionRequest { pub struct BatchCollectionRequest {
pub media_ids: Vec<Uuid>, pub media_ids: Vec<Uuid>,
pub collection_id: Uuid, pub collection_id: Uuid,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct BatchDeleteRequest { pub struct BatchDeleteRequest {
pub media_ids: Vec<Uuid>, pub media_ids: Vec<Uuid>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct BatchUpdateRequest { pub struct BatchUpdateRequest {
pub media_ids: Vec<Uuid>, pub media_ids: Vec<Uuid>,
pub title: Option<String>, pub title: Option<String>,
@ -29,7 +29,7 @@ pub struct BatchUpdateRequest {
pub description: Option<String>, pub description: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct BatchOperationResponse { pub struct BatchOperationResponse {
pub processed: usize, pub processed: usize,
pub errors: Vec<String>, pub errors: Vec<String>,

View file

@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct CollectionResponse { pub struct CollectionResponse {
pub id: String, pub id: String,
pub name: String, pub name: String,
@ -13,7 +13,7 @@ pub struct CollectionResponse {
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateCollectionRequest { pub struct CreateCollectionRequest {
pub name: String, pub name: String,
pub kind: String, pub kind: String,
@ -21,7 +21,7 @@ pub struct CreateCollectionRequest {
pub filter_query: Option<String>, pub filter_query: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct AddMemberRequest { pub struct AddMemberRequest {
pub media_id: Uuid, pub media_id: Uuid,
pub position: Option<i32>, pub position: Option<i32>,

View file

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ConfigResponse { pub struct ConfigResponse {
pub backend: String, pub backend: String,
pub database_path: Option<String>, pub database_path: Option<String>,
@ -12,33 +12,33 @@ pub struct ConfigResponse {
pub config_writable: bool, pub config_writable: bool,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ScanningConfigResponse { pub struct ScanningConfigResponse {
pub watch: bool, pub watch: bool,
pub poll_interval_secs: u64, pub poll_interval_secs: u64,
pub ignore_patterns: Vec<String>, pub ignore_patterns: Vec<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ServerConfigResponse { pub struct ServerConfigResponse {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdateScanningRequest { pub struct UpdateScanningRequest {
pub watch: Option<bool>, pub watch: Option<bool>,
pub poll_interval_secs: Option<u64>, pub poll_interval_secs: Option<u64>,
pub ignore_patterns: Option<Vec<String>>, pub ignore_patterns: Option<Vec<String>>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct RootDirRequest { pub struct RootDirRequest {
pub path: String, pub path: String,
} }
// UI Config // UI Config
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct UiConfigResponse { pub struct UiConfigResponse {
pub theme: String, pub theme: String,
pub default_view: String, pub default_view: String,
@ -49,7 +49,7 @@ pub struct UiConfigResponse {
pub sidebar_collapsed: bool, pub sidebar_collapsed: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdateUiConfigRequest { pub struct UpdateUiConfigRequest {
pub theme: Option<String>, pub theme: Option<String>,
pub default_view: Option<String>, pub default_view: Option<String>,

View file

@ -1,12 +1,13 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::Serialize; use serde::Serialize;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ExternalMetadataResponse { pub struct ExternalMetadataResponse {
pub id: String, pub id: String,
pub media_id: String, pub media_id: String,
pub source: String, pub source: String,
pub external_id: Option<String>, pub external_id: Option<String>,
#[schema(value_type = Object)]
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub confidence: f64, pub confidence: f64,
pub last_updated: DateTime<Utc>, pub last_updated: DateTime<Utc>,

View file

@ -34,7 +34,7 @@ pub fn relativize_path(full_path: &Path, roots: &[PathBuf]) -> String {
full_path.to_string_lossy().into_owned() full_path.to_string_lossy().into_owned()
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct MediaResponse { pub struct MediaResponse {
pub id: String, pub id: String,
pub path: String, pub path: String,
@ -50,6 +50,7 @@ pub struct MediaResponse {
pub duration_secs: Option<f64>, pub duration_secs: Option<f64>,
pub description: Option<String>, pub description: Option<String>,
pub has_thumbnail: bool, pub has_thumbnail: bool,
#[schema(value_type = Object)]
pub custom_fields: FxHashMap<String, CustomFieldResponse>, pub custom_fields: FxHashMap<String, CustomFieldResponse>,
// Photo-specific metadata // Photo-specific metadata
@ -67,24 +68,25 @@ pub struct MediaResponse {
pub links_extracted_at: Option<DateTime<Utc>>, pub links_extracted_at: Option<DateTime<Utc>>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct CustomFieldResponse { pub struct CustomFieldResponse {
pub field_type: String, pub field_type: String,
pub value: String, pub value: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ImportRequest { pub struct ImportRequest {
#[schema(value_type = String)]
pub path: PathBuf, pub path: PathBuf,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ImportResponse { pub struct ImportResponse {
pub media_id: String, pub media_id: String,
pub was_duplicate: bool, pub was_duplicate: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdateMediaRequest { pub struct UpdateMediaRequest {
pub title: Option<String>, pub title: Option<String>,
pub artist: Option<String>, pub artist: Option<String>,
@ -95,56 +97,60 @@ pub struct UpdateMediaRequest {
} }
// File Management // File Management
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct RenameMediaRequest { pub struct RenameMediaRequest {
pub new_name: String, pub new_name: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct MoveMediaRequest { pub struct MoveMediaRequest {
#[schema(value_type = String)]
pub destination: PathBuf, pub destination: PathBuf,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct BatchMoveRequest { pub struct BatchMoveRequest {
pub media_ids: Vec<Uuid>, pub media_ids: Vec<Uuid>,
#[schema(value_type = String)]
pub destination: PathBuf, pub destination: PathBuf,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct TrashResponse { pub struct TrashResponse {
pub items: Vec<MediaResponse>, pub items: Vec<MediaResponse>,
pub total_count: u64, pub total_count: u64,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct TrashInfoResponse { pub struct TrashInfoResponse {
pub count: u64, pub count: u64,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct EmptyTrashResponse { pub struct EmptyTrashResponse {
pub deleted_count: u64, pub deleted_count: u64,
} }
// Enhanced Import // Enhanced Import
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ImportWithOptionsRequest { pub struct ImportWithOptionsRequest {
#[schema(value_type = String)]
pub path: PathBuf, pub path: PathBuf,
pub tag_ids: Option<Vec<Uuid>>, pub tag_ids: Option<Vec<Uuid>>,
pub new_tags: Option<Vec<String>>, pub new_tags: Option<Vec<String>>,
pub collection_id: Option<Uuid>, pub collection_id: Option<Uuid>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct BatchImportRequest { pub struct BatchImportRequest {
#[schema(value_type = Vec<String>)]
pub paths: Vec<PathBuf>, pub paths: Vec<PathBuf>,
pub tag_ids: Option<Vec<Uuid>>, pub tag_ids: Option<Vec<Uuid>>,
pub new_tags: Option<Vec<String>>, pub new_tags: Option<Vec<String>>,
pub collection_id: Option<Uuid>, pub collection_id: Option<Uuid>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct BatchImportResponse { pub struct BatchImportResponse {
pub results: Vec<BatchImportItemResult>, pub results: Vec<BatchImportItemResult>,
pub total: usize, pub total: usize,
@ -153,7 +159,7 @@ pub struct BatchImportResponse {
pub errors: usize, pub errors: usize,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct BatchImportItemResult { pub struct BatchImportItemResult {
pub path: String, pub path: String,
pub media_id: Option<String>, pub media_id: Option<String>,
@ -161,22 +167,23 @@ pub struct BatchImportItemResult {
pub error: Option<String>, pub error: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct DirectoryImportRequest { pub struct DirectoryImportRequest {
#[schema(value_type = String)]
pub path: PathBuf, pub path: PathBuf,
pub tag_ids: Option<Vec<Uuid>>, pub tag_ids: Option<Vec<Uuid>>,
pub new_tags: Option<Vec<String>>, pub new_tags: Option<Vec<String>>,
pub collection_id: Option<Uuid>, pub collection_id: Option<Uuid>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct DirectoryPreviewResponse { pub struct DirectoryPreviewResponse {
pub files: Vec<DirectoryPreviewFile>, pub files: Vec<DirectoryPreviewFile>,
pub total_count: usize, pub total_count: usize,
pub total_size: u64, pub total_size: u64,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct DirectoryPreviewFile { pub struct DirectoryPreviewFile {
pub path: String, pub path: String,
pub file_name: String, pub file_name: String,
@ -185,7 +192,7 @@ pub struct DirectoryPreviewFile {
} }
// Custom Fields // Custom Fields
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct SetCustomFieldRequest { pub struct SetCustomFieldRequest {
pub name: String, pub name: String,
pub field_type: String, pub field_type: String,
@ -193,7 +200,7 @@ pub struct SetCustomFieldRequest {
} }
// Media update extended // Media update extended
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdateMediaFullRequest { pub struct UpdateMediaFullRequest {
pub title: Option<String>, pub title: Option<String>,
pub artist: Option<String>, pub artist: Option<String>,
@ -204,26 +211,26 @@ pub struct UpdateMediaFullRequest {
} }
// Search with sort // Search with sort
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct MediaCountResponse { pub struct MediaCountResponse {
pub count: u64, pub count: u64,
} }
// Duplicates // Duplicates
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct DuplicateGroupResponse { pub struct DuplicateGroupResponse {
pub content_hash: String, pub content_hash: String,
pub items: Vec<MediaResponse>, pub items: Vec<MediaResponse>,
} }
// Open // Open
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct OpenRequest { pub struct OpenRequest {
pub media_id: Uuid, pub media_id: Uuid,
} }
// Upload // Upload
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UploadResponse { pub struct UploadResponse {
pub media_id: String, pub media_id: String,
pub content_hash: String, pub content_hash: String,
@ -242,7 +249,7 @@ impl From<pinakes_core::model::UploadResult> for UploadResponse {
} }
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ManagedStorageStatsResponse { pub struct ManagedStorageStatsResponse {
pub total_blobs: u64, pub total_blobs: u64,
pub total_size_bytes: u64, pub total_size_bytes: u64,
@ -368,12 +375,12 @@ mod tests {
} }
// Watch progress // Watch progress
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct WatchProgressRequest { pub struct WatchProgressRequest {
pub progress_secs: f64, pub progress_secs: f64,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct WatchProgressResponse { pub struct WatchProgressResponse {
pub progress_secs: f64, pub progress_secs: f64,
} }

View file

@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct PlaylistResponse { pub struct PlaylistResponse {
pub id: String, pub id: String,
pub owner_id: String, pub owner_id: String,
@ -31,7 +31,7 @@ impl From<pinakes_core::playlists::Playlist> for PlaylistResponse {
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreatePlaylistRequest { pub struct CreatePlaylistRequest {
pub name: String, pub name: String,
pub description: Option<String>, pub description: Option<String>,
@ -40,20 +40,20 @@ pub struct CreatePlaylistRequest {
pub filter_query: Option<String>, pub filter_query: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdatePlaylistRequest { pub struct UpdatePlaylistRequest {
pub name: Option<String>, pub name: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub is_public: Option<bool>, pub is_public: Option<bool>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct PlaylistItemRequest { pub struct PlaylistItemRequest {
pub media_id: Uuid, pub media_id: Uuid,
pub position: Option<i32>, pub position: Option<i32>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ReorderPlaylistRequest { pub struct ReorderPlaylistRequest {
pub media_id: Uuid, pub media_id: Uuid,
pub new_position: i32, pub new_position: i32,

View file

@ -1,7 +1,7 @@
use pinakes_plugin_api::{UiPage, UiWidget}; use pinakes_plugin_api::{UiPage, UiWidget};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct PluginResponse { pub struct PluginResponse {
pub id: String, pub id: String,
pub name: String, pub name: String,
@ -12,22 +12,23 @@ pub struct PluginResponse {
pub enabled: bool, pub enabled: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct InstallPluginRequest { pub struct InstallPluginRequest {
pub source: String, // URL or file path pub source: String, // URL or file path
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct TogglePluginRequest { pub struct TogglePluginRequest {
pub enabled: bool, pub enabled: bool,
} }
/// A single plugin UI page entry in the list response /// A single plugin UI page entry in the list response
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct PluginUiPageEntry { pub struct PluginUiPageEntry {
/// Plugin ID that provides this page /// Plugin ID that provides this page
pub plugin_id: String, pub plugin_id: String,
/// Full page definition /// Full page definition
#[schema(value_type = Object)]
pub page: UiPage, pub page: UiPage,
/// Endpoint paths this plugin is allowed to fetch (empty means no /// Endpoint paths this plugin is allowed to fetch (empty means no
/// restriction) /// restriction)
@ -35,19 +36,21 @@ pub struct PluginUiPageEntry {
} }
/// A single plugin UI widget entry in the list response /// A single plugin UI widget entry in the list response
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct PluginUiWidgetEntry { pub struct PluginUiWidgetEntry {
/// Plugin ID that provides this widget /// Plugin ID that provides this widget
pub plugin_id: String, pub plugin_id: String,
/// Full widget definition /// Full widget definition
#[schema(value_type = Object)]
pub widget: UiWidget, pub widget: UiWidget,
} }
/// Request body for emitting a plugin event /// Request body for emitting a plugin event
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct PluginEventRequest { pub struct PluginEventRequest {
pub event: String, pub event: String,
#[serde(default)] #[serde(default)]
#[schema(value_type = Object)]
pub payload: serde_json::Value, pub payload: serde_json::Value,
} }

View file

@ -2,24 +2,25 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ScanRequest { pub struct ScanRequest {
#[schema(value_type = Option<String>)]
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ScanResponse { pub struct ScanResponse {
pub files_found: usize, pub files_found: usize,
pub files_processed: usize, pub files_processed: usize,
pub errors: Vec<String>, pub errors: Vec<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ScanJobResponse { pub struct ScanJobResponse {
pub job_id: String, pub job_id: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ScanStatusResponse { pub struct ScanStatusResponse {
pub scanning: bool, pub scanning: bool,
pub files_found: usize, pub files_found: usize,

View file

@ -9,7 +9,7 @@ pub const MAX_OFFSET: u64 = 10_000_000;
/// Maximum page size accepted from most listing endpoints. /// Maximum page size accepted from most listing endpoints.
pub const MAX_LIMIT: u64 = 1000; pub const MAX_LIMIT: u64 = 1000;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct SearchParams { pub struct SearchParams {
pub q: String, pub q: String,
pub sort: Option<String>, pub sort: Option<String>,
@ -28,14 +28,14 @@ impl SearchParams {
} }
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct SearchResponse { pub struct SearchResponse {
pub items: Vec<MediaResponse>, pub items: Vec<MediaResponse>,
pub total_count: u64, pub total_count: u64,
} }
// Search (POST body) // Search (POST body)
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct SearchRequestBody { pub struct SearchRequestBody {
pub q: String, pub q: String,
pub sort: Option<String>, pub sort: Option<String>,
@ -55,7 +55,7 @@ impl SearchRequestBody {
} }
// Pagination // Pagination
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct PaginationParams { pub struct PaginationParams {
pub offset: Option<u64>, pub offset: Option<u64>,
pub limit: Option<u64>, pub limit: Option<u64>,

View file

@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateShareRequest { pub struct CreateShareRequest {
pub target_type: String, pub target_type: String,
pub target_id: String, pub target_id: String,
@ -16,7 +16,7 @@ pub struct CreateShareRequest {
pub inherit_to_children: Option<bool>, pub inherit_to_children: Option<bool>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct SharePermissionsRequest { pub struct SharePermissionsRequest {
pub can_view: Option<bool>, pub can_view: Option<bool>,
pub can_download: Option<bool>, pub can_download: Option<bool>,
@ -26,7 +26,7 @@ pub struct SharePermissionsRequest {
pub can_add: Option<bool>, pub can_add: Option<bool>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ShareResponse { pub struct ShareResponse {
pub id: String, pub id: String,
pub target_type: String, pub target_type: String,
@ -46,7 +46,7 @@ pub struct ShareResponse {
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct SharePermissionsResponse { pub struct SharePermissionsResponse {
pub can_view: bool, pub can_view: bool,
pub can_download: bool, pub can_download: bool,
@ -125,7 +125,7 @@ impl From<pinakes_core::sharing::Share> for ShareResponse {
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdateShareRequest { pub struct UpdateShareRequest {
pub permissions: Option<SharePermissionsRequest>, pub permissions: Option<SharePermissionsRequest>,
pub note: Option<String>, pub note: Option<String>,
@ -133,7 +133,7 @@ pub struct UpdateShareRequest {
pub inherit_to_children: Option<bool>, pub inherit_to_children: Option<bool>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ShareActivityResponse { pub struct ShareActivityResponse {
pub id: String, pub id: String,
pub share_id: String, pub share_id: String,
@ -158,7 +158,7 @@ impl From<pinakes_core::sharing::ShareActivity> for ShareActivityResponse {
} }
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ShareNotificationResponse { pub struct ShareNotificationResponse {
pub id: String, pub id: String,
pub share_id: String, pub share_id: String,
@ -181,12 +181,12 @@ impl From<pinakes_core::sharing::ShareNotification>
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct BatchDeleteSharesRequest { pub struct BatchDeleteSharesRequest {
pub share_ids: Vec<Uuid>, pub share_ids: Vec<Uuid>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct AccessSharedRequest { pub struct AccessSharedRequest {
pub password: Option<String>, pub password: Option<String>,
} }
@ -194,7 +194,7 @@ pub struct AccessSharedRequest {
/// Response for accessing shared content. /// Response for accessing shared content.
/// Single-media shares return the media object directly (backwards compatible). /// Single-media shares return the media object directly (backwards compatible).
/// Collection/Tag/SavedSearch shares return a list of items. /// Collection/Tag/SavedSearch shares return a list of items.
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
#[serde(untagged)] #[serde(untagged)]
pub enum SharedContentResponse { pub enum SharedContentResponse {
Single(super::MediaResponse), Single(super::MediaResponse),

View file

@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct RatingResponse { pub struct RatingResponse {
pub id: String, pub id: String,
pub user_id: String, pub user_id: String,
@ -25,13 +25,13 @@ impl From<pinakes_core::social::Rating> for RatingResponse {
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateRatingRequest { pub struct CreateRatingRequest {
pub stars: u8, pub stars: u8,
pub review_text: Option<String>, pub review_text: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct CommentResponse { pub struct CommentResponse {
pub id: String, pub id: String,
pub user_id: String, pub user_id: String,
@ -54,25 +54,25 @@ impl From<pinakes_core::social::Comment> for CommentResponse {
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateCommentRequest { pub struct CreateCommentRequest {
pub text: String, pub text: String,
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct FavoriteRequest { pub struct FavoriteRequest {
pub media_id: Uuid, pub media_id: Uuid,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateShareLinkRequest { pub struct CreateShareLinkRequest {
pub media_id: Uuid, pub media_id: Uuid,
pub password: Option<String>, pub password: Option<String>,
pub expires_in_hours: Option<u64>, pub expires_in_hours: Option<u64>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ShareLinkResponse { pub struct ShareLinkResponse {
pub id: String, pub id: String,
pub media_id: String, pub media_id: String,

View file

@ -1,7 +1,7 @@
use serde::Serialize; use serde::Serialize;
// Library Statistics // Library Statistics
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct LibraryStatisticsResponse { pub struct LibraryStatisticsResponse {
pub total_media: u64, pub total_media: u64,
pub total_size_bytes: u64, pub total_size_bytes: u64,
@ -17,7 +17,7 @@ pub struct LibraryStatisticsResponse {
pub total_duplicates: u64, pub total_duplicates: u64,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct TypeCountResponse { pub struct TypeCountResponse {
pub name: String, pub name: String,
pub count: u64, pub count: u64,
@ -61,7 +61,7 @@ impl From<pinakes_core::storage::LibraryStatistics>
} }
// Database management // Database management
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct DatabaseStatsResponse { pub struct DatabaseStatsResponse {
pub media_count: u64, pub media_count: u64,
pub tag_count: u64, pub tag_count: u64,
@ -72,7 +72,7 @@ pub struct DatabaseStatsResponse {
} }
// Scheduled Tasks // Scheduled Tasks
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ScheduledTaskResponse { pub struct ScheduledTaskResponse {
pub id: String, pub id: String,
pub name: String, pub name: String,

View file

@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
use super::media::MediaResponse; use super::media::MediaResponse;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct RegisterDeviceRequest { pub struct RegisterDeviceRequest {
pub name: String, pub name: String,
pub device_type: String, pub device_type: String,
@ -11,7 +11,7 @@ pub struct RegisterDeviceRequest {
pub os_info: Option<String>, pub os_info: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct DeviceResponse { pub struct DeviceResponse {
pub id: String, pub id: String,
pub name: String, pub name: String,
@ -42,25 +42,25 @@ impl From<pinakes_core::sync::SyncDevice> for DeviceResponse {
} }
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct DeviceRegistrationResponse { pub struct DeviceRegistrationResponse {
pub device: DeviceResponse, pub device: DeviceResponse,
pub device_token: String, pub device_token: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdateDeviceRequest { pub struct UpdateDeviceRequest {
pub name: Option<String>, pub name: Option<String>,
pub enabled: Option<bool>, pub enabled: Option<bool>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct GetChangesParams { pub struct GetChangesParams {
pub cursor: Option<i64>, pub cursor: Option<i64>,
pub limit: Option<u64>, pub limit: Option<u64>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct SyncChangeResponse { pub struct SyncChangeResponse {
pub id: String, pub id: String,
pub sequence: i64, pub sequence: i64,
@ -87,14 +87,14 @@ impl From<pinakes_core::sync::SyncLogEntry> for SyncChangeResponse {
} }
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ChangesResponse { pub struct ChangesResponse {
pub changes: Vec<SyncChangeResponse>, pub changes: Vec<SyncChangeResponse>,
pub cursor: i64, pub cursor: i64,
pub has_more: bool, pub has_more: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ClientChangeReport { pub struct ClientChangeReport {
pub path: String, pub path: String,
pub change_type: String, pub change_type: String,
@ -103,19 +103,19 @@ pub struct ClientChangeReport {
pub local_mtime: Option<i64>, pub local_mtime: Option<i64>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ReportChangesRequest { pub struct ReportChangesRequest {
pub changes: Vec<ClientChangeReport>, pub changes: Vec<ClientChangeReport>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ReportChangesResponse { pub struct ReportChangesResponse {
pub accepted: Vec<String>, pub accepted: Vec<String>,
pub conflicts: Vec<ConflictResponse>, pub conflicts: Vec<ConflictResponse>,
pub upload_required: Vec<String>, pub upload_required: Vec<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ConflictResponse { pub struct ConflictResponse {
pub id: String, pub id: String,
pub path: String, pub path: String,
@ -136,12 +136,12 @@ impl From<pinakes_core::sync::SyncConflict> for ConflictResponse {
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ResolveConflictRequest { pub struct ResolveConflictRequest {
pub resolution: String, pub resolution: String,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateUploadSessionRequest { pub struct CreateUploadSessionRequest {
pub target_path: String, pub target_path: String,
pub expected_hash: String, pub expected_hash: String,
@ -149,7 +149,7 @@ pub struct CreateUploadSessionRequest {
pub chunk_size: Option<u64>, pub chunk_size: Option<u64>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UploadSessionResponse { pub struct UploadSessionResponse {
pub id: String, pub id: String,
pub target_path: String, pub target_path: String,
@ -178,19 +178,19 @@ impl From<pinakes_core::sync::UploadSession> for UploadSessionResponse {
} }
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ChunkUploadedResponse { pub struct ChunkUploadedResponse {
pub chunk_index: u64, pub chunk_index: u64,
pub received: bool, pub received: bool,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct AcknowledgeChangesRequest { pub struct AcknowledgeChangesRequest {
pub cursor: i64, pub cursor: i64,
} }
// Most viewed (uses MediaResponse) // Most viewed (uses MediaResponse)
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct MostViewedResponse { pub struct MostViewedResponse {
pub media: MediaResponse, pub media: MediaResponse,
pub view_count: u64, pub view_count: u64,

View file

@ -2,7 +2,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct TagResponse { pub struct TagResponse {
pub id: String, pub id: String,
pub name: String, pub name: String,
@ -10,13 +10,13 @@ pub struct TagResponse {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateTagRequest { pub struct CreateTagRequest {
pub name: String, pub name: String,
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct TagMediaRequest { pub struct TagMediaRequest {
pub tag_id: Uuid, pub tag_id: Uuid,
} }

View file

@ -1,7 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct TranscodeSessionResponse { pub struct TranscodeSessionResponse {
pub id: String, pub id: String,
pub media_id: String, pub media_id: String,
@ -28,7 +28,7 @@ impl From<pinakes_core::transcode::TranscodeSession>
} }
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateTranscodeRequest { pub struct CreateTranscodeRequest {
pub profile: String, pub profile: String,
} }

View file

@ -2,27 +2,27 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// Auth // Auth
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct LoginRequest { pub struct LoginRequest {
pub username: String, pub username: String,
pub password: String, pub password: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct LoginResponse { pub struct LoginResponse {
pub token: String, pub token: String,
pub username: String, pub username: String,
pub role: String, pub role: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UserInfoResponse { pub struct UserInfoResponse {
pub username: String, pub username: String,
pub role: String, pub role: String,
} }
// Users // Users
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UserResponse { pub struct UserResponse {
pub id: String, pub id: String,
pub username: String, pub username: String,
@ -32,14 +32,14 @@ pub struct UserResponse {
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UserProfileResponse { pub struct UserProfileResponse {
pub avatar_path: Option<String>, pub avatar_path: Option<String>,
pub bio: Option<String>, pub bio: Option<String>,
pub preferences: UserPreferencesResponse, pub preferences: UserPreferencesResponse,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UserPreferencesResponse { pub struct UserPreferencesResponse {
pub theme: Option<String>, pub theme: Option<String>,
pub language: Option<String>, pub language: Option<String>,
@ -47,7 +47,7 @@ pub struct UserPreferencesResponse {
pub auto_play: bool, pub auto_play: bool,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UserLibraryResponse { pub struct UserLibraryResponse {
pub user_id: String, pub user_id: String,
pub root_path: String, pub root_path: String,
@ -55,13 +55,14 @@ pub struct UserLibraryResponse {
pub granted_at: DateTime<Utc>, pub granted_at: DateTime<Utc>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct GrantLibraryAccessRequest { pub struct GrantLibraryAccessRequest {
pub root_path: String, pub root_path: String,
#[schema(value_type = String)]
pub permission: pinakes_core::users::LibraryPermission, pub permission: pinakes_core::users::LibraryPermission,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct RevokeLibraryAccessRequest { pub struct RevokeLibraryAccessRequest {
pub root_path: String, pub root_path: String,
} }

View file

@ -44,6 +44,25 @@ impl IntoResponse for ApiError {
PinakesError::InvalidOperation(msg) => { PinakesError::InvalidOperation(msg) => {
(StatusCode::BAD_REQUEST, msg.clone()) (StatusCode::BAD_REQUEST, msg.clone())
}, },
PinakesError::InvalidLanguageCode(code) => {
(
StatusCode::BAD_REQUEST,
format!("invalid language code: {code}"),
)
},
PinakesError::SubtitleTrackNotFound { index } => {
(
StatusCode::NOT_FOUND,
format!("subtitle track {index} not found in media"),
)
},
PinakesError::ExternalTool { tool, .. } => {
tracing::error!(tool = %tool, error = %self.0, "external tool failed");
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("external tool `{tool}` failed"),
)
},
PinakesError::Authentication(msg) => { PinakesError::Authentication(msg) => {
(StatusCode::UNAUTHORIZED, msg.clone()) (StatusCode::UNAUTHORIZED, msg.clone())
}, },

View file

@ -1,3 +1,4 @@
pub mod api_doc;
pub mod app; pub mod app;
pub mod auth; pub mod auth;
pub mod dto; pub mod dto;

View file

@ -24,6 +24,21 @@ use crate::{
const MAX_LIMIT: u64 = 100; const MAX_LIMIT: u64 = 100;
#[utoipa::path(
get,
path = "/api/v1/analytics/most-viewed",
tag = "analytics",
params(
("limit" = Option<u64>, Query, description = "Maximum number of results"),
("offset" = Option<u64>, Query, description = "Pagination offset"),
),
responses(
(status = 200, description = "Most viewed media", body = Vec<MostViewedResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_most_viewed( pub async fn get_most_viewed(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<PaginationParams>, Query(params): Query<PaginationParams>,
@ -44,6 +59,21 @@ pub async fn get_most_viewed(
)) ))
} }
#[utoipa::path(
get,
path = "/api/v1/analytics/recently-viewed",
tag = "analytics",
params(
("limit" = Option<u64>, Query, description = "Maximum number of results"),
("offset" = Option<u64>, Query, description = "Pagination offset"),
),
responses(
(status = 200, description = "Recently viewed media", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_recently_viewed( pub async fn get_recently_viewed(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -61,6 +91,18 @@ pub async fn get_recently_viewed(
)) ))
} }
#[utoipa::path(
post,
path = "/api/v1/analytics/events",
tag = "analytics",
request_body = RecordUsageEventRequest,
responses(
(status = 200, description = "Event recorded"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn record_event( pub async fn record_event(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -84,6 +126,21 @@ pub async fn record_event(
Ok(Json(serde_json::json!({"recorded": true}))) Ok(Json(serde_json::json!({"recorded": true})))
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}/progress",
tag = "analytics",
params(
("id" = Uuid, Path, description = "Media item ID"),
),
responses(
(status = 200, description = "Watch progress", body = WatchProgressResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_watch_progress( pub async fn get_watch_progress(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -100,6 +157,23 @@ pub async fn get_watch_progress(
})) }))
} }
#[utoipa::path(
put,
path = "/api/v1/media/{id}/progress",
tag = "analytics",
params(
("id" = Uuid, Path, description = "Media item ID"),
),
request_body = WatchProgressRequest,
responses(
(status = 200, description = "Progress updated"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update_watch_progress( pub async fn update_watch_progress(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,

View file

@ -9,6 +9,21 @@ use crate::{
state::AppState, state::AppState,
}; };
#[utoipa::path(
get,
path = "/api/v1/audit",
tag = "audit",
params(
("offset" = Option<u64>, Query, description = "Pagination offset"),
("limit" = Option<u64>, Query, description = "Page size"),
),
responses(
(status = 200, description = "Audit log entries", body = Vec<AuditEntryResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_audit( pub async fn list_audit(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<PaginationParams>, Query(params): Query<PaginationParams>,

View file

@ -17,6 +17,19 @@ const DUMMY_HASH: &str =
"$argon2id$v=19$m=19456,t=2,\ "$argon2id$v=19$m=19456,t=2,\
p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk"; p=1$VGltaW5nU2FmZUR1bW15$c2ltdWxhdGVkX2hhc2hfZm9yX3RpbWluZ19zYWZldHk";
#[utoipa::path(
post,
path = "/api/v1/auth/login",
tag = "auth",
request_body = LoginRequest,
responses(
(status = 200, description = "Login successful", body = LoginResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Invalid credentials"),
(status = 500, description = "Internal server error"),
),
security()
)]
pub async fn login( pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<LoginRequest>, Json(req): Json<LoginRequest>,
@ -82,6 +95,7 @@ pub async fn login(
let user = user.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; let user = user.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
// Generate session token using unbiased uniform distribution // Generate session token using unbiased uniform distribution
#[expect(clippy::expect_used)]
let token: String = { let token: String = {
use rand::seq::IndexedRandom; use rand::seq::IndexedRandom;
const CHARSET: &[u8] = const CHARSET: &[u8] =
@ -134,39 +148,64 @@ pub async fn login(
})) }))
} }
#[utoipa::path(
post,
path = "/api/v1/auth/logout",
tag = "auth",
responses(
(status = 200, description = "Logged out"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn logout( pub async fn logout(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
) -> StatusCode { ) -> StatusCode {
if let Some(token) = extract_bearer_token(&headers) { let Some(token) = extract_bearer_token(&headers) else {
// Get username before deleting session return StatusCode::UNAUTHORIZED;
let username = match state.storage.get_session(token).await { };
Ok(Some(session)) => Some(session.username),
_ => None,
};
// Delete session from database // Get username before deleting session
if let Err(e) = state.storage.delete_session(token).await { let username = match state.storage.get_session(token).await {
tracing::error!(error = %e, "failed to delete session from database"); Ok(Some(session)) => Some(session.username),
return StatusCode::INTERNAL_SERVER_ERROR; _ => None,
} };
// Record logout in audit log // Delete session from database
if let Some(user) = username if let Err(e) = state.storage.delete_session(token).await {
&& let Err(e) = pinakes_core::audit::record_action( tracing::error!(error = %e, "failed to delete session from database");
&state.storage, return StatusCode::INTERNAL_SERVER_ERROR;
None,
pinakes_core::model::AuditAction::Logout,
Some(format!("username: {user}")),
)
.await
{
tracing::warn!(error = %e, "failed to record logout audit");
}
} }
// Record logout in audit log
if let Some(user) = username
&& let Err(e) = pinakes_core::audit::record_action(
&state.storage,
None,
pinakes_core::model::AuditAction::Logout,
Some(format!("username: {user}")),
)
.await
{
tracing::warn!(error = %e, "failed to record logout audit");
}
StatusCode::OK StatusCode::OK
} }
#[utoipa::path(
get,
path = "/api/v1/auth/me",
tag = "auth",
responses(
(status = 200, description = "Current user info", body = UserInfoResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn me( pub async fn me(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@ -204,6 +243,17 @@ fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
/// Refresh the current session, extending its expiry by the configured /// Refresh the current session, extending its expiry by the configured
/// duration. /// duration.
#[utoipa::path(
post,
path = "/api/v1/auth/refresh",
tag = "auth",
responses(
(status = 200, description = "Session refreshed"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn refresh( pub async fn refresh(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@ -232,6 +282,17 @@ pub async fn refresh(
} }
/// Revoke all sessions for the current user /// Revoke all sessions for the current user
#[utoipa::path(
post,
path = "/api/v1/auth/revoke-all",
tag = "auth",
responses(
(status = 200, description = "All sessions revoked"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn revoke_all_sessions( pub async fn revoke_all_sessions(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@ -280,12 +341,12 @@ pub async fn revoke_all_sessions(
} }
/// List all active sessions (admin only) /// List all active sessions (admin only)
#[derive(serde::Serialize)] #[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SessionListResponse { pub struct SessionListResponse {
pub sessions: Vec<SessionInfo>, pub sessions: Vec<SessionInfo>,
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize, utoipa::ToSchema)]
pub struct SessionInfo { pub struct SessionInfo {
pub username: String, pub username: String,
pub role: String, pub role: String,
@ -294,6 +355,18 @@ pub struct SessionInfo {
pub expires_at: String, pub expires_at: String,
} }
#[utoipa::path(
get,
path = "/api/v1/auth/sessions",
tag = "auth",
responses(
(status = 200, description = "Active sessions", body = SessionListResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_active_sessions( pub async fn list_active_sessions(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<SessionListResponse>, StatusCode> { ) -> Result<Json<SessionListResponse>, StatusCode> {

View file

@ -11,6 +11,18 @@ use crate::{error::ApiError, state::AppState};
/// ///
/// For `SQLite`: creates a backup via VACUUM INTO and returns the file. /// For `SQLite`: creates a backup via VACUUM INTO and returns the file.
/// For `PostgreSQL`: returns unsupported error (use `pg_dump` instead). /// For `PostgreSQL`: returns unsupported error (use `pg_dump` instead).
#[utoipa::path(
post,
path = "/api/v1/admin/backup",
tag = "backup",
responses(
(status = 200, description = "Backup file download"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn create_backup( pub async fn create_backup(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Response, ApiError> { ) -> Result<Response, ApiError> {

View file

@ -29,7 +29,7 @@ use crate::{
}; };
/// Book metadata response DTO /// Book metadata response DTO
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct BookMetadataResponse { pub struct BookMetadataResponse {
pub media_id: Uuid, pub media_id: Uuid,
pub isbn: Option<String>, pub isbn: Option<String>,
@ -42,6 +42,7 @@ pub struct BookMetadataResponse {
pub series_index: Option<f64>, pub series_index: Option<f64>,
pub format: Option<String>, pub format: Option<String>,
pub authors: Vec<AuthorResponse>, pub authors: Vec<AuthorResponse>,
#[schema(value_type = Object)]
pub identifiers: FxHashMap<String, Vec<String>>, pub identifiers: FxHashMap<String, Vec<String>>,
} }
@ -69,7 +70,7 @@ impl From<BookMetadata> for BookMetadataResponse {
} }
/// Author response DTO /// Author response DTO
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct AuthorResponse { pub struct AuthorResponse {
pub name: String, pub name: String,
pub role: String, pub role: String,
@ -89,7 +90,7 @@ impl From<AuthorInfo> for AuthorResponse {
} }
/// Reading progress response DTO /// Reading progress response DTO
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ReadingProgressResponse { pub struct ReadingProgressResponse {
pub media_id: Uuid, pub media_id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
@ -113,7 +114,7 @@ impl From<ReadingProgress> for ReadingProgressResponse {
} }
/// Update reading progress request /// Update reading progress request
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdateProgressRequest { pub struct UpdateProgressRequest {
pub current_page: i32, pub current_page: i32,
} }
@ -141,20 +142,32 @@ const fn default_limit() -> u64 {
} }
/// Series summary DTO /// Series summary DTO
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct SeriesSummary { pub struct SeriesSummary {
pub name: String, pub name: String,
pub book_count: u64, pub book_count: u64,
} }
/// Author summary DTO /// Author summary DTO
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct AuthorSummary { pub struct AuthorSummary {
pub name: String, pub name: String,
pub book_count: u64, pub book_count: u64,
} }
/// Get book metadata by media ID /// Get book metadata by media ID
#[utoipa::path(
get,
path = "/api/v1/books/{id}/metadata",
tag = "books",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Book metadata", body = BookMetadataResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_book_metadata( pub async fn get_book_metadata(
State(state): State<AppState>, State(state): State<AppState>,
Path(media_id): Path<Uuid>, Path(media_id): Path<Uuid>,
@ -173,6 +186,26 @@ pub async fn get_book_metadata(
} }
/// List all books with optional search filters /// List all books with optional search filters
#[utoipa::path(
get,
path = "/api/v1/books",
tag = "books",
params(
("isbn" = Option<String>, Query, description = "Filter by ISBN"),
("author" = Option<String>, Query, description = "Filter by author"),
("series" = Option<String>, Query, description = "Filter by series"),
("publisher" = Option<String>, Query, description = "Filter by publisher"),
("language" = Option<String>, Query, description = "Filter by language"),
("offset" = Option<u64>, Query, description = "Pagination offset"),
("limit" = Option<u64>, Query, description = "Pagination limit"),
),
responses(
(status = 200, description = "List of books", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_books( pub async fn list_books(
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<SearchBooksQuery>, Query(query): Query<SearchBooksQuery>,
@ -204,6 +237,16 @@ pub async fn list_books(
} }
/// List all series with book counts /// List all series with book counts
#[utoipa::path(
get,
path = "/api/v1/books/series",
tag = "books",
responses(
(status = 200, description = "List of series with counts", body = Vec<SeriesSummary>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_series( pub async fn list_series(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
@ -222,6 +265,17 @@ pub async fn list_series(
} }
/// Get books in a specific series /// Get books in a specific series
#[utoipa::path(
get,
path = "/api/v1/books/series/{name}",
tag = "books",
params(("name" = String, Path, description = "Series name")),
responses(
(status = 200, description = "Books in series", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_series_books( pub async fn get_series_books(
State(state): State<AppState>, State(state): State<AppState>,
Path(series_name): Path<String>, Path(series_name): Path<String>,
@ -236,6 +290,20 @@ pub async fn get_series_books(
} }
/// List all authors with book counts /// List all authors with book counts
#[utoipa::path(
get,
path = "/api/v1/books/authors",
tag = "books",
params(
("offset" = Option<u64>, Query, description = "Pagination offset"),
("limit" = Option<u64>, Query, description = "Pagination limit"),
),
responses(
(status = 200, description = "Authors with book counts", body = Vec<AuthorSummary>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_authors( pub async fn list_authors(
State(state): State<AppState>, State(state): State<AppState>,
Query(pagination): Query<Pagination>, Query(pagination): Query<Pagination>,
@ -255,6 +323,21 @@ pub async fn list_authors(
} }
/// Get books by a specific author /// Get books by a specific author
#[utoipa::path(
get,
path = "/api/v1/books/authors/{name}/books",
tag = "books",
params(
("name" = String, Path, description = "Author name"),
("offset" = Option<u64>, Query, description = "Pagination offset"),
("limit" = Option<u64>, Query, description = "Pagination limit"),
),
responses(
(status = 200, description = "Books by author", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_author_books( pub async fn get_author_books(
State(state): State<AppState>, State(state): State<AppState>,
Path(author_name): Path<String>, Path(author_name): Path<String>,
@ -274,6 +357,18 @@ pub async fn get_author_books(
} }
/// Get reading progress for a book /// Get reading progress for a book
#[utoipa::path(
get,
path = "/api/v1/books/{id}/progress",
tag = "books",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Reading progress", body = ReadingProgressResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_reading_progress( pub async fn get_reading_progress(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -294,6 +389,19 @@ pub async fn get_reading_progress(
} }
/// Update reading progress for a book /// Update reading progress for a book
#[utoipa::path(
put,
path = "/api/v1/books/{id}/progress",
tag = "books",
params(("id" = Uuid, Path, description = "Media item ID")),
request_body = UpdateProgressRequest,
responses(
(status = 204, description = "Progress updated"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn update_reading_progress( pub async fn update_reading_progress(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -306,6 +414,10 @@ pub async fn update_reading_progress(
let user_id = resolve_user_id(&state.storage, &username).await?; let user_id = resolve_user_id(&state.storage, &username).await?;
let media_id = MediaId(media_id); let media_id = MediaId(media_id);
// Verify the media item exists before writing progress; a FK violation from
// the storage layer would otherwise surface as a 500 rather than 404.
state.storage.get_media(media_id).await?;
state state
.storage .storage
.update_reading_progress(user_id.0, media_id, req.current_page) .update_reading_progress(user_id.0, media_id, req.current_page)
@ -315,6 +427,17 @@ pub async fn update_reading_progress(
} }
/// Get user's reading list /// Get user's reading list
#[utoipa::path(
get,
path = "/api/v1/books/reading-list",
tag = "books",
params(("status" = Option<String>, Query, description = "Filter by reading status. Valid values: to_read, reading, completed, abandoned")),
responses(
(status = 200, description = "Reading list", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_reading_list( pub async fn get_reading_list(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,

View file

@ -16,6 +16,20 @@ use crate::{
state::AppState, state::AppState,
}; };
#[utoipa::path(
post,
path = "/api/v1/collections",
tag = "collections",
request_body = CreateCollectionRequest,
responses(
(status = 200, description = "Collection created", body = CollectionResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn create_collection( pub async fn create_collection(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<CreateCollectionRequest>, Json(req): Json<CreateCollectionRequest>,
@ -60,6 +74,17 @@ pub async fn create_collection(
Ok(Json(CollectionResponse::from(col))) Ok(Json(CollectionResponse::from(col)))
} }
#[utoipa::path(
get,
path = "/api/v1/collections",
tag = "collections",
responses(
(status = 200, description = "List of collections", body = Vec<CollectionResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_collections( pub async fn list_collections(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<CollectionResponse>>, ApiError> { ) -> Result<Json<Vec<CollectionResponse>>, ApiError> {
@ -69,6 +94,19 @@ pub async fn list_collections(
)) ))
} }
#[utoipa::path(
get,
path = "/api/v1/collections/{id}",
tag = "collections",
params(("id" = Uuid, Path, description = "Collection ID")),
responses(
(status = 200, description = "Collection", body = CollectionResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_collection( pub async fn get_collection(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -77,6 +115,20 @@ pub async fn get_collection(
Ok(Json(CollectionResponse::from(col))) Ok(Json(CollectionResponse::from(col)))
} }
#[utoipa::path(
delete,
path = "/api/v1/collections/{id}",
tag = "collections",
params(("id" = Uuid, Path, description = "Collection ID")),
responses(
(status = 200, description = "Collection deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_collection( pub async fn delete_collection(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -91,6 +143,21 @@ pub async fn delete_collection(
Ok(Json(serde_json::json!({"deleted": true}))) Ok(Json(serde_json::json!({"deleted": true})))
} }
#[utoipa::path(
post,
path = "/api/v1/collections/{id}/members",
tag = "collections",
params(("id" = Uuid, Path, description = "Collection ID")),
request_body = AddMemberRequest,
responses(
(status = 200, description = "Member added"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn add_member( pub async fn add_member(
State(state): State<AppState>, State(state): State<AppState>,
Path(collection_id): Path<Uuid>, Path(collection_id): Path<Uuid>,
@ -106,6 +173,23 @@ pub async fn add_member(
Ok(Json(serde_json::json!({"added": true}))) Ok(Json(serde_json::json!({"added": true})))
} }
#[utoipa::path(
delete,
path = "/api/v1/collections/{id}/members/{media_id}",
tag = "collections",
params(
("id" = Uuid, Path, description = "Collection ID"),
("media_id" = Uuid, Path, description = "Media item ID"),
),
responses(
(status = 200, description = "Member removed"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn remove_member( pub async fn remove_member(
State(state): State<AppState>, State(state): State<AppState>,
Path((collection_id, media_id)): Path<(Uuid, Uuid)>, Path((collection_id, media_id)): Path<(Uuid, Uuid)>,
@ -119,6 +203,19 @@ pub async fn remove_member(
Ok(Json(serde_json::json!({"removed": true}))) Ok(Json(serde_json::json!({"removed": true})))
} }
#[utoipa::path(
get,
path = "/api/v1/collections/{id}/members",
tag = "collections",
params(("id" = Uuid, Path, description = "Collection ID")),
responses(
(status = 200, description = "Collection members", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_members( pub async fn get_members(
State(state): State<AppState>, State(state): State<AppState>,
Path(collection_id): Path<Uuid>, Path(collection_id): Path<Uuid>,

View file

@ -14,6 +14,18 @@ use crate::{
state::AppState, state::AppState,
}; };
#[utoipa::path(
get,
path = "/api/v1/config",
tag = "config",
responses(
(status = 200, description = "Current server configuration", body = ConfigResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_config( pub async fn get_config(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<ConfigResponse>, ApiError> { ) -> Result<Json<ConfigResponse>, ApiError> {
@ -63,6 +75,17 @@ pub async fn get_config(
})) }))
} }
#[utoipa::path(
get,
path = "/api/v1/config/ui",
tag = "config",
responses(
(status = 200, description = "UI configuration", body = UiConfigResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_ui_config( pub async fn get_ui_config(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<UiConfigResponse>, ApiError> { ) -> Result<Json<UiConfigResponse>, ApiError> {
@ -70,6 +93,19 @@ pub async fn get_ui_config(
Ok(Json(UiConfigResponse::from(&config.ui))) Ok(Json(UiConfigResponse::from(&config.ui)))
} }
#[utoipa::path(
patch,
path = "/api/v1/config/ui",
tag = "config",
request_body = UpdateUiConfigRequest,
responses(
(status = 200, description = "Updated UI configuration", body = UiConfigResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update_ui_config( pub async fn update_ui_config(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<UpdateUiConfigRequest>, Json(req): Json<UpdateUiConfigRequest>,
@ -104,6 +140,19 @@ pub async fn update_ui_config(
Ok(Json(UiConfigResponse::from(&config.ui))) Ok(Json(UiConfigResponse::from(&config.ui)))
} }
#[utoipa::path(
patch,
path = "/api/v1/config/scanning",
tag = "config",
request_body = UpdateScanningRequest,
responses(
(status = 200, description = "Updated configuration", body = ConfigResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update_scanning_config( pub async fn update_scanning_config(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<UpdateScanningRequest>, Json(req): Json<UpdateScanningRequest>,
@ -169,6 +218,20 @@ pub async fn update_scanning_config(
})) }))
} }
#[utoipa::path(
post,
path = "/api/v1/config/roots",
tag = "config",
request_body = RootDirRequest,
responses(
(status = 200, description = "Updated configuration", body = ConfigResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn add_root( pub async fn add_root(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RootDirRequest>, Json(req): Json<RootDirRequest>,
@ -196,6 +259,19 @@ pub async fn add_root(
get_config(State(state)).await get_config(State(state)).await
} }
#[utoipa::path(
delete,
path = "/api/v1/config/roots",
tag = "config",
request_body = RootDirRequest,
responses(
(status = 200, description = "Updated configuration", body = ConfigResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn remove_root( pub async fn remove_root(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<RootDirRequest>, Json(req): Json<RootDirRequest>,

View file

@ -2,6 +2,18 @@ use axum::{Json, extract::State};
use crate::{dto::DatabaseStatsResponse, error::ApiError, state::AppState}; use crate::{dto::DatabaseStatsResponse, error::ApiError, state::AppState};
#[utoipa::path(
get,
path = "/api/v1/admin/database/stats",
tag = "database",
responses(
(status = 200, description = "Database statistics", body = DatabaseStatsResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn database_stats( pub async fn database_stats(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<DatabaseStatsResponse>, ApiError> { ) -> Result<Json<DatabaseStatsResponse>, ApiError> {
@ -16,6 +28,18 @@ pub async fn database_stats(
})) }))
} }
#[utoipa::path(
post,
path = "/api/v1/admin/database/vacuum",
tag = "database",
responses(
(status = 200, description = "Database vacuumed"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn vacuum_database( pub async fn vacuum_database(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
@ -23,6 +47,18 @@ pub async fn vacuum_database(
Ok(Json(serde_json::json!({"status": "ok"}))) Ok(Json(serde_json::json!({"status": "ok"})))
} }
#[utoipa::path(
post,
path = "/api/v1/admin/database/clear",
tag = "database",
responses(
(status = 200, description = "Database cleared"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn clear_database( pub async fn clear_database(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {

View file

@ -6,6 +6,17 @@ use crate::{
state::AppState, state::AppState,
}; };
#[utoipa::path(
get,
path = "/api/v1/media/duplicates",
tag = "duplicates",
responses(
(status = 200, description = "Duplicate groups", body = Vec<DuplicateGroupResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_duplicates( pub async fn list_duplicates(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<DuplicateGroupResponse>>, ApiError> { ) -> Result<Json<Vec<DuplicateGroupResponse>>, ApiError> {

View file

@ -11,6 +11,20 @@ use crate::{
state::AppState, state::AppState,
}; };
#[utoipa::path(
post,
path = "/api/v1/media/{id}/enrich",
tag = "enrichment",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Enrichment job submitted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn trigger_enrichment( pub async fn trigger_enrichment(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -25,6 +39,19 @@ pub async fn trigger_enrichment(
Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) Ok(Json(serde_json::json!({"job_id": job_id.to_string()})))
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}/metadata/external",
tag = "enrichment",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "External metadata", body = Vec<ExternalMetadataResponse>),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_external_metadata( pub async fn get_external_metadata(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -38,6 +65,20 @@ pub async fn get_external_metadata(
)) ))
} }
#[utoipa::path(
post,
path = "/api/v1/media/enrich/batch",
tag = "enrichment",
request_body = BatchDeleteRequest,
responses(
(status = 200, description = "Enrichment job submitted"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn batch_enrich( pub async fn batch_enrich(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<BatchDeleteRequest>, // Reuse: has media_ids field Json(req): Json<BatchDeleteRequest>, // Reuse: has media_ids field

View file

@ -5,12 +5,25 @@ use serde::Deserialize;
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ExportRequest { pub struct ExportRequest {
pub format: String, pub format: String,
#[schema(value_type = String)]
pub destination: PathBuf, pub destination: PathBuf,
} }
#[utoipa::path(
post,
path = "/api/v1/export",
tag = "export",
responses(
(status = 200, description = "Export job submitted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn trigger_export( pub async fn trigger_export(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
@ -25,6 +38,19 @@ pub async fn trigger_export(
Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) Ok(Json(serde_json::json!({ "job_id": job_id.to_string() })))
} }
#[utoipa::path(
post,
path = "/api/v1/export/options",
tag = "export",
request_body = ExportRequest,
responses(
(status = 200, description = "Export job submitted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn trigger_export_with_options( pub async fn trigger_export_with_options(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<ExportRequest>, Json(req): Json<ExportRequest>,

View file

@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::state::AppState; use crate::state::AppState;
/// Basic health check response /// Basic health check response
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct HealthResponse { pub struct HealthResponse {
pub status: String, pub status: String,
pub version: String, pub version: String,
@ -18,7 +18,7 @@ pub struct HealthResponse {
pub cache: Option<CacheHealth>, pub cache: Option<CacheHealth>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DatabaseHealth { pub struct DatabaseHealth {
pub status: String, pub status: String,
pub latency_ms: u64, pub latency_ms: u64,
@ -26,14 +26,14 @@ pub struct DatabaseHealth {
pub media_count: Option<u64>, pub media_count: Option<u64>,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct FilesystemHealth { pub struct FilesystemHealth {
pub status: String, pub status: String,
pub roots_configured: usize, pub roots_configured: usize,
pub roots_accessible: usize, pub roots_accessible: usize,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct CacheHealth { pub struct CacheHealth {
pub hit_rate: f64, pub hit_rate: f64,
pub total_entries: u64, pub total_entries: u64,
@ -43,6 +43,14 @@ pub struct CacheHealth {
} }
/// Comprehensive health check - includes database, filesystem, and cache status /// Comprehensive health check - includes database, filesystem, and cache status
#[utoipa::path(
get,
path = "/api/v1/health",
tag = "health",
responses(
(status = 200, description = "Health status", body = HealthResponse),
)
)]
pub async fn health(State(state): State<AppState>) -> Json<HealthResponse> { pub async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
let mut response = HealthResponse { let mut response = HealthResponse {
status: "ok".to_string(), status: "ok".to_string(),
@ -106,6 +114,14 @@ pub async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
/// Liveness probe - just checks if the server is running /// Liveness probe - just checks if the server is running
/// Returns 200 OK if the server process is alive /// Returns 200 OK if the server process is alive
#[utoipa::path(
get,
path = "/api/v1/health/live",
tag = "health",
responses(
(status = 200, description = "Server is alive"),
)
)]
pub async fn liveness() -> impl IntoResponse { pub async fn liveness() -> impl IntoResponse {
( (
StatusCode::OK, StatusCode::OK,
@ -117,6 +133,15 @@ pub async fn liveness() -> impl IntoResponse {
/// Readiness probe - checks if the server can serve requests /// Readiness probe - checks if the server can serve requests
/// Returns 200 OK if database is accessible /// Returns 200 OK if database is accessible
#[utoipa::path(
get,
path = "/api/v1/health/ready",
tag = "health",
responses(
(status = 200, description = "Server is ready"),
(status = 503, description = "Server not ready"),
)
)]
pub async fn readiness(State(state): State<AppState>) -> impl IntoResponse { pub async fn readiness(State(state): State<AppState>) -> impl IntoResponse {
// Check database connectivity // Check database connectivity
let db_start = Instant::now(); let db_start = Instant::now();
@ -144,7 +169,7 @@ pub async fn readiness(State(state): State<AppState>) -> impl IntoResponse {
} }
/// Detailed health check for monitoring dashboards /// Detailed health check for monitoring dashboards
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct DetailedHealthResponse { pub struct DetailedHealthResponse {
pub status: String, pub status: String,
pub version: String, pub version: String,
@ -155,12 +180,20 @@ pub struct DetailedHealthResponse {
pub jobs: JobsHealth, pub jobs: JobsHealth,
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct JobsHealth { pub struct JobsHealth {
pub pending: usize, pub pending: usize,
pub running: usize, pub running: usize,
} }
#[utoipa::path(
get,
path = "/api/v1/health/detailed",
tag = "health",
responses(
(status = 200, description = "Detailed health status", body = DetailedHealthResponse),
)
)]
pub async fn health_detailed( pub async fn health_detailed(
State(state): State<AppState>, State(state): State<AppState>,
) -> Json<DetailedHealthResponse> { ) -> Json<DetailedHealthResponse> {

View file

@ -3,12 +3,24 @@ use serde::Deserialize;
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct OrphanResolveRequest { pub struct OrphanResolveRequest {
pub action: String, pub action: String,
pub ids: Vec<uuid::Uuid>, pub ids: Vec<uuid::Uuid>,
} }
#[utoipa::path(
post,
path = "/api/v1/admin/integrity/orphans/detect",
tag = "integrity",
responses(
(status = 200, description = "Orphan detection job submitted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn trigger_orphan_detection( pub async fn trigger_orphan_detection(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
@ -17,6 +29,19 @@ pub async fn trigger_orphan_detection(
Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) Ok(Json(serde_json::json!({ "job_id": job_id.to_string() })))
} }
#[utoipa::path(
post,
path = "/api/v1/admin/integrity/verify",
tag = "integrity",
request_body = VerifyIntegrityRequest,
responses(
(status = 200, description = "Integrity verification job submitted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn trigger_verify_integrity( pub async fn trigger_verify_integrity(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<VerifyIntegrityRequest>, Json(req): Json<VerifyIntegrityRequest>,
@ -31,11 +56,23 @@ pub async fn trigger_verify_integrity(
Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) Ok(Json(serde_json::json!({ "job_id": job_id.to_string() })))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct VerifyIntegrityRequest { pub struct VerifyIntegrityRequest {
pub media_ids: Vec<uuid::Uuid>, pub media_ids: Vec<uuid::Uuid>,
} }
#[utoipa::path(
post,
path = "/api/v1/admin/integrity/thumbnails/cleanup",
tag = "integrity",
responses(
(status = 200, description = "Thumbnail cleanup job submitted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn trigger_cleanup_thumbnails( pub async fn trigger_cleanup_thumbnails(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {
@ -44,7 +81,7 @@ pub async fn trigger_cleanup_thumbnails(
Ok(Json(serde_json::json!({ "job_id": job_id.to_string() }))) Ok(Json(serde_json::json!({ "job_id": job_id.to_string() })))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct GenerateThumbnailsRequest { pub struct GenerateThumbnailsRequest {
/// When true, only generate thumbnails for items that don't have one yet. /// When true, only generate thumbnails for items that don't have one yet.
/// When false (default), regenerate all thumbnails. /// When false (default), regenerate all thumbnails.
@ -52,6 +89,19 @@ pub struct GenerateThumbnailsRequest {
pub only_missing: bool, pub only_missing: bool,
} }
#[utoipa::path(
post,
path = "/api/v1/admin/integrity/thumbnails/generate",
tag = "integrity",
request_body = GenerateThumbnailsRequest,
responses(
(status = 200, description = "Thumbnail generation job submitted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn generate_all_thumbnails( pub async fn generate_all_thumbnails(
State(state): State<AppState>, State(state): State<AppState>,
body: Option<Json<GenerateThumbnailsRequest>>, body: Option<Json<GenerateThumbnailsRequest>>,
@ -77,6 +127,19 @@ pub async fn generate_all_thumbnails(
}))) })))
} }
#[utoipa::path(
post,
path = "/api/v1/admin/integrity/orphans/resolve",
tag = "integrity",
request_body = OrphanResolveRequest,
responses(
(status = 200, description = "Orphans resolved"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn resolve_orphans( pub async fn resolve_orphans(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<OrphanResolveRequest>, Json(req): Json<OrphanResolveRequest>,

View file

@ -6,10 +6,34 @@ use pinakes_core::jobs::Job;
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
#[utoipa::path(
get,
path = "/api/v1/jobs",
tag = "jobs",
responses(
(status = 200, description = "List of jobs"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn list_jobs(State(state): State<AppState>) -> Json<Vec<Job>> { pub async fn list_jobs(State(state): State<AppState>) -> Json<Vec<Job>> {
Json(state.job_queue.list().await) Json(state.job_queue.list().await)
} }
#[utoipa::path(
get,
path = "/api/v1/jobs/{id}",
tag = "jobs",
params(("id" = uuid::Uuid, Path, description = "Job ID")),
responses(
(status = 200, description = "Job details"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_job( pub async fn get_job(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<uuid::Uuid>, Path(id): Path<uuid::Uuid>,
@ -20,6 +44,19 @@ pub async fn get_job(
}) })
} }
#[utoipa::path(
post,
path = "/api/v1/jobs/{id}/cancel",
tag = "jobs",
params(("id" = uuid::Uuid, Path, description = "Job ID")),
responses(
(status = 200, description = "Job cancelled"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn cancel_job( pub async fn cancel_job(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<uuid::Uuid>, Path(id): Path<uuid::Uuid>,

View file

@ -99,6 +99,20 @@ async fn apply_import_post_processing(
} }
} }
#[utoipa::path(
post,
path = "/api/v1/media/import",
tag = "media",
request_body = ImportRequest,
responses(
(status = 200, description = "Media imported", body = ImportResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn import_media( pub async fn import_media(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<ImportRequest>, Json(req): Json<ImportRequest>,
@ -126,6 +140,22 @@ pub async fn import_media(
})) }))
} }
#[utoipa::path(
get,
path = "/api/v1/media",
tag = "media",
params(
("offset" = Option<u64>, Query, description = "Pagination offset"),
("limit" = Option<u64>, Query, description = "Page size"),
("sort" = Option<String>, Query, description = "Sort field"),
),
responses(
(status = 200, description = "List of media items", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_media( pub async fn list_media(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<PaginationParams>, Query(params): Query<PaginationParams>,
@ -141,6 +171,19 @@ pub async fn list_media(
)) ))
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}",
tag = "media",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Media item", body = MediaResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_media( pub async fn get_media(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -172,6 +215,22 @@ fn validate_optional_text(
Ok(()) Ok(())
} }
#[utoipa::path(
patch,
path = "/api/v1/media/{id}",
tag = "media",
params(("id" = Uuid, Path, description = "Media item ID")),
request_body = UpdateMediaRequest,
responses(
(status = 200, description = "Updated media item", body = MediaResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn update_media( pub async fn update_media(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -229,6 +288,20 @@ pub async fn update_media(
Ok(Json(MediaResponse::new(item, &roots))) Ok(Json(MediaResponse::new(item, &roots)))
} }
#[utoipa::path(
delete,
path = "/api/v1/media/{id}",
tag = "media",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Media deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_media( pub async fn delete_media(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -267,6 +340,19 @@ pub async fn delete_media(
Ok(Json(serde_json::json!({"deleted": true}))) Ok(Json(serde_json::json!({"deleted": true})))
} }
#[utoipa::path(
post,
path = "/api/v1/media/{id}/open",
tag = "media",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Media opened"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn open_media( pub async fn open_media(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -284,6 +370,20 @@ pub async fn open_media(
Ok(Json(serde_json::json!({"opened": true}))) Ok(Json(serde_json::json!({"opened": true})))
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}/stream",
tag = "media",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Media stream"),
(status = 206, description = "Partial content"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn stream_media( pub async fn stream_media(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -395,6 +495,20 @@ fn parse_range(header: &str, total_size: u64) -> Option<(u64, u64)> {
} }
} }
#[utoipa::path(
post,
path = "/api/v1/media/import/options",
tag = "media",
request_body = ImportWithOptionsRequest,
responses(
(status = 200, description = "Media imported", body = ImportResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn import_with_options( pub async fn import_with_options(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<ImportWithOptionsRequest>, Json(req): Json<ImportWithOptionsRequest>,
@ -429,6 +543,20 @@ pub async fn import_with_options(
})) }))
} }
#[utoipa::path(
post,
path = "/api/v1/media/import/batch",
tag = "media",
request_body = BatchImportRequest,
responses(
(status = 200, description = "Batch import results", body = BatchImportResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn batch_import( pub async fn batch_import(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<BatchImportRequest>, Json(req): Json<BatchImportRequest>,
@ -503,6 +631,20 @@ pub async fn batch_import(
})) }))
} }
#[utoipa::path(
post,
path = "/api/v1/media/import/directory",
tag = "media",
request_body = DirectoryImportRequest,
responses(
(status = 200, description = "Directory import results", body = BatchImportResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn import_directory_endpoint( pub async fn import_directory_endpoint(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<DirectoryImportRequest>, Json(req): Json<DirectoryImportRequest>,
@ -571,6 +713,19 @@ pub async fn import_directory_endpoint(
})) }))
} }
#[utoipa::path(
post,
path = "/api/v1/media/import/preview",
tag = "media",
responses(
(status = 200, description = "Directory preview", body = DirectoryPreviewResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn preview_directory( pub async fn preview_directory(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<serde_json::Value>, Json(req): Json<serde_json::Value>,
@ -672,6 +827,22 @@ pub async fn preview_directory(
})) }))
} }
#[utoipa::path(
put,
path = "/api/v1/media/{id}/custom-fields",
tag = "media",
params(("id" = Uuid, Path, description = "Media item ID")),
request_body = SetCustomFieldRequest,
responses(
(status = 200, description = "Custom field set"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn set_custom_field( pub async fn set_custom_field(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -709,6 +880,23 @@ pub async fn set_custom_field(
Ok(Json(serde_json::json!({"set": true}))) Ok(Json(serde_json::json!({"set": true})))
} }
#[utoipa::path(
delete,
path = "/api/v1/media/{id}/custom-fields/{name}",
tag = "media",
params(
("id" = Uuid, Path, description = "Media item ID"),
("name" = String, Path, description = "Custom field name"),
),
responses(
(status = 200, description = "Custom field deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_custom_field( pub async fn delete_custom_field(
State(state): State<AppState>, State(state): State<AppState>,
Path((id, name)): Path<(Uuid, String)>, Path((id, name)): Path<(Uuid, String)>,
@ -720,6 +908,20 @@ pub async fn delete_custom_field(
Ok(Json(serde_json::json!({"deleted": true}))) Ok(Json(serde_json::json!({"deleted": true})))
} }
#[utoipa::path(
post,
path = "/api/v1/media/batch/tag",
tag = "media",
request_body = BatchTagRequest,
responses(
(status = 200, description = "Batch tag result", body = BatchOperationResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn batch_tag( pub async fn batch_tag(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<BatchTagRequest>, Json(req): Json<BatchTagRequest>,
@ -754,6 +956,18 @@ pub async fn batch_tag(
} }
} }
#[utoipa::path(
delete,
path = "/api/v1/media",
tag = "media",
responses(
(status = 200, description = "All media deleted", body = BatchOperationResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_all_media( pub async fn delete_all_media(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<BatchOperationResponse>, ApiError> { ) -> Result<Json<BatchOperationResponse>, ApiError> {
@ -785,6 +999,20 @@ pub async fn delete_all_media(
} }
} }
#[utoipa::path(
post,
path = "/api/v1/media/batch/delete",
tag = "media",
request_body = BatchDeleteRequest,
responses(
(status = 200, description = "Batch delete result", body = BatchOperationResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn batch_delete( pub async fn batch_delete(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<BatchDeleteRequest>, Json(req): Json<BatchDeleteRequest>,
@ -829,6 +1057,20 @@ pub async fn batch_delete(
} }
} }
#[utoipa::path(
post,
path = "/api/v1/media/batch/collection",
tag = "media",
request_body = BatchCollectionRequest,
responses(
(status = 200, description = "Batch collection result", body = BatchOperationResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn batch_add_to_collection( pub async fn batch_add_to_collection(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<BatchCollectionRequest>, Json(req): Json<BatchCollectionRequest>,
@ -859,6 +1101,20 @@ pub async fn batch_add_to_collection(
Ok(Json(BatchOperationResponse { processed, errors })) Ok(Json(BatchOperationResponse { processed, errors }))
} }
#[utoipa::path(
post,
path = "/api/v1/media/batch/update",
tag = "media",
request_body = BatchUpdateRequest,
responses(
(status = 200, description = "Batch update result", body = BatchOperationResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn batch_update( pub async fn batch_update(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<BatchUpdateRequest>, Json(req): Json<BatchUpdateRequest>,
@ -901,6 +1157,19 @@ pub async fn batch_update(
} }
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}/thumbnail",
tag = "media",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Thumbnail image"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_thumbnail( pub async fn get_thumbnail(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -934,6 +1203,17 @@ pub async fn get_thumbnail(
}) })
} }
#[utoipa::path(
get,
path = "/api/v1/media/count",
tag = "media",
responses(
(status = 200, description = "Media count", body = MediaCountResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_media_count( pub async fn get_media_count(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<MediaCountResponse>, ApiError> { ) -> Result<Json<MediaCountResponse>, ApiError> {
@ -941,6 +1221,22 @@ pub async fn get_media_count(
Ok(Json(MediaCountResponse { count })) Ok(Json(MediaCountResponse { count }))
} }
#[utoipa::path(
post,
path = "/api/v1/media/{id}/rename",
tag = "media",
params(("id" = Uuid, Path, description = "Media item ID")),
request_body = RenameMediaRequest,
responses(
(status = 200, description = "Renamed media item", body = MediaResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn rename_media( pub async fn rename_media(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -993,6 +1289,22 @@ pub async fn rename_media(
Ok(Json(MediaResponse::new(item, &roots))) Ok(Json(MediaResponse::new(item, &roots)))
} }
#[utoipa::path(
post,
path = "/api/v1/media/{id}/move",
tag = "media",
params(("id" = Uuid, Path, description = "Media item ID")),
request_body = MoveMediaRequest,
responses(
(status = 200, description = "Moved media item", body = MediaResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn move_media_endpoint( pub async fn move_media_endpoint(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -1042,6 +1354,20 @@ pub async fn move_media_endpoint(
Ok(Json(MediaResponse::new(item, &roots))) Ok(Json(MediaResponse::new(item, &roots)))
} }
#[utoipa::path(
post,
path = "/api/v1/media/batch/move",
tag = "media",
request_body = BatchMoveRequest,
responses(
(status = 200, description = "Batch move result", body = BatchOperationResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn batch_move_media( pub async fn batch_move_media(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<BatchMoveRequest>, Json(req): Json<BatchMoveRequest>,
@ -1111,6 +1437,20 @@ pub async fn batch_move_media(
} }
} }
#[utoipa::path(
delete,
path = "/api/v1/media/{id}/trash",
tag = "media",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Media moved to trash"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn soft_delete_media( pub async fn soft_delete_media(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -1157,6 +1497,20 @@ pub async fn soft_delete_media(
Ok(Json(serde_json::json!({"deleted": true, "trashed": true}))) Ok(Json(serde_json::json!({"deleted": true, "trashed": true})))
} }
#[utoipa::path(
post,
path = "/api/v1/media/{id}/restore",
tag = "media",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Media restored", body = MediaResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn restore_media( pub async fn restore_media(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -1204,6 +1558,21 @@ pub async fn restore_media(
Ok(Json(MediaResponse::new(item, &roots))) Ok(Json(MediaResponse::new(item, &roots)))
} }
#[utoipa::path(
get,
path = "/api/v1/media/trash",
tag = "media",
params(
("offset" = Option<u64>, Query, description = "Pagination offset"),
("limit" = Option<u64>, Query, description = "Page size"),
),
responses(
(status = 200, description = "Trashed media items", body = TrashResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_trash( pub async fn list_trash(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<PaginationParams>, Query(params): Query<PaginationParams>,
@ -1223,6 +1592,17 @@ pub async fn list_trash(
})) }))
} }
#[utoipa::path(
get,
path = "/api/v1/media/trash/info",
tag = "media",
responses(
(status = 200, description = "Trash info", body = TrashInfoResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn trash_info( pub async fn trash_info(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<TrashInfoResponse>, ApiError> { ) -> Result<Json<TrashInfoResponse>, ApiError> {
@ -1230,6 +1610,18 @@ pub async fn trash_info(
Ok(Json(TrashInfoResponse { count })) Ok(Json(TrashInfoResponse { count }))
} }
#[utoipa::path(
delete,
path = "/api/v1/media/trash",
tag = "media",
responses(
(status = 200, description = "Trash emptied", body = EmptyTrashResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn empty_trash( pub async fn empty_trash(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<EmptyTrashResponse>, ApiError> { ) -> Result<Json<EmptyTrashResponse>, ApiError> {
@ -1247,6 +1639,23 @@ pub async fn empty_trash(
Ok(Json(EmptyTrashResponse { deleted_count })) Ok(Json(EmptyTrashResponse { deleted_count }))
} }
#[utoipa::path(
delete,
path = "/api/v1/media/{id}/permanent",
tag = "media",
params(
("id" = Uuid, Path, description = "Media item ID"),
("permanent" = Option<String>, Query, description = "Set to 'true' for permanent deletion"),
),
responses(
(status = 200, description = "Media deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn permanent_delete_media( pub async fn permanent_delete_media(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,

View file

@ -26,14 +26,14 @@ use uuid::Uuid;
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
/// Response for backlinks query /// Response for backlinks query
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct BacklinksResponse { pub struct BacklinksResponse {
pub backlinks: Vec<BacklinkItem>, pub backlinks: Vec<BacklinkItem>,
pub count: usize, pub count: usize,
} }
/// Individual backlink item /// Individual backlink item
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct BacklinkItem { pub struct BacklinkItem {
pub link_id: Uuid, pub link_id: Uuid,
pub source_id: Uuid, pub source_id: Uuid,
@ -61,14 +61,14 @@ impl From<BacklinkInfo> for BacklinkItem {
} }
/// Response for outgoing links query /// Response for outgoing links query
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct OutgoingLinksResponse { pub struct OutgoingLinksResponse {
pub links: Vec<OutgoingLinkItem>, pub links: Vec<OutgoingLinkItem>,
pub count: usize, pub count: usize,
} }
/// Individual outgoing link item /// Individual outgoing link item
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct OutgoingLinkItem { pub struct OutgoingLinkItem {
pub id: Uuid, pub id: Uuid,
pub target_path: String, pub target_path: String,
@ -94,7 +94,7 @@ impl From<MarkdownLink> for OutgoingLinkItem {
} }
/// Response for graph visualization /// Response for graph visualization
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct GraphResponse { pub struct GraphResponse {
pub nodes: Vec<GraphNodeResponse>, pub nodes: Vec<GraphNodeResponse>,
pub edges: Vec<GraphEdgeResponse>, pub edges: Vec<GraphEdgeResponse>,
@ -103,7 +103,7 @@ pub struct GraphResponse {
} }
/// Graph node for visualization /// Graph node for visualization
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct GraphNodeResponse { pub struct GraphNodeResponse {
pub id: String, pub id: String,
pub label: String, pub label: String,
@ -127,7 +127,7 @@ impl From<GraphNode> for GraphNodeResponse {
} }
/// Graph edge for visualization /// Graph edge for visualization
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct GraphEdgeResponse { pub struct GraphEdgeResponse {
pub source: String, pub source: String,
pub target: String, pub target: String,
@ -180,20 +180,20 @@ const fn default_depth() -> u32 {
} }
/// Response for reindex operation /// Response for reindex operation
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ReindexResponse { pub struct ReindexResponse {
pub message: String, pub message: String,
pub links_extracted: usize, pub links_extracted: usize,
} }
/// Response for link resolution /// Response for link resolution
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ResolveLinksResponse { pub struct ResolveLinksResponse {
pub resolved_count: u64, pub resolved_count: u64,
} }
/// Response for unresolved links count /// Response for unresolved links count
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UnresolvedLinksResponse { pub struct UnresolvedLinksResponse {
pub count: u64, pub count: u64,
} }
@ -201,6 +201,19 @@ pub struct UnresolvedLinksResponse {
/// Get backlinks (incoming links) to a media item. /// Get backlinks (incoming links) to a media item.
/// ///
/// GET /api/v1/media/{id}/backlinks /// GET /api/v1/media/{id}/backlinks
#[utoipa::path(
get,
path = "/api/v1/media/{id}/backlinks",
tag = "notes",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Backlinks", body = BacklinksResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_backlinks( pub async fn get_backlinks(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -221,6 +234,19 @@ pub async fn get_backlinks(
/// Get outgoing links from a media item. /// Get outgoing links from a media item.
/// ///
/// GET /api/v1/media/{id}/outgoing-links /// GET /api/v1/media/{id}/outgoing-links
#[utoipa::path(
get,
path = "/api/v1/media/{id}/outgoing-links",
tag = "notes",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Outgoing links", body = OutgoingLinksResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_outgoing_links( pub async fn get_outgoing_links(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -241,6 +267,21 @@ pub async fn get_outgoing_links(
/// Get graph data for visualization. /// Get graph data for visualization.
/// ///
/// GET /api/v1/notes/graph?center={uuid}&depth={n} /// GET /api/v1/notes/graph?center={uuid}&depth={n}
#[utoipa::path(
get,
path = "/api/v1/notes/graph",
tag = "notes",
params(
("center" = Option<Uuid>, Query, description = "Center node ID"),
("depth" = Option<u32>, Query, description = "Traversal depth (max 5, default 2)"),
),
responses(
(status = 200, description = "Graph data", body = GraphResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_graph( pub async fn get_graph(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<GraphQuery>, Query(params): Query<GraphQuery>,
@ -256,6 +297,19 @@ pub async fn get_graph(
/// Re-extract links from a media item. /// Re-extract links from a media item.
/// ///
/// POST /api/v1/media/{id}/reindex-links /// POST /api/v1/media/{id}/reindex-links
#[utoipa::path(
post,
path = "/api/v1/media/{id}/reindex-links",
tag = "notes",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Links reindexed", body = ReindexResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn reindex_links( pub async fn reindex_links(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -304,6 +358,17 @@ pub async fn reindex_links(
/// Resolve all unresolved links in the database. /// Resolve all unresolved links in the database.
/// ///
/// POST /api/v1/notes/resolve-links /// POST /api/v1/notes/resolve-links
#[utoipa::path(
post,
path = "/api/v1/notes/resolve-links",
tag = "notes",
responses(
(status = 200, description = "Links resolved", body = ResolveLinksResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn resolve_links( pub async fn resolve_links(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<ResolveLinksResponse>, ApiError> { ) -> Result<Json<ResolveLinksResponse>, ApiError> {
@ -315,6 +380,17 @@ pub async fn resolve_links(
/// Get count of unresolved links. /// Get count of unresolved links.
/// ///
/// GET /api/v1/notes/unresolved-count /// GET /api/v1/notes/unresolved-count
#[utoipa::path(
get,
path = "/api/v1/notes/unresolved-count",
tag = "notes",
responses(
(status = 200, description = "Unresolved link count", body = UnresolvedLinksResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_unresolved_count( pub async fn get_unresolved_count(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<UnresolvedLinksResponse>, ApiError> { ) -> Result<Json<UnresolvedLinksResponse>, ApiError> {

View file

@ -36,7 +36,7 @@ const fn default_timeline_limit() -> u64 {
} }
/// Timeline group response /// Timeline group response
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct TimelineGroup { pub struct TimelineGroup {
pub date: String, pub date: String,
pub count: usize, pub count: usize,
@ -54,7 +54,7 @@ pub struct MapQuery {
} }
/// Map marker response /// Map marker response
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct MapMarker { pub struct MapMarker {
pub id: String, pub id: String,
pub latitude: f64, pub latitude: f64,
@ -63,6 +63,23 @@ pub struct MapMarker {
pub date_taken: Option<DateTime<Utc>>, 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 /// Get timeline of photos grouped by date
pub async fn get_timeline( pub async fn get_timeline(
State(state): State<AppState>, State(state): State<AppState>,
@ -147,6 +164,24 @@ pub async fn get_timeline(
Ok(Json(timeline)) 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 /// Get photos in a bounding box for map view
pub async fn get_map_photos( pub async fn get_map_photos(
State(state): State<AppState>, State(state): State<AppState>,

View file

@ -51,6 +51,19 @@ async fn check_playlist_access(
Ok(playlist) Ok(playlist)
} }
#[utoipa::path(
post,
path = "/api/v1/playlists",
tag = "playlists",
request_body = CreatePlaylistRequest,
responses(
(status = 200, description = "Playlist created", body = PlaylistResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn create_playlist( pub async fn create_playlist(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -78,6 +91,17 @@ pub async fn create_playlist(
Ok(Json(PlaylistResponse::from(playlist))) Ok(Json(PlaylistResponse::from(playlist)))
} }
#[utoipa::path(
get,
path = "/api/v1/playlists",
tag = "playlists",
responses(
(status = 200, description = "List of playlists", body = Vec<PlaylistResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_playlists( pub async fn list_playlists(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -93,6 +117,19 @@ pub async fn list_playlists(
Ok(Json(visible)) Ok(Json(visible))
} }
#[utoipa::path(
get,
path = "/api/v1/playlists/{id}",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
responses(
(status = 200, description = "Playlist details", body = PlaylistResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_playlist( pub async fn get_playlist(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -104,6 +141,21 @@ pub async fn get_playlist(
Ok(Json(PlaylistResponse::from(playlist))) Ok(Json(PlaylistResponse::from(playlist)))
} }
#[utoipa::path(
patch,
path = "/api/v1/playlists/{id}",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
request_body = UpdatePlaylistRequest,
responses(
(status = 200, description = "Playlist updated", body = PlaylistResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn update_playlist( pub async fn update_playlist(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -133,6 +185,19 @@ pub async fn update_playlist(
Ok(Json(PlaylistResponse::from(playlist))) Ok(Json(PlaylistResponse::from(playlist)))
} }
#[utoipa::path(
delete,
path = "/api/v1/playlists/{id}",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
responses(
(status = 200, description = "Playlist deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_playlist( pub async fn delete_playlist(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -144,6 +209,20 @@ pub async fn delete_playlist(
Ok(Json(serde_json::json!({"deleted": true}))) Ok(Json(serde_json::json!({"deleted": true})))
} }
#[utoipa::path(
post,
path = "/api/v1/playlists/{id}/items",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
request_body = PlaylistItemRequest,
responses(
(status = 200, description = "Item added"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn add_item( pub async fn add_item(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -165,6 +244,22 @@ pub async fn add_item(
Ok(Json(serde_json::json!({"added": true}))) Ok(Json(serde_json::json!({"added": true})))
} }
#[utoipa::path(
delete,
path = "/api/v1/playlists/{id}/items/{media_id}",
tag = "playlists",
params(
("id" = Uuid, Path, description = "Playlist ID"),
("media_id" = Uuid, Path, description = "Media item ID"),
),
responses(
(status = 200, description = "Item removed"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn remove_item( pub async fn remove_item(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -179,6 +274,19 @@ pub async fn remove_item(
Ok(Json(serde_json::json!({"removed": true}))) Ok(Json(serde_json::json!({"removed": true})))
} }
#[utoipa::path(
get,
path = "/api/v1/playlists/{id}/items",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
responses(
(status = 200, description = "Playlist items", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn list_items( pub async fn list_items(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -196,6 +304,20 @@ pub async fn list_items(
)) ))
} }
#[utoipa::path(
patch,
path = "/api/v1/playlists/{id}/items/reorder",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
request_body = ReorderPlaylistRequest,
responses(
(status = 200, description = "Item reordered"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn reorder_item( pub async fn reorder_item(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -211,6 +333,19 @@ pub async fn reorder_item(
Ok(Json(serde_json::json!({"reordered": true}))) Ok(Json(serde_json::json!({"reordered": true})))
} }
#[utoipa::path(
post,
path = "/api/v1/playlists/{id}/shuffle",
tag = "playlists",
params(("id" = Uuid, Path, description = "Playlist ID")),
responses(
(status = 200, description = "Shuffled playlist items", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn shuffle_playlist( pub async fn shuffle_playlist(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,

View file

@ -31,6 +31,17 @@ fn require_plugin_manager(
} }
/// List all installed plugins /// List all installed plugins
#[utoipa::path(
get,
path = "/api/v1/plugins",
tag = "plugins",
responses(
(status = 200, description = "List of plugins", body = Vec<PluginResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_plugins( pub async fn list_plugins(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<PluginResponse>>, ApiError> { ) -> Result<Json<Vec<PluginResponse>>, ApiError> {
@ -46,6 +57,18 @@ pub async fn list_plugins(
} }
/// Get a specific plugin by ID /// Get a specific plugin by ID
#[utoipa::path(
get,
path = "/api/v1/plugins/{id}",
tag = "plugins",
params(("id" = String, Path, description = "Plugin ID")),
responses(
(status = 200, description = "Plugin details", body = PluginResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_plugin( pub async fn get_plugin(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
@ -63,6 +86,19 @@ pub async fn get_plugin(
} }
/// Install a plugin from URL or file path /// Install a plugin from URL or file path
#[utoipa::path(
post,
path = "/api/v1/plugins",
tag = "plugins",
request_body = InstallPluginRequest,
responses(
(status = 200, description = "Plugin installed", body = PluginResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn install_plugin( pub async fn install_plugin(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<InstallPluginRequest>, Json(req): Json<InstallPluginRequest>,
@ -91,6 +127,19 @@ pub async fn install_plugin(
} }
/// Uninstall a plugin /// Uninstall a plugin
#[utoipa::path(
delete,
path = "/api/v1/plugins/{id}",
tag = "plugins",
params(("id" = String, Path, description = "Plugin ID")),
responses(
(status = 200, description = "Plugin uninstalled"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn uninstall_plugin( pub async fn uninstall_plugin(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
@ -107,6 +156,20 @@ pub async fn uninstall_plugin(
} }
/// Enable or disable a plugin /// Enable or disable a plugin
#[utoipa::path(
patch,
path = "/api/v1/plugins/{id}/toggle",
tag = "plugins",
params(("id" = String, Path, description = "Plugin ID")),
request_body = TogglePluginRequest,
responses(
(status = 200, description = "Plugin toggled"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn toggle_plugin( pub async fn toggle_plugin(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
@ -146,6 +209,16 @@ pub async fn toggle_plugin(
} }
/// List all UI pages provided by loaded plugins /// List all UI pages provided by loaded plugins
#[utoipa::path(
get,
path = "/api/v1/plugins/ui/pages",
tag = "plugins",
responses(
(status = 200, description = "Plugin UI pages", body = Vec<PluginUiPageEntry>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_plugin_ui_pages( pub async fn list_plugin_ui_pages(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<PluginUiPageEntry>>, ApiError> { ) -> Result<Json<Vec<PluginUiPageEntry>>, ApiError> {
@ -166,6 +239,16 @@ pub async fn list_plugin_ui_pages(
} }
/// List all UI widgets provided by loaded plugins /// List all UI widgets provided by loaded plugins
#[utoipa::path(
get,
path = "/api/v1/plugins/ui/widgets",
tag = "plugins",
responses(
(status = 200, description = "Plugin UI widgets", body = Vec<PluginUiWidgetEntry>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_plugin_ui_widgets( pub async fn list_plugin_ui_widgets(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<PluginUiWidgetEntry>>, ApiError> { ) -> Result<Json<Vec<PluginUiWidgetEntry>>, ApiError> {
@ -181,6 +264,17 @@ pub async fn list_plugin_ui_widgets(
/// Receive a plugin event emitted from the UI and dispatch it to interested /// Receive a plugin event emitted from the UI and dispatch it to interested
/// server-side event-handler plugins via the pipeline. /// server-side event-handler plugins via the pipeline.
#[utoipa::path(
post,
path = "/api/v1/plugins/events",
tag = "plugins",
request_body = PluginEventRequest,
responses(
(status = 200, description = "Event received"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn emit_plugin_event( pub async fn emit_plugin_event(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<PluginEventRequest>, Json(req): Json<PluginEventRequest>,
@ -193,6 +287,16 @@ pub async fn emit_plugin_event(
} }
/// List merged CSS custom property overrides from all enabled plugins /// List merged CSS custom property overrides from all enabled plugins
#[utoipa::path(
get,
path = "/api/v1/plugins/ui/theme",
tag = "plugins",
responses(
(status = 200, description = "Plugin UI theme extensions"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_plugin_ui_theme_extensions( pub async fn list_plugin_ui_theme_extensions(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<FxHashMap<String, String>>, ApiError> { ) -> Result<Json<FxHashMap<String, String>>, ApiError> {
@ -201,6 +305,19 @@ pub async fn list_plugin_ui_theme_extensions(
} }
/// Reload a plugin (for development) /// Reload a plugin (for development)
#[utoipa::path(
post,
path = "/api/v1/plugins/{id}/reload",
tag = "plugins",
params(("id" = String, Path, description = "Plugin ID")),
responses(
(status = 200, description = "Plugin reloaded"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn reload_plugin( pub async fn reload_plugin(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,

View file

@ -6,14 +6,14 @@ use serde::{Deserialize, Serialize};
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateSavedSearchRequest { pub struct CreateSavedSearchRequest {
pub name: String, pub name: String,
pub query: String, pub query: String,
pub sort_order: Option<String>, pub sort_order: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct SavedSearchResponse { pub struct SavedSearchResponse {
pub id: String, pub id: String,
pub name: String, pub name: String,
@ -31,6 +31,19 @@ const VALID_SORT_ORDERS: &[&str] = &[
"size_desc", "size_desc",
]; ];
#[utoipa::path(
post,
path = "/api/v1/searches",
tag = "saved_searches",
request_body = CreateSavedSearchRequest,
responses(
(status = 200, description = "Search saved", body = SavedSearchResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn create_saved_search( pub async fn create_saved_search(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<CreateSavedSearchRequest>, Json(req): Json<CreateSavedSearchRequest>,
@ -76,6 +89,17 @@ pub async fn create_saved_search(
})) }))
} }
#[utoipa::path(
get,
path = "/api/v1/searches",
tag = "saved_searches",
responses(
(status = 200, description = "List of saved searches", body = Vec<SavedSearchResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_saved_searches( pub async fn list_saved_searches(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<SavedSearchResponse>>, ApiError> { ) -> Result<Json<Vec<SavedSearchResponse>>, ApiError> {
@ -100,6 +124,19 @@ pub async fn list_saved_searches(
)) ))
} }
#[utoipa::path(
delete,
path = "/api/v1/searches/{id}",
tag = "saved_searches",
params(("id" = uuid::Uuid, Path, description = "Saved search ID")),
responses(
(status = 200, description = "Saved search deleted"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_saved_search( pub async fn delete_saved_search(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<uuid::Uuid>, Path(id): Path<uuid::Uuid>,

View file

@ -7,6 +7,19 @@ use crate::{
}; };
/// Trigger a scan as a background job. Returns the job ID immediately. /// Trigger a scan as a background job. Returns the job ID immediately.
#[utoipa::path(
post,
path = "/api/v1/scan",
tag = "scan",
request_body = ScanRequest,
responses(
(status = 200, description = "Scan job submitted", body = ScanJobResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn trigger_scan( pub async fn trigger_scan(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<ScanRequest>, Json(req): Json<ScanRequest>,
@ -18,6 +31,16 @@ pub async fn trigger_scan(
})) }))
} }
#[utoipa::path(
get,
path = "/api/v1/scan/status",
tag = "scan",
responses(
(status = 200, description = "Scan status", body = ScanStatusResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn scan_status( pub async fn scan_status(
State(state): State<AppState>, State(state): State<AppState>,
) -> Json<ScanStatusResponse> { ) -> Json<ScanStatusResponse> {

View file

@ -5,6 +5,17 @@ use axum::{
use crate::{dto::ScheduledTaskResponse, error::ApiError, state::AppState}; use crate::{dto::ScheduledTaskResponse, error::ApiError, state::AppState};
#[utoipa::path(
get,
path = "/api/v1/scheduled-tasks",
tag = "scheduled_tasks",
responses(
(status = 200, description = "List of scheduled tasks", body = Vec<ScheduledTaskResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn list_scheduled_tasks( pub async fn list_scheduled_tasks(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<ScheduledTaskResponse>>, ApiError> { ) -> Result<Json<Vec<ScheduledTaskResponse>>, ApiError> {
@ -26,6 +37,19 @@ pub async fn list_scheduled_tasks(
Ok(Json(responses)) Ok(Json(responses))
} }
#[utoipa::path(
post,
path = "/api/v1/scheduled-tasks/{id}/toggle",
tag = "scheduled_tasks",
params(("id" = String, Path, description = "Task ID")),
responses(
(status = 200, description = "Task toggled"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn toggle_scheduled_task( pub async fn toggle_scheduled_task(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
@ -45,6 +69,19 @@ pub async fn toggle_scheduled_task(
} }
} }
#[utoipa::path(
post,
path = "/api/v1/scheduled-tasks/{id}/run",
tag = "scheduled_tasks",
params(("id" = String, Path, description = "Task ID")),
responses(
(status = 200, description = "Task triggered"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn run_scheduled_task_now( pub async fn run_scheduled_task_now(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,

View file

@ -22,6 +22,24 @@ fn resolve_sort(sort: Option<&str>) -> SortOrder {
} }
} }
#[utoipa::path(
get,
path = "/api/v1/search",
tag = "search",
params(
("q" = String, Query, description = "Search query"),
("sort" = Option<String>, Query, description = "Sort order"),
("offset" = Option<u64>, Query, description = "Pagination offset"),
("limit" = Option<u64>, Query, description = "Pagination limit"),
),
responses(
(status = 200, description = "Search results", body = SearchResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn search( pub async fn search(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<SearchParams>, Query(params): Query<SearchParams>,
@ -56,6 +74,19 @@ pub async fn search(
})) }))
} }
#[utoipa::path(
post,
path = "/api/v1/search",
tag = "search",
request_body = SearchRequestBody,
responses(
(status = 200, description = "Search results", body = SearchResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn search_post( pub async fn search_post(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<SearchRequestBody>, Json(body): Json<SearchRequestBody>,

View file

@ -48,6 +48,19 @@ use crate::{
/// Create a new share /// Create a new share
/// POST /api/shares /// POST /api/shares
#[utoipa::path(
post,
path = "/api/v1/shares",
tag = "shares",
request_body = CreateShareRequest,
responses(
(status = 200, description = "Share created", body = ShareResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn create_share( pub async fn create_share(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -201,6 +214,20 @@ pub async fn create_share(
/// List outgoing shares (shares I created) /// List outgoing shares (shares I created)
/// GET /api/shares/outgoing /// GET /api/shares/outgoing
#[utoipa::path(
get,
path = "/api/v1/shares/outgoing",
tag = "shares",
params(
("offset" = Option<u64>, Query, description = "Pagination offset"),
("limit" = Option<u64>, Query, description = "Pagination limit"),
),
responses(
(status = 200, description = "Outgoing shares", body = Vec<ShareResponse>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_outgoing( pub async fn list_outgoing(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -220,6 +247,20 @@ pub async fn list_outgoing(
/// List incoming shares (shares shared with me) /// List incoming shares (shares shared with me)
/// GET /api/shares/incoming /// GET /api/shares/incoming
#[utoipa::path(
get,
path = "/api/v1/shares/incoming",
tag = "shares",
params(
("offset" = Option<u64>, Query, description = "Pagination offset"),
("limit" = Option<u64>, Query, description = "Pagination limit"),
),
responses(
(status = 200, description = "Incoming shares", body = Vec<ShareResponse>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_incoming( pub async fn list_incoming(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -239,6 +280,19 @@ pub async fn list_incoming(
/// Get share details /// Get share details
/// GET /api/shares/{id} /// GET /api/shares/{id}
#[utoipa::path(
get,
path = "/api/v1/shares/{id}",
tag = "shares",
params(("id" = Uuid, Path, description = "Share ID")),
responses(
(status = 200, description = "Share details", body = ShareResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_share( pub async fn get_share(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -269,6 +323,20 @@ pub async fn get_share(
/// Update a share /// Update a share
/// PATCH /api/shares/{id} /// PATCH /api/shares/{id}
#[utoipa::path(
patch,
path = "/api/v1/shares/{id}",
tag = "shares",
params(("id" = Uuid, Path, description = "Share ID")),
request_body = UpdateShareRequest,
responses(
(status = 200, description = "Share updated", body = ShareResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn update_share( pub async fn update_share(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -349,6 +417,19 @@ pub async fn update_share(
/// Delete (revoke) a share /// Delete (revoke) a share
/// DELETE /api/shares/{id} /// DELETE /api/shares/{id}
#[utoipa::path(
delete,
path = "/api/v1/shares/{id}",
tag = "shares",
params(("id" = Uuid, Path, description = "Share ID")),
responses(
(status = 204, description = "Share deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_share( pub async fn delete_share(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -393,6 +474,19 @@ pub async fn delete_share(
/// Batch delete shares /// Batch delete shares
/// POST /api/shares/batch/delete /// POST /api/shares/batch/delete
#[utoipa::path(
post,
path = "/api/v1/shares/batch/delete",
tag = "shares",
request_body = BatchDeleteSharesRequest,
responses(
(status = 200, description = "Shares deleted"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn batch_delete( pub async fn batch_delete(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -432,6 +526,20 @@ pub async fn batch_delete(
/// Access a public shared resource /// Access a public shared resource
/// GET /api/shared/{token} /// GET /api/shared/{token}
#[utoipa::path(
get,
path = "/api/v1/shared/{token}",
tag = "shares",
params(
("token" = String, Path, description = "Share token"),
("password" = Option<String>, Query, description = "Share password if required"),
),
responses(
(status = 200, description = "Shared content", body = SharedContentResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
)
)]
pub async fn access_shared( pub async fn access_shared(
State(state): State<AppState>, State(state): State<AppState>,
Path(token): Path<String>, Path(token): Path<String>,
@ -599,6 +707,23 @@ pub async fn access_shared(
/// Get share activity log /// Get share activity log
/// GET /api/shares/{id}/activity /// GET /api/shares/{id}/activity
#[utoipa::path(
get,
path = "/api/v1/shares/{id}/activity",
tag = "shares",
params(
("id" = Uuid, Path, description = "Share ID"),
("offset" = Option<u64>, Query, description = "Pagination offset"),
("limit" = Option<u64>, Query, description = "Pagination limit"),
),
responses(
(status = 200, description = "Share activity", body = Vec<ShareActivityResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_activity( pub async fn get_activity(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -632,6 +757,16 @@ pub async fn get_activity(
/// Get unread share notifications /// Get unread share notifications
/// GET /api/notifications/shares /// GET /api/notifications/shares
#[utoipa::path(
get,
path = "/api/v1/notifications/shares",
tag = "shares",
responses(
(status = 200, description = "Unread notifications", body = Vec<ShareNotificationResponse>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_notifications( pub async fn get_notifications(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -650,6 +785,17 @@ pub async fn get_notifications(
/// Mark a notification as read /// Mark a notification as read
/// POST /api/notifications/shares/{id}/read /// POST /api/notifications/shares/{id}/read
#[utoipa::path(
post,
path = "/api/v1/notifications/shares/{id}/read",
tag = "shares",
params(("id" = Uuid, Path, description = "Notification ID")),
responses(
(status = 200, description = "Notification marked as read"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn mark_notification_read( pub async fn mark_notification_read(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -667,6 +813,16 @@ pub async fn mark_notification_read(
/// Mark all notifications as read /// Mark all notifications as read
/// POST /api/notifications/shares/read-all /// POST /api/notifications/shares/read-all
#[utoipa::path(
post,
path = "/api/v1/notifications/shares/read-all",
tag = "shares",
responses(
(status = 200, description = "All notifications marked as read"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn mark_all_read( pub async fn mark_all_read(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,

View file

@ -27,6 +27,20 @@ pub struct ShareLinkQuery {
pub password: Option<String>, pub password: Option<String>,
} }
#[utoipa::path(
post,
path = "/api/v1/media/{id}/rate",
tag = "social",
params(("id" = Uuid, Path, description = "Media item ID")),
request_body = CreateRatingRequest,
responses(
(status = 200, description = "Rating saved", body = RatingResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn rate_media( pub async fn rate_media(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -59,6 +73,18 @@ pub async fn rate_media(
Ok(Json(RatingResponse::from(rating))) Ok(Json(RatingResponse::from(rating)))
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}/ratings",
tag = "social",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Media ratings", body = Vec<RatingResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_media_ratings( pub async fn get_media_ratings(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -69,6 +95,20 @@ pub async fn get_media_ratings(
)) ))
} }
#[utoipa::path(
post,
path = "/api/v1/media/{id}/comments",
tag = "social",
params(("id" = Uuid, Path, description = "Media item ID")),
request_body = CreateCommentRequest,
responses(
(status = 200, description = "Comment added", body = CommentResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn add_comment( pub async fn add_comment(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -91,6 +131,18 @@ pub async fn add_comment(
Ok(Json(CommentResponse::from(comment))) Ok(Json(CommentResponse::from(comment)))
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}/comments",
tag = "social",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Media comments", body = Vec<CommentResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_media_comments( pub async fn get_media_comments(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -101,6 +153,18 @@ pub async fn get_media_comments(
)) ))
} }
#[utoipa::path(
post,
path = "/api/v1/favorites",
tag = "social",
request_body = FavoriteRequest,
responses(
(status = 200, description = "Added to favorites"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn add_favorite( pub async fn add_favorite(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -114,6 +178,18 @@ pub async fn add_favorite(
Ok(Json(serde_json::json!({"added": true}))) Ok(Json(serde_json::json!({"added": true})))
} }
#[utoipa::path(
delete,
path = "/api/v1/favorites/{media_id}",
tag = "social",
params(("media_id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Removed from favorites"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn remove_favorite( pub async fn remove_favorite(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -127,6 +203,17 @@ pub async fn remove_favorite(
Ok(Json(serde_json::json!({"removed": true}))) Ok(Json(serde_json::json!({"removed": true})))
} }
#[utoipa::path(
get,
path = "/api/v1/favorites",
tag = "social",
responses(
(status = 200, description = "User favorites", body = Vec<MediaResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_favorites( pub async fn list_favorites(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -145,6 +232,19 @@ pub async fn list_favorites(
)) ))
} }
#[utoipa::path(
post,
path = "/api/v1/media/share",
tag = "social",
request_body = CreateShareLinkRequest,
responses(
(status = 200, description = "Share link created", body = ShareLinkResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn create_share_link( pub async fn create_share_link(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -191,6 +291,20 @@ pub async fn create_share_link(
Ok(Json(ShareLinkResponse::from(link))) Ok(Json(ShareLinkResponse::from(link)))
} }
#[utoipa::path(
get,
path = "/api/v1/shared/media/{token}",
tag = "social",
params(
("token" = String, Path, description = "Share token"),
("password" = Option<String>, Query, description = "Share password"),
),
responses(
(status = 200, description = "Shared media", body = MediaResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
)
)]
pub async fn access_shared_media( pub async fn access_shared_media(
State(state): State<AppState>, State(state): State<AppState>,
Path(token): Path<String>, Path(token): Path<String>,

View file

@ -2,6 +2,17 @@ use axum::{Json, extract::State};
use crate::{dto::LibraryStatisticsResponse, error::ApiError, state::AppState}; use crate::{dto::LibraryStatisticsResponse, error::ApiError, state::AppState};
#[utoipa::path(
get,
path = "/api/v1/statistics",
tag = "statistics",
responses(
(status = 200, description = "Library statistics", body = LibraryStatisticsResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn library_statistics( pub async fn library_statistics(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<LibraryStatisticsResponse>, ApiError> { ) -> Result<Json<LibraryStatisticsResponse>, ApiError> {

View file

@ -49,6 +49,18 @@ fn escape_xml(s: &str) -> String {
.replace('\'', "&apos;") .replace('\'', "&apos;")
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}/stream/hls/master.m3u8",
tag = "streaming",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "HLS master playlist"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn hls_master_playlist( pub async fn hls_master_playlist(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -75,6 +87,22 @@ pub async fn hls_master_playlist(
build_response("application/vnd.apple.mpegurl", playlist) build_response("application/vnd.apple.mpegurl", playlist)
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}/stream/hls/{profile}/playlist.m3u8",
tag = "streaming",
params(
("id" = Uuid, Path, description = "Media item ID"),
("profile" = String, Path, description = "Transcode profile name"),
),
responses(
(status = 200, description = "HLS variant playlist"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn hls_variant_playlist( pub async fn hls_variant_playlist(
State(state): State<AppState>, State(state): State<AppState>,
Path((id, profile)): Path<(Uuid, String)>, Path((id, profile)): Path<(Uuid, String)>,
@ -112,6 +140,23 @@ pub async fn hls_variant_playlist(
build_response("application/vnd.apple.mpegurl", playlist) build_response("application/vnd.apple.mpegurl", playlist)
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}/stream/hls/{profile}/{segment}",
tag = "streaming",
params(
("id" = Uuid, Path, description = "Media item ID"),
("profile" = String, Path, description = "Transcode profile name"),
("segment" = String, Path, description = "Segment filename"),
),
responses(
(status = 200, description = "HLS segment data"),
(status = 202, description = "Segment not yet available"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn hls_segment( pub async fn hls_segment(
State(state): State<AppState>, State(state): State<AppState>,
Path((id, profile, segment)): Path<(Uuid, String, String)>, Path((id, profile, segment)): Path<(Uuid, String, String)>,
@ -167,6 +212,19 @@ pub async fn hls_segment(
)) ))
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}/stream/dash/manifest.mpd",
tag = "streaming",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "DASH manifest"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn dash_manifest( pub async fn dash_manifest(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -216,6 +274,23 @@ pub async fn dash_manifest(
build_response("application/dash+xml", mpd) build_response("application/dash+xml", mpd)
} }
#[utoipa::path(
get,
path = "/api/v1/media/{id}/stream/dash/{profile}/{segment}",
tag = "streaming",
params(
("id" = Uuid, Path, description = "Media item ID"),
("profile" = String, Path, description = "Transcode profile name"),
("segment" = String, Path, description = "Segment filename"),
),
responses(
(status = 200, description = "DASH segment data"),
(status = 202, description = "Segment not yet available"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn dash_segment( pub async fn dash_segment(
State(state): State<AppState>, State(state): State<AppState>,
Path((id, profile, segment)): Path<(Uuid, String, String)>, Path((id, profile, segment)): Path<(Uuid, String, String)>,

View file

@ -4,62 +4,185 @@ use axum::{
}; };
use pinakes_core::{ use pinakes_core::{
model::MediaId, model::MediaId,
subtitles::{Subtitle, SubtitleFormat}, subtitles::{
Subtitle,
detect_format,
extract_embedded_track,
list_embedded_tracks,
validate_language_code,
},
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
dto::{AddSubtitleRequest, SubtitleResponse, UpdateSubtitleOffsetRequest}, dto::{
AddSubtitleRequest,
SubtitleListResponse,
SubtitleResponse,
SubtitleTrackInfoResponse,
UpdateSubtitleOffsetRequest,
},
error::ApiError, error::ApiError,
state::AppState, state::AppState,
}; };
#[utoipa::path(
get,
path = "/api/v1/media/{id}/subtitles",
tag = "subtitles",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Subtitles and available embedded tracks", body = SubtitleListResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn list_subtitles( pub async fn list_subtitles(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<Vec<SubtitleResponse>>, ApiError> { ) -> Result<Json<SubtitleListResponse>, ApiError> {
let item = state.storage.get_media(MediaId(id)).await?;
let subtitles = state.storage.get_media_subtitles(MediaId(id)).await?; let subtitles = state.storage.get_media_subtitles(MediaId(id)).await?;
Ok(Json(
subtitles.into_iter().map(SubtitleResponse::from).collect(), let available_tracks =
)) list_embedded_tracks(&item.path).await.unwrap_or_default();
Ok(Json(SubtitleListResponse {
subtitles: subtitles
.into_iter()
.map(SubtitleResponse::from)
.collect(),
available_tracks: available_tracks
.into_iter()
.map(SubtitleTrackInfoResponse::from)
.collect(),
}))
} }
#[utoipa::path(
post,
path = "/api/v1/media/{id}/subtitles",
tag = "subtitles",
params(("id" = Uuid, Path, description = "Media item ID")),
request_body = AddSubtitleRequest,
responses(
(status = 200, description = "Subtitle added", body = SubtitleResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn add_subtitle( pub async fn add_subtitle(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(req): Json<AddSubtitleRequest>, Json(req): Json<AddSubtitleRequest>,
) -> Result<Json<SubtitleResponse>, ApiError> { ) -> Result<Json<SubtitleResponse>, ApiError> {
let format: SubtitleFormat = req.format.parse().map_err(|e: String| { // Validate language code if provided.
ApiError(pinakes_core::error::PinakesError::InvalidOperation(e)) if let Some(ref lang) = req.language {
})?; if !validate_language_code(lang) {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidLanguageCode(lang.clone()),
));
}
}
let is_embedded = req.is_embedded.unwrap_or(false); let is_embedded = req.is_embedded.unwrap_or(false);
if !is_embedded && req.file_path.is_none() {
return Err(ApiError( let (file_path, resolved_format) = if is_embedded {
pinakes_core::error::PinakesError::InvalidOperation( // Embedded subtitle: validate track_index and extract via ffmpeg.
"file_path is required for non-embedded subtitles".into(), let track_index = req.track_index.ok_or_else(|| {
), ApiError(pinakes_core::error::PinakesError::InvalidOperation(
));
}
if is_embedded && req.track_index.is_none() {
return Err(ApiError(
pinakes_core::error::PinakesError::InvalidOperation(
"track_index is required for embedded subtitles".into(), "track_index is required for embedded subtitles".into(),
), ))
)); })?;
}
if req let item = state.storage.get_media(MediaId(id)).await?;
.language let tracks = list_embedded_tracks(&item.path).await?;
.as_ref()
.is_some_and(|l| l.is_empty() || l.len() > 64) let track =
{ tracks
return Err(ApiError::bad_request("language must be 1-64 bytes")); .iter()
} .find(|t| t.index == track_index)
.ok_or(ApiError(
pinakes_core::error::PinakesError::SubtitleTrackNotFound {
index: track_index,
},
))?;
// Use the format detected from the embedded track metadata as
// authoritative.
let embedded_format = track.format;
let ext = embedded_format.to_string();
let output_dir = pinakes_core::config::Config::default_data_dir()
.join("subtitles")
.join(id.to_string());
let output_path = output_dir.join(format!("{track_index}.{ext}"));
tokio::fs::create_dir_all(&output_dir).await.map_err(|e| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
format!("failed to create subtitle output dir: {e}"),
))
})?;
extract_embedded_track(&item.path, track_index, &output_path).await?;
(Some(output_path), embedded_format)
} else {
// External subtitle file: validate path then detect format from content.
let path_str = req.file_path.ok_or_else(|| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"file_path is required for non-embedded subtitles".into(),
))
})?;
let path = std::path::PathBuf::from(&path_str);
use std::path::Component;
if !path.is_absolute()
|| path.components().any(|c| c == Component::ParentDir)
{
return Err(ApiError::bad_request(
"file_path must be an absolute path within a configured root",
));
}
let roots = state.config.read().await.directories.roots.clone();
if !roots.iter().any(|root| path.starts_with(root)) {
return Err(ApiError::bad_request(
"file_path must be an absolute path within a configured root",
));
}
let exists = tokio::fs::try_exists(&path).await.map_err(|e| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
format!("failed to check subtitle file: {e}"),
))
})?;
if !exists {
return Err(ApiError(pinakes_core::error::PinakesError::FileNotFound(
path,
)));
}
// Detect the actual format from the file extension; use it as authoritative
// rather than trusting the client-supplied format field.
let detected_format = detect_format(&path).ok_or_else(|| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
format!("unrecognised subtitle format for: {}", path.display()),
))
})?;
(Some(path), detected_format)
};
let subtitle = Subtitle { let subtitle = Subtitle {
id: Uuid::now_v7(), id: Uuid::now_v7(),
media_id: MediaId(id), media_id: MediaId(id),
language: req.language, language: req.language,
format, format: resolved_format,
file_path: req.file_path.map(std::path::PathBuf::from), file_path,
is_embedded, is_embedded,
track_index: req.track_index, track_index: req.track_index,
offset_ms: req.offset_ms.unwrap_or(0), offset_ms: req.offset_ms.unwrap_or(0),
@ -69,6 +192,18 @@ pub async fn add_subtitle(
Ok(Json(SubtitleResponse::from(subtitle))) Ok(Json(SubtitleResponse::from(subtitle)))
} }
#[utoipa::path(
delete,
path = "/api/v1/subtitles/{id}",
tag = "subtitles",
params(("id" = Uuid, Path, description = "Subtitle ID")),
responses(
(status = 200, description = "Subtitle deleted"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_subtitle( pub async fn delete_subtitle(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -77,6 +212,21 @@ pub async fn delete_subtitle(
Ok(Json(serde_json::json!({"deleted": true}))) Ok(Json(serde_json::json!({"deleted": true})))
} }
#[utoipa::path(
get,
path = "/api/v1/media/{media_id}/subtitles/{subtitle_id}/content",
tag = "subtitles",
params(
("media_id" = Uuid, Path, description = "Media item ID"),
("subtitle_id" = Uuid, Path, description = "Subtitle ID"),
),
responses(
(status = 200, description = "Subtitle content"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_subtitle_content( pub async fn get_subtitle_content(
State(state): State<AppState>, State(state): State<AppState>,
Path((media_id, subtitle_id)): Path<(Uuid, Uuid)>, Path((media_id, subtitle_id)): Path<(Uuid, Uuid)>,
@ -91,40 +241,65 @@ pub async fn get_subtitle_content(
))) )))
})?; })?;
if let Some(ref path) = subtitle.file_path { let path = subtitle.file_path.ok_or_else(|| {
let content = tokio::fs::read_to_string(path).await.map_err(|e| { ApiError(pinakes_core::error::PinakesError::InvalidOperation(
"subtitle has no associated file to serve".into(),
))
})?;
let fmt = subtitle.format;
let content_type = fmt.mime_type();
let body = if fmt.is_binary() {
let bytes = tokio::fs::read(&path).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound { if e.kind() == std::io::ErrorKind::NotFound {
ApiError(pinakes_core::error::PinakesError::FileNotFound( ApiError(pinakes_core::error::PinakesError::FileNotFound(
path.clone(), path.clone(),
)) ))
} else { } else {
ApiError(pinakes_core::error::PinakesError::InvalidOperation( ApiError(pinakes_core::error::PinakesError::InvalidOperation(
format!("failed to read subtitle file {}: {}", path.display(), e), format!("failed to read subtitle file {}: {e}", path.display()),
)) ))
} }
})?; })?;
let content_type = match subtitle.format { axum::body::Body::from(bytes)
SubtitleFormat::Vtt => "text/vtt",
SubtitleFormat::Srt => "application/x-subrip",
_ => "text/plain",
};
axum::response::Response::builder()
.header("Content-Type", content_type)
.body(axum::body::Body::from(content))
.map_err(|e| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
format!("failed to build response: {e}"),
))
})
} else { } else {
Err(ApiError( let text = tokio::fs::read_to_string(&path).await.map_err(|e| {
pinakes_core::error::PinakesError::InvalidOperation( if e.kind() == std::io::ErrorKind::NotFound {
"subtitle is embedded, no file to serve".into(), ApiError(pinakes_core::error::PinakesError::FileNotFound(
), path.clone(),
)) ))
} } else {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
format!("failed to read subtitle file {}: {e}", path.display()),
))
}
})?;
axum::body::Body::from(text)
};
axum::response::Response::builder()
.header("Content-Type", content_type)
.body(body)
.map_err(|e| {
ApiError(pinakes_core::error::PinakesError::InvalidOperation(
format!("failed to build response: {e}"),
))
})
} }
#[utoipa::path(
patch,
path = "/api/v1/subtitles/{id}/offset",
tag = "subtitles",
params(("id" = Uuid, Path, description = "Subtitle ID")),
request_body = UpdateSubtitleOffsetRequest,
responses(
(status = 200, description = "Offset updated"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn update_offset( pub async fn update_offset(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,

View file

@ -57,6 +57,19 @@ const DEFAULT_CHANGES_LIMIT: u64 = 100;
/// Register a new sync device /// Register a new sync device
/// POST /api/sync/devices /// POST /api/sync/devices
#[utoipa::path(
post,
path = "/api/v1/sync/devices",
tag = "sync",
request_body = RegisterDeviceRequest,
responses(
(status = 200, description = "Device registered", body = DeviceRegistrationResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn register_device( pub async fn register_device(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -111,6 +124,16 @@ pub async fn register_device(
/// List user's sync devices /// List user's sync devices
/// GET /api/sync/devices /// GET /api/sync/devices
#[utoipa::path(
get,
path = "/api/v1/sync/devices",
tag = "sync",
responses(
(status = 200, description = "List of devices", body = Vec<DeviceResponse>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_devices( pub async fn list_devices(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -127,6 +150,19 @@ pub async fn list_devices(
/// Get device details /// Get device details
/// GET /api/sync/devices/{id} /// GET /api/sync/devices/{id}
#[utoipa::path(
get,
path = "/api/v1/sync/devices/{id}",
tag = "sync",
params(("id" = Uuid, Path, description = "Device ID")),
responses(
(status = 200, description = "Device details", body = DeviceResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_device( pub async fn get_device(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -149,6 +185,20 @@ pub async fn get_device(
/// Update a device /// Update a device
/// PUT /api/sync/devices/{id} /// PUT /api/sync/devices/{id}
#[utoipa::path(
put,
path = "/api/v1/sync/devices/{id}",
tag = "sync",
params(("id" = Uuid, Path, description = "Device ID")),
request_body = UpdateDeviceRequest,
responses(
(status = 200, description = "Device updated", body = DeviceResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn update_device( pub async fn update_device(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -185,6 +235,19 @@ pub async fn update_device(
/// Delete a device /// Delete a device
/// DELETE /api/sync/devices/{id} /// DELETE /api/sync/devices/{id}
#[utoipa::path(
delete,
path = "/api/v1/sync/devices/{id}",
tag = "sync",
params(("id" = Uuid, Path, description = "Device ID")),
responses(
(status = 204, description = "Device deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_device( pub async fn delete_device(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -213,6 +276,19 @@ pub async fn delete_device(
/// Regenerate device token /// Regenerate device token
/// POST /api/sync/devices/{id}/token /// POST /api/sync/devices/{id}/token
#[utoipa::path(
post,
path = "/api/v1/sync/devices/{id}/token",
tag = "sync",
params(("id" = Uuid, Path, description = "Device ID")),
responses(
(status = 200, description = "Token regenerated", body = DeviceRegistrationResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn regenerate_token( pub async fn regenerate_token(
State(state): State<AppState>, State(state): State<AppState>,
Extension(username): Extension<String>, Extension(username): Extension<String>,
@ -253,6 +329,21 @@ pub async fn regenerate_token(
/// Get changes since cursor /// Get changes since cursor
/// GET /api/sync/changes /// GET /api/sync/changes
#[utoipa::path(
get,
path = "/api/v1/sync/changes",
tag = "sync",
params(
("cursor" = Option<u64>, Query, description = "Sync cursor"),
("limit" = Option<u64>, Query, description = "Max changes (max 1000)"),
),
responses(
(status = 200, description = "Changes since cursor", body = ChangesResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_changes( pub async fn get_changes(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<GetChangesParams>, Query(params): Query<GetChangesParams>,
@ -290,6 +381,18 @@ pub async fn get_changes(
/// Report local changes from client /// Report local changes from client
/// POST /api/sync/report /// POST /api/sync/report
#[utoipa::path(
post,
path = "/api/v1/sync/report",
tag = "sync",
request_body = ReportChangesRequest,
responses(
(status = 200, description = "Changes processed", body = ReportChangesResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn report_changes( pub async fn report_changes(
State(state): State<AppState>, State(state): State<AppState>,
Extension(_username): Extension<String>, Extension(_username): Extension<String>,
@ -392,6 +495,18 @@ pub async fn report_changes(
/// Acknowledge processed changes /// Acknowledge processed changes
/// POST /api/sync/ack /// POST /api/sync/ack
#[utoipa::path(
post,
path = "/api/v1/sync/ack",
tag = "sync",
request_body = AcknowledgeChangesRequest,
responses(
(status = 200, description = "Changes acknowledged"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn acknowledge_changes( pub async fn acknowledge_changes(
State(state): State<AppState>, State(state): State<AppState>,
Extension(_username): Extension<String>, Extension(_username): Extension<String>,
@ -422,6 +537,16 @@ pub async fn acknowledge_changes(
/// List unresolved conflicts /// List unresolved conflicts
/// GET /api/sync/conflicts /// GET /api/sync/conflicts
#[utoipa::path(
get,
path = "/api/v1/sync/conflicts",
tag = "sync",
responses(
(status = 200, description = "Unresolved conflicts", body = Vec<ConflictResponse>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_conflicts( pub async fn list_conflicts(
State(state): State<AppState>, State(state): State<AppState>,
Extension(_username): Extension<String>, Extension(_username): Extension<String>,
@ -451,6 +576,19 @@ pub async fn list_conflicts(
/// Resolve a sync conflict /// Resolve a sync conflict
/// POST /api/sync/conflicts/{id}/resolve /// POST /api/sync/conflicts/{id}/resolve
#[utoipa::path(
post,
path = "/api/v1/sync/conflicts/{id}/resolve",
tag = "sync",
params(("id" = Uuid, Path, description = "Conflict ID")),
request_body = ResolveConflictRequest,
responses(
(status = 200, description = "Conflict resolved"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn resolve_conflict( pub async fn resolve_conflict(
State(state): State<AppState>, State(state): State<AppState>,
Extension(_username): Extension<String>, Extension(_username): Extension<String>,
@ -477,6 +615,18 @@ pub async fn resolve_conflict(
/// Create an upload session for chunked upload /// Create an upload session for chunked upload
/// POST /api/sync/upload /// POST /api/sync/upload
#[utoipa::path(
post,
path = "/api/v1/sync/upload",
tag = "sync",
request_body = CreateUploadSessionRequest,
responses(
(status = 200, description = "Upload session created", body = UploadSessionResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn create_upload( pub async fn create_upload(
State(state): State<AppState>, State(state): State<AppState>,
Extension(_username): Extension<String>, Extension(_username): Extension<String>,
@ -541,6 +691,23 @@ pub async fn create_upload(
/// Upload a chunk /// Upload a chunk
/// PUT /api/sync/upload/{id}/chunks/{index} /// PUT /api/sync/upload/{id}/chunks/{index}
#[utoipa::path(
put,
path = "/api/v1/sync/upload/{id}/chunks/{index}",
tag = "sync",
params(
("id" = Uuid, Path, description = "Upload session ID"),
("index" = u64, Path, description = "Chunk index"),
),
request_body(content = Vec<u8>, description = "Chunk binary data", content_type = "application/octet-stream"),
responses(
(status = 200, description = "Chunk received", body = ChunkUploadedResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn upload_chunk( pub async fn upload_chunk(
State(state): State<AppState>, State(state): State<AppState>,
Path((session_id, chunk_index)): Path<(Uuid, u64)>, Path((session_id, chunk_index)): Path<(Uuid, u64)>,
@ -590,6 +757,18 @@ pub async fn upload_chunk(
/// Get upload session status /// Get upload session status
/// GET /api/sync/upload/{id} /// GET /api/sync/upload/{id}
#[utoipa::path(
get,
path = "/api/v1/sync/upload/{id}",
tag = "sync",
params(("id" = Uuid, Path, description = "Upload session ID")),
responses(
(status = 200, description = "Upload session status", body = UploadSessionResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_upload_status( pub async fn get_upload_status(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -603,6 +782,19 @@ pub async fn get_upload_status(
/// Complete an upload session /// Complete an upload session
/// POST /api/sync/upload/{id}/complete /// POST /api/sync/upload/{id}/complete
#[utoipa::path(
post,
path = "/api/v1/sync/upload/{id}/complete",
tag = "sync",
params(("id" = Uuid, Path, description = "Upload session ID")),
responses(
(status = 200, description = "Upload completed"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn complete_upload( pub async fn complete_upload(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -759,6 +951,18 @@ pub async fn complete_upload(
/// Cancel an upload session /// Cancel an upload session
/// DELETE /api/sync/upload/{id} /// DELETE /api/sync/upload/{id}
#[utoipa::path(
delete,
path = "/api/v1/sync/upload/{id}",
tag = "sync",
params(("id" = Uuid, Path, description = "Upload session ID")),
responses(
(status = 204, description = "Upload cancelled"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn cancel_upload( pub async fn cancel_upload(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -789,6 +993,19 @@ pub async fn cancel_upload(
/// Download a file for sync (supports Range header) /// Download a file for sync (supports Range header)
/// GET /api/sync/download/{*path} /// GET /api/sync/download/{*path}
#[utoipa::path(
get,
path = "/api/v1/sync/download/{path}",
tag = "sync",
params(("path" = String, Path, description = "File path")),
responses(
(status = 200, description = "File content"),
(status = 206, description = "Partial content"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn download_file( pub async fn download_file(
State(state): State<AppState>, State(state): State<AppState>,
Path(path): Path<String>, Path(path): Path<String>,

View file

@ -11,6 +11,20 @@ use crate::{
state::AppState, state::AppState,
}; };
#[utoipa::path(
post,
path = "/api/v1/tags",
tag = "tags",
request_body = CreateTagRequest,
responses(
(status = 200, description = "Tag created", body = TagResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn create_tag( pub async fn create_tag(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<CreateTagRequest>, Json(req): Json<CreateTagRequest>,
@ -28,6 +42,17 @@ pub async fn create_tag(
Ok(Json(TagResponse::from(tag))) Ok(Json(TagResponse::from(tag)))
} }
#[utoipa::path(
get,
path = "/api/v1/tags",
tag = "tags",
responses(
(status = 200, description = "List of tags", body = Vec<TagResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn list_tags( pub async fn list_tags(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<TagResponse>>, ApiError> { ) -> Result<Json<Vec<TagResponse>>, ApiError> {
@ -35,6 +60,19 @@ pub async fn list_tags(
Ok(Json(tags.into_iter().map(TagResponse::from).collect())) Ok(Json(tags.into_iter().map(TagResponse::from).collect()))
} }
#[utoipa::path(
get,
path = "/api/v1/tags/{id}",
tag = "tags",
params(("id" = Uuid, Path, description = "Tag ID")),
responses(
(status = 200, description = "Tag", body = TagResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_tag( pub async fn get_tag(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -43,6 +81,20 @@ pub async fn get_tag(
Ok(Json(TagResponse::from(tag))) Ok(Json(TagResponse::from(tag)))
} }
#[utoipa::path(
delete,
path = "/api/v1/tags/{id}",
tag = "tags",
params(("id" = Uuid, Path, description = "Tag ID")),
responses(
(status = 200, description = "Tag deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_tag( pub async fn delete_tag(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -51,6 +103,21 @@ pub async fn delete_tag(
Ok(Json(serde_json::json!({"deleted": true}))) Ok(Json(serde_json::json!({"deleted": true})))
} }
#[utoipa::path(
post,
path = "/api/v1/media/{media_id}/tags",
tag = "tags",
params(("media_id" = Uuid, Path, description = "Media item ID")),
request_body = TagMediaRequest,
responses(
(status = 200, description = "Tag applied"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn tag_media( pub async fn tag_media(
State(state): State<AppState>, State(state): State<AppState>,
Path(media_id): Path<Uuid>, Path(media_id): Path<Uuid>,
@ -70,6 +137,23 @@ pub async fn tag_media(
Ok(Json(serde_json::json!({"tagged": true}))) Ok(Json(serde_json::json!({"tagged": true})))
} }
#[utoipa::path(
delete,
path = "/api/v1/media/{media_id}/tags/{tag_id}",
tag = "tags",
params(
("media_id" = Uuid, Path, description = "Media item ID"),
("tag_id" = Uuid, Path, description = "Tag ID"),
),
responses(
(status = 200, description = "Tag removed"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn untag_media( pub async fn untag_media(
State(state): State<AppState>, State(state): State<AppState>,
Path((media_id, tag_id)): Path<(Uuid, Uuid)>, Path((media_id, tag_id)): Path<(Uuid, Uuid)>,
@ -88,6 +172,19 @@ pub async fn untag_media(
Ok(Json(serde_json::json!({"untagged": true}))) Ok(Json(serde_json::json!({"untagged": true})))
} }
#[utoipa::path(
get,
path = "/api/v1/media/{media_id}/tags",
tag = "tags",
params(("media_id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "Media tags", body = Vec<TagResponse>),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn get_media_tags( pub async fn get_media_tags(
State(state): State<AppState>, State(state): State<AppState>,
Path(media_id): Path<Uuid>, Path(media_id): Path<Uuid>,

View file

@ -11,6 +11,20 @@ use crate::{
state::AppState, state::AppState,
}; };
#[utoipa::path(
post,
path = "/api/v1/media/{id}/transcode",
tag = "transcode",
params(("id" = Uuid, Path, description = "Media item ID")),
request_body = CreateTranscodeRequest,
responses(
(status = 200, description = "Transcode job submitted"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn start_transcode( pub async fn start_transcode(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -29,6 +43,18 @@ pub async fn start_transcode(
Ok(Json(serde_json::json!({"job_id": job_id.to_string()}))) Ok(Json(serde_json::json!({"job_id": job_id.to_string()})))
} }
#[utoipa::path(
get,
path = "/api/v1/transcode/{id}",
tag = "transcode",
params(("id" = Uuid, Path, description = "Transcode session ID")),
responses(
(status = 200, description = "Transcode session details", body = TranscodeSessionResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_session( pub async fn get_session(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -37,6 +63,16 @@ pub async fn get_session(
Ok(Json(TranscodeSessionResponse::from(session))) Ok(Json(TranscodeSessionResponse::from(session)))
} }
#[utoipa::path(
get,
path = "/api/v1/transcode",
tag = "transcode",
responses(
(status = 200, description = "List of transcode sessions", body = Vec<TranscodeSessionResponse>),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn list_sessions( pub async fn list_sessions(
State(state): State<AppState>, State(state): State<AppState>,
Query(params): Query<PaginationParams>, Query(params): Query<PaginationParams>,
@ -51,6 +87,18 @@ pub async fn list_sessions(
)) ))
} }
#[utoipa::path(
delete,
path = "/api/v1/transcode/{id}",
tag = "transcode",
params(("id" = Uuid, Path, description = "Transcode session ID")),
responses(
(status = 200, description = "Transcode session cancelled"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn cancel_session( pub async fn cancel_session(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,

View file

@ -32,6 +32,18 @@ fn sanitize_content_disposition(filename: &str) -> String {
/// Upload a file to managed storage /// Upload a file to managed storage
/// POST /api/upload /// POST /api/upload
#[utoipa::path(
post,
path = "/api/v1/upload",
tag = "upload",
responses(
(status = 200, description = "File uploaded", body = UploadResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn upload_file( pub async fn upload_file(
State(state): State<AppState>, State(state): State<AppState>,
mut multipart: Multipart, mut multipart: Multipart,
@ -85,6 +97,19 @@ pub async fn upload_file(
/// Download a managed file /// Download a managed file
/// GET /api/media/{id}/download /// GET /api/media/{id}/download
#[utoipa::path(
get,
path = "/api/v1/media/{id}/download",
tag = "upload",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 200, description = "File content"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn download_file( pub async fn download_file(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -154,6 +179,19 @@ pub async fn download_file(
/// Migrate an external file to managed storage /// Migrate an external file to managed storage
/// POST /api/media/{id}/move-to-managed /// POST /api/media/{id}/move-to-managed
#[utoipa::path(
post,
path = "/api/v1/media/{id}/move-to-managed",
tag = "upload",
params(("id" = Uuid, Path, description = "Media item ID")),
responses(
(status = 204, description = "File migrated"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn move_to_managed( pub async fn move_to_managed(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
@ -177,6 +215,17 @@ pub async fn move_to_managed(
/// Get managed storage statistics /// Get managed storage statistics
/// GET /api/managed/stats /// GET /api/managed/stats
#[utoipa::path(
get,
path = "/api/v1/managed/stats",
tag = "upload",
responses(
(status = 200, description = "Managed storage statistics", body = ManagedStorageStatsResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn managed_stats( pub async fn managed_stats(
State(state): State<AppState>, State(state): State<AppState>,
) -> ApiResult<Json<ManagedStorageStatsResponse>> { ) -> ApiResult<Json<ManagedStorageStatsResponse>> {

View file

@ -16,6 +16,17 @@ use crate::{
}; };
/// List all users (admin only) /// List all users (admin only)
#[utoipa::path(
get,
path = "/api/v1/admin/users",
tag = "users",
responses(
(status = 200, description = "List of users", body = Vec<UserResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn list_users( pub async fn list_users(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<UserResponse>>, ApiError> { ) -> Result<Json<Vec<UserResponse>>, ApiError> {
@ -24,6 +35,24 @@ pub async fn list_users(
} }
/// Create a new user (admin only) /// Create a new user (admin only)
#[utoipa::path(
post,
path = "/api/v1/admin/users",
tag = "users",
request_body(
content = inline(serde_json::Value),
description = "username, password, role, and optional profile fields",
content_type = "application/json"
),
responses(
(status = 200, description = "User created", body = UserResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 500, description = "Internal server error"),
),
security(("bearer_auth" = []))
)]
pub async fn create_user( pub async fn create_user(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<CreateUserRequest>, Json(req): Json<CreateUserRequest>,
@ -74,6 +103,19 @@ pub async fn create_user(
} }
/// Get a specific user by ID /// Get a specific user by ID
#[utoipa::path(
get,
path = "/api/v1/admin/users/{id}",
tag = "users",
params(("id" = String, Path, description = "User ID")),
responses(
(status = 200, description = "User details", body = UserResponse),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn get_user( pub async fn get_user(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
@ -90,6 +132,25 @@ pub async fn get_user(
} }
/// Update a user /// Update a user
#[utoipa::path(
patch,
path = "/api/v1/admin/users/{id}",
tag = "users",
params(("id" = String, Path, description = "User ID")),
request_body(
content = inline(serde_json::Value),
description = "Optional password, role, or profile fields to update",
content_type = "application/json"
),
responses(
(status = 200, description = "User updated", body = UserResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn update_user( pub async fn update_user(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
@ -125,6 +186,19 @@ pub async fn update_user(
} }
/// Delete a user (admin only) /// Delete a user (admin only)
#[utoipa::path(
delete,
path = "/api/v1/admin/users/{id}",
tag = "users",
params(("id" = String, Path, description = "User ID")),
responses(
(status = 200, description = "User deleted"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
(status = 404, description = "Not found"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_user( pub async fn delete_user(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
@ -141,6 +215,18 @@ pub async fn delete_user(
} }
/// Get user's accessible libraries /// Get user's accessible libraries
#[utoipa::path(
get,
path = "/api/v1/admin/users/{id}/libraries",
tag = "users",
params(("id" = String, Path, description = "User ID")),
responses(
(status = 200, description = "User libraries", body = Vec<UserLibraryResponse>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn get_user_libraries( pub async fn get_user_libraries(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
@ -177,6 +263,20 @@ fn validate_root_path(path: &str) -> Result<(), ApiError> {
} }
/// Grant library access to a user (admin only) /// Grant library access to a user (admin only)
#[utoipa::path(
post,
path = "/api/v1/admin/users/{id}/libraries",
tag = "users",
params(("id" = String, Path, description = "User ID")),
request_body = GrantLibraryAccessRequest,
responses(
(status = 200, description = "Access granted"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn grant_library_access( pub async fn grant_library_access(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
@ -202,6 +302,20 @@ pub async fn grant_library_access(
/// ///
/// Uses a JSON body instead of a path parameter because `root_path` may contain /// Uses a JSON body instead of a path parameter because `root_path` may contain
/// slashes that conflict with URL routing. /// slashes that conflict with URL routing.
#[utoipa::path(
delete,
path = "/api/v1/admin/users/{id}/libraries",
tag = "users",
params(("id" = String, Path, description = "User ID")),
request_body = RevokeLibraryAccessRequest,
responses(
(status = 200, description = "Access revoked"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn revoke_library_access( pub async fn revoke_library_access(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,

View file

@ -3,12 +3,23 @@ use serde::Serialize;
use crate::{error::ApiError, state::AppState}; use crate::{error::ApiError, state::AppState};
#[derive(Debug, Serialize)] #[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct WebhookInfo { pub struct WebhookInfo {
pub url: String, pub url: String,
pub events: Vec<String>, pub events: Vec<String>,
} }
#[utoipa::path(
get,
path = "/api/v1/webhooks",
tag = "webhooks",
responses(
(status = 200, description = "List of configured webhooks", body = Vec<WebhookInfo>),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn list_webhooks( pub async fn list_webhooks(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<WebhookInfo>>, ApiError> { ) -> Result<Json<Vec<WebhookInfo>>, ApiError> {
@ -26,6 +37,17 @@ pub async fn list_webhooks(
Ok(Json(hooks)) Ok(Json(hooks))
} }
#[utoipa::path(
post,
path = "/api/v1/webhooks/test",
tag = "webhooks",
responses(
(status = 200, description = "Test webhook sent"),
(status = 401, description = "Unauthorized"),
(status = 403, description = "Forbidden"),
),
security(("bearer_auth" = []))
)]
pub async fn test_webhook( pub async fn test_webhook(
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<serde_json::Value>, ApiError> { ) -> Result<Json<serde_json::Value>, ApiError> {

View file

@ -32,10 +32,7 @@ async fn get_book_metadata_not_found() {
.oneshot(get(&format!("/api/v1/books/{fake_id}/metadata"))) .oneshot(get(&format!("/api/v1/books/{fake_id}/metadata")))
.await .await
.unwrap(); .unwrap();
assert!( assert_eq!(resp.status(), StatusCode::NOT_FOUND);
resp.status() == StatusCode::NOT_FOUND
|| resp.status() == StatusCode::INTERNAL_SERVER_ERROR
);
} }
#[tokio::test] #[tokio::test]
@ -77,10 +74,8 @@ async fn reading_progress_nonexistent_book() {
)) ))
.await .await
.unwrap(); .unwrap();
// Nonexistent book; expect NOT_FOUND or empty response // Nonexistent book always returns 404.
assert!( assert_eq!(resp.status(), StatusCode::NOT_FOUND);
resp.status() == StatusCode::NOT_FOUND || resp.status() == StatusCode::OK
);
} }
#[tokio::test] #[tokio::test]
@ -96,11 +91,8 @@ async fn update_reading_progress_nonexistent_book() {
)) ))
.await .await
.unwrap(); .unwrap();
// Nonexistent book; expect NOT_FOUND or error // Nonexistent book: handler verifies existence first, so always 404.
assert!( assert_eq!(resp.status(), StatusCode::NOT_FOUND);
resp.status() == StatusCode::NOT_FOUND
|| resp.status() == StatusCode::INTERNAL_SERVER_ERROR
);
} }
#[tokio::test] #[tokio::test]

View file

@ -154,6 +154,7 @@ pub fn default_config() -> Config {
authentication_disabled: true, authentication_disabled: true,
cors_enabled: false, cors_enabled: false,
cors_origins: vec![], cors_origins: vec![],
swagger_ui: false,
}, },
rate_limits: RateLimitConfig::default(), rate_limits: RateLimitConfig::default(),
ui: UiConfig::default(), ui: UiConfig::default(),

View file

@ -51,7 +51,26 @@ async fn notes_graph_empty() {
.unwrap(); .unwrap();
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
let body = response_body(resp).await; let body = response_body(resp).await;
assert!(body.is_object() || body.is_array()); // Fresh database: graph must be empty.
if let Some(arr) = body.as_array() {
assert!(arr.is_empty(), "graph should be empty, got {arr:?}");
} else if let Some(obj) = body.as_object() {
// Accept an object if the schema uses {nodes:[], edges:[]} style.
let nodes_empty = obj
.get("nodes")
.and_then(|v| v.as_array())
.map_or(true, |a| a.is_empty());
let edges_empty = obj
.get("edges")
.and_then(|v| v.as_array())
.map_or(true, |a| a.is_empty());
assert!(
nodes_empty && edges_empty,
"graph should be empty, got {obj:?}"
);
} else {
panic!("expected array or object, got {body}");
}
} }
#[tokio::test] #[tokio::test]
@ -62,6 +81,12 @@ async fn unresolved_count_zero() {
.await .await
.unwrap(); .unwrap();
assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.status(), StatusCode::OK);
let body = response_body(resp).await;
// Fresh database has no unresolved links.
let count = body["count"]
.as_u64()
.expect("response should have a numeric 'count' field");
assert_eq!(count, 0, "expected zero unresolved links in fresh database");
} }
#[tokio::test] #[tokio::test]