Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/upnp/didl.rs

18.1 KiB, 96 runs

created by r1870400018:19713, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! DIDL-Lite: what a ContentDirectory `Browse` actually answers with
2//! (ContentDirectory:1 §2.8, and DLNA guidelines part 1 §7.3 for the profiles).
3//!
4//! The answer to a browse is not the XML a control point sees first. It is a
5//! *string* carried inside the `<Result>` argument of a SOAP response, so the
6//! whole DIDL-Lite document is escaped once on the way out. That double layer is
7//! the commonest way a media server's output is subtly wrong: an unescaped
8//! ampersand in a photograph's file name breaks the outer document, and a
9//! doubly-escaped one shows up on the television as `&amp;`.
10//!
11//! # `protocolInfo`, and why it decides everything
12//!
13//! Each `<res>` element carries a `protocolInfo` of four colon-separated fields:
14//! `http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_LRG;DLNA.ORG_OP=01;...`. The fourth
15//! field is where a television decides whether it will play something at all. A
16//! set that finds no `DLNA.ORG_PN` it recognises may show the item and refuse to
17//! open it, and a set given a `DLNA.ORG_PN` that does not match the bytes behind
18//! it fails in stranger ways still. [`ProtocolInfo`] therefore holds the pieces
19//! apart, so that a caller trying profile strings against a real television
20//! changes one table rather than a dozen format strings.
21//!
22//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
23//! Anthropic Claude
24
25use crate::upnp::{
26 escape,
27 NS_DC,
28 NS_DIDL,
29 NS_DLNA_METADATA,
30 NS_UPNP,
31};
32
33use oxedyne_fe2o3_core::prelude::*;
34
35use std::fmt;
36
37
38/// A control point decides how to *treat* an object from its `upnp:class`, not
39/// from what is in it: a set showing a slideshow looks for `imageItem`, and one
40/// browsing for something to play looks for `videoItem`. Held as an enum so that
41/// a class cannot be misspelled at one call site out of six.
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub enum Class {
44 Container, // nothing more specific is true
45 StorageFolder, // what a filesystem tree becomes
46 PhotoAlbum, // what an album becomes
47 Photo,
48 Movie,
49 Other(&'static str), // a class this crate does not model
50}
51
52impl fmt::Display for Class {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 write!(f, "{}", match self {
55 Self::Container => "object.container",
56 Self::StorageFolder => "object.container.storageFolder",
57 Self::PhotoAlbum => "object.container.album.photoAlbum",
58 Self::Photo => "object.item.imageItem.photo",
59 Self::Movie => "object.item.videoItem.movie",
60 Self::Other(s) => s,
61 })
62 }
63}
64
65/// The fourth field of a `protocolInfo`, which is where DLNA lives.
66///
67/// Every part is optional, because a resource whose profile is not confidently
68/// known is better described by `*` than by a guess: a television shown a profile
69/// that does not match the bytes fails in a way that is hard to read, whereas one
70/// shown no profile at all either plays the file or does not.
71#[derive(Clone, Debug, Default, Eq, PartialEq)]
72pub struct DlnaExtras {
73 pub profile: Option<String>, // `DLNA.ORG_PN`, e.g. `JPEG_LRG`
74 pub operations: Option<&'static str>, // `DLNA.ORG_OP`, time-seek then byte-seek
75 pub converted: Option<bool>, // `DLNA.ORG_CI`, 1 where the server made the bytes
76 pub flags: Option<&'static str>, // `DLNA.ORG_FLAGS`, see FLAGS_IMAGE and FLAGS_STREAMING
77}
78
79//// `DLNA.ORG_OP` values.
80// Two flags, time-seek then byte-seek. A server answering HTTP byte ranges and
81// nothing else advertises the first of these.
82pub const OP_BYTE_RANGE: &str = "01";
83pub const OP_NONE: &str = "00";
84
85//// `DLNA.ORG_FLAGS` values.
86// One thirty-two digit hexadecimal number whose meaning is all in its first
87// eight digits; the rest are reserved and are zero. Both declare DLNA v1.5 and
88// HTTP stalling; the image value is interactive transfer, and the film value
89// adds streaming and background transfer.
90pub const FLAGS_IMAGE: &str = "00D00000000000000000000000000000";
91pub const FLAGS_STREAMING: &str = "01700000000000000000000000000000";
92
93impl DlnaExtras {
94
95 /// The extras a rendition the server made itself carries.
96 pub fn rendition(profile: &str) -> Self {
97 Self {
98 profile: Some(profile.to_string()),
99 operations: Some(OP_BYTE_RANGE),
100 converted: Some(true),
101 flags: Some(FLAGS_IMAGE),
102 }
103 }
104
105 /// The extras an original served as it lies carries.
106 pub fn original(profile: Option<String>, streaming: bool) -> Self {
107 Self {
108 profile,
109 operations: Some(OP_BYTE_RANGE),
110 converted: Some(false),
111 flags: Some(if streaming { FLAGS_STREAMING } else { FLAGS_IMAGE }),
112 }
113 }
114}
115
116impl fmt::Display for DlnaExtras {
117 /// In the order the DLNA guidelines write it. An entirely empty set of
118 /// extras goes out as `*`, the field's way of saying nothing is claimed.
119 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
120 let mut parts: Vec<String> = Vec::with_capacity(4);
121 if let Some(pn) = &self.profile {
122 parts.push(fmt!("DLNA.ORG_PN={}", pn));
123 }
124 if let Some(op) = self.operations {
125 parts.push(fmt!("DLNA.ORG_OP={}", op));
126 }
127 if let Some(ci) = self.converted {
128 parts.push(fmt!("DLNA.ORG_CI={}", if ci { 1 } else { 0 }));
129 }
130 if let Some(flags) = self.flags {
131 parts.push(fmt!("DLNA.ORG_FLAGS={}", flags));
132 }
133 if parts.is_empty() {
134 return write!(f, "*");
135 }
136 write!(f, "{}", parts.join(";"))
137 }
138}
139
140/// The whole `protocolInfo` attribute: how to fetch it, from where, what it is,
141/// and what DLNA says about it.
142#[derive(Clone, Debug, Eq, PartialEq)]
143pub struct ProtocolInfo {
144 pub protocol: String, // `http-get` for everything served over HTTP
145 pub network: String, // `*` everywhere, a field only because the syntax has four
146 pub content: String, // content type, e.g. `image/jpeg`
147 pub extras: DlnaExtras,
148}
149
150impl ProtocolInfo {
151
152 pub fn http_get<S: Into<String>>(content: S, extras: DlnaExtras) -> Self {
153 Self {
154 protocol: "http-get".to_string(),
155 network: "*".to_string(),
156 content: content.into(),
157 extras,
158 }
159 }
160}
161
162impl fmt::Display for ProtocolInfo {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 write!(f, "{}:{}:{}:{}", self.protocol, self.network, self.content, self.extras)
165 }
166}
167
168/// One `<res>`. An object may carry several, and a control point picks whichever
169/// it likes the look of, which is why a thumbnail and a full-size rendition of
170/// the same photograph are two resources on one item rather than two items.
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct Resource {
173 pub uri: String,
174 pub info: ProtocolInfo,
175 pub size: Option<u64>, // where it is known without reading the bytes
176 pub resolution: Option<(u32, u32)>, // width then height, in pixels
177 pub duration: Option<String>, // running time, as `H:MM:SS.mmm`
178 pub depth: Option<u32>, // bits per pixel, which a few sets read and none require
179}
180
181impl Resource {
182
183 /// The `uri` and the `info` are the two things every resource must have.
184 pub fn new<S: Into<String>>(uri: S, info: ProtocolInfo) -> Self {
185 Self {
186 uri: uri.into(),
187 info,
188 size: None,
189 resolution: None,
190 duration: None,
191 depth: None,
192 }
193 }
194
195 pub fn sized(mut self, bytes: u64) -> Self {
196 self.size = Some(bytes);
197 self
198 }
199
200 pub fn at(mut self, w: u32, h: u32) -> Self {
201 self.resolution = Some((w, h));
202 self
203 }
204
205 /// Every value written here is escaped, attributes included.
206 fn write(&self, out: &mut String) {
207 out.push_str(&fmt!("<res protocolInfo=\"{}\"", escape(&fmt!("{}", self.info))));
208 if let Some(size) = self.size {
209 out.push_str(&fmt!(" size=\"{}\"", size));
210 }
211 if let Some((w, h)) = self.resolution {
212 out.push_str(&fmt!(" resolution=\"{}x{}\"", w, h));
213 }
214 if let Some(duration) = &self.duration {
215 out.push_str(&fmt!(" duration=\"{}\"", escape(duration)));
216 }
217 if let Some(depth) = self.depth {
218 out.push_str(&fmt!(" colorDepth=\"{}\"", depth));
219 }
220 out.push_str(&fmt!(">{}</res>", escape(&self.uri)));
221 }
222}
223
224/// Something a control point can browse into.
225#[derive(Clone, Debug, Eq, PartialEq)]
226pub struct Container {
227 pub id: String,
228 pub parent: String, // the root's parent is `-1`
229 pub title: String,
230 pub class: Class,
231 // A control point draws a count from this, and some will not descend without one.
232 pub children: Option<u64>, // direct children, where that is cheap to know
233 pub restricted: bool, // a served library is restricted, going out as `1`
234 pub searchable: bool, // whether `Search` may be run against it
235}
236
237impl Container {
238
239 /// A restricted, unsearchable container, which is what a served library is
240 /// made of.
241 pub fn new<I, P, T>(id: I, parent: P, title: T, class: Class) -> Self
242 where
243 I: Into<String>,
244 P: Into<String>,
245 T: Into<String>,
246 {
247 Self {
248 id: id.into(),
249 parent: parent.into(),
250 title: title.into(),
251 class,
252 children: None,
253 restricted: true,
254 searchable: false,
255 }
256 }
257
258 pub fn holding(mut self, n: u64) -> Self {
259 self.children = Some(n);
260 self
261 }
262
263 fn write(&self, out: &mut String) {
264 out.push_str(&fmt!("<container id=\"{}\" parentID=\"{}\" restricted=\"{}\"",
265 escape(&self.id), escape(&self.parent), if self.restricted { 1 } else { 0 }));
266 if let Some(n) = self.children {
267 out.push_str(&fmt!(" childCount=\"{}\"", n));
268 }
269 out.push_str(&fmt!(" searchable=\"{}\">", if self.searchable { 1 } else { 0 }));
270 out.push_str(&fmt!("<dc:title>{}</dc:title>", escape(&self.title)));
271 out.push_str(&fmt!("<upnp:class>{}</upnp:class>", self.class));
272 out.push_str("</container>");
273 }
274}
275
276/// Something a control point can play or show.
277#[derive(Clone, Debug, Eq, PartialEq)]
278pub struct Item {
279 pub id: String,
280 pub parent: String, // the container it was browsed from
281 pub title: String,
282 pub class: Class,
283 // A television that groups or sorts by date reads this and nothing else.
284 pub date: Option<String>, // `YYYY-MM-DDTHH:MM:SS`
285 pub art: Option<String>, // thumbnail, which most sets use and none need
286 pub art_profile: Option<String>, // goes out as `dlna:profileID`
287 pub restricted: bool,
288 pub resources: Vec<Resource>, // best first
289}
290
291impl Item {
292
293 /// A restricted item with no resources yet.
294 pub fn new<I, P, T>(id: I, parent: P, title: T, class: Class) -> Self
295 where
296 I: Into<String>,
297 P: Into<String>,
298 T: Into<String>,
299 {
300 Self {
301 id: id.into(),
302 parent: parent.into(),
303 title: title.into(),
304 class,
305 date: None,
306 art: None,
307 art_profile: None,
308 restricted: true,
309 resources: Vec::new(),
310 }
311 }
312
313 pub fn with(mut self, res: Resource) -> Self {
314 self.resources.push(res);
315 self
316 }
317
318 pub fn taken<S: Into<String>>(mut self, when: S) -> Self {
319 self.date = Some(when.into());
320 self
321 }
322
323 pub fn thumbnail<S: Into<String>>(mut self, uri: S, profile: &str) -> Self {
324 self.art = Some(uri.into());
325 self.art_profile = Some(profile.to_string());
326 self
327 }
328
329 fn write(&self, out: &mut String) {
330 out.push_str(&fmt!("<item id=\"{}\" parentID=\"{}\" restricted=\"{}\">",
331 escape(&self.id), escape(&self.parent), if self.restricted { 1 } else { 0 }));
332 out.push_str(&fmt!("<dc:title>{}</dc:title>", escape(&self.title)));
333 out.push_str(&fmt!("<upnp:class>{}</upnp:class>", self.class));
334 if let Some(date) = &self.date {
335 out.push_str(&fmt!("<dc:date>{}</dc:date>", escape(date)));
336 }
337 if let Some(art) = &self.art {
338 match &self.art_profile {
339 Some(profile) => out.push_str(&fmt!(
340 "<upnp:albumArtURI dlna:profileID=\"{}\">{}</upnp:albumArtURI>",
341 escape(profile), escape(art))),
342 None => out.push_str(&fmt!(
343 "<upnp:albumArtURI>{}</upnp:albumArtURI>", escape(art))),
344 }
345 }
346 for res in &self.resources {
347 res.write(out);
348 }
349 out.push_str("</item>");
350 }
351}
352
353#[derive(Clone, Debug, Eq, PartialEq)]
354pub enum Object {
355 Container(Container),
356 Item(Item),
357}
358
359/// A DIDL-Lite document: the objects, and the four namespaces they are spelled in.
360#[derive(Clone, Debug, Default, Eq, PartialEq)]
361pub struct Didl {
362 pub objects: Vec<Object>, // in the order they are to be shown
363}
364
365impl Didl {
366
367 /// An empty document, which is a perfectly good answer to a browse.
368 pub fn new() -> Self {
369 Self { objects: Vec::new() }
370 }
371
372 pub fn container(&mut self, c: Container) {
373 self.objects.push(Object::Container(c));
374 }
375
376 pub fn item(&mut self, i: Item) {
377 self.objects.push(Object::Item(i));
378 }
379
380 /// A browse's `NumberReturned`.
381 pub fn len(&self) -> usize {
382 self.objects.len()
383 }
384
385 pub fn is_empty(&self) -> bool {
386 self.objects.is_empty()
387 }
388
389 /// No XML declaration: the string goes inside a SOAP argument, where a second
390 /// declaration would be in the middle of a document and make it unparseable.
391 pub fn to_xml(&self) -> String {
392 let mut out = String::with_capacity(256 + self.objects.len() * 400);
393 out.push_str(&fmt!(
394 "<DIDL-Lite xmlns=\"{}\" xmlns:dc=\"{}\" xmlns:upnp=\"{}\" xmlns:dlna=\"{}\">",
395 NS_DIDL, NS_DC, NS_UPNP, NS_DLNA_METADATA));
396 for object in &self.objects {
397 match object {
398 Object::Container(c) => c.write(&mut out),
399 Object::Item(i) => i.write(&mut out),
400 }
401 }
402 out.push_str("</DIDL-Lite>");
403 out
404 }
405}
406
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn test_a_protocol_info_has_its_four_fields_in_order() {
414 let info = ProtocolInfo::http_get("image/jpeg", DlnaExtras::rendition("JPEG_LRG"));
415 assert_eq!(fmt!("{}", info),
416 "http-get:*:image/jpeg:DLNA.ORG_PN=JPEG_LRG;DLNA.ORG_OP=01;\
417 DLNA.ORG_CI=1;DLNA.ORG_FLAGS=00D00000000000000000000000000000");
418 }
419
420 /// A resource claiming nothing says so with a star, rather than with an empty
421 /// fourth field that some sets read as a malformed one.
422 #[test]
423 fn test_a_resource_that_claims_no_profile_says_star() {
424 let info = ProtocolInfo::http_get("video/quicktime", DlnaExtras::default());
425 assert_eq!(fmt!("{}", info), "http-get:*:video/quicktime:*");
426 }
427
428 #[test]
429 fn test_a_container_carries_its_title_class_and_count() {
430 let mut didl = Didl::new();
431 didl.container(Container::new("0$A", "0", "Albums", Class::StorageFolder).holding(7));
432 let xml = didl.to_xml();
433 assert!(xml.contains("<container id=\"0$A\" parentID=\"0\" restricted=\"1\" \
434 childCount=\"7\" searchable=\"0\">"), "{}", xml);
435 assert!(xml.contains("<dc:title>Albums</dc:title>"), "{}", xml);
436 assert!(xml.contains("<upnp:class>object.container.storageFolder</upnp:class>"),
437 "{}", xml);
438 }
439
440 #[test]
441 fn test_an_item_carries_its_resources_in_the_order_given() {
442 let item = Item::new("0$D$2016$03$abc", "0$D$2016$03", "IMG_0079", Class::Photo)
443 .taken("2016-03-04T10:22:31")
444 .thumbnail("http://h/t/abc.jpg", "JPEG_TN")
445 .with(Resource::new(
446 "http://h/r/abc.jpg",
447 ProtocolInfo::http_get("image/jpeg", DlnaExtras::rendition("JPEG_LRG")),
448 ).sized(482_113).at(1920, 1440));
449 let mut didl = Didl::new();
450 didl.item(item);
451 let xml = didl.to_xml();
452 assert!(xml.contains("<dc:date>2016-03-04T10:22:31</dc:date>"), "{}", xml);
453 assert!(xml.contains("resolution=\"1920x1440\""), "{}", xml);
454 assert!(xml.contains("size=\"482113\""), "{}", xml);
455 assert!(xml.contains("<upnp:albumArtURI dlna:profileID=\"JPEG_TN\">"), "{}", xml);
456 }
457
458 /// The name of a photograph is a file name, and a file name may hold any of
459 /// the five characters XML reserves. One of them unescaped breaks the whole
460 /// answer, not merely one item.
461 #[test]
462 fn test_a_title_with_reserved_characters_does_not_break_the_document() {
463 let mut didl = Didl::new();
464 didl.container(Container::new(
465 "0$F$1", "0$F", "Rosie & Chloe <\"2016\">", Class::StorageFolder));
466 let xml = didl.to_xml();
467 assert!(xml.contains("Rosie &amp; Chloe &lt;&quot;2016&quot;&gt;"), "{}", xml);
468 // And exactly one unescaped angle bracket pair per element.
469 assert_eq!(xml.matches("<dc:title>").count(), 1);
470 }
471
472 /// A URI is a string in an element body and its ampersands are escaped there
473 /// too, which is what a query string in a resource URL makes necessary.
474 #[test]
475 fn test_a_resource_uri_is_escaped() {
476 let mut didl = Didl::new();
477 didl.item(Item::new("i", "0", "t", Class::Photo).with(Resource::new(
478 "http://h/r?a=1&b=2",
479 ProtocolInfo::http_get("image/jpeg", DlnaExtras::default()),
480 )));
481 let xml = didl.to_xml();
482 assert!(xml.contains("http://h/r?a=1&amp;b=2"), "{}", xml);
483 }
484
485 /// The document goes inside a SOAP string argument, so it must not carry an
486 /// XML declaration of its own.
487 #[test]
488 fn test_the_document_has_no_declaration() {
489 assert!(!Didl::new().to_xml().starts_with("<?xml"));
490 assert!(Didl::new().to_xml().starts_with("<DIDL-Lite xmlns="));
491 }
492}