Skip to main content

tiny_dlna/
cli.rs

1//! `tiny-cli`: find DLNA renderers and control them, or stream local files.
2
3use 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);
26/// Characters kept as-is in served file names (RFC 3986 unreserved).
27const 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 available DLNA devices
43    List {
44        /// Show all device attributes, and enable verbose logs
45        #[arg(short)]
46        verbose: bool,
47    },
48    /// Stop DLNA streaming
49    Stop(Target),
50    /// Seek DLNA streaming
51    Seek {
52        #[command(flatten)]
53        target: Target,
54        /// The new time point, format: HH:MM:SS
55        to: String,
56    },
57    /// Play a local video file or an http(s) URL via a DLNA device
58    Play {
59        video_file: String,
60        #[command(flatten)]
61        target: Target,
62        /// Override the title
63        #[arg(long)]
64        title: Option<String>,
65    },
66}
67
68#[derive(ClapArgs)]
69struct Target {
70    /// Enable verbose logs
71    #[arg(short)]
72    verbose: bool,
73    /// Specify device by (part of) its friendly name
74    #[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
141/// A device picked by name, ready to be controlled.
142struct Renderer {
143    control_url: String,
144    ip: Option<IpAddr>,
145}
146
147/// Parse an SSDP reply; `None` unless it is an AVTransport service.
148fn 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
176/// Turn a `controlURL` into an absolute URL, relative to the device's host.
177fn 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
194/// Fill in name and control URL from the device description XML.
195fn 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    // Fetch descriptions in parallel; drop devices that don't answer.
251    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
312/// Send a SOAP action; failures are reported to the user.
313fn 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    // Keep serving until Ctrl-C, then stop playback on the device.
401    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        // Normal when a TV drops a connection to seek elsewhere.
485        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
504/// Parse a single-range `Range` header against a body of `len` bytes.
505/// `None`: ignore the header and send everything; `Some(Err)`: unsatisfiable.
506fn 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    // Always send Content-Length: many TVs can't stream chunked responses.
566    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}