Skip to main content

ac_rustube/
id.rs

1use alloc::borrow::{Cow, ToOwned};
2use alloc::string::String;
3
4#[cfg(feature = "regex")]
5use regex::Regex;
6use serde::{
7    de::{Error as SerdeError, Unexpected},
8    Deserialize, Deserializer, Serialize,
9};
10use url::Url;
11
12use core::cmp::Ordering;
13
14#[cfg(feature = "std")]
15use crate::{Error, Result};
16
17/// Alias for an owned [`Id`].
18pub type IdBuf = Id<'static>;
19
20// todo: check patterns with regex debugger
21/// A list of possible YouTube video identifier patterns.
22///
23/// ## Guarantees:
24/// - each pattern contains an `id` group that will always capture when the pattern matches
25/// - The captured id will always match following regex (defined in [ID_PATTERN]): `^[a-zA-Z0-9_-]{11}$`
26#[cfg(feature = "regex")]
27pub static ID_PATTERNS: [&once_cell::sync::Lazy<Regex>; 5] = [
28    &WATCH_URL_PATTERN,
29    &SHORTS_URL_PATTERN,
30    &EMBED_URL_PATTERN,
31    &SHARE_URL_PATTERN,
32    &ID_PATTERN
33];
34/// A pattern matching the watch url of a video (i.e. `youtube.com/watch?v=<ID>`).
35#[cfg(feature = "regex")]
36pub static WATCH_URL_PATTERN: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(||
37    // watch url    (i.e. https://youtube.com/watch?v=video_id)
38    Regex::new(r"^(https?://)?(www\.)?youtube.\w\w\w?/watch\?v=(?P<id>[a-zA-Z0-9_-]{11})(&.*)?$").unwrap()
39);
40/// A pattern matching the shorts url of a video (i.e. `https://youtube.com/shorts/<ID>`).
41#[cfg(feature = "regex")]
42pub static SHORTS_URL_PATTERN: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(||
43    Regex::new(r"^(https?://)?(www\.)?youtube.\w\w\w?/shorts/(?P<id>[a-zA-Z0-9_-]{11})(\?.*)?$").unwrap()
44);
45/// A pattern matching the embedded url of a video (i.e. `youtube.com/embed/<ID>`).
46#[cfg(feature = "regex")]
47pub static EMBED_URL_PATTERN: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(||
48    // embed url    (i.e. https://youtube.com/embed/video_id)
49    Regex::new(r"^(https?://)?(www\.)?youtube.\w\w\w?/embed/(?P<id>[a-zA-Z0-9_-]{11})\\?(\?.*)?$").unwrap()
50);
51/// A pattern matching the embedded url of a video (i.e. `youtu.be/<ID>`).
52#[cfg(feature = "regex")]
53pub static SHARE_URL_PATTERN: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(||
54    // share url    (i.e. https://youtu.be/video_id)
55    Regex::new(r"^(https?://)?youtu\.be/(?P<id>[a-zA-Z0-9_-]{11})$").unwrap()
56);
57/// A pattern matching the id of a video (`^[a-zA-Z0-9_-]{11}$`).
58#[cfg(feature = "regex")]
59pub static ID_PATTERN: once_cell::sync::Lazy<Regex> = once_cell::sync::Lazy::new(||
60    // id          (i.e. video_id)
61    Regex::new("^(?P<id>[a-zA-Z0-9_-]{11})$").unwrap()
62);
63
64/// A wrapper around a Cow<'a, str> that makes sure the video id, which is contained, always
65/// has the correct format.
66///
67///
68/// ## Guaranties:
69/// Since YouTube does not guarantee a consistent video-id format, these guarantees can change in
70/// major version updates. If your application depends on them, make sure to check this section on
71/// regular bases!
72///
73/// - The id will always match following regex (defined in [ID_PATTERN]): `^[a-zA-Z0-9_-]{11}$`
74/// - The id can always be used as a valid url segment
75/// - The id can always be used as a valid url parameter
76///
77/// ## Ownership
78/// All available constructors except for [`Id::deserialize`] and [`Id::from_string`] will
79/// create the borrowed version with the lifetime of the input. Therefore no allocation is required.
80///
81/// If you don't need 'static deserialization, you can use [`Id::deserialize_borrowed`], which will
82/// create an `Id<'de>`.
83///
84/// If you require [`Id`] to be owned (`Id<'static`>), you can use [`Id::as_owned`] or
85/// [`Id::into_owned`], which both can easily be chained. You can also use [`IdBuf`], which is
86/// an alias for `Id<'static>`, to make functions and types less verbose.
87#[derive(Clone, Debug, Serialize, Hash)]
88pub struct Id<'a>(Cow<'a, str>);
89
90#[allow(clippy::should_implement_trait)]
91impl<'a> Id<'a> {
92    cfg_if::cfg_if! {
93        if #[cfg(feature = "regex")] {
94            pub fn from_raw(raw: &'a str) -> Result<Self> {
95                ID_PATTERNS
96                    .iter()
97                    .find_map(|pattern|
98                        pattern
99                            .captures(raw)
100                            .map(|c| {
101                                // will never panic due to guarantees by [`ID_PATTERNS`]
102                                let id = c.name("id").unwrap().as_str();
103                                Self(Cow::Borrowed(id))
104                            })
105                    )
106                    .ok_or(Error::BadIdFormat)
107            }
108
109            #[inline]
110            pub fn from_str(id: &'a str) -> Result<Self> {
111                match ID_PATTERN.is_match(id) {
112                    true => Ok(Self(Cow::Borrowed(id))),
113                    false => Err(Error::BadIdFormat)
114                }
115            }
116        } else {
117            #[inline]
118            pub fn from_str(id: &'a str) -> Option<Self> {
119                match Self::check_str(id) {
120                    Ok(_) => Some(Self(Cow::Borrowed(id))),
121                    Err(_) => None
122                }
123            }
124
125            #[inline]
126            fn check_str(id: &'_ str) -> Result<(), ()> {
127                if id.len() != 11 {
128                    return Err(());
129                }
130
131                let only_allowed_chars = id
132                    .chars()
133                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
134
135                if only_allowed_chars {
136                    Ok(())
137                } else {
138                    Err(())
139                }
140            }
141        }
142    }
143}
144
145impl<'a> Id<'a> {
146    #[inline]
147    pub fn is_borrowed(&self) -> bool {
148        matches!(self.0, Cow::Borrowed(_))
149    }
150
151    #[inline]
152    pub fn is_owned(&self) -> bool {
153        matches!(self.0, Cow::Owned(_))
154    }
155
156    #[inline]
157    pub fn make_owned(&mut self) -> &mut Self {
158        if let Cow::Borrowed(id) = self.0 {
159            self.0 = Cow::Owned(id.to_owned());
160        }
161        self
162    }
163
164    #[inline]
165    #[must_use]
166    pub fn into_owned(self) -> IdBuf {
167        match self.0 {
168            Cow::Owned(id) => Id(Cow::Owned(id)),
169            Cow::Borrowed(id) => Id(Cow::Owned(id.to_owned()))
170        }
171    }
172
173    #[inline]
174    #[must_use]
175    pub fn as_owned(&self) -> IdBuf {
176        self
177            .clone()
178            .into_owned()
179    }
180
181    #[inline]
182    #[must_use]
183    pub fn as_borrowed(&'a self) -> Self {
184        Self(Cow::Borrowed(&self.0))
185    }
186
187    #[inline]
188    #[must_use]
189    pub fn as_str(&self) -> &str {
190        self.0.as_ref()
191    }
192
193    #[inline]
194    #[must_use]
195    pub fn watch_url(&self) -> Url {
196        Url::parse_with_params(
197            "https://www.youtube.com/watch?",
198            &[("v", self.as_str())],
199        ).unwrap()
200    }
201
202    #[inline]
203    #[must_use]
204    pub fn shorts_url(&self) -> Url {
205        let mut url = Url::parse("https://www.youtube.com/shorts")
206            .unwrap();
207        url
208            .path_segments_mut()
209            .unwrap()
210            .push(self.as_str());
211        url
212    }
213
214    #[inline]
215    #[must_use]
216    pub fn embed_url(&self) -> Url {
217        let mut url = Url::parse("https://www.youtube.com/embed")
218            .unwrap();
219        url
220            .path_segments_mut()
221            .unwrap()
222            .push(self.as_str());
223        url
224    }
225
226    #[inline]
227    #[must_use]
228    pub fn share_url(&self) -> Url {
229        let mut url = Url::parse("https://youtu.be")
230            .unwrap();
231        url
232            .path_segments_mut()
233            .unwrap()
234            .push(self.as_str());
235        url
236    }
237}
238
239impl IdBuf {
240    cfg_if::cfg_if! {
241        if #[cfg(feature = "regex")] {
242            #[inline]
243            pub fn from_string(id: String) -> Result<Self, String> {
244                match ID_PATTERN.is_match(id.as_str()) {
245                    true => Ok(Self(Cow::Owned(id))),
246                    false => Err(id)
247                }
248            }
249        } else {
250            #[inline]
251            pub fn from_string(id: String) -> Result<Self, String> {
252                match Self::check_str(&id) {
253                    Ok(_) => Ok(Self(Cow::Owned(id))),
254                    Err(_) => Err(id)
255                }
256            }
257        }
258    }
259}
260
261impl<'de> Id<'de> {
262    #[inline]
263    pub fn deserialize_borrowed<D>(deserializer: D) -> Result<Self, <D as Deserializer<'de>>::Error>
264        where
265            D: Deserializer<'de> {
266        let raw = <&'de str>::deserialize(deserializer)?;
267        #[cfg(not(all(feature = "regex", feature = "std")))]
268            let res = Self::from_str(raw).ok_or(());
269        #[cfg(all(feature = "regex", feature = "std"))]
270            let res = Self::from_raw(raw);
271
272        res
273            .map_err(|_| D::Error::invalid_value(
274                Unexpected::Str(raw),
275                &"expected a valid youtube video identifier",
276            ))
277    }
278}
279
280impl<'de> Deserialize<'de> for Id<'static> {
281    #[inline]
282    fn deserialize<D>(deserializer: D) -> Result<Self, <D as Deserializer<'de>>::Error>
283        where
284            D: Deserializer<'de> {
285        let raw = String::deserialize(deserializer)?;
286        Self::from_string(raw)
287            .map_err(|s| D::Error::invalid_value(
288                Unexpected::Str(&s),
289                &"expected a valid youtube video identifier",
290            ))
291    }
292}
293
294
295impl core::fmt::Display for Id<'_> {
296    #[inline]
297    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
298        f.write_str(self.as_str())
299    }
300}
301
302impl core::ops::Deref for Id<'_> {
303    type Target = str;
304
305    #[inline]
306    fn deref(&self) -> &Self::Target {
307        self.as_str()
308    }
309}
310
311impl core::convert::AsRef<str> for Id<'_> {
312    #[inline]
313    fn as_ref(&self) -> &str {
314        self.as_str()
315    }
316}
317
318impl<T> core::cmp::PartialEq<T> for Id<'_>
319    where
320        T: core::convert::AsRef<str> {
321    #[inline]
322    fn eq(&self, other: &T) -> bool {
323        core::cmp::PartialEq::eq(
324            self.as_str(),
325            other.as_ref(),
326        )
327    }
328}
329
330impl core::cmp::Eq for Id<'_> {}
331
332impl core::cmp::Ord for Id<'_> {
333    #[inline]
334    fn cmp(&self, other: &Self) -> Ordering {
335        self.as_str().cmp(other.as_str())
336    }
337}
338
339impl<T> core::cmp::PartialOrd<T> for Id<'_>
340    where
341        T: AsRef<str> {
342    #[inline]
343    fn partial_cmp(&self, other: &T) -> Option<Ordering> {
344        core::cmp::PartialOrd::partial_cmp(
345            self.as_str(),
346            other.as_ref(),
347        )
348    }
349}