Skip to main content

tiny_dlna/
render.rs

1//! `tiny-render`: a DLNA media renderer that plays streams with mpv, or
2//! records them with mpv or ffmpeg (ffmpeg needs no display).
3
4use 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    /// Enable HTTP and SSDP server logs
28    #[arg(long)]
29    http_logs: bool,
30    /// Enable debug logs
31    #[arg(short, long)]
32    verbose: bool,
33    /// Specify render name
34    #[arg(long)]
35    name: Option<String>,
36    /// Server port
37    #[arg(long)]
38    port: Option<u16>,
39    /// Dump streaming to a file (stream recording)
40    #[arg(long, value_name = "FILE")]
41    dump_to: Option<PathBuf>,
42    /// Record with ffmpeg instead of mpv, without a window (the default when
43    /// there is no display or mpv is missing)
44    #[arg(long, requires = "dump_to")]
45    headless: bool,
46}
47
48/// The program that plays or records the stream.
49#[derive(Clone, Copy, Debug, PartialEq)]
50enum Backend {
51    /// Plays in a window; records while playing with `--stream-record`.
52    Mpv,
53    /// Copies the stream into the file without decoding it. Recording only.
54    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
74/// Whether a GUI can be shown. Only X11/Wayland systems can lack one.
75fn 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
90/// PID of the running ffmpeg recorder (0 if none), for the signal handler.
91static FFMPEG_PID: AtomicU32 = AtomicU32::new(0);
92/// Set once we asked ffmpeg to stop.
93static FFMPEG_STOPPING: AtomicBool = AtomicBool::new(false);
94
95/// Ask a running ffmpeg recorder to finish the file and quit. False if
96/// there's none.
97#[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/// Elsewhere ffmpeg shares our console and gets the Ctrl-C itself.
111#[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            // Let ffmpeg finish the file; we exit once it's done. Another
184            // signal exits right away.
185            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    /// The render's UUID, to unregister when the ffmpeg recorder exits.
238    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        // `--` keeps a hostile URI from being parsed as an mpv option.
264        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    /// Record with ffmpeg. A recorder records one stream, so when ffmpeg
271    /// exits (stream ended, error, or we stopped it), the whole process exits.
272    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            // Its own process group: a terminal Ctrl-C reaches only us, and we
283            // stop ffmpeg ourselves (once) so it can finish the file.
284            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            // ffmpeg exits non-zero after we signal it, even when all is well.
298            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        // Reap in the background so the old mpv doesn't linger as a zombie.
328        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
337/// Container formats whose files need a trailer; for these ffmpeg writes a
338/// fragmented file instead, which stays playable if ffmpeg gets killed.
339fn 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        // Network only: a sender's playlist mustn't read local files.
350        .args([
351            "-protocol_whitelist",
352            "http,https,tcp,tls,crypto,httpproxy,data",
353        ])
354        .arg("-i")
355        .arg(uri)
356        // Best video + best audio, copied as-is. Subtitle/data streams
357        // (e.g. WebVTT, ID3 in HLS) often can't be copied into the target.
358        .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    // SIGTERM lets mpv shut down cleanly (and finish a stream recording).
369    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    /// The ffmpeg recorder is done serving and waits for ffmpeg to finish.
387    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            // Keep answering requests (and exit when ffmpeg does) so the file
463            // gets finished, e.g. for a VOD still downloading.
464            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    /// Handle an AVTransport SOAP action. The flag asks the caller to exit
472    /// after responding: a recorder records one stream, then quits.
473    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                // Approximation: time since Play; mpv isn't queried.
518                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
555/// The action name, from the SOAPACTION header (`"urn:...:AVTransport:1#Play"`)
556/// or else from the first element inside the SOAP body.
557fn 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
598/// Title and subtitle URL from DIDL-Lite metadata. Senders are sloppy here
599/// (double-escaped XML, raw `&` in URLs), so invalid metadata is normalized
600/// and retried, and as a last resort the title is found by plain text search.
601fn 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('&', "&amp;");
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
616/// `(title, subtitle URL)` from a parsed DIDL-Lite document.
617fn 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        // pick_backend(recording, headless, display, has_mpv)
668        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        // undeclared `upnp:` prefix makes the DIDL invalid
756        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}