Skip to main content

mkt_core/models/
media.rs

1//! Media asset domain model.
2
3use std::fmt;
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8/// Opaque media asset identifier.
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10pub struct MediaAssetId(pub String);
11
12impl fmt::Display for MediaAssetId {
13    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14        write!(f, "{}", self.0)
15    }
16}
17
18impl<T: Into<String>> From<T> for MediaAssetId {
19    fn from(s: T) -> Self {
20        Self(s.into())
21    }
22}
23
24/// A unified media asset representation across all providers.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct MediaAsset {
27    /// Unique identifier.
28    pub id: MediaAssetId,
29    /// Which provider this asset belongs to.
30    pub provider: String,
31    /// The type of media.
32    pub media_type: MediaType,
33    /// CDN or storage URL for the asset.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub url: Option<String>,
36    /// Original filename.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub filename: Option<String>,
39    /// File size in bytes.
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub size_bytes: Option<u64>,
42    /// When the asset was created.
43    pub created_at: DateTime<Utc>,
44    /// Original API response for debugging and raw access.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub raw: Option<serde_json::Value>,
47}
48
49/// The media format of an asset.
50#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
51#[serde(rename_all = "snake_case")]
52pub enum MediaType {
53    /// A static image (JPEG, PNG, GIF, etc.).
54    Image,
55    /// A video file.
56    Video,
57    /// Platform-specific media type not mapped to a known variant.
58    Other(String),
59}
60
61impl fmt::Display for MediaType {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        match self {
64            Self::Image => write!(f, "image"),
65            Self::Video => write!(f, "video"),
66            Self::Other(s) => write!(f, "{s}"),
67        }
68    }
69}
70
71impl<'de> Deserialize<'de> for MediaType {
72    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
73        let s = String::deserialize(deserializer)?;
74        Ok(match s.as_str() {
75            "image" => Self::Image,
76            "video" => Self::Video,
77            _ => Self::Other(s),
78        })
79    }
80}
81
82/// Input for uploading an image asset.
83#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct UploadImageInput {
85    /// Local file path to the image.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub file_path: Option<String>,
88    /// Remote URL of the image to import.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub url: Option<String>,
91    /// Optional display name for the asset.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub name: Option<String>,
94}
95
96/// Input for uploading a video asset.
97#[derive(Debug, Clone, Default, Serialize, Deserialize)]
98pub struct UploadVideoInput {
99    /// Local file path to the video.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub file_path: Option<String>,
102    /// Remote URL of the video to import.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub url: Option<String>,
105    /// Optional display name for the asset.
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub name: Option<String>,
108    /// Video title shown in the player.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub title: Option<String>,
111    /// Video description.
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub description: Option<String>,
114}
115
116impl crate::output::Formattable for MediaAsset {
117    fn headers() -> Vec<String> {
118        vec!["ID".into(), "Type".into(), "URL".into(), "Provider".into()]
119    }
120
121    fn row(&self) -> Vec<String> {
122        vec![
123            self.id.to_string(),
124            self.media_type.to_string(),
125            self.url.clone().unwrap_or_default(),
126            self.provider.clone(),
127        ]
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use test_case::test_case;
135
136    #[test]
137    fn media_asset_id_display() {
138        let id = MediaAssetId("media_123".into());
139        assert_eq!(id.to_string(), "media_123");
140    }
141
142    #[test]
143    fn media_asset_id_from_str() {
144        let id = MediaAssetId::from("media_456");
145        assert_eq!(id.0, "media_456");
146    }
147
148    #[test]
149    #[allow(clippy::expect_used)]
150    fn media_asset_id_serde_roundtrip() {
151        let id = MediaAssetId("media_789".into());
152        let json = serde_json::to_string(&id).expect("serialize MediaAssetId");
153        let back: MediaAssetId = serde_json::from_str(&json).expect("deserialize MediaAssetId");
154        assert_eq!(id, back);
155    }
156
157    #[test_case(MediaType::Image, "image" ; "image")]
158    #[test_case(MediaType::Video, "video" ; "video")]
159    #[allow(clippy::needless_pass_by_value)]
160    fn media_type_display(media_type: MediaType, expected: &str) {
161        assert_eq!(media_type.to_string(), expected);
162    }
163
164    #[test]
165    fn media_type_other_display() {
166        let t = MediaType::Other("audio".into());
167        assert_eq!(t.to_string(), "audio");
168    }
169
170    #[test]
171    #[allow(clippy::expect_used)]
172    fn media_type_serde_roundtrip() {
173        let json = serde_json::to_string(&MediaType::Image).expect("serialize");
174        assert_eq!(json, r#""image""#);
175        let back: MediaType = serde_json::from_str(&json).expect("deserialize");
176        assert_eq!(back, MediaType::Image);
177    }
178
179    #[test]
180    #[allow(clippy::expect_used)]
181    fn unknown_media_type_deserializes_as_other() {
182        let t: MediaType = serde_json::from_str(r#""document""#).expect("deserialize");
183        assert_eq!(t, MediaType::Other("document".into()));
184    }
185
186    #[test]
187    fn upload_image_input_default_all_none() {
188        let input = UploadImageInput::default();
189        assert!(input.file_path.is_none());
190        assert!(input.url.is_none());
191        assert!(input.name.is_none());
192    }
193
194    #[test]
195    fn upload_video_input_default_all_none() {
196        let input = UploadVideoInput::default();
197        assert!(input.file_path.is_none());
198        assert!(input.url.is_none());
199        assert!(input.name.is_none());
200        assert!(input.title.is_none());
201        assert!(input.description.is_none());
202    }
203
204    #[test]
205    #[allow(clippy::expect_used)]
206    fn upload_image_input_skips_none_fields() {
207        let input = UploadImageInput {
208            file_path: Some("/tmp/image.png".into()),
209            url: None,
210            name: None,
211        };
212        let json = serde_json::to_string(&input).expect("serialize UploadImageInput");
213        assert!(!json.contains("\"url\""));
214        assert!(!json.contains("\"name\""));
215    }
216
217    #[test]
218    #[allow(clippy::expect_used)]
219    fn upload_video_input_serde_roundtrip() {
220        let input = UploadVideoInput {
221            file_path: None,
222            url: Some("https://cdn.example.com/video.mp4".into()),
223            name: Some("my_video".into()),
224            title: Some("My Video".into()),
225            description: Some("A great video".into()),
226        };
227        let json = serde_json::to_string(&input).expect("serialize UploadVideoInput");
228        let back: UploadVideoInput =
229            serde_json::from_str(&json).expect("deserialize UploadVideoInput");
230        assert!(back.file_path.is_none());
231        assert_eq!(
232            back.url.as_deref(),
233            Some("https://cdn.example.com/video.mp4")
234        );
235        assert_eq!(back.title.as_deref(), Some("My Video"));
236        assert_eq!(back.description.as_deref(), Some("A great video"));
237    }
238
239    #[test]
240    #[allow(clippy::expect_used)]
241    fn media_asset_optional_fields_skip_serializing_if_none() {
242        let now = chrono::Utc::now();
243        let asset = MediaAsset {
244            id: MediaAssetId("m_1".into()),
245            provider: "meta".into(),
246            media_type: MediaType::Image,
247            url: None,
248            filename: None,
249            size_bytes: None,
250            created_at: now,
251            raw: None,
252        };
253        let json = serde_json::to_string(&asset).expect("serialize MediaAsset");
254        assert!(!json.contains("url"));
255        assert!(!json.contains("filename"));
256        assert!(!json.contains("size_bytes"));
257        assert!(!json.contains("raw"));
258    }
259}