Skip to main content

flix_db/entity/
tmdb.rs

1//! This module contains entities for storing dynamic data from TMDB.
2
3/// Collection entity.
4pub mod collections {
5	use flix_model::id::CollectionId as FlixId;
6	use flix_tmdb::model::id::CollectionId;
7
8	use chrono::{DateTime, Utc};
9	use sea_orm::entity::prelude::*;
10
11	use crate::entity;
12
13	/// The database representation of a tmdb collection.
14	#[sea_orm::model]
15	#[derive(Debug, Clone, DeriveEntityModel)]
16	#[sea_orm(table_name = "flix_tmdb_collections")]
17	pub struct Model {
18		/// The collection's TMDB ID.
19		#[sea_orm(primary_key, auto_increment = false)]
20		pub tmdb_id: CollectionId,
21		/// The collection's ID.
22		#[sea_orm(unique)]
23		pub flix_id: FlixId,
24		/// The date of the last update.
25		pub last_update: DateTime<Utc>,
26		/// The number of movies in the collection.
27		pub movie_count: u16,
28
29		/// The info for this collection.
30		#[sea_orm(
31			belongs_to,
32			from = "flix_id",
33			to = "id",
34			on_update = "Cascade",
35			on_delete = "Cascade"
36		)]
37		pub info: HasOne<entity::info::collections::Entity>,
38
39		/// Movies that are in this collection.
40		#[sea_orm(has_many)]
41		pub movies: HasMany<super::movies::Entity>,
42	}
43
44	#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
45	impl ActiveModelBehavior for ActiveModel {}
46}
47
48/// Movie entity.
49pub mod movies {
50	use flix_model::id::MovieId as FlixId;
51	use flix_tmdb::model::id::{CollectionId, MovieId};
52
53	use seamantic::model::duration::Seconds;
54
55	use chrono::{DateTime, Utc};
56	use sea_orm::entity::prelude::*;
57
58	use crate::entity;
59
60	/// The database representation of a tmdb movie.
61	#[sea_orm::model]
62	#[derive(Debug, Clone, DeriveEntityModel)]
63	#[sea_orm(table_name = "flix_tmdb_movies")]
64	pub struct Model {
65		/// The movie's TMDB ID.
66		#[sea_orm(primary_key, auto_increment = false)]
67		pub tmdb_id: MovieId,
68		/// The movie's ID.
69		#[sea_orm(unique)]
70		pub flix_id: FlixId,
71		/// The date of the last update.
72		pub last_update: DateTime<Utc>,
73		/// The movie's runtime in seconds.
74		pub runtime: Seconds,
75		/// The TMDB ID of the collection this movie belongs to.
76		#[sea_orm(indexed)]
77		pub collection_id: Option<CollectionId>,
78
79		/// The collection this movie belongs to.
80		#[sea_orm(
81			belongs_to,
82			from = "collection_id",
83			to = "tmdb_id",
84			on_update = "Cascade",
85			on_delete = "Cascade"
86		)]
87		pub collection: HasOne<super::collections::Entity>,
88		/// The info for this movie.
89		#[sea_orm(
90			belongs_to,
91			from = "flix_id",
92			to = "id",
93			on_update = "Cascade",
94			on_delete = "Cascade"
95		)]
96		pub info: HasOne<entity::info::movies::Entity>,
97	}
98
99	#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
100	impl ActiveModelBehavior for ActiveModel {}
101}
102
103/// Show entity.
104pub mod shows {
105	use flix_model::id::ShowId as FlixId;
106	use flix_tmdb::model::id::ShowId;
107
108	use chrono::{DateTime, Utc};
109	use sea_orm::entity::prelude::*;
110
111	use crate::entity;
112
113	/// The database representation of a tmdb show.
114	#[sea_orm::model]
115	#[derive(Debug, Clone, DeriveEntityModel)]
116	#[sea_orm(table_name = "flix_tmdb_shows")]
117	pub struct Model {
118		/// The show's TMDB ID.
119		#[sea_orm(primary_key, auto_increment = false)]
120		pub tmdb_id: ShowId,
121		/// The show's ID.
122		#[sea_orm(unique)]
123		pub flix_id: FlixId,
124		/// The movie's runtime in seconds.
125		pub last_update: DateTime<Utc>,
126		/// The number of seasons the show has.
127		pub number_of_seasons: u32,
128
129		/// The info for this show.
130		#[sea_orm(
131			belongs_to,
132			from = "flix_id",
133			to = "id",
134			on_update = "Cascade",
135			on_delete = "Cascade"
136		)]
137		pub info: HasOne<entity::info::shows::Entity>,
138
139		/// Seasons that are part of this show.
140		#[sea_orm(has_many)]
141		pub seasons: HasMany<super::seasons::Entity>,
142		/// Episodes that are part of this show.
143		#[sea_orm(has_many)]
144		pub episodes: HasMany<super::episodes::Entity>,
145	}
146
147	#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
148	impl ActiveModelBehavior for ActiveModel {}
149}
150
151/// Season entity.
152pub mod seasons {
153	use flix_model::id::ShowId as FlixId;
154	use flix_model::numbers::SeasonNumber;
155	use flix_tmdb::model::id::ShowId;
156
157	use chrono::{DateTime, Utc};
158	use sea_orm::entity::prelude::*;
159
160	use crate::entity;
161
162	/// The database representation of a tmdb season.
163	#[sea_orm::model]
164	#[derive(Debug, Clone, DeriveEntityModel)]
165	#[sea_orm(table_name = "flix_tmdb_seasons")]
166	pub struct Model {
167		/// The season's show's TMDB ID.
168		#[sea_orm(primary_key, auto_increment = false)]
169		pub tmdb_show: ShowId,
170		/// The season's TMDB season number.
171		#[sea_orm(primary_key, auto_increment = false)]
172		pub tmdb_season: SeasonNumber,
173		/// The season's show's ID.
174		#[sea_orm(unique_key = "flix")]
175		pub flix_show: FlixId,
176		/// The season's number.
177		#[sea_orm(unique_key = "flix")]
178		pub flix_season: SeasonNumber,
179		/// The date of the last update.
180		pub last_update: DateTime<Utc>,
181
182		/// The show this season belongs to.
183		#[sea_orm(
184			belongs_to,
185			from = "tmdb_show",
186			to = "tmdb_id",
187			on_update = "Cascade",
188			on_delete = "Cascade"
189		)]
190		pub show: HasOne<super::shows::Entity>,
191		/// The info for this season.
192		#[sea_orm(
193			belongs_to,
194			from = "(flix_show, flix_season)",
195			to = "(show_id, season_number)",
196			on_update = "Cascade",
197			on_delete = "Cascade"
198		)]
199		pub info: HasOne<entity::info::seasons::Entity>,
200
201		/// Episodes that are part of this season.
202		#[sea_orm(has_many)]
203		pub episodes: HasMany<super::episodes::Entity>,
204	}
205
206	#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
207	impl ActiveModelBehavior for ActiveModel {}
208}
209
210/// Season entity.
211pub mod episodes {
212	use flix_model::id::ShowId as FlixId;
213	use flix_model::numbers::{EpisodeNumber, SeasonNumber};
214	use flix_tmdb::model::id::ShowId;
215	use seamantic::model::duration::Seconds;
216
217	use chrono::{DateTime, Utc};
218	use sea_orm::entity::prelude::*;
219
220	use crate::entity;
221
222	/// The database representation of a tmdb episode.
223	#[sea_orm::model]
224	#[derive(Debug, Clone, DeriveEntityModel)]
225	#[sea_orm(table_name = "flix_tmdb_episodes")]
226	pub struct Model {
227		/// The episode's show's TMDB ID.
228		#[sea_orm(primary_key, auto_increment = false)]
229		pub tmdb_show: ShowId,
230		/// The episode's season's TMDB season number.
231		#[sea_orm(primary_key, auto_increment = false)]
232		pub tmdb_season: SeasonNumber,
233		/// The episode's TMDB episode number.
234		#[sea_orm(primary_key, auto_increment = false)]
235		pub tmdb_episode: EpisodeNumber,
236		/// The episode's show's ID.
237		#[sea_orm(unique_key = "flix")]
238		pub flix_show: FlixId,
239		/// The episode's season's number.
240		#[sea_orm(unique_key = "flix")]
241		pub flix_season: SeasonNumber,
242		/// The episode's number.
243		#[sea_orm(unique_key = "flix")]
244		pub flix_episode: EpisodeNumber,
245		/// The date of the last update.
246		pub last_update: DateTime<Utc>,
247		/// The episode's runtime in seconds.
248		pub runtime: Seconds,
249
250		/// The show this episode belongs to.
251		#[sea_orm(
252			belongs_to,
253			from = "tmdb_show",
254			to = "tmdb_id",
255			on_update = "Cascade",
256			on_delete = "Cascade"
257		)]
258		pub show: HasOne<super::shows::Entity>,
259		/// The season this episode belongs to.
260		#[sea_orm(
261			belongs_to,
262			from = "(tmdb_show, tmdb_season)",
263			to = "(tmdb_show, tmdb_season)",
264			on_update = "Cascade",
265			on_delete = "Cascade"
266		)]
267		pub season: HasOne<super::seasons::Entity>,
268		/// The info for this episode.
269		#[sea_orm(
270			belongs_to,
271			from = "(flix_show, flix_season, flix_episode)",
272			to = "(show_id, season_number, episode_number)",
273			on_update = "Cascade",
274			on_delete = "Cascade"
275		)]
276		pub info: HasOne<entity::info::episodes::Entity>,
277	}
278
279	#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
280	impl ActiveModelBehavior for ActiveModel {}
281}
282
283/// Macros for creating tmdb entities.
284#[cfg(test)]
285pub mod test {
286	macro_rules! make_tmdb_collection {
287		($db:expr, $id:expr, $flix_id:expr) => {
288			_ = $crate::entity::tmdb::collections::ActiveModel {
289				tmdb_id: Set(::flix_tmdb::model::id::CollectionId::from_raw($id)),
290				flix_id: Set(::flix_model::id::CollectionId::from_raw($flix_id)),
291				last_update: Set(::chrono::Utc::now()),
292				movie_count: Set(::core::default::Default::default()),
293			}
294			.insert($db)
295			.await
296			.expect("insert");
297		};
298	}
299	pub(crate) use make_tmdb_collection;
300
301	macro_rules! make_tmdb_movie {
302		($db:expr, $id:expr, $flix_id:expr) => {
303			_ = $crate::entity::tmdb::movies::ActiveModel {
304				tmdb_id: Set(::flix_tmdb::model::id::MovieId::from_raw($id)),
305				flix_id: Set(::flix_model::id::MovieId::from_raw($flix_id)),
306				last_update: Set(::chrono::Utc::now()),
307				runtime: Set(::core::default::Default::default()),
308				collection_id: Set(None),
309			}
310			.insert($db)
311			.await
312			.expect("insert");
313		};
314	}
315	pub(crate) use make_tmdb_movie;
316
317	macro_rules! make_tmdb_show {
318		($db:expr, $id:expr, $flix_id:expr) => {
319			_ = $crate::entity::tmdb::shows::ActiveModel {
320				tmdb_id: Set(::flix_tmdb::model::id::ShowId::from_raw($id)),
321				flix_id: Set(::flix_model::id::ShowId::from_raw($flix_id)),
322				last_update: Set(::chrono::Utc::now()),
323				number_of_seasons: Set(::core::default::Default::default()),
324			}
325			.insert($db)
326			.await
327			.expect("insert");
328		};
329	}
330	pub(crate) use make_tmdb_show;
331
332	macro_rules! make_tmdb_season {
333		($db:expr, $show:expr, $season:expr, $flix_show:expr, $flix_season:expr) => {
334			_ = $crate::entity::tmdb::seasons::ActiveModel {
335				tmdb_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)),
336				tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)),
337				flix_show: Set(::flix_model::id::ShowId::from_raw($flix_show)),
338				flix_season: Set(::flix_model::numbers::SeasonNumber::new($flix_season)),
339				last_update: Set(::chrono::Utc::now()),
340			}
341			.insert($db)
342			.await
343			.expect("insert");
344		};
345	}
346	pub(crate) use make_tmdb_season;
347
348	macro_rules! make_tmdb_episode {
349		($db:expr, $show:expr, $season:expr, $episode:expr, $flix_show:expr, $flix_season:expr, $flix_episode:expr) => {
350			_ = $crate::entity::tmdb::episodes::ActiveModel {
351				tmdb_show: Set(::flix_tmdb::model::id::ShowId::from_raw($show)),
352				tmdb_season: Set(::flix_model::numbers::SeasonNumber::new($season)),
353				tmdb_episode: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
354				flix_show: Set(::flix_model::id::ShowId::from_raw($flix_show)),
355				flix_season: Set(::flix_model::numbers::SeasonNumber::new($flix_season)),
356				flix_episode: Set(::flix_model::numbers::EpisodeNumber::new($flix_episode)),
357				last_update: Set(::chrono::Utc::now()),
358				runtime: Set(::core::default::Default::default()),
359			}
360			.insert($db)
361			.await
362			.expect("insert");
363		};
364	}
365	pub(crate) use make_tmdb_episode;
366}
367
368#[cfg(test)]
369mod tests {
370	use core::time::Duration;
371
372	use flix_model::id::{CollectionId, MovieId, ShowId};
373	use flix_tmdb::model::id::{
374		CollectionId as TmdbCollectionId, MovieId as TmdbMovieId, ShowId as TmdbShowId,
375	};
376
377	use chrono::NaiveDate;
378	use sea_orm::ActiveValue::{NotSet, Set};
379	use sea_orm::entity::prelude::*;
380	use sea_orm::sqlx::error::ErrorKind;
381
382	use crate::entity::info::test::{
383		make_info_collection, make_info_episode, make_info_movie, make_info_season, make_info_show,
384	};
385	use crate::tests::new_initialized_memory_db;
386
387	use super::super::tests::get_error_kind;
388	use super::super::tests::notsettable;
389	use super::test::{
390		make_tmdb_collection, make_tmdb_episode, make_tmdb_movie, make_tmdb_season, make_tmdb_show,
391	};
392
393	#[cfg(not(miri))]
394	#[tokio::test]
395	#[expect(clippy::missing_panics_doc, reason = "unit test")]
396	#[expect(clippy::default_numeric_fallback, reason = "unit test")]
397	async fn use_test_macros() {
398		let db = new_initialized_memory_db().await;
399
400		make_info_collection!(&db, 1);
401		make_info_movie!(&db, 1);
402		make_info_show!(&db, 1);
403		make_info_season!(&db, 1, 1);
404		make_info_episode!(&db, 1, 1, 1);
405
406		make_tmdb_collection!(&db, 1, 1);
407		make_tmdb_movie!(&db, 1, 1);
408		make_tmdb_show!(&db, 1, 1);
409		make_tmdb_season!(&db, 1, 1, 1, 1);
410		make_tmdb_episode!(&db, 1, 1, 1, 1, 1, 1);
411	}
412
413	#[cfg(not(miri))]
414	#[tokio::test]
415	#[expect(clippy::missing_panics_doc, reason = "unit test")]
416	#[expect(clippy::default_numeric_fallback, reason = "unit test")]
417	async fn round_trip_collections() {
418		let db = new_initialized_memory_db().await;
419
420		macro_rules! assert_collection {
421			($db:expr, $id:literal, $tid:literal, Success $(; $($skip:ident),+)?) => {
422				let model = assert_collection!(@insert, $db, $id, $tid $(; $($skip),+)?)
423					.expect("insert");
424
425				assert_eq!(model.tmdb_id, TmdbCollectionId::from_raw($tid));
426				assert_eq!(model.flix_id, CollectionId::from_raw($id));
427				assert_eq!(model.last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
428				assert_eq!(model.movie_count, $id);
429			};
430			($db:expr, $id:literal, $tid:literal, $error:ident $(; $($skip:ident),+)?) => {
431				let model = assert_collection!(@insert, $db, $id, $tid $(; $($skip),+)?)
432					.expect_err("insert");
433
434				assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error);
435			};
436			(@insert, $db:expr, $id:literal, $tid:literal $(; $($skip:ident),+)?) => {
437				super::collections::ActiveModel {
438					tmdb_id: notsettable!(tmdb_id, TmdbCollectionId::from_raw($tid) $(, $($skip),+)?),
439					flix_id: notsettable!(flix_id, CollectionId::from_raw($id) $(, $($skip),+)?),
440					last_update: notsettable!(last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
441					movie_count: notsettable!(movie_count, $id $(, $($skip),+)?),
442				}.insert($db).await
443			};
444		}
445
446		assert_collection!(&db, 1, 1, ForeignKeyViolation);
447		make_info_collection!(&db, 1);
448		assert_collection!(&db, 1, 1, Success);
449		assert_collection!(&db, 1, 1, UniqueViolation);
450
451		assert_collection!(&db, 1, 2, UniqueViolation);
452		assert_collection!(&db, 2, 1, UniqueViolation);
453		make_info_collection!(&db, 2);
454		assert_collection!(&db, 2, 2, Success);
455
456		make_info_collection!(&db, 3);
457		assert_collection!(&db, 3, 3, Success; tmdb_id);
458		assert_collection!(&db, 4, 4, NotNullViolation; flix_id);
459		assert_collection!(&db, 5, 5, NotNullViolation; last_update);
460		assert_collection!(&db, 6, 6, NotNullViolation; movie_count);
461	}
462
463	#[cfg(not(miri))]
464	#[tokio::test]
465	#[expect(clippy::missing_panics_doc, reason = "unit test")]
466	#[expect(clippy::default_numeric_fallback, reason = "unit test")]
467	async fn round_trip_movies() {
468		let db = new_initialized_memory_db().await;
469
470		macro_rules! assert_movie {
471			($db:expr, $id:literal, $tid:literal, $cid:expr, Success $(; $($skip:ident),+)?) => {
472				let model = assert_movie!(@insert, $db, $id, $tid, $cid $(; $($skip),+)?)
473					.expect("insert");
474
475				assert_eq!(model.tmdb_id, TmdbMovieId::from_raw($tid));
476				assert_eq!(model.flix_id, MovieId::from_raw($id));
477				assert_eq!(model.last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
478				assert_eq!(model.runtime, Duration::from_secs($tid).into());
479				assert_eq!(model.collection_id, $cid.map(TmdbCollectionId::from_raw));
480			};
481			($db:expr, $id:literal, $tid:literal, $cid:expr, $error:ident $(; $($skip:ident),+)?) => {
482				let model = assert_movie!(@insert, $db, $id, $tid, $cid $(; $($skip),+)?)
483					.expect_err("insert");
484
485				assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error);
486			};
487			(@insert, $db:expr, $id:literal, $tid:literal, $cid:expr $(; $($skip:ident),+)?) => {
488				super::movies::ActiveModel {
489					tmdb_id: notsettable!(tmdb_id, TmdbMovieId::from_raw($tid) $(, $($skip),+)?),
490					flix_id: notsettable!(flix_id, MovieId::from_raw($id) $(, $($skip),+)?),
491					last_update: notsettable!(last_update, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
492					runtime: notsettable!(runtime, Duration::from_secs($tid).into() $(, $($skip),+)?),
493					collection_id: notsettable!(collection_id, $cid.map(TmdbCollectionId::from_raw) $(, $($skip),+)?),
494				}.insert($db).await
495			};
496		}
497
498		assert_movie!(&db, 1, 1, None, ForeignKeyViolation);
499		make_info_movie!(&db, 1);
500		assert_movie!(&db, 1, 1, None, Success);
501		assert_movie!(&db, 1, 1, None, UniqueViolation);
502
503		make_info_movie!(&db, 2);
504		assert_movie!(&db, 2, 2, Some(2), ForeignKeyViolation);
505		make_info_collection!(&db, 2);
506		make_tmdb_collection!(&db, 2, 2);
507		assert_movie!(&db, 2, 2, Some(2), Success);
508		assert_movie!(&db, 1, 2, None, UniqueViolation);
509		assert_movie!(&db, 2, 1, None, UniqueViolation);
510
511		make_info_movie!(&db, 3);
512		assert_movie!(&db, 3, 3, None, Success; tmdb_id);
513		assert_movie!(&db, 4, 4, None, NotNullViolation; flix_id);
514		assert_movie!(&db, 5, 5, None, NotNullViolation; last_update);
515		assert_movie!(&db, 6, 6, None, NotNullViolation; runtime);
516		assert_movie!(&db, 7, 7, None, ForeignKeyViolation; collection_id);
517	}
518
519	#[cfg(not(miri))]
520	#[tokio::test]
521	#[expect(clippy::missing_panics_doc, reason = "unit test")]
522	#[expect(clippy::default_numeric_fallback, reason = "unit test")]
523	async fn round_trip_shows() {
524		let db = new_initialized_memory_db().await;
525
526		macro_rules! assert_show {
527			($db:expr, $id:literal, $tid:literal, Success $(; $($skip:ident),+)?) => {
528				let model = assert_show!(@insert, $db, $id, $tid $(; $($skip),+)?)
529					.expect("insert");
530
531				assert_eq!(model.tmdb_id, TmdbShowId::from_raw($tid));
532				assert_eq!(model.flix_id, ShowId::from_raw($id));
533				assert_eq!(model.last_update, NaiveDate::from_yo_opt($tid, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
534				assert_eq!(model.number_of_seasons, $id);
535			};
536			($db:expr, $id:literal, $tid:literal, $error:ident $(; $($skip:ident),+)?) => {
537				let model = assert_show!(@insert, $db, $id, $tid $(; $($skip),+)?)
538					.expect_err("insert");
539
540				assert_eq!(
541					get_error_kind(model).expect("get_error_kind"),
542					ErrorKind::$error
543				);
544			};
545			(@insert, $db:expr, $id:literal, $tid:literal $(; $($skip:ident),+)?) => {
546				super::shows::ActiveModel {
547					tmdb_id: notsettable!(tmdb_id, TmdbShowId::from_raw($tid) $(, $($skip),+)?),
548					flix_id: notsettable!(flix_id, ShowId::from_raw($id) $(, $($skip),+)?),
549					last_update: notsettable!(last_update, NaiveDate::from_yo_opt($tid, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
550					number_of_seasons: notsettable!(number_of_seasons, $id $(, $($skip),+)?),
551				}.insert($db).await
552			};
553		}
554
555		assert_show!(&db, 1, 1, ForeignKeyViolation);
556		make_info_show!(&db, 1);
557		assert_show!(&db, 1, 1, Success);
558		assert_show!(&db, 1, 1, UniqueViolation);
559
560		assert_show!(&db, 1, 2, UniqueViolation);
561		assert_show!(&db, 2, 1, UniqueViolation);
562		make_info_show!(&db, 2);
563		assert_show!(&db, 2, 2, Success);
564
565		make_info_show!(&db, 3);
566		assert_show!(&db, 3, 3, Success; tmdb_id);
567		assert_show!(&db, 4, 4, NotNullViolation; flix_id);
568		assert_show!(&db, 5, 5, NotNullViolation; last_update);
569		assert_show!(&db, 6, 6, NotNullViolation; number_of_seasons);
570	}
571
572	#[cfg(not(miri))]
573	#[tokio::test]
574	#[expect(clippy::missing_panics_doc, reason = "unit test")]
575	#[expect(clippy::default_numeric_fallback, reason = "unit test")]
576	async fn round_trip_seasons() {
577		let db = new_initialized_memory_db().await;
578
579		macro_rules! assert_season {
580			($db:expr, $show:literal, $season:literal, $tshow:literal, $tseason:literal, Success $(; $($skip:ident),+)?) => {
581				let model = assert_season!(@insert, $db, $show, $season, $tshow, $tseason $(; $($skip),+)?)
582					.expect("insert");
583
584				assert_eq!(model.tmdb_show, TmdbShowId::from_raw($tshow));
585				assert_eq!(model.tmdb_season, ::flix_model::numbers::SeasonNumber::new($tseason));
586				assert_eq!(model.flix_show, ShowId::from_raw($show));
587				assert_eq!(model.flix_season, ::flix_model::numbers::SeasonNumber::new($season));
588				assert_eq!(model.last_update, NaiveDate::from_yo_opt($tshow, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
589			};
590			($db:expr, $show:literal, $season:literal, $tshow:literal, $tseason:literal, $error:ident $(; $($skip:ident),+)?) => {
591				let model = assert_season!(@insert, $db, $show, $season, $tshow, $tseason $(; $($skip),+)?)
592					.expect_err("insert");
593
594				assert_eq!(
595					get_error_kind(model).expect("get_error_kind"),
596					ErrorKind::$error
597				);
598			};
599			(@insert, $db:expr, $show:literal, $season:literal, $tshow:literal, $tseason:literal $(; $($skip:ident),+)?) => {
600				super::seasons::ActiveModel {
601					tmdb_show: notsettable!(tmdb_show, TmdbShowId::from_raw($tshow) $(, $($skip),+)?),
602					tmdb_season: notsettable!(tmdb_season, ::flix_model::numbers::SeasonNumber::new($tseason) $(, $($skip),+)?),
603					flix_show: notsettable!(flix_show, ShowId::from_raw($show) $(, $($skip),+)?),
604					flix_season: notsettable!(flix_season, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
605					last_update: notsettable!(last_update, NaiveDate::from_yo_opt($tshow, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
606				}.insert($db).await
607			};
608		}
609
610		make_info_show!(&db, 1);
611		make_tmdb_show!(&db, 1, 1);
612
613		assert_season!(&db, 1, 1, 1, 1, ForeignKeyViolation);
614		make_info_season!(&db, 1, 1);
615		assert_season!(&db, 1, 1, 1, 1, Success);
616
617		assert_season!(&db, 1, 1, 1, 1, UniqueViolation);
618		assert_season!(&db, 1, 1, 2, 1, UniqueViolation);
619		assert_season!(&db, 2, 1, 1, 1, UniqueViolation);
620		make_info_season!(&db, 1, 2);
621		assert_season!(&db, 1, 2, 1, 2, Success);
622
623		assert_season!(&db, 1, 3, 1, 3, NotNullViolation; tmdb_show);
624		assert_season!(&db, 1, 4, 1, 4, NotNullViolation; tmdb_season);
625		assert_season!(&db, 1, 5, 1, 5, NotNullViolation; flix_show);
626		assert_season!(&db, 1, 6, 1, 6, NotNullViolation; flix_season);
627		assert_season!(&db, 1, 7, 1, 7, NotNullViolation; last_update);
628	}
629
630	#[cfg(not(miri))]
631	#[tokio::test]
632	#[expect(clippy::missing_panics_doc, reason = "unit test")]
633	#[expect(clippy::default_numeric_fallback, reason = "unit test")]
634	async fn round_trip_episodes() {
635		let db = new_initialized_memory_db().await;
636
637		macro_rules! assert_episode {
638			($db:expr, $show:literal, $season:literal, $episode:literal, $tshow:literal, $tseason:literal, $tepisode:literal, Success $(; $($skip:ident),+)?) => {
639				let model = assert_episode!(@insert, $db, $show, $season, $episode, $tshow, $tseason, $tepisode $(; $($skip),+)?)
640					.expect("insert");
641
642				assert_eq!(model.tmdb_show, TmdbShowId::from_raw($tshow));
643				assert_eq!(model.tmdb_season, ::flix_model::numbers::SeasonNumber::new($tseason));
644				assert_eq!(model.tmdb_episode, ::flix_model::numbers::EpisodeNumber::new($tepisode));
645				assert_eq!(model.flix_show, ShowId::from_raw($show));
646				assert_eq!(model.flix_season, ::flix_model::numbers::SeasonNumber::new($season));
647				assert_eq!(model.flix_episode, ::flix_model::numbers::EpisodeNumber::new($episode));
648				assert_eq!(model.last_update, NaiveDate::from_yo_opt($tshow, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc());
649				assert_eq!(model.runtime, Duration::from_secs($tshow).into());
650			};
651			($db:expr, $show:literal, $season:literal, $episode:literal, $tshow:literal, $tseason:literal, $tepisode:literal, $error:ident $(; $($skip:ident),+)?) => {
652				let model = assert_episode!(@insert, $db, $show, $season, $episode, $tshow, $tseason, $tepisode $(; $($skip),+)?)
653					.expect_err("insert");
654
655				assert_eq!(
656					get_error_kind(model).expect("get_error_kind"),
657					ErrorKind::$error
658				);
659			};
660			(@insert, $db:expr, $show:literal, $season:literal, $episode:literal, $tshow:literal, $tseason:literal, $tepisode:literal $(; $($skip:ident),+)?) => {
661				super::episodes::ActiveModel {
662					tmdb_show: notsettable!(tmdb_show, TmdbShowId::from_raw($tshow) $(, $($skip),+)?),
663					tmdb_season: notsettable!(tmdb_season, ::flix_model::numbers::SeasonNumber::new($tseason) $(, $($skip),+)?),
664					tmdb_episode: notsettable!(tmdb_episode, ::flix_model::numbers::EpisodeNumber::new($tepisode) $(, $($skip),+)?),
665					flix_show: notsettable!(flix_show, ShowId::from_raw($show) $(, $($skip),+)?),
666					flix_season: notsettable!(flix_season, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
667					flix_episode: notsettable!(flix_episode, ::flix_model::numbers::EpisodeNumber::new($episode) $(, $($skip),+)?),
668					last_update: notsettable!(last_update, NaiveDate::from_yo_opt($tshow, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?),
669					runtime: notsettable!(runtime, Duration::from_secs($tshow).into() $(, $($skip),+)?),
670				}.insert($db).await
671			};
672		}
673
674		make_info_show!(&db, 1);
675		make_info_season!(&db, 1, 1);
676		make_tmdb_show!(&db, 1, 1);
677		make_tmdb_season!(&db, 1, 1, 1, 1);
678
679		assert_episode!(&db, 1, 1, 1, 1, 1, 1, ForeignKeyViolation);
680		make_info_episode!(&db, 1, 1, 1);
681		assert_episode!(&db, 1, 1, 1, 1, 1, 1, Success);
682
683		assert_episode!(&db, 1, 1, 1, 1, 1, 1, UniqueViolation);
684		assert_episode!(&db, 1, 1, 1, 1, 2, 1, UniqueViolation);
685		assert_episode!(&db, 1, 1, 1, 2, 1, 1, UniqueViolation);
686		assert_episode!(&db, 1, 2, 1, 1, 1, 1, UniqueViolation);
687		assert_episode!(&db, 2, 1, 1, 1, 1, 1, UniqueViolation);
688		make_info_episode!(&db, 1, 1, 2);
689		assert_episode!(&db, 1, 1, 2, 1, 1, 2, Success);
690
691		assert_episode!(&db, 1, 1, 3, 1, 1, 3, NotNullViolation; tmdb_show);
692		assert_episode!(&db, 1, 1, 3, 1, 1, 4, NotNullViolation; tmdb_season);
693		assert_episode!(&db, 1, 1, 3, 1, 1, 5, NotNullViolation; tmdb_episode);
694		assert_episode!(&db, 1, 1, 3, 1, 1, 6, NotNullViolation; flix_show);
695		assert_episode!(&db, 1, 1, 3, 1, 1, 7, NotNullViolation; flix_season);
696		assert_episode!(&db, 1, 1, 3, 1, 1, 8, NotNullViolation; flix_episode);
697		assert_episode!(&db, 1, 1, 3, 1, 1, 9, NotNullViolation; last_update);
698		assert_episode!(&db, 1, 1, 3, 1, 1, 10, NotNullViolation; runtime);
699	}
700}