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, 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);
26const POLL_EVERY: Duration = Duration::from_secs(2);
28const POLL_TIMEOUT: Duration = Duration::from_secs(3);
29const START_GRACE: Duration = Duration::from_secs(30);
32const MAX_MISSES: u32 = 3;
34const 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 {
52 #[arg(short)]
54 verbose: bool,
55 },
56 Stop(Target),
58 Seek {
60 #[command(flatten)]
61 target: Target,
62 to: String,
64 },
65 Play {
67 video_file: String,
68 #[command(flatten)]
69 target: Target,
70 #[arg(long)]
72 title: Option<String>,
73 },
74}
75
76#[derive(ClapArgs)]
77struct Target {
78 #[arg(short)]
80 verbose: bool,
81 #[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
149struct Renderer {
151 control_url: String,
152 ip: Option<IpAddr>,
153}
154
155fn 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
184fn 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
202fn 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 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 Status(u16),
324 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
337fn 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
358fn 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
372fn 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
379fn 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
401fn 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
412struct Status {
414 state: String,
416 uri: Option<String>,
418}
419
420#[derive(Debug, PartialEq)]
422enum Ending {
423 Finished,
425 Replaced,
427 Gone,
429 NeverStarted,
431}
432
433struct 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 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 _ => None,
469 }
470 }
471}
472
473fn 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 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 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 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
715fn 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 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 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 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 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&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}