yta_rs/
player_response.rs

1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4use serde::Deserialize;
5use serde_aux::prelude::*;
6
7use crate::{dash, util};
8
9// Generated with https://transform.tools/json-to-rust-serde
10
11#[derive(Debug, Clone, PartialEq, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct InitialPlayerResponse {
14    pub response_context: ResponseContext,
15    pub playability_status: PlayabilityStatus,
16    pub streaming_data: Option<StreamingData>,
17    pub video_details: Option<VideoDetails>,
18    pub microformat: Option<Microformat>,
19}
20
21#[derive(Debug, Clone, PartialEq, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct ResponseContext {
24    pub main_app_web_response_context: MainAppWebResponseContext,
25}
26
27#[derive(Debug, Clone, PartialEq, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct MainAppWebResponseContext {
30    pub logged_out: bool,
31}
32
33#[derive(Debug, Clone, PartialEq, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct PlayabilityStatus {
36    pub status: Status,
37    pub reason: Option<String>,
38    pub live_streamability: Option<LiveStreamability>,
39}
40
41#[derive(Debug, Clone, PartialEq, Deserialize)]
42#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
43pub enum Status {
44    Ok,
45    LiveStreamOffline,
46    Unplayable,
47    LoginRequired,
48    Error,
49}
50
51#[derive(Debug, Clone, PartialEq, Deserialize)]
52#[serde(rename_all = "camelCase")]
53pub struct LiveStreamability {
54    pub live_streamability_renderer: LiveStreamabilityRenderer,
55}
56
57#[derive(Debug, Clone, PartialEq, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct LiveStreamabilityRenderer {
60    pub video_id: String,
61    #[serde(deserialize_with = "deserialize_number_from_string")]
62    pub poll_delay_ms: i64,
63    pub offline_slate: Option<OfflineSlate>,
64}
65
66#[derive(Debug, Clone, PartialEq, Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct OfflineSlate {
69    pub live_stream_offline_slate_renderer: LiveStreamOfflineSlateRenderer,
70}
71
72#[derive(Debug, Clone, PartialEq, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct LiveStreamOfflineSlateRenderer {
75    #[serde(deserialize_with = "deserialize_datetime_utc_from_seconds")]
76    pub scheduled_start_time: DateTime<Utc>,
77}
78
79#[derive(Debug, Clone, PartialEq, Deserialize)]
80#[serde(rename_all = "camelCase")]
81pub struct StreamingData {
82    #[serde(deserialize_with = "deserialize_number_from_string")]
83    pub expires_in_seconds: i64,
84    pub adaptive_formats: Vec<AdaptiveFormat>,
85    pub dash_manifest_url: Option<String>,
86}
87
88#[derive(Debug, Clone, PartialEq, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct AdaptiveFormat {
91    pub itag: i64,
92    pub url: Option<String>,
93    pub mime_type: String,
94    pub bitrate: i64,
95    pub target_duration_sec: Option<f64>,
96}
97
98#[derive(Debug, Clone, PartialEq, Deserialize)]
99#[serde(rename_all = "camelCase")]
100pub struct VideoDetails {
101    pub video_id: String,
102    pub title: String,
103    #[serde(deserialize_with = "deserialize_number_from_string")]
104    pub length_seconds: i64,
105    #[serde(default)]
106    pub is_live: bool,
107    pub channel_id: String,
108    pub is_owner_viewing: bool,
109    pub short_description: String,
110    pub allow_ratings: bool,
111    #[serde(deserialize_with = "deserialize_number_from_string")]
112    pub view_count: i64,
113    pub author: String,
114    pub is_live_content: bool,
115}
116
117#[derive(Debug, Clone, PartialEq, Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct Microformat {
120    pub player_microformat_renderer: PlayerMicroformatRenderer,
121}
122
123#[derive(Debug, Clone, PartialEq, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct PlayerMicroformatRenderer {
126    pub thumbnail: Thumbnail,
127    pub owner_profile_url: String,
128    pub publish_date: String,
129    pub live_broadcast_details: Option<LiveBroadcastDetails>,
130    pub upload_date: String,
131}
132
133#[derive(Debug, Clone, PartialEq, Deserialize)]
134#[serde(rename_all = "camelCase")]
135pub struct Thumbnail {
136    pub thumbnails: Vec<ThumbnailURL>,
137}
138
139#[derive(Debug, Clone, PartialEq, Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct ThumbnailURL {
142    pub url: String,
143}
144
145#[derive(Debug, Clone, PartialEq, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct LiveBroadcastDetails {
148    pub is_live_now: bool,
149    pub start_timestamp: String,
150}
151
152#[derive(thiserror::Error, Debug)]
153pub enum PlayerResponseError {
154    #[error("Could not find initial player response")]
155    NoInitialPlayerResponse,
156    #[error("Could not parse initial player response")]
157    ParseInitialPlayerResponse(#[from] serde_json::Error),
158    #[error("No DASH manifest URL found")]
159    NoDashManifestURL,
160    #[error("Could not download DASH manifest")]
161    DownloadDashManifestError(#[from] util::DownloadError),
162    #[error("Could not parse DASH manifest")]
163    ParseDashManifestError(#[from] quick_xml::Error),
164}
165
166const IPR_STR: &str = "var ytInitialPlayerResponse =";
167
168fn get_ipr_str(html: &str) -> Option<&str> {
169    // Find the start of the initial player response
170    let idx_ipr = html.find(IPR_STR)? + IPR_STR.len();
171
172    // Find the start and end of the JSON object
173    let idx_start = html[idx_ipr..].find("{")? + idx_ipr;
174    let idx_end = html[idx_start..].find("};")? + idx_start + 1;
175
176    // Bounds check
177    if idx_start >= idx_end || idx_start >= html.len() || idx_end >= html.len() {
178        return None;
179    }
180
181    Some(&html[idx_start..idx_end])
182}
183
184impl InitialPlayerResponse {
185    pub fn from_html(html: &str) -> Result<Self, PlayerResponseError> {
186        // Find the initial player response
187        let ipr_str = get_ipr_str(html).ok_or(PlayerResponseError::NoInitialPlayerResponse)?;
188
189        // Parse the JSON
190        serde_json::from_str(ipr_str).map_err(PlayerResponseError::ParseInitialPlayerResponse)
191    }
192
193    pub fn is_usable(&self) -> bool {
194        self.video_details
195            .as_ref()
196            .map(|v| v.video_id.clone())
197            .unwrap_or("".into())
198            != ""
199            && self
200                .playability_status
201                .live_streamability
202                .as_ref()
203                .map(|ls| ls.live_streamability_renderer.video_id != "")
204                .unwrap_or(false)
205            && self.playability_status.status == Status::Ok
206            && self
207                .microformat
208                .as_ref()
209                .and_then(|mf| {
210                    mf.player_microformat_renderer
211                        .live_broadcast_details
212                        .clone()
213                })
214                .as_ref()
215                .map(|lbd| lbd.is_live_now)
216                .unwrap_or(false)
217    }
218
219    pub fn target_duration(&self) -> Option<f64> {
220        self.streaming_data
221            .as_ref()?
222            .adaptive_formats
223            .first()?
224            .target_duration_sec
225    }
226
227    pub fn get_adaptive_formats(&self) -> Option<HashMap<i64, String>> {
228        Some(
229            self.streaming_data
230                .as_ref()?
231                .adaptive_formats
232                .iter()
233                .filter_map(|af| match af.url {
234                    Some(ref url) => Some((af.itag, url.clone())),
235                    None => None,
236                })
237                .collect(),
238        )
239    }
240
241    pub async fn get_dash_representations(
242        &self,
243        client: &util::HttpClient,
244    ) -> Result<dash::Manifest, PlayerResponseError> {
245        let dash_url = self
246            .streaming_data
247            .as_ref()
248            .and_then(|sd| sd.dash_manifest_url.as_ref())
249            .ok_or(PlayerResponseError::NoDashManifestURL)?;
250
251        client
252            .fetch_text(dash_url)
253            .await
254            .map_err(PlayerResponseError::DownloadDashManifestError)
255            .and_then(|manifest| {
256                dash::parse_manifest(&manifest).map_err(PlayerResponseError::ParseDashManifestError)
257            })
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use std::str::FromStr;
264
265    use super::*;
266
267    #[test]
268    fn ipr_str() {
269        let test_str = r#"<script>var ytInitialPlayerResponse = {"response": "test"};</script>"#;
270        let result = get_ipr_str(test_str).expect("Could not find IPR");
271        assert_eq!(result, r#"{"response": "test"}"#);
272
273        let test_str = r#"<script>var ytInitialPlayerResponse = {"#;
274        assert!(get_ipr_str(test_str).is_none());
275
276        let test_str = r#"<script>var ytInitialPlayerResponse = "#;
277        assert!(get_ipr_str(test_str).is_none());
278
279        let test_str = r#"<script>var ytInitialPlayerResponse ="#;
280        assert!(get_ipr_str(test_str).is_none());
281    }
282
283    fn get_test_html(fname: &str) -> String {
284        let mut d = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
285        d.push("resources/test/");
286        d.push(fname);
287        std::fs::read_to_string(d).expect(format!("Could not read {}", fname).as_str())
288    }
289
290    #[test]
291    fn ipr_live() {
292        let html = get_test_html("watchpage_live.html");
293        let ipr = InitialPlayerResponse::from_html(&html).expect("Could not parse IPR");
294        let details = ipr.video_details.unwrap();
295
296        assert_eq!(details.is_live, true, "Video is not live");
297        assert_eq!(details.length_seconds, 0, "Video length is not 0");
298        assert_eq!(details.view_count, 210_943_922, "View count is not correct");
299        assert!(
300            ipr.playability_status
301                .live_streamability
302                .expect("No live streamability")
303                .live_streamability_renderer
304                .offline_slate
305                .is_none(),
306            "Video is not livestreamable"
307        );
308    }
309
310    #[test]
311    fn ipr_scheduled() {
312        let html = get_test_html("watchpage_scheduled.html");
313        let ipr = InitialPlayerResponse::from_html(&html).expect("Could not parse IPR");
314        let details = ipr.video_details.unwrap();
315
316        assert_eq!(details.is_live, false, "Video is live");
317        assert_eq!(
318            ipr.playability_status.status,
319            Status::LiveStreamOffline,
320            "Playability status is not LiveStreamOffline"
321        );
322        assert_eq!(details.length_seconds, 0, "Video length is not 0");
323        assert_eq!(details.view_count, 0, "View count is not correct");
324        assert_eq!(
325            ipr.playability_status
326                .live_streamability
327                .expect("No live streamability")
328                .live_streamability_renderer
329                .offline_slate
330                .expect("Video should be offline")
331                .live_stream_offline_slate_renderer
332                .scheduled_start_time,
333            DateTime::<Utc>::from_str("2024-02-15T08:15:00Z").unwrap(),
334            "Video schedule does not match"
335        );
336    }
337}