Skip to main content

mlb_api/requests/schedule/
mod.rs

1//! Schedules, typically the main entrypoint to navigate through games.
2
3// #![allow(non_snake_case)]
4
5use crate::game::{DoubleHeaderKind, GameId};
6use crate::hydrations::Hydrations;
7use crate::league::LeagueId;
8use crate::meta::DayNight;
9use crate::meta::GameStatus;
10use crate::meta::GameType;
11use crate::request::RequestURL;
12use crate::season::SeasonId;
13use crate::sport::SportId;
14use crate::team::NamedTeam;
15use crate::team::TeamId;
16use crate::venue::{NamedVenue, VenueId};
17use crate::{Copyright, HomeAway, MLB_API_DATE_FORMAT, NaiveDateRange};
18use bon::Builder;
19use chrono::{DateTime, NaiveDate, Utc};
20use either::Either;
21use itertools::Itertools;
22use serde::Deserialize;
23use serde::de::DeserializeOwned;
24use serde_with::DefaultOnError;
25use serde_with::serde_as;
26use std::fmt::{Debug, Display, Formatter};
27use std::marker::PhantomData;
28use uuid::Uuid;
29
30pub mod postseason;
31pub mod tied;
32
33#[derive(Debug, Deserialize, PartialEq, Clone)]
34#[serde(rename_all = "camelCase", bound = "H: ScheduleHydrations")]
35pub struct ScheduleResponse<H: ScheduleHydrations> {
36	#[serde(default)] // Schedules in Team Hydrations don't have the copyright.
37	pub copyright: Copyright,
38	pub dates: Vec<ScheduleDate<H>>,
39}
40
41#[derive(Debug, Deserialize, PartialEq, Clone)]
42#[serde(rename_all = "camelCase", bound = "H: ScheduleHydrations")]
43pub struct ScheduleDate<H: ScheduleHydrations> {
44	pub date: NaiveDate,
45	pub games: Vec<ScheduleGame<H>>,
46}
47
48#[allow(clippy::struct_excessive_bools, reason = "false positive")]
49#[derive(Debug, Deserialize, PartialEq, Clone)]
50#[serde(from = "__ScheduleGameStruct<H>", bound = "H: ScheduleHydrations")]
51pub struct ScheduleGame<H: ScheduleHydrations> {
52	pub game_id: GameId,
53	pub game_guid: Uuid,
54	pub game_type: GameType,
55	pub season: SeasonId,
56	pub game_date: DateTime<Utc>,
57	/// Different from `game_date.date_naive()` in cases such as a rescheduled/postponed game (ex: Toronto @ Boston June 26, 2024)
58	pub official_date: NaiveDate,
59	pub status: GameStatus,
60	pub teams: HomeAway<TeamWithStandings<H>>,
61	pub venue: NamedVenue,
62	pub is_tie: bool,
63
64	/// Refers to the ordinal in the day? (maybe season?).
65	/// Starts at 1.
66	pub game_ordinal: u32,
67	pub is_public_facing: bool,
68	pub double_header: DoubleHeaderKind,
69	// #[serde(rename = "gamedayType")]
70	// pub gameday_game_type: GamedayGameType,
71	pub is_tiebreaker: bool,
72	// pub calender_event_id: CalenderEventId,
73	pub displayed_season: SeasonId,
74	pub day_night: DayNight,
75	pub description: Option<String>,
76	pub scheduled_innings: u32,
77	pub reverse_home_away_status: bool,
78	pub inning_break_length: uom::si::i32::Time,
79	/// [`None`] if the current game is not of a series-format (ex: [Spring Training](`GameType::SpringTraining`))
80	pub series_data: Option<SeriesData>,
81}
82
83#[serde_as]
84#[derive(Deserialize)]
85#[serde(rename_all = "camelCase", bound = "H: ScheduleHydrations")]
86struct __ScheduleGameStruct<H: ScheduleHydrations> {
87	#[serde(rename = "gamePk")]
88	game_id: GameId,
89	game_guid: Uuid,
90	game_type: GameType,
91	season: SeasonId,
92	#[serde(deserialize_with = "crate::deserialize_datetime")]
93	game_date: DateTime<Utc>,
94	official_date: NaiveDate,
95	status: GameStatus,
96	teams: HomeAway<TeamWithStandings<H>>,
97	#[serde_as(deserialize_as = "DefaultOnError")]
98	venue: Option<NamedVenue>,
99	is_tie: Option<bool>,
100	#[serde(rename = "gameNumber")]
101	game_ordinal: u32,
102	#[serde(rename = "publicFacing")]
103	is_public_facing: bool,
104	double_header: DoubleHeaderKind,
105	// #[serde(rename = "gamedayType")]
106	// gameday_game_type: GamedayGameType,
107	#[serde(rename = "tiebreaker", deserialize_with = "crate::from_yes_no")]
108	is_tiebreaker: bool,
109	// calender_event_id: CalenderEventId,
110	#[serde(rename = "seasonDisplay")]
111	displayed_season: SeasonId,
112	day_night: DayNight,
113	description: Option<String>,
114	scheduled_innings: u32,
115	reverse_home_away_status: bool,
116	inning_break_length: Option<u32>,
117	#[serde(flatten)]
118	series_data: Option<SeriesData>,
119}
120
121impl<H: ScheduleHydrations> From<__ScheduleGameStruct<H>> for ScheduleGame<H> {
122	#[allow(clippy::cast_possible_wrap, reason = "not gonna happen")]
123	fn from(
124		__ScheduleGameStruct {
125			game_id,
126			game_guid,
127			game_type,
128			season,
129			game_date,
130			official_date,
131			status,
132			teams,
133			venue,
134			is_tie,
135			game_ordinal,
136			is_public_facing,
137			double_header,
138			is_tiebreaker,
139			displayed_season,
140			day_night,
141			description,
142			scheduled_innings,
143			reverse_home_away_status,
144			inning_break_length,
145			series_data,
146		}: __ScheduleGameStruct<H>,
147	) -> Self {
148		Self {
149			game_id,
150			game_guid,
151			game_type,
152			season,
153			game_date,
154			official_date,
155			status,
156			teams,
157			venue: venue.unwrap_or_else(NamedVenue::unknown_venue),
158			is_tie: is_tie.unwrap_or(false),
159			game_ordinal,
160			is_public_facing,
161			double_header,
162			is_tiebreaker,
163			displayed_season,
164			day_night,
165			description,
166			scheduled_innings,
167			reverse_home_away_status,
168			inning_break_length: uom::si::i32::Time::new::<uom::si::time::second>(inning_break_length.unwrap_or(120) as i32),
169			series_data,
170		}
171	}
172}
173
174#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
175#[serde(rename_all = "camelCase")]
176pub struct SeriesData {
177	pub games_in_series: u32,
178	#[serde(rename = "seriesGameNumber")]
179	pub game_in_series_ordinal: u32,
180}
181
182#[derive(Debug, Deserialize, PartialEq, Clone)]
183#[serde(rename_all = "camelCase", bound = "H: ScheduleHydrations")]
184pub struct TeamWithStandings<H: ScheduleHydrations> {
185	pub team: H::Team,
186	#[serde(rename = "leagueRecord")]
187	pub standings: Standings,
188	#[serde(flatten)]
189	pub score: Option<TeamWithStandingsGameScore>,
190	#[serde(rename = "splitSquad")]
191	pub is_split_squad_game: bool,
192
193	/// Refers to the ordinal of series, not within the current series.
194	/// Starts at 1.
195	/// [`None`] if the current game is not of a series-format (ex: [Spring Training](`GameType::SpringTraining`))
196	#[serde(rename = "seriesNumber")]
197	pub series_ordinal: Option<u32>,
198}
199
200#[derive(Debug, Deserialize, PartialEq, Eq, Clone)]
201#[serde(rename_all = "camelCase")]
202pub struct TeamWithStandingsGameScore {
203	#[serde(rename = "score")]
204	pub runs_scored: u32,
205	pub is_winner: bool,
206}
207
208#[derive(Debug, Deserialize, PartialEq, Eq, Copy, Clone)]
209#[serde(rename_all = "camelCase")]
210pub struct Standings {
211	pub wins: u32,
212	pub losses: u32,
213}
214
215impl Standings {
216	#[must_use]
217	pub const fn games_played(self) -> u32 {
218		self.wins + self.losses
219	}
220
221	#[must_use]
222	pub fn pct(self) -> f64 {
223		f64::from(self.wins) / f64::from(self.games_played())
224	}
225}
226
227pub trait ScheduleHydrations: Hydrations<RequestData = ()> {
228	type Team: Debug + DeserializeOwned + Clone + PartialEq;
229
230	type Venue: Debug + DeserializeOwned + Clone + PartialEq;
231}
232
233impl ScheduleHydrations for () {
234	type Team = NamedTeam;
235
236	type Venue = NamedVenue;
237}
238
239/// Creates hydrations for a schedule
240///
241/// ## Schedule Hydrations
242/// | Name                                           | Type                 |
243/// |------------------------------------------------|----------------------|
244/// | `team`                                         | [`team_hydrations!`] |
245/// | `game`                                         |                      |
246/// | `linescore`                                    |                      |
247/// | `decisions`                                    |                      |
248/// | `scoring_plays`                                |                      |
249/// | `broadcasts`                                   |                      |
250/// | `radio_broadcasts`                             |                      |
251/// | `metadata`                                     |                      |
252/// | `series_status`                                |                      |
253/// | `venue`                                        | [`venue_hydrations!`]|
254/// | `weather`                                      |                      |
255/// | `game_info`                                    |                      |
256/// | `officials`                                    |                      |
257/// | `probable_officials`                           |                      |
258/// | `tracking_version`                             |                      |
259/// | `coaching_video`                               |                      |
260/// | `probable_pitcher`                             |                      |
261/// | `probable_pitcher(all)`                        |                      |
262/// | `probable_pitcher(note)`                       |                      |
263/// | `probable_pitcher(show_on_preview)`            |                      |
264/// | `review`                                       |                      |
265/// | `event(performers)`                            |                      |
266/// | `event(promotions)`                            |                      |
267/// | `event(timezone)`                              |                      |
268/// | `event(tickets)`                               |                      |
269/// | `event(venue)`                                 |                      |
270/// | `event(designations)`                          |                      |
271/// | `event(game)`                                  |                      |
272/// | `event(status)`                                |                      |
273/// | `event(sport)`                                 |                      |
274/// | `event(league)`                                |                      |
275/// | `event(division)`                              |                      |
276/// | `linescore(positions)`                         |                      |
277/// | `linescore(matchup)`                           |                      |
278/// | `linescore(runners)`                           |                      |
279/// | `lineups`                                      |                      |
280/// | `live_lookin`                                  |                      |
281/// | `flags`                                        |                      |
282/// | `alerts`                                       |                      |
283/// | `previous_play`                                |                      |
284/// | `home_runs`                                    |                      |
285/// | `xrefId`                                       |                      |
286/// | `person`                                       |                      |
287/// | `stats`                                        |                      |
288/// | `game_id`                                      |                      |
289/// | `story`                                        |                      |
290/// | `rule_settings`                                |                      |
291/// | `abs_challenge`                                |                      |
292/// | `acs_challenge`                                |                      |
293/// | `status_flags`                                 |                      |
294/// | `weather_forecast`                             |                      |
295///
296/// [`team_hydrations!`]: crate::team_hydrations
297/// [`venue_hydrations!`]: crate::venue_hydrations
298#[macro_export]
299macro_rules! schedule_hydrations {
300	(@ inline_structs [team: { $($inline_tt:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
301		$crate::macro_use::pastey::paste! {
302			$crate::team_hydrations! {
303				$vis struct [<$name InlineTeam>] {
304					$($inline_tt)*
305				}
306			}
307
308			$crate::schedule_hydrations! { @ inline_structs [$($($tt)*)?]
309				$vis struct $name {
310					$($field_tt)*
311					team: [<$name InlineTeam>],
312				}
313			}
314		}
315	};
316	(@ inline_structs [venue: { $($inline_tt:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
317		$crate::macro_use::pastey::paste! {
318			$crate::venue_hydrations! {
319				$vis struct [<$name InlineVenue>] {
320					$($inline_tt)*
321				}
322			}
323
324			$crate::schedule_hydrations! { @ inline_structs [$($($tt)*)?]
325				$vis struct $name {
326					$($field_tt)*
327					venue: [<$name InlineVenue>],
328				}
329			}
330		}
331	};
332	(@ inline_structs [$_01:ident : { $($_02:tt)* } $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
333		::core::compile_error!("Found unknown inline struct");
334	};
335	(@ inline_structs [$field:ident $(: $value:ty)? $(, $($tt:tt)*)?] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
336		$crate::schedule_hydrations! { @ inline_structs [$($($tt)*)?]
337			$vis struct $name {
338				$($field_tt)*
339				$field $(: $value)?,
340			}
341		}
342	};
343	(@ inline_structs [] $vis:vis struct $name:ident { $($field_tt:tt)* }) => {
344		$crate::schedule_hydrations! { @ actual
345			$vis struct $name {
346				$($field_tt)*
347			}
348		}
349	};
350
351    (@ team) => { $crate::team::NamedTeam };
352	(@ team $hydrations:ty) => { $crate::team::Team<$hydrations> };
353
354    (@ venue) => { $crate::venue::NamedVenue };
355	(@ venue $hydrations:ty) => { $crate::venue::Venue<$hydrations> };
356
357	(@ actual $vis:vis struct $name:ident {
358		$(team: $team:ty ,)?
359		$(venue: $venue:ty)?
360	}) => {
361		#[derive(::core::fmt::Debug, $crate::macro_use::serde::Deserialize, ::core::cmp::PartialEq, ::core::clone::Clone)]
362		#[serde(rename_all = "camelCase")]
363		$vis struct $name {
364
365		}
366
367		impl $crate::schedule::ScheduleHydrations for $name {
368			type Team = $crate::schedule_hydrations!(@ team $($team)?);
369
370			type Venue = $crate::schedule_hydrations!(@ venue $($venue)?);
371		}
372
373		impl $crate::hydrations::Hydrations for $name {
374			type RequestData = ();
375
376			fn hydration_text(&(): Self::RequestData) -> ::std::borrow::Cow<'static, str> {
377				let text = ::std::borrow::Cow::Borrowed(::core::concat!(
378
379				));
380
381				$(let text = ::std::borrow::Cow::Owned(::std::format!("{text}team({}),", <$team as $crate::hydrations::Hydrations>::hydration_text(&()))))?
382				$(let text = ::std::borrow::Cow::Owned(::std::format!("{text}venue({}),", <$venue as $crate::hydrations::Hydrations>::hydration_text(&()))))?
383
384				text
385			}
386		}
387	};
388}
389
390#[allow(dead_code, reason = "rust analyzer says that opponent_id and season are dead, while being used in Display")]
391#[derive(Builder)]
392#[builder(derive(Into))]
393pub struct ScheduleRequest<H: ScheduleHydrations> {
394	#[builder(into)]
395	#[builder(default)]
396	sport_id: SportId,
397	#[builder(setters(vis = "", name = game_ids_internal))]
398	game_ids: Option<Vec<GameId>>,
399	#[builder(into)]
400	team_id: Option<TeamId>,
401	#[builder(into)]
402	league_id: Option<LeagueId>,
403	#[builder(setters(vis = "", name = venue_ids_internal))]
404	venue_ids: Option<Vec<VenueId>>,
405	#[builder(default = Either::Left(Utc::now().date_naive()))]
406	#[builder(setters(vis = "", name = date_internal))]
407	date: Either<NaiveDate, NaiveDateRange>,
408	#[builder(into)]
409	opponent_id: Option<TeamId>,
410	#[builder(into)]
411	season: Option<SeasonId>,
412	game_type: Option<GameType>,
413	#[builder(skip)]
414	_marker: PhantomData<H>,
415}
416
417impl<H: ScheduleHydrations, S: schedule_request_builder::State + schedule_request_builder::IsComplete> crate::request::RequestURLBuilderExt for ScheduleRequestBuilder<H, S> {
418	type Built = ScheduleRequest<H>;
419}
420
421impl<H: ScheduleHydrations, S: schedule_request_builder::State> ScheduleRequestBuilder<H, S> {
422	pub fn game_ids(self, game_ids: Vec<impl Into<GameId>>) -> ScheduleRequestBuilder<H, schedule_request_builder::SetGameIds<S>>
423	where
424		S::GameIds: schedule_request_builder::IsUnset,
425	{
426		self.game_ids_internal(game_ids.into_iter().map(Into::into).collect())
427	}
428
429	pub fn venue_ids(self, venue_ids: Vec<impl Into<VenueId>>) -> ScheduleRequestBuilder<H, schedule_request_builder::SetVenueIds<S>>
430	where
431		S::VenueIds: schedule_request_builder::IsUnset,
432	{
433		self.venue_ids_internal(venue_ids.into_iter().map(Into::into).collect())
434	}
435
436	pub fn date(self, date: NaiveDate) -> ScheduleRequestBuilder<H, schedule_request_builder::SetDate<S>>
437	where
438		S::Date: schedule_request_builder::IsUnset,
439	{
440		self.date_internal(Either::Left(date))
441	}
442
443	pub fn date_range(self, range: NaiveDateRange) -> ScheduleRequestBuilder<H, schedule_request_builder::SetDate<S>>
444	where
445		S::Date: schedule_request_builder::IsUnset,
446	{
447		self.date_internal(Either::Right(range))
448	}
449}
450
451impl<H: ScheduleHydrations> Display for ScheduleRequest<H> {
452	fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
453		let hydrations = Some(H::hydration_text(&())).filter(|s| !s.is_empty());
454
455		write!(
456			f,
457			"http://statsapi.mlb.com/api/v1/schedule{params}",
458			params = gen_params! {
459				"hydrate"?: hydrations,
460				"sportId": self.sport_id,
461				"gamePks"?: self.game_ids.as_ref().map(|ids| ids.iter().map(ToString::to_string).join(",")),
462				"teamId"?: self.team_id,
463				"leagueId"?: self.league_id,
464				"date"?: self.date.as_ref().left().map(|x| x.format(MLB_API_DATE_FORMAT)),
465				"startDate"?: self.date.as_ref().right().map(|range| range.start().format(MLB_API_DATE_FORMAT)),
466				"endDate"?: self.date.as_ref().right().map(|range| range.end().format(MLB_API_DATE_FORMAT)),
467				"opponentId"?; self.opponent_id,
468				"season"?: self.season,
469				"venueIds"?: self.venue_ids.as_ref().map(|ids| ids.iter().map(ToString::to_string).join(",")),
470				"gameType"?: self.game_type,
471			}
472		)
473	}
474}
475
476impl<H: ScheduleHydrations> RequestURL for ScheduleRequest<H> {
477	type Response = ScheduleResponse<H>;
478}
479
480#[cfg(test)]
481mod tests {
482	use crate::TEST_YEAR;
483	use crate::request::RequestURLBuilderExt;
484	use crate::schedule::ScheduleRequest;
485	use chrono::NaiveDate;
486
487	#[tokio::test]
488	async fn test_one_date() {
489		let date = NaiveDate::from_ymd_opt(2020, 8, 2).expect("Valid date");
490		let _ = ScheduleRequest::<()>::builder().date(date).build_and_get().await.unwrap();
491	}
492
493	#[tokio::test]
494	async fn test_all_dates_current_year() {
495		let _ = ScheduleRequest::<()>::builder()
496			.date_range(NaiveDate::from_ymd_opt(TEST_YEAR.try_into().unwrap(), 1, 1).expect("Valid date")..=NaiveDate::from_ymd_opt(TEST_YEAR.try_into().unwrap(), 12, 31).expect("Valid date"))
497			.build_and_get()
498			.await
499			.unwrap();
500	}
501
502	#[tokio::test]
503	#[cfg_attr(not(feature = "_heavy_tests"), ignore)]
504	async fn test_all_dates_all_years() {
505		for year in 1876..=TEST_YEAR {
506			let _ = ScheduleRequest::<()>::builder()
507				.date_range(NaiveDate::from_ymd_opt(year.try_into().unwrap(), 1, 1).unwrap()..=NaiveDate::from_ymd_opt(year.try_into().unwrap(), 12, 31).unwrap())
508				.build_and_get()
509				.await
510				.unwrap();
511		}
512	}
513}