Skip to main content

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}