Skip to main content

sonos_api/
subscription.rs

1//! Managed UPnP subscription with lifecycle management
2//!
3//! This module provides a higher-level subscription API that handles the complete
4//! lifecycle of UPnP subscriptions with manual renewal and proper cleanup.
5
6use crate::services::events::{
7    RenewOperation, RenewRequest, RenewResponse, SubscribeOperation, SubscribeRequest,
8    UnsubscribeOperation, UnsubscribeRequest, UnsubscribeResponse,
9};
10use crate::{ApiError, Result, Service};
11use soap_client::SoapClient;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, SystemTime};
14
15/// A managed UPnP subscription with lifecycle management
16///
17/// This struct wraps the low-level subscription operations and provides:
18/// - Expiration tracking
19/// - Manual renewal with proper state updates
20/// - Proper cleanup on drop
21/// - Thread-safe state management
22///
23/// # Example
24/// ```rust,no_run
25/// use sonos_api::{SonosClient, Service};
26///
27/// # fn main() -> sonos_api::Result<()> {
28/// let client = SonosClient::new();
29/// let subscription = client.create_managed_subscription(
30///     "192.168.1.100",
31///     Service::AVTransport,
32///     "http://192.168.1.50:8080/callback",
33///     1800
34/// )?;
35///
36/// // Check if renewal is needed
37/// if subscription.needs_renewal() {
38///     subscription.renew()?;
39/// }
40///
41/// // Clean up when done
42/// subscription.unsubscribe()?;
43/// # Ok(())
44/// # }
45/// ```
46#[derive(Debug)]
47pub struct ManagedSubscription {
48    /// UPnP subscription ID (SID) returned by the device
49    sid: String,
50    /// Device IP address
51    device_ip: String,
52    /// Service being subscribed to
53    service: Service,
54    /// Subscription state (protected by mutex)
55    state: Arc<Mutex<SubscriptionState>>,
56    /// SOAP client for making requests
57    soap_client: SoapClient,
58}
59
60#[derive(Debug)]
61struct SubscriptionState {
62    /// When this subscription expires
63    expires_at: SystemTime,
64    /// Whether the subscription is currently active
65    active: bool,
66    /// Timeout duration for this subscription
67    timeout_seconds: u32,
68}
69
70impl ManagedSubscription {
71    /// Create a new managed subscription by performing the initial subscribe operation
72    pub(crate) fn create(
73        device_ip: String,
74        service: Service,
75        callback_url: String,
76        timeout_seconds: u32,
77        soap_client: SoapClient,
78    ) -> Result<Self> {
79        let request = SubscribeRequest {
80            callback_url,
81            timeout_seconds,
82        };
83
84        let response = SubscribeOperation::execute(&soap_client, &device_ip, service, &request)?;
85
86        let state = SubscriptionState {
87            expires_at: SystemTime::now() + Duration::from_secs(response.timeout_seconds as u64),
88            active: true,
89            timeout_seconds: response.timeout_seconds,
90        };
91
92        Ok(Self {
93            sid: response.sid,
94            device_ip,
95            service,
96            state: Arc::new(Mutex::new(state)),
97            soap_client,
98        })
99    }
100
101    /// Send a UPnP unsubscribe request (internal use only)
102    fn unsubscribe_internal(
103        soap_client: &SoapClient,
104        device_ip: &str,
105        service: Service,
106        request: &UnsubscribeRequest,
107    ) -> Result<UnsubscribeResponse> {
108        UnsubscribeOperation::execute(soap_client, device_ip, service, request)
109    }
110
111    /// Send a UPnP renewal request (internal use only)
112    fn renew_internal(
113        soap_client: &SoapClient,
114        device_ip: &str,
115        service: Service,
116        request: &RenewRequest,
117    ) -> Result<RenewResponse> {
118        RenewOperation::execute(soap_client, device_ip, service, request)
119    }
120
121    /// Get the subscription ID
122    pub fn subscription_id(&self) -> &str {
123        &self.sid
124    }
125
126    /// Check if the subscription is still active and not expired
127    pub fn is_active(&self) -> bool {
128        let state = self.state.lock().unwrap();
129        state.active && SystemTime::now() < state.expires_at
130    }
131
132    /// Check if the subscription needs renewal
133    ///
134    /// Returns true if the subscription is active and will expire within
135    /// the renewal threshold (5 minutes by default).
136    pub fn needs_renewal(&self) -> bool {
137        self.time_until_renewal().is_some()
138    }
139
140    /// Get the time until renewal is needed
141    ///
142    /// Returns `Some(duration)` if renewal is needed within the threshold,
143    /// `None` if renewal is not needed or subscription is inactive.
144    pub fn time_until_renewal(&self) -> Option<Duration> {
145        let state = self.state.lock().unwrap();
146
147        if !state.active {
148            return None;
149        }
150
151        let now = SystemTime::now();
152        if now >= state.expires_at {
153            return Some(Duration::ZERO);
154        }
155
156        let time_until_expiry = state.expires_at.duration_since(now).ok()?;
157        let renewal_threshold = Duration::from_secs(300); // 5 minutes
158
159        if time_until_expiry <= renewal_threshold {
160            Some(time_until_expiry)
161        } else {
162            None
163        }
164    }
165
166    /// Get when the subscription expires
167    pub fn expires_at(&self) -> SystemTime {
168        let state = self.state.lock().unwrap();
169        state.expires_at
170    }
171
172    /// Manually renew the subscription
173    ///
174    /// This sends a renewal request to the device and updates the internal
175    /// expiration time based on the response.
176    ///
177    /// # Returns
178    /// `Ok(())` if renewal succeeded, `Err(ApiError)` if it failed.
179    ///
180    /// # Errors
181    /// - `ApiError::SubscriptionExpired` if the subscription has already expired
182    /// - Network or device errors from the renewal request
183    pub fn renew(&self) -> Result<()> {
184        let current_timeout = {
185            let state = self.state.lock().unwrap();
186            if !state.active {
187                return Err(ApiError::subscription_expired());
188            }
189            state.timeout_seconds
190        };
191
192        let request = RenewRequest {
193            sid: self.sid.clone(),
194            timeout_seconds: current_timeout,
195        };
196
197        let response =
198            Self::renew_internal(&self.soap_client, &self.device_ip, self.service, &request)?;
199
200        // Update state with new expiration time
201        {
202            let mut state = self.state.lock().unwrap();
203            state.expires_at =
204                SystemTime::now() + Duration::from_secs(response.timeout_seconds as u64);
205            state.timeout_seconds = response.timeout_seconds;
206        }
207
208        Ok(())
209    }
210
211    /// Unsubscribe and clean up the subscription
212    ///
213    /// This sends an unsubscribe request to the device and marks the
214    /// subscription as inactive. After calling this method, the subscription
215    /// should not be used for any further operations.
216    ///
217    /// # Returns
218    /// `Ok(())` if unsubscribe succeeded, `Err(ApiError)` if it failed.
219    /// Note that the subscription is marked inactive regardless of the result.
220    pub fn unsubscribe(&self) -> Result<()> {
221        // Mark as inactive first
222        {
223            let mut state = self.state.lock().unwrap();
224            state.active = false;
225        }
226
227        // Send unsubscribe request
228        let request = UnsubscribeRequest {
229            sid: self.sid.clone(),
230        };
231
232        Self::unsubscribe_internal(&self.soap_client, &self.device_ip, self.service, &request)
233            .map(|_| ())
234    }
235}
236
237impl Drop for ManagedSubscription {
238    fn drop(&mut self) {
239        // Mark as inactive
240        if let Ok(mut state) = self.state.lock() {
241            if state.active {
242                state.active = false;
243
244                // Attempt to unsubscribe, but don't panic if it fails
245                let request = UnsubscribeRequest {
246                    sid: self.sid.clone(),
247                };
248
249                if let Err(e) = Self::unsubscribe_internal(
250                    &self.soap_client,
251                    &self.device_ip,
252                    self.service,
253                    &request,
254                ) {
255                    eprintln!("⚠️  Failed to unsubscribe {} during drop: {}", self.sid, e);
256                }
257            }
258        }
259    }
260}