Skip to main content

barbed_ffz/
lib.rs

1use std::collections::HashMap;
2
3use async_trait::async_trait;
4use barbed_core::emotes::Emote;
5#[cfg(any(test, feature = "reqwest-client"))]
6use barbed_core::emotes::{
7    EmoteId, EmoteImage, EmoteImageFormat, EmoteImageScale, EmoteModifier, EmoteModifierFlags,
8    EmoteProvider, EmoteThemeMode,
9};
10#[cfg(any(test, feature = "reqwest-client"))]
11use serde::Deserialize;
12use thiserror::Error;
13
14#[cfg(feature = "reqwest-client")]
15const API_BASE: &str = "https://api.frankerfacez.com/v1";
16
17#[derive(Debug, Error)]
18pub enum FfzError {
19    #[error("FFZ response failed to decode: {0}")]
20    Json(#[from] serde_json::Error),
21    #[cfg(feature = "reqwest-client")]
22    #[error("FFZ request failed: {0}")]
23    Http(#[from] reqwest::Error),
24    #[error("FFZ global emote sets were not seeded in the in-memory client")]
25    MissingGlobal,
26    #[error("FFZ room `{0}` was not seeded in the in-memory client")]
27    MissingRoom(String),
28    #[error("FFZ room response missing room payload")]
29    MissingRoomPayload,
30}
31
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct FfzEmoteSet {
34    pub id: String,
35    pub title: Option<String>,
36    pub emotes: Vec<Emote>,
37}
38
39impl FfzEmoteSet {
40    pub fn is_empty(&self) -> bool {
41        self.emotes.is_empty()
42    }
43}
44
45#[derive(Clone, Debug, Default, PartialEq, Eq)]
46pub struct FfzGlobalEmoteSets {
47    sets: HashMap<String, FfzEmoteSet>,
48    default_set_ids: Vec<String>,
49    user_scoped: HashMap<String, Vec<String>>,
50}
51
52impl FfzGlobalEmoteSets {
53    pub fn is_empty(&self) -> bool {
54        self.default_set_ids.is_empty() && self.user_scoped.is_empty()
55    }
56
57    pub fn default_sets(&self) -> Vec<FfzEmoteSet> {
58        self.default_set_ids
59            .iter()
60            .filter_map(|id| self.sets.get(id).cloned())
61            .collect()
62    }
63
64    pub fn sets_for_user(&self, user_id: &str) -> Vec<FfzEmoteSet> {
65        self.user_scoped
66            .iter()
67            .filter_map(|(set_id, users)| {
68                if users.iter().any(|candidate| candidate == user_id) {
69                    self.sets.get(set_id).cloned()
70                } else {
71                    None
72                }
73            })
74            .collect()
75    }
76
77    pub fn user_scoped_summary(&self) -> Vec<(FfzEmoteSet, usize)> {
78        self.user_scoped
79            .iter()
80            .filter_map(|(set_id, users)| {
81                self.sets.get(set_id).cloned().map(|set| (set, users.len()))
82            })
83            .collect()
84    }
85}
86
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub struct FfzRoomEmoteSets {
89    pub room_id: String,
90    pub twitch_id: String,
91    pub sets: Vec<FfzEmoteSet>,
92}
93
94impl FfzRoomEmoteSets {
95    pub fn is_empty(&self) -> bool {
96        self.sets.is_empty()
97    }
98}
99
100#[async_trait]
101pub trait FfzApi: Send + Sync {
102    async fn global_emote_sets(&self) -> Result<FfzGlobalEmoteSets, FfzError>;
103
104    async fn room_emote_sets_by_twitch_id(
105        &self,
106        twitch_id: &str,
107    ) -> Result<FfzRoomEmoteSets, FfzError>;
108}
109
110#[derive(Clone, Default)]
111pub struct InMemoryFfzApi {
112    global: Option<FfzGlobalEmoteSets>,
113    rooms: HashMap<String, FfzRoomEmoteSets>,
114}
115
116impl InMemoryFfzApi {
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    pub fn with_global(mut self, sets: FfzGlobalEmoteSets) -> Self {
122        self.global = Some(sets);
123        self
124    }
125
126    pub fn insert_room(&mut self, twitch_id: impl Into<String>, room: FfzRoomEmoteSets) {
127        self.rooms.insert(twitch_id.into(), room);
128    }
129}
130
131#[async_trait]
132impl FfzApi for InMemoryFfzApi {
133    async fn global_emote_sets(&self) -> Result<FfzGlobalEmoteSets, FfzError> {
134        self.global.clone().ok_or(FfzError::MissingGlobal)
135    }
136
137    async fn room_emote_sets_by_twitch_id(
138        &self,
139        twitch_id: &str,
140    ) -> Result<FfzRoomEmoteSets, FfzError> {
141        self.rooms
142            .get(twitch_id)
143            .cloned()
144            .ok_or_else(|| FfzError::MissingRoom(twitch_id.to_string()))
145    }
146}
147
148#[cfg(feature = "reqwest-client")]
149#[derive(Clone)]
150pub struct FfzClient {
151    http: reqwest::Client,
152}
153
154#[cfg(feature = "reqwest-client")]
155impl FfzClient {
156    pub fn new() -> Result<Self, FfzError> {
157        Ok(Self {
158            http: reqwest::Client::builder()
159                .user_agent("barbed/0.0.2")
160                .build()?,
161        })
162    }
163}
164
165#[cfg(feature = "reqwest-client")]
166#[async_trait]
167impl FfzApi for FfzClient {
168    async fn global_emote_sets(&self) -> Result<FfzGlobalEmoteSets, FfzError> {
169        let body = self
170            .http
171            .get(format!("{API_BASE}/set/global"))
172            .send()
173            .await?
174            .error_for_status()?
175            .text()
176            .await?;
177        parse_global_sets_json(&body)
178    }
179
180    async fn room_emote_sets_by_twitch_id(
181        &self,
182        twitch_id: &str,
183    ) -> Result<FfzRoomEmoteSets, FfzError> {
184        let body = self
185            .http
186            .get(format!("{API_BASE}/room/id/{twitch_id}"))
187            .send()
188            .await?
189            .error_for_status()?
190            .text()
191            .await?;
192        parse_room_sets_json(&body)
193    }
194}
195
196#[cfg(any(test, feature = "reqwest-client"))]
197fn parse_global_sets_json(body: &str) -> Result<FfzGlobalEmoteSets, FfzError> {
198    let response: GlobalSetsResponse = serde_json::from_str(body)?;
199    let sets = response
200        .sets
201        .into_iter()
202        .map(|(id, model)| (id, emote_set_from_model(model)))
203        .collect();
204    Ok(FfzGlobalEmoteSets {
205        sets,
206        default_set_ids: response
207            .default_sets
208            .into_iter()
209            .map(|id| id.to_string())
210            .collect(),
211        user_scoped: response.users,
212    })
213}
214
215#[cfg(any(test, feature = "reqwest-client"))]
216fn parse_room_sets_json(body: &str) -> Result<FfzRoomEmoteSets, FfzError> {
217    let response: RoomResponse = serde_json::from_str(body)?;
218    let room = response.room.ok_or(FfzError::MissingRoomPayload)?;
219    Ok(FfzRoomEmoteSets {
220        room_id: room.id,
221        twitch_id: room.twitch_id.to_string(),
222        sets: response
223            .sets
224            .into_values()
225            .map(emote_set_from_model)
226            .collect(),
227    })
228}
229
230#[derive(Deserialize)]
231#[cfg(any(test, feature = "reqwest-client"))]
232struct GlobalSetsResponse {
233    #[serde(default)]
234    default_sets: Vec<u64>,
235    #[serde(default)]
236    sets: HashMap<String, EmoteSetModel>,
237    #[serde(default)]
238    users: HashMap<String, Vec<String>>,
239}
240
241#[derive(Deserialize)]
242#[cfg(any(test, feature = "reqwest-client"))]
243struct RoomResponse {
244    room: Option<RoomModel>,
245    #[serde(default)]
246    sets: HashMap<String, EmoteSetModel>,
247}
248
249#[derive(Deserialize)]
250#[cfg(any(test, feature = "reqwest-client"))]
251struct RoomModel {
252    id: String,
253    #[serde(rename = "twitch_id")]
254    twitch_id: u64,
255}
256
257#[derive(Deserialize)]
258#[cfg(any(test, feature = "reqwest-client"))]
259struct EmoteSetModel {
260    id: u64,
261    title: Option<String>,
262    #[serde(default)]
263    emoticons: Vec<EmoteModel>,
264}
265
266#[derive(Deserialize)]
267#[cfg(any(test, feature = "reqwest-client"))]
268struct EmoteModel {
269    id: u64,
270    name: String,
271    #[serde(default)]
272    hidden: bool,
273    #[serde(default)]
274    modifier: bool,
275    #[serde(default)]
276    modifier_flags: u32,
277    #[serde(default)]
278    urls: HashMap<String, Option<String>>,
279    animated: Option<HashMap<String, Option<String>>>,
280    mask: Option<HashMap<String, Option<String>>>,
281    mask_animated: Option<HashMap<String, Option<String>>>,
282}
283
284#[cfg(any(test, feature = "reqwest-client"))]
285fn emote_set_from_model(model: EmoteSetModel) -> FfzEmoteSet {
286    FfzEmoteSet {
287        id: model.id.to_string(),
288        title: model.title,
289        emotes: model.emoticons.into_iter().map(emote_from_model).collect(),
290    }
291}
292
293#[cfg(any(test, feature = "reqwest-client"))]
294fn emote_from_model(model: EmoteModel) -> Emote {
295    let mut images = image_variants(&model.urls, EmoteImageFormat::Static);
296    if let Some(animated) = &model.animated {
297        images.extend(image_variants(animated, EmoteImageFormat::Animated));
298    }
299
300    let mut emote = Emote::new(
301        EmoteId::new(EmoteProvider::FrankerFaceZ, model.id.to_string()),
302        model.name,
303        images,
304    );
305
306    if model.modifier {
307        let mut flags = EmoteModifierFlags::from_bits_retain(model.modifier_flags);
308        if model.hidden {
309            flags |= EmoteModifierFlags::HIDDEN;
310        }
311        let mut mask_images = Vec::new();
312        if let Some(mask) = &model.mask {
313            mask_images.extend(image_variants(mask, EmoteImageFormat::Static));
314        }
315        if let Some(mask) = &model.mask_animated {
316            mask_images.extend(image_variants(mask, EmoteImageFormat::Animated));
317        }
318        emote = emote.with_modifier(EmoteModifier { flags, mask_images });
319    }
320
321    emote
322}
323
324#[cfg(any(test, feature = "reqwest-client"))]
325fn image_variants(
326    images: &HashMap<String, Option<String>>,
327    format: EmoteImageFormat,
328) -> Vec<EmoteImage> {
329    images
330        .iter()
331        .filter_map(|(scale_key, url)| {
332            let url = url.clone()?;
333            Some(EmoteImage {
334                format: format.clone(),
335                theme_mode: EmoteThemeMode::Light,
336                scale: EmoteImageScale::parse(scale_key),
337                url,
338            })
339        })
340        .collect()
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn global_fixture_preserves_default_and_user_scoped_sets() {
349        let sets = parse_global_sets_json(include_str!("../tests/fixtures/global_sets.json"))
350            .expect("global fixture should parse");
351        assert_eq!(sets.default_sets().len(), 1);
352        assert_eq!(sets.sets_for_user("42").len(), 1);
353        assert_eq!(sets.user_scoped_summary().len(), 1);
354    }
355
356    #[test]
357    fn room_fixture_preserves_modifier_metadata() {
358        let room = parse_room_sets_json(include_str!("../tests/fixtures/room_sets.json"))
359            .expect("room fixture should parse");
360        let modifier = room.sets[0].emotes[0]
361            .modifier
362            .as_ref()
363            .expect("modifier metadata should exist");
364        assert!(modifier.flags.contains(EmoteModifierFlags::HIDDEN));
365        assert!(modifier.flags.contains(EmoteModifierFlags::RAINBOW));
366    }
367
368    #[tokio::test(flavor = "current_thread")]
369    async fn in_memory_api_returns_seeded_global_and_room_sets() {
370        let mut api = InMemoryFfzApi::new().with_global(
371            parse_global_sets_json(include_str!("../tests/fixtures/global_sets.json"))
372                .expect("global fixture should parse"),
373        );
374        api.insert_room(
375            "42",
376            parse_room_sets_json(include_str!("../tests/fixtures/room_sets.json"))
377                .expect("room fixture should parse"),
378        );
379
380        assert_eq!(
381            api.global_emote_sets()
382                .await
383                .expect("global sets should exist")
384                .default_sets()
385                .len(),
386            1
387        );
388        assert_eq!(
389            api.room_emote_sets_by_twitch_id("42")
390                .await
391                .expect("room sets should exist")
392                .sets
393                .len(),
394            1
395        );
396    }
397}