Skip to main content

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, FeedMeta, MediaRating, 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/// Parse `media:rating` text and optional `scheme` attribute into a `MediaRating`.
138fn parse_rating(scheme: Option<&str>, text: &str) -> Option<MediaRating> {
139    let content = text.trim().to_string();
140    if content.is_empty() {
141        return None;
142    }
143    Some(MediaRating {
144        scheme: scheme.map(str::to_owned),
145        content,
146    })
147}
148
149/// Handle Media RSS element at feed level (`media:rating`, `media:keywords`).
150///
151/// # Arguments
152///
153/// * `element` - Local name of the element (without namespace prefix)
154/// * `scheme` - Optional `scheme` attribute value (for `media:rating`)
155/// * `text` - Text content of the element
156/// * `feed` - Feed metadata to update
157pub fn handle_feed_element(element: &str, scheme: Option<&str>, text: &str, feed: &mut FeedMeta) {
158    match element {
159        "rating" if feed.media_rating.is_none() => {
160            feed.media_rating = parse_rating(scheme, text);
161        }
162        "keywords" if feed.media_keywords.is_none() && !text.trim().is_empty() => {
163            feed.media_keywords = Some(text.trim().to_string());
164        }
165        _ => {}
166    }
167}
168
169/// Handle Media RSS element at entry level
170///
171/// Note: This is a simplified implementation. Full Media RSS support
172/// would require parsing element attributes (url, type, width, height, etc.)
173///
174/// # Arguments
175///
176/// * `element` - Local name of the element (without namespace prefix)
177/// * `text` - Text content of the element
178/// * `entry` - Entry to update
179pub fn handle_entry_element(element: &str, text: &str, entry: &mut Entry) {
180    match element {
181        "title" if entry.title.is_none() => {
182            entry.title = Some(text.to_string());
183        }
184        "description" if entry.summary.is_none() => {
185            entry.summary = Some(text.to_string());
186        }
187        "keywords" => {
188            // Store raw comma-separated string
189            if entry.media_keywords.is_none() && !text.is_empty() {
190                entry.media_keywords = Some(text.to_string());
191            }
192            // Split into tags
193            for keyword in text.split(',') {
194                let keyword = keyword.trim();
195                if !keyword.is_empty() {
196                    entry.tags.push(Tag::new(keyword));
197                }
198            }
199        }
200        "category" if !text.is_empty() => {
201            entry.tags.push(Tag::new(text));
202        }
203        _ => {
204            // Other elements like media:content, media:thumbnail, media:credit
205            // would require attribute parsing which needs integration with
206            // the XML parser. For now, we skip these.
207        }
208    }
209}
210
211/// Handle `media:rating` at entry level with `scheme` attribute.
212///
213/// Called from the full XML parser where attribute access is available.
214pub fn handle_entry_rating(scheme: Option<&str>, text: &str, entry: &mut Entry) {
215    if entry.media_rating.is_none() {
216        entry.media_rating = parse_rating(scheme, text);
217    }
218}
219
220/// Convert `MediaContent` to `Enclosure` for backward compatibility
221///
222/// Extracts URL, type, and `file_size` to create a basic enclosure.
223/// Used when adding `media:content` to `entry.enclosures`.
224///
225/// # Examples
226///
227/// ```
228/// use feedparser_rs::namespace::media_rss::{MediaContent, media_content_to_enclosure};
229///
230/// let content = MediaContent {
231///     url: "https://example.com/video.mp4".to_string(),
232///     type_: Some("video/mp4".to_string()),
233///     file_size: Some(1_024_000),
234///     ..Default::default()
235/// };
236///
237/// let enclosure = media_content_to_enclosure(&content);
238/// assert_eq!(enclosure.url, "https://example.com/video.mp4");
239/// assert_eq!(enclosure.enclosure_type.as_deref(), Some("video/mp4"));
240/// assert_eq!(enclosure.length.as_deref(), Some("1024000"));
241/// ```
242pub fn media_content_to_enclosure(content: &MediaContent) -> Enclosure {
243    Enclosure {
244        url: content.url.clone().into(),
245        enclosure_type: content.type_.as_ref().map(|t| t.clone().into()),
246        length: content.file_size.map(|v| v.to_string()),
247        title: None,
248        duration: None,
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn test_media_title() {
258        let mut entry = Entry::default();
259        handle_entry_element("title", "Video Title", &mut entry);
260
261        assert_eq!(entry.title.as_deref(), Some("Video Title"));
262    }
263
264    #[test]
265    fn test_media_description() {
266        let mut entry = Entry::default();
267        handle_entry_element("description", "Video description", &mut entry);
268
269        assert_eq!(entry.summary.as_deref(), Some("Video description"));
270    }
271
272    #[test]
273    fn test_media_keywords() {
274        let mut entry = Entry::default();
275        handle_entry_element("keywords", "tech, programming, rust", &mut entry);
276
277        assert_eq!(entry.tags.len(), 3);
278        assert_eq!(entry.tags[0].term, "tech");
279        assert_eq!(entry.tags[1].term, "programming");
280        assert_eq!(entry.tags[2].term, "rust");
281    }
282
283    #[test]
284    fn test_media_keywords_with_spaces() {
285        let mut entry = Entry::default();
286        handle_entry_element("keywords", "  tech  ,  programming  ", &mut entry);
287
288        assert_eq!(entry.tags.len(), 2);
289        assert_eq!(entry.tags[0].term, "tech");
290        assert_eq!(entry.tags[1].term, "programming");
291    }
292
293    #[test]
294    fn test_media_category() {
295        let mut entry = Entry::default();
296        handle_entry_element("category", "Technology", &mut entry);
297
298        assert_eq!(entry.tags.len(), 1);
299        assert_eq!(entry.tags[0].term, "Technology");
300    }
301
302    #[test]
303    fn test_media_content_default() {
304        let content = MediaContent::default();
305        assert!(content.url.is_empty());
306        assert!(content.type_.is_none());
307        assert!(content.medium.is_none());
308        assert!(content.file_size.is_none());
309        assert!(content.bitrate.is_none());
310        assert!(content.framerate.is_none());
311        assert!(content.width.is_none());
312        assert!(content.height.is_none());
313        assert!(content.duration.is_none());
314        assert!(content.expression.is_none());
315        assert!(content.is_default.is_none());
316    }
317
318    #[test]
319    fn test_media_content_full_attributes() {
320        let content = MediaContent {
321            url: "https://example.com/video.mp4".to_string(),
322            type_: Some("video/mp4".to_string()),
323            medium: Some("video".to_string()),
324            file_size: Some(10_485_760), // 10 MB
325            bitrate: Some(1500),         // 1500 kbps
326            framerate: Some(30.0),
327            width: Some(1920),
328            height: Some(1080),
329            duration: Some(600), // 10 minutes
330            expression: Some("full".to_string()),
331            is_default: Some(true),
332        };
333
334        assert_eq!(content.url, "https://example.com/video.mp4");
335        assert_eq!(content.type_.as_deref(), Some("video/mp4"));
336        assert_eq!(content.medium.as_deref(), Some("video"));
337        assert_eq!(content.file_size, Some(10_485_760));
338        assert_eq!(content.bitrate, Some(1500));
339        assert_eq!(content.framerate, Some(30.0));
340        assert_eq!(content.width, Some(1920));
341        assert_eq!(content.height, Some(1080));
342        assert_eq!(content.duration, Some(600));
343        assert_eq!(content.expression.as_deref(), Some("full"));
344        assert_eq!(content.is_default, Some(true));
345    }
346
347    #[test]
348    fn test_media_content_audio() {
349        let content = MediaContent {
350            url: "https://example.com/audio.mp3".to_string(),
351            type_: Some("audio/mpeg".to_string()),
352            medium: Some("audio".to_string()),
353            file_size: Some(5_242_880), // 5 MB
354            bitrate: Some(128),         // 128 kbps
355            duration: Some(180),        // 3 minutes
356            ..Default::default()
357        };
358
359        assert_eq!(content.medium.as_deref(), Some("audio"));
360        assert_eq!(content.bitrate, Some(128));
361        assert!(content.width.is_none());
362        assert!(content.height.is_none());
363        assert!(content.framerate.is_none());
364    }
365
366    #[test]
367    fn test_media_content_image() {
368        let content = MediaContent {
369            url: "https://example.com/image.jpg".to_string(),
370            type_: Some("image/jpeg".to_string()),
371            medium: Some("image".to_string()),
372            width: Some(800),
373            height: Some(600),
374            ..Default::default()
375        };
376
377        assert_eq!(content.medium.as_deref(), Some("image"));
378        assert_eq!(content.width, Some(800));
379        assert_eq!(content.height, Some(600));
380        assert!(content.duration.is_none());
381    }
382
383    #[test]
384    fn test_media_content_expression_variants() {
385        let full = MediaContent {
386            expression: Some("full".to_string()),
387            ..Default::default()
388        };
389        let sample = MediaContent {
390            expression: Some("sample".to_string()),
391            ..Default::default()
392        };
393        let nonstop = MediaContent {
394            expression: Some("nonstop".to_string()),
395            ..Default::default()
396        };
397
398        assert_eq!(full.expression.as_deref(), Some("full"));
399        assert_eq!(sample.expression.as_deref(), Some("sample"));
400        assert_eq!(nonstop.expression.as_deref(), Some("nonstop"));
401    }
402
403    #[test]
404    fn test_media_thumbnail_default() {
405        let thumbnail = MediaThumbnail::default();
406        assert!(thumbnail.url.is_empty());
407        assert!(thumbnail.width.is_none());
408        assert!(thumbnail.height.is_none());
409        assert!(thumbnail.time.is_none());
410    }
411
412    #[test]
413    fn test_media_thumbnail_full_attributes() {
414        let thumbnail = MediaThumbnail {
415            url: "https://example.com/thumb.jpg".to_string(),
416            width: Some(640),
417            height: Some(480),
418            time: Some("12:05:01.123".to_string()),
419        };
420
421        assert_eq!(thumbnail.url, "https://example.com/thumb.jpg");
422        assert_eq!(thumbnail.width, Some(640));
423        assert_eq!(thumbnail.height, Some(480));
424        assert_eq!(thumbnail.time.as_deref(), Some("12:05:01.123"));
425    }
426
427    #[test]
428    fn test_media_thumbnail_without_time() {
429        let thumbnail = MediaThumbnail {
430            url: "https://example.com/poster.jpg".to_string(),
431            width: Some(1920),
432            height: Some(1080),
433            time: None,
434        };
435
436        assert_eq!(thumbnail.width, Some(1920));
437        assert_eq!(thumbnail.height, Some(1080));
438        assert!(thumbnail.time.is_none());
439    }
440
441    #[test]
442    fn test_media_content_to_enclosure() {
443        let content = MediaContent {
444            url: "https://example.com/video.mp4".to_string(),
445            type_: Some("video/mp4".to_string()),
446            file_size: Some(1_024_000),
447            width: Some(1920), // These fields are not in Enclosure
448            height: Some(1080),
449            ..Default::default()
450        };
451
452        let enclosure = media_content_to_enclosure(&content);
453
454        assert_eq!(enclosure.url, "https://example.com/video.mp4");
455        assert_eq!(enclosure.enclosure_type.as_deref(), Some("video/mp4"));
456        assert_eq!(enclosure.length.as_deref(), Some("1024000"));
457    }
458
459    #[test]
460    fn test_media_content_to_enclosure_minimal() {
461        let content = MediaContent {
462            url: "https://example.com/file.bin".to_string(),
463            ..Default::default()
464        };
465
466        let enclosure = media_content_to_enclosure(&content);
467
468        assert_eq!(enclosure.url, "https://example.com/file.bin");
469        assert!(enclosure.enclosure_type.is_none());
470        assert!(enclosure.length.is_none());
471    }
472
473    #[test]
474    fn test_empty_keywords() {
475        let mut entry = Entry::default();
476        handle_entry_element("keywords", "", &mut entry);
477
478        assert!(entry.tags.is_empty());
479    }
480
481    #[test]
482    fn test_keywords_with_empty_values() {
483        let mut entry = Entry::default();
484        handle_entry_element("keywords", "tech, , programming", &mut entry);
485
486        assert_eq!(entry.tags.len(), 2);
487        assert_eq!(entry.tags[0].term, "tech");
488        assert_eq!(entry.tags[1].term, "programming");
489    }
490}