Skip to main content

max / makenotwork

Support video clips in dynamic insertions Insertion clips were audio-only (validation + a hardcoded media_type) even though the segment player already ran on video items. Make video clips a first-class path: - Upload: new ALLOWED_INSERTION_TYPES (audio + video); dedicated Insertion arm in content-type scanning accepting audio-or-video magic. - Persist real media_type derived from the confirmed MIME. - Placement guard: clips only on audio/video items; video clips require a video item; audio clips fit either. Dropdown filtered to compatible clips. - Player: dual-element gapless video (second <video> + visibility toggle); client duration detection and MIME resolution handle video clips. Video mid-roll still re-buffers the main asset (segment-model reload); the overlay rewrite to remove it is tracked as a follow-up. Gate: lib 1787 + integration content_insertions 15 (7 new) + creator_media 12 green; clippy --lib --tests clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-01 15:56 UTC
Commit: 8a9ca199bf392110572649675ab4fedef58bfb34
Parent: 03e2a7b
10 files changed, +371 insertions, -27 deletions
@@ -147,7 +147,8 @@ Audio/podcast chapter markers. Sorted by `start_seconds`.
147 147 - **FK:** item_id → items CASCADE
148 148
149 149 ### content_insertions
150 - Reusable audio clips (ads, intros, outros) uploaded by creators.
150 + Reusable audio or video clips (ads, intros, outros) uploaded by creators.
151 + `media_type` is `audio` or `video`, derived from the confirmed MIME.
151 152
152 153 - **FK:** user_id → users CASCADE
153 154 - **Key columns:** title, media_type, storage_key, duration_ms, file_size
@@ -188,11 +188,16 @@ pub(super) async fn confirm_insertion(
188 188 // Clear the pending upload record now that the upload is confirmed
189 189 db::pending_uploads::remove_pending_upload(&state.db, user.id, &req.s3_key, "main").await?;
190 190
191 + // Persist the real media family (audio vs video) derived from the validated
192 + // MIME, rather than assuming audio. Drives the placement type-compat guard
193 + // and which player element the clip renders in.
194 + let media_type = S3Client::insertion_media_type(&req.mime_type);
195 +
191 196 let insertion = db::content_insertions::create_insertion(
192 197 &state.db,
193 198 user.id,
194 199 &req.title,
195 - "audio",
200 + media_type,
196 201 &req.s3_key,
197 202 req.duration_ms,
198 203 file_size_bytes,
@@ -337,10 +342,16 @@ pub(super) async fn list_placements(
337 342 AuthUser(user): AuthUser,
338 343 Path(item_id): Path<ItemId>,
339 344 ) -> Result<impl IntoResponse> {
340 - verify_item_ownership(&state, item_id, user.id).await?;
345 + let (item, _project) = verify_item_ownership(&state, item_id, user.id).await?;
341 346
342 347 let placements = db::content_insertions::list_placements_for_item(&state.db, item_id).await?;
343 - let available = db::content_insertions::list_insertions(&state.db, user.id).await?;
348 + // Only offer clips that can legally be placed on this item (a video clip on an
349 + // audio item would be rejected by create_placement, so don't surface it).
350 + let available: Vec<_> = db::content_insertions::list_insertions(&state.db, user.id)
351 + .await?
352 + .into_iter()
353 + .filter(|i| clip_compatible_with_item(&i.media_type, item.item_type))
354 + .collect();
344 355
345 356 let placement_display: Vec<crate::templates::PlacementDisplay> = placements
346 357 .iter()
@@ -384,13 +395,28 @@ pub(super) async fn create_placement(
384 395 Json(req): Json<CreatePlacementRequest>,
385 396 ) -> Result<impl IntoResponse> {
386 397 user.check_not_suspended()?;
387 - verify_item_ownership(&state, item_id, user.id).await?;
398 + let (item, _project) = verify_item_ownership(&state, item_id, user.id).await?;
388 399
389 400 // Verify the insertion belongs to this user
390 401 let insertion = db::content_insertions::get_insertion(&state.db, req.insertion_id, user.id)
391 402 .await?
392 403 .ok_or(AppError::NotFound)?;
393 404
405 + // Only audio/video items host a media player, so only they can carry clips.
406 + // A video clip additionally requires a video item — an audio item renders in
407 + // an `<audio>` element that would silently drop the clip's video track. An
408 + // audio clip on a video item is allowed (plays as a blank-frame segment).
409 + if !item_hosts_insertions(item.item_type) {
410 + return Err(AppError::BadRequest(
411 + "Clips can only be placed on audio or video items".to_string(),
412 + ));
413 + }
414 + if !clip_compatible_with_item(&insertion.media_type, item.item_type) {
415 + return Err(AppError::BadRequest(
416 + "Video clips can only be placed on video items".to_string(),
417 + ));
418 + }
419 +
394 420 // Validate mid-roll offset
395 421 if req.position == InsertionPosition::MidRoll && req.offset_ms.is_none() {
396 422 return Err(AppError::BadRequest(
@@ -437,6 +463,21 @@ pub(super) async fn delete_placement(
437 463 // Helpers
438 464 // =============================================================================
439 465
466 + /// Whether an item type has a media player that can host insertion clips.
467 + /// Only audio and video items render a segment-aware player.
468 + fn item_hosts_insertions(item_type: db::ItemType) -> bool {
469 + matches!(item_type, db::ItemType::Audio | db::ItemType::Video)
470 + }
471 +
472 + /// Whether a clip of the given media type may be placed on the given item type.
473 + /// Video clips require a video item; audio clips fit either audio or video items.
474 + fn clip_compatible_with_item(clip_media_type: &str, item_type: db::ItemType) -> bool {
475 + if !item_hosts_insertions(item_type) {
476 + return false;
477 + }
478 + clip_media_type != "video" || item_type == db::ItemType::Video
479 + }
480 +
440 481 /// Format milliseconds as MM:SS.
441 482 fn format_duration_ms(ms: i32) -> String {
442 483 let total_secs = ms / 1000;
@@ -490,4 +531,31 @@ mod tests {
490 531 // -1500 / 1000 = -1, -1 / 60 = 0, -1 % 60 = -1
491 532 assert_eq!(format_duration_ms(-1500), "0:-1");
492 533 }
534 +
535 + #[test]
536 + fn only_audio_and_video_items_host_insertions() {
537 + assert!(item_hosts_insertions(db::ItemType::Audio));
538 + assert!(item_hosts_insertions(db::ItemType::Video));
539 + assert!(!item_hosts_insertions(db::ItemType::Text));
540 + assert!(!item_hosts_insertions(db::ItemType::Digital));
541 + assert!(!item_hosts_insertions(db::ItemType::Bundle));
542 + }
543 +
544 + #[test]
545 + fn audio_clip_fits_audio_and_video_items() {
546 + assert!(clip_compatible_with_item("audio", db::ItemType::Audio));
547 + assert!(clip_compatible_with_item("audio", db::ItemType::Video));
548 + }
549 +
550 + #[test]
551 + fn video_clip_requires_video_item() {
552 + assert!(clip_compatible_with_item("video", db::ItemType::Video));
553 + assert!(!clip_compatible_with_item("video", db::ItemType::Audio));
554 + }
555 +
556 + #[test]
557 + fn no_clip_fits_a_non_media_item() {
558 + assert!(!clip_compatible_with_item("audio", db::ItemType::Text));
559 + assert!(!clip_compatible_with_item("video", db::ItemType::Text));
560 + }
493 561 }
@@ -72,7 +72,7 @@ pub fn verify_content_type(data: &[u8], claimed_type: FileType) -> LayerResult {
72 72 }
73 73
74 74 match claimed_type {
75 - FileType::Audio | FileType::Insertion => {
75 + FileType::Audio => {
76 76 // Audio files must be detected as audio/* or application/ogg.
77 77 // Unrecognized data is rejected (prevents disguised executables/scripts).
78 78 match detected {
@@ -103,6 +103,43 @@ pub fn verify_content_type(data: &[u8], claimed_type: FileType) -> LayerResult {
103 103 },
104 104 }
105 105 }
106 + FileType::Insertion => {
107 + // An insertion clip may be audio (intro/sponsor read) or video
108 + // (pre/mid/post-roll on a video item), so accept either media family.
109 + // Unrecognized data is still rejected (prevents disguised
110 + // executables/scripts). `application/ogg` is a valid audio container.
111 + match detected {
112 + Some(kind) => {
113 + let mime = kind.mime_type();
114 + let is_media = mime.starts_with("audio/")
115 + || mime.starts_with("video/")
116 + || mime.contains("ogg");
117 + if !is_media {
118 + return LayerResult {
119 + layer: "content_type",
120 + verdict: LayerVerdict::Fail,
121 + detail: Some(format!(
122 + "File claimed as insertion clip but detected as: {}",
123 + mime
124 + )),
125 + };
126 + }
127 + LayerResult {
128 + layer: "content_type",
129 + verdict: LayerVerdict::Pass,
130 + detail: Some(mime.to_string()),
131 + }
132 + }
133 + None => LayerResult {
134 + layer: "content_type",
135 + verdict: LayerVerdict::Fail,
136 + detail: Some(
137 + "Could not detect audio or video format from insertion file header"
138 + .to_string(),
139 + ),
140 + },
141 + }
142 + }
106 143 FileType::Cover | FileType::MediaImage => {
107 144 // Cover/media images must be detected as image/*
108 145 match detected {
@@ -99,6 +99,22 @@ const ALLOWED_VIDEO_TYPES: &[(&str, &str)] = &[
99 99 ("mov", "video/quicktime"),
100 100 ];
101 101
102 + /// Allowed insertion-clip extensions and MIME types. A clip may be audio (the
103 + /// original use: intros, sponsor reads) or video (pre/mid/post-roll on a video
104 + /// item), so this is the union of the audio and video allow-lists. Kept as one
105 + /// literal because `allowed_types()` returns a `&'static` slice.
106 + const ALLOWED_INSERTION_TYPES: &[(&str, &str)] = &[
107 + ("mp3", "audio/mpeg"),
108 + ("wav", "audio/wav"),
109 + ("m4a", "audio/mp4"),
110 + ("ogg", "audio/ogg"),
111 + ("flac", "audio/flac"),
112 + ("aac", "audio/aac"),
113 + ("mp4", "video/mp4"),
114 + ("webm", "video/webm"),
115 + ("mov", "video/quicktime"),
116 + ];
117 +
102 118 /// MIME types accepted for video uploads
103 119 const ALLOWED_VIDEO_MIMES: &[&str] = &[
104 120 "video/mp4",
@@ -244,7 +260,8 @@ impl FileType {
244 260
245 261 pub fn allowed_types(&self) -> &'static [(&'static str, &'static str)] {
246 262 match self {
247 - FileType::Audio | FileType::Insertion => ALLOWED_AUDIO_TYPES,
263 + FileType::Audio => ALLOWED_AUDIO_TYPES,
264 + FileType::Insertion => ALLOWED_INSERTION_TYPES,
248 265 FileType::Cover | FileType::MediaImage => ALLOWED_IMAGE_TYPES,
249 266 FileType::Download => ALLOWED_DOWNLOAD_TYPES,
250 267 FileType::Video | FileType::MediaVideo => ALLOWED_VIDEO_TYPES,
@@ -595,6 +612,19 @@ impl S3Client {
595 612 Ok(())
596 613 }
597 614
615 + /// Classify a validated MIME type as the `media_type` we persist for an
616 + /// insertion clip: `"video"` for `video/*`, otherwise `"audio"`. The MIME is
617 + /// expected to have already passed `validate_content_type`, so the audio
618 + /// fallback is safe (the only non-audio family the insertion allow-list
619 + /// admits is `video/*`).
620 + pub fn insertion_media_type(mime_type: &str) -> &'static str {
621 + if mime_type.starts_with("video/") {
622 + "video"
623 + } else {
624 + "audio"
625 + }
626 + }
627 +
598 628 /// Validate file extension for the given file type
599 629 pub fn validate_extension(file_type: FileType, filename: &str) -> Result<()> {
600 630 if file_type == FileType::Download {
@@ -1221,9 +1251,15 @@ mod tests {
1221 1251
1222 1252 #[test]
1223 1253 fn validate_insertion_content_types() {
1254 + // Audio clips (the original use).
1224 1255 assert!(S3Client::validate_content_type(FileType::Insertion, "audio/mpeg").is_ok());
1225 1256 assert!(S3Client::validate_content_type(FileType::Insertion, "audio/wav").is_ok());
1226 1257 assert!(S3Client::validate_content_type(FileType::Insertion, "audio/flac").is_ok());
1258 + // Video clips (pre/mid/post-roll on video items).
1259 + assert!(S3Client::validate_content_type(FileType::Insertion, "video/mp4").is_ok());
1260 + assert!(S3Client::validate_content_type(FileType::Insertion, "video/webm").is_ok());
1261 + assert!(S3Client::validate_content_type(FileType::Insertion, "video/quicktime").is_ok());
1262 + // Neither audio nor video is rejected.
1227 1263 assert!(S3Client::validate_content_type(FileType::Insertion, "image/png").is_err());
1228 1264 }
1229 1265
@@ -1232,10 +1268,20 @@ mod tests {
1232 1268 assert!(S3Client::validate_extension(FileType::Insertion, "intro.mp3").is_ok());
1233 1269 assert!(S3Client::validate_extension(FileType::Insertion, "sponsor.wav").is_ok());
1234 1270 assert!(S3Client::validate_extension(FileType::Insertion, "outro.flac").is_ok());
1271 + assert!(S3Client::validate_extension(FileType::Insertion, "bumper.mp4").is_ok());
1272 + assert!(S3Client::validate_extension(FileType::Insertion, "bumper.webm").is_ok());
1235 1273 assert!(S3Client::validate_extension(FileType::Insertion, "clip.png").is_err());
1236 1274 }
1237 1275
1238 1276 #[test]
1277 + fn insertion_media_type_classifies_by_mime_family() {
1278 + assert_eq!(S3Client::insertion_media_type("audio/mpeg"), "audio");
1279 + assert_eq!(S3Client::insertion_media_type("audio/mp4"), "audio");
1280 + assert_eq!(S3Client::insertion_media_type("video/mp4"), "video");
1281 + assert_eq!(S3Client::insertion_media_type("video/webm"), "video");
1282 + }
1283 +
1284 + #[test]
1239 1285 fn generate_insertion_key_format() {
1240 1286 let user_id: UserId = "11111111-1111-1111-1111-111111111111".parse().unwrap();
1241 1287 let key = S3Client::generate_insertion_key(user_id, "intro.mp3");
@@ -9,16 +9,34 @@
9 9 document.getElementById('insertion-file-input').click();
10 10 }
11 11
12 + // Map a file extension to a MIME type when the browser doesn't supply one
13 + // (some OSes leave file.type empty). Must stay in sync with the server's
14 + // ALLOWED_INSERTION_TYPES allow-list.
15 + var EXT_MIME = {
16 + mp3: 'audio/mpeg', wav: 'audio/wav', m4a: 'audio/mp4', ogg: 'audio/ogg',
17 + flac: 'audio/flac', aac: 'audio/aac',
18 + mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime'
19 + };
20 +
21 + function resolveMime(file) {
22 + if (file.type) return file.type;
23 + var ext = (file.name.split('.').pop() || '').toLowerCase();
24 + return EXT_MIME[ext] || 'application/octet-stream';
25 + }
26 +
12 27 async function handleFileSelected(input) {
13 28 const file = input.files[0];
14 29 if (!file) return;
15 30
16 - // Detect duration via temporary audio element
31 + var mime = resolveMime(file);
32 + var isVideo = mime.indexOf('video/') === 0;
33 +
34 + // Detect duration via a temporary media element matching the clip kind
17 35 var durationMs;
18 36 try {
19 - durationMs = await detectDuration(file);
37 + durationMs = await detectDuration(file, isVideo);
20 38 } catch (e) {
21 - showToast('Could not detect audio duration. Please try a different file.');
39 + showToast('Could not detect clip duration. Please try a different file.');
22 40 input.value = '';
23 41 return;
24 42 }
@@ -30,7 +48,7 @@
30 48 headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()),
31 49 body: JSON.stringify({
32 50 file_name: file.name,
33 - content_type: file.type || 'audio/mpeg'
51 + content_type: mime
34 52 })
35 53 });
36 54
@@ -44,7 +62,7 @@
44 62 // Step 2: Upload to S3
45 63 var uploadRes = await fetch(presignData.upload_url, {
46 64 method: 'PUT',
47 - headers: { 'Content-Type': file.type || 'audio/mpeg' },
65 + headers: { 'Content-Type': mime },
48 66 body: file
49 67 });
50 68
@@ -62,7 +80,7 @@
62 80 title: title,
63 81 duration_ms: durationMs,
64 82 file_size: file.size,
65 - mime_type: file.type || 'audio/mpeg'
83 + mime_type: mime
66 84 })
67 85 });
68 86
@@ -80,23 +98,23 @@
80 98 input.value = '';
81 99 }
82 100
83 - function detectDuration(file) {
101 + function detectDuration(file, isVideo) {
84 102 return new Promise(function(resolve, reject) {
85 - var audio = document.createElement('audio');
86 - audio.preload = 'metadata';
103 + var el = document.createElement(isVideo ? 'video' : 'audio');
104 + el.preload = 'metadata';
87 105
88 - audio.addEventListener('loadedmetadata', function() {
89 - var ms = Math.round(audio.duration * 1000);
90 - URL.revokeObjectURL(audio.src);
106 + el.addEventListener('loadedmetadata', function() {
107 + var ms = Math.round(el.duration * 1000);
108 + URL.revokeObjectURL(el.src);
91 109 resolve(ms);
92 110 });
93 111
94 - audio.addEventListener('error', function() {
95 - URL.revokeObjectURL(audio.src);
96 - reject(new Error('Cannot read audio metadata'));
112 + el.addEventListener('error', function() {
113 + URL.revokeObjectURL(el.src);
114 + reject(new Error('Cannot read media metadata'));
97 115 });
98 116
99 - audio.src = URL.createObjectURL(file);
117 + el.src = URL.createObjectURL(file);
100 118 });
101 119 }
102 120
@@ -112,6 +112,12 @@
112 112 display: block;
113 113 }
114 114
115 + /* Only the active element is shown; the standby preloads the next segment
116 + off-screen so pre/mid/post-roll transitions are gapless. */
117 + .video-display video.is-standby {
118 + display: none;
119 + }
120 +
115 121 /* Player controls */
116 122 .media-player {
117 123 background: var(--light-background);
@@ -100,8 +100,11 @@
100 100
101 101 var currentSegIndex = 0;
102 102 var activeEl = mediaA;
103 - // Video uses single element (no dual-element gapless — brief gap acceptable)
104 - var standbyEl = isVideo ? null : mediaB;
103 + // Dual-element gapless for both audio and video: the standby element
104 + // preloads the next segment while the active one plays. For video, the two
105 + // <video> tags are stacked and only the active one is shown (see
106 + // syncVideoVisibility). Falls back to single-element if media-b is absent.
107 + var standbyEl = mediaB || null;
105 108 var playing = false;
106 109 var currentSpeed = 1;
107 110 var currentVolume = volumeSlider.value / 100;
@@ -110,6 +113,14 @@
110 113 return segments[idx] && segments[idx].segment_type !== 'main';
111 114 }
112 115
116 + // Show the active <video> and hide the standby so stacked elements don't
117 + // both render. No-op for audio (elements are invisible) or single-element.
118 + function syncVideoVisibility() {
119 + if (!isVideo || !standbyEl) return;
120 + activeEl.classList.remove('is-standby');
121 + standbyEl.classList.add('is-standby');
122 + }
123 +
113 124 function loadSegment(el, idx) {
114 125 if (idx >= segments.length) return;
115 126 var seg = segments[idx];
@@ -134,6 +145,9 @@
134 145 }
135 146
136 147 function updateInsertionUI() {
148 + // Keep the visible video element in sync with the active role on every
149 + // segment transition (this runs after each advance/seek/chapter jump).
150 + syncVideoVisibility();
137 151 if (isInsertionSegment(currentSegIndex)) {
138 152 var seg = segments[currentSegIndex];
139 153 insertionLabel.textContent = seg.title || seg.segment_type.replace('_', '-');
@@ -39,6 +39,8 @@
39 39 <source src="{{ vurl }}">
40 40 {% endif %}
41 41 </video>
42 + <!-- Standby element for gapless segment transitions (pre/mid/post-roll clips). -->
43 + <video id="media-b" class="is-standby" preload="metadata"></video>
42 44 </div>
43 45
44 46 <div class="media-player">
@@ -4,11 +4,11 @@
4 4
5 5 <div class="insertion-list-toolbar">
6 6 <button type="button" class="btn btn-sm insertion-list-upload" onclick="MNW.insertions.startUpload()">Upload Clip</button>
7 - <input type="file" id="insertion-file-input" class="sr-only" accept=".mp3,.wav,.flac,.ogg,.m4a" onchange="MNW.insertions.handleFileSelected(this)">
7 + <input type="file" id="insertion-file-input" class="sr-only" accept=".mp3,.wav,.flac,.ogg,.m4a,.aac,.mp4,.webm,.mov" onchange="MNW.insertions.handleFileSelected(this)">
8 8 </div>
9 9
10 10 {% if insertions.is_empty() %}
11 - {% call ui::empty_state("", "No clips yet. Upload an audio clip to use as a pre-roll, mid-roll, or post-roll on your items.") %}
11 + {% call ui::empty_state("", "No clips yet. Upload an audio or video clip to use as a pre-roll, mid-roll, or post-roll on your items.") %}
12 12 {% else %}
13 13 <table class="data-table insertion-list-table">
14 14 <thead>
@@ -179,3 +179,155 @@ async fn rename_nonexistent_insertion_returns_404() {
179 179 resp.status, resp.text
180 180 );
181 181 }
182 +
183 + // ── Video clips ──
184 +
185 + #[tokio::test]
186 + async fn presign_valid_video_succeeds() {
187 + let mut h = TestHarness::with_storage().await;
188 + let _user_id = setup_creator(&mut h).await;
189 +
190 + let resp = h
191 + .client
192 + .post_json(
193 + "/api/users/me/insertions/presign",
194 + r#"{"file_name": "bumper.mp4", "content_type": "video/mp4"}"#,
195 + )
196 + .await;
197 + assert!(
198 + resp.status.is_success(),
199 + "Valid video presign should succeed: {} {}",
200 + resp.status, resp.text
201 + );
202 + }
203 +
204 + /// Confirm an insertion clip of the given kind and return its JSON body.
205 + /// Uses placeholder bytes: confirm validates size/mime/title synchronously and
206 + /// only sniffs magic bytes later in the async scan worker.
207 + async fn confirm_clip(h: &mut TestHarness, user_id: &str, file: &str, mime: &str) -> Value {
208 + let s3_key = format!("{}/insertions/{}", user_id, file);
209 + h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 1024]);
210 + let resp = h
211 + .client
212 + .post_json(
213 + "/api/users/me/insertions/confirm",
214 + &format!(
215 + r#"{{"s3_key": "{}", "title": "Clip", "duration_ms": 3000, "file_size": 1024, "mime_type": "{}"}}"#,
216 + s3_key, mime
217 + ),
218 + )
219 + .await;
220 + assert!(
221 + resp.status.is_success(),
222 + "Confirm {} should succeed: {} {}",
223 + file, resp.status, resp.text
224 + );
225 + resp.json()
226 + }
227 +
228 + #[tokio::test]
229 + async fn confirm_video_persists_video_media_type() {
230 + let mut h = TestHarness::with_storage().await;
231 + let user_id = setup_creator(&mut h).await;
232 +
233 + let body = confirm_clip(&mut h, &user_id, "bumper.mp4", "video/mp4").await;
234 + assert_eq!(
235 + body["media_type"].as_str().unwrap(),
236 + "video",
237 + "A video/mp4 clip must persist media_type=video, not the legacy audio default"
238 + );
239 + }
240 +
241 + #[tokio::test]
242 + async fn confirm_audio_persists_audio_media_type() {
243 + let mut h = TestHarness::with_storage().await;
244 + let user_id = setup_creator(&mut h).await;
245 +
246 + let body = confirm_clip(&mut h, &user_id, "intro.mp3", "audio/mpeg").await;
247 + assert_eq!(body["media_type"].as_str().unwrap(), "audio");
248 + }
249 +
250 + /// POST a placement of `insertion_id` on `item_id` as a pre-roll.
251 + async fn place_pre_roll(h: &mut TestHarness, item_id: &str, insertion_id: &str) -> u16 {
252 + let resp = h
253 + .client
254 + .post_json(
255 + &format!("/api/items/{}/insertions", item_id),
256 + &format!(
257 + r#"{{"insertion_id": "{}", "position": "pre_roll", "sort_order": 0}}"#,
258 + insertion_id
259 + ),
260 + )
261 + .await;
262 + resp.status.as_u16()
263 + }
264 +
265 + #[tokio::test]
266 + async fn video_clip_rejected_on_audio_item() {
267 + let mut h = TestHarness::with_storage().await;
268 + let setup = h.create_creator_with_item("vidonaud", "audio", 0).await;
269 + h.grant_tier(setup.user_id, "small_files").await;
270 + let user_id = setup.user_id.to_string();
271 +
272 + let clip = confirm_clip(&mut h, &user_id, "bumper.mp4", "video/mp4").await;
273 + let insertion_id = clip["id"].as_str().unwrap().to_string();
274 +
275 + let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await;
276 + assert_eq!(
277 + status, 400,
278 + "A video clip must not be placeable on an audio item"
279 + );
280 + }
281 +
282 + #[tokio::test]
283 + async fn video_clip_allowed_on_video_item() {
284 + let mut h = TestHarness::with_storage().await;
285 + let setup = h.create_creator_with_item("vidonvid", "video", 0).await;
286 + h.grant_tier(setup.user_id, "small_files").await;
287 + let user_id = setup.user_id.to_string();
288 +
289 + let clip = confirm_clip(&mut h, &user_id, "bumper.mp4", "video/mp4").await;
290 + let insertion_id = clip["id"].as_str().unwrap().to_string();
291 +
292 + let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await;
293 + assert!(
294 + (200..300).contains(&status),
295 + "A video clip must be placeable on a video item, got {}",
296 + status
297 + );
298 + }
299 +
300 + #[tokio::test]
301 + async fn audio_clip_allowed_on_video_item() {
302 + let mut h = TestHarness::with_storage().await;
303 + let setup = h.create_creator_with_item("audonvid", "video", 0).await;
304 + h.grant_tier(setup.user_id, "small_files").await;
305 + let user_id = setup.user_id.to_string();
306 +
307 + let clip = confirm_clip(&mut h, &user_id, "intro.mp3", "audio/mpeg").await;
308 + let insertion_id = clip["id"].as_str().unwrap().to_string();
309 +
310 + let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await;
311 + assert!(
312 + (200..300).contains(&status),
313 + "An audio clip must be placeable on a video item, got {}",
314 + status
315 + );
316 + }
317 +
318 + #[tokio::test]
319 + async fn clip_rejected_on_non_media_item() {
320 + let mut h = TestHarness::with_storage().await;
321 + let setup = h.create_creator_with_item("cliponwtext", "text", 0).await;
322 + h.grant_tier(setup.user_id, "small_files").await;
323 + let user_id = setup.user_id.to_string();
324 +
325 + let clip = confirm_clip(&mut h, &user_id, "intro.mp3", "audio/mpeg").await;
326 + let insertion_id = clip["id"].as_str().unwrap().to_string();
327 +
328 + let status = place_pre_roll(&mut h, &setup.item_id, &insertion_id).await;
329 + assert_eq!(
330 + status, 400,
331 + "Clips must not be placeable on a non-media (text) item"
332 + );
333 + }