Skip to main content

tiny_dlna/
render.rs

1//! `tiny-render`: a DLNA media renderer that plays streams with mpv.
2
3use std::io::{Cursor, Read};
4use std::path::{Path, PathBuf};
5use std::process::{self, Child, Command, Stdio};
6use std::thread;
7use std::time::Instant;
8
9use clap::Parser;
10use log::{LevelFilter, debug, error, info};
11use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
12
13use crate::{HTTP_LOG, config, ssdp, xml};
14
15const PORT_DEFAULT: u16 = 59876;
16const MAX_BODY: u64 = 1 << 20;
17
18#[derive(Parser)]
19#[command(
20    name = "tiny-render",
21    version,
22    about = "A tiny DLNA media renderer (plays with mpv)"
23)]
24struct Args {
25    /// Enable HTTP and SSDP server logs
26    #[arg(long)]
27    http_logs: bool,
28    /// Enable debug logs
29    #[arg(short, long)]
30    verbose: bool,
31    /// Specify render name
32    #[arg(long)]
33    name: Option<String>,
34    /// Server port
35    #[arg(long)]
36    port: Option<u16>,
37    /// Dump streaming to a file (stream recording)
38    #[arg(long, value_name = "FILE")]
39    dump_to: Option<PathBuf>,
40}
41
42fn friendly_name(name: Option<&str>, recording: bool) -> String {
43    match (name, recording) {
44        (None, false) => "Tiny Render".into(),
45        (None, true) => "Tiny Recorder".into(),
46        (Some(n), false) => n.into(),
47        (Some(n), true) => format!("{n} (Recorder)"),
48    }
49}
50
51pub fn main() {
52    let args = Args::parse();
53
54    let app_level = if args.verbose {
55        LevelFilter::Debug
56    } else {
57        LevelFilter::Info
58    };
59    let net_level = if args.http_logs {
60        LevelFilter::Debug
61    } else {
62        LevelFilter::Error
63    };
64    crate::init_logging(&[
65        ("tiny_dlna", app_level),
66        ("tiny_dlna::ssdp", net_level),
67        (HTTP_LOG, net_level),
68    ]);
69
70    let dump_to = args.dump_to.as_deref().map(absolute);
71    if let Some(path) = dump_to.as_ref().filter(|p| p.exists()) {
72        error!("target file exists: {}", path.display());
73        process::exit(1);
74    }
75
76    let port = match args.port {
77        Some(p) if p != 0 => p,
78        _ => PORT_DEFAULT + u16::from(dump_to.is_some()),
79    };
80    let server = match Server::http(("0.0.0.0", port)) {
81        Ok(s) => s,
82        Err(e) => {
83            error!("failed to listen on port {port}: {e}");
84            process::exit(1);
85        }
86    };
87
88    let name = friendly_name(args.name.as_deref(), dump_to.is_some());
89    let uuid = config::render_uuid(port);
90    {
91        let uuid = uuid.clone();
92        ctrlc::set_handler(move || {
93            debug!("got killing signal");
94            config::unregister_render(&uuid);
95            process::exit(0);
96        })
97        .expect("failed to install signal handler");
98    }
99    config::register_render(&uuid, &name, port);
100
101    thread::spawn(ssdp::serve_forever);
102
103    info!("Starting DLNA Receiver: {name}");
104    if let Some(path) = &dump_to {
105        info!("Recording stream to {}", path.display());
106    }
107
108    let mut render = Render {
109        friendly_name: name,
110        uuid,
111        dump_to,
112        media: Media::default(),
113        started_at: None,
114        player: Player::default(),
115    };
116    for request in server.incoming_requests() {
117        render.handle(request);
118    }
119}
120
121fn absolute(path: &Path) -> PathBuf {
122    std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf())
123}
124
125#[derive(Default, Debug, PartialEq)]
126struct Media {
127    uri: String,
128    srt: String,
129    title: String,
130}
131
132#[derive(Default)]
133struct Player {
134    child: Option<Child>,
135}
136
137impl Player {
138    fn play(&mut self, media: &Media, dump_to: Option<&Path>) -> std::io::Result<()> {
139        self.stop();
140        let mut cmd = Command::new("mpv");
141        cmd.args(["--quiet", "--screen=1", "--no-terminal"]);
142        if let Some(path) = dump_to {
143            cmd.arg(format!("--stream-record={}", path.display()));
144        }
145        if !media.title.is_empty() {
146            cmd.arg(format!("--title={}", media.title));
147        }
148        if !media.srt.is_empty() {
149            cmd.arg(format!("--sub-file={}", media.srt));
150        }
151        // `--` keeps a hostile URI from being parsed as an mpv option.
152        cmd.arg("--").arg(&media.uri);
153        debug!("running: {cmd:?}");
154        self.child = Some(cmd.stdin(Stdio::null()).stderr(Stdio::null()).spawn()?);
155        Ok(())
156    }
157
158    fn stop(&mut self) {
159        let Some(mut child) = self.child.take() else {
160            return;
161        };
162        terminate(&mut child);
163        // Reap in the background so the old mpv doesn't linger as a zombie.
164        thread::spawn(move || child.wait());
165    }
166}
167
168#[cfg(unix)]
169fn terminate(child: &mut Child) {
170    // SIGTERM lets mpv shut down cleanly (and finish a stream recording).
171    unsafe {
172        libc::kill(child.id() as libc::pid_t, libc::SIGTERM);
173    }
174}
175
176#[cfg(not(unix))]
177fn terminate(child: &mut Child) {
178    let _ = child.kill();
179}
180
181struct Render {
182    friendly_name: String,
183    uuid: String,
184    dump_to: Option<PathBuf>,
185    media: Media,
186    started_at: Option<Instant>,
187    player: Player,
188}
189
190type Resp = Response<Cursor<Vec<u8>>>;
191
192fn header(name: &str, value: &str) -> Header {
193    Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid header")
194}
195
196fn respond(status: u16, content_type: &str, body: impl Into<Vec<u8>>) -> Resp {
197    let body = body.into();
198    let len = body.len();
199    Response::new(
200        StatusCode(status),
201        vec![
202            header("Content-Type", content_type),
203            header("Server", &format!("UPnP/1.0 TinyRender/{}", crate::VERSION)),
204        ],
205        Cursor::new(body),
206        Some(len),
207        None,
208    )
209}
210
211fn xml_ok(body: impl Into<Vec<u8>>) -> Resp {
212    respond(200, "text/xml; charset=utf-8", body)
213}
214
215fn text(status: u16, body: &str) -> Resp {
216    respond(status, "text/plain; charset=utf-8", body)
217}
218
219impl Render {
220    fn handle(&mut self, mut request: Request) {
221        let method = request.method().clone();
222        let url = request.url().to_string();
223        let path = url.split('?').next().unwrap_or_default();
224        let is_get = matches!(method, Method::Get | Method::Head);
225
226        let mut exit_after = false;
227        let resp = match path {
228            "/description.xml" if is_get => {
229                xml_ok(xml::description(&self.friendly_name, &self.uuid))
230            }
231            "/dlna/AVTransport.xml" if is_get => xml_ok(xml::AVTRANSPORT_SCPD),
232            "/dlna/RenderingControl.xml" if is_get => xml_ok(xml::RENDERING_CONTROL_SCPD),
233            "/dlna/ConnectionManager.xml" if is_get => xml_ok(xml::CONNECTION_MANAGER_SCPD),
234            "/AVTransport/control" if method == Method::Post => {
235                let (resp, exit) = self.control(&mut request);
236                exit_after = exit;
237                resp
238            }
239            "/description.xml"
240            | "/dlna/AVTransport.xml"
241            | "/dlna/RenderingControl.xml"
242            | "/dlna/ConnectionManager.xml"
243            | "/AVTransport/control" => text(405, "Method Not Allowed"),
244            _ => text(404, "Not Found"),
245        };
246
247        let remote = request
248            .remote_addr()
249            .map(|a| a.ip().to_string())
250            .unwrap_or_default();
251        log::info!(target: HTTP_LOG, "{remote} \"{method} {url}\" {}", resp.status_code().0);
252        if let Err(e) = request.respond(resp) {
253            debug!("failed to send response: {e}");
254        }
255
256        if exit_after {
257            config::unregister_render(&self.uuid);
258            info!("stopping the recorder as a whole; mpv process is left open");
259            process::exit(0);
260        }
261    }
262
263    /// Handle an AVTransport SOAP action. The flag asks the caller to exit
264    /// after responding: a recorder records one stream, then quits.
265    fn control(&mut self, request: &mut Request) -> (Resp, bool) {
266        let mut raw = Vec::new();
267        if let Err(e) = request.as_reader().take(MAX_BODY).read_to_end(&mut raw) {
268            error!("failed to read request body: {e}");
269            return (text(400, "Bad Request"), false);
270        }
271        let body = String::from_utf8_lossy(&raw);
272        let soap_action = request
273            .headers()
274            .iter()
275            .find(|h| h.field.equiv("SOAPACTION"))
276            .map(|h| h.value.as_str().to_string());
277        let recording_done = self.started_at.is_some() && self.dump_to.is_some();
278
279        match soap_action_name(soap_action.as_deref(), &body).as_deref() {
280            Some("SetAVTransportURI") => match parse_set_av_transport_uri(&body) {
281                Ok(media) => {
282                    debug!("Action: SetAV: {}", media.uri);
283                    debug!("Title: {} SRT: {}", media.title, media.srt);
284                    self.media = media;
285                    (xml_ok(xml::SET_AV_TRANSPORT_URI_RESPONSE), false)
286                }
287                Err(e) => {
288                    error!("bad SetAVTransportURI request: {e}");
289                    (text(400, "Bad Request"), false)
290                }
291            },
292            Some("Play") => {
293                if recording_done {
294                    return (xml_ok(xml::PLAY_RESPONSE), true);
295                }
296                debug!("action: Play: {}", self.media.uri);
297                if self.media.uri.is_empty() {
298                    error!("Play requested without a media URI");
299                    return (text(500, "No Media"), false);
300                }
301                if let Err(e) = self.player.play(&self.media, self.dump_to.as_deref()) {
302                    error!("failed to run mpv: {e}");
303                    return (text(500, "Failed To Start Player"), false);
304                }
305                self.started_at = Some(Instant::now());
306                (xml_ok(xml::PLAY_RESPONSE), false)
307            }
308            Some("GetPositionInfo") => {
309                // Approximation: time since Play; mpv isn't queried.
310                debug!("action: GetPositionInfo");
311                let secs = self.started_at.map_or(0, |t| t.elapsed().as_secs());
312                (
313                    xml_ok(xml::position_info_response(&track_time(secs))),
314                    false,
315                )
316            }
317            Some("GetTransportInfo") => {
318                debug!("action: GetTransportInfo");
319                (xml_ok(xml::TRANSPORT_INFO_RESPONSE), false)
320            }
321            Some("Stop") => {
322                debug!("stopping");
323                if recording_done {
324                    return (xml_ok(xml::STOP_RESPONSE), true);
325                }
326                self.media = Media::default();
327                self.started_at = None;
328                self.player.stop();
329                (xml_ok(xml::STOP_RESPONSE), false)
330            }
331            Some("Seek") => {
332                debug!("action: Seek");
333                (xml_ok(xml::SEEK_RESPONSE), false)
334            }
335            _ => {
336                error!("action not supported: {body}");
337                (text(500, "Action Not Supported"), false)
338            }
339        }
340    }
341}
342
343fn track_time(secs: u64) -> String {
344    format!("{}:{:02}:{:02}", secs / 3600, secs % 3600 / 60, secs % 60)
345}
346
347/// The action name, from the SOAPACTION header (`"urn:...:AVTransport:1#Play"`)
348/// or else from the first element inside the SOAP body.
349fn soap_action_name(header: Option<&str>, body: &str) -> Option<String> {
350    if let Some(name) = header
351        .and_then(|h| h.trim().trim_matches('"').rsplit_once('#'))
352        .map(|(_, name)| name.trim())
353        .filter(|name| !name.is_empty())
354    {
355        return Some(name.to_string());
356    }
357    let doc = roxmltree::Document::parse(body.trim()).ok()?;
358    let soap_body = doc.descendants().find(|n| n.has_tag_name("Body"))?;
359    let action = soap_body.children().find(|n| n.is_element())?;
360    Some(action.tag_name().name().to_string())
361}
362
363fn element_text<'a>(doc: &'a roxmltree::Document, name: &str) -> &'a str {
364    doc.descendants()
365        .find(|n| n.tag_name().name() == name)
366        .and_then(|n| n.text())
367        .unwrap_or_default()
368}
369
370fn parse_set_av_transport_uri(body: &str) -> Result<Media, roxmltree::Error> {
371    let doc = roxmltree::Document::parse(body.trim())?;
372    let uri = element_text(&doc, "CurrentURI").trim().to_string();
373    let metadata = element_text(&doc, "CurrentURIMetaData");
374    if metadata.trim().is_empty() {
375        debug!("no metadata");
376        return Ok(Media {
377            uri,
378            ..Media::default()
379        });
380    }
381    let (title, srt) = parse_didl(metadata);
382    Ok(Media { uri, srt, title })
383}
384
385const NS_DIDL: &str = "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/";
386const NS_DC: &str = "http://purl.org/dc/elements/1.1/";
387const NS_SEC: &str = "http://www.sec.co.kr/";
388const NS_PV: &str = "http://www.pv.com/pvns/";
389
390/// Title and subtitle URL from DIDL-Lite metadata. Senders are sloppy here
391/// (double-escaped XML, raw `&` in URLs), so invalid metadata is normalized
392/// and retried, and as a last resort the title is found by plain text search.
393fn parse_didl(metadata: &str) -> (String, String) {
394    if let Ok(doc) = roxmltree::Document::parse(metadata.trim()) {
395        return didl_fields(&doc);
396    }
397    let unescaped = xml::unescape(metadata);
398    let normalized = unescaped.replace('&', "&amp;");
399    match roxmltree::Document::parse(normalized.trim()) {
400        Ok(doc) => didl_fields(&doc),
401        Err(e) => {
402            debug!("metadata is not valid XML ({e}), searching for title only");
403            (title_by_search(&unescaped), String::new())
404        }
405    }
406}
407
408/// `(title, subtitle URL)` from a parsed DIDL-Lite document.
409fn didl_fields(doc: &roxmltree::Document) -> (String, String) {
410    let find = |ns: &str, name: &str| {
411        doc.descendants()
412            .filter(|n| n.has_tag_name((ns, name)))
413            .find_map(|n| n.text().map(str::trim).filter(|t| !t.is_empty()))
414            .map(str::to_string)
415    };
416    let title = find(NS_DC, "title").unwrap_or_default();
417    let srt = find(NS_SEC, "CaptionInfoEx")
418        .or_else(|| find(NS_SEC, "CaptionInfo"))
419        .or_else(|| find(NS_PV, "subtitleFileUri"))
420        .or_else(|| {
421            doc.descendants()
422                .filter(|n| n.has_tag_name((NS_DIDL, "res")))
423                .filter(|n| {
424                    n.attribute("type") == Some("text/subtitle")
425                        || n.attribute("protocolInfo")
426                            .is_some_and(|p| p.contains("text/srt"))
427                })
428                .find_map(|n| n.text().map(str::trim).filter(|t| !t.is_empty()))
429                .map(str::to_string)
430        })
431        .unwrap_or_default();
432    (title, srt)
433}
434
435fn title_by_search(metadata: &str) -> String {
436    let Some(start) = metadata.find("<dc:title>") else {
437        return String::new();
438    };
439    let rest = &metadata[start + "<dc:title>".len()..];
440    rest.find("</dc:title>")
441        .map_or("", |end| &rest[..end])
442        .to_string()
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn friendly_names() {
451        assert_eq!(friendly_name(None, false), "Tiny Render");
452        assert_eq!(friendly_name(None, true), "Tiny Recorder");
453        assert_eq!(friendly_name(Some("TV"), true), "TV (Recorder)");
454    }
455
456    #[test]
457    fn track_time_format() {
458        assert_eq!(track_time(0), "0:00:00");
459        assert_eq!(track_time(3725), "1:02:05");
460    }
461
462    #[test]
463    fn action_from_header_or_body() {
464        let hdr = Some("\"urn:schemas-upnp-org:service:AVTransport:1#Play\"");
465        assert_eq!(soap_action_name(hdr, "").as_deref(), Some("Play"));
466        assert_eq!(soap_action_name(None, xml::STOP).as_deref(), Some("Stop"));
467        let body = xml::seek("00:01:00")
468            .replace("u:", "x:")
469            .replace("xmlns:u", "xmlns:x");
470        assert_eq!(soap_action_name(None, &body).as_deref(), Some("Seek"));
471        assert_eq!(soap_action_name(None, "garbage"), None);
472    }
473
474    #[test]
475    fn set_av_roundtrip_with_subtitle() {
476        let video = "http://10.0.0.2:50001/videos/a%20b.mp4?x=1&y=2";
477        let srt = "http://10.0.0.2:50001/videos/a%20b.srt";
478        let meta = xml::didl_metadata("A & B", video, Some(srt));
479        let media = parse_set_av_transport_uri(&xml::set_av_transport_uri(video, &meta)).unwrap();
480        assert_eq!(
481            media,
482            Media {
483                uri: video.into(),
484                srt: srt.into(),
485                title: "A & B".into()
486            }
487        );
488    }
489
490    #[test]
491    fn markup_in_title_keeps_subtitle() {
492        let meta = xml::didl_metadata("Custom <T>", "http://h/v.mp4", Some("http://h/v.srt"));
493        assert_eq!(
494            parse_didl(&meta),
495            ("Custom <T>".into(), "http://h/v.srt".into())
496        );
497    }
498
499    #[test]
500    fn double_escaped_metadata() {
501        let meta = xml::escape(&xml::didl_metadata(
502            "T",
503            "http://h/v?a=1&b=2",
504            Some("http://h/v.srt"),
505        ));
506        assert_eq!(parse_didl(&meta), ("T".into(), "http://h/v.srt".into()));
507    }
508
509    #[test]
510    fn set_av_without_metadata() {
511        let body = xml::set_av_transport_uri("http://h/v.m3u8", "");
512        let media = parse_set_av_transport_uri(&body).unwrap();
513        assert_eq!(media.uri, "http://h/v.m3u8");
514        assert!(media.title.is_empty() && media.srt.is_empty());
515    }
516
517    #[test]
518    fn broken_metadata_falls_back_to_title_search() {
519        // undeclared `upnp:` prefix makes the DIDL invalid
520        let meta = "<DIDL-Lite><item><dc:title>Live 1</dc:title><upnp:class>x</upnp:class></item></DIDL-Lite>";
521        assert_eq!(parse_didl(meta), ("Live 1".into(), String::new()));
522    }
523
524    #[test]
525    fn subtitle_from_res_type() {
526        let meta = format!(
527            r#"<DIDL-Lite xmlns="{NS_DIDL}" xmlns:dc="{NS_DC}"><item><dc:title>T</dc:title>
528            <res protocolInfo="http-get:*:video/mp4:*">http://h/v.mp4</res>
529            <res type="text/subtitle">http://h/v.srt</res></item></DIDL-Lite>"#
530        );
531        assert_eq!(parse_didl(&meta), ("T".into(), "http://h/v.srt".into()));
532    }
533}