Skip to main content

max / makenotwork

5.5 KB · 210 lines History Blame Raw
1 //! Content insertion CRUD: reusable clips (intros, outros, sponsor reads)
2 //! and their per-item placements.
3
4 use sqlx::PgPool;
5
6 use super::models::*;
7 use super::{ContentInsertionId, ContentInsertionPlacementId, InsertionPosition, ItemId, UserId};
8 use crate::error::Result;
9
10 // ── Insertion library ──
11
12 /// Create a new reusable insertion clip for a creator.
13 #[allow(clippy::too_many_arguments)]
14 #[tracing::instrument(skip_all)]
15 pub async fn create_insertion(
16 pool: &PgPool,
17 user_id: UserId,
18 title: &str,
19 media_type: &str,
20 storage_key: &str,
21 duration_ms: i32,
22 file_size: i64,
23 mime_type: &str,
24 ) -> Result<DbContentInsertion> {
25 let row = sqlx::query_as::<_, DbContentInsertion>(
26 r#"
27 INSERT INTO content_insertions (user_id, title, media_type, storage_key, duration_ms, file_size, mime_type)
28 VALUES ($1, $2, $3, $4, $5, $6, $7)
29 RETURNING *
30 "#,
31 )
32 .bind(user_id)
33 .bind(title)
34 .bind(media_type)
35 .bind(storage_key)
36 .bind(duration_ms)
37 .bind(file_size)
38 .bind(mime_type)
39 .fetch_one(pool)
40 .await?;
41
42 Ok(row)
43 }
44
45 /// List all insertion clips for a creator, newest first.
46 #[tracing::instrument(skip_all)]
47 pub async fn list_insertions(pool: &PgPool, user_id: UserId) -> Result<Vec<DbContentInsertion>> {
48 let rows = sqlx::query_as::<_, DbContentInsertion>(
49 "SELECT * FROM content_insertions WHERE user_id = $1 ORDER BY created_at DESC LIMIT 500",
50 )
51 .bind(user_id)
52 .fetch_all(pool)
53 .await?;
54
55 Ok(rows)
56 }
57
58 /// Get a single insertion by ID, scoped to the owning user.
59 #[tracing::instrument(skip_all)]
60 pub async fn get_insertion(
61 pool: &PgPool,
62 id: ContentInsertionId,
63 user_id: UserId,
64 ) -> Result<Option<DbContentInsertion>> {
65 let row = sqlx::query_as::<_, DbContentInsertion>(
66 "SELECT * FROM content_insertions WHERE id = $1 AND user_id = $2",
67 )
68 .bind(id)
69 .bind(user_id)
70 .fetch_optional(pool)
71 .await?;
72
73 Ok(row)
74 }
75
76 /// Rename an insertion clip. Returns true if the row was found and updated.
77 #[tracing::instrument(skip_all)]
78 pub async fn update_insertion_title(
79 pool: &PgPool,
80 id: ContentInsertionId,
81 user_id: UserId,
82 title: &str,
83 ) -> Result<bool> {
84 let result = sqlx::query(
85 "UPDATE content_insertions SET title = $3 WHERE id = $1 AND user_id = $2",
86 )
87 .bind(id)
88 .bind(user_id)
89 .bind(title)
90 .execute(pool)
91 .await?;
92
93 Ok(result.rows_affected() > 0)
94 }
95
96 /// Delete an insertion clip (placements cascade). Returns true if deleted.
97 #[tracing::instrument(skip_all)]
98 pub async fn delete_insertion(
99 pool: &PgPool,
100 id: ContentInsertionId,
101 user_id: UserId,
102 ) -> Result<bool> {
103 let result = sqlx::query(
104 "DELETE FROM content_insertions WHERE id = $1 AND user_id = $2",
105 )
106 .bind(id)
107 .bind(user_id)
108 .execute(pool)
109 .await?;
110
111 Ok(result.rows_affected() > 0)
112 }
113
114 // ── Placements ──
115
116 /// Attach an insertion clip to an item at a given position.
117 #[tracing::instrument(skip_all)]
118 pub async fn create_placement(
119 pool: &PgPool,
120 item_id: ItemId,
121 insertion_id: ContentInsertionId,
122 position: InsertionPosition,
123 offset_ms: Option<i32>,
124 sort_order: i32,
125 ) -> Result<DbInsertionPlacement> {
126 let row = sqlx::query_as::<_, DbInsertionPlacement>(
127 r#"
128 INSERT INTO content_insertion_placements (item_id, insertion_id, position, offset_ms, sort_order)
129 VALUES ($1, $2, $3, $4, $5)
130 RETURNING *
131 "#,
132 )
133 .bind(item_id)
134 .bind(insertion_id)
135 .bind(position)
136 .bind(offset_ms)
137 .bind(sort_order)
138 .fetch_one(pool)
139 .await?;
140
141 Ok(row)
142 }
143
144 /// List all placements for an item, joined with insertion metadata.
145 /// Ordered by position (pre_roll, mid_roll by offset, post_roll) then sort_order.
146 #[tracing::instrument(skip_all)]
147 pub async fn list_placements_for_item(
148 pool: &PgPool,
149 item_id: ItemId,
150 ) -> Result<Vec<DbPlacementWithInsertion>> {
151 let rows = sqlx::query_as::<_, DbPlacementWithInsertion>(
152 r#"
153 SELECT
154 p.id, p.item_id, p.insertion_id, p.position, p.offset_ms, p.sort_order, p.created_at,
155 i.title AS insertion_title,
156 i.duration_ms AS insertion_duration_ms,
157 i.storage_key AS insertion_storage_key
158 FROM content_insertion_placements p
159 JOIN content_insertions i ON i.id = p.insertion_id
160 WHERE p.item_id = $1
161 ORDER BY
162 CASE p.position
163 WHEN 'pre_roll' THEN 0
164 WHEN 'mid_roll' THEN 1
165 WHEN 'post_roll' THEN 2
166 END,
167 p.offset_ms NULLS LAST,
168 p.sort_order
169 LIMIT 100
170 "#,
171 )
172 .bind(item_id)
173 .fetch_all(pool)
174 .await?;
175
176 Ok(rows)
177 }
178
179 /// Delete a single placement by ID. Returns true if deleted.
180 #[tracing::instrument(skip_all)]
181 pub async fn delete_placement(
182 pool: &PgPool,
183 placement_id: ContentInsertionPlacementId,
184 ) -> Result<bool> {
185 let result = sqlx::query(
186 "DELETE FROM content_insertion_placements WHERE id = $1",
187 )
188 .bind(placement_id)
189 .execute(pool)
190 .await?;
191
192 Ok(result.rows_affected() > 0)
193 }
194
195 /// Get a placement by ID (for ownership verification via item).
196 #[tracing::instrument(skip_all)]
197 pub async fn get_placement_by_id(
198 pool: &PgPool,
199 placement_id: ContentInsertionPlacementId,
200 ) -> Result<Option<DbInsertionPlacement>> {
201 let row = sqlx::query_as::<_, DbInsertionPlacement>(
202 "SELECT * FROM content_insertion_placements WHERE id = $1",
203 )
204 .bind(placement_id)
205 .fetch_optional(pool)
206 .await?;
207
208 Ok(row)
209 }
210