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, EmoteProvider, EmoteThemeMode,
8};
9#[cfg(any(test, feature = "reqwest-client"))]
10use serde::Deserialize;
11use thiserror::Error;
12
13#[cfg(feature = "reqwest-client")]
14const API_BASE: &str = "https://api.betterttv.net";
15#[cfg(any(test, feature = "reqwest-client"))]
16const CDN_BASE: &str = "https://cdn.betterttv.net/emote";
17
18#[derive(Debug, Error)]
19pub enum BttvError {
20 #[error("BetterTTV response failed to decode: {0}")]
21 Json(#[from] serde_json::Error),
22 #[cfg(feature = "reqwest-client")]
23 #[error("BetterTTV request failed: {0}")]
24 Http(#[from] reqwest::Error),
25 #[error("BetterTTV channel `{0}` was not seeded in the in-memory client")]
26 MissingChannel(String),
27 #[error("BetterTTV global emotes were not seeded in the in-memory client")]
28 MissingGlobal,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32pub struct BttvEmoteSet {
33 pub id: String,
34 pub name: String,
35 pub emotes: Vec<Emote>,
36}
37
38impl BttvEmoteSet {
39 pub fn is_empty(&self) -> bool {
40 self.emotes.is_empty()
41 }
42}
43
44#[derive(Clone, Debug, Default, PartialEq, Eq)]
45pub struct BttvChannelEmoteSets {
46 pub channel: Option<BttvEmoteSet>,
47 pub shared: Option<BttvEmoteSet>,
48}
49
50impl BttvChannelEmoteSets {
51 pub fn is_empty(&self) -> bool {
52 self.channel.as_ref().is_none_or(BttvEmoteSet::is_empty)
53 && self.shared.as_ref().is_none_or(BttvEmoteSet::is_empty)
54 }
55
56 pub fn all_emotes(&self) -> Vec<Emote> {
57 let mut emotes = Vec::new();
58 if let Some(set) = &self.channel {
59 emotes.extend(set.emotes.iter().cloned());
60 }
61 if let Some(set) = &self.shared {
62 emotes.extend(set.emotes.iter().cloned());
63 }
64 emotes
65 }
66}
67
68#[async_trait]
69pub trait BttvApi: Send + Sync {
70 async fn global_emotes(&self) -> Result<BttvEmoteSet, BttvError>;
71
72 async fn channel_emotes_by_twitch_id(
73 &self,
74 twitch_id: &str,
75 ) -> Result<BttvChannelEmoteSets, BttvError>;
76}
77
78#[derive(Clone, Default)]
79pub struct InMemoryBttvApi {
80 global: Option<BttvEmoteSet>,
81 channels: HashMap<String, BttvChannelEmoteSets>,
82}
83
84impl InMemoryBttvApi {
85 pub fn new() -> Self {
86 Self::default()
87 }
88
89 pub fn with_global(mut self, set: BttvEmoteSet) -> Self {
90 self.global = Some(set);
91 self
92 }
93
94 pub fn insert_channel_sets(
95 &mut self,
96 twitch_id: impl Into<String>,
97 sets: BttvChannelEmoteSets,
98 ) {
99 self.channels.insert(twitch_id.into(), sets);
100 }
101}
102
103#[async_trait]
104impl BttvApi for InMemoryBttvApi {
105 async fn global_emotes(&self) -> Result<BttvEmoteSet, BttvError> {
106 self.global.clone().ok_or(BttvError::MissingGlobal)
107 }
108
109 async fn channel_emotes_by_twitch_id(
110 &self,
111 twitch_id: &str,
112 ) -> Result<BttvChannelEmoteSets, BttvError> {
113 self.channels
114 .get(twitch_id)
115 .cloned()
116 .ok_or_else(|| BttvError::MissingChannel(twitch_id.to_string()))
117 }
118}
119
120#[cfg(feature = "reqwest-client")]
121#[derive(Clone)]
122pub struct BttvClient {
123 http: reqwest::Client,
124}
125
126#[cfg(feature = "reqwest-client")]
127impl BttvClient {
128 pub fn new() -> Result<Self, BttvError> {
129 Ok(Self {
130 http: reqwest::Client::builder()
131 .user_agent("barbed/0.0.2")
132 .build()?,
133 })
134 }
135}
136
137#[cfg(feature = "reqwest-client")]
138#[async_trait]
139impl BttvApi for BttvClient {
140 async fn global_emotes(&self) -> Result<BttvEmoteSet, BttvError> {
141 let body = self
142 .http
143 .get(format!("{API_BASE}/3/cached/emotes/global"))
144 .send()
145 .await?
146 .error_for_status()?
147 .text()
148 .await?;
149 parse_global_emotes_json(&body)
150 }
151
152 async fn channel_emotes_by_twitch_id(
153 &self,
154 twitch_id: &str,
155 ) -> Result<BttvChannelEmoteSets, BttvError> {
156 let body = self
157 .http
158 .get(format!("{API_BASE}/3/cached/users/twitch/{twitch_id}"))
159 .send()
160 .await?
161 .error_for_status()?
162 .text()
163 .await?;
164 parse_channel_emotes_json(twitch_id, &body)
165 }
166}
167
168#[cfg(any(test, feature = "reqwest-client"))]
169fn parse_global_emotes_json(body: &str) -> Result<BttvEmoteSet, BttvError> {
170 let models: Vec<EmoteModel> = serde_json::from_str(body)?;
171 Ok(BttvEmoteSet {
172 id: "global".to_string(),
173 name: "BetterTTV Global".to_string(),
174 emotes: models.into_iter().map(emote_from_model).collect(),
175 })
176}
177
178#[cfg(any(test, feature = "reqwest-client"))]
179fn parse_channel_emotes_json(
180 twitch_id: &str,
181 body: &str,
182) -> Result<BttvChannelEmoteSets, BttvError> {
183 let response: ChannelResponse = serde_json::from_str(body)?;
184 Ok(BttvChannelEmoteSets {
185 channel: (!response.channel_emotes.is_empty()).then(|| BttvEmoteSet {
186 id: format!("channel:{twitch_id}"),
187 name: "Channel Emotes".to_string(),
188 emotes: response
189 .channel_emotes
190 .into_iter()
191 .map(emote_from_model)
192 .collect(),
193 }),
194 shared: (!response.shared_emotes.is_empty()).then(|| BttvEmoteSet {
195 id: format!("shared:{twitch_id}"),
196 name: "Shared Emotes".to_string(),
197 emotes: response
198 .shared_emotes
199 .into_iter()
200 .map(emote_from_model)
201 .collect(),
202 }),
203 })
204}
205
206#[derive(Deserialize)]
207#[cfg(any(test, feature = "reqwest-client"))]
208struct EmoteModel {
209 id: String,
210 code: String,
211 #[serde(rename = "imageType", default)]
212 image_type: String,
213 #[serde(default)]
214 animated: bool,
215}
216
217#[derive(Deserialize)]
218#[cfg(any(test, feature = "reqwest-client"))]
219struct ChannelResponse {
220 #[serde(rename = "channelEmotes", default)]
221 channel_emotes: Vec<EmoteModel>,
222 #[serde(rename = "sharedEmotes", default)]
223 shared_emotes: Vec<EmoteModel>,
224}
225
226#[cfg(any(test, feature = "reqwest-client"))]
227fn emote_from_model(model: EmoteModel) -> Emote {
228 let is_animated = model.animated
229 || matches!(
230 model.image_type.to_ascii_lowercase().as_str(),
231 "gif" | "webm"
232 );
233 let format = EmoteImageFormat::from_animated(is_animated);
234 let images = vec![
235 EmoteImage {
236 format: format.clone(),
237 theme_mode: EmoteThemeMode::Light,
238 scale: EmoteImageScale::One,
239 url: format!("{CDN_BASE}/{}/1x", model.id),
240 },
241 EmoteImage {
242 format: format.clone(),
243 theme_mode: EmoteThemeMode::Light,
244 scale: EmoteImageScale::Two,
245 url: format!("{CDN_BASE}/{}/2x", model.id),
246 },
247 EmoteImage {
248 format,
249 theme_mode: EmoteThemeMode::Light,
250 scale: EmoteImageScale::Three,
251 url: format!("{CDN_BASE}/{}/3x", model.id),
252 },
253 ];
254
255 Emote::new(
256 EmoteId::new(EmoteProvider::BetterTtv, model.id),
257 model.code,
258 images,
259 )
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn global_fixture_builds_static_and_animated_emotes() {
268 let set = parse_global_emotes_json(include_str!("../tests/fixtures/global_emotes.json"))
269 .expect("global fixture should parse");
270 assert_eq!(set.emotes.len(), 2);
271 assert!(set.emotes.iter().any(|emote| emote.is_animated()));
272 }
273
274 #[test]
275 fn channel_fixture_preserves_channel_and_shared_sets() {
276 let sets =
277 parse_channel_emotes_json("42", include_str!("../tests/fixtures/channel_emotes.json"))
278 .expect("channel fixture should parse");
279 assert_eq!(
280 sets.channel
281 .as_ref()
282 .expect("channel set should exist")
283 .emotes
284 .len(),
285 1
286 );
287 assert_eq!(
288 sets.shared
289 .as_ref()
290 .expect("shared set should exist")
291 .emotes
292 .len(),
293 1
294 );
295 }
296
297 #[tokio::test(flavor = "current_thread")]
298 async fn in_memory_api_round_trips_seeded_sets() {
299 let mut api = InMemoryBttvApi::new().with_global(
300 parse_global_emotes_json(include_str!("../tests/fixtures/global_emotes.json"))
301 .expect("fixture should parse"),
302 );
303 api.insert_channel_sets(
304 "42",
305 parse_channel_emotes_json("42", include_str!("../tests/fixtures/channel_emotes.json"))
306 .expect("fixture should parse"),
307 );
308
309 assert_eq!(
310 api.global_emotes()
311 .await
312 .expect("global set should exist")
313 .emotes
314 .len(),
315 2
316 );
317 assert_eq!(
318 api.channel_emotes_by_twitch_id("42")
319 .await
320 .expect("channel sets should exist")
321 .all_emotes()
322 .len(),
323 2
324 );
325 }
326}