Skip to main content

barbed_bttv/
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, 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}