feedparser_rs/namespace/
media_rss.rs

1/// Media RSS Specification
2///
3/// Namespace: <http://search.yahoo.com/mrss/>
4/// Prefix: media
5///
6/// This module provides parsing support for Media RSS elements commonly
7/// used in video/audio feeds and podcasts.
8///
9/// Common elements:
10/// - `media:content` → enclosures
11/// - `media:thumbnail` → (could add thumbnails field)
12/// - `media:title` → title (fallback)
13/// - `media:description` → summary (fallback)
14/// - `media:keywords` → tags (comma-separated)
15/// - `media:category` → tags
16/// - `media:credit` → contributors
17///
18/// # Type Design Note
19///
20/// The [`MediaContent`] and [`MediaThumbnail`] types in this module use raw `String`
21/// fields instead of the `Url`/`MimeType` newtypes from `types::common`. This is
22/// intentional:
23///
24/// 1. These are internal parsing types with extended attributes (medium, bitrate,
25///    framerate, expression, `is_default`) not present in the public API types.
26/// 2. The `media_content_to_enclosure` function handles conversion to public types.
27/// 3. The public API types in `types::common::MediaContent` use proper newtypes.
28use crate::types::{Enclosure, Entry, Tag};
29
30/// Media RSS namespace URI
31pub const MEDIA_NAMESPACE: &str = "http://search.yahoo.com/mrss/";
32
33/// Media RSS content element with full attribute support
34///
35/// Represents a media object embedded in the feed with detailed metadata.
36/// Commonly used in video/audio feeds and podcasts.
37///
38/// # Security Warning
39///
40/// The `url` field comes from untrusted feed input and has NOT been validated for SSRF.
41/// Applications MUST validate URLs before fetching to prevent SSRF attacks.
42///
43/// # Examples
44///
45/// ```
46/// use feedparser_rs::namespace::media_rss::MediaContent;
47///
48/// let content = MediaContent {
49///     url: "https://example.com/video.mp4".to_string(),
50///     type_: Some("video/mp4".to_string()),
51///     medium: Some("video".to_string()),
52///     width: Some(1920),
53///     height: Some(1080),
54///     ..Default::default()
55/// };
56///
57/// assert_eq!(content.url, "https://example.com/video.mp4");
58/// ```
59#[derive(Debug, Clone, Default, PartialEq)]
60#[allow(clippy::derive_partial_eq_without_eq)]
61pub struct MediaContent {
62    /// URL of the media object (url attribute)
63    ///
64    /// # Security Warning
65    ///
66    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
67    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
68    pub url: String,
69    /// MIME type (type attribute): "video/mp4", "audio/mpeg", etc.
70    pub type_: Option<String>,
71    /// Medium type (medium attribute): "image", "video", "audio", "document", "executable"
72    pub medium: Option<String>,
73    /// File size in bytes (fileSize attribute)
74    pub file_size: Option<u64>,
75    /// Bitrate in kilobits per second (bitrate attribute)
76    pub bitrate: Option<u32>,
77    /// Frame rate in frames per second (framerate attribute)
78    pub framerate: Option<f32>,
79    /// Width in pixels (width attribute)
80    pub width: Option<u32>,
81    /// Height in pixels (height attribute)
82    pub height: Option<u32>,
83    /// Duration in seconds (duration attribute)
84    pub duration: Option<u32>,
85    /// Expression (expression attribute): "full", "sample", "nonstop"
86    ///
87    /// - "full": complete media object
88    /// - "sample": preview/sample of media
89    /// - "nonstop": continuous/streaming media
90    pub expression: Option<String>,
91    /// Whether this is the default media object (isDefault attribute)
92    pub is_default: Option<bool>,
93}
94
95/// Media RSS thumbnail element
96///
97/// Represents a thumbnail image for a media object.
98///
99/// # Security Warning
100///
101/// The `url` field comes from untrusted feed input and has NOT been validated for SSRF.
102/// Applications MUST validate URLs before fetching to prevent SSRF attacks.
103///
104/// # Examples
105///
106/// ```
107/// use feedparser_rs::namespace::media_rss::MediaThumbnail;
108///
109/// let thumbnail = MediaThumbnail {
110///     url: "https://example.com/thumb.jpg".to_string(),
111///     width: Some(640),
112///     height: Some(480),
113///     time: None,
114/// };
115///
116/// assert_eq!(thumbnail.url, "https://example.com/thumb.jpg");
117/// ```
118#[derive(Debug, Clone, Default, PartialEq, Eq)]
119pub struct MediaThumbnail {
120    /// URL of the thumbnail image (url attribute)
121    ///
122    /// # Security Warning
123    ///
124    /// This URL comes from untrusted feed input and has NOT been validated for SSRF.
125    /// Applications MUST validate URLs before fetching to prevent SSRF attacks.
126    pub url: String,
127    /// Width in pixels (width attribute)
128    pub width: Option<u32>,
129    /// Height in pixels (height attribute)
130    pub height: Option<u32>,
131    /// Time offset in NTP format (time attribute)
132    ///
133    /// Indicates which frame of the media this thumbnail represents.
134    pub time: Option<String>,
135}
136
137/// Handle Media RSS element at entry level
138///
139/// Note: This is a simplified implementation. Full Media RSS support
140/// would require parsing element attributes (url, type, width, height, etc.)
141///
142/// # Arguments
143///
144/// * `element` - Local name of the element (without namespace prefix)
145/// * `text` - Text content of the element
146/// * `entry` - Entry to update
147pub fn handle_entry_element(element: &str, text: &str, entry: &mut Entry) {
148    match element {
149        "title" => {
150            if entry.title.is_none() {
151                entry.title = Some(text.to_string());
152            }
153        }
154        "description" => {
155            if entry.summary.is_none() {
156                entry.summary = Some(text.to_string());
157            }
158        }
159        "keywords" => {
160            // Comma-separated keywords
161            for keyword in text.split(',') {
162                let keyword = keyword.trim();
163                if !keyword.is_empty() {
164                    entry.tags.push(Tag::new(keyword));
165                }
166            }
167        }
168        "category" => {
169            if !text.is_empty() {
170                entry.tags.push(Tag::new(text));
171            }
172        }
173        _ => {
174            // Other elements like media:content, media:thumbnail, media:credit
175            // would require attribute parsing which needs integration with
176            // the XML parser. For now, we skip these.
177        }
178    }
179}
180
181/// Convert `MediaContent` to `Enclosure` for backward compatibility
182///
183/// Extracts URL, type, and `file_size` to create a basic enclosure.
184/// Used when adding `media:content` to `entry.enclosures`.
185///
186/// # Examples
187///
188/// ```
189/// use feedparser_rs::namespace::media_rss::{MediaContent, media_content_to_enclosure};
190///
191/// let content = MediaContent {
192///     url: "https://example.com/video.mp4".to_string(),
193///     type_: Some("video/mp4".to_string()),
194///     file_size: Some(1_024_000),
195///     ..Default::default()
196/// };
197///
198/// let enclosure = media_content_to_enclosure(&content);
199/// assert_eq!(enclosure.url, "https://example.com/video.mp4");
200/// assert_eq!(enclosure.enclosure_type.as_deref(), Some("video/mp4"));
201/// assert_eq!(enclosure.length, Some(1_024_000));
202/// ```
203pub fn media_content_to_enclosure(content: &MediaContent) -> Enclosure {
204    Enclosure {
205        url: content.url.clone().into(),
206        enclosure_type: content.type_.as_ref().map(|t| t.clone().into()),
207        length: content.file_size,
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_media_title() {
217        let mut entry = Entry::default();
218        handle_entry_element("title", "Video Title", &mut entry);
219
220        assert_eq!(entry.title.as_deref(), Some("Video Title"));
221    }
222
223    #[test]
224    fn test_media_description() {
225        let mut entry = Entry::default();
226        handle_entry_element("description", "Video description", &mut entry);
227
228        assert_eq!(entry.summary.as_deref(), Some("Video description"));
229    }
230
231    #[test]
232    fn test_media_keywords() {
233        let mut entry = Entry::default();
234        handle_entry_element("keywords", "tech, programming, rust", &mut entry);
235
236        assert_eq!(entry.tags.len(), 3);
237        assert_eq!(entry.tags[0].term, "tech");
238        assert_eq!(entry.tags[1].term, "programming");
239        assert_eq!(entry.tags[2].term, "rust");
240    }
241
242    #[test]
243    fn test_media_keywords_with_spaces() {
244        let mut entry = Entry::default();
245        handle_entry_element("keywords", "  tech  ,  programming  ", &mut entry);
246
247        assert_eq!(entry.tags.len(), 2);
248        assert_eq!(entry.tags[0].term, "tech");
249        assert_eq!(entry.tags[1].term, "programming");
250    }
251
252    #[test]
253    fn test_media_category() {
254        let mut entry = Entry::default();
255        handle_entry_element("category", "Technology", &mut entry);
256
257        assert_eq!(entry.tags.len(), 1);
258        assert_eq!(entry.tags[0].term, "Technology");
259    }
260
261    #[test]
262    fn test_media_content_default() {
263        let content = MediaContent::default();
264        assert!(content.url.is_empty());
265        assert!(content.type_.is_none());
266        assert!(content.medium.is_none());
267        assert!(content.file_size.is_none());
268        assert!(content.bitrate.is_none());
269        assert!(content.framerate.is_none());
270        assert!(content.width.is_none());
271        assert!(content.height.is_none());
272        assert!(content.duration.is_none());
273        assert!(content.expression.is_none());
274        assert!(content.is_default.is_none());
275    }
276
277    #[test]
278    fn test_media_content_full_attributes() {
279        let content = MediaContent {
280            url: "https://example.com/video.mp4".to_string(),
281            type_: Some("video/mp4".to_string()),
282            medium: Some("video".to_string()),
283            file_size: Some(10_485_760), // 10 MB
284            bitrate: Some(1500),         // 1500 kbps
285            framerate: Some(30.0),
286            width: Some(1920),
287            height: Some(1080),
288            duration: Some(600), // 10 minutes
289            expression: Some("full".to_string()),
290            is_default: Some(true),
291        };
292
293        assert_eq!(content.url, "https://example.com/video.mp4");
294        assert_eq!(content.type_.as_deref(), Some("video/mp4"));
295        assert_eq!(content.medium.as_deref(), Some("video"));
296        assert_eq!(content.file_size, Some(10_485_760));
297        assert_eq!(content.bitrate, Some(1500));
298        assert_eq!(content.framerate, Some(30.0));
299        assert_eq!(content.width, Some(1920));
300        assert_eq!(content.height, Some(1080));
301        assert_eq!(content.duration, Some(600));
302        assert_eq!(content.expression.as_deref(), Some("full"));
303        assert_eq!(content.is_default, Some(true));
304    }
305
306    #[test]
307    fn test_media_content_audio() {
308        let content = MediaContent {
309            url: "https://example.com/audio.mp3".to_string(),
310            type_: Some("audio/mpeg".to_string()),
311            medium: Some("audio".to_string()),
312            file_size: Some(5_242_880), // 5 MB
313            bitrate: Some(128),         // 128 kbps
314            duration: Some(180),        // 3 minutes
315            ..Default::default()
316        };
317
318        assert_eq!(content.medium.as_deref(), Some("audio"));
319        assert_eq!(content.bitrate, Some(128));
320        assert!(content.width.is_none());
321        assert!(content.height.is_none());
322        assert!(content.framerate.is_none());
323    }
324
325    #[test]
326    fn test_media_content_image() {
327        let content = MediaContent {
328            url: "https://example.com/image.jpg".to_string(),
329            type_: Some("image/jpeg".to_string()),
330            medium: Some("image".to_string()),
331            width: Some(800),
332            height: Some(600),
333            ..Default::default()
334        };
335
336        assert_eq!(content.medium.as_deref(), Some("image"));
337        assert_eq!(content.width, Some(800));
338        assert_eq!(content.height, Some(600));
339        assert!(content.duration.is_none());
340    }
341
342    #[test]
343    fn test_media_content_expression_variants() {
344        let full = MediaContent {
345            expression: Some("full".to_string()),
346            ..Default::default()
347        };
348        let sample = MediaContent {
349            expression: Some("sample".to_string()),
350            ..Default::default()
351        };
352        let nonstop = MediaContent {
353            expression: Some("nonstop".to_string()),
354            ..Default::default()
355        };
356
357        assert_eq!(full.expression.as_deref(), Some("full"));
358        assert_eq!(sample.expression.as_deref(), Some("sample"));
359        assert_eq!(nonstop.expression.as_deref(), Some("nonstop"));
360    }
361
362    #[test]
363    fn test_media_thumbnail_default() {
364        let thumbnail = MediaThumbnail::default();
365        assert!(thumbnail.url.is_empty());
366        assert!(thumbnail.width.is_none());
367        assert!(thumbnail.height.is_none());
368        assert!(thumbnail.time.is_none());
369    }
370
371    #[test]
372    fn test_media_thumbnail_full_attributes() {
373        let thumbnail = MediaThumbnail {
374            url: "https://example.com/thumb.jpg".to_string(),
375            width: Some(640),
376            height: Some(480),
377            time: Some("12:05:01.123".to_string()),
378        };
379
380        assert_eq!(thumbnail.url, "https://example.com/thumb.jpg");
381        assert_eq!(thumbnail.width, Some(640));
382        assert_eq!(thumbnail.height, Some(480));
383        assert_eq!(thumbnail.time.as_deref(), Some("12:05:01.123"));
384    }
385
386    #[test]
387    fn test_media_thumbnail_without_time() {
388        let thumbnail = MediaThumbnail {
389            url: "https://example.com/poster.jpg".to_string(),
390            width: Some(1920),
391            height: Some(1080),
392            time: None,
393        };
394
395        assert_eq!(thumbnail.width, Some(1920));
396        assert_eq!(thumbnail.height, Some(1080));
397        assert!(thumbnail.time.is_none());
398    }
399
400    #[test]
401    fn test_media_content_to_enclosure() {
402        let content = MediaContent {
403            url: "https://example.com/video.mp4".to_string(),
404            type_: Some("video/mp4".to_string()),
405            file_size: Some(1_024_000),
406            width: Some(1920), // These fields are not in Enclosure
407            height: Some(1080),
408            ..Default::default()
409        };
410
411        let enclosure = media_content_to_enclosure(&content);
412
413        assert_eq!(enclosure.url, "https://example.com/video.mp4");
414        assert_eq!(enclosure.enclosure_type.as_deref(), Some("video/mp4"));
415        assert_eq!(enclosure.length, Some(1_024_000));
416    }
417
418    #[test]
419    fn test_media_content_to_enclosure_minimal() {
420        let content = MediaContent {
421            url: "https://example.com/file.bin".to_string(),
422            ..Default::default()
423        };
424
425        let enclosure = media_content_to_enclosure(&content);
426
427        assert_eq!(enclosure.url, "https://example.com/file.bin");
428        assert!(enclosure.enclosure_type.is_none());
429        assert!(enclosure.length.is_none());
430    }
431
432    #[test]
433    fn test_empty_keywords() {
434        let mut entry = Entry::default();
435        handle_entry_element("keywords", "", &mut entry);
436
437        assert!(entry.tags.is_empty());
438    }
439
440    #[test]
441    fn test_keywords_with_empty_values() {
442        let mut entry = Entry::default();
443        handle_entry_element("keywords", "tech, , programming", &mut entry);
444
445        assert_eq!(entry.tags.len(), 2);
446        assert_eq!(entry.tags[0].term, "tech");
447        assert_eq!(entry.tags[1].term, "programming");
448    }
449}