1use 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 #[arg(long)]
27 http_logs: bool,
28 #[arg(short, long)]
30 verbose: bool,
31 #[arg(long)]
33 name: Option<String>,
34 #[arg(long)]
36 port: Option<u16>,
37 #[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 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 thread::spawn(move || child.wait());
165 }
166}
167
168#[cfg(unix)]
169fn terminate(child: &mut Child) {
170 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 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 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
347fn 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
390fn 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('&', "&");
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
408fn 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 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}