embystream 0.0.36

Another Emby streaming application (frontend/backend separation) written in Rust.
Documentation
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
pub struct OAuthToken {
    #[serde(default)]
    pub access_token: String,
    #[serde(default)]
    pub refresh_token: String,
    #[serde(default = "default_token_type")]
    pub token_type: String,
    #[serde(default)]
    pub expiry: Option<DateTime<Utc>>,
}

fn default_token_type() -> String {
    "Bearer".to_string()
}

impl OAuthToken {
    pub fn has_access_token(&self) -> bool {
        !self.access_token.trim().is_empty()
    }

    pub fn has_refresh_token(&self) -> bool {
        !self.refresh_token.trim().is_empty()
    }

    pub fn authorization_header_value(&self) -> Option<String> {
        let access_token = self.access_token.trim();
        if access_token.is_empty() {
            return None;
        }

        let token_type = self.token_type.trim();
        if token_type.is_empty() {
            return Some(access_token.to_string());
        }

        Some(format!("{token_type} {access_token}"))
    }

    pub fn remaining_lifetime(&self, now: DateTime<Utc>) -> Option<Duration> {
        self.expiry.map(|expiry| expiry - now)
    }

    pub fn is_valid_for(
        &self,
        min_valid_for: Duration,
        now: DateTime<Utc>,
    ) -> bool {
        self.has_access_token()
            && self
                .remaining_lifetime(now)
                .map(|remaining| remaining > min_valid_for)
                .unwrap_or(false)
    }

    pub fn from_refresh_parts(
        access_token: String,
        refresh_token: String,
        token_type: String,
        expiry: Option<DateTime<Utc>>,
    ) -> Self {
        Self {
            access_token,
            refresh_token,
            token_type: if token_type.trim().is_empty() {
                default_token_type()
            } else {
                token_type
            },
            expiry,
        }
    }
}

#[cfg(test)]
mod tests {
    use chrono::{Duration, TimeZone, Utc};

    use super::OAuthToken;

    #[test]
    fn token_validity_requires_access_token_and_expiry() {
        let now = Utc
            .with_ymd_and_hms(2026, 4, 16, 12, 0, 0)
            .single()
            .expect("valid timestamp");
        let token = OAuthToken::from_refresh_parts(
            "access-token".to_string(),
            "refresh-token".to_string(),
            "Bearer".to_string(),
            Some(now + Duration::minutes(30)),
        );

        assert!(token.is_valid_for(Duration::minutes(5), now));
        assert!(!token.is_valid_for(Duration::minutes(40), now));
    }
}