1use std::{
4 collections::BTreeSet,
5 time::{SystemTime, UNIX_EPOCH},
6};
7
8use crate::{
9 AssetId, ContentStructure, Error, ErrorKind, Locator, MediaRootId, ProductionId,
10 RepresentationFingerprint, RepresentationId, Resource, Result, uri::normalize_uri,
11};
12
13#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18pub struct Timestamp(i64);
19
20impl Timestamp {
21 #[must_use]
23 pub const fn from_unix_micros(micros: i64) -> Self {
24 Self(micros)
25 }
26
27 #[must_use]
29 pub const fn as_unix_micros(self) -> i64 {
30 self.0
31 }
32
33 pub fn now() -> Result<Self> {
40 let duration = SystemTime::now()
41 .duration_since(UNIX_EPOCH)
42 .map_err(|error| {
43 Error::new(
44 ErrorKind::Internal,
45 format!("system clock is before the Unix epoch: {error}"),
46 )
47 })?;
48 let micros = i64::try_from(duration.as_micros()).map_err(|error| {
49 Error::new(
50 ErrorKind::Internal,
51 format!("system clock is outside the supported range: {error}"),
52 )
53 })?;
54 Ok(Self(micros))
55 }
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
60pub struct Production {
61 id: ProductionId,
62 schema_version: u32,
63 created_at: Timestamp,
64 display_name: Option<String>,
65 media_roots: Vec<MediaRoot>,
66}
67
68impl Production {
69 #[must_use]
71 pub fn new(
72 id: ProductionId,
73 schema_version: u32,
74 created_at: Timestamp,
75 display_name: Option<String>,
76 ) -> Self {
77 Self {
78 id,
79 schema_version,
80 created_at,
81 display_name,
82 media_roots: Vec::new(),
83 }
84 }
85
86 #[must_use]
88 pub const fn id(&self) -> ProductionId {
89 self.id
90 }
91
92 #[must_use]
94 pub const fn schema_version(&self) -> u32 {
95 self.schema_version
96 }
97
98 #[must_use]
100 pub const fn created_at(&self) -> Timestamp {
101 self.created_at
102 }
103
104 #[must_use]
106 pub fn display_name(&self) -> Option<&str> {
107 self.display_name.as_deref()
108 }
109
110 #[must_use]
112 pub fn media_roots(&self) -> &[MediaRoot] {
113 &self.media_roots
114 }
115
116 pub fn set_media_roots(&mut self, mut roots: Vec<MediaRoot>) {
118 roots.sort_by_key(|root| (root.priority(), root.id()));
119 self.media_roots = roots;
120 }
121}
122
123#[derive(Clone, Debug, Eq, PartialEq)]
125pub struct Asset {
126 id: AssetId,
127 created_at: Timestamp,
128 display_name: Option<String>,
129 import_source: Option<String>,
130}
131
132impl Asset {
133 #[must_use]
135 pub fn new(
136 id: AssetId,
137 created_at: Timestamp,
138 display_name: Option<String>,
139 import_source: Option<String>,
140 ) -> Self {
141 Self {
142 id,
143 created_at,
144 display_name,
145 import_source,
146 }
147 }
148
149 #[must_use]
151 pub const fn id(&self) -> AssetId {
152 self.id
153 }
154
155 #[must_use]
157 pub const fn created_at(&self) -> Timestamp {
158 self.created_at
159 }
160
161 #[must_use]
163 pub fn display_name(&self) -> Option<&str> {
164 self.display_name.as_deref()
165 }
166
167 #[must_use]
169 pub fn import_source(&self) -> Option<&str> {
170 self.import_source.as_deref()
171 }
172}
173
174#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
176#[non_exhaustive]
177pub enum RepresentationKind {
178 Original,
180 Proxy,
182 Optimized,
184 Derived,
186}
187
188#[derive(Clone, Debug, Eq, PartialEq)]
190pub struct Representation {
191 id: RepresentationId,
192 asset_id: AssetId,
193 kind: RepresentationKind,
194 content_structure: ContentStructure,
195 fingerprints: Vec<RepresentationFingerprint>,
196}
197
198impl Representation {
199 #[must_use]
201 pub fn new(
202 id: RepresentationId,
203 asset_id: AssetId,
204 kind: RepresentationKind,
205 content_structure: ContentStructure,
206 fingerprints: Vec<RepresentationFingerprint>,
207 ) -> Self {
208 Self {
209 id,
210 asset_id,
211 kind,
212 content_structure,
213 fingerprints,
214 }
215 }
216
217 #[must_use]
219 pub const fn id(&self) -> RepresentationId {
220 self.id
221 }
222
223 #[must_use]
225 pub const fn asset_id(&self) -> AssetId {
226 self.asset_id
227 }
228
229 #[must_use]
231 pub const fn kind(&self) -> RepresentationKind {
232 self.kind
233 }
234
235 #[must_use]
237 pub const fn content_structure(&self) -> &ContentStructure {
238 &self.content_structure
239 }
240
241 #[must_use]
243 pub fn fingerprints(&self) -> &[RepresentationFingerprint] {
244 &self.fingerprints
245 }
246}
247
248#[derive(Clone, Debug, Eq, PartialEq)]
250pub struct RepresentationImport {
251 representation: Representation,
252 resources: Vec<Resource>,
253 locators: Vec<Locator>,
254}
255
256impl RepresentationImport {
257 pub fn new(
265 representation: Representation,
266 resources: Vec<Resource>,
267 locators: Vec<Locator>,
268 ) -> Result<Self> {
269 let expected: BTreeSet<_> = representation
270 .content_structure()
271 .resource_ids()
272 .into_iter()
273 .collect();
274 let supplied: BTreeSet<_> = resources.iter().map(Resource::id).collect();
275 if expected != supplied || supplied.len() != resources.len() {
276 return Err(Error::new(
277 ErrorKind::InvalidArgument,
278 "representation resources do not exactly match the content structure",
279 ));
280 }
281 if locators
282 .iter()
283 .any(|locator| !supplied.contains(&locator.resource_id()))
284 || supplied.iter().any(|resource_id| {
285 !locators
286 .iter()
287 .any(|item| item.resource_id() == *resource_id)
288 })
289 {
290 return Err(Error::new(
291 ErrorKind::InvalidArgument,
292 "every representation resource must own at least one supplied locator",
293 ));
294 }
295 Ok(Self {
296 representation,
297 resources,
298 locators,
299 })
300 }
301
302 #[must_use]
304 pub const fn representation(&self) -> &Representation {
305 &self.representation
306 }
307
308 #[must_use]
310 pub fn resources(&self) -> &[Resource] {
311 &self.resources
312 }
313
314 #[must_use]
316 pub fn locators(&self) -> &[Locator] {
317 &self.locators
318 }
319
320 #[must_use]
322 pub fn into_parts(self) -> (Representation, Vec<Resource>, Vec<Locator>) {
323 (self.representation, self.resources, self.locators)
324 }
325}
326
327#[derive(Clone, Debug, Eq, PartialEq)]
329pub struct OriginalMediaImport {
330 asset: Asset,
331 media: RepresentationImport,
332}
333
334impl OriginalMediaImport {
335 pub fn new(
343 asset: Asset,
344 representation: Representation,
345 resources: Vec<Resource>,
346 locators: Vec<Locator>,
347 ) -> Result<Self> {
348 if representation.asset_id() != asset.id() {
349 return Err(Error::new(
350 ErrorKind::InvalidArgument,
351 "import representation does not belong to its asset",
352 ));
353 }
354 if representation.kind() != RepresentationKind::Original {
355 return Err(Error::new(
356 ErrorKind::InvalidArgument,
357 "initial import representation must be original media",
358 ));
359 }
360 let media = RepresentationImport::new(representation, resources, locators)?;
361 Ok(Self { asset, media })
362 }
363
364 #[must_use]
366 pub const fn asset(&self) -> &Asset {
367 &self.asset
368 }
369
370 #[must_use]
372 pub const fn representation(&self) -> &Representation {
373 self.media.representation()
374 }
375
376 #[must_use]
378 pub fn resources(&self) -> &[Resource] {
379 self.media.resources()
380 }
381
382 #[must_use]
384 pub fn locators(&self) -> &[Locator] {
385 self.media.locators()
386 }
387
388 #[must_use]
390 pub fn into_parts(self) -> (Asset, Representation, Vec<Resource>, Vec<Locator>) {
391 let (representation, resources, locators) = self.media.into_parts();
392 (self.asset, representation, resources, locators)
393 }
394}
395
396#[derive(Clone, Debug, Eq, PartialEq)]
398pub struct MediaRoot {
399 id: MediaRootId,
400 name: String,
401 label: Option<String>,
402 legacy_uri: Option<String>,
403 priority: i32,
404 enabled: bool,
405}
406
407impl MediaRoot {
408 pub fn validate_name(name: &str) -> Result<()> {
415 if name.is_empty()
416 || name.len() > 128
417 || name.trim() != name
418 || name
419 .chars()
420 .any(|character| character.is_control() || matches!(character, '/' | '\\'))
421 {
422 return Err(Error::new(
423 ErrorKind::InvalidArgument,
424 "media-root name must be 1-128 UTF-8 bytes without surrounding whitespace, control characters, or path separators",
425 ));
426 }
427 Ok(())
428 }
429
430 pub fn new(
437 id: MediaRootId,
438 name: impl Into<String>,
439 label: Option<String>,
440 legacy_uri: Option<String>,
441 priority: i32,
442 enabled: bool,
443 ) -> Result<Self> {
444 let name = name.into();
445 Self::validate_name(&name)?;
446 let legacy_uri = legacy_uri
447 .map(|uri| normalize_uri(uri, "legacy media-root"))
448 .transpose()?;
449 Ok(Self {
450 id,
451 name,
452 label,
453 legacy_uri,
454 priority,
455 enabled,
456 })
457 }
458
459 #[must_use]
461 pub const fn id(&self) -> MediaRootId {
462 self.id
463 }
464
465 #[must_use]
467 pub fn name(&self) -> &str {
468 &self.name
469 }
470
471 #[must_use]
473 pub fn label(&self) -> Option<&str> {
474 self.label.as_deref()
475 }
476
477 #[must_use]
481 pub fn legacy_uri(&self) -> Option<&str> {
482 self.legacy_uri.as_deref()
483 }
484
485 #[must_use]
487 pub const fn priority(&self) -> i32 {
488 self.priority
489 }
490
491 #[must_use]
493 pub const fn is_enabled(&self) -> bool {
494 self.enabled
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::{LocatorAvailability, LocatorId, ResourceId};
502
503 #[test]
504 fn rejects_invalid_root_names() {
505 let root = MediaRoot::new(MediaRootId::new(), "path/name", None, None, 0, true);
506
507 assert_eq!(
508 root.expect_err("path-shaped name must fail").kind(),
509 ErrorKind::InvalidArgument
510 );
511 }
512
513 #[test]
514 fn media_roots_have_deterministic_priority_order() {
515 let first_id = MediaRootId::from_bytes([1; 16]);
516 let second_id = MediaRootId::from_bytes([2; 16]);
517 let mut production =
518 Production::new(ProductionId::new(), 1, Timestamp::from_unix_micros(0), None);
519 production.set_media_roots(vec![
520 MediaRoot::new(second_id, "second", None, None, 10, true).expect("valid root"),
521 MediaRoot::new(first_id, "first", None, None, 10, true).expect("valid root"),
522 MediaRoot::new(MediaRootId::new(), "top", None, None, 0, true).expect("valid root"),
523 ]);
524
525 assert_eq!(production.media_roots()[0].priority(), 0);
526 assert_eq!(production.media_roots()[1].id(), first_id);
527 assert_eq!(production.media_roots()[2].id(), second_id);
528 }
529
530 #[test]
531 fn import_aggregate_enforces_ownership() {
532 let asset = Asset::new(AssetId::new(), Timestamp::from_unix_micros(0), None, None);
533 let resource_id = ResourceId::new();
534 let representation = Representation::new(
535 RepresentationId::new(),
536 AssetId::new(),
537 RepresentationKind::Original,
538 ContentStructure::single_resource(resource_id),
539 Vec::new(),
540 );
541 let resource = Resource::new(resource_id, Vec::new(), None);
542 let locator = Locator::new(
543 LocatorId::new(),
544 resource_id,
545 "file:///media.mov",
546 None,
547 LocatorAvailability::Online,
548 )
549 .expect("valid locator");
550
551 assert_eq!(
552 OriginalMediaImport::new(asset, representation, vec![resource], vec![locator])
553 .expect_err("mismatched ownership must fail")
554 .kind(),
555 ErrorKind::InvalidArgument
556 );
557 }
558
559 #[test]
560 fn representation_import_accepts_non_original_media() {
561 let asset_id = AssetId::new();
562 let resource_id = ResourceId::new();
563 let representation = Representation::new(
564 RepresentationId::new(),
565 asset_id,
566 RepresentationKind::Proxy,
567 ContentStructure::single_resource(resource_id),
568 Vec::new(),
569 );
570 let resource = Resource::new(resource_id, Vec::new(), None);
571 let locator = Locator::new(
572 LocatorId::new(),
573 resource_id,
574 "file:///proxy.mov",
575 None,
576 LocatorAvailability::Online,
577 )
578 .expect("valid locator");
579
580 let imported =
581 RepresentationImport::new(representation.clone(), vec![resource], vec![locator])
582 .expect("valid representation import");
583
584 assert_eq!(imported.representation(), &representation);
585 assert_eq!(imported.representation().asset_id(), asset_id);
586 assert_eq!(imported.representation().kind(), RepresentationKind::Proxy);
587 }
588
589 #[test]
590 fn representation_import_requires_a_locator_for_every_resource() {
591 let resource_id = ResourceId::new();
592 let representation = Representation::new(
593 RepresentationId::new(),
594 AssetId::new(),
595 RepresentationKind::Derived,
596 ContentStructure::single_resource(resource_id),
597 Vec::new(),
598 );
599
600 assert_eq!(
601 RepresentationImport::new(
602 representation,
603 vec![Resource::new(resource_id, Vec::new(), None)],
604 Vec::new(),
605 )
606 .expect_err("missing locator must fail")
607 .kind(),
608 ErrorKind::InvalidArgument
609 );
610 }
611}