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