Skip to main content

max / makenotwork

4.9 KB · 167 lines History Blame Raw
1 //! Chapter marker handlers for audio items.
2
3 use axum::{
4 extract::{Path, State},
5 http::{header::HeaderMap, StatusCode},
6 response::{IntoResponse, Response},
7 Json,
8 };
9 use serde::{Deserialize, Serialize};
10
11 use crate::{
12 auth::AuthUser,
13 db::{self, ChapterId, ItemId},
14 error::{AppError, Result},
15 helpers::{htmx_toast_response, is_htmx_request},
16 types::ListResponse,
17 validation,
18 AppState,
19 };
20
21 use super::super::verify_item_ownership;
22
23 /// JSON input for creating a chapter marker on an audio item.
24 #[derive(Debug, Deserialize)]
25 pub struct CreateChapterRequest {
26 pub title: String,
27 pub start_seconds: f32,
28 #[serde(default)]
29 pub sort_order: i32,
30 }
31
32 /// JSON input for updating an existing chapter marker.
33 #[derive(Debug, Deserialize)]
34 pub struct UpdateChapterRequest {
35 pub title: String,
36 pub start_seconds: f32,
37 pub sort_order: i32,
38 }
39
40 /// JSON response representing a chapter marker.
41 #[derive(Debug, Serialize)]
42 struct ChapterResponse {
43 id: ChapterId,
44 item_id: ItemId,
45 title: String,
46 start_seconds: f32,
47 sort_order: i32,
48 }
49
50 /// Create a new chapter marker on an owned audio item.
51 #[tracing::instrument(skip_all, name = "items::create_chapter")]
52 pub(in crate::routes::api) async fn create_chapter(
53 State(state): State<AppState>,
54 AuthUser(user): AuthUser,
55 Path(item_id): Path<ItemId>,
56 Json(req): Json<CreateChapterRequest>,
57 ) -> Result<impl IntoResponse> {
58 user.check_not_suspended()?;
59 validation::validate_chapter_title(&req.title)?;
60 let (item, _) = verify_item_ownership(&state, item_id, user.id).await?;
61
62 let chapter = db::chapters::create_chapter(
63 &state.db,
64 item_id,
65 &req.title,
66 req.start_seconds,
67 req.sort_order,
68 )
69 .await?;
70
71 db::projects::bump_cache_generation(&state.db, item.project_id).await?;
72
73 Ok(Json(ChapterResponse {
74 id: chapter.id,
75 item_id: chapter.item_id,
76 title: chapter.title,
77 start_seconds: chapter.start_seconds,
78 sort_order: chapter.sort_order,
79 }))
80 }
81
82 /// List all chapters for a given item (public items only; drafts return 404).
83 #[tracing::instrument(skip_all, name = "items::list_chapters")]
84 pub(in crate::routes::api) async fn list_chapters(
85 State(state): State<AppState>,
86 Path(item_id): Path<ItemId>,
87 ) -> Result<impl IntoResponse> {
88 let item = db::items::get_item_by_id(&state.db, item_id)
89 .await?
90 .ok_or(AppError::NotFound)?;
91 if !item.is_public {
92 return Err(AppError::NotFound);
93 }
94 let chapters = db::chapters::get_chapters_by_item(&state.db, item_id).await?;
95 let data: Vec<ChapterResponse> = chapters.into_iter().map(|c| ChapterResponse {
96 id: c.id,
97 item_id: c.item_id,
98 title: c.title,
99 start_seconds: c.start_seconds,
100 sort_order: c.sort_order,
101 }).collect();
102 Ok(Json(ListResponse { data }))
103 }
104
105 /// Update an existing chapter marker on an owned item.
106 #[tracing::instrument(skip_all, name = "items::update_chapter")]
107 pub(in crate::routes::api) async fn update_chapter(
108 State(state): State<AppState>,
109 AuthUser(user): AuthUser,
110 Path(chapter_id): Path<ChapterId>,
111 Json(req): Json<UpdateChapterRequest>,
112 ) -> Result<impl IntoResponse> {
113 user.check_not_suspended()?;
114 validation::validate_chapter_title(&req.title)?;
115 // Get chapter to find item_id for ownership check
116 let chapter = db::chapters::get_chapter_by_id(&state.db, chapter_id)
117 .await?
118 .ok_or(AppError::NotFound)?;
119
120 let (item, _) = verify_item_ownership(&state, chapter.item_id, user.id).await?;
121
122 let updated = db::chapters::update_chapter(
123 &state.db,
124 chapter_id,
125 &req.title,
126 req.start_seconds,
127 req.sort_order,
128 )
129 .await?;
130
131 db::projects::bump_cache_generation(&state.db, item.project_id).await?;
132
133 Ok(Json(ChapterResponse {
134 id: updated.id,
135 item_id: updated.item_id,
136 title: updated.title,
137 start_seconds: updated.start_seconds,
138 sort_order: updated.sort_order,
139 }))
140 }
141
142 /// Delete a chapter marker from an owned item.
143 #[tracing::instrument(skip_all, name = "items::delete_chapter")]
144 pub(in crate::routes::api) async fn delete_chapter(
145 State(state): State<AppState>,
146 headers: HeaderMap,
147 AuthUser(user): AuthUser,
148 Path(chapter_id): Path<ChapterId>,
149 ) -> Result<Response> {
150 user.check_not_suspended()?;
151 // Get chapter to find item_id for ownership check
152 let chapter = db::chapters::get_chapter_by_id(&state.db, chapter_id)
153 .await?
154 .ok_or(AppError::NotFound)?;
155
156 let (item, _) = verify_item_ownership(&state, chapter.item_id, user.id).await?;
157
158 db::chapters::delete_chapter(&state.db, chapter_id).await?;
159 db::projects::bump_cache_generation(&state.db, item.project_id).await?;
160
161 if is_htmx_request(&headers) {
162 return Ok(htmx_toast_response("Chapter deleted", "success").into_response());
163 }
164
165 Ok(StatusCode::NO_CONTENT.into_response())
166 }
167