1use std::collections::HashMap;
2
3use chrono::{DateTime, Utc};
4use serde::Deserialize;
5use serde_aux::prelude::*;
6
7use crate::{dash, util};
8
9#[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 let idx_ipr = html.find(IPR_STR)? + IPR_STR.len();
171
172 let idx_start = html[idx_ipr..].find("{")? + idx_ipr;
174 let idx_end = html[idx_start..].find("};")? + idx_start + 1;
175
176 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 let ipr_str = get_ipr_str(html).ok_or(PlayerResponseError::NoInitialPlayerResponse)?;
188
189 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}