Skip to main content

rsynko_reqwest/
x.rs

1use crate::RuntimeEnvironment;
2use rsynko_media::{FORMAT_EXTENSION, FORMAT_HAS_AUDIO, FORMAT_HAS_VIDEO, FORMAT_SOURCE, FormatAlg, MetadataAlg};
3use rsynko_memory::{Extraction, Format, InfoValue, Media, MediaSyntax};
4use rsynko_x::*;
5use serde_json::Value;
6
7/// Names the observation stating which kind of file one format was taken from.
8pub const X_KIND: &str = "x_kind";
9
10/// Names what the answer calls the pieces it lists.
11const MEDIA_DETAILS: &str = "mediaDetails";
12
13/// Denotes failure to read what one tweet carries.
14#[derive(Debug, thiserror::Error)]
15pub enum RuntimeXError {
16    /// Denotes an address naming no tweet.
17    #[error("{0} names no tweet")]
18    Unaddressed(String),
19    /// Denotes a tweet that could not be asked about.
20    #[error("the tweet could not be asked about: {0}")]
21    Unreachable(#[from] reqwest::Error),
22    /// Denotes an answer that could not be read.
23    #[error("what the tweet answered could not be read: {0}")]
24    Unreadable(#[from] serde_json::Error),
25    /// Denotes a tweet carrying nothing anybody can take.
26    ///
27    /// The address answers with nothing at all for a tweet that was removed, or one whose author
28    /// is protected, so this is what being refused looks like as well as what an empty post is.
29    #[error("the tweet carries no media, or is not public")]
30    Empty,
31}
32
33impl XSorts for RuntimeEnvironment {
34    type Request = String;
35    type Attachment = Format;
36}
37
38impl XRequestAlg for RuntimeEnvironment {
39    fn tweet_request(&self, address: impl Into<String>) -> String {
40        address.into()
41    }
42}
43
44impl XRequestViewAlg for RuntimeEnvironment {
45    fn request_address<'a>(&self, request: &'a String) -> &'a str {
46        request
47    }
48}
49
50impl RuntimeEnvironment {
51    /// Reads what one public tweet carries, as the formats a reader chooses between.
52    ///
53    /// # Errors
54    ///
55    /// Returns the failure to name, ask about, or read the tweet.
56    pub(crate) fn extract_x(&self, url: &str) -> Result<Extraction, RuntimeXError> {
57        let id = status_id(url).ok_or_else(|| RuntimeXError::Unaddressed(url.to_owned()))?;
58        let answered = self.fetch_bytes(&self.status_request(&id))?;
59        let answer: Value = serde_json::from_slice(&answered)?;
60        let carried = answer.get(MEDIA_DETAILS).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
61        let formats =
62            carried.iter().enumerate().flat_map(|(place, piece)| read_piece(place, piece)).collect::<Vec<_>>();
63        if formats.is_empty() {
64            return Err(RuntimeXError::Empty);
65        }
66        let author = answer.pointer("/user/screen_name").and_then(Value::as_str).unwrap_or("tweet");
67        Ok(Extraction::Media(Media::new(
68            id.clone(),
69            MediaSyntax.metadata([("title".to_owned(), MediaSyntax.string_metadata(format!("{author}-{id}")))]),
70            formats,
71        )))
72    }
73}
74
75/// States every format one piece of a tweet offers.
76fn read_piece(place: usize, piece: &Value) -> Vec<Format> {
77    let Some(kind) = piece.get("type").and_then(Value::as_str).and_then(attachment_kind::from) else {
78        return Vec::new();
79    };
80    let place = place + 1;
81    match kind {
82        // A video is offered at several sizes, and each is a whole file rather than a piece of
83        // one, so every one of them is a format a reader may choose.
84        AttachmentKind::Video | AttachmentKind::Animation => piece
85            .pointer("/video_info/variants")
86            .and_then(Value::as_array)
87            .map(Vec::as_slice)
88            .unwrap_or_default()
89            .iter()
90            .filter(|variant| variant.get("content_type").and_then(Value::as_str) == Some("video/mp4"))
91            .filter_map(|variant| read_variant(place, kind, variant))
92            .collect(),
93        AttachmentKind::Photo => piece
94            .get("media_url_https")
95            .and_then(Value::as_str)
96            .map(|address| read_photo(place, address))
97            .into_iter()
98            .collect(),
99    }
100}
101
102/// States one size a tweet offers a video at.
103fn read_variant(place: usize, kind: AttachmentKind, variant: &Value) -> Option<Format> {
104    let address = variant.get("url").and_then(Value::as_str)?;
105    let bitrate = variant.get("bitrate").and_then(Value::as_i64).unwrap_or(0);
106    let measured = measurement(address);
107    let identity = measured.as_ref().map_or_else(
108        || format!("{}-{place}", attachment_kind::to(kind)),
109        |size| format!("{}-{place}-{size}", attachment_kind::to(kind)),
110    );
111    Some(MediaSyntax.format(
112        identity,
113        MediaSyntax.metadata([
114            (FORMAT_SOURCE.to_owned(), MediaSyntax.string_metadata(address)),
115            (FORMAT_EXTENSION.to_owned(), MediaSyntax.string_metadata("mp4")),
116            (FORMAT_HAS_VIDEO.to_owned(), MediaSyntax.boolean_metadata(true)),
117            // An animation carries no sound, and is a video in every other way.
118            (FORMAT_HAS_AUDIO.to_owned(), MediaSyntax.boolean_metadata(kind == AttachmentKind::Video)),
119            (X_KIND.to_owned(), MediaSyntax.string_metadata(attachment_kind::to(kind))),
120            ("bitrate".to_owned(), MediaSyntax.integer_metadata(bitrate)),
121            ("quality".to_owned(), MediaSyntax.string_metadata(measured.unwrap_or_default())),
122        ]),
123    ))
124}
125
126/// States the one file a picture is, at the size it was posted at.
127fn read_photo(place: usize, address: &str) -> Format {
128    let extension = address.rsplit('.').next().unwrap_or("jpg").to_owned();
129    Format::new(
130        format!("photo-{place}"),
131        MediaSyntax.metadata([
132            (
133                FORMAT_SOURCE.to_owned(),
134                // A picture is served at the size it is asked for, and the original is the one
135                // that was posted rather than one the service made to show it with.
136                MediaSyntax.string_metadata(format!("{address}?format={extension}&name=orig")),
137            ),
138            (FORMAT_EXTENSION.to_owned(), MediaSyntax.string_metadata(extension)),
139            (FORMAT_HAS_VIDEO.to_owned(), MediaSyntax.boolean_metadata(false)),
140            (FORMAT_HAS_AUDIO.to_owned(), MediaSyntax.boolean_metadata(false)),
141            (X_KIND.to_owned(), MediaSyntax.string_metadata(attachment_kind::to(AttachmentKind::Photo))),
142        ]),
143    )
144}
145
146/// Observes the size a video address states it was encoded at, when it states one.
147fn measurement(address: &str) -> Option<String> {
148    address
149        .split('/')
150        .find(|segment| {
151            let mut sides = segment.split('x');
152            let (across, down) = (sides.next(), sides.next());
153            sides.next().is_none()
154                && across.is_some_and(|side| !side.is_empty() && side.bytes().all(|b| b.is_ascii_digit()))
155                && down.is_some_and(|side| !side.is_empty() && side.bytes().all(|b| b.is_ascii_digit()))
156        })
157        .map(str::to_owned)
158}
159
160/// Names the value one observation states, when it states text.
161#[must_use]
162pub fn kind_of(format: &Format) -> Option<AttachmentKind> {
163    match format.metadata.get(X_KIND) {
164        Some(InfoValue::String(stated)) => attachment_kind::from(stated),
165        _ => None,
166    }
167}