grafton_ndi/finder.rs
1//! NDI source discovery and network browsing.
2
3use std::{
4 collections::HashMap,
5 ffi::{CStr, CString},
6 fmt::{self, Display, Formatter},
7 ptr,
8 sync::{Arc, Mutex},
9 time::{Duration, Instant},
10};
11
12use crate::{ndi_lib::*, to_ms_checked, Error, Result, NDI};
13
14/// Configuration for NDI source discovery.
15///
16/// Use the builder pattern to create instances with specific settings.
17///
18/// # Examples
19///
20/// ```
21/// use grafton_ndi::FinderOptions;
22///
23/// // Find all sources including local ones
24/// let finder = FinderOptions::builder()
25/// .show_local_sources(true)
26/// .build();
27///
28/// // Find sources in specific groups
29/// let finder = FinderOptions::builder()
30/// .groups("Public,Studio")
31/// .build();
32///
33/// // Find sources on specific network segments
34/// let finder = FinderOptions::builder()
35/// .extra_ips("192.168.1.0/24,10.0.0.0/24")
36/// .build();
37/// ```
38#[derive(Debug, Default)]
39pub struct FinderOptions {
40 /// Whether to include local sources in discovery.
41 pub show_local_sources: bool,
42 /// Comma-separated list of groups to search (e.g., "Public,Private").
43 pub groups: Option<String>,
44 /// Additional IP addresses or ranges to search.
45 pub extra_ips: Option<String>,
46}
47
48impl FinderOptions {
49 /// Create a builder for configuring find options
50 pub fn builder() -> FinderOptionsBuilder {
51 FinderOptionsBuilder::new()
52 }
53}
54
55/// Builder for configuring FinderOptions with ergonomic method chaining
56#[derive(Debug, Clone)]
57pub struct FinderOptionsBuilder {
58 show_local_sources: Option<bool>,
59 groups: Option<String>,
60 extra_ips: Option<String>,
61}
62
63impl FinderOptionsBuilder {
64 /// Creates a new builder with default settings.
65 ///
66 /// Default settings:
67 /// - `show_local_sources`: `true`
68 /// - `groups`: `None` (search all groups)
69 /// - `extra_ips`: `None` (no additional IPs)
70 pub fn new() -> Self {
71 Self {
72 show_local_sources: None,
73 groups: None,
74 extra_ips: None,
75 }
76 }
77
78 /// Configure whether to show local sources
79 #[must_use]
80 pub fn show_local_sources(mut self, show: bool) -> Self {
81 self.show_local_sources = Some(show);
82 self
83 }
84
85 /// Set the groups to search
86 #[must_use]
87 pub fn groups<S: Into<String>>(mut self, groups: S) -> Self {
88 self.groups = Some(groups.into());
89 self
90 }
91
92 /// Set extra IPs to search
93 #[must_use]
94 pub fn extra_ips<S: Into<String>>(mut self, ips: S) -> Self {
95 self.extra_ips = Some(ips.into());
96 self
97 }
98
99 /// Build the FinderOptions
100 #[must_use]
101 pub fn build(self) -> FinderOptions {
102 FinderOptions {
103 show_local_sources: self.show_local_sources.unwrap_or(true),
104 groups: self.groups,
105 extra_ips: self.extra_ips,
106 }
107 }
108}
109
110impl Default for FinderOptionsBuilder {
111 fn default() -> Self {
112 Self::new()
113 }
114}
115
116/// Discovers NDI sources on the network.
117///
118/// `Finder` provides methods to discover and monitor NDI sources. It maintains
119/// a background thread that continuously updates the list of available sources.
120///
121/// # Examples
122///
123/// ```no_run
124/// # use grafton_ndi::{NDI, FinderOptions, Finder};
125/// # use std::time::Duration;
126/// # fn main() -> Result<(), grafton_ndi::Error> {
127/// let ndi = NDI::new()?;
128/// let options = FinderOptions::builder().show_local_sources(true).build();
129/// let finder = Finder::new(&ndi, &options)?;
130///
131/// // Wait for initial discovery
132/// if finder.wait_for_sources(Duration::from_secs(5))? {
133/// let sources = finder.current_sources()?;
134/// for source in sources {
135/// println!("Found: {}", source);
136/// }
137/// }
138/// # Ok(())
139/// # }
140/// ```
141pub struct Finder {
142 instance: NDIlib_find_instance_t,
143 _groups: Option<CString>, // Hold ownership of CStrings
144 _extra_ips: Option<CString>, // to ensure they outlive SDK usage
145 _ndi: NDI,
146}
147
148impl Finder {
149 /// Creates a new source finder with the specified settings.
150 ///
151 /// # Arguments
152 ///
153 /// * `ndi` - The NDI instance (cloned internally to keep the runtime alive)
154 /// * `settings` - Configuration for source discovery
155 ///
156 /// # Errors
157 ///
158 /// Returns an error if the finder cannot be created, typically due to
159 /// invalid settings or network issues.
160 pub fn new(ndi: &NDI, settings: &FinderOptions) -> Result<Self> {
161 let groups_cstr = settings
162 .groups
163 .as_deref()
164 .map(CString::new)
165 .transpose()
166 .map_err(Error::InvalidCString)?;
167 let extra_ips_cstr = settings
168 .extra_ips
169 .as_deref()
170 .map(CString::new)
171 .transpose()
172 .map_err(Error::InvalidCString)?;
173
174 let create_settings = NDIlib_find_create_t {
175 show_local_sources: settings.show_local_sources,
176 p_groups: groups_cstr.as_ref().map_or(ptr::null(), |s| s.as_ptr()),
177 p_extra_ips: extra_ips_cstr.as_ref().map_or(ptr::null(), |s| s.as_ptr()),
178 };
179
180 let instance = unsafe { NDIlib_find_create_v2(&create_settings) };
181 if instance.is_null() {
182 return Err(Error::InitializationFailed(
183 "NDIlib_find_create_v2 failed".into(),
184 ));
185 }
186 Ok(Self {
187 instance,
188 _groups: groups_cstr,
189 _extra_ips: extra_ips_cstr,
190 _ndi: ndi.clone(),
191 })
192 }
193
194 /// Waits for the source list to change.
195 ///
196 /// This method blocks until the list of discovered sources changes or the
197 /// timeout expires. Use this to efficiently monitor for source changes.
198 ///
199 /// # Arguments
200 ///
201 /// * `timeout` - Maximum time to wait ([`Duration::ZERO`] = no wait).
202 /// Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
203 ///
204 /// # Returns
205 ///
206 /// `true` if the source list changed, `false` if the timeout expired.
207 ///
208 /// # Errors
209 ///
210 /// Returns [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`].
211 ///
212 /// # Examples
213 ///
214 /// ```no_run
215 /// # use grafton_ndi::{NDI, FinderOptions, Finder};
216 /// # use std::time::Duration;
217 /// # fn main() -> Result<(), grafton_ndi::Error> {
218 /// # let ndi = NDI::new()?;
219 /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
220 /// // Wait up to 5 seconds for changes
221 /// if finder.wait_for_sources(Duration::from_secs(5))? {
222 /// println!("Source list changed!");
223 /// }
224 /// # Ok(())
225 /// # }
226 /// ```
227 pub fn wait_for_sources(&self, timeout: Duration) -> Result<bool> {
228 let timeout_ms = to_ms_checked(timeout)?;
229 Ok(unsafe { NDIlib_find_wait_for_sources(self.instance, timeout_ms) })
230 }
231
232 /// Returns the current list of discovered sources (snapshot).
233 ///
234 /// This method uses `NDIlib_find_get_current_sources` which provides a snapshot
235 /// of the current source list without any additional network discovery.
236 ///
237 /// Available since NDI SDK 6.0.
238 ///
239 /// # Returns
240 ///
241 /// A vector of currently known sources. May be empty if no sources are found.
242 ///
243 /// # Examples
244 ///
245 /// ```no_run
246 /// # use grafton_ndi::{NDI, FinderOptions, Finder};
247 /// # fn main() -> Result<(), grafton_ndi::Error> {
248 /// # let ndi = NDI::new()?;
249 /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
250 /// // Get current snapshot of sources
251 /// let sources = finder.current_sources()?;
252 ///
253 /// for source in sources {
254 /// println!("Current source: {}", source);
255 /// }
256 /// # Ok(())
257 /// # }
258 /// ```
259 pub fn current_sources(&self) -> Result<Vec<Source>> {
260 let mut num_sources = 0;
261 let sources_ptr =
262 unsafe { NDIlib_find_get_current_sources(self.instance, &mut num_sources) };
263 if sources_ptr.is_null() {
264 return Ok(vec![]);
265 }
266
267 // Convert each source, skipping any that fail null checks
268 let mut sources = Vec::with_capacity(num_sources as usize);
269 for i in 0..num_sources {
270 let source_ptr = unsafe { sources_ptr.add(i as usize) };
271 match Source::try_from_raw(source_ptr) {
272 Ok(source) => sources.push(source),
273 Err(_e) => {
274 // Skip invalid sources (null pointers from SDK)
275 // This is a defensive measure - the SDK should not return null entries,
276 // but we handle it gracefully if it does
277 #[cfg(debug_assertions)]
278 eprintln!("Warning: Skipping invalid source at index {i}: {_e}");
279 }
280 }
281 }
282 Ok(sources)
283 }
284
285 /// Poll discovery until `accept` selects a value or `timeout` elapses.
286 ///
287 /// After each source-list snapshot, `accept` is handed the current sources
288 /// and may return `Some` to stop immediately; otherwise this blocks on the
289 /// next source-list change and retries until the deadline. A single
290 /// [`wait_for_sources`](Self::wait_for_sources) returns on the *first*
291 /// change, yielding only a partial snapshot when discovery is staggered
292 /// (unicast / IP-hint-only networks where responders trickle in across
293 /// several notifications); polling to the deadline makes resolution robust
294 /// against that while still returning the instant `accept` is satisfied.
295 ///
296 /// Returns `Ok(None)` if the deadline passes without `accept` selecting a
297 /// value.
298 fn poll_until_deadline<T>(
299 &self,
300 timeout: Duration,
301 mut accept: impl FnMut(Vec<Source>) -> Option<T>,
302 ) -> Result<Option<T>> {
303 // Validate up front so an over-long timeout is rejected even when the
304 // first snapshot already satisfies `accept`.
305 to_ms_checked(timeout)?;
306 let deadline = Instant::now() + timeout;
307 loop {
308 if let Some(selected) = accept(self.current_sources()?) {
309 return Ok(Some(selected));
310 }
311 let remaining = deadline.saturating_duration_since(Instant::now());
312 if remaining.is_zero() {
313 return Ok(None);
314 }
315 // Block until the source list changes or the remaining window
316 // expires; either way the next iteration re-snapshots and the
317 // zero-`remaining` check then terminates the loop.
318 self.wait_for_sources(remaining)?;
319 }
320 }
321
322 /// Discovers sources for up to `timeout`, returning the complete set.
323 ///
324 /// This honors the full `timeout` window and returns every source observed
325 /// within it. Unlike a single [`wait_for_sources`](Self::wait_for_sources)
326 /// followed by a snapshot — which returns on the *first* source-list change
327 /// and so can miss responders that announce late on staggered or unicast
328 /// (IP-hint-only) networks — enumeration here is complete and deterministic.
329 ///
330 /// For an immediate, non-blocking snapshot of already-known sources use
331 /// [`current_sources`](Self::current_sources); to resolve a single specific
332 /// host use [`SourceCache::find_by_host`].
333 ///
334 /// # Arguments
335 ///
336 /// * `timeout` - Discovery window. [`Duration::ZERO`] returns an immediate
337 /// snapshot. Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
338 ///
339 /// # Returns
340 ///
341 /// Every source discovered within the window. May be empty if none appear.
342 ///
343 /// # Errors
344 ///
345 /// Returns [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`].
346 ///
347 /// # Examples
348 ///
349 /// ```no_run
350 /// # use grafton_ndi::{NDI, FinderOptions, Finder};
351 /// # use std::time::Duration;
352 /// # fn main() -> Result<(), grafton_ndi::Error> {
353 /// # let ndi = NDI::new()?;
354 /// # let finder = Finder::new(&ndi, &FinderOptions::default())?;
355 /// // Discover for up to 5 seconds, then list everything found
356 /// let sources = finder.find_sources(Duration::from_secs(5))?;
357 ///
358 /// for source in sources {
359 /// println!("Found: {}", source);
360 /// }
361 /// # Ok(())
362 /// # }
363 /// ```
364 pub fn find_sources(&self, timeout: Duration) -> Result<Vec<Source>> {
365 // `accept` never short-circuits, so the loop runs to the deadline and
366 // we keep the freshest snapshot, which is the complete set.
367 let mut discovered = Vec::new();
368 self.poll_until_deadline(timeout, |sources| {
369 discovered = sources;
370 None::<()>
371 })?;
372 Ok(discovered)
373 }
374}
375
376impl Drop for Finder {
377 fn drop(&mut self) {
378 unsafe { NDIlib_find_destroy(self.instance) };
379 }
380}
381
382/// # Safety
383///
384/// The NDI SDK documentation states that find operations are thread-safe.
385/// `NDIlib_find_create_v2`, `NDIlib_find_wait_for_sources`, and `NDIlib_find_get_sources`
386/// can be called from multiple threads. The Finder struct only holds an opaque pointer
387/// returned by the SDK and does not perform any mutations that could cause data races.
388unsafe impl std::marker::Send for Finder {}
389
390/// # Safety
391///
392/// The NDI SDK documentation guarantees thread-safety for find operations.
393/// Multiple threads can safely call methods on a shared Finder instance as the
394/// SDK handles all necessary synchronization internally.
395unsafe impl std::marker::Sync for Finder {}
396
397/// Network address of an NDI source.
398///
399/// NDI sources can be addressed via URL (for NDI HX sources) or IP address
400/// (for standard NDI sources).
401#[derive(Debug, Default, Clone)]
402pub enum SourceAddress {
403 /// No address available.
404 #[default]
405 None,
406 /// URL address (typically for NDI HX sources).
407 Url(String),
408 /// IP address (for standard NDI sources).
409 Ip(String),
410}
411
412impl SourceAddress {
413 /// Check if this address contains the given host or IP.
414 ///
415 /// This performs a substring match against the address string, useful for
416 /// finding sources by hostname or IP address.
417 ///
418 /// # Arguments
419 ///
420 /// * `host` - The hostname or IP address to search for
421 ///
422 /// # Examples
423 ///
424 /// ```
425 /// use grafton_ndi::SourceAddress;
426 ///
427 /// let addr = SourceAddress::Ip("192.168.1.100:5960".to_string());
428 /// assert!(addr.contains_host("192.168.1.100"));
429 /// assert!(addr.contains_host("192.168.1"));
430 ///
431 /// let url = SourceAddress::Url("http://camera.local:8080".to_string());
432 /// assert!(url.contains_host("camera.local"));
433 /// ```
434 pub fn contains_host(&self, host: &str) -> bool {
435 match self {
436 SourceAddress::Ip(ip) => ip.contains(host),
437 SourceAddress::Url(url) => url.contains(host),
438 SourceAddress::None => false,
439 }
440 }
441
442 /// Extract the port number from this address if present.
443 ///
444 /// Parses the port from addresses in the format `host:port`.
445 ///
446 /// # Returns
447 ///
448 /// `Some(port)` if a valid port is found, `None` otherwise.
449 ///
450 /// # Examples
451 ///
452 /// ```
453 /// use grafton_ndi::SourceAddress;
454 ///
455 /// let addr = SourceAddress::Ip("192.168.1.100:5960".to_string());
456 /// assert_eq!(addr.port(), Some(5960));
457 ///
458 /// let no_port = SourceAddress::Ip("192.168.1.100".to_string());
459 /// assert_eq!(no_port.port(), None);
460 ///
461 /// let url = SourceAddress::Url("http://camera.local:8080".to_string());
462 /// assert_eq!(url.port(), Some(8080));
463 /// ```
464 pub fn port(&self) -> Option<u16> {
465 let addr_str = match self {
466 SourceAddress::Ip(ip) => ip.as_str(),
467 SourceAddress::Url(url) => url.as_str(),
468 SourceAddress::None => return None,
469 };
470
471 if let SourceAddress::Url(_) = self {
472 // Try to parse as URL to extract port
473 // Format might be http://host:port or similar
474 if let Some(port_start) = addr_str.rfind(':') {
475 // Make sure this isn't the :// in the scheme
476 let before_colon = &addr_str[..port_start];
477 if !before_colon.ends_with('/') {
478 // Try to parse what comes after the colon
479 let port_str = &addr_str[port_start + 1..];
480 // Remove any trailing path
481 let port_str = port_str.split('/').next().unwrap_or(port_str);
482 return port_str.parse::<u16>().ok();
483 }
484 }
485 } else if let Some(colon_pos) = addr_str.rfind(':') {
486 let port_str = &addr_str[colon_pos + 1..];
487 return port_str.parse::<u16>().ok();
488 }
489
490 None
491 }
492}
493
494/// Represents an NDI source discovered on the network.
495///
496/// Sources contain a human-readable name and network address. The name
497/// typically includes the machine name and source name (e.g., "MACHINE (Source)").
498///
499/// # Examples
500///
501/// ```
502/// use grafton_ndi::{Source, SourceAddress};
503///
504/// let source = Source {
505/// name: "LAPTOP (Camera 1)".to_string(),
506/// address: SourceAddress::Ip("192.168.1.100:5960".to_string()),
507/// };
508///
509/// println!("Source: {}", source); // Displays: LAPTOP (Camera 1)@192.168.1.100:5960
510/// ```
511#[derive(Debug, Default, Clone)]
512pub struct Source {
513 /// The NDI source name (e.g., "MACHINE (Source Name)").
514 pub name: String,
515 /// The network address for connecting to this source.
516 pub address: SourceAddress,
517}
518
519#[repr(C)]
520pub(crate) struct RawSource {
521 _name: CString,
522 _url_address: Option<CString>,
523 _ip_address: Option<CString>,
524 pub raw: NDIlib_source_t,
525}
526
527impl Source {
528 /// Check if this source matches a given host or IP address.
529 ///
530 /// This method checks both the source name and address for a match,
531 /// making it easy to find sources by hostname or IP.
532 ///
533 /// # Arguments
534 ///
535 /// * `host` - The hostname or IP address to match against
536 ///
537 /// # Examples
538 ///
539 /// ```
540 /// use grafton_ndi::{Source, SourceAddress};
541 ///
542 /// let source = Source {
543 /// name: "CAMERA1 (Chan1, 192.168.0.107)".to_string(),
544 /// address: SourceAddress::Ip("192.168.0.107:5960".to_string()),
545 /// };
546 ///
547 /// assert!(source.matches_host("192.168.0.107"));
548 /// assert!(source.matches_host("CAMERA1"));
549 /// assert!(!source.matches_host("192.168.1.1"));
550 /// ```
551 pub fn matches_host(&self, host: &str) -> bool {
552 self.name.contains(host) || self.address.contains_host(host)
553 }
554
555 /// Extract the IP address from this source if available.
556 ///
557 /// For IP-based sources, this returns the IP portion without the port.
558 /// For URL-based sources, this extracts the hostname portion.
559 ///
560 /// # Returns
561 ///
562 /// `Some(ip)` if an IP or hostname is found, `None` otherwise.
563 ///
564 /// # Examples
565 ///
566 /// ```
567 /// use grafton_ndi::{Source, SourceAddress};
568 ///
569 /// let source = Source {
570 /// name: "CAMERA1".to_string(),
571 /// address: SourceAddress::Ip("192.168.1.100:5960".to_string()),
572 /// };
573 ///
574 /// assert_eq!(source.ip_address(), Some("192.168.1.100"));
575 /// ```
576 pub fn ip_address(&self) -> Option<&str> {
577 match &self.address {
578 SourceAddress::Ip(ip) => Some(ip.split(':').next().unwrap_or(ip)),
579 SourceAddress::Url(url) => {
580 let without_scheme = if let Some(idx) = url.find("://") {
581 &url[idx + 3..]
582 } else {
583 url.as_str()
584 };
585 let host = without_scheme
586 .split(':')
587 .next()
588 .unwrap_or(without_scheme)
589 .split('/')
590 .next()
591 .unwrap_or(without_scheme);
592 if host.is_empty() {
593 None
594 } else {
595 Some(host)
596 }
597 }
598 SourceAddress::None => None,
599 }
600 }
601
602 /// Extract the hostname or IP without port.
603 ///
604 /// This is an alias for `ip_address()` for better API discoverability.
605 ///
606 /// # Examples
607 ///
608 /// ```
609 /// use grafton_ndi::{Source, SourceAddress};
610 ///
611 /// let source = Source {
612 /// name: "CAMERA1".to_string(),
613 /// address: SourceAddress::Ip("192.168.1.100:5960".to_string()),
614 /// };
615 ///
616 /// assert_eq!(source.host(), Some("192.168.1.100"));
617 /// ```
618 pub fn host(&self) -> Option<&str> {
619 self.ip_address()
620 }
621
622 /// Safely convert from raw NDI source pointer with null checks.
623 ///
624 /// This performs defensive checks at the FFI boundary to prevent undefined behavior
625 /// from null or invalid pointers returned by the NDI SDK.
626 ///
627 /// # Errors
628 ///
629 /// Returns `Error::NullPointer` if:
630 /// - The source pointer itself is null
631 /// - The `p_ndi_name` field is null
632 ///
633 /// # Safety
634 ///
635 /// The caller must ensure that if `source_ptr` is non-null, it points to a valid
636 /// `NDIlib_source_t` with proper lifetime.
637 pub(crate) fn try_from_raw(source_ptr: *const NDIlib_source_t) -> Result<Self> {
638 // Check top-level pointer
639 if source_ptr.is_null() {
640 return Err(Error::NullPointer("NDIlib_source_t pointer".into()));
641 }
642
643 let ndi_source = unsafe { &*source_ptr };
644
645 // Check p_ndi_name field
646 if ndi_source.p_ndi_name.is_null() {
647 return Err(Error::NullPointer("NDIlib_source_t::p_ndi_name".into()));
648 }
649
650 let name = unsafe {
651 CStr::from_ptr(ndi_source.p_ndi_name)
652 .to_string_lossy()
653 .into_owned()
654 };
655
656 // For unions, we need to determine which field is active.
657 // NDI SDK convention: URL addresses are used for NDI HX sources,
658 // IP addresses for regular sources. We check URL first as it's
659 // typically used for newer/HX sources.
660 let address = unsafe {
661 if !ndi_source.__bindgen_anon_1.p_url_address.is_null() {
662 let url_str = CStr::from_ptr(ndi_source.__bindgen_anon_1.p_url_address)
663 .to_string_lossy()
664 .into_owned();
665 if url_str.contains("://") {
666 SourceAddress::Url(url_str)
667 } else {
668 SourceAddress::Ip(url_str)
669 }
670 } else {
671 SourceAddress::None
672 }
673 };
674
675 Ok(Source { name, address })
676 }
677
678 /// Convert to raw format for FFI use
679 ///
680 /// # Safety
681 ///
682 /// The returned RawSource struct uses #[repr(C)] to guarantee C-compatible layout
683 /// for safe FFI interop with the NDI SDK.
684 pub(crate) fn to_raw(&self) -> Result<RawSource> {
685 let name = CString::new(self.name.clone()).map_err(Error::InvalidCString)?;
686
687 let (url_address, ip_address, __bindgen_anon_1) = match &self.address {
688 SourceAddress::Url(url) => {
689 let url_cstr = CString::new(url.clone()).map_err(Error::InvalidCString)?;
690 let p_url = url_cstr.as_ptr();
691 (
692 Some(url_cstr),
693 None,
694 NDIlib_source_t__bindgen_ty_1 {
695 p_url_address: p_url,
696 },
697 )
698 }
699 SourceAddress::Ip(ip) => {
700 let ip_cstr = CString::new(ip.clone()).map_err(Error::InvalidCString)?;
701 let p_ip = ip_cstr.as_ptr();
702 (
703 None,
704 Some(ip_cstr),
705 NDIlib_source_t__bindgen_ty_1 { p_ip_address: p_ip },
706 )
707 }
708 SourceAddress::None => (
709 None,
710 None,
711 NDIlib_source_t__bindgen_ty_1 {
712 p_ip_address: ptr::null(),
713 },
714 ),
715 };
716
717 let p_ndi_name = name.as_ptr();
718
719 Ok(RawSource {
720 _name: name,
721 _url_address: url_address,
722 _ip_address: ip_address,
723 raw: NDIlib_source_t {
724 p_ndi_name,
725 __bindgen_anon_1,
726 },
727 })
728 }
729}
730
731impl Display for Source {
732 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
733 match &self.address {
734 SourceAddress::Url(url) => write!(f, "{name}@{url}", name = self.name),
735 SourceAddress::Ip(ip) => write!(f, "{name}@{ip}", name = self.name),
736 SourceAddress::None => write!(f, "{name}", name = self.name),
737 }
738 }
739}
740
741/// Cached NDI source with associated NDI runtime instance.
742///
743/// The `_ndi` field keeps the NDI runtime alive for as long as the source is cached,
744/// ensuring the runtime doesn't get destroyed while sources are still in use.
745#[derive(Clone)]
746struct CachedSource {
747 _ndi: Arc<NDI>,
748 source: Source,
749}
750
751/// Thread-safe cache for NDI source discovery.
752///
753/// `SourceCache` eliminates the need for applications to manually cache NDI instances
754/// and discovered sources. It handles expensive NDI initialization and source discovery
755/// operations internally with built-in caching.
756///
757/// # Thread Safety
758///
759/// `SourceCache` is thread-safe and can be shared across threads using `Arc<SourceCache>`.
760/// Interior mutability is handled internally with proper synchronization.
761///
762/// # Examples
763///
764/// ```no_run
765/// use grafton_ndi::SourceCache;
766/// use std::time::Duration;
767///
768/// # fn main() -> Result<(), grafton_ndi::Error> {
769/// // Create a cache instance
770/// let cache = SourceCache::new()?;
771///
772/// // Find a source by hostname or IP with automatic caching
773/// let source = cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
774/// println!("Found source: {}", source);
775///
776/// // Subsequent lookups use the cache
777/// let same_source = cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
778///
779/// # Ok(())
780/// # }
781/// ```
782pub struct SourceCache {
783 cache: Mutex<HashMap<String, CachedSource>>,
784}
785
786impl SourceCache {
787 /// Create a new source cache.
788 ///
789 /// # Errors
790 ///
791 /// Returns an error if the NDI runtime cannot be initialized.
792 ///
793 /// # Examples
794 ///
795 /// ```no_run
796 /// use grafton_ndi::SourceCache;
797 ///
798 /// # fn main() -> Result<(), grafton_ndi::Error> {
799 /// let cache = SourceCache::new()?;
800 /// # Ok(())
801 /// # }
802 /// ```
803 pub fn new() -> Result<Self> {
804 Ok(Self {
805 cache: Mutex::new(HashMap::new()),
806 })
807 }
808
809 /// Find a source by IP address or hostname with built-in caching.
810 ///
811 /// This method handles NDI initialization and source discovery internally.
812 /// If a source matching the host has been previously found, it returns the
813 /// cached result. Otherwise, it performs NDI discovery and caches the result.
814 ///
815 /// # Arguments
816 ///
817 /// * `host` - The hostname or IP address to search for
818 /// * `timeout` - Maximum time to wait for source discovery.
819 /// Must not exceed [`crate::MAX_TIMEOUT`] (~49.7 days).
820 ///
821 /// # Returns
822 ///
823 /// The discovered source, or an error if no matching source is found or
824 /// the timeout expires.
825 ///
826 /// # Errors
827 ///
828 /// - [`Error::NoSourcesFound`] if no source matching the host is discovered
829 /// - [`Error::InvalidConfiguration`] if `timeout` exceeds [`crate::MAX_TIMEOUT`]
830 /// - Other errors if NDI initialization or discovery fails
831 ///
832 /// # Examples
833 ///
834 /// ```no_run
835 /// use grafton_ndi::SourceCache;
836 /// use std::time::Duration;
837 ///
838 /// # fn main() -> Result<(), grafton_ndi::Error> {
839 /// let cache = SourceCache::new()?;
840 ///
841 /// // Find by IP address
842 /// let source = cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
843 ///
844 /// // Find by partial IP
845 /// let source = cache.find_by_host("192.168.0", Duration::from_secs(5))?;
846 ///
847 /// // Find by name
848 /// let source = cache.find_by_host("CAMERA1", Duration::from_secs(5))?;
849 /// # Ok(())
850 /// # }
851 /// ```
852 pub fn find_by_host(&self, host: &str, timeout: Duration) -> Result<Source> {
853 {
854 let cache = self
855 .cache
856 .lock()
857 .unwrap_or_else(|poisoned| poisoned.into_inner());
858 if let Some(cached) = cache.get(host) {
859 return Ok(cached.source.clone());
860 }
861 }
862
863 let ndi = Arc::new(NDI::new()?);
864 // Use extra_ips to hint NDI to look at the specific host IP/network segment
865 // This significantly improves discovery speed and reliability
866 let options = FinderOptions::builder()
867 .show_local_sources(true)
868 .extra_ips(host)
869 .build();
870 let finder = Finder::new(&ndi, &options)?;
871
872 // Resolve the host by polling discovery to the deadline, returning the
873 // instant a matching source appears. See `Finder::poll_until_deadline`
874 // for why this beats a single `wait_for_sources` on staggered networks.
875 let source = finder
876 .poll_until_deadline(timeout, |sources| {
877 sources.into_iter().find(|s| s.matches_host(host))
878 })?
879 .ok_or_else(|| Error::NoSourcesFound {
880 criteria: format!("host: {host}"),
881 })?;
882
883 {
884 let mut cache = self
885 .cache
886 .lock()
887 .unwrap_or_else(|poisoned| poisoned.into_inner());
888 cache.insert(
889 host.to_string(),
890 CachedSource {
891 _ndi: ndi.clone(),
892 source: source.clone(),
893 },
894 );
895 }
896
897 Ok(source)
898 }
899
900 /// Invalidate the cache entry for a specific host.
901 ///
902 /// This is useful when a source goes offline or when you want to force
903 /// a fresh discovery on the next lookup.
904 ///
905 /// # Arguments
906 ///
907 /// * `host` - The hostname or IP address to remove from the cache
908 ///
909 /// # Examples
910 ///
911 /// ```no_run
912 /// use grafton_ndi::SourceCache;
913 /// use std::time::Duration;
914 ///
915 /// # fn main() -> Result<(), grafton_ndi::Error> {
916 /// let cache = SourceCache::new()?;
917 /// let source = cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
918 ///
919 /// // Later, if the source goes offline
920 /// cache.invalidate("192.168.0.107");
921 ///
922 /// // Next lookup will perform fresh discovery
923 /// # Ok(())
924 /// # }
925 /// ```
926 pub fn invalidate(&self, host: &str) {
927 let mut cache = self
928 .cache
929 .lock()
930 .unwrap_or_else(|poisoned| poisoned.into_inner());
931 cache.remove(host);
932 }
933
934 /// Clear all cached sources.
935 ///
936 /// This removes all entries from the cache, forcing fresh discovery
937 /// for all subsequent lookups.
938 ///
939 /// # Examples
940 ///
941 /// ```no_run
942 /// use grafton_ndi::SourceCache;
943 /// use std::time::Duration;
944 ///
945 /// # fn main() -> Result<(), grafton_ndi::Error> {
946 /// let cache = SourceCache::new()?;
947 /// cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
948 /// cache.find_by_host("192.168.0.108", Duration::from_secs(5))?;
949 ///
950 /// // Clear all cached sources
951 /// cache.clear();
952 /// # Ok(())
953 /// # }
954 /// ```
955 pub fn clear(&self) {
956 let mut cache = self
957 .cache
958 .lock()
959 .unwrap_or_else(|poisoned| poisoned.into_inner());
960 cache.clear();
961 }
962
963 /// Get the number of cached sources.
964 ///
965 /// This can be useful for monitoring cache usage and debugging.
966 ///
967 /// # Examples
968 ///
969 /// ```no_run
970 /// use grafton_ndi::SourceCache;
971 /// use std::time::Duration;
972 ///
973 /// # fn main() -> Result<(), grafton_ndi::Error> {
974 /// let cache = SourceCache::new()?;
975 /// assert_eq!(cache.len(), 0);
976 ///
977 /// cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
978 /// assert_eq!(cache.len(), 1);
979 /// # Ok(())
980 /// # }
981 /// ```
982 pub fn len(&self) -> usize {
983 let cache = self
984 .cache
985 .lock()
986 .unwrap_or_else(|poisoned| poisoned.into_inner());
987 cache.len()
988 }
989
990 /// Check if the cache is empty.
991 ///
992 /// # Examples
993 ///
994 /// ```no_run
995 /// use grafton_ndi::SourceCache;
996 /// use std::time::Duration;
997 ///
998 /// # fn main() -> Result<(), grafton_ndi::Error> {
999 /// let cache = SourceCache::new()?;
1000 /// assert!(cache.is_empty());
1001 ///
1002 /// cache.find_by_host("192.168.0.107", Duration::from_secs(5))?;
1003 /// assert!(!cache.is_empty());
1004 /// # Ok(())
1005 /// # }
1006 /// ```
1007 pub fn is_empty(&self) -> bool {
1008 let cache = self
1009 .cache
1010 .lock()
1011 .unwrap_or_else(|poisoned| poisoned.into_inner());
1012 cache.is_empty()
1013 }
1014}
1015
1016impl Default for SourceCache {
1017 fn default() -> Self {
1018 Self {
1019 cache: Mutex::new(HashMap::new()),
1020 }
1021 }
1022}
1023
1024#[cfg(test)]
1025mod tests {
1026 use super::*;
1027 use std::ffi::CString;
1028
1029 #[test]
1030 fn test_try_from_raw_null_pointer() {
1031 // Test that null pointer is rejected
1032 let result = Source::try_from_raw(ptr::null());
1033 assert!(result.is_err());
1034 match result {
1035 Err(Error::NullPointer(msg)) => {
1036 assert!(msg.contains("NDIlib_source_t pointer"));
1037 }
1038 _ => panic!("Expected NullPointer error"),
1039 }
1040 }
1041
1042 #[test]
1043 fn test_try_from_raw_null_name() {
1044 // Create a source with null p_ndi_name
1045 let source = NDIlib_source_t {
1046 p_ndi_name: ptr::null(),
1047 __bindgen_anon_1: NDIlib_source_t__bindgen_ty_1 {
1048 p_ip_address: ptr::null(),
1049 },
1050 };
1051
1052 let result = Source::try_from_raw(&source as *const _);
1053 assert!(result.is_err());
1054 match result {
1055 Err(Error::NullPointer(msg)) => {
1056 assert!(msg.contains("p_ndi_name"));
1057 }
1058 _ => panic!("Expected NullPointer error for null name"),
1059 }
1060 }
1061
1062 #[test]
1063 fn test_try_from_raw_valid_source_with_ip() {
1064 // Create valid C strings
1065 let name = CString::new("Test Source").unwrap();
1066 let ip = CString::new("192.168.1.100:5960").unwrap();
1067
1068 let source = NDIlib_source_t {
1069 p_ndi_name: name.as_ptr(),
1070 __bindgen_anon_1: NDIlib_source_t__bindgen_ty_1 {
1071 p_ip_address: ip.as_ptr(),
1072 },
1073 };
1074
1075 let result = Source::try_from_raw(&source as *const _);
1076 assert!(result.is_ok());
1077
1078 let source = result.unwrap();
1079 assert_eq!(source.name, "Test Source");
1080 match source.address {
1081 SourceAddress::Ip(ip_str) => {
1082 assert_eq!(ip_str, "192.168.1.100:5960");
1083 }
1084 _ => panic!("Expected IP address"),
1085 }
1086 }
1087
1088 #[test]
1089 fn test_try_from_raw_valid_source_with_url() {
1090 // Create valid C strings
1091 let name = CString::new("HX Source").unwrap();
1092 let url = CString::new("http://camera.local:8080/ndi").unwrap();
1093
1094 let source = NDIlib_source_t {
1095 p_ndi_name: name.as_ptr(),
1096 __bindgen_anon_1: NDIlib_source_t__bindgen_ty_1 {
1097 p_url_address: url.as_ptr(),
1098 },
1099 };
1100
1101 let result = Source::try_from_raw(&source as *const _);
1102 assert!(result.is_ok());
1103
1104 let source = result.unwrap();
1105 assert_eq!(source.name, "HX Source");
1106 match source.address {
1107 SourceAddress::Url(url_str) => {
1108 assert_eq!(url_str, "http://camera.local:8080/ndi");
1109 }
1110 _ => panic!("Expected URL address"),
1111 }
1112 }
1113
1114 #[test]
1115 fn test_try_from_raw_valid_source_no_address() {
1116 // Create valid C string for name, null for address
1117 let name = CString::new("Source No Addr").unwrap();
1118
1119 let source = NDIlib_source_t {
1120 p_ndi_name: name.as_ptr(),
1121 __bindgen_anon_1: NDIlib_source_t__bindgen_ty_1 {
1122 p_ip_address: ptr::null(),
1123 },
1124 };
1125
1126 let result = Source::try_from_raw(&source as *const _);
1127 assert!(result.is_ok());
1128
1129 let source = result.unwrap();
1130 assert_eq!(source.name, "Source No Addr");
1131 match source.address {
1132 SourceAddress::None => {}
1133 _ => panic!("Expected None address"),
1134 }
1135 }
1136
1137 #[test]
1138 fn test_source_cache_poison_recovery() {
1139 use std::sync::Arc;
1140 use std::thread;
1141
1142 let cache = Arc::new(SourceCache::default());
1143 let cache_clone = Arc::clone(&cache);
1144
1145 // Spawn a thread that acquires the lock and panics
1146 let handle = thread::spawn(move || {
1147 let _lock = cache_clone
1148 .cache
1149 .lock()
1150 .unwrap_or_else(|poisoned| poisoned.into_inner());
1151 panic!("intentional panic while holding lock");
1152 });
1153
1154 // Wait for the thread to panic
1155 let _ = handle.join();
1156
1157 // Verify all cache methods still work after poison
1158 assert!(cache.is_empty(), "is_empty should work after poison");
1159 assert_eq!(cache.len(), 0, "len should work after poison");
1160 cache.invalidate("test");
1161 cache.clear();
1162 }
1163}