twitch_api2/
types.rs

1//! Twitch types
2
3use serde::{Deserialize, Serialize};
4
5/// A user ID.
6#[aliri_braid::braid(serde)]
7pub struct UserId;
8
9/// A reward ID.
10#[aliri_braid::braid(serde)]
11pub struct RewardId;
12
13/// A reward redemption ID.
14#[aliri_braid::braid(serde)]
15pub struct RedemptionId;
16
17/// A username, also specified as login. Should not be capitalized.
18pub type UserName = Nickname;
19
20/// A reference to a borrowed [`UserName`], also specified as login. Should not be capitalized.
21pub type UserNameRef = NicknameRef;
22
23/// A users display name
24#[aliri_braid::braid(serde)]
25pub struct DisplayName;
26
27/// A nickname, not capitalized.
28#[aliri_braid::braid(serde)]
29pub struct Nickname;
30
31/// RFC3339 timestamp
32#[aliri_braid::braid(serde, validator)]
33pub struct Timestamp;
34
35impl aliri_braid::Validator for Timestamp {
36    type Error = TimestampParseError;
37
38    fn validate(s: &str) -> Result<(), Self::Error> {
39        #[cfg(feature = "time")]
40        {
41            let _ = time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)?;
42            Ok(())
43        }
44        #[cfg(not(feature = "time"))]
45        {
46            // This validator is lacking some features for now
47            if !s.chars().all(|c| {
48                c.is_numeric()
49                    || c == 'T'
50                    || c == 'Z'
51                    || c == '+'
52                    || c == '.'
53                    || c == '-'
54                    || c == ':'
55            }) {
56                return Err(TimestampParseError::invalid());
57            }
58            // PSA: Don't do time stuff... it sucks
59            if let Some(i) = s.find('T') {
60                // if no `T`, then it's not a valid timestamp
61                if i < 1 {
62                    return Err(TimestampParseError::invalid());
63                };
64                let (full_date, full_time) = s.split_at(i);
65                if full_date.len() != "1900-00-00".len() {
66                    return Err(TimestampParseError::invalid_s(full_date));
67                }
68                if !full_date.chars().all(|c| c.is_numeric() || c == '-') {
69                    return Err(TimestampParseError::invalid_s(full_date));
70                }
71                let partial_time = if let Some(stripped) = full_time.strip_suffix('Z') {
72                    stripped
73                } else {
74                    return Err(TimestampParseError::Other("unsupported non-UTC timestamp, enable the `time` feature in `twitch_api2` to enable parsing these"));
75                };
76                if 2 != partial_time
77                    .chars()
78                    .into_iter()
79                    .filter(|&b| b == ':')
80                    .count()
81                {
82                    return Err(TimestampParseError::invalid_s(partial_time));
83                };
84                if !partial_time.contains('.') && partial_time.len() != "T00:00:00".len() {
85                    return Err(TimestampParseError::invalid_s(partial_time));
86                } else if partial_time.contains('.') {
87                    let mut i = partial_time.split('.');
88                    // if len not correct or next is none
89                    if !i
90                        .next()
91                        .map(|s| s.len() == "T00:00:00".len())
92                        .unwrap_or_default()
93                    {
94                        return Err(TimestampParseError::invalid_s(partial_time));
95                    }
96                }
97            } else {
98                return Err(TimestampParseError::invalid());
99            }
100            Ok(())
101        }
102    }
103}
104
105/// Errors that can occur when parsing a timestamp.
106#[derive(Debug, thiserror::Error, displaydoc::Display)]
107#[ignore_extra_doc_attributes]
108#[non_exhaustive]
109pub enum TimestampParseError {
110    /// Could not parse the timestamp using `time`
111    #[cfg(feature = "time")]
112    #[cfg_attr(nightly, doc(cfg(feature = "time")))]
113    TimeError(#[from] time::error::Parse),
114    /// Could not format the timestamp using `time`
115    #[cfg(feature = "time")]
116    #[cfg_attr(nightly, doc(cfg(feature = "time")))]
117    TimeFormatError(#[from] time::error::Format),
118    /// {0}
119    Other(&'static str),
120    /// timestamp has an invalid format. {s:?} - {location}
121    InvalidFormat {
122        /// location of error
123        location: &'static std::panic::Location<'static>,
124        /// Thing that failed
125        s: Option<String>,
126    },
127}
128
129impl TimestampParseError {
130    #[cfg(not(feature = "time"))]
131    #[track_caller]
132    fn invalid() -> Self {
133        Self::InvalidFormat {
134            location: std::panic::Location::caller(),
135            s: None,
136        }
137    }
138
139    #[cfg(not(feature = "time"))]
140    #[track_caller]
141    fn invalid_s(s: &str) -> Self {
142        Self::InvalidFormat {
143            location: std::panic::Location::caller(),
144            s: Some(s.to_string()),
145        }
146    }
147}
148
149impl Timestamp {
150    /// Set the partial-time component of the timestamp.
151    ///
152    /// # Panics
153    ///
154    /// Internally, without the `time` feature, this uses `unsafe` to deal with the raw string bytes. To ensure safety, the method will panic on invalid input and source.
155    fn set_time(&mut self, hours: u8, minutes: u8, seconds: u8) {
156        #[cfg(feature = "time")]
157        {
158            use std::convert::TryInto;
159            let _ = std::mem::replace(
160                self,
161                self.to_fixed_offset()
162                    .replace_time(
163                        time::Time::from_hms(hours, minutes, seconds)
164                            .expect("could not create time"),
165                    )
166                    .try_into()
167                    .expect("could not make timestamp"),
168            );
169        }
170        #[cfg(not(feature = "time"))]
171        {
172            const ERROR_MSG: &str = "malformed timestamp";
173            assert!(hours < 24);
174            assert!(minutes < 60);
175            assert!(seconds < 60);
176
177            #[inline]
178            fn replace_len2(s: &mut str, replace: &str) {
179                assert!(replace.as_bytes().len() == 2);
180                assert!(s.as_bytes().len() == 2);
181
182                let replace = replace.as_bytes();
183                // Safety:
184                // There are two things to make sure the replacement is valid.
185                // 1. The length of the two slices are equal to two.
186                // 2. `replace` slice does not contain any invalid characters.
187                //    As a property of being a `&str` of len 2, start and end of the str slice are valid boundaries, start is index 0, end is index 1 == `replace.len()` => 2 iff 1.)
188                let b = unsafe { s.as_bytes_mut() };
189                b[0] = replace[0];
190                b[1] = replace[1];
191            }
192            let t = self.0.find('T').expect(ERROR_MSG);
193            let partial_time: &mut str = &mut self.0[t + 1..];
194            // find the hours, minutes and seconds
195            let mut matches = partial_time.match_indices(':');
196            let (h, m, s) = (
197                0,
198                matches.next().expect(ERROR_MSG).0 + 1,
199                matches.next().expect(ERROR_MSG).0 + 1,
200            );
201            assert!(matches.next().is_none());
202            // RFC3339 requires partial-time components to be 2DIGIT
203            partial_time
204                .get_mut(h..h + 2)
205                .map(|s| replace_len2(s, &format!("{:02}", hours)))
206                .expect(ERROR_MSG);
207            partial_time
208                .get_mut(m..m + 2)
209                .map(|s| replace_len2(s, &format!("{:02}", minutes)))
210                .expect(ERROR_MSG);
211            partial_time
212                .get_mut(s..s + 2)
213                .map(|s| replace_len2(s, &format!("{:02}", seconds)))
214                .expect(ERROR_MSG);
215        }
216    }
217}
218
219#[cfg(feature = "time")]
220#[cfg_attr(nightly, doc(cfg(feature = "time")))]
221impl Timestamp {
222    /// Create a timestamp corresponding to current time
223    pub fn now() -> Timestamp {
224        use std::convert::TryInto;
225        time::OffsetDateTime::now_utc()
226            .try_into()
227            .expect("could not make timestamp")
228    }
229
230    /// Create a timestamp corresponding to the start of the current day. Timezone will always be UTC.
231    pub fn today() -> Timestamp {
232        use std::convert::TryInto;
233        time::OffsetDateTime::now_utc()
234            .replace_time(time::Time::MIDNIGHT)
235            .try_into()
236            .expect("could not make timestamp")
237    }
238}
239
240impl TimestampRef {
241    /// Normalize the timestamp into UTC time.
242    ///
243    /// # Examples
244    ///
245    /// ```rust
246    /// use twitch_api2::types::Timestamp;
247    ///
248    /// let time = Timestamp::new("2021-07-01T13:37:00Z").unwrap();
249    /// assert_eq!(time.normalize()?.as_ref(), &time);
250    /// let time2 = Timestamp::new("2021-07-01T13:37:00-01:00").unwrap();
251    /// assert_ne!(time2.normalize()?.as_ref(), &time2);
252    /// # Ok::<(), std::boxed::Box<dyn std::error::Error + 'static>>(())
253    /// ```
254    #[allow(unreachable_code)]
255    pub fn normalize(&'_ self) -> Result<std::borrow::Cow<'_, TimestampRef>, TimestampParseError> {
256        let s = self.as_str();
257        if s.ends_with('Z') {
258            Ok(self.into())
259        } else {
260            #[cfg(feature = "time")]
261            {
262                use std::convert::TryInto;
263                let utc = self.to_utc();
264                return Ok(std::borrow::Cow::Owned(utc.try_into()?));
265            }
266            panic!("non `Z` timestamps are not possible to use without the `time` feature enabled for `twitch_api2`")
267        }
268    }
269
270    /// Compare another time and return `self < other`.
271    ///
272    /// # Examples
273    ///
274    /// ```rust
275    /// use twitch_api2::types::Timestamp;
276    ///
277    /// let time2021 = Timestamp::new("2021-07-01T13:37:00Z").unwrap();
278    /// let time2020 = Timestamp::new("2020-07-01T13:37:00Z").unwrap();
279    /// assert!(time2020.is_before(&time2021));
280    /// ```
281    pub fn is_before<T>(&self, other: &T) -> bool
282    where Self: PartialOrd<T> {
283        self < other
284    }
285
286    /// Make a timestamp with the time component set to 00:00:00.
287    ///
288    /// # Examples
289    ///
290    /// ```rust
291    /// use twitch_api2::types::Timestamp;
292    ///
293    /// let time = Timestamp::new("2021-07-01T13:37:00Z").unwrap();
294    /// assert_eq!(time.to_day().as_str(), "2021-07-01T00:00:00Z")
295    /// ```  
296    pub fn to_day(&self) -> Timestamp {
297        let mut c = self.to_owned();
298        c.set_time(0, 0, 0);
299        c
300    }
301}
302
303#[cfg(feature = "time")]
304#[cfg_attr(nightly, doc(cfg(feature = "time")))]
305impl TimestampRef {
306    /// Construct into a [`OffsetDateTime`](time::OffsetDateTime) time with a guaranteed UTC offset.
307    ///
308    /// # Panics
309    ///
310    /// This method assumes the timestamp is a valid rfc3339 timestamp, and panics if not.
311    pub fn to_utc(&self) -> time::OffsetDateTime {
312        self.to_fixed_offset().to_offset(time::UtcOffset::UTC)
313    }
314
315    /// Construct into a [`OffsetDateTime`](time::OffsetDateTime) time.
316    ///
317    /// # Panics
318    ///
319    /// This method assumes the timestamp is a valid rfc3339 timestamp, and panics if not.
320    pub fn to_fixed_offset(&self) -> time::OffsetDateTime {
321        time::OffsetDateTime::parse(&self.0, &time::format_description::well_known::Rfc3339)
322            .expect("this should never fail")
323    }
324}
325
326impl PartialOrd for Timestamp {
327    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
328        // Defer to TimestampRef impl
329        let this: &TimestampRef = self.as_ref();
330        let other: &TimestampRef = other.as_ref();
331        this.partial_cmp(other)
332    }
333}
334
335impl PartialOrd<Timestamp> for TimestampRef {
336    fn partial_cmp(&self, other: &Timestamp) -> Option<std::cmp::Ordering> {
337        // Defer to TimestampRef impl
338        let other: &TimestampRef = other.as_ref();
339        self.partial_cmp(other)
340    }
341}
342
343impl PartialOrd for TimestampRef {
344    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
345        // to check ordering, we normalize offset, then do a lexicographic comparison if possible,
346        // We can do this because the timestamp should always be RFC3339 with time-offset = 'Z' with normalize.
347        // However, we need to make sure punctuation and length is correct. Without the `time` feature, it's impossible to get a non-UTC timestamp, so normalize will do nothing.
348        let this = self
349            .normalize()
350            .expect("normalization failed, this is a bug");
351        let other = other
352            .normalize()
353            .expect("normalization of other failed, this is a bug");
354        // If a punctuation exists in only one, we can't order.
355        #[allow(clippy::if_same_then_else)]
356        if this.as_ref().as_str().contains('.') ^ other.as_ref().as_str().contains('.') {
357            #[cfg(feature = "tracing")]
358            tracing::trace!("comparing two `Timestamps` with differing punctuation");
359            return None;
360        } else if this.0.len() != other.0.len() {
361            #[cfg(feature = "tracing")]
362            tracing::trace!("comparing two `Timestamps` with differing length");
363            return None;
364        }
365        this.as_str().partial_cmp(other.as_str())
366    }
367}
368
369#[cfg(feature = "time")]
370#[cfg_attr(nightly, doc(cfg(feature = "time")))]
371impl PartialEq<time::OffsetDateTime> for Timestamp {
372    fn eq(&self, other: &time::OffsetDateTime) -> bool {
373        // Defer to TimestampRef impl
374        let this: &TimestampRef = self.as_ref();
375        this.eq(other)
376    }
377}
378
379#[cfg(feature = "time")]
380#[cfg_attr(nightly, doc(cfg(feature = "time")))]
381impl PartialOrd<time::OffsetDateTime> for Timestamp {
382    fn partial_cmp(&self, other: &time::OffsetDateTime) -> Option<std::cmp::Ordering> {
383        // Defer to TimestampRef impl
384        let this: &TimestampRef = self.as_ref();
385        this.partial_cmp(other)
386    }
387}
388
389#[cfg(feature = "time")]
390#[cfg_attr(nightly, doc(cfg(feature = "time")))]
391impl PartialEq<time::OffsetDateTime> for TimestampRef {
392    fn eq(&self, other: &time::OffsetDateTime) -> bool { &self.to_utc() == other }
393}
394
395#[cfg(feature = "time")]
396#[cfg_attr(nightly, doc(cfg(feature = "time")))]
397impl PartialOrd<time::OffsetDateTime> for TimestampRef {
398    fn partial_cmp(&self, other: &time::OffsetDateTime) -> Option<std::cmp::Ordering> {
399        self.to_utc().partial_cmp(other)
400    }
401}
402
403#[cfg(feature = "time")]
404#[cfg_attr(nightly, doc(cfg(feature = "time")))]
405impl std::convert::TryFrom<time::OffsetDateTime> for Timestamp {
406    type Error = time::error::Format;
407
408    fn try_from(value: time::OffsetDateTime) -> Result<Self, Self::Error> {
409        Ok(Timestamp(
410            value.format(&time::format_description::well_known::Rfc3339)?,
411        ))
412    }
413}
414
415/// A blocked term ID
416#[aliri_braid::braid(serde)]
417pub struct BlockedTermId;
418
419/// A game or category ID
420#[aliri_braid::braid(serde)]
421pub struct CategoryId;
422
423/// A tag ID
424#[aliri_braid::braid(serde)]
425pub struct TagId;
426
427/// A video ID
428#[aliri_braid::braid(serde)]
429pub struct VideoId;
430
431/// An EventSub Subscription ID
432#[aliri_braid::braid(serde)]
433pub struct EventSubId;
434
435/// A Team ID
436#[aliri_braid::braid(serde)]
437pub struct TeamId;
438
439/// A Stream ID
440#[aliri_braid::braid(serde)]
441pub struct StreamId;
442
443/// A message ID
444#[aliri_braid::braid(serde)]
445pub struct MsgId;
446
447/// A poll ID
448#[aliri_braid::braid(serde)]
449pub struct PollId;
450
451/// A poll choice ID
452#[aliri_braid::braid(serde)]
453pub struct PollChoiceId;
454
455/// A prediction ID
456#[aliri_braid::braid(serde)]
457pub struct PredictionId;
458
459/// A prediction choice ID
460#[aliri_braid::braid(serde)]
461pub struct PredictionOutcomeId;
462
463/// A Badge set ID
464#[aliri_braid::braid(serde)]
465pub struct BadgeSetId;
466
467/// A channel chat badge ID
468#[aliri_braid::braid(serde)]
469pub struct ChatBadgeId;
470
471/// A chat Emote ID
472#[aliri_braid::braid(serde)]
473pub struct EmoteId;
474
475impl EmoteIdRef {
476    /// Generates url for this emote.
477    ///
478    /// Generated URL will be `"https://static-cdn.jtvnw.net/emoticons/v2/{emote_id}/default/light/1.0"`
479    pub fn default_render(&self) -> String {
480        EmoteUrlBuilder {
481            id: self.into(),
482            animation_setting: None,
483            theme_mode: EmoteThemeMode::Light,
484            scale: EmoteScale::Size1_0,
485            template: EMOTE_V2_URL_TEMPLATE.into(),
486        }
487        .render()
488    }
489
490    /// Create a [`EmoteUrlBuilder`] for this emote
491    pub fn url(&self) -> EmoteUrlBuilder<'_> { EmoteUrlBuilder::new(self) }
492}
493
494pub(crate) static EMOTE_V2_URL_TEMPLATE: &str =
495    "https://static-cdn.jtvnw.net/emoticons/v2/{{id}}/{{format}}/{{theme_mode}}/{{scale}}";
496
497/// Formats for an emote.
498#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
499#[serde(rename_all = "lowercase")]
500pub enum EmoteAnimationSetting {
501    /// Static
502    Static,
503    /// Animated
504    Animated,
505}
506
507impl std::fmt::Display for EmoteAnimationSetting {
508    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.serialize(f) }
509}
510
511/// Background themes available for an emote.
512#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
513#[serde(rename_all = "lowercase")]
514pub enum EmoteThemeMode {
515    /// Light
516    Light,
517    /// Dark
518    Dark,
519}
520
521impl Default for EmoteThemeMode {
522    fn default() -> Self { Self::Light }
523}
524
525impl std::fmt::Display for EmoteThemeMode {
526    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.serialize(f) }
527}
528
529/// Scales available for an emote.
530#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
531pub enum EmoteScale {
532    /// 1.0
533    #[serde(rename = "1.0")]
534    Size1_0,
535    /// 2.0
536    #[serde(rename = "2.0")]
537    Size2_0,
538    /// 3.0
539    #[serde(rename = "3.0")]
540    Size3_0,
541}
542
543impl Default for EmoteScale {
544    fn default() -> Self { Self::Size1_0 }
545}
546
547impl std::fmt::Display for EmoteScale {
548    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.serialize(f) }
549}
550
551/// Builder for [emote URLs](https://dev.twitch.tv/docs/irc/emotes#emote-cdn-url-format).
552///
553/// # Examples
554///
555/// ```rust
556/// # use twitch_api2::types::EmoteId;
557/// let emote_id = EmoteId::from("emotesv2_dc24652ada1e4c84a5e3ceebae4de709");
558/// assert_eq!(emote_id.url().size_3x().dark_mode().render(), "https://static-cdn.jtvnw.net/emoticons/v2/emotesv2_dc24652ada1e4c84a5e3ceebae4de709/default/dark/3.0")
559/// ```
560#[derive(Debug, Clone)]
561pub struct EmoteUrlBuilder<'a> {
562    pub(crate) id: std::borrow::Cow<'a, EmoteIdRef>,
563    pub(crate) animation_setting: Option<EmoteAnimationSetting>,
564    pub(crate) theme_mode: EmoteThemeMode,
565    pub(crate) scale: EmoteScale,
566    pub(crate) template: std::borrow::Cow<'a, str>,
567}
568
569impl EmoteUrlBuilder<'_> {
570    // FIXME: AsRef
571    /// Construct a new [`EmoteUrlBuilder`] from a [`EmoteId`]
572    ///
573    /// Defaults to `1.0` scale, `default` animation and `light` theme.
574    pub fn new(id: &EmoteIdRef) -> EmoteUrlBuilder<'_> {
575        EmoteUrlBuilder {
576            id: id.into(),
577            animation_setting: <_>::default(),
578            theme_mode: <_>::default(),
579            scale: <_>::default(),
580            template: EMOTE_V2_URL_TEMPLATE.into(),
581        }
582    }
583
584    /// Set size to 1.0
585    pub fn size_1x(mut self) -> Self {
586        self.scale = EmoteScale::Size1_0;
587        self
588    }
589
590    /// Set size to 2.0
591    pub fn size_2x(mut self) -> Self {
592        self.scale = EmoteScale::Size2_0;
593        self
594    }
595
596    /// Set size to 3.0
597    pub fn size_3x(mut self) -> Self {
598        self.scale = EmoteScale::Size3_0;
599        self
600    }
601
602    /// Set theme to dark mode
603    pub fn dark_mode(mut self) -> Self {
604        self.theme_mode = EmoteThemeMode::Dark;
605        self
606    }
607
608    /// Set theme to light mode
609    pub fn light_mode(mut self) -> Self {
610        self.theme_mode = EmoteThemeMode::Light;
611        self
612    }
613
614    /// Set animation mode to default
615    pub fn animation_default(mut self) -> Self {
616        self.animation_setting = None;
617        self
618    }
619
620    /// Set animation mode to static
621    pub fn animation_static(mut self) -> Self {
622        self.animation_setting = Some(EmoteAnimationSetting::Static);
623        self
624    }
625
626    /// Set animation mode to animate
627    pub fn animation_animated(mut self) -> Self {
628        self.animation_setting = Some(EmoteAnimationSetting::Animated);
629        self
630    }
631
632    /// Create the URL for this emote.
633    pub fn render(self) -> String {
634        if self.template != "https://static-cdn.jtvnw.net/emoticons/v2/{{id}}/{{format}}/{{theme_mode}}/{{scale}}" {
635            let custom_template = |builder: &EmoteUrlBuilder| -> Option<String> {
636                let mut template = self.template.clone().into_owned();
637                let emote_id_range = template.find("{{id}}")?;
638                eprintln!("id");
639                template.replace_range(emote_id_range..emote_id_range+"{{id}}".len(), builder.id.as_str());
640                eprintln!("format");
641                let format_range = template.find("{{format}}")?;
642                template.replace_range(format_range..format_range+"{{format}}".len(), &builder.animation_setting.as_ref().map(|s| s.to_string()).unwrap_or_else(|| String::from("default")));
643                eprintln!("theme_mode");
644                let theme_mode_range = template.find("{{theme_mode}}")?;
645                template.replace_range(theme_mode_range..theme_mode_range+"{{theme_mode}}".len(), &builder.theme_mode.to_string());
646                eprintln!("scale");
647                let scale_range = template.find("{{scale}}")?;
648                template.replace_range(scale_range..scale_range+"{{scale}}".len(), &builder.scale.to_string());
649                if template.contains("{{") || template.contains("}}") {
650                    None
651                } else {
652                    Some(template)
653                }
654            };
655            if let Some(template) = custom_template(&self) {
656                return template
657            } else {
658                #[cfg(feature = "tracing")]
659                tracing::warn!(template = %self.template, "emote builder was supplied an invalid or unknown template url, falling back to standard builder");
660            }
661        }
662        // fallback to known working template
663        format!("https://static-cdn.jtvnw.net/emoticons/v2/{emote_id}/{animation_setting}/{theme_mode}/{scale}",
664            emote_id = self.id,
665            animation_setting = self.animation_setting.as_ref().map(|s| s.to_string()).unwrap_or_else(|| String::from("default")),
666            theme_mode = self.theme_mode,
667            scale = self.scale,
668        )
669    }
670}
671
672/// An Emote Set ID
673#[aliri_braid::braid(serde)]
674pub struct EmoteSetId;
675
676/// A Stream Segment ID.
677#[aliri_braid::braid(serde)]
678pub struct StreamSegmentId;
679
680/// A Hype Train ID
681#[aliri_braid::braid(serde)]
682pub struct HypeTrainId;
683
684/// A Creator Goal ID
685#[aliri_braid::braid(serde)]
686pub struct CreatorGoalId;
687
688/// An emote index as defined by eventsub, similar to IRC `emotes` twitch tag.
689#[derive(PartialEq, Eq, Deserialize, Serialize, Debug, Clone)]
690#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
691#[non_exhaustive]
692pub struct ResubscriptionEmote {
693    /// The index of where the Emote starts in the text.
694    pub begin: i64,
695    /// The index of where the Emote ends in the text.
696    pub end: i64,
697    /// The emote ID.
698    pub id: EmoteId,
699}
700
701impl std::fmt::Display for ResubscriptionEmote {
702    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703        write!(f, "{}:{}-{}", self.id, self.begin, self.end)
704    }
705}
706
707/// A game or category as defined by Twitch
708#[derive(PartialEq, Deserialize, Serialize, Debug, Clone)]
709#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
710#[non_exhaustive]
711pub struct TwitchCategory {
712    /// Template URL for the game’s box art.
713    pub box_art_url: String,
714    /// Game or category ID.
715    pub id: CategoryId,
716    /// Game name.
717    pub name: String,
718}
719
720/// Subscription tiers
721#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
722#[serde(field_identifier)]
723pub enum SubscriptionTier {
724    /// Tier 1. $4.99
725    #[serde(rename = "1000")]
726    Tier1,
727    /// Tier 1. $9.99
728    #[serde(rename = "2000")]
729    Tier2,
730    /// Tier 1. $24.99
731    #[serde(rename = "3000")]
732    Tier3,
733    /// Prime subscription
734    Prime,
735    /// Other
736    Other(String),
737}
738
739impl Serialize for SubscriptionTier {
740    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
741    where S: serde::Serializer {
742        serializer.serialize_str(match self {
743            SubscriptionTier::Tier1 => "1000",
744            SubscriptionTier::Tier2 => "2000",
745            SubscriptionTier::Tier3 => "3000",
746            SubscriptionTier::Prime => "Prime",
747            SubscriptionTier::Other(o) => o,
748        })
749    }
750}
751
752/// Broadcaster types: "partner", "affiliate", or "".
753#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
754pub enum BroadcasterType {
755    /// Partner
756    #[serde(rename = "partner")]
757    Partner,
758    /// Affiliate
759    #[serde(rename = "affiliate")]
760    Affiliate,
761    /// None
762    #[serde(other)]
763    None,
764}
765
766impl Serialize for BroadcasterType {
767    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
768    where S: serde::Serializer {
769        serializer.serialize_str(match self {
770            BroadcasterType::Partner => "partner",
771            BroadcasterType::Affiliate => "affiliate",
772            BroadcasterType::None => "",
773        })
774    }
775}
776
777/// User types: "staff", "admin", "global_mod", or "".
778#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
779pub enum UserType {
780    /// Staff
781    #[serde(rename = "staff")]
782    Staff,
783    /// Admin
784    #[serde(rename = "admin")]
785    Admin,
786    /// Global Moderator
787    #[serde(rename = "global_mod")]
788    GlobalMod,
789    /// None
790    #[serde(other)]
791    None,
792}
793
794impl Serialize for UserType {
795    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
796    where S: serde::Serializer {
797        serializer.serialize_str(match self {
798            UserType::Staff => "staff",
799            UserType::Admin => "admin",
800            UserType::GlobalMod => "global_mod",
801            UserType::None => "",
802        })
803    }
804}
805
806/// Period during which the video was created
807#[derive(PartialEq, Deserialize, Serialize, Clone, Debug)]
808#[serde(rename_all = "lowercase")]
809pub enum VideoPeriod {
810    /// Filter by all. Effectively a no-op
811    All,
812    /// Filter by from this day only
813    Day,
814    /// Filter by this week
815    Week,
816    /// Filter by this month
817    Month,
818}
819
820/// Type of video
821#[derive(PartialEq, Eq, Deserialize, Serialize, Clone, Debug)]
822#[serde(rename_all = "snake_case")]
823pub enum VideoType {
824    /// A live video
825    Live,
826    // FIXME: What is this?
827    /// A playlist video
828    Playlist,
829    /// A uploaded video
830    Upload,
831    /// An archived video
832    Archive,
833    /// A highlight
834    Highlight,
835    /// A premiere
836    Premiere,
837    /// A rerun
838    Rerun,
839    /// A watch party
840    WatchParty,
841    /// A watchparty premiere,
842    WatchPartyPremiere,
843    /// A watchparty rerun
844    WatchPartyRerun,
845}
846
847/// Type of video
848#[derive(PartialEq, Eq, Deserialize, Serialize, Clone, Debug)]
849#[serde(rename_all = "lowercase")]
850pub enum VideoPrivacy {
851    /// Video is public
852    Public,
853    /// Video is private
854    Private,
855}
856
857/// Length of the commercial in seconds
858#[derive(
859    displaydoc::Display,
860    serde_repr::Serialize_repr,
861    serde_repr::Deserialize_repr,
862    Debug,
863    Clone,
864    PartialEq,
865    Eq,
866)]
867#[repr(u64)]
868#[non_exhaustive]
869pub enum CommercialLength {
870    /// 30s
871    Length30 = 30,
872    /// 60s
873    Length60 = 60,
874    /// 90s
875    Length90 = 90,
876    /// 120s
877    Length120 = 120,
878    /// 150s
879    Length150 = 150,
880    /// 180s
881    Length180 = 180,
882}
883
884impl std::convert::TryFrom<u64> for CommercialLength {
885    type Error = CommercialLengthParseError;
886
887    fn try_from(l: u64) -> Result<Self, Self::Error> {
888        match l {
889            30 => Ok(CommercialLength::Length30),
890            60 => Ok(CommercialLength::Length60),
891            90 => Ok(CommercialLength::Length90),
892            120 => Ok(CommercialLength::Length120),
893            150 => Ok(CommercialLength::Length150),
894            180 => Ok(CommercialLength::Length180),
895            other => Err(CommercialLengthParseError::InvalidLength(other)),
896        }
897    }
898}
899
900/// Error for the `TryFrom` on [`CommercialLength`]
901#[derive(thiserror::Error, Debug, displaydoc::Display)]
902pub enum CommercialLengthParseError {
903    /// invalid length of {0}
904    InvalidLength(u64),
905}
906
907/// A user according to many endpoints
908#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
909#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
910pub struct User {
911    /// ID of the user
912    #[serde(alias = "user_id")]
913    pub id: UserId,
914    /// Login name of the user, not capitalized
915    #[serde(alias = "user_login")]
916    pub login: UserName,
917    /// Display name of user
918    #[serde(alias = "user_display_name", alias = "user_name")]
919    pub display_name: DisplayName,
920    #[serde(default)]
921    /// URL of the user's profile
922    pub profile_image_url: Option<String>,
923}
924
925/// Links to the same image of different sizes
926#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
927#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
928#[non_exhaustive]
929pub struct Image {
930    /// URL to png of size 28x28
931    pub url_1x: String,
932    /// URL to png of size 56x56
933    pub url_2x: String,
934    /// URL to png of size 112x112
935    pub url_4x: String,
936}
937
938/// Information about global cooldown
939#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
940#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
941#[non_exhaustive]
942pub struct GlobalCooldown {
943    /// Cooldown enabled
944    pub is_enabled: bool,
945    /// Cooldown amount
946    #[serde(alias = "seconds")]
947    pub global_cooldown_seconds: u32,
948}
949
950/// Reward redemption max
951#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
952#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
953#[serde(untagged)]
954#[non_exhaustive]
955pub enum Max {
956    /// Max per stream
957    MaxPerStream {
958        /// Max per stream is enabled
959        is_enabled: bool,
960        /// Max amount of redemptions per stream
961        #[serde(alias = "value")]
962        max_per_stream: u32,
963    },
964    /// Max per user per stream
965    MaxPerUserPerStream {
966        /// Max per user per stream is enabled
967        is_enabled: bool,
968        /// Max amount of redemptions per user per stream
969        #[serde(alias = "value")]
970        max_per_user_per_stream: u32,
971    },
972}
973
974/// Poll choice
975#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
976#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
977#[non_exhaustive]
978pub struct PollChoice {
979    /// ID for the choice.
980    pub id: String,
981    /// Text displayed for the choice.
982    pub title: String,
983    /// Total number of votes received for the choice across all methods of voting.
984    pub votes: Option<i64>,
985    /// Number of votes received via Channel Points.
986    pub channel_points_votes: Option<i64>,
987    /// Number of votes received via Bits.
988    pub bits_votes: Option<i64>,
989}
990
991// FIXME: Poll status has different name depending on if returned from helix or eventsub. See https://twitch.uservoice.com/forums/310213-developers/suggestions/43402176
992/// Status of a poll
993#[derive(PartialEq, Eq, Deserialize, Serialize, Debug, Clone)]
994#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
995#[serde(rename_all = "UPPERCASE")]
996#[non_exhaustive]
997pub enum PollStatus {
998    /// Poll is currently in progress.
999    #[serde(alias = "active")]
1000    Active,
1001    /// Poll has reached its ended_at time.
1002    #[serde(alias = "completed")]
1003    Completed,
1004    /// Poll has been manually terminated before its ended_at time.
1005    #[serde(alias = "terminated")]
1006    Terminated,
1007    /// Poll is no longer visible on the channel.
1008    #[serde(alias = "archived")]
1009    Archived,
1010    /// Poll is no longer visible to any user on Twitch.
1011    #[serde(alias = "moderated")]
1012    Moderated,
1013    /// Something went wrong determining the state.
1014    #[serde(alias = "invalid")]
1015    Invalid,
1016}
1017
1018// FIXME: Prediction status has different name depending on if returned from helix or eventsub. See https://twitch.uservoice.com/forums/310213-developers/suggestions/43402197
1019/// Status of the Prediction
1020#[derive(PartialEq, Deserialize, Serialize, Debug, Clone)]
1021#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
1022#[serde(rename_all = "UPPERCASE")]
1023#[non_exhaustive]
1024pub enum PredictionStatus {
1025    /// A winning outcome has been chosen and the Channel Points have been distributed to the users who guessed the correct outcome.
1026    #[serde(alias = "resolved")]
1027    Resolved,
1028    /// The Prediction is active and viewers can make predictions.
1029    #[serde(alias = "active")]
1030    Active,
1031    /// The Prediction has been canceled and the Channel Points have been refunded to participants.
1032    #[serde(alias = "canceled")]
1033    Canceled,
1034    /// The Prediction has been locked and viewers can no longer make predictions.
1035    #[serde(alias = "locked")]
1036    Locked,
1037}
1038
1039/// Outcome for the Prediction
1040#[derive(PartialEq, Deserialize, Serialize, Debug, Clone)]
1041#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
1042#[non_exhaustive]
1043pub struct PredictionOutcome {
1044    /// ID for the outcome.
1045    pub id: String,
1046    /// Text displayed for outcome.
1047    pub title: String,
1048    /// Number of unique users that chose the outcome.
1049    pub users: Option<i64>,
1050    /// Number of Channel Points used for the outcome.
1051    pub channel_points: Option<i64>,
1052    /// Array of users who were the top predictors. null if none. Top 10
1053    pub top_predictors: Option<Vec<PredictionTopPredictors>>,
1054    /// Color for the outcome. Valid values: BLUE, PINK
1055    pub color: String,
1056}
1057
1058// FIXME: eventsub adds prefix `user_*`. See https://discord.com/channels/325552783787032576/326772207844065290/842359030252437514
1059/// Users who were the top predictors.
1060#[derive(PartialEq, Deserialize, Serialize, Debug, Clone)]
1061#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
1062#[non_exhaustive]
1063pub struct PredictionTopPredictors {
1064    /// ID of the user.
1065    #[serde(alias = "user_id")]
1066    pub id: UserId,
1067    /// Display name of the user.
1068    #[serde(alias = "user_name")]
1069    pub name: DisplayName,
1070    /// Login of the user.
1071    #[serde(alias = "user_login")]
1072    pub login: UserName,
1073    /// Number of Channel Points used by the user.
1074    pub channel_points_used: i64,
1075    /// Number of Channel Points won by the user.
1076    ///
1077    /// This value is always null in the event payload for Prediction progress and Prediction lock. This value is 0 if the outcome did not win or if the Prediction was canceled and Channel Points were refunded.
1078    pub channel_points_won: Option<i64>,
1079}
1080
1081/// Status of a message that is or was in AutoMod queue
1082#[derive(PartialEq, Eq, Deserialize, Serialize, Debug, Clone)]
1083#[cfg_attr(feature = "deny_unknown_fields", serde(deny_unknown_fields))]
1084#[serde(rename_all = "UPPERCASE")]
1085#[non_exhaustive]
1086pub enum AutomodStatus {
1087    /// Message has been caught and pending moderation
1088    Pending,
1089    /// Message has been allowed
1090    Allowed,
1091    /// Message has been denied
1092    Denied,
1093    /// Automod message expired in queue
1094    Expired,
1095}
1096
1097/// Type of creator goal
1098#[derive(PartialEq, Eq, Deserialize, Serialize, Debug, Clone)]
1099#[serde(rename_all = "lowercase")]
1100#[non_exhaustive]
1101pub enum CreatorGoalType {
1102    /// Creator goal is for followers
1103    Follower,
1104    /// Creator goal is for subscriptions
1105    Subscription,
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111
1112    #[test]
1113    pub fn time_test() {
1114        let mut time1 = Timestamp::new("2021-11-11T10:00:00Z").unwrap();
1115        time1.set_time(10, 0, 32);
1116        let time2 = Timestamp::new("2021-11-10T10:00:00Z").unwrap();
1117        assert!(time2.is_before(&time1));
1118        dbg!(time1.normalize().unwrap());
1119        #[cfg(feature = "time")]
1120        let time = Timestamp::new("2021-11-11T13:37:00-01:00").unwrap();
1121        #[cfg(feature = "time")]
1122        dbg!(time.normalize().unwrap());
1123    }
1124}