Skip to main content

postproject_core/
model.rs

1//! Core media identity and locator value types.
2
3use 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/// A UTC instant represented as microseconds since the Unix epoch.
14///
15/// The representation is independent of SQLite and has sufficient precision for
16/// filesystem and domain bookkeeping without exposing a third-party time type.
17#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18pub struct Timestamp(i64);
19
20impl Timestamp {
21    /// Creates a timestamp from signed Unix microseconds.
22    #[must_use]
23    pub const fn from_unix_micros(micros: i64) -> Self {
24        Self(micros)
25    }
26
27    /// Returns the signed Unix-microsecond representation.
28    #[must_use]
29    pub const fn as_unix_micros(self) -> i64 {
30        self.0
31    }
32
33    /// Reads the current system time.
34    ///
35    /// # Errors
36    ///
37    /// Returns [`ErrorKind::Internal`] if the platform clock predates the Unix
38    /// epoch or cannot be represented in signed microseconds.
39    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/// A persistent container for production state.
59#[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    /// Creates an in-memory production value.
70    #[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    /// Returns the production's stable identity.
87    #[must_use]
88    pub const fn id(&self) -> ProductionId {
89        self.id
90    }
91
92    /// Returns the persistence schema version used to load this production.
93    #[must_use]
94    pub const fn schema_version(&self) -> u32 {
95        self.schema_version
96    }
97
98    /// Returns when the production was created.
99    #[must_use]
100    pub const fn created_at(&self) -> Timestamp {
101        self.created_at
102    }
103
104    /// Returns the optional user-facing name.
105    #[must_use]
106    pub fn display_name(&self) -> Option<&str> {
107        self.display_name.as_deref()
108    }
109
110    /// Returns configured media roots in resolver priority order.
111    #[must_use]
112    pub fn media_roots(&self) -> &[MediaRoot] {
113        &self.media_roots
114    }
115
116    /// Replaces media roots after sorting by priority and stable identity.
117    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/// Logical identity for one piece of production media.
124#[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    /// Creates an asset value. Paths belong to locators, not assets.
134    #[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    /// Returns the asset's stable identity.
150    #[must_use]
151    pub const fn id(&self) -> AssetId {
152        self.id
153    }
154
155    /// Returns when the asset was created.
156    #[must_use]
157    pub const fn created_at(&self) -> Timestamp {
158        self.created_at
159    }
160
161    /// Returns the optional user-facing name.
162    #[must_use]
163    pub fn display_name(&self) -> Option<&str> {
164        self.display_name.as_deref()
165    }
166
167    /// Returns optional application-supplied import provenance.
168    #[must_use]
169    pub fn import_source(&self) -> Option<&str> {
170        self.import_source.as_deref()
171    }
172}
173
174/// The semantic role of an asset representation.
175#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
176#[non_exhaustive]
177pub enum RepresentationKind {
178    /// Source media as imported.
179    Original,
180    /// A lower-cost representation intended for interactive work.
181    Proxy,
182    /// A representation optimized for a particular workflow.
183    Optimized,
184    /// Media derived from another production operation.
185    Derived,
186}
187
188/// One encoded or derived form of an asset.
189#[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    /// Creates a representation value.
200    #[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    /// Returns the representation's stable identity.
218    #[must_use]
219    pub const fn id(&self) -> RepresentationId {
220        self.id
221    }
222
223    /// Returns the owning asset identity.
224    #[must_use]
225    pub const fn asset_id(&self) -> AssetId {
226        self.asset_id
227    }
228
229    /// Returns the representation's semantic role.
230    #[must_use]
231    pub const fn kind(&self) -> RepresentationKind {
232        self.kind
233    }
234
235    /// Returns how storage resources realize this representation.
236    #[must_use]
237    pub const fn content_structure(&self) -> &ContentStructure {
238        &self.content_structure
239    }
240
241    /// Returns structure-aware identity evidence for this representation.
242    #[must_use]
243    pub fn fingerprints(&self) -> &[RepresentationFingerprint] {
244        &self.fingerprints
245    }
246}
247
248/// A validated representation and the storage resources that realize it.
249#[derive(Clone, Debug, Eq, PartialEq)]
250pub struct RepresentationImport {
251    representation: Representation,
252    resources: Vec<Resource>,
253    locators: Vec<Locator>,
254}
255
256impl RepresentationImport {
257    /// Creates an import aggregate whose resource relationships are consistent.
258    ///
259    /// # Errors
260    ///
261    /// Returns [`ErrorKind::InvalidArgument`] if a referenced resource is absent
262    /// or duplicated, an extra resource is supplied, or any resource lacks a
263    /// locator.
264    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    /// Returns the representation being imported.
303    #[must_use]
304    pub const fn representation(&self) -> &Representation {
305        &self.representation
306    }
307
308    /// Returns the storage resources realizing the representation.
309    #[must_use]
310    pub fn resources(&self) -> &[Resource] {
311        &self.resources
312    }
313
314    /// Returns the known access routes for the imported resources.
315    #[must_use]
316    pub fn locators(&self) -> &[Locator] {
317        &self.locators
318    }
319
320    /// Splits the aggregate into persistable domain values.
321    #[must_use]
322    pub fn into_parts(self) -> (Representation, Vec<Resource>, Vec<Locator>) {
323        (self.representation, self.resources, self.locators)
324    }
325}
326
327/// A validated aggregate representing an imported asset and original media.
328#[derive(Clone, Debug, Eq, PartialEq)]
329pub struct OriginalMediaImport {
330    asset: Asset,
331    media: RepresentationImport,
332}
333
334impl OriginalMediaImport {
335    /// Creates an import aggregate whose ownership relationships are consistent.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`ErrorKind::InvalidArgument`] if ownership is inconsistent, a
340    /// referenced resource is absent or duplicated, an extra resource is
341    /// supplied, or any resource lacks a locator.
342    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    /// Returns the logical asset.
365    #[must_use]
366    pub const fn asset(&self) -> &Asset {
367        &self.asset
368    }
369
370    /// Returns the original representation.
371    #[must_use]
372    pub const fn representation(&self) -> &Representation {
373        self.media.representation()
374    }
375
376    /// Returns the storage resources realizing the representation.
377    #[must_use]
378    pub fn resources(&self) -> &[Resource] {
379        self.media.resources()
380    }
381
382    /// Returns the known access routes for the imported resources.
383    #[must_use]
384    pub fn locators(&self) -> &[Locator] {
385        self.media.locators()
386    }
387
388    /// Splits the aggregate into persistable domain values.
389    #[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/// An ordered filesystem or URI boundary searched by the resolver.
397#[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    /// Validates a production-portable logical root name.
409    ///
410    /// # Errors
411    ///
412    /// Returns [`ErrorKind::InvalidArgument`] when `name` is empty, oversized,
413    /// path-shaped, contains control characters, or has surrounding whitespace.
414    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    /// Creates a configured logical media root.
431    ///
432    /// # Errors
433    ///
434    /// Returns [`ErrorKind::InvalidArgument`] when `name` is not a bounded,
435    /// portable root name or when `legacy_uri` is invalid or relative.
436    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    /// Returns the root's stable identity.
460    #[must_use]
461    pub const fn id(&self) -> MediaRootId {
462        self.id
463    }
464
465    /// Returns the production-portable logical root name.
466    #[must_use]
467    pub fn name(&self) -> &str {
468        &self.name
469    }
470
471    /// Returns the optional user-facing label.
472    #[must_use]
473    pub fn label(&self) -> Option<&str> {
474        self.label.as_deref()
475    }
476
477    /// Returns the absolute URI retained while migrating a pre-version-6 root.
478    ///
479    /// New roots do not carry this machine-local fallback.
480    #[must_use]
481    pub fn legacy_uri(&self) -> Option<&str> {
482        self.legacy_uri.as_deref()
483    }
484
485    /// Returns the resolver priority; lower values are considered first.
486    #[must_use]
487    pub const fn priority(&self) -> i32 {
488        self.priority
489    }
490
491    /// Returns whether this root participates in resolution.
492    #[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}