feat: frontend-ready backend — pagination, auto-derivatives, list endpoints, bulk ops, OpenAPI
Pagination: count_by_owner + count_search on AssetRepository,
timeline/search return real total count (not page len).
Auto-derivatives: worker enqueues GenerateDerivative when
ExtractMetadata job completes, closing the upload→thumbnail gap.
List endpoints: GET /albums, GET /stacks with user scoping.
ListAlbumsHandler, ListStacksHandler, find_by_owner on AssetStackRepository.
Tag filtering: tag_name field on AssetFilters, JOIN asset_tags+tags
in postgres search/count queries.
Bulk operations: POST /assets/bulk-delete, POST /assets/bulk-tag.
Album update: PUT /albums/{id} with UpdateAlbumHandler (title, description).
OpenAPI: utoipa annotations on all 47 endpoints + all request/response
schemas registered. Scalar UI at /scalar covers full API.
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
|
||||
use api_types::requests::UpdateAlbumRequest;
|
||||
use api_types::{
|
||||
requests::{AlbumEntryRequest, CreateAlbumRequest},
|
||||
responses::AlbumResponse,
|
||||
};
|
||||
use application::organization::{
|
||||
AlbumAction, CreateAlbumCommand, GetAlbumQuery, ManageAlbumEntriesCommand,
|
||||
AlbumAction, CreateAlbumCommand, GetAlbumQuery, ListAlbumsQuery, ManageAlbumEntriesCommand,
|
||||
UpdateAlbumCommand,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -13,6 +15,35 @@ use axum::{
|
||||
};
|
||||
use domain::value_objects::SystemId;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/albums",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "List of albums", body = Vec<AlbumResponse>),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn list_albums(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
) -> Result<Json<Vec<AlbumResponse>>, AppError> {
|
||||
let query = ListAlbumsQuery {
|
||||
user_id: claims.user_id,
|
||||
};
|
||||
let albums = state.organization.list_albums.execute(query).await?;
|
||||
let resp = albums.iter().map(AlbumResponse::from_domain).collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/albums",
|
||||
request_body = CreateAlbumRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Album created", body = AlbumResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn create_album(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -29,6 +60,15 @@ pub async fn create_album(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/albums/{id}",
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Album ID")),
|
||||
responses(
|
||||
(status = 200, description = "Album details", body = AlbumResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn get_album(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -42,6 +82,42 @@ pub async fn get_album(
|
||||
Ok(Json(AlbumResponse::from_domain(&album)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/albums/{id}",
|
||||
request_body = UpdateAlbumRequest,
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Album ID")),
|
||||
responses(
|
||||
(status = 200, description = "Album updated", body = AlbumResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn update_album(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
Path((album_id,)): Path<(uuid::Uuid,)>,
|
||||
Json(req): Json<UpdateAlbumRequest>,
|
||||
) -> Result<Json<AlbumResponse>, AppError> {
|
||||
let cmd = UpdateAlbumCommand {
|
||||
album_id: SystemId::from_uuid(album_id),
|
||||
user_id: claims.user_id,
|
||||
title: req.title,
|
||||
description: req.description,
|
||||
};
|
||||
let album = state.organization.update_album.execute(cmd).await?;
|
||||
Ok(Json(AlbumResponse::from_domain(&album)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/albums/{id}/entries",
|
||||
request_body = AlbumEntryRequest,
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Album ID")),
|
||||
responses(
|
||||
(status = 201, description = "Entry added", body = AlbumResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn add_entry(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -62,6 +138,18 @@ pub async fn add_entry(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/albums/{id}/entries/{asset_id}",
|
||||
security(("bearer_token" = [])),
|
||||
params(
|
||||
("id" = uuid::Uuid, Path, description = "Album ID"),
|
||||
("asset_id" = uuid::Uuid, Path, description = "Asset ID")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Entry removed", body = AlbumResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn remove_entry(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -44,10 +44,29 @@ pub struct SearchParams {
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
pub is_processed: Option<bool>,
|
||||
pub tag: Option<String>,
|
||||
pub limit: Option<u32>,
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/assets",
|
||||
security(("bearer_token" = [])),
|
||||
params(
|
||||
("type" = Option<String>, Query, description = "Asset type filter"),
|
||||
("mime_type" = Option<String>, Query, description = "MIME type filter"),
|
||||
("date_from" = Option<String>, Query, description = "Start date (YYYY-MM-DD)"),
|
||||
("date_to" = Option<String>, Query, description = "End date (YYYY-MM-DD)"),
|
||||
("is_processed" = Option<bool>, Query, description = "Processed filter"),
|
||||
("tag" = Option<String>, Query, description = "Tag name filter"),
|
||||
("limit" = Option<u32>, Query, description = "Page size"),
|
||||
("offset" = Option<u32>, Query, description = "Page offset")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Search results", body = TimelineResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn search_assets(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -89,6 +108,7 @@ pub async fn search_assets(
|
||||
date_from,
|
||||
date_to,
|
||||
is_processed: params.is_processed,
|
||||
tag_name: params.tag,
|
||||
};
|
||||
|
||||
let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
|
||||
@@ -100,15 +120,27 @@ pub async fn search_assets(
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
let results = state.catalog.search_assets.execute(query).await?;
|
||||
let total = results.len();
|
||||
let assets = results
|
||||
let result = state.catalog.search_assets.execute(query).await?;
|
||||
let assets = result
|
||||
.items
|
||||
.iter()
|
||||
.map(|a| AssetResponse::from_domain(a, &StructuredData::new()))
|
||||
.collect();
|
||||
Ok(Json(TimelineResponse { assets, total }))
|
||||
Ok(Json(TimelineResponse {
|
||||
assets,
|
||||
total: result.total,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/assets/ingest",
|
||||
security(("bearer_token" = [])),
|
||||
request_body(content_type = "multipart/form-data"),
|
||||
responses(
|
||||
(status = 201, description = "Asset ingested", body = IngestResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn ingest(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -136,6 +168,15 @@ pub async fn ingest(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/assets/{id}",
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||
responses(
|
||||
(status = 200, description = "Asset details", body = AssetResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn get_asset(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -149,6 +190,18 @@ pub async fn get_asset(
|
||||
Ok(Json(AssetResponse::from_domain(&asset, &metadata)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/assets/timeline",
|
||||
security(("bearer_token" = [])),
|
||||
params(
|
||||
("limit" = Option<u32>, Query, description = "Page size"),
|
||||
("offset" = Option<u32>, Query, description = "Page offset")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Timeline view", body = TimelineResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn timeline(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -160,15 +213,28 @@ pub async fn timeline(
|
||||
limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE),
|
||||
offset: params.offset.unwrap_or(0),
|
||||
};
|
||||
let results = state.catalog.get_timeline.execute(query).await?;
|
||||
let total = results.len();
|
||||
let assets = results
|
||||
let result = state.catalog.get_timeline.execute(query).await?;
|
||||
let assets = result
|
||||
.items
|
||||
.iter()
|
||||
.map(|(asset, meta)| AssetResponse::from_domain(asset, meta))
|
||||
.collect();
|
||||
Ok(Json(TimelineResponse { assets, total }))
|
||||
Ok(Json(TimelineResponse {
|
||||
assets,
|
||||
total: result.total,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put, path = "/api/v1/assets/{id}/metadata",
|
||||
request_body = api_types::requests::UpdateMetadataRequest,
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||
responses(
|
||||
(status = 200, description = "Metadata updated"),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn update_metadata(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -189,6 +255,15 @@ pub async fn update_metadata(
|
||||
Ok(Json(serde_json::json!({ "status": "updated" })))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/assets/{id}/file",
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||
responses(
|
||||
(status = 200, description = "File content", content_type = "application/octet-stream"),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn serve_file(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -212,6 +287,16 @@ pub async fn serve_file(
|
||||
.map_err(|e| AppError::from(domain::errors::DomainError::Internal(e.to_string())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/assets/{id}/tags",
|
||||
request_body = TagAssetRequest,
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||
responses(
|
||||
(status = 201, description = "Tag applied", body = TagResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn tag_asset(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -227,6 +312,15 @@ pub async fn tag_asset(
|
||||
Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/assets/{id}",
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||
responses(
|
||||
(status = 204, description = "Asset deleted"),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn delete_asset(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -240,6 +334,18 @@ pub async fn delete_asset(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/assets/{id}/derivatives/{profile}",
|
||||
security(("bearer_token" = [])),
|
||||
params(
|
||||
("id" = uuid::Uuid, Path, description = "Asset ID"),
|
||||
("profile" = String, Path, description = "Derivative profile")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Derivative content", content_type = "application/octet-stream"),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn serve_derivative(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -262,6 +368,15 @@ pub async fn serve_derivative(
|
||||
.map_err(|e| AppError::from(DomainError::Internal(e.to_string())))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/assets/register",
|
||||
request_body = RegisterAssetRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Asset registered", body = AssetResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn register_asset(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -283,3 +398,56 @@ pub async fn register_asset(
|
||||
Json(AssetResponse::from_domain(&asset, &StructuredData::new())),
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/assets/bulk-delete",
|
||||
request_body = api_types::requests::BulkDeleteRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Assets deleted"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn bulk_delete(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
Json(req): Json<api_types::requests::BulkDeleteRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let mut deleted = 0u32;
|
||||
for id in req.asset_ids {
|
||||
let cmd = DeleteAssetCommand {
|
||||
asset_id: SystemId::from_uuid(id),
|
||||
deleted_by: claims.user_id,
|
||||
};
|
||||
state.catalog.delete_asset.execute(cmd).await?;
|
||||
deleted += 1;
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "deleted": deleted })))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/assets/bulk-tag",
|
||||
request_body = api_types::requests::BulkTagRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Assets tagged"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn bulk_tag(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
Json(req): Json<api_types::requests::BulkTagRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let mut tagged = 0u32;
|
||||
for id in req.asset_ids {
|
||||
let cmd = application::organization::TagAssetCommand {
|
||||
asset_id: SystemId::from_uuid(id),
|
||||
tag_name: req.tag_name.clone(),
|
||||
user_id: claims.user_id,
|
||||
};
|
||||
state.organization.tag_asset.execute(cmd).await?;
|
||||
tagged += 1;
|
||||
}
|
||||
Ok(Json(serde_json::json!({ "tagged": tagged })))
|
||||
}
|
||||
|
||||
@@ -92,6 +92,14 @@ pub async fn me(
|
||||
Ok(Json(UserResponse::from_domain(&user)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/refresh",
|
||||
request_body = RefreshTokenRequest,
|
||||
responses(
|
||||
(status = 200, description = "Token refreshed", body = AuthResponse),
|
||||
(status = 401, description = "Invalid refresh token")
|
||||
)
|
||||
)]
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
ValidatedJson(req): ValidatedJson<RefreshTokenRequest>,
|
||||
@@ -113,6 +121,14 @@ pub async fn refresh(
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/auth/logout",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 204, description = "Logged out"),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -19,6 +19,18 @@ pub struct ListDuplicatesParams {
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/duplicates",
|
||||
security(("bearer_token" = [])),
|
||||
params(
|
||||
("limit" = Option<u32>, Query, description = "Page size"),
|
||||
("offset" = Option<u32>, Query, description = "Page offset")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Duplicate groups", body = Vec<DuplicateGroupResponse>),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn list_duplicates(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -37,6 +49,16 @@ pub async fn list_duplicates(
|
||||
Ok(Json(resp))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/duplicates/{id}/resolve",
|
||||
request_body = ResolveDuplicateRequest,
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Duplicate group ID")),
|
||||
responses(
|
||||
(status = 200, description = "Duplicate resolved"),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn resolve_duplicate(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -40,6 +40,19 @@ pub struct ListJobsParams {
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/jobs",
|
||||
security(("bearer_token" = [])),
|
||||
params(
|
||||
("status" = Option<String>, Query, description = "Status filter"),
|
||||
("limit" = Option<u32>, Query, description = "Page size"),
|
||||
("offset" = Option<u32>, Query, description = "Page offset")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Job list", body = JobListResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn list_jobs(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -59,6 +72,15 @@ pub async fn list_jobs(
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/jobs",
|
||||
request_body = EnqueueJobRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Job enqueued", body = JobResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn enqueue_job(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -82,6 +104,15 @@ pub async fn enqueue_job(
|
||||
Ok((StatusCode::CREATED, Json(JobResponse::from_domain(&job))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/jobs/{id}/start",
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Job ID")),
|
||||
responses(
|
||||
(status = 200, description = "Job started", body = JobResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn start_job(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -95,6 +126,16 @@ pub async fn start_job(
|
||||
Ok(Json(JobResponse::from_domain(&job)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/jobs/{id}/complete",
|
||||
request_body = CompleteJobRequest,
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Job ID")),
|
||||
responses(
|
||||
(status = 200, description = "Job completed", body = JobResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn complete_job(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -110,6 +151,16 @@ pub async fn complete_job(
|
||||
Ok(Json(JobResponse::from_domain(&job)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/jobs/{id}/fail",
|
||||
request_body = FailJobRequest,
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Job ID")),
|
||||
responses(
|
||||
(status = 200, description = "Job failed", body = JobResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn fail_job(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -125,6 +176,15 @@ pub async fn fail_job(
|
||||
Ok(Json(JobResponse::from_domain(&job)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/jobs/batches/{id}",
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Batch ID")),
|
||||
responses(
|
||||
(status = 200, description = "Batch progress", body = BatchProgressResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn batch_progress(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -138,6 +198,15 @@ pub async fn batch_progress(
|
||||
Ok(Json(BatchProgressResponse::from_domain(&progress)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/plugins",
|
||||
request_body = ManagePluginRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Plugin managed", body = PluginResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn manage_plugin(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -182,6 +251,15 @@ pub async fn manage_plugin(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/pipelines",
|
||||
request_body = ConfigurePipelineRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Pipeline configured", body = PipelineResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn configure_pipeline(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -15,6 +15,15 @@ use domain::value_objects::{DateTimeStamp, SystemId};
|
||||
|
||||
const DEFAULT_ACCESS_LEVEL: &str = "view_only";
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/sharing",
|
||||
request_body = ShareResourceRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Resource shared", body = ShareScopeResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn share_resource(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -38,6 +47,15 @@ pub async fn share_resource(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/sharing/links",
|
||||
request_body = GenerateShareLinkRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Share link generated", body = ShareLinkResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn generate_link(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -65,6 +83,15 @@ pub async fn generate_link(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/sharing/{id}",
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Share scope ID")),
|
||||
responses(
|
||||
(status = 204, description = "Share revoked"),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn revoke(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -78,6 +105,14 @@ pub async fn revoke(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/sharing/access/{token}",
|
||||
params(("token" = String, Path, description = "Share token")),
|
||||
responses(
|
||||
(status = 200, description = "Shared resource", body = SharedResourceResponse),
|
||||
(status = 404, description = "Invalid token")
|
||||
)
|
||||
)]
|
||||
pub async fn access_by_token(
|
||||
State(state): State<AppState>,
|
||||
Path((token,)): Path<(String,)>,
|
||||
|
||||
@@ -10,6 +10,15 @@ use axum::{
|
||||
};
|
||||
use domain::value_objects::SystemId;
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/sidecar/export/{asset_id}",
|
||||
security(("bearer_token" = [])),
|
||||
params(("asset_id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||
responses(
|
||||
(status = 200, description = "Sidecar exported", body = SidecarExportResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn export_sidecar(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -23,6 +32,14 @@ pub async fn export_sidecar(
|
||||
Ok(Json(SidecarExportResponse::from_domain(&record)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/sidecar/detect-changes",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Changes detected", body = DetectChangesResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn detect_changes(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -38,6 +55,15 @@ pub async fn detect_changes(
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/sidecar/import/{asset_id}",
|
||||
security(("bearer_token" = [])),
|
||||
params(("asset_id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||
responses(
|
||||
(status = 200, description = "Sidecar imported", body = SidecarImportResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn import_sidecar(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -54,6 +80,16 @@ pub async fn import_sidecar(
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/sidecar/resolve/{asset_id}",
|
||||
request_body = api_types::requests::ResolveConflictRequest,
|
||||
security(("bearer_token" = [])),
|
||||
params(("asset_id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||
responses(
|
||||
(status = 200, description = "Conflict resolved", body = SidecarExportResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn resolve_conflict(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -70,6 +106,14 @@ pub async fn resolve_conflict(
|
||||
Ok(Json(SidecarExportResponse::from_domain(&record)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/sidecar/full-export",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Full export completed", body = DetectChangesResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn full_export(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -83,6 +127,14 @@ pub async fn full_export(
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/sidecar/full-import",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Full import completed", body = DetectChangesResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn full_import(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::{errors::AppError, extractors::JwtClaims, parsers, state::AppState};
|
||||
use api_types::{requests::CreateStackRequest, responses::StackResponse};
|
||||
use application::catalog::{
|
||||
CreateStackCommand, DeleteStackCommand, DetectLivePhotosCommand, GetStackQuery,
|
||||
CreateStackCommand, DeleteStackCommand, DetectLivePhotosCommand, GetStackQuery, ListStacksQuery,
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -10,6 +10,35 @@ use axum::{
|
||||
};
|
||||
use domain::value_objects::SystemId;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/stacks",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "List of stacks", body = Vec<StackResponse>),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn list_stacks(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
) -> Result<Json<Vec<StackResponse>>, AppError> {
|
||||
let query = ListStacksQuery {
|
||||
owner_id: claims.user_id,
|
||||
};
|
||||
let stacks = state.catalog.list_stacks.execute(query).await?;
|
||||
let resp = stacks.iter().map(StackResponse::from_domain).collect();
|
||||
Ok(Json(resp))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/stacks",
|
||||
request_body = CreateStackRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Stack created", body = StackResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn create_stack(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -38,6 +67,15 @@ pub async fn create_stack(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/stacks/{id}",
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Stack ID")),
|
||||
responses(
|
||||
(status = 200, description = "Stack details", body = StackResponse),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn get_stack(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -51,6 +89,15 @@ pub async fn get_stack(
|
||||
Ok(Json(StackResponse::from_domain(&stack)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete, path = "/api/v1/stacks/{id}",
|
||||
security(("bearer_token" = [])),
|
||||
params(("id" = uuid::Uuid, Path, description = "Stack ID")),
|
||||
responses(
|
||||
(status = 204, description = "Stack deleted"),
|
||||
(status = 404, description = "Not found")
|
||||
)
|
||||
)]
|
||||
pub async fn delete_stack(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -64,6 +111,14 @@ pub async fn delete_stack(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/stacks/detect-live-photos",
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 200, description = "Detected live photo stacks", body = Vec<StackResponse>),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn detect_live_photos(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -11,6 +11,15 @@ use axum::{
|
||||
};
|
||||
use domain::value_objects::SystemId;
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/storage/volumes",
|
||||
request_body = RegisterVolumeRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Volume registered", body = VolumeResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn register_volume(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -29,6 +38,15 @@ pub async fn register_volume(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post, path = "/api/v1/storage/library-paths",
|
||||
request_body = RegisterLibraryPathRequest,
|
||||
security(("bearer_token" = [])),
|
||||
responses(
|
||||
(status = 201, description = "Library path registered", body = LibraryPathResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn register_library_path(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
@@ -51,6 +69,18 @@ pub async fn register_library_path(
|
||||
const DEFAULT_QUOTA_USAGE_TYPE: &str = "storage_bytes";
|
||||
const DEFAULT_QUOTA_AMOUNT: u64 = 0;
|
||||
|
||||
#[utoipa::path(
|
||||
get, path = "/api/v1/storage/quota",
|
||||
security(("bearer_token" = [])),
|
||||
params(
|
||||
("usage_type" = Option<String>, Query, description = "Usage type"),
|
||||
("amount" = Option<u64>, Query, description = "Requested amount")
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Quota check result", body = QuotaCheckResponse),
|
||||
(status = 401, description = "Unauthorized")
|
||||
)
|
||||
)]
|
||||
pub async fn check_quota(
|
||||
State(state): State<AppState>,
|
||||
claims: JwtClaims,
|
||||
|
||||
@@ -13,15 +13,111 @@ use utoipa_scalar::{Scalar, Servable};
|
||||
crate::handlers::auth::register,
|
||||
crate::handlers::auth::login,
|
||||
crate::handlers::auth::me,
|
||||
crate::handlers::auth::refresh,
|
||||
crate::handlers::auth::logout,
|
||||
crate::handlers::albums::list_albums,
|
||||
crate::handlers::albums::create_album,
|
||||
crate::handlers::albums::get_album,
|
||||
crate::handlers::albums::update_album,
|
||||
crate::handlers::albums::add_entry,
|
||||
crate::handlers::albums::remove_entry,
|
||||
crate::handlers::assets::search_assets,
|
||||
crate::handlers::assets::ingest,
|
||||
crate::handlers::assets::timeline,
|
||||
crate::handlers::assets::get_asset,
|
||||
crate::handlers::assets::update_metadata,
|
||||
crate::handlers::assets::serve_file,
|
||||
crate::handlers::assets::serve_derivative,
|
||||
crate::handlers::assets::tag_asset,
|
||||
crate::handlers::assets::delete_asset,
|
||||
crate::handlers::assets::register_asset,
|
||||
crate::handlers::assets::bulk_delete,
|
||||
crate::handlers::assets::bulk_tag,
|
||||
crate::handlers::stacks::list_stacks,
|
||||
crate::handlers::stacks::create_stack,
|
||||
crate::handlers::stacks::get_stack,
|
||||
crate::handlers::stacks::delete_stack,
|
||||
crate::handlers::stacks::detect_live_photos,
|
||||
crate::handlers::duplicates::list_duplicates,
|
||||
crate::handlers::duplicates::resolve_duplicate,
|
||||
crate::handlers::sharing::share_resource,
|
||||
crate::handlers::sharing::generate_link,
|
||||
crate::handlers::sharing::revoke,
|
||||
crate::handlers::sharing::access_by_token,
|
||||
crate::handlers::storage::register_volume,
|
||||
crate::handlers::storage::register_library_path,
|
||||
crate::handlers::storage::check_quota,
|
||||
crate::handlers::processing::list_jobs,
|
||||
crate::handlers::processing::enqueue_job,
|
||||
crate::handlers::processing::start_job,
|
||||
crate::handlers::processing::complete_job,
|
||||
crate::handlers::processing::fail_job,
|
||||
crate::handlers::processing::batch_progress,
|
||||
crate::handlers::processing::manage_plugin,
|
||||
crate::handlers::processing::configure_pipeline,
|
||||
crate::handlers::sidecar::export_sidecar,
|
||||
crate::handlers::sidecar::detect_changes,
|
||||
crate::handlers::sidecar::import_sidecar,
|
||||
crate::handlers::sidecar::resolve_conflict,
|
||||
crate::handlers::sidecar::full_export,
|
||||
crate::handlers::sidecar::full_import,
|
||||
),
|
||||
components(schemas(
|
||||
api_types::requests::RegisterRequest,
|
||||
api_types::requests::LoginRequest,
|
||||
api_types::requests::RefreshTokenRequest,
|
||||
api_types::requests::CreateAlbumRequest,
|
||||
api_types::requests::UpdateAlbumRequest,
|
||||
api_types::requests::AlbumEntryRequest,
|
||||
api_types::requests::RegisterAssetRequest,
|
||||
api_types::requests::UpdateMetadataRequest,
|
||||
api_types::requests::TagAssetRequest,
|
||||
api_types::requests::BulkDeleteRequest,
|
||||
api_types::requests::BulkTagRequest,
|
||||
api_types::requests::CreateStackRequest,
|
||||
api_types::requests::StackMemberRequest,
|
||||
api_types::requests::ResolveDuplicateRequest,
|
||||
api_types::requests::ShareResourceRequest,
|
||||
api_types::requests::GenerateShareLinkRequest,
|
||||
api_types::requests::RegisterVolumeRequest,
|
||||
api_types::requests::RegisterLibraryPathRequest,
|
||||
api_types::requests::CheckQuotaParams,
|
||||
api_types::requests::EnqueueJobRequest,
|
||||
api_types::requests::CompleteJobRequest,
|
||||
api_types::requests::FailJobRequest,
|
||||
api_types::requests::ManagePluginRequest,
|
||||
api_types::requests::ConfigurePipelineRequest,
|
||||
api_types::requests::PipelineStepRequest,
|
||||
api_types::responses::AuthResponse,
|
||||
api_types::responses::UserResponse,
|
||||
api_types::responses::AlbumResponse,
|
||||
api_types::responses::AssetResponse,
|
||||
api_types::responses::TimelineResponse,
|
||||
api_types::responses::IngestResponse,
|
||||
api_types::responses::TagResponse,
|
||||
api_types::responses::StackResponse,
|
||||
api_types::responses::StackMemberResponse,
|
||||
api_types::responses::DuplicateGroupResponse,
|
||||
api_types::responses::DuplicateCandidateResponse,
|
||||
api_types::responses::ShareScopeResponse,
|
||||
api_types::responses::ShareLinkResponse,
|
||||
api_types::responses::SharedResourceResponse,
|
||||
api_types::responses::VolumeResponse,
|
||||
api_types::responses::LibraryPathResponse,
|
||||
api_types::responses::QuotaCheckResponse,
|
||||
api_types::responses::JobResponse,
|
||||
api_types::responses::JobListResponse,
|
||||
api_types::responses::BatchProgressResponse,
|
||||
api_types::responses::PluginResponse,
|
||||
api_types::responses::PipelineResponse,
|
||||
api_types::responses::SidecarExportResponse,
|
||||
api_types::responses::DetectChangesResponse,
|
||||
api_types::responses::SidecarImportResponse,
|
||||
api_types::requests::ResolveConflictRequest,
|
||||
)),
|
||||
modifiers(&SecurityAddon),
|
||||
info(title = "k-template", version = "0.1.0")
|
||||
info(title = "K-Photos API", version = "0.1.0",
|
||||
description = "Self-hosted photo management API")
|
||||
)]
|
||||
pub struct ApiDoc;
|
||||
|
||||
|
||||
@@ -26,8 +26,14 @@ fn protected_routes(state: &AppState) -> Router<AppState> {
|
||||
.route("/auth/me", get(auth::me))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
// albums
|
||||
.route("/albums", post(albums::create_album))
|
||||
.route("/albums/{id}", get(albums::get_album))
|
||||
.route(
|
||||
"/albums",
|
||||
get(albums::list_albums).post(albums::create_album),
|
||||
)
|
||||
.route(
|
||||
"/albums/{id}",
|
||||
get(albums::get_album).put(albums::update_album),
|
||||
)
|
||||
.route("/albums/{id}/entries", post(albums::add_entry))
|
||||
.route(
|
||||
"/albums/{id}/entries/{asset_id}",
|
||||
@@ -49,8 +55,13 @@ fn protected_routes(state: &AppState) -> Router<AppState> {
|
||||
get(assets::serve_derivative),
|
||||
)
|
||||
.route("/assets/{id}/tags", post(assets::tag_asset))
|
||||
.route("/assets/bulk-delete", post(assets::bulk_delete))
|
||||
.route("/assets/bulk-tag", post(assets::bulk_tag))
|
||||
// stacks
|
||||
.route("/stacks", post(stacks::create_stack))
|
||||
.route(
|
||||
"/stacks",
|
||||
get(stacks::list_stacks).post(stacks::create_stack),
|
||||
)
|
||||
.route(
|
||||
"/stacks/detect-live-photos",
|
||||
post(stacks::detect_live_photos),
|
||||
|
||||
@@ -4,15 +4,16 @@ use application::{
|
||||
catalog::{
|
||||
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
|
||||
GetAssetHandler, GetStackHandler, GetTimelineHandler, ListDuplicatesHandler,
|
||||
ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
|
||||
SearchAssetsHandler, UpdateMetadataHandler,
|
||||
ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler,
|
||||
ResolveDuplicateHandler, SearchAssetsHandler, UpdateMetadataHandler,
|
||||
},
|
||||
identity::{
|
||||
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler,
|
||||
RegisterUserHandler,
|
||||
},
|
||||
organization::{
|
||||
CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler, TagAssetHandler,
|
||||
CreateAlbumHandler, GetAlbumHandler, ListAlbumsHandler, ManageAlbumEntriesHandler,
|
||||
TagAssetHandler, UpdateAlbumHandler,
|
||||
},
|
||||
processing::{
|
||||
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
|
||||
@@ -59,13 +60,16 @@ pub struct CatalogHandlers {
|
||||
pub get_stack: Arc<GetStackHandler>,
|
||||
pub delete_stack: Arc<DeleteStackHandler>,
|
||||
pub detect_live_photos: Arc<DetectLivePhotosHandler>,
|
||||
pub list_stacks: Arc<ListStacksHandler>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OrganizationHandlers {
|
||||
pub create_album: Arc<CreateAlbumHandler>,
|
||||
pub get_album: Arc<GetAlbumHandler>,
|
||||
pub list_albums: Arc<ListAlbumsHandler>,
|
||||
pub manage_album_entries: Arc<ManageAlbumEntriesHandler>,
|
||||
pub update_album: Arc<UpdateAlbumHandler>,
|
||||
pub tag_asset: Arc<TagAssetHandler>,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user