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}