//! Chapter marker handlers for audio items. use axum::{ extract::{Path, State}, http::{header::HeaderMap, StatusCode}, response::{IntoResponse, Response}, Json, }; use serde::{Deserialize, Serialize}; use crate::{ auth::AuthUser, db::{self, ChapterId, ItemId}, error::{AppError, Result}, helpers::{htmx_toast_response, is_htmx_request}, types::ListResponse, validation, AppState, }; use super::super::verify_item_ownership; /// JSON input for creating a chapter marker on an audio item. #[derive(Debug, Deserialize)] pub struct CreateChapterRequest { pub title: String, pub start_seconds: f32, #[serde(default)] pub sort_order: i32, } /// JSON input for updating an existing chapter marker. #[derive(Debug, Deserialize)] pub struct UpdateChapterRequest { pub title: String, pub start_seconds: f32, pub sort_order: i32, } /// JSON response representing a chapter marker. #[derive(Debug, Serialize)] struct ChapterResponse { id: ChapterId, item_id: ItemId, title: String, start_seconds: f32, sort_order: i32, } /// Create a new chapter marker on an owned audio item. #[tracing::instrument(skip_all, name = "items::create_chapter")] pub(in crate::routes::api) async fn create_chapter( State(state): State, AuthUser(user): AuthUser, Path(item_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; validation::validate_chapter_title(&req.title)?; let (item, _) = verify_item_ownership(&state, item_id, user.id).await?; let chapter = db::chapters::create_chapter( &state.db, item_id, &req.title, req.start_seconds, req.sort_order, ) .await?; db::projects::bump_cache_generation(&state.db, item.project_id).await?; Ok(Json(ChapterResponse { id: chapter.id, item_id: chapter.item_id, title: chapter.title, start_seconds: chapter.start_seconds, sort_order: chapter.sort_order, })) } /// List all chapters for a given item (public items only; drafts return 404). #[tracing::instrument(skip_all, name = "items::list_chapters")] pub(in crate::routes::api) async fn list_chapters( State(state): State, Path(item_id): Path, ) -> Result { let item = db::items::get_item_by_id(&state.db, item_id) .await? .ok_or(AppError::NotFound)?; if !item.is_public { return Err(AppError::NotFound); } let chapters = db::chapters::get_chapters_by_item(&state.db, item_id).await?; let data: Vec = chapters.into_iter().map(|c| ChapterResponse { id: c.id, item_id: c.item_id, title: c.title, start_seconds: c.start_seconds, sort_order: c.sort_order, }).collect(); Ok(Json(ListResponse { data })) } /// Update an existing chapter marker on an owned item. #[tracing::instrument(skip_all, name = "items::update_chapter")] pub(in crate::routes::api) async fn update_chapter( State(state): State, AuthUser(user): AuthUser, Path(chapter_id): Path, Json(req): Json, ) -> Result { user.check_not_suspended()?; validation::validate_chapter_title(&req.title)?; // Get chapter to find item_id for ownership check let chapter = db::chapters::get_chapter_by_id(&state.db, chapter_id) .await? .ok_or(AppError::NotFound)?; let (item, _) = verify_item_ownership(&state, chapter.item_id, user.id).await?; let updated = db::chapters::update_chapter( &state.db, chapter_id, &req.title, req.start_seconds, req.sort_order, ) .await?; db::projects::bump_cache_generation(&state.db, item.project_id).await?; Ok(Json(ChapterResponse { id: updated.id, item_id: updated.item_id, title: updated.title, start_seconds: updated.start_seconds, sort_order: updated.sort_order, })) } /// Delete a chapter marker from an owned item. #[tracing::instrument(skip_all, name = "items::delete_chapter")] pub(in crate::routes::api) async fn delete_chapter( State(state): State, headers: HeaderMap, AuthUser(user): AuthUser, Path(chapter_id): Path, ) -> Result { user.check_not_suspended()?; // Get chapter to find item_id for ownership check let chapter = db::chapters::get_chapter_by_id(&state.db, chapter_id) .await? .ok_or(AppError::NotFound)?; let (item, _) = verify_item_ownership(&state, chapter.item_id, user.id).await?; db::chapters::delete_chapter(&state.db, chapter_id).await?; db::projects::bump_cache_generation(&state.db, item.project_id).await?; if is_htmx_request(&headers) { return Ok(htmx_toast_response("Chapter deleted", "success").into_response()); } Ok(StatusCode::NO_CONTENT.into_response()) }