1use std::collections::{HashMap, HashSet};
4use std::fs::File;
5use std::io::{self, Read, Seek, SeekFrom};
6use std::net::IpAddr;
7use std::path::{Path, PathBuf};
8use std::process;
9use std::sync::Arc;
10use std::thread;
11use std::time::{Duration, SystemTime, UNIX_EPOCH};
12
13use clap::{Args as ClapArgs, Parser, Subcommand};
14use log::{LevelFilter, debug, error, info};
15use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
16use serde::Serialize;
17use tiny_http::{Header, Method, Request, Response, ResponseBox, Server, StatusCode};
18use ureq::http::Uri;
19
20use crate::{HTTP_LOG, net, ssdp, xml};
21
22const AVTRANSPORT: &str = "urn:schemas-upnp-org:service:AVTransport:1";
23const SEARCH_WAIT: Duration = Duration::from_millis(1500);
24const DESCRIPTION_TIMEOUT: Duration = Duration::from_secs(1);
25const COMMAND_TIMEOUT: Duration = Duration::from_secs(10);
26const PATH_SAFE: &AsciiSet = &NON_ALPHANUMERIC
28 .remove(b'-')
29 .remove(b'.')
30 .remove(b'_')
31 .remove(b'~');
32
33#[derive(Parser)]
34#[command(name = "tiny-cli", version, about = "A tiny DLNA sender")]
35struct Cli {
36 #[command(subcommand)]
37 command: Command,
38}
39
40#[derive(Subcommand)]
41enum Command {
42 List {
44 #[arg(short)]
46 verbose: bool,
47 },
48 Stop(Target),
50 Seek {
52 #[command(flatten)]
53 target: Target,
54 to: String,
56 },
57 Play {
59 video_file: String,
60 #[command(flatten)]
61 target: Target,
62 #[arg(long)]
64 title: Option<String>,
65 },
66}
67
68#[derive(ClapArgs)]
69struct Target {
70 #[arg(short)]
72 verbose: bool,
73 #[arg(short = 'q', value_name = "QUERY")]
75 query: String,
76}
77
78pub fn main() {
79 let cli = Cli::parse();
80 let verbose = match &cli.command {
81 Command::List { verbose } => *verbose,
82 Command::Stop(t) | Command::Seek { target: t, .. } | Command::Play { target: t, .. } => {
83 t.verbose
84 }
85 };
86 let level = if verbose {
87 LevelFilter::Debug
88 } else {
89 LevelFilter::Error
90 };
91 let http_level = if verbose {
92 LevelFilter::Info
93 } else {
94 LevelFilter::Error
95 };
96 crate::init_logging(&[("tiny_dlna", level), (HTTP_LOG, http_level)]);
97
98 match cli.command {
99 Command::List { verbose } => list(verbose),
100 Command::Stop(target) => {
101 let device = find_device(&target.query);
102 debug!("Stopping streaming on DLNA: {}", device.control_url);
103 send_or_exit(&device.control_url, xml::STOP, "Stop");
104 }
105 Command::Seek { target, to } => {
106 let device = find_device(&target.query);
107 debug!("Seek streaming to {to}: {}", device.control_url);
108 send_or_exit(&device.control_url, &xml::seek(&to), "Seek");
109 }
110 Command::Play {
111 video_file,
112 target,
113 title,
114 } => {
115 let device = find_device(&target.query);
116 play(&device, &video_file, title.as_deref());
117 }
118 }
119}
120
121#[derive(Serialize, Debug, Default)]
122struct Device {
123 #[serde(skip_serializing_if = "Option::is_none")]
124 control_url: Option<String>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 friendly_name: Option<String>,
127 location: String,
128 st: String,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 usn: Option<String>,
131 #[serde(skip)]
132 ip: Option<IpAddr>,
133}
134
135impl Device {
136 fn name(&self) -> &str {
137 self.friendly_name.as_deref().unwrap_or("no-name")
138 }
139}
140
141struct Renderer {
143 control_url: String,
144 ip: Option<IpAddr>,
145}
146
147fn parse_ssdp_reply(reply: &str) -> Option<Device> {
149 let mut lines = reply.split("\r\n");
150 if !lines.next()?.trim_end().ends_with("200 OK") {
151 return None;
152 }
153 let mut device = Device::default();
154 for line in lines {
155 let Some((key, value)) = line.split_once(':') else {
156 continue;
157 };
158 let value = value.trim().to_string();
159 match key.trim().to_ascii_lowercase().as_str() {
160 "location" => device.location = value,
161 "usn" => device.usn = Some(value),
162 "st" => device.st = value,
163 _ => {}
164 }
165 }
166 (device.st.contains(":service:AVTransport:") && !device.location.is_empty()).then_some(device)
167}
168
169fn agent(timeout: Duration) -> ureq::Agent {
170 ureq::Agent::config_builder()
171 .timeout_global(Some(timeout))
172 .build()
173 .into()
174}
175
176fn resolve_control_url(location: &str, control: &str) -> Option<String> {
178 let control = control.trim();
179 if control.starts_with("http://") || control.starts_with("https://") {
180 return Some(control.to_string());
181 }
182 let uri: Uri = location.parse().ok()?;
183 let scheme = uri.scheme_str().unwrap_or("http");
184 let host = uri.host()?;
185 let port = uri
186 .port_u16()
187 .unwrap_or(if scheme == "https" { 443 } else { 80 });
188 Some(format!(
189 "{scheme}://{host}:{port}/{}",
190 control.trim_start_matches('/')
191 ))
192}
193
194fn parse_description(device: &mut Device, xml_text: &str) -> Result<(), String> {
196 let doc = roxmltree::Document::parse(xml_text.trim()).map_err(|e| e.to_string())?;
197 let child_text = |node: roxmltree::Node, name: &str| {
198 node.children()
199 .find(|n| n.tag_name().name() == name)
200 .and_then(|n| n.text())
201 .map(str::trim)
202 .filter(|t| !t.is_empty())
203 .map(str::to_string)
204 };
205 device.friendly_name = doc
206 .descendants()
207 .find(|n| n.tag_name().name() == "friendlyName")
208 .and_then(|n| n.text())
209 .map(str::trim)
210 .filter(|t| !t.is_empty())
211 .map(str::to_string);
212 device.control_url = doc
213 .descendants()
214 .filter(|n| n.tag_name().name() == "service")
215 .find(|n| {
216 child_text(*n, "serviceId").as_deref() == Some("urn:upnp-org:serviceId:AVTransport")
217 })
218 .and_then(|n| child_text(n, "controlURL"))
219 .and_then(|url| resolve_control_url(&device.location, &url));
220 Ok(())
221}
222
223fn fetch_description(device: &mut Device) -> Result<(), String> {
224 let text = agent(DESCRIPTION_TIMEOUT)
225 .get(&device.location)
226 .call()
227 .and_then(|mut r| r.body_mut().read_to_string())
228 .map_err(|e| e.to_string())?;
229 parse_description(device, &text)
230}
231
232fn discover() -> Vec<Device> {
233 let replies = match ssdp::search(SEARCH_WAIT) {
234 Ok(r) => r,
235 Err(e) => {
236 error!("SSDP search failed: {e}");
237 return Vec::new();
238 }
239 };
240 let mut seen = HashSet::new();
241 let devices: Vec<Device> = replies
242 .into_iter()
243 .filter_map(|(addr, reply)| {
244 let mut device = parse_ssdp_reply(&reply)?;
245 device.ip = Some(addr.ip());
246 seen.insert(device.location.clone()).then_some(device)
247 })
248 .collect();
249
250 let handles: Vec<_> = devices
252 .into_iter()
253 .map(|mut d| {
254 thread::spawn(move || match fetch_description(&mut d) {
255 Ok(()) => Some(d),
256 Err(e) => {
257 debug!("skip {}: {e}", d.location);
258 None
259 }
260 })
261 })
262 .collect();
263 handles
264 .into_iter()
265 .filter_map(|h| h.join().ok().flatten())
266 .collect()
267}
268
269fn list(verbose: bool) {
270 let devices = discover();
271 if verbose {
272 let out = serde_json::json!({ "devices": devices });
273 println!(
274 "{}",
275 serde_json::to_string_pretty(&out).expect("serializable")
276 );
277 return;
278 }
279 if devices.is_empty() {
280 eprintln!("No DLNA devices found.");
281 }
282 for d in &devices {
283 println!("{}", d.name());
284 }
285}
286
287fn find_device(query: &str) -> Renderer {
288 let devices = discover();
289 let q = query.to_lowercase();
290 let (matched, others): (Vec<&Device>, Vec<&Device>) = devices.iter().partition(|d| {
291 d.friendly_name
292 .as_ref()
293 .is_some_and(|n| n.to_lowercase().contains(&q))
294 });
295 if let Some((d, url)) = matched
296 .iter()
297 .find_map(|d| d.control_url.as_ref().map(|url| (d, url)))
298 {
299 return Renderer {
300 control_url: url.clone(),
301 ip: d.ip,
302 };
303 }
304 eprintln!("No such DLNA device found: {query}");
305 let names: Vec<&str> = others.iter().chain(&matched).map(|d| d.name()).collect();
306 if !names.is_empty() {
307 eprintln!("Available names: {}", names.join(", "));
308 }
309 process::exit(1);
310}
311
312fn send_command(control_url: &str, body: &str, action: &str) -> bool {
314 let result = agent(COMMAND_TIMEOUT)
315 .post(control_url)
316 .header("Content-Type", "text/xml; charset=\"utf-8\"")
317 .header("SOAPACTION", format!("\"{AVTRANSPORT}#{action}\""))
318 .send(body);
319 match result {
320 Ok(_) => {
321 debug!("{action} request sent");
322 true
323 }
324 Err(e) => {
325 eprintln!("{action} failed: {e}");
326 false
327 }
328 }
329}
330
331fn send_or_exit(control_url: &str, body: &str, action: &str) {
332 if !send_command(control_url, body, action) {
333 process::exit(1);
334 }
335}
336
337fn file_name(path: &Path) -> String {
338 path.file_name()
339 .unwrap_or_default()
340 .to_string_lossy()
341 .into_owned()
342}
343
344fn play(device: &Renderer, video: &str, title: Option<&str>) {
345 let is_url = video.starts_with("http://") || video.starts_with("https://");
346 let (url_video, url_srt, title) = if is_url {
347 (
348 video.to_string(),
349 None,
350 title.unwrap_or("online stream").to_string(),
351 )
352 } else {
353 let path = Path::new(video);
354 if !path.is_file() {
355 eprintln!("no such file: {video}");
356 process::exit(1);
357 }
358 let Some(ip) = device.ip.map_or_else(net::host_ip, net::local_ip_for) else {
359 eprintln!("failed to find a local IP address reachable by the device");
360 process::exit(1);
361 };
362 let (server, port) = match bind_file_server() {
363 Ok(s) => s,
364 Err(e) => {
365 eprintln!("failed to start file server: {e}");
366 process::exit(1);
367 }
368 };
369
370 info!("play video: {video}");
371 let mut files = HashMap::new();
372 let mut url_for = |path: &Path| {
373 let name = file_name(path);
374 let url = format!(
375 "http://{ip}:{port}/videos/{}",
376 utf8_percent_encode(&name, PATH_SAFE)
377 );
378 info!("serving {} at {url}", path.display());
379 files.insert(
380 name,
381 std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()),
382 );
383 url
384 };
385 let url_video = url_for(path);
386 let srt = path.with_extension("srt");
387 let url_srt = srt.is_file().then(|| url_for(&srt));
388
389 let files = Arc::new(files);
390 thread::spawn(move || serve_files(server, files));
391 let title = title.map_or_else(|| file_name(path), str::to_string);
392 (url_video, url_srt, title)
393 };
394
395 let metadata = xml::didl_metadata(&title, &url_video, url_srt.as_deref());
396 let set_av = xml::set_av_transport_uri(&url_video, &metadata);
397 send_or_exit(&device.control_url, &set_av, "SetAVTransportURI");
398 send_or_exit(&device.control_url, xml::PLAY, "Play");
399
400 let control_url = device.control_url.clone();
402 ctrlc::set_handler(move || {
403 send_command(&control_url, xml::STOP, "Stop");
404 process::exit(0);
405 })
406 .expect("failed to install signal handler");
407 loop {
408 thread::park();
409 }
410}
411
412fn bind_file_server() -> io::Result<(Server, u16)> {
413 let seed = SystemTime::now()
414 .duration_since(UNIX_EPOCH)
415 .unwrap_or_default()
416 .subsec_nanos();
417 let offset = (seed ^ process::id()) % 9000;
418 let mut last_err = None;
419 for i in 0..50 {
420 let port = 50000 + ((offset + i) % 9000) as u16;
421 match Server::http(("0.0.0.0", port)) {
422 Ok(server) => return Ok((server, port)),
423 Err(e) => last_err = Some(e),
424 }
425 }
426 Err(io::Error::other(
427 last_err.map_or("no free port".into(), |e| e.to_string()),
428 ))
429}
430
431fn serve_files(server: Server, files: Arc<HashMap<String, PathBuf>>) {
432 for request in server.incoming_requests() {
433 let files = files.clone();
434 thread::spawn(move || serve_one(request, &files));
435 }
436}
437
438fn header(name: &str, value: &str) -> Header {
439 Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid header")
440}
441
442fn plain(status: u16, body: &str) -> ResponseBox {
443 Response::from_string(body)
444 .with_status_code(StatusCode(status))
445 .boxed()
446}
447
448fn serve_one(request: Request, files: &HashMap<String, PathBuf>) {
449 let url = request.url().to_string();
450 let path = url.split('?').next().unwrap_or_default();
451 let file = path
452 .strip_prefix("/videos/")
453 .map(|name| percent_decode_str(name).decode_utf8_lossy().into_owned())
454 .and_then(|name| files.get(&name));
455
456 let resp = match (request.method(), file) {
457 (_, None) => plain(404, "Not Found"),
458 (Method::Get | Method::Head, Some(path)) => {
459 file_response(&request, path).unwrap_or_else(|e| {
460 error!("failed to read {}: {e}", path.display());
461 plain(500, "Internal Server Error")
462 })
463 }
464 _ => plain(405, "Method Not Allowed"),
465 };
466
467 let remote = request
468 .remote_addr()
469 .map(|a| a.ip().to_string())
470 .unwrap_or_default();
471 let range = request
472 .headers()
473 .iter()
474 .find(|h| h.field.equiv("Range"))
475 .map(|h| h.value.to_string());
476 log::info!(
477 target: HTTP_LOG,
478 "{remote} \"{} {url}\" {}{}",
479 request.method(),
480 resp.status_code().0,
481 range.map(|r| format!(" ({r})")).unwrap_or_default()
482 );
483 if let Err(e) = request.respond(resp) {
484 debug!("response for {url} interrupted: {e}");
486 }
487}
488
489fn content_type(path: &Path) -> String {
490 match path
491 .extension()
492 .and_then(|e| e.to_str())
493 .map(str::to_ascii_lowercase)
494 .as_deref()
495 {
496 Some("srt") => "text/srt".into(),
497 Some("ts" | "m2ts" | "mts") => "video/mp2t".into(),
498 _ => mime_guess::from_path(path)
499 .first_or_octet_stream()
500 .to_string(),
501 }
502}
503
504fn parse_range(value: &str, len: u64) -> Option<Result<(u64, u64), ()>> {
507 let spec = value.trim().strip_prefix("bytes=")?;
508 if spec.contains(',') {
509 return None;
510 }
511 let (start, end) = spec.split_once('-')?;
512 let (start, end) = (start.trim(), end.trim());
513 if start.is_empty() {
514 let suffix: u64 = end.parse().ok()?;
515 if suffix == 0 || len == 0 {
516 return Some(Err(()));
517 }
518 return Some(Ok((len.saturating_sub(suffix), len - 1)));
519 }
520 let start: u64 = start.parse().ok()?;
521 let end = if end.is_empty() {
522 len.saturating_sub(1)
523 } else {
524 end.parse::<u64>().ok()?.min(len.saturating_sub(1))
525 };
526 if start >= len || start > end {
527 return Some(Err(()));
528 }
529 Some(Ok((start, end)))
530}
531
532fn file_response(request: &Request, path: &Path) -> io::Result<ResponseBox> {
533 let mut file = File::open(path)?;
534 let meta = file.metadata()?;
535 let len = meta.len();
536 let mut headers = vec![
537 header("Content-Type", &content_type(path)),
538 header("Accept-Ranges", "bytes"),
539 ];
540 if let Ok(modified) = meta.modified() {
541 headers.push(header("Last-Modified", &httpdate::fmt_http_date(modified)));
542 }
543
544 let range = request
545 .headers()
546 .iter()
547 .find(|h| h.field.equiv("Range"))
548 .and_then(|h| parse_range(h.value.as_str(), len));
549 let (status, body, body_len): (u16, Box<dyn Read + Send>, u64) = match range {
550 None => (200, Box::new(file), len),
551 Some(Ok((start, end))) => {
552 file.seek(SeekFrom::Start(start))?;
553 headers.push(header(
554 "Content-Range",
555 &format!("bytes {start}-{end}/{len}"),
556 ));
557 let n = end - start + 1;
558 (206, Box::new(file.take(n)), n)
559 }
560 Some(Err(())) => {
561 headers.push(header("Content-Range", &format!("bytes */{len}")));
562 (416, Box::new(io::empty()), 0)
563 }
564 };
565 Ok(Response::new(
567 StatusCode(status),
568 headers,
569 body,
570 usize::try_from(body_len).ok(),
571 None,
572 )
573 .with_chunked_threshold(usize::MAX))
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn ssdp_reply_parsing() {
582 let reply = "HTTP/1.1 200 OK\r\nST: urn:schemas-upnp-org:service:AVTransport:1\r\n\
583 USN: uuid:x::y\r\nLOCATION: http://10.0.0.5:1234/d.xml\r\n\r\n";
584 let d = parse_ssdp_reply(reply).unwrap();
585 assert_eq!(d.location, "http://10.0.0.5:1234/d.xml");
586 assert_eq!(d.usn.as_deref(), Some("uuid:x::y"));
587 assert!(parse_ssdp_reply(&reply.replace("AVTransport", "ContentDirectory")).is_none());
588 assert!(parse_ssdp_reply(&reply.replace("200 OK", "404 Not Found")).is_none());
589 }
590
591 #[test]
592 fn control_url_resolution() {
593 let loc = "http://10.0.0.5:1234/desc/d.xml";
594 assert_eq!(
595 resolve_control_url(loc, "/AVT/ctl").unwrap(),
596 "http://10.0.0.5:1234/AVT/ctl"
597 );
598 assert_eq!(
599 resolve_control_url(loc, "AVT/ctl").unwrap(),
600 "http://10.0.0.5:1234/AVT/ctl"
601 );
602 assert_eq!(
603 resolve_control_url("http://10.0.0.5/d.xml", "c").unwrap(),
604 "http://10.0.0.5:80/c"
605 );
606 assert_eq!(
607 resolve_control_url(loc, "http://other/c").unwrap(),
608 "http://other/c"
609 );
610 }
611
612 #[test]
613 fn description_parsing() {
614 let mut d = Device {
615 location: "http://10.0.0.5:59876/description.xml".into(),
616 ..Default::default()
617 };
618 parse_description(&mut d, &xml::description("Living Room & TV", "u-1")).unwrap();
619 assert_eq!(d.friendly_name.as_deref(), Some("Living Room & TV"));
620 assert_eq!(
621 d.control_url.as_deref(),
622 Some("http://10.0.0.5:59876/AVTransport/control")
623 );
624 }
625
626 #[test]
627 fn ranges() {
628 assert_eq!(parse_range("bytes=0-", 100), Some(Ok((0, 99))));
629 assert_eq!(parse_range("bytes=10-19", 100), Some(Ok((10, 19))));
630 assert_eq!(parse_range("bytes=90-200", 100), Some(Ok((90, 99))));
631 assert_eq!(parse_range("bytes=-10", 100), Some(Ok((90, 99))));
632 assert_eq!(parse_range("bytes=-500", 100), Some(Ok((0, 99))));
633 assert_eq!(parse_range("bytes=100-", 100), Some(Err(())));
634 assert_eq!(parse_range("bytes=20-10", 100), Some(Err(())));
635 assert_eq!(parse_range("bytes=0-1,5-6", 100), None);
636 assert_eq!(parse_range("items=0-1", 100), None);
637 }
638
639 #[test]
640 fn content_types() {
641 assert_eq!(content_type(Path::new("a.SRT")), "text/srt");
642 assert_eq!(content_type(Path::new("a.mp4")), "video/mp4");
643 assert_eq!(content_type(Path::new("a.mkv")), "video/x-matroska");
644 assert_eq!(content_type(Path::new("a.ts")), "video/mp2t");
645 }
646}