barbed-ffz 0.0.4

FrankerFaceZ provider helpers for the barbed workspace
Documentation
use std::collections::HashMap;

use async_trait::async_trait;
use barbed_core::emotes::Emote;
#[cfg(any(test, feature = "reqwest-client"))]
use barbed_core::emotes::{
    EmoteId, EmoteImage, EmoteImageFormat, EmoteImageScale, EmoteModifier, EmoteModifierFlags,
    EmoteProvider, EmoteThemeMode,
};
#[cfg(any(test, feature = "reqwest-client"))]
use serde::Deserialize;
use thiserror::Error;

#[cfg(feature = "reqwest-client")]
const API_BASE: &str = "https://api.frankerfacez.com/v1";

#[derive(Debug, Error)]
pub enum FfzError {
    #[error("FFZ response failed to decode: {0}")]
    Json(#[from] serde_json::Error),
    #[cfg(feature = "reqwest-client")]
    #[error("FFZ request failed: {0}")]
    Http(#[from] reqwest::Error),
    #[error("FFZ global emote sets were not seeded in the in-memory client")]
    MissingGlobal,
    #[error("FFZ room `{0}` was not seeded in the in-memory client")]
    MissingRoom(String),
    #[error("FFZ room response missing room payload")]
    MissingRoomPayload,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FfzEmoteSet {
    pub id: String,
    pub title: Option<String>,
    pub emotes: Vec<Emote>,
}

impl FfzEmoteSet {
    pub fn is_empty(&self) -> bool {
        self.emotes.is_empty()
    }
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FfzGlobalEmoteSets {
    sets: HashMap<String, FfzEmoteSet>,
    default_set_ids: Vec<String>,
    user_scoped: HashMap<String, Vec<String>>,
}

impl FfzGlobalEmoteSets {
    pub fn is_empty(&self) -> bool {
        self.default_set_ids.is_empty() && self.user_scoped.is_empty()
    }

    pub fn default_sets(&self) -> Vec<FfzEmoteSet> {
        self.default_set_ids
            .iter()
            .filter_map(|id| self.sets.get(id).cloned())
            .collect()
    }

    pub fn sets_for_user(&self, user_id: &str) -> Vec<FfzEmoteSet> {
        self.user_scoped
            .iter()
            .filter_map(|(set_id, users)| {
                if users.iter().any(|candidate| candidate == user_id) {
                    self.sets.get(set_id).cloned()
                } else {
                    None
                }
            })
            .collect()
    }

    pub fn user_scoped_summary(&self) -> Vec<(FfzEmoteSet, usize)> {
        self.user_scoped
            .iter()
            .filter_map(|(set_id, users)| {
                self.sets.get(set_id).cloned().map(|set| (set, users.len()))
            })
            .collect()
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FfzRoomEmoteSets {
    pub room_id: String,
    pub twitch_id: String,
    pub sets: Vec<FfzEmoteSet>,
}

impl FfzRoomEmoteSets {
    pub fn is_empty(&self) -> bool {
        self.sets.is_empty()
    }
}

#[async_trait]
pub trait FfzApi: Send + Sync {
    async fn global_emote_sets(&self) -> Result<FfzGlobalEmoteSets, FfzError>;

    async fn room_emote_sets_by_twitch_id(
        &self,
        twitch_id: &str,
    ) -> Result<FfzRoomEmoteSets, FfzError>;
}

#[derive(Clone, Default)]
pub struct InMemoryFfzApi {
    global: Option<FfzGlobalEmoteSets>,
    rooms: HashMap<String, FfzRoomEmoteSets>,
}

impl InMemoryFfzApi {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn with_global(mut self, sets: FfzGlobalEmoteSets) -> Self {
        self.global = Some(sets);
        self
    }

    pub fn insert_room(&mut self, twitch_id: impl Into<String>, room: FfzRoomEmoteSets) {
        self.rooms.insert(twitch_id.into(), room);
    }
}

#[async_trait]
impl FfzApi for InMemoryFfzApi {
    async fn global_emote_sets(&self) -> Result<FfzGlobalEmoteSets, FfzError> {
        self.global.clone().ok_or(FfzError::MissingGlobal)
    }

    async fn room_emote_sets_by_twitch_id(
        &self,
        twitch_id: &str,
    ) -> Result<FfzRoomEmoteSets, FfzError> {
        self.rooms
            .get(twitch_id)
            .cloned()
            .ok_or_else(|| FfzError::MissingRoom(twitch_id.to_string()))
    }
}

#[cfg(feature = "reqwest-client")]
#[derive(Clone)]
pub struct FfzClient {
    http: reqwest::Client,
}

#[cfg(feature = "reqwest-client")]
impl FfzClient {
    pub fn new() -> Result<Self, FfzError> {
        Ok(Self {
            http: reqwest::Client::builder()
                .user_agent("barbed/0.0.2")
                .build()?,
        })
    }
}

#[cfg(feature = "reqwest-client")]
#[async_trait]
impl FfzApi for FfzClient {
    async fn global_emote_sets(&self) -> Result<FfzGlobalEmoteSets, FfzError> {
        let body = self
            .http
            .get(format!("{API_BASE}/set/global"))
            .send()
            .await?
            .error_for_status()?
            .text()
            .await?;
        parse_global_sets_json(&body)
    }

    async fn room_emote_sets_by_twitch_id(
        &self,
        twitch_id: &str,
    ) -> Result<FfzRoomEmoteSets, FfzError> {
        let body = self
            .http
            .get(format!("{API_BASE}/room/id/{twitch_id}"))
            .send()
            .await?
            .error_for_status()?
            .text()
            .await?;
        parse_room_sets_json(&body)
    }
}

#[cfg(any(test, feature = "reqwest-client"))]
fn parse_global_sets_json(body: &str) -> Result<FfzGlobalEmoteSets, FfzError> {
    let response: GlobalSetsResponse = serde_json::from_str(body)?;
    let sets = response
        .sets
        .into_iter()
        .map(|(id, model)| (id, emote_set_from_model(model)))
        .collect();
    Ok(FfzGlobalEmoteSets {
        sets,
        default_set_ids: response
            .default_sets
            .into_iter()
            .map(|id| id.to_string())
            .collect(),
        user_scoped: response.users,
    })
}

#[cfg(any(test, feature = "reqwest-client"))]
fn parse_room_sets_json(body: &str) -> Result<FfzRoomEmoteSets, FfzError> {
    let response: RoomResponse = serde_json::from_str(body)?;
    let room = response.room.ok_or(FfzError::MissingRoomPayload)?;
    Ok(FfzRoomEmoteSets {
        room_id: room.id,
        twitch_id: room.twitch_id.to_string(),
        sets: response
            .sets
            .into_values()
            .map(emote_set_from_model)
            .collect(),
    })
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct GlobalSetsResponse {
    #[serde(default)]
    default_sets: Vec<u64>,
    #[serde(default)]
    sets: HashMap<String, EmoteSetModel>,
    #[serde(default)]
    users: HashMap<String, Vec<String>>,
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct RoomResponse {
    room: Option<RoomModel>,
    #[serde(default)]
    sets: HashMap<String, EmoteSetModel>,
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct RoomModel {
    id: String,
    #[serde(rename = "twitch_id")]
    twitch_id: u64,
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct EmoteSetModel {
    id: u64,
    title: Option<String>,
    #[serde(default)]
    emoticons: Vec<EmoteModel>,
}

#[derive(Deserialize)]
#[cfg(any(test, feature = "reqwest-client"))]
struct EmoteModel {
    id: u64,
    name: String,
    #[serde(default)]
    hidden: bool,
    #[serde(default)]
    modifier: bool,
    #[serde(default)]
    modifier_flags: u32,
    #[serde(default)]
    urls: HashMap<String, Option<String>>,
    animated: Option<HashMap<String, Option<String>>>,
    mask: Option<HashMap<String, Option<String>>>,
    mask_animated: Option<HashMap<String, Option<String>>>,
}

#[cfg(any(test, feature = "reqwest-client"))]
fn emote_set_from_model(model: EmoteSetModel) -> FfzEmoteSet {
    FfzEmoteSet {
        id: model.id.to_string(),
        title: model.title,
        emotes: model.emoticons.into_iter().map(emote_from_model).collect(),
    }
}

#[cfg(any(test, feature = "reqwest-client"))]
fn emote_from_model(model: EmoteModel) -> Emote {
    let mut images = image_variants(&model.urls, EmoteImageFormat::Static);
    if let Some(animated) = &model.animated {
        images.extend(image_variants(animated, EmoteImageFormat::Animated));
    }

    let mut emote = Emote::new(
        EmoteId::new(EmoteProvider::FrankerFaceZ, model.id.to_string()),
        model.name,
        images,
    );

    if model.modifier {
        let mut flags = EmoteModifierFlags::from_bits_retain(model.modifier_flags);
        if model.hidden {
            flags |= EmoteModifierFlags::HIDDEN;
        }
        let mut mask_images = Vec::new();
        if let Some(mask) = &model.mask {
            mask_images.extend(image_variants(mask, EmoteImageFormat::Static));
        }
        if let Some(mask) = &model.mask_animated {
            mask_images.extend(image_variants(mask, EmoteImageFormat::Animated));
        }
        emote = emote.with_modifier(EmoteModifier { flags, mask_images });
    }

    emote
}

#[cfg(any(test, feature = "reqwest-client"))]
fn image_variants(
    images: &HashMap<String, Option<String>>,
    format: EmoteImageFormat,
) -> Vec<EmoteImage> {
    images
        .iter()
        .filter_map(|(scale_key, url)| {
            let url = url.clone()?;
            Some(EmoteImage {
                format: format.clone(),
                theme_mode: EmoteThemeMode::Light,
                scale: EmoteImageScale::parse(scale_key),
                url,
            })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn global_fixture_preserves_default_and_user_scoped_sets() {
        let sets = parse_global_sets_json(include_str!("../tests/fixtures/global_sets.json"))
            .expect("global fixture should parse");
        assert_eq!(sets.default_sets().len(), 1);
        assert_eq!(sets.sets_for_user("42").len(), 1);
        assert_eq!(sets.user_scoped_summary().len(), 1);
    }

    #[test]
    fn room_fixture_preserves_modifier_metadata() {
        let room = parse_room_sets_json(include_str!("../tests/fixtures/room_sets.json"))
            .expect("room fixture should parse");
        let modifier = room.sets[0].emotes[0]
            .modifier
            .as_ref()
            .expect("modifier metadata should exist");
        assert!(modifier.flags.contains(EmoteModifierFlags::HIDDEN));
        assert!(modifier.flags.contains(EmoteModifierFlags::RAINBOW));
    }

    #[tokio::test(flavor = "current_thread")]
    async fn in_memory_api_returns_seeded_global_and_room_sets() {
        let mut api = InMemoryFfzApi::new().with_global(
            parse_global_sets_json(include_str!("../tests/fixtures/global_sets.json"))
                .expect("global fixture should parse"),
        );
        api.insert_room(
            "42",
            parse_room_sets_json(include_str!("../tests/fixtures/room_sets.json"))
                .expect("room fixture should parse"),
        );

        assert_eq!(
            api.global_emote_sets()
                .await
                .expect("global sets should exist")
                .default_sets()
                .len(),
            1
        );
        assert_eq!(
            api.room_emote_sets_by_twitch_id("42")
                .await
                .expect("room sets should exist")
                .sets
                .len(),
            1
        );
    }
}