1use std::io::{Cursor, Read};
5use std::path::{Path, PathBuf};
6use std::process::{self, Child, Command, Stdio};
7use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
8use std::thread;
9use std::time::Instant;
10
11use clap::Parser;
12use log::{LevelFilter, debug, error, info, warn};
13use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
14
15use crate::{HTTP_LOG, config, ssdp, xml};
16
17const PORT_DEFAULT: u16 = 59876;
18const MAX_BODY: u64 = 1 << 20;
19
20#[derive(Parser)]
21#[command(
22 name = "tiny-render",
23 version,
24 about = "A tiny DLNA media renderer (plays with mpv)"
25)]
26struct Args {
27 #[arg(long)]
29 http_logs: bool,
30 #[arg(short, long)]
32 verbose: bool,
33 #[arg(long)]
35 name: Option<String>,
36 #[arg(long)]
38 port: Option<u16>,
39 #[arg(long, value_name = "FILE")]
41 dump_to: Option<PathBuf>,
42 #[arg(long, requires = "dump_to")]
45 headless: bool,
46}
47
48#[derive(Clone, Copy, Debug, PartialEq)]
50enum Backend {
51 Mpv,
53 Ffmpeg,
55}
56
57impl Backend {
58 fn program(self) -> &'static str {
59 match self {
60 Backend::Mpv => "mpv",
61 Backend::Ffmpeg => "ffmpeg",
62 }
63 }
64}
65
66fn pick_backend(recording: bool, headless: bool, display: bool, has_mpv: bool) -> Backend {
67 if recording && (headless || !display || !has_mpv) {
68 Backend::Ffmpeg
69 } else {
70 Backend::Mpv
71 }
72}
73
74fn has_display() -> bool {
76 if cfg!(any(windows, target_os = "macos")) {
77 return true;
78 }
79 ["DISPLAY", "WAYLAND_DISPLAY"]
80 .iter()
81 .any(|v| std::env::var_os(v).is_some_and(|s| !s.is_empty()))
82}
83
84fn in_path(program: &str) -> bool {
85 let exe = format!("{program}{}", std::env::consts::EXE_SUFFIX);
86 std::env::var_os("PATH")
87 .is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(&exe).is_file()))
88}
89
90static FFMPEG_PID: AtomicU32 = AtomicU32::new(0);
92static FFMPEG_STOPPING: AtomicBool = AtomicBool::new(false);
94
95#[cfg(unix)]
98fn stop_ffmpeg() -> bool {
99 let pid = FFMPEG_PID.swap(0, Ordering::SeqCst);
100 if pid == 0 {
101 return false;
102 }
103 FFMPEG_STOPPING.store(true, Ordering::SeqCst);
104 unsafe {
105 libc::kill(pid as libc::pid_t, libc::SIGTERM);
106 }
107 true
108}
109
110#[cfg(not(unix))]
112fn stop_ffmpeg() -> bool {
113 false
114}
115
116fn friendly_name(name: Option<&str>, recording: bool) -> String {
117 match (name, recording) {
118 (None, false) => "Tiny Render".into(),
119 (None, true) => "Tiny Recorder".into(),
120 (Some(n), false) => n.into(),
121 (Some(n), true) => format!("{n} (Recorder)"),
122 }
123}
124
125pub fn main() {
126 let args = Args::parse();
127
128 let app_level = if args.verbose {
129 LevelFilter::Debug
130 } else {
131 LevelFilter::Info
132 };
133 let net_level = if args.http_logs {
134 LevelFilter::Debug
135 } else {
136 LevelFilter::Error
137 };
138 crate::init_logging(&[
139 ("tiny_dlna", app_level),
140 ("tiny_dlna::ssdp", net_level),
141 (HTTP_LOG, net_level),
142 ]);
143
144 let dump_to = args.dump_to.as_deref().map(absolute);
145 if let Some(path) = dump_to.as_ref().filter(|p| p.exists()) {
146 error!("target file exists: {}", path.display());
147 process::exit(1);
148 }
149
150 let port = match args.port {
151 Some(p) if p != 0 => p,
152 _ => PORT_DEFAULT + u16::from(dump_to.is_some()),
153 };
154 let server = match Server::http(("0.0.0.0", port)) {
155 Ok(s) => s,
156 Err(e) => {
157 error!("failed to listen on port {port}: {e}");
158 process::exit(1);
159 }
160 };
161
162 let backend = pick_backend(
163 dump_to.is_some(),
164 args.headless,
165 has_display(),
166 in_path("mpv"),
167 );
168 if !in_path(backend.program()) {
169 if backend == Backend::Ffmpeg {
170 error!("ffmpeg is required for headless recording, but not found in PATH");
171 process::exit(1);
172 }
173 warn!("mpv not found in PATH");
174 }
175
176 let name = friendly_name(args.name.as_deref(), dump_to.is_some());
177 let uuid = config::render_uuid(port);
178 {
179 let uuid = uuid.clone();
180 ctrlc::set_handler(move || {
181 debug!("got killing signal");
182 config::unregister_render(&uuid);
183 if stop_ffmpeg() {
186 info!("finishing the recording...");
187 return;
188 }
189 process::exit(0);
190 })
191 .expect("failed to install signal handler");
192 }
193 config::register_render(&uuid, &name, port);
194
195 thread::spawn(ssdp::serve_forever);
196
197 info!("Starting DLNA Receiver: {name}");
198 if let Some(path) = &dump_to {
199 info!(
200 "Recording stream to {} (with {})",
201 path.display(),
202 backend.program()
203 );
204 }
205
206 let mut render = Render {
207 friendly_name: name,
208 uuid: uuid.clone(),
209 dump_to,
210 media: Media::default(),
211 started_at: None,
212 player: Player {
213 backend,
214 uuid,
215 child: None,
216 },
217 finishing: false,
218 };
219 for request in server.incoming_requests() {
220 render.handle(request);
221 }
222}
223
224fn absolute(path: &Path) -> PathBuf {
225 std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())
226}
227
228#[derive(Default, Debug, PartialEq)]
229struct Media {
230 uri: String,
231 srt: String,
232 title: String,
233}
234
235struct Player {
236 backend: Backend,
237 uuid: String,
239 child: Option<Child>,
240}
241
242impl Player {
243 fn play(&mut self, media: &Media, dump_to: Option<&Path>) -> std::io::Result<()> {
244 match (self.backend, dump_to) {
245 (Backend::Ffmpeg, Some(path)) => self.record(media, path),
246 _ => self.play_mpv(media, dump_to),
247 }
248 }
249
250 fn play_mpv(&mut self, media: &Media, dump_to: Option<&Path>) -> std::io::Result<()> {
251 self.stop();
252 let mut cmd = Command::new("mpv");
253 cmd.args(["--quiet", "--screen=1", "--no-terminal"]);
254 if let Some(path) = dump_to {
255 cmd.arg(format!("--stream-record={}", path.display()));
256 }
257 if !media.title.is_empty() {
258 cmd.arg(format!("--title={}", media.title));
259 }
260 if !media.srt.is_empty() {
261 cmd.arg(format!("--sub-file={}", media.srt));
262 }
263 cmd.arg("--").arg(&media.uri);
265 debug!("running: {cmd:?}");
266 self.child = Some(cmd.stdin(Stdio::null()).stderr(Stdio::null()).spawn()?);
267 Ok(())
268 }
269
270 fn record(&mut self, media: &Media, path: &Path) -> std::io::Result<()> {
273 if !is_http(&media.uri) {
274 return Err(std::io::Error::other(format!(
275 "not an http(s) URI: {}",
276 media.uri
277 )));
278 }
279 let mut cmd = ffmpeg_command(&media.uri, path, log::log_enabled!(log::Level::Debug));
280 #[cfg(unix)]
281 {
282 use std::os::unix::process::CommandExt;
285 cmd.process_group(0);
286 }
287 debug!("running: {cmd:?}");
288 let mut child = cmd.stdin(Stdio::null()).spawn()?;
289 FFMPEG_PID.store(child.id(), Ordering::SeqCst);
290
291 let uuid = self.uuid.clone();
292 let path = path.to_path_buf();
293 thread::spawn(move || {
294 let status = child.wait();
295 FFMPEG_PID.store(0, Ordering::SeqCst);
296 config::unregister_render(&uuid);
297 let stopped = FFMPEG_STOPPING.load(Ordering::SeqCst);
299 let saved = path.metadata().is_ok_and(|m| m.len() > 0);
300 match status {
301 Ok(s) if s.success() || stopped => {
302 if saved {
303 info!("recording saved to {}", path.display());
304 process::exit(0);
305 }
306 error!("nothing recorded");
307 }
308 Ok(s) if saved => {
309 error!(
310 "ffmpeg failed ({s}); partial recording kept in {}",
311 path.display()
312 )
313 }
314 Ok(s) => error!("ffmpeg failed ({s}); nothing recorded"),
315 Err(e) => error!("failed to wait for ffmpeg: {e}"),
316 }
317 process::exit(1);
318 });
319 Ok(())
320 }
321
322 fn stop(&mut self) {
323 let Some(mut child) = self.child.take() else {
324 return;
325 };
326 terminate(&mut child);
327 thread::spawn(move || child.wait());
329 }
330}
331
332fn is_http(uri: &str) -> bool {
333 let lower = uri.to_ascii_lowercase();
334 lower.starts_with("http://") || lower.starts_with("https://")
335}
336
337fn is_mp4_like(path: &Path) -> bool {
340 path.extension()
341 .and_then(|e| e.to_str())
342 .is_some_and(|e| ["mp4", "m4v", "mov"].contains(&e.to_ascii_lowercase().as_str()))
343}
344
345fn ffmpeg_command(uri: &str, path: &Path, verbose: bool) -> Command {
346 let mut cmd = Command::new("ffmpeg");
347 cmd.args(["-nostdin", "-hide_banner", "-loglevel"])
348 .arg(if verbose { "warning" } else { "error" })
349 .args([
351 "-protocol_whitelist",
352 "http,https,tcp,tls,crypto,httpproxy,data",
353 ])
354 .arg("-i")
355 .arg(uri)
356 .args(["-c", "copy", "-sn", "-dn"]);
359 if is_mp4_like(path) {
360 cmd.args(["-movflags", "+frag_keyframe+default_base_moof"]);
361 }
362 cmd.arg("-n").arg(path);
363 cmd
364}
365
366#[cfg(unix)]
367fn terminate(child: &mut Child) {
368 unsafe {
370 libc::kill(child.id() as libc::pid_t, libc::SIGTERM);
371 }
372}
373
374#[cfg(not(unix))]
375fn terminate(child: &mut Child) {
376 let _ = child.kill();
377}
378
379struct Render {
380 friendly_name: String,
381 uuid: String,
382 dump_to: Option<PathBuf>,
383 media: Media,
384 started_at: Option<Instant>,
385 player: Player,
386 finishing: bool,
388}
389
390type Resp = Response<Cursor<Vec<u8>>>;
391
392fn header(name: &str, value: &str) -> Header {
393 Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid header")
394}
395
396fn respond(status: u16, content_type: &str, body: impl Into<Vec<u8>>) -> Resp {
397 let body = body.into();
398 let len = body.len();
399 Response::new(
400 StatusCode(status),
401 vec![
402 header("Content-Type", content_type),
403 header("Server", &format!("UPnP/1.0 TinyRender/{}", crate::VERSION)),
404 ],
405 Cursor::new(body),
406 Some(len),
407 None,
408 )
409}
410
411fn xml_ok(body: impl Into<Vec<u8>>) -> Resp {
412 respond(200, "text/xml; charset=utf-8", body)
413}
414
415fn text(status: u16, body: &str) -> Resp {
416 respond(status, "text/plain; charset=utf-8", body)
417}
418
419impl Render {
420 fn handle(&mut self, mut request: Request) {
421 let method = request.method().clone();
422 let url = request.url().to_string();
423 let path = url.split('?').next().unwrap_or_default();
424 let is_get = matches!(method, Method::Get | Method::Head);
425
426 let mut exit_after = false;
427 let resp = match path {
428 "/description.xml" if is_get => {
429 xml_ok(xml::description(&self.friendly_name, &self.uuid))
430 }
431 "/dlna/AVTransport.xml" if is_get => xml_ok(xml::AVTRANSPORT_SCPD),
432 "/dlna/RenderingControl.xml" if is_get => xml_ok(xml::RENDERING_CONTROL_SCPD),
433 "/dlna/ConnectionManager.xml" if is_get => xml_ok(xml::CONNECTION_MANAGER_SCPD),
434 "/AVTransport/control" if method == Method::Post => {
435 let (resp, exit) = self.control(&mut request);
436 exit_after = exit;
437 resp
438 }
439 "/description.xml"
440 | "/dlna/AVTransport.xml"
441 | "/dlna/RenderingControl.xml"
442 | "/dlna/ConnectionManager.xml"
443 | "/AVTransport/control" => text(405, "Method Not Allowed"),
444 _ => text(404, "Not Found"),
445 };
446
447 let remote = request
448 .remote_addr()
449 .map(|a| a.ip().to_string())
450 .unwrap_or_default();
451 log::info!(target: HTTP_LOG, "{remote} \"{method} {url}\" {}", resp.status_code().0);
452 if let Err(e) = request.respond(resp) {
453 debug!("failed to send response: {e}");
454 }
455
456 if exit_after {
457 config::unregister_render(&self.uuid);
458 if self.player.backend == Backend::Mpv {
459 info!("stopping the recorder as a whole; mpv process is left open");
460 process::exit(0);
461 }
462 if !self.finishing {
465 self.finishing = true;
466 info!("sender is done; waiting for ffmpeg to finish (Ctrl-C to stop it now)");
467 }
468 }
469 }
470
471 fn control(&mut self, request: &mut Request) -> (Resp, bool) {
474 let mut raw = Vec::new();
475 if let Err(e) = request.as_reader().take(MAX_BODY).read_to_end(&mut raw) {
476 error!("failed to read request body: {e}");
477 return (text(400, "Bad Request"), false);
478 }
479 let body = String::from_utf8_lossy(&raw);
480 let soap_action = request
481 .headers()
482 .iter()
483 .find(|h| h.field.equiv("SOAPACTION"))
484 .map(|h| h.value.as_str().to_string());
485 let recording_done = self.started_at.is_some() && self.dump_to.is_some();
486
487 match soap_action_name(soap_action.as_deref(), &body).as_deref() {
488 Some("SetAVTransportURI") => match parse_set_av_transport_uri(&body) {
489 Ok(media) => {
490 debug!("Action: SetAV: {}", media.uri);
491 debug!("Title: {} SRT: {}", media.title, media.srt);
492 self.media = media;
493 (xml_ok(xml::SET_AV_TRANSPORT_URI_RESPONSE), false)
494 }
495 Err(e) => {
496 error!("bad SetAVTransportURI request: {e}");
497 (text(400, "Bad Request"), false)
498 }
499 },
500 Some("Play") => {
501 if recording_done {
502 return (xml_ok(xml::PLAY_RESPONSE), true);
503 }
504 debug!("action: Play: {}", self.media.uri);
505 if self.media.uri.is_empty() {
506 error!("Play requested without a media URI");
507 return (text(500, "No Media"), false);
508 }
509 if let Err(e) = self.player.play(&self.media, self.dump_to.as_deref()) {
510 error!("failed to run {}: {e}", self.player.backend.program());
511 return (text(500, "Failed To Start Player"), false);
512 }
513 self.started_at = Some(Instant::now());
514 (xml_ok(xml::PLAY_RESPONSE), false)
515 }
516 Some("GetPositionInfo") => {
517 debug!("action: GetPositionInfo");
519 let secs = self.started_at.map_or(0, |t| t.elapsed().as_secs());
520 (
521 xml_ok(xml::position_info_response(&track_time(secs))),
522 false,
523 )
524 }
525 Some("GetTransportInfo") => {
526 debug!("action: GetTransportInfo");
527 (xml_ok(xml::TRANSPORT_INFO_RESPONSE), false)
528 }
529 Some("Stop") => {
530 debug!("stopping");
531 if recording_done {
532 return (xml_ok(xml::STOP_RESPONSE), true);
533 }
534 self.media = Media::default();
535 self.started_at = None;
536 self.player.stop();
537 (xml_ok(xml::STOP_RESPONSE), false)
538 }
539 Some("Seek") => {
540 debug!("action: Seek");
541 (xml_ok(xml::SEEK_RESPONSE), false)
542 }
543 _ => {
544 error!("action not supported: {body}");
545 (text(500, "Action Not Supported"), false)
546 }
547 }
548 }
549}
550
551fn track_time(secs: u64) -> String {
552 format!("{}:{:02}:{:02}", secs / 3600, secs % 3600 / 60, secs % 60)
553}
554
555fn soap_action_name(header: Option<&str>, body: &str) -> Option<String> {
558 if let Some(name) = header
559 .and_then(|h| h.trim().trim_matches('"').rsplit_once('#'))
560 .map(|(_, name)| name.trim())
561 .filter(|name| !name.is_empty())
562 {
563 return Some(name.to_string());
564 }
565 let doc = roxmltree::Document::parse(body.trim()).ok()?;
566 let soap_body = doc.descendants().find(|n| n.has_tag_name("Body"))?;
567 let action = soap_body.children().find(|n| n.is_element())?;
568 Some(action.tag_name().name().to_string())
569}
570
571fn element_text<'a>(doc: &'a roxmltree::Document, name: &str) -> &'a str {
572 doc.descendants()
573 .find(|n| n.tag_name().name() == name)
574 .and_then(|n| n.text())
575 .unwrap_or_default()
576}
577
578fn parse_set_av_transport_uri(body: &str) -> Result<Media, roxmltree::Error> {
579 let doc = roxmltree::Document::parse(body.trim())?;
580 let uri = element_text(&doc, "CurrentURI").trim().to_string();
581 let metadata = element_text(&doc, "CurrentURIMetaData");
582 if metadata.trim().is_empty() {
583 debug!("no metadata");
584 return Ok(Media {
585 uri,
586 ..Media::default()
587 });
588 }
589 let (title, srt) = parse_didl(metadata);
590 Ok(Media { uri, srt, title })
591}
592
593const NS_DIDL: &str = "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/";
594const NS_DC: &str = "http://purl.org/dc/elements/1.1/";
595const NS_SEC: &str = "http://www.sec.co.kr/";
596const NS_PV: &str = "http://www.pv.com/pvns/";
597
598fn parse_didl(metadata: &str) -> (String, String) {
602 if let Ok(doc) = roxmltree::Document::parse(metadata.trim()) {
603 return didl_fields(&doc);
604 }
605 let unescaped = xml::unescape(metadata);
606 let normalized = unescaped.replace('&', "&");
607 match roxmltree::Document::parse(normalized.trim()) {
608 Ok(doc) => didl_fields(&doc),
609 Err(e) => {
610 debug!("metadata is not valid XML ({e}), searching for title only");
611 (title_by_search(&unescaped), String::new())
612 }
613 }
614}
615
616fn didl_fields(doc: &roxmltree::Document) -> (String, String) {
618 let find = |ns: &str, name: &str| {
619 doc.descendants()
620 .filter(|n| n.has_tag_name((ns, name)))
621 .find_map(|n| n.text().map(str::trim).filter(|t| !t.is_empty()))
622 .map(str::to_string)
623 };
624 let title = find(NS_DC, "title").unwrap_or_default();
625 let srt = find(NS_SEC, "CaptionInfoEx")
626 .or_else(|| find(NS_SEC, "CaptionInfo"))
627 .or_else(|| find(NS_PV, "subtitleFileUri"))
628 .or_else(|| {
629 doc.descendants()
630 .filter(|n| n.has_tag_name((NS_DIDL, "res")))
631 .filter(|n| {
632 n.attribute("type") == Some("text/subtitle")
633 || n.attribute("protocolInfo")
634 .is_some_and(|p| p.contains("text/srt"))
635 })
636 .find_map(|n| n.text().map(str::trim).filter(|t| !t.is_empty()))
637 .map(str::to_string)
638 })
639 .unwrap_or_default();
640 (title, srt)
641}
642
643fn title_by_search(metadata: &str) -> String {
644 let Some(start) = metadata.find("<dc:title>") else {
645 return String::new();
646 };
647 let rest = &metadata[start + "<dc:title>".len()..];
648 rest.find("</dc:title>")
649 .map_or("", |end| &rest[..end])
650 .to_string()
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656
657 #[test]
658 fn friendly_names() {
659 assert_eq!(friendly_name(None, false), "Tiny Render");
660 assert_eq!(friendly_name(None, true), "Tiny Recorder");
661 assert_eq!(friendly_name(Some("TV"), true), "TV (Recorder)");
662 }
663
664 #[test]
665 fn backend_choice() {
666 use Backend::*;
667 assert_eq!(pick_backend(false, false, false, false), Mpv);
669 assert_eq!(pick_backend(true, false, true, true), Mpv);
670 assert_eq!(pick_backend(true, true, true, true), Ffmpeg);
671 assert_eq!(pick_backend(true, false, false, true), Ffmpeg);
672 assert_eq!(pick_backend(true, false, true, false), Ffmpeg);
673 }
674
675 #[test]
676 fn ffmpeg_args() {
677 let args = |path: &str| {
678 let cmd = ffmpeg_command("http://h/a.m3u8", Path::new(path), false);
679 cmd.get_args()
680 .map(|a| a.to_string_lossy().into_owned())
681 .collect::<Vec<_>>()
682 .join(" ")
683 };
684 let mp4 = args("/r/out.MP4");
685 assert!(mp4.contains("-i http://h/a.m3u8 -c copy"));
686 assert!(mp4.contains("-movflags"));
687 assert!(mp4.ends_with("-n /r/out.MP4"));
688 assert!(!args("/r/out.ts").contains("-movflags"));
689 assert!(is_http("HTTPS://h/x") && !is_http("file:///etc/passwd"));
690 }
691
692 #[test]
693 fn track_time_format() {
694 assert_eq!(track_time(0), "0:00:00");
695 assert_eq!(track_time(3725), "1:02:05");
696 }
697
698 #[test]
699 fn action_from_header_or_body() {
700 let hdr = Some("\"urn:schemas-upnp-org:service:AVTransport:1#Play\"");
701 assert_eq!(soap_action_name(hdr, "").as_deref(), Some("Play"));
702 assert_eq!(soap_action_name(None, xml::STOP).as_deref(), Some("Stop"));
703 let body = xml::seek("00:01:00")
704 .replace("u:", "x:")
705 .replace("xmlns:u", "xmlns:x");
706 assert_eq!(soap_action_name(None, &body).as_deref(), Some("Seek"));
707 assert_eq!(soap_action_name(None, "garbage"), None);
708 }
709
710 #[test]
711 fn set_av_roundtrip_with_subtitle() {
712 let video = "http://10.0.0.2:50001/videos/a%20b.mp4?x=1&y=2";
713 let srt = "http://10.0.0.2:50001/videos/a%20b.srt";
714 let meta = xml::didl_metadata("A & B", video, Some(srt));
715 let media = parse_set_av_transport_uri(&xml::set_av_transport_uri(video, &meta)).unwrap();
716 assert_eq!(
717 media,
718 Media {
719 uri: video.into(),
720 srt: srt.into(),
721 title: "A & B".into()
722 }
723 );
724 }
725
726 #[test]
727 fn markup_in_title_keeps_subtitle() {
728 let meta = xml::didl_metadata("Custom <T>", "http://h/v.mp4", Some("http://h/v.srt"));
729 assert_eq!(
730 parse_didl(&meta),
731 ("Custom <T>".into(), "http://h/v.srt".into())
732 );
733 }
734
735 #[test]
736 fn double_escaped_metadata() {
737 let meta = xml::escape(&xml::didl_metadata(
738 "T",
739 "http://h/v?a=1&b=2",
740 Some("http://h/v.srt"),
741 ));
742 assert_eq!(parse_didl(&meta), ("T".into(), "http://h/v.srt".into()));
743 }
744
745 #[test]
746 fn set_av_without_metadata() {
747 let body = xml::set_av_transport_uri("http://h/v.m3u8", "");
748 let media = parse_set_av_transport_uri(&body).unwrap();
749 assert_eq!(media.uri, "http://h/v.m3u8");
750 assert!(media.title.is_empty() && media.srt.is_empty());
751 }
752
753 #[test]
754 fn broken_metadata_falls_back_to_title_search() {
755 let meta = "<DIDL-Lite><item><dc:title>Live 1</dc:title><upnp:class>x</upnp:class></item></DIDL-Lite>";
757 assert_eq!(parse_didl(meta), ("Live 1".into(), String::new()));
758 }
759
760 #[test]
761 fn subtitle_from_res_type() {
762 let meta = format!(
763 r#"<DIDL-Lite xmlns="{NS_DIDL}" xmlns:dc="{NS_DC}"><item><dc:title>T</dc:title>
764 <res protocolInfo="http-get:*:video/mp4:*">http://h/v.mp4</res>
765 <res type="text/subtitle">http://h/v.srt</res></item></DIDL-Lite>"#
766 );
767 assert_eq!(parse_didl(&meta), ("T".into(), "http://h/v.srt".into()));
768 }
769}