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, Instant, 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/// While playing, how often to ask the device for its state.
27const POLL_EVERY: Duration = Duration::from_secs(2);
28const POLL_TIMEOUT: Duration = Duration::from_secs(3);
29/// Some devices report STOPPED while still loading; how long we wait for
30/// playback to start before giving up.
31const START_GRACE: Duration = Duration::from_secs(30);
32/// Unanswered polls in a row before we decide the device is gone.
33const MAX_MISSES: u32 = 3;
34/// Characters kept as-is in served file names (RFC 3986 unreserved).
35const PATH_SAFE: &AsciiSet = &NON_ALPHANUMERIC
36    .remove(b'-')
37    .remove(b'.')
38    .remove(b'_')
39    .remove(b'~');
40
41#[derive(Parser)]
42#[command(name = "tiny-cli", version, about = "A tiny DLNA sender")]
43struct Cli {
44    #[command(subcommand)]
45    command: Command,
46}
47
48#[derive(Subcommand)]
49enum Command {
50    /// List available DLNA devices
51    List {
52        /// Show all device attributes, and enable verbose logs
53        #[arg(short)]
54        verbose: bool,
55    },
56    /// Stop DLNA streaming
57    Stop(Target),
58    /// Seek DLNA streaming
59    Seek {
60        #[command(flatten)]
61        target: Target,
62        /// The new time point, format: HH:MM:SS
63        to: String,
64    },
65    /// Play a local video file or an http(s) URL via a DLNA device
66    Play {
67        video_file: String,
68        #[command(flatten)]
69        target: Target,
70        /// Override the title
71        #[arg(long)]
72        title: Option<String>,
73    },
74}
75
76#[derive(ClapArgs)]
77struct Target {
78    /// Enable verbose logs
79    #[arg(short)]
80    verbose: bool,
81    /// Specify device by (part of) its friendly name
82    #[arg(short = 'q', value_name = "QUERY")]
83    query: String,
84}
85
86pub fn main() {
87    let cli = Cli::parse();
88    let verbose = match &cli.command {
89        Command::List { verbose } => *verbose,
90        Command::Stop(t) | Command::Seek { target: t, .. } | Command::Play { target: t, .. } => {
91            t.verbose
92        }
93    };
94    let level = if verbose {
95        LevelFilter::Debug
96    } else {
97        LevelFilter::Error
98    };
99    let http_level = if verbose {
100        LevelFilter::Info
101    } else {
102        LevelFilter::Error
103    };
104    crate::init_logging(&[("tiny_dlna", level), (HTTP_LOG, http_level)]);
105
106    match cli.command {
107        Command::List { verbose } => list(verbose),
108        Command::Stop(target) => {
109            let device = find_device(&target.query);
110            debug!("Stopping streaming on DLNA: {}", device.control_url);
111            send_or_exit(&device.control_url, xml::STOP, "Stop");
112        }
113        Command::Seek { target, to } => {
114            let device = find_device(&target.query);
115            debug!("Seek streaming to {to}: {}", device.control_url);
116            send_or_exit(&device.control_url, &xml::seek(&to), "Seek");
117        }
118        Command::Play {
119            video_file,
120            target,
121            title,
122        } => {
123            let device = find_device(&target.query);
124            play(&device, &video_file, title.as_deref());
125        }
126    }
127}
128
129#[derive(Serialize, Debug, Default)]
130struct Device {
131    #[serde(skip_serializing_if = "Option::is_none")]
132    control_url: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    friendly_name: Option<String>,
135    location: String,
136    st: String,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    usn: Option<String>,
139    #[serde(skip)]
140    ip: Option<IpAddr>,
141}
142
143impl Device {
144    fn name(&self) -> &str {
145        self.friendly_name.as_deref().unwrap_or("no-name")
146    }
147}
148
149/// A device picked by name, ready to be controlled.
150struct Renderer {
151    control_url: String,
152    ip: Option<IpAddr>,
153}
154
155/// Parse an SSDP reply; `None` unless it is an AVTransport service.
156fn parse_ssdp_reply(reply: &str) -> Option<Device> {
157    let mut lines = reply.split("\r\n");
158    if !lines.next()?.trim_end().ends_with("200 OK") {
159        return None;
160    }
161    let mut device = Device::default();
162    for line in lines {
163        let Some((key, value)) = line.split_once(':') else {
164            continue;
165        };
166        let value = value.trim().to_string();
167        match key.trim().to_ascii_lowercase().as_str() {
168            "location" => device.location = value,
169            "usn" => device.usn = Some(value),
170            "st" => device.st = value,
171            _ => {}
172        }
173    }
174    (device.st.contains(":service:AVTransport:") && !device.location.is_empty()).then_some(device)
175}
176
177fn agent(timeout: Duration) -> ureq::Agent {
178    ureq::Agent::config_builder()
179        .timeout_global(Some(timeout))
180        .build()
181        .into()
182}
183
184/// Turn a `controlURL` into an absolute URL, relative to the device's host.
185fn resolve_control_url(location: &str, control: &str) -> Option<String> {
186    let control = control.trim();
187    if control.starts_with("http://") || control.starts_with("https://") {
188        return Some(control.to_string());
189    }
190    let uri: Uri = location.parse().ok()?;
191    let scheme = uri.scheme_str().unwrap_or("http");
192    let host = uri.host()?;
193    let port = uri
194        .port_u16()
195        .unwrap_or(if scheme == "https" { 443 } else { 80 });
196    Some(format!(
197        "{scheme}://{host}:{port}/{}",
198        control.trim_start_matches('/')
199    ))
200}
201
202/// Fill in name and control URL from the device description XML.
203fn parse_description(device: &mut Device, xml_text: &str) -> Result<(), String> {
204    let doc = roxmltree::Document::parse(xml_text.trim()).map_err(|e| e.to_string())?;
205    let child_text = |node: roxmltree::Node, name: &str| {
206        node.children()
207            .find(|n| n.tag_name().name() == name)
208            .and_then(|n| n.text())
209            .map(str::trim)
210            .filter(|t| !t.is_empty())
211            .map(str::to_string)
212    };
213    device.friendly_name = doc
214        .descendants()
215        .find(|n| n.tag_name().name() == "friendlyName")
216        .and_then(|n| n.text())
217        .map(str::trim)
218        .filter(|t| !t.is_empty())
219        .map(str::to_string);
220    device.control_url = doc
221        .descendants()
222        .filter(|n| n.tag_name().name() == "service")
223        .find(|n| {
224            child_text(*n, "serviceId").as_deref() == Some("urn:upnp-org:serviceId:AVTransport")
225        })
226        .and_then(|n| child_text(n, "controlURL"))
227        .and_then(|url| resolve_control_url(&device.location, &url));
228    Ok(())
229}
230
231fn fetch_description(device: &mut Device) -> Result<(), String> {
232    let text = agent(DESCRIPTION_TIMEOUT)
233        .get(&device.location)
234        .call()
235        .and_then(|mut r| r.body_mut().read_to_string())
236        .map_err(|e| e.to_string())?;
237    parse_description(device, &text)
238}
239
240fn discover() -> Vec<Device> {
241    let replies = match ssdp::search(SEARCH_WAIT) {
242        Ok(r) => r,
243        Err(e) => {
244            error!("SSDP search failed: {e}");
245            return Vec::new();
246        }
247    };
248    let mut seen = HashSet::new();
249    let devices: Vec<Device> = replies
250        .into_iter()
251        .filter_map(|(addr, reply)| {
252            let mut device = parse_ssdp_reply(&reply)?;
253            device.ip = Some(addr.ip());
254            seen.insert(device.location.clone()).then_some(device)
255        })
256        .collect();
257
258    // Fetch descriptions in parallel; drop devices that don't answer.
259    let handles: Vec<_> = devices
260        .into_iter()
261        .map(|mut d| {
262            thread::spawn(move || match fetch_description(&mut d) {
263                Ok(()) => Some(d),
264                Err(e) => {
265                    debug!("skip {}: {e}", d.location);
266                    None
267                }
268            })
269        })
270        .collect();
271    handles
272        .into_iter()
273        .filter_map(|h| h.join().ok().flatten())
274        .collect()
275}
276
277fn list(verbose: bool) {
278    let devices = discover();
279    if verbose {
280        let out = serde_json::json!({ "devices": devices });
281        println!(
282            "{}",
283            serde_json::to_string_pretty(&out).expect("serializable")
284        );
285        return;
286    }
287    if devices.is_empty() {
288        eprintln!("No DLNA devices found.");
289    }
290    for d in &devices {
291        println!("{}", d.name());
292    }
293}
294
295fn find_device(query: &str) -> Renderer {
296    let devices = discover();
297    let q = query.to_lowercase();
298    let (matched, others): (Vec<&Device>, Vec<&Device>) = devices.iter().partition(|d| {
299        d.friendly_name
300            .as_ref()
301            .is_some_and(|n| n.to_lowercase().contains(&q))
302    });
303    if let Some((d, url)) = matched
304        .iter()
305        .find_map(|d| d.control_url.as_ref().map(|url| (d, url)))
306    {
307        return Renderer {
308            control_url: url.clone(),
309            ip: d.ip,
310        };
311    }
312    eprintln!("No such DLNA device found: {query}");
313    let names: Vec<&str> = others.iter().chain(&matched).map(|d| d.name()).collect();
314    if !names.is_empty() {
315        eprintln!("Available names: {}", names.join(", "));
316    }
317    process::exit(1);
318}
319
320enum SoapError {
321    /// The device answered with an HTTP error status, e.g. for an action it
322    /// doesn't support.
323    Status(u16),
324    /// No usable answer: connection refused, timeout, ...
325    Unreachable(String),
326}
327
328impl std::fmt::Display for SoapError {
329    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
330        match self {
331            SoapError::Status(code) => write!(f, "http status: {code}"),
332            SoapError::Unreachable(e) => f.write_str(e),
333        }
334    }
335}
336
337/// Send a SOAP action and return the response body.
338fn soap(
339    control_url: &str,
340    body: &str,
341    action: &str,
342    timeout: Duration,
343) -> Result<String, SoapError> {
344    let mut resp = agent(timeout)
345        .post(control_url)
346        .header("Content-Type", "text/xml; charset=\"utf-8\"")
347        .header("SOAPACTION", format!("\"{AVTRANSPORT}#{action}\""))
348        .send(body)
349        .map_err(|e| match e {
350            ureq::Error::StatusCode(code) => SoapError::Status(code),
351            e => SoapError::Unreachable(e.to_string()),
352        })?;
353    resp.body_mut()
354        .read_to_string()
355        .map_err(|e| SoapError::Unreachable(e.to_string()))
356}
357
358/// Send a SOAP action; failures are reported to the user.
359fn send_command(control_url: &str, body: &str, action: &str) -> bool {
360    match soap(control_url, body, action, COMMAND_TIMEOUT) {
361        Ok(_) => {
362            debug!("{action} request sent");
363            true
364        }
365        Err(e) => {
366            eprintln!("{action} failed: {e}");
367            false
368        }
369    }
370}
371
372/// Text of the first `name` element in a SOAP response.
373fn response_field(xml_text: &str, name: &str) -> Option<String> {
374    let doc = roxmltree::Document::parse(xml_text.trim()).ok()?;
375    let node = doc.descendants().find(|n| n.tag_name().name() == name)?;
376    Some(node.text().unwrap_or_default().trim().to_string())
377}
378
379/// The device's `CurrentURI`, if it tells. Clears `supported` when the
380/// device doesn't implement GetMediaInfo, so we stop asking.
381fn current_uri(control_url: &str, supported: &mut bool) -> Option<String> {
382    match soap(
383        control_url,
384        xml::GET_MEDIA_INFO,
385        "GetMediaInfo",
386        POLL_TIMEOUT,
387    ) {
388        Ok(body) => response_field(&body, "CurrentURI"),
389        Err(SoapError::Status(code)) => {
390            debug!("GetMediaInfo not supported (http status: {code})");
391            *supported = false;
392            None
393        }
394        Err(e) => {
395            debug!("GetMediaInfo failed: {e}");
396            None
397        }
398    }
399}
400
401/// Whether the device's `CurrentURI` shows it moved on to another video.
402/// Devices may echo our URI back escaped differently, so compare decoded.
403fn is_other_uri(ours: &str, theirs: Option<&str>) -> bool {
404    let norm = |s: &str| {
405        percent_decode_str(&xml::unescape(s.trim()))
406            .decode_utf8_lossy()
407            .into_owned()
408    };
409    theirs.is_some_and(|t| !t.trim().is_empty() && norm(ours) != norm(t))
410}
411
412/// What a poll learned about the device.
413struct Status {
414    /// `CurrentTransportState`, e.g. `PLAYING` or `STOPPED`.
415    state: String,
416    /// `CurrentURI`, when known.
417    uri: Option<String>,
418}
419
420/// Why `play` stopped waiting.
421#[derive(Debug, PartialEq)]
422enum Ending {
423    /// Our video played, then stopped: it ended, or was stopped/closed.
424    Finished,
425    /// The device moved on to another video.
426    Replaced,
427    /// The device stopped answering.
428    Gone,
429    /// The device never started playing.
430    NeverStarted,
431}
432
433/// Decides, poll by poll, when `play` is done.
434struct Watch<'a> {
435    our_uri: &'a str,
436    started: bool,
437    misses: u32,
438}
439
440impl<'a> Watch<'a> {
441    fn new(our_uri: &'a str) -> Self {
442        Watch {
443            our_uri,
444            started: false,
445            misses: 0,
446        }
447    }
448
449    /// `status` is `None` when the device didn't answer; `elapsed` is the
450    /// time since Play.
451    fn update(&mut self, status: Option<&Status>, elapsed: Duration) -> Option<Ending> {
452        let Some(status) = status else {
453            self.misses += 1;
454            return (self.misses >= MAX_MISSES).then_some(Ending::Gone);
455        };
456        self.misses = 0;
457        if is_other_uri(self.our_uri, status.uri.as_deref()) {
458            return Some(Ending::Replaced);
459        }
460        match status.state.as_str() {
461            "PLAYING" | "PAUSED_PLAYBACK" | "RECORDING" | "PAUSED_RECORDING" => {
462                self.started = true;
463                None
464            }
465            "STOPPED" | "NO_MEDIA_PRESENT" if self.started => Some(Ending::Finished),
466            "STOPPED" | "NO_MEDIA_PRESENT" if elapsed >= START_GRACE => Some(Ending::NeverStarted),
467            // TRANSITIONING (loading), or STOPPED within the grace period.
468            _ => None,
469        }
470    }
471}
472
473/// Poll the device until our video is done. `None` if the device can't
474/// report its state.
475fn watch(control_url: &str, our_uri: &str) -> Option<Ending> {
476    let since = Instant::now();
477    let mut watch = Watch::new(our_uri);
478    let mut answered = false;
479    let mut media_info = true;
480    loop {
481        thread::sleep(POLL_EVERY);
482        let status = match soap(
483            control_url,
484            xml::GET_TRANSPORT_INFO,
485            "GetTransportInfo",
486            POLL_TIMEOUT,
487        ) {
488            Ok(body) => {
489                answered = true;
490                let state = response_field(&body, "CurrentTransportState").unwrap_or_default();
491                let uri = if media_info {
492                    current_uri(control_url, &mut media_info)
493                } else {
494                    None
495                };
496                debug!("device state: {state}, uri: {uri:?}");
497                Some(Status { state, uri })
498            }
499            Err(SoapError::Status(code)) if !answered => {
500                debug!("GetTransportInfo not supported (http status: {code})");
501                return None;
502            }
503            Err(e) => {
504                debug!("GetTransportInfo failed: {e}");
505                None
506            }
507        };
508        if let Some(ending) = watch.update(status.as_ref(), since.elapsed()) {
509            return Some(ending);
510        }
511    }
512}
513
514fn send_or_exit(control_url: &str, body: &str, action: &str) {
515    if !send_command(control_url, body, action) {
516        process::exit(1);
517    }
518}
519
520fn file_name(path: &Path) -> String {
521    path.file_name()
522        .unwrap_or_default()
523        .to_string_lossy()
524        .into_owned()
525}
526
527fn play(device: &Renderer, video: &str, title: Option<&str>) {
528    let is_url = video.starts_with("http://") || video.starts_with("https://");
529    let (url_video, url_srt, title) = if is_url {
530        (
531            video.to_string(),
532            None,
533            title.unwrap_or("online stream").to_string(),
534        )
535    } else {
536        let path = Path::new(video);
537        if !path.is_file() {
538            eprintln!("no such file: {video}");
539            process::exit(1);
540        }
541        let Some(ip) = device.ip.map_or_else(net::host_ip, net::local_ip_for) else {
542            eprintln!("failed to find a local IP address reachable by the device");
543            process::exit(1);
544        };
545        let (server, port) = match bind_file_server() {
546            Ok(s) => s,
547            Err(e) => {
548                eprintln!("failed to start file server: {e}");
549                process::exit(1);
550            }
551        };
552
553        info!("play video: {video}");
554        let mut files = HashMap::new();
555        let mut url_for = |path: &Path| {
556            let name = file_name(path);
557            let url = format!(
558                "http://{ip}:{port}/videos/{}",
559                utf8_percent_encode(&name, PATH_SAFE)
560            );
561            info!("serving {} at {url}", path.display());
562            files.insert(
563                name,
564                std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()),
565            );
566            url
567        };
568        let url_video = url_for(path);
569        let srt = path.with_extension("srt");
570        let url_srt = srt.is_file().then(|| url_for(&srt));
571
572        let files = Arc::new(files);
573        thread::spawn(move || serve_files(server, files));
574        let title = title.map_or_else(|| file_name(path), str::to_string);
575        (url_video, url_srt, title)
576    };
577
578    let metadata = xml::didl_metadata(&title, &url_video, url_srt.as_deref());
579    let set_av = xml::set_av_transport_uri(&url_video, &metadata);
580    send_or_exit(&device.control_url, &set_av, "SetAVTransportURI");
581    send_or_exit(&device.control_url, xml::PLAY, "Play");
582
583    // On Ctrl-C, stop playback on the device, unless it has moved on to
584    // another video (then it's not ours to stop).
585    let control_url = device.control_url.clone();
586    let our_uri = url_video.clone();
587    ctrlc::set_handler(move || {
588        let mut supported = true;
589        if is_other_uri(
590            &our_uri,
591            current_uri(&control_url, &mut supported).as_deref(),
592        ) {
593            debug!("device is playing something else; not stopping it");
594        } else {
595            send_command(&control_url, xml::STOP, "Stop");
596        }
597        process::exit(0);
598    })
599    .expect("failed to install signal handler");
600
601    // Keep serving until the video is done on the device.
602    let Some(ending) = watch(&device.control_url, &url_video) else {
603        debug!("device can't report its state; serving until Ctrl-C");
604        loop {
605            thread::park();
606        }
607    };
608    match ending {
609        Ending::Finished => info!("playback finished"),
610        Ending::Replaced => eprintln!("device is playing something else now"),
611        Ending::Gone => {
612            eprintln!("device stopped responding");
613            process::exit(1);
614        }
615        Ending::NeverStarted => {
616            eprintln!("device never started playing");
617            process::exit(1);
618        }
619    }
620    process::exit(0);
621}
622
623fn bind_file_server() -> io::Result<(Server, u16)> {
624    let seed = SystemTime::now()
625        .duration_since(UNIX_EPOCH)
626        .unwrap_or_default()
627        .subsec_nanos();
628    let offset = (seed ^ process::id()) % 9000;
629    let mut last_err = None;
630    for i in 0..50 {
631        let port = 50000 + ((offset + i) % 9000) as u16;
632        match Server::http(("0.0.0.0", port)) {
633            Ok(server) => return Ok((server, port)),
634            Err(e) => last_err = Some(e),
635        }
636    }
637    Err(io::Error::other(
638        last_err.map_or("no free port".into(), |e| e.to_string()),
639    ))
640}
641
642fn serve_files(server: Server, files: Arc<HashMap<String, PathBuf>>) {
643    for request in server.incoming_requests() {
644        let files = files.clone();
645        thread::spawn(move || serve_one(request, &files));
646    }
647}
648
649fn header(name: &str, value: &str) -> Header {
650    Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid header")
651}
652
653fn plain(status: u16, body: &str) -> ResponseBox {
654    Response::from_string(body)
655        .with_status_code(StatusCode(status))
656        .boxed()
657}
658
659fn serve_one(request: Request, files: &HashMap<String, PathBuf>) {
660    let url = request.url().to_string();
661    let path = url.split('?').next().unwrap_or_default();
662    let file = path
663        .strip_prefix("/videos/")
664        .map(|name| percent_decode_str(name).decode_utf8_lossy().into_owned())
665        .and_then(|name| files.get(&name));
666
667    let resp = match (request.method(), file) {
668        (_, None) => plain(404, "Not Found"),
669        (Method::Get | Method::Head, Some(path)) => {
670            file_response(&request, path).unwrap_or_else(|e| {
671                error!("failed to read {}: {e}", path.display());
672                plain(500, "Internal Server Error")
673            })
674        }
675        _ => plain(405, "Method Not Allowed"),
676    };
677
678    let remote = request
679        .remote_addr()
680        .map(|a| a.ip().to_string())
681        .unwrap_or_default();
682    let range = request
683        .headers()
684        .iter()
685        .find(|h| h.field.equiv("Range"))
686        .map(|h| h.value.to_string());
687    log::info!(
688        target: HTTP_LOG,
689        "{remote} \"{} {url}\" {}{}",
690        request.method(),
691        resp.status_code().0,
692        range.map(|r| format!(" ({r})")).unwrap_or_default()
693    );
694    if let Err(e) = request.respond(resp) {
695        // Normal when a TV drops a connection to seek elsewhere.
696        debug!("response for {url} interrupted: {e}");
697    }
698}
699
700fn content_type(path: &Path) -> String {
701    match path
702        .extension()
703        .and_then(|e| e.to_str())
704        .map(str::to_ascii_lowercase)
705        .as_deref()
706    {
707        Some("srt") => "text/srt".into(),
708        Some("ts" | "m2ts" | "mts") => "video/mp2t".into(),
709        _ => mime_guess::from_path(path)
710            .first_or_octet_stream()
711            .to_string(),
712    }
713}
714
715/// Parse a single-range `Range` header against a body of `len` bytes.
716/// `None`: ignore the header and send everything; `Some(Err)`: unsatisfiable.
717fn parse_range(value: &str, len: u64) -> Option<Result<(u64, u64), ()>> {
718    let spec = value.trim().strip_prefix("bytes=")?;
719    if spec.contains(',') {
720        return None;
721    }
722    let (start, end) = spec.split_once('-')?;
723    let (start, end) = (start.trim(), end.trim());
724    if start.is_empty() {
725        let suffix: u64 = end.parse().ok()?;
726        if suffix == 0 || len == 0 {
727            return Some(Err(()));
728        }
729        return Some(Ok((len.saturating_sub(suffix), len - 1)));
730    }
731    let start: u64 = start.parse().ok()?;
732    let end = if end.is_empty() {
733        len.saturating_sub(1)
734    } else {
735        end.parse::<u64>().ok()?.min(len.saturating_sub(1))
736    };
737    if start >= len || start > end {
738        return Some(Err(()));
739    }
740    Some(Ok((start, end)))
741}
742
743fn file_response(request: &Request, path: &Path) -> io::Result<ResponseBox> {
744    let mut file = File::open(path)?;
745    let meta = file.metadata()?;
746    let len = meta.len();
747    let mut headers = vec![
748        header("Content-Type", &content_type(path)),
749        header("Accept-Ranges", "bytes"),
750    ];
751    if let Ok(modified) = meta.modified() {
752        headers.push(header("Last-Modified", &httpdate::fmt_http_date(modified)));
753    }
754
755    let range = request
756        .headers()
757        .iter()
758        .find(|h| h.field.equiv("Range"))
759        .and_then(|h| parse_range(h.value.as_str(), len));
760    let (status, body, body_len): (u16, Box<dyn Read + Send>, u64) = match range {
761        None => (200, Box::new(file), len),
762        Some(Ok((start, end))) => {
763            file.seek(SeekFrom::Start(start))?;
764            headers.push(header(
765                "Content-Range",
766                &format!("bytes {start}-{end}/{len}"),
767            ));
768            let n = end - start + 1;
769            (206, Box::new(file.take(n)), n)
770        }
771        Some(Err(())) => {
772            headers.push(header("Content-Range", &format!("bytes */{len}")));
773            (416, Box::new(io::empty()), 0)
774        }
775    };
776    // Always send Content-Length: many TVs can't stream chunked responses.
777    Ok(Response::new(
778        StatusCode(status),
779        headers,
780        body,
781        usize::try_from(body_len).ok(),
782        None,
783    )
784    .with_chunked_threshold(usize::MAX))
785}
786
787#[cfg(test)]
788mod tests {
789    use super::*;
790
791    #[test]
792    fn ssdp_reply_parsing() {
793        let reply = "HTTP/1.1 200 OK\r\nST: urn:schemas-upnp-org:service:AVTransport:1\r\n\
794                     USN: uuid:x::y\r\nLOCATION: http://10.0.0.5:1234/d.xml\r\n\r\n";
795        let d = parse_ssdp_reply(reply).unwrap();
796        assert_eq!(d.location, "http://10.0.0.5:1234/d.xml");
797        assert_eq!(d.usn.as_deref(), Some("uuid:x::y"));
798        assert!(parse_ssdp_reply(&reply.replace("AVTransport", "ContentDirectory")).is_none());
799        assert!(parse_ssdp_reply(&reply.replace("200 OK", "404 Not Found")).is_none());
800    }
801
802    #[test]
803    fn control_url_resolution() {
804        let loc = "http://10.0.0.5:1234/desc/d.xml";
805        assert_eq!(
806            resolve_control_url(loc, "/AVT/ctl").unwrap(),
807            "http://10.0.0.5:1234/AVT/ctl"
808        );
809        assert_eq!(
810            resolve_control_url(loc, "AVT/ctl").unwrap(),
811            "http://10.0.0.5:1234/AVT/ctl"
812        );
813        assert_eq!(
814            resolve_control_url("http://10.0.0.5/d.xml", "c").unwrap(),
815            "http://10.0.0.5:80/c"
816        );
817        assert_eq!(
818            resolve_control_url(loc, "http://other/c").unwrap(),
819            "http://other/c"
820        );
821    }
822
823    #[test]
824    fn description_parsing() {
825        let mut d = Device {
826            location: "http://10.0.0.5:59876/description.xml".into(),
827            ..Default::default()
828        };
829        parse_description(&mut d, &xml::description("Living Room & TV", "u-1")).unwrap();
830        assert_eq!(d.friendly_name.as_deref(), Some("Living Room & TV"));
831        assert_eq!(
832            d.control_url.as_deref(),
833            Some("http://10.0.0.5:59876/AVTransport/control")
834        );
835    }
836
837    #[test]
838    fn ranges() {
839        assert_eq!(parse_range("bytes=0-", 100), Some(Ok((0, 99))));
840        assert_eq!(parse_range("bytes=10-19", 100), Some(Ok((10, 19))));
841        assert_eq!(parse_range("bytes=90-200", 100), Some(Ok((90, 99))));
842        assert_eq!(parse_range("bytes=-10", 100), Some(Ok((90, 99))));
843        assert_eq!(parse_range("bytes=-500", 100), Some(Ok((0, 99))));
844        assert_eq!(parse_range("bytes=100-", 100), Some(Err(())));
845        assert_eq!(parse_range("bytes=20-10", 100), Some(Err(())));
846        assert_eq!(parse_range("bytes=0-1,5-6", 100), None);
847        assert_eq!(parse_range("items=0-1", 100), None);
848    }
849
850    fn status(state: &str, uri: Option<&str>) -> Option<Status> {
851        Some(Status {
852            state: state.into(),
853            uri: uri.map(str::to_string),
854        })
855    }
856
857    #[test]
858    fn watch_finishes_after_playing() {
859        let ours = "http://10.0.0.2:50001/videos/a%20b.mp4";
860        let mut w = Watch::new(ours);
861        let t = Duration::from_secs(5);
862        // STOPPED while loading is not the end
863        assert_eq!(w.update(status("STOPPED", None).as_ref(), t), None);
864        assert_eq!(w.update(status("TRANSITIONING", None).as_ref(), t), None);
865        assert_eq!(w.update(status("PLAYING", Some(ours)).as_ref(), t), None);
866        assert_eq!(w.update(status("PAUSED_PLAYBACK", None).as_ref(), t), None);
867        assert_eq!(
868            w.update(status("NO_MEDIA_PRESENT", Some("")).as_ref(), t),
869            Some(Ending::Finished)
870        );
871    }
872
873    #[test]
874    fn watch_never_started() {
875        let mut w = Watch::new("http://h/v.mp4");
876        assert_eq!(
877            w.update(status("STOPPED", None).as_ref(), START_GRACE),
878            Some(Ending::NeverStarted)
879        );
880    }
881
882    #[test]
883    fn watch_replaced() {
884        let ours = "http://h/videos/a%20b.mp4";
885        let mut w = Watch::new(ours);
886        let t = Duration::from_secs(5);
887        // same URI, escaped differently by the device
888        assert_eq!(
889            w.update(
890                status("PLAYING", Some("http://h/videos/a b.mp4")).as_ref(),
891                t
892            ),
893            None
894        );
895        assert_eq!(
896            w.update(status("PLAYING", Some("http://h/other.mp4")).as_ref(), t),
897            Some(Ending::Replaced)
898        );
899    }
900
901    #[test]
902    fn watch_gone_after_misses() {
903        let mut w = Watch::new("http://h/v.mp4");
904        let t = Duration::from_secs(5);
905        assert_eq!(w.update(None, t), None);
906        assert_eq!(w.update(None, t), None);
907        // an answer resets the count
908        assert_eq!(w.update(status("PLAYING", None).as_ref(), t), None);
909        for _ in 1..MAX_MISSES {
910            assert_eq!(w.update(None, t), None);
911        }
912        assert_eq!(w.update(None, t), Some(Ending::Gone));
913    }
914
915    #[test]
916    fn uri_comparison() {
917        let ours = "http://h/v?a=1&b=2";
918        assert!(!is_other_uri(ours, None));
919        assert!(!is_other_uri(ours, Some("  ")));
920        assert!(!is_other_uri(ours, Some("http://h/v?a=1&amp;b=2")));
921        assert!(is_other_uri(ours, Some("http://h/w")));
922    }
923
924    #[test]
925    fn response_fields() {
926        let body = xml::transport_info_response("STOPPED");
927        assert_eq!(
928            response_field(&body, "CurrentTransportState").as_deref(),
929            Some("STOPPED")
930        );
931        let body = xml::media_info_response("http://h/v?a=1&b=2");
932        assert_eq!(
933            response_field(&body, "CurrentURI").as_deref(),
934            Some("http://h/v?a=1&b=2")
935        );
936        assert_eq!(
937            response_field(&xml::media_info_response(""), "CurrentURI").as_deref(),
938            Some("")
939        );
940    }
941
942    #[test]
943    fn content_types() {
944        assert_eq!(content_type(Path::new("a.SRT")), "text/srt");
945        assert_eq!(content_type(Path::new("a.mp4")), "video/mp4");
946        assert_eq!(content_type(Path::new("a.mkv")), "video/x-matroska");
947        assert_eq!(content_type(Path::new("a.ts")), "video/mp2t");
948    }
949}