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/// PID of the running mpv (0 if none), for the signal handler.
95static MPV_PID: AtomicU32 = AtomicU32::new(0);
96
97/// Ask a running mpv to quit.
98fn stop_mpv() {
99    let pid = MPV_PID.swap(0, Ordering::SeqCst);
100    if pid != 0 {
101        kill_pid(pid);
102    }
103}
104
105/// SIGTERM lets mpv and ffmpeg shut down cleanly (and finish a recording).
106#[cfg(unix)]
107fn kill_pid(pid: u32) {
108    unsafe {
109        libc::kill(pid as libc::pid_t, libc::SIGTERM);
110    }
111}
112
113/// Elsewhere children share our console and get the Ctrl-C themselves.
114#[cfg(not(unix))]
115fn kill_pid(_pid: u32) {}
116
117/// Ask a running ffmpeg recorder to finish the file and quit. False if
118/// there's none.
119#[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/// Elsewhere ffmpeg shares our console and gets the Ctrl-C itself.
131#[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            // Let ffmpeg finish the file; we exit once it's done. Another
205            // signal exits right away.
206            if stop_ffmpeg() {
207                info!("finishing the recording...");
208                return;
209            }
210            // Don't leave mpv playing when the render is gone (a terminal
211            // Ctrl-C reaches it anyway, but a plain `kill` doesn't). A
212            // recorder's mpv is left alone so its file gets finished.
213            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    /// The render's UUID, to unregister when the ffmpeg recorder exits.
265    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        // `--` keeps a hostile URI from being parsed as an mpv option.
291        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    /// Record with ffmpeg. A recorder records one stream, so when ffmpeg
300    /// exits (stream ended, error, or we stopped it), the whole process exits.
301    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            // Its own process group: a terminal Ctrl-C reaches only us, and we
312            // stop ffmpeg ourselves (once) so it can finish the file.
313            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            // ffmpeg exits non-zero after we signal it, even when all is well.
327            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    /// Whether mpv (or the ffmpeg recorder) is still running. An mpv that
352    /// exited (end of file, or its window was closed) gets reaped here.
353    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        // Reap in the background so the old mpv doesn't linger as a zombie.
382        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
391/// Container formats whose files need a trailer; for these ffmpeg writes a
392/// fragmented file instead, which stays playable if ffmpeg gets killed.
393fn 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        // Network only: a sender's playlist mustn't read local files.
404        .args([
405            "-protocol_whitelist",
406            "http,https,tcp,tls,crypto,httpproxy,data",
407        ])
408        .arg("-i")
409        .arg(uri)
410        // Best video + best audio, copied as-is. Subtitle/data streams
411        // (e.g. WebVTT, ID3 in HLS) often can't be copied into the target.
412        .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    /// The ffmpeg recorder is done serving and waits for ffmpeg to finish.
438    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            // Keep answering requests (and exit when ffmpeg does) so the file
514            // gets finished, e.g. for a VOD still downloading.
515            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    /// The AVTransport state, from whether the player is still running.
523    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    /// Handle an AVTransport SOAP action. The flag asks the caller to exit
534    /// after responding: a recorder records one stream, then quits.
535    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                // Approximation: time since Play; mpv isn't queried.
580                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
622/// The action name, from the SOAPACTION header (`"urn:...:AVTransport:1#Play"`)
623/// or else from the first element inside the SOAP body.
624fn 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
665/// Title and subtitle URL from DIDL-Lite metadata. Senders are sloppy here
666/// (double-escaped XML, raw `&` in URLs), so invalid metadata is normalized
667/// and retried, and as a last resort the title is found by plain text search.
668fn 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('&', "&amp;");
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
683/// `(title, subtitle URL)` from a parsed DIDL-Lite document.
684fn 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        // pick_backend(recording, headless, display, has_mpv)
735        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        // undeclared `upnp:` prefix makes the DIDL invalid
823        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}