Skip to main content

flix_db/entity/
content.rs

1//! This module contains entities for storing media file information
2
3/// Library entity
4pub mod libraries {
5	use flix_model::id::LibraryId;
6
7	use seamantic::model::duration::Seconds;
8	use seamantic::model::path::PathBytes;
9
10	use chrono::{DateTime, Utc};
11	use sea_orm::entity::prelude::*;
12
13	/// The database representation of a library media folder
14	#[sea_orm::model]
15	#[derive(Debug, Clone, DeriveEntityModel)]
16	#[sea_orm(table_name = "flix_libraries")]
17	pub struct Model {
18		/// The library's ID
19		#[sea_orm(primary_key, auto_increment = false)]
20		pub id: LibraryId,
21		/// The library's directory
22		pub directory: PathBytes,
23		/// The library's last scan data
24		pub last_scan_date: Option<DateTime<Utc>>,
25		/// The library's last scan duration
26		pub last_scan_duration: Option<Seconds>,
27
28		/// Collections that are part of this library
29		#[sea_orm(has_many)]
30		pub collections: HasMany<super::collections::Entity>,
31		/// Movies that are part of this library
32		#[sea_orm(has_many)]
33		pub movies: HasMany<super::movies::Entity>,
34		/// Shows that are part of this library
35		#[sea_orm(has_many)]
36		pub shows: HasMany<super::shows::Entity>,
37		/// Seasons that are part of this library
38		#[sea_orm(has_many)]
39		pub seasons: HasMany<super::seasons::Entity>,
40		/// Episodes that are part of this library
41		#[sea_orm(has_many)]
42		pub episodes: HasMany<super::episodes::Entity>,
43	}
44
45	impl ActiveModelBehavior for ActiveModel {}
46}
47
48/// Collection entity
49pub mod collections {
50	use flix_model::id::{CollectionId, LibraryId};
51
52	use seamantic::model::path::PathBytes;
53
54	use sea_orm::entity::prelude::*;
55
56	use crate::entity;
57
58	/// The database representation of a collection media folder
59	#[sea_orm::model]
60	#[derive(Debug, Clone, DeriveEntityModel)]
61	#[sea_orm(table_name = "flix_collections")]
62	pub struct Model {
63		/// The collection's ID
64		#[sea_orm(primary_key, auto_increment = false)]
65		pub id: CollectionId,
66		/// The collection's parent
67		#[sea_orm(indexed)]
68		pub parent_id: Option<CollectionId>,
69		/// The collection's library ID
70		pub library_id: LibraryId,
71		/// The collection's directory
72		pub directory: PathBytes,
73		/// The collection's poster path
74		pub relative_poster_path: Option<String>,
75
76		/// This collection's parent
77		#[sea_orm(
78			self_ref,
79			relation_enum = "Parent",
80			from = "parent_id",
81			to = "id",
82			on_update = "Cascade",
83			on_delete = "Cascade"
84		)]
85		pub parent: HasOne<Entity>,
86		/// The library this collection belongs to
87		#[sea_orm(
88			belongs_to,
89			from = "library_id",
90			to = "id",
91			on_update = "Cascade",
92			on_delete = "Cascade"
93		)]
94		pub library: HasOne<super::libraries::Entity>,
95		/// The info for this collection
96		#[sea_orm(
97			belongs_to,
98			relation_enum = "Info",
99			from = "id",
100			to = "id",
101			on_update = "Cascade",
102			on_delete = "Cascade"
103		)]
104		pub info: HasOne<entity::info::collections::Entity>,
105
106		/// The watched info for this collection
107		#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
108		pub watched: HasMany<entity::watched::collections::Entity>,
109	}
110
111	impl ActiveModelBehavior for ActiveModel {}
112}
113
114/// Movie entity
115pub mod movies {
116	use flix_model::id::{CollectionId, LibraryId, MovieId};
117
118	use seamantic::model::path::PathBytes;
119
120	use sea_orm::entity::prelude::*;
121
122	use crate::entity;
123
124	/// The database representation of a movie media folder
125	#[sea_orm::model]
126	#[derive(Debug, Clone, DeriveEntityModel)]
127	#[sea_orm(table_name = "flix_movies")]
128	pub struct Model {
129		/// The movie's ID
130		#[sea_orm(primary_key, auto_increment = false)]
131		pub id: MovieId,
132		/// The movie's parent
133		#[sea_orm(indexed)]
134		pub parent_id: Option<CollectionId>,
135		/// The movie's library
136		pub library_id: LibraryId,
137		/// The movie's directory
138		pub directory: PathBytes,
139		/// The movie's media path
140		pub relative_media_path: String,
141		/// The movie's poster path
142		pub relative_poster_path: Option<String>,
143
144		/// This movie's parent
145		#[sea_orm(
146			belongs_to,
147			from = "parent_id",
148			to = "id",
149			on_update = "Cascade",
150			on_delete = "Cascade"
151		)]
152		pub parent: HasOne<super::collections::Entity>,
153		/// The library this movie belongs to
154		#[sea_orm(
155			belongs_to,
156			from = "library_id",
157			to = "id",
158			on_update = "Cascade",
159			on_delete = "Cascade"
160		)]
161		pub library: HasOne<super::libraries::Entity>,
162		/// The info for this movie
163		#[sea_orm(
164			belongs_to,
165			relation_enum = "Info",
166			from = "id",
167			to = "id",
168			on_update = "Cascade",
169			on_delete = "Cascade"
170		)]
171		pub info: HasOne<entity::info::movies::Entity>,
172
173		/// The watched info for this movie
174		#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
175		pub watched: HasMany<entity::watched::movies::Entity>,
176	}
177
178	impl ActiveModelBehavior for ActiveModel {}
179}
180
181/// Show entity
182pub mod shows {
183	use flix_model::id::{CollectionId, LibraryId, ShowId};
184
185	use seamantic::model::path::PathBytes;
186
187	use sea_orm::entity::prelude::*;
188
189	use crate::entity;
190
191	/// The database representation of a show media folder
192	#[sea_orm::model]
193	#[derive(Debug, Clone, DeriveEntityModel)]
194	#[sea_orm(table_name = "flix_shows")]
195	pub struct Model {
196		/// The show's ID
197		#[sea_orm(primary_key, auto_increment = false)]
198		pub id: ShowId,
199		/// The show's parent
200		#[sea_orm(indexed)]
201		pub parent_id: Option<CollectionId>,
202		/// The show's library
203		pub library_id: LibraryId,
204		/// The show's directory
205		pub directory: PathBytes,
206		/// The show's poster path
207		pub relative_poster_path: Option<String>,
208
209		/// This show's parent
210		#[sea_orm(
211			belongs_to,
212			from = "parent_id",
213			to = "id",
214			on_update = "Cascade",
215			on_delete = "Cascade"
216		)]
217		pub parent: HasOne<super::collections::Entity>,
218		/// The library this show belongs to
219		#[sea_orm(
220			belongs_to,
221			from = "library_id",
222			to = "id",
223			on_update = "Cascade",
224			on_delete = "Cascade"
225		)]
226		pub library: HasOne<super::libraries::Entity>,
227		/// The info for this show
228		#[sea_orm(
229			belongs_to,
230			relation_enum = "Info",
231			from = "id",
232			to = "id",
233			on_update = "Cascade",
234			on_delete = "Cascade"
235		)]
236		pub info: HasOne<entity::info::shows::Entity>,
237
238		/// Seasons that are part of this show
239		#[sea_orm(has_many)]
240		pub seasons: HasMany<super::seasons::Entity>,
241		/// Episodes that are part of this show
242		#[sea_orm(has_many)]
243		pub episodes: HasMany<super::episodes::Entity>,
244		/// The watched info for this show
245		#[sea_orm(has_many, relation_enum = "Watched", from = "id", to = "id")]
246		pub watched: HasMany<entity::watched::shows::Entity>,
247	}
248
249	impl ActiveModelBehavior for ActiveModel {}
250}
251
252/// Season entity
253pub mod seasons {
254	use flix_model::id::{LibraryId, ShowId};
255	use flix_model::numbers::SeasonNumber;
256
257	use seamantic::model::path::PathBytes;
258
259	use sea_orm::entity::prelude::*;
260
261	use crate::entity;
262
263	/// The database representation of a season media folder
264	#[sea_orm::model]
265	#[derive(Debug, Clone, DeriveEntityModel)]
266	#[sea_orm(table_name = "flix_seasons")]
267	pub struct Model {
268		/// The season's show's ID
269		#[sea_orm(primary_key, auto_increment = false)]
270		pub show_id: ShowId,
271		/// The season's number
272		#[sea_orm(primary_key, auto_increment = false)]
273		pub season_number: SeasonNumber,
274		/// The season's library
275		pub library_id: LibraryId,
276		/// The season's directory
277		pub directory: PathBytes,
278		/// The season's poster path
279		pub relative_poster_path: Option<String>,
280
281		/// This season's show
282		#[sea_orm(
283			belongs_to,
284			from = "show_id",
285			to = "id",
286			on_update = "Cascade",
287			on_delete = "Cascade"
288		)]
289		pub show: HasOne<super::shows::Entity>,
290		/// The library this season belongs to
291		#[sea_orm(
292			belongs_to,
293			from = "library_id",
294			to = "id",
295			on_update = "Cascade",
296			on_delete = "Cascade"
297		)]
298		pub library: HasOne<super::libraries::Entity>,
299		/// The info for this season
300		#[sea_orm(
301			belongs_to,
302			relation_enum = "Info",
303			from = "(show_id, season_number)",
304			to = "(show_id, season_number)",
305			on_update = "Cascade",
306			on_delete = "Cascade"
307		)]
308		pub info: HasOne<entity::info::seasons::Entity>,
309
310		/// Episodes that are part of this show
311		#[sea_orm(has_many)]
312		pub episodes: HasMany<super::episodes::Entity>,
313		/// The watched info for this season
314		#[sea_orm(
315			has_many,
316			relation_enum = "Watched",
317			from = "(show_id, season_number)",
318			to = "(show_id, season_number)"
319		)]
320		pub watched: HasMany<entity::watched::seasons::Entity>,
321	}
322
323	impl ActiveModelBehavior for ActiveModel {}
324}
325
326/// Episode entity
327pub mod episodes {
328	use flix_model::id::{LibraryId, ShowId};
329	use flix_model::numbers::{EpisodeNumber, SeasonNumber};
330
331	use seamantic::model::path::PathBytes;
332
333	use sea_orm::entity::prelude::*;
334
335	use crate::entity;
336
337	/// The database representation of a episode media folder
338	#[sea_orm::model]
339	#[derive(Debug, Clone, DeriveEntityModel)]
340	#[sea_orm(table_name = "flix_episodes")]
341	pub struct Model {
342		/// The episode's show's ID
343		#[sea_orm(primary_key, auto_increment = false)]
344		pub show_id: ShowId,
345		/// The episode's season's number
346		#[sea_orm(primary_key, auto_increment = false)]
347		pub season_number: SeasonNumber,
348		/// The episode's number
349		#[sea_orm(primary_key, auto_increment = false)]
350		pub episode_number: EpisodeNumber,
351		/// The number of additional contained episodes
352		pub count: u8,
353		/// The episode's library
354		pub library_id: LibraryId,
355		/// The episode's directory
356		pub directory: PathBytes,
357		/// The episode's media path
358		pub relative_media_path: String,
359		/// The episode's poster path
360		pub relative_poster_path: Option<String>,
361
362		/// This episode's show
363		#[sea_orm(
364			belongs_to,
365			from = "show_id",
366			to = "id",
367			on_update = "Cascade",
368			on_delete = "Cascade"
369		)]
370		pub show: HasOne<super::shows::Entity>,
371		/// This episode's season
372		#[sea_orm(
373			belongs_to,
374			from = "(show_id, season_number)",
375			to = "(show_id, season_number)",
376			on_update = "Cascade",
377			on_delete = "Cascade"
378		)]
379		pub season: HasOne<super::seasons::Entity>,
380		/// The library this episode belongs to
381		#[sea_orm(
382			belongs_to,
383			from = "library_id",
384			to = "id",
385			on_update = "Cascade",
386			on_delete = "Cascade"
387		)]
388		pub library: HasOne<super::libraries::Entity>,
389		/// The info for this episode
390		#[sea_orm(
391			belongs_to,
392			relation_enum = "Info",
393			from = "(show_id, season_number, episode_number)",
394			to = "(show_id, season_number, episode_number)",
395			on_update = "Cascade",
396			on_delete = "Cascade"
397		)]
398		pub info: HasOne<entity::info::episodes::Entity>,
399
400		/// The watched info for this episode
401		#[sea_orm(
402			has_many,
403			relation_enum = "Watched",
404			from = "(show_id, season_number, episode_number)",
405			to = "(show_id, season_number, episode_number)"
406		)]
407		pub watched: HasMany<entity::watched::episodes::Entity>,
408	}
409
410	impl ActiveModelBehavior for ActiveModel {}
411}
412
413/// Macros for creating content entities
414#[cfg(test)]
415pub mod test {
416	macro_rules! make_content_library {
417		($db:expr, $id:expr) => {
418			$crate::entity::content::libraries::ActiveModel {
419				id: Set(::flix_model::id::LibraryId::from_raw($id)),
420				directory: Set(::std::path::PathBuf::new().into()),
421				last_scan_date: Set(None),
422				last_scan_duration: Set(None),
423			}
424			.insert($db)
425			.await
426			.expect("insert");
427		};
428	}
429	pub(crate) use make_content_library;
430
431	macro_rules! make_content_collection {
432		($db:expr, $lid:expr, $id:expr, $pid:expr) => {
433			$crate::entity::info::test::make_info_collection!($db, $id);
434			$crate::entity::content::collections::ActiveModel {
435				id: Set(::flix_model::id::CollectionId::from_raw($id)),
436				parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
437				library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
438				directory: Set(::std::path::PathBuf::new().into()),
439				relative_poster_path: Set(::core::option::Option::None),
440			}
441			.insert($db)
442			.await
443			.expect("insert");
444		};
445	}
446	pub(crate) use make_content_collection;
447
448	macro_rules! make_content_movie {
449		($db:expr, $lid:expr, $id:expr, $pid:expr) => {
450			$crate::entity::info::test::make_info_movie!($db, $id);
451			$crate::entity::content::movies::ActiveModel {
452				id: Set(::flix_model::id::MovieId::from_raw($id)),
453				parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
454				library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
455				directory: Set(::std::path::PathBuf::new().into()),
456				relative_media_path: Set(::std::string::String::new()),
457				relative_poster_path: Set(::core::option::Option::None),
458			}
459			.insert($db)
460			.await
461			.expect("insert");
462		};
463	}
464	pub(crate) use make_content_movie;
465
466	macro_rules! make_content_show {
467		($db:expr, $lid:expr, $id:expr, $pid:expr) => {
468			$crate::entity::info::test::make_info_show!($db, $id);
469			$crate::entity::content::shows::ActiveModel {
470				id: Set(::flix_model::id::ShowId::from_raw($id)),
471				parent_id: Set($pid.map(::flix_model::id::CollectionId::from_raw)),
472				library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
473				directory: Set(::std::path::PathBuf::new().into()),
474				relative_poster_path: Set(::core::option::Option::None),
475			}
476			.insert($db)
477			.await
478			.expect("insert");
479		};
480	}
481	pub(crate) use make_content_show;
482
483	macro_rules! make_content_season {
484		($db:expr, $lid:expr, $show:expr, $season:expr) => {
485			$crate::entity::info::test::make_info_season!($db, $show, $season);
486			$crate::entity::content::seasons::ActiveModel {
487				show_id: Set(::flix_model::id::ShowId::from_raw($show)),
488				season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
489				library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
490				directory: Set(::std::path::PathBuf::new().into()),
491				relative_poster_path: Set(::core::option::Option::None),
492			}
493			.insert($db)
494			.await
495			.expect("insert");
496		};
497	}
498	pub(crate) use make_content_season;
499
500	macro_rules! make_content_episode {
501		($db:expr, $lid:expr, $show:expr, $season:expr, $episode:expr) => {
502			make_content_episode!(@make, $db, $lid, $show, $season, $episode, 0);
503		};
504		($db:expr, $lid:literal, $show:literal, $season:literal, $episode:literal, >1) => {
505			make_content_episode!(@make, $db, $lid, $show, $season, $episode, 1);
506		};
507		(@make, $db:expr, $lid:expr, $show:expr, $season:expr, $episode:expr, $count:literal) => {
508			$crate::entity::info::test::make_info_episode!($db, $show, $season, $episode);
509			$crate::entity::content::episodes::ActiveModel {
510				show_id: Set(::flix_model::id::ShowId::from_raw($show)),
511				season_number: Set(::flix_model::numbers::SeasonNumber::new($season)),
512				episode_number: Set(::flix_model::numbers::EpisodeNumber::new($episode)),
513				count: Set($count),
514				library_id: Set(::flix_model::id::LibraryId::from_raw($lid)),
515				directory: Set(::std::path::PathBuf::new().into()),
516				relative_media_path: Set(::std::string::String::new()),
517				relative_poster_path: Set(::core::option::Option::None),
518			}
519			.insert($db)
520			.await
521			.expect("insert");
522		};
523	}
524	pub(crate) use make_content_episode;
525}
526
527#[cfg(test)]
528mod tests {
529	use core::time::Duration;
530	use std::path::Path;
531
532	use flix_model::id::{CollectionId, LibraryId, MovieId, ShowId};
533
534	use seamantic::model::duration::Seconds;
535
536	use chrono::NaiveDate;
537	use sea_orm::ActiveValue::{NotSet, Set};
538	use sea_orm::entity::prelude::*;
539	use sea_orm::sqlx::error::ErrorKind;
540
541	use crate::entity::content::test::{
542		make_content_collection, make_content_episode, make_content_library, make_content_movie,
543		make_content_season, make_content_show,
544	};
545	use crate::entity::info::test::{
546		make_info_collection, make_info_episode, make_info_movie, make_info_season, make_info_show,
547	};
548	use crate::tests::new_initialized_memory_db;
549
550	use super::super::tests::get_error_kind;
551	use super::super::tests::{noneable, notsettable};
552
553	#[tokio::test]
554	async fn use_test_macros() {
555		let db = new_initialized_memory_db().await;
556
557		make_content_library!(&db, 1);
558		make_content_collection!(&db, 1, 1, None);
559		make_content_movie!(&db, 1, 1, None);
560		make_content_show!(&db, 1, 1, None);
561		make_content_season!(&db, 1, 1, 1);
562		make_content_episode!(&db, 1, 1, 1, 1);
563	}
564
565	#[tokio::test]
566	async fn test_round_trip_libraries() {
567		let db = new_initialized_memory_db().await;
568
569		macro_rules! assert_library {
570			($db:expr, $id:literal, Success $(; $($skip:ident),+)?) => {
571				let model = assert_library!(@insert, $db, $id $(; $($skip),+)?)
572					.expect("insert");
573
574				assert_eq!(model.id, LibraryId::from_raw($id));
575				assert_eq!(model.directory, Path::new(concat!("L Directory ", $id)).to_owned().into());
576				assert_eq!(model.last_scan_date, noneable!(last_scan_date, NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc() $(, $($skip),+)?));
577				assert_eq!(model.last_scan_duration, noneable!(last_scan_duration, Seconds(Duration::from_secs($id)) $(, $($skip),+)?));
578			};
579			($db:expr, $id:literal, $error:ident $(; $($skip:ident),+)?) => {
580				let model = assert_library!(@insert, $db, $id $(; $($skip),+)?)
581					.expect_err("insert");
582
583				assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error);
584			};
585			(@insert, $db:expr, $id:literal $(; $($skip:ident),+)?) => {
586				super::libraries::ActiveModel {
587					id: notsettable!(id, LibraryId::from_raw($id) $(, $($skip),+)?),
588					directory: notsettable!(directory, Path::new(concat!("L Directory ", $id)).to_owned().into() $(, $($skip),+)?),
589					last_scan_date: notsettable!(last_scan_date, Some(NaiveDate::from_yo_opt($id, 1).expect("from_yo_opt").and_hms_opt(0, 0, 0).expect("and_hms_opt").and_utc()) $(, $($skip),+)?),
590					last_scan_duration: notsettable!(last_scan_duration, Some(Seconds(Duration::from_secs($id))) $(, $($skip),+)?),
591				}.insert($db).await
592			};
593		}
594
595		assert_library!(&db, 1, Success);
596		assert_library!(&db, 1, UniqueViolation);
597		assert_library!(&db, 2, Success);
598		assert_library!(&db, 3, Success; id);
599		assert_library!(&db, 4, NotNullViolation; directory);
600		assert_library!(&db, 5, Success; last_scan_date);
601		assert_library!(&db, 6, Success; last_scan_duration);
602	}
603
604	#[tokio::test]
605	async fn test_round_trip_collections() {
606		let db = new_initialized_memory_db().await;
607
608		macro_rules! assert_collection {
609			($db:expr, $id:literal, $pid:expr, $lid:literal, Success $(; $($skip:ident),+)?) => {
610				let model = assert_collection!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?)
611					.expect("insert");
612
613				assert_eq!(model.id, CollectionId::from_raw($id));
614				assert_eq!(model.parent_id, $pid.map(CollectionId::from_raw));
615				assert_eq!(model.library_id, LibraryId::from_raw($lid));
616				assert_eq!(model.directory, Path::new(concat!("C Directory ", $id)).to_owned().into());
617				assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, concat!("C Poster ", $id).to_owned() $(, $($skip),+)?));
618			};
619			($db:expr, $id:literal, $pid:expr, $lid:literal, $error:ident $(; $($skip:ident),+)?) => {
620				let model = assert_collection!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?)
621					.expect_err("insert");
622
623				assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error);
624			};
625			(@insert, $db:expr, $id:literal, $pid:expr, $lid:literal $(; $($skip:ident),+)?) => {
626				super::collections::ActiveModel {
627					id: notsettable!(id, CollectionId::from_raw($id) $(, $($skip),+)?),
628					parent_id: notsettable!(parent_id, $pid.map(CollectionId::from_raw) $(, $($skip),+)?),
629					library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?),
630					directory: notsettable!(directory, Path::new(concat!("C Directory ", $id)).to_owned().into() $(, $($skip),+)?),
631					relative_poster_path: notsettable!(relative_poster_path, Some(concat!("C Poster ", $id).to_owned()) $(, $($skip),+)?),
632				}.insert($db).await
633			};
634		}
635
636		make_content_library!(&db, 1);
637		assert_collection!(&db, 1, None, 1, ForeignKeyViolation);
638		make_info_collection!(&db, 1);
639		assert_collection!(&db, 1, None, 1, Success);
640		make_info_collection!(&db, 2);
641		assert_collection!(&db, 2, None, 2, ForeignKeyViolation);
642		make_content_library!(&db, 2);
643		assert_collection!(&db, 2, None, 2, Success);
644
645		assert_collection!(&db, 1, None, 1, UniqueViolation);
646		make_info_collection!(&db, 3);
647		make_info_collection!(&db, 4);
648		make_info_collection!(&db, 5);
649		make_info_collection!(&db, 6);
650		make_info_collection!(&db, 7);
651		make_info_collection!(&db, 8);
652		assert_collection!(&db, 3, None, 1, Success; id);
653		assert_collection!(&db, 4, None, 1, Success; parent_id);
654		assert_collection!(&db, 5, None, 1, NotNullViolation; library_id);
655		assert_collection!(&db, 6, None, 1, NotNullViolation; directory);
656		assert_collection!(&db, 7, None, 1, Success; relative_poster_path);
657	}
658
659	#[tokio::test]
660	async fn test_round_trip_movies() {
661		let db = new_initialized_memory_db().await;
662
663		macro_rules! assert_movie {
664			($db:expr, $id:literal, $pid:expr, $lid:literal, Success $(; $($skip:ident),+)?) => {
665				let model = assert_movie!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?)
666					.expect("insert");
667
668				assert_eq!(model.id, MovieId::from_raw($id));
669				assert_eq!(model.parent_id, $pid.map(CollectionId::from_raw));
670				assert_eq!(model.library_id, LibraryId::from_raw($lid));
671				assert_eq!(model.directory, Path::new(concat!("M Directory ", $id)).to_owned().into());
672				assert_eq!(model.relative_media_path, concat!("M Media ", $id));
673				assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, concat!("M Poster ", $id).to_owned() $(, $($skip),+)?));
674			};
675			($db:expr, $id:literal, $pid:expr, $lid:literal, $error:ident $(; $($skip:ident),+)?) => {
676				let model = assert_movie!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?)
677					.expect_err("insert");
678
679				assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error);
680			};
681			(@insert, $db:expr, $id:literal, $pid:expr, $lid:literal $(; $($skip:ident),+)?) => {
682				super::movies::ActiveModel {
683					id: notsettable!(id, MovieId::from_raw($id) $(, $($skip),+)?),
684					parent_id: notsettable!(parent_id, $pid.map(CollectionId::from_raw) $(, $($skip),+)?),
685					library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?),
686					directory: notsettable!(directory, Path::new(concat!("M Directory ", $id)).to_owned().into() $(, $($skip),+)?),
687					relative_media_path: notsettable!(relative_media_path, concat!("M Media ", $id).to_owned() $(, $($skip),+)?),
688					relative_poster_path: notsettable!(relative_poster_path, Some(concat!("M Poster ", $id).to_owned()) $(, $($skip),+)?),
689				}.insert($db).await
690			};
691		}
692
693		make_content_library!(&db, 1);
694		assert_movie!(&db, 1, None, 1, ForeignKeyViolation);
695		make_info_movie!(&db, 1);
696		assert_movie!(&db, 1, Some(1), 1, ForeignKeyViolation);
697		make_content_collection!(&db, 1, 1, None);
698		assert_movie!(&db, 1, Some(1), 1, Success);
699		assert_movie!(&db, 2, None, 2, ForeignKeyViolation);
700		make_info_movie!(&db, 2);
701		assert_movie!(&db, 2, None, 2, ForeignKeyViolation);
702		make_content_library!(&db, 2);
703		assert_movie!(&db, 2, None, 2, Success);
704
705		assert_movie!(&db, 1, None, 1, UniqueViolation);
706		make_info_movie!(&db, 3);
707		make_info_movie!(&db, 4);
708		make_info_movie!(&db, 5);
709		make_info_movie!(&db, 6);
710		make_info_movie!(&db, 7);
711		make_info_movie!(&db, 8);
712		make_info_movie!(&db, 9);
713		assert_movie!(&db, 3, None, 1, Success; id);
714		assert_movie!(&db, 4, None, 1, Success; parent_id);
715		assert_movie!(&db, 5, None, 1, NotNullViolation; library_id);
716		assert_movie!(&db, 6, None, 1, NotNullViolation; directory);
717		assert_movie!(&db, 7, None, 1, NotNullViolation; relative_media_path);
718		assert_movie!(&db, 8, None, 1, Success; relative_poster_path);
719	}
720
721	#[tokio::test]
722	async fn test_round_trip_shows() {
723		let db = new_initialized_memory_db().await;
724
725		macro_rules! assert_show {
726			($db:expr, $id:literal, $pid:expr, $lid:literal, Success $(; $($skip:ident),+)?) => {
727				let model = assert_show!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?)
728					.expect("insert");
729
730				assert_eq!(model.id, ShowId::from_raw($id));
731				assert_eq!(model.parent_id, $pid.map(CollectionId::from_raw));
732				assert_eq!(model.library_id, LibraryId::from_raw($lid));
733				assert_eq!(model.directory, Path::new(concat!("S Directory ", $id)).to_owned().into());
734				assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, concat!("S Poster ", $id).to_owned() $(, $($skip),+)?));
735			};
736			($db:expr, $id:literal, $pid:expr, $lid:literal, $error:ident $(; $($skip:ident),+)?) => {
737				let model = assert_show!(@insert, $db, $id, $pid, $lid $(; $($skip),+)?)
738					.expect_err("insert");
739
740				assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error);
741			};
742			(@insert, $db:expr, $id:literal, $pid:expr, $lid:literal $(; $($skip:ident),+)?) => {
743				super::shows::ActiveModel {
744					id: notsettable!(id, ShowId::from_raw($id) $(, $($skip),+)?),
745					parent_id: notsettable!(parent_id, $pid.map(CollectionId::from_raw) $(, $($skip),+)?),
746					library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?),
747					directory: notsettable!(directory, Path::new(concat!("S Directory ", $id)).to_owned().into() $(, $($skip),+)?),
748					relative_poster_path: notsettable!(relative_poster_path, Some(concat!("S Poster ", $id).to_owned()) $(, $($skip),+)?),
749				}.insert($db).await
750			};
751		}
752
753		make_content_library!(&db, 1);
754		assert_show!(&db, 1, None, 1, ForeignKeyViolation);
755		make_info_show!(&db, 1);
756		assert_show!(&db, 1, Some(1), 1, ForeignKeyViolation);
757		make_content_collection!(&db, 1, 1, None);
758		assert_show!(&db, 1, Some(1), 1, Success);
759		assert_show!(&db, 2, None, 2, ForeignKeyViolation);
760		make_info_show!(&db, 2);
761		assert_show!(&db, 2, None, 2, ForeignKeyViolation);
762		make_content_library!(&db, 2);
763		assert_show!(&db, 2, None, 2, Success);
764
765		assert_show!(&db, 1, None, 1, UniqueViolation);
766		make_info_show!(&db, 3);
767		make_info_show!(&db, 4);
768		make_info_show!(&db, 5);
769		make_info_show!(&db, 6);
770		make_info_show!(&db, 7);
771		make_info_show!(&db, 8);
772		assert_show!(&db, 3, None, 1, Success; id);
773		assert_show!(&db, 4, None, 1, Success; parent_id);
774		assert_show!(&db, 5, None, 1, NotNullViolation; library_id);
775		assert_show!(&db, 6, None, 1, NotNullViolation; directory);
776		assert_show!(&db, 7, None, 1, Success; relative_poster_path);
777	}
778
779	#[tokio::test]
780	async fn test_round_trip_seasons() {
781		let db = new_initialized_memory_db().await;
782
783		macro_rules! assert_season {
784			($db:expr, $id:literal, $season:literal, $lid:literal, Success $(; $($skip:ident),+)?) => {
785				let model = assert_season!(@insert, $db, $id, $season, $lid $(; $($skip),+)?)
786					.expect("insert");
787
788				assert_eq!(model.show_id, ShowId::from_raw($id));
789				assert_eq!(model.season_number, ::flix_model::numbers::SeasonNumber::new($season));
790				assert_eq!(model.library_id, LibraryId::from_raw($lid));
791				assert_eq!(model.directory, Path::new(concat!("SS Directory ", $id, ",", $season)).to_owned().into());
792				assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, concat!("SS Poster ", $id, ",", $season).to_owned() $(, $($skip),+)?));
793			};
794			($db:expr, $id:literal, $season:literal, $lid:literal, $error:ident $(; $($skip:ident),+)?) => {
795				let model = assert_season!(@insert, $db, $id, $season, $lid $(; $($skip),+)?)
796					.expect_err("insert");
797
798				assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error);
799			};
800			(@insert, $db:expr, $id:literal, $season:literal, $lid:literal $(; $($skip:ident),+)?) => {
801				super::seasons::ActiveModel {
802					show_id: notsettable!(show_id, ShowId::from_raw($id) $(, $($skip),+)?),
803					season_number: notsettable!(season_number, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
804					library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?),
805					directory: notsettable!(directory, Path::new(concat!("SS Directory ", $id, ",", $season)).to_owned().into() $(, $($skip),+)?),
806					relative_poster_path: notsettable!(relative_poster_path, Some(concat!("SS Poster ", $id, ",", $season).to_owned()) $(, $($skip),+)?),
807				}.insert($db).await
808			};
809		}
810
811		make_content_library!(&db, 1);
812		make_content_show!(&db, 1, 1, None);
813		assert_season!(&db, 1, 1, 1, ForeignKeyViolation);
814		make_info_season!(&db, 1, 1);
815		assert_season!(&db, 1, 1, 1, Success);
816
817		assert_season!(&db, 1, 1, 1, UniqueViolation);
818		make_info_season!(&db, 1, 3);
819		make_info_season!(&db, 1, 4);
820		make_info_season!(&db, 1, 5);
821		make_info_season!(&db, 1, 6);
822		make_info_season!(&db, 1, 7);
823		make_info_season!(&db, 1, 8);
824		assert_season!(&db, 1, 3, 1, NotNullViolation; show_id);
825		assert_season!(&db, 1, 4, 1, NotNullViolation; season_number);
826		assert_season!(&db, 1, 5, 1, NotNullViolation; library_id);
827		assert_season!(&db, 1, 6, 1, NotNullViolation; directory);
828		assert_season!(&db, 1, 7, 1, Success; relative_poster_path);
829	}
830
831	#[tokio::test]
832	async fn test_round_trip_episodes() {
833		let db = new_initialized_memory_db().await;
834
835		macro_rules! assert_episode {
836			($db:expr, $id:literal, $season:literal, $episode:literal, $lid:literal, Success $(; $($skip:ident),+)?) => {
837				let model = assert_episode!(@insert, $db, $id, $season, $episode, $lid $(; $($skip),+)?)
838					.expect("insert");
839
840				assert_eq!(model.show_id, ShowId::from_raw($id));
841				assert_eq!(model.season_number, ::flix_model::numbers::SeasonNumber::new($season));
842				assert_eq!(model.episode_number, ::flix_model::numbers::EpisodeNumber::new($episode));
843				assert_eq!(model.library_id, LibraryId::from_raw($lid));
844				assert_eq!(model.directory, Path::new(concat!("SS Directory ", $id, ",", $season, $episode)).to_owned().into());
845				assert_eq!(model.relative_media_path, concat!("SS Media ", $id, ",", $season, $episode));
846				assert_eq!(model.relative_poster_path, noneable!(relative_poster_path, concat!("SS Poster ", $id, ",", $season, $episode).to_owned() $(, $($skip),+)?));
847			};
848			($db:expr, $id:literal, $season:literal, $episode:literal, $lid:literal, $error:ident $(; $($skip:ident),+)?) => {
849				let model = assert_episode!(@insert, $db, $id, $season, $episode, $lid $(; $($skip),+)?)
850					.expect_err("insert");
851
852				assert_eq!(get_error_kind(model).expect("get_error_kind"), ErrorKind::$error);
853			};
854			(@insert, $db:expr, $id:literal, $season:literal, $episode:literal, $lid:literal $(; $($skip:ident),+)?) => {
855				super::episodes::ActiveModel {
856					show_id: notsettable!(show_id, ShowId::from_raw($id) $(, $($skip),+)?),
857					season_number: notsettable!(season_number, ::flix_model::numbers::SeasonNumber::new($season) $(, $($skip),+)?),
858					episode_number: notsettable!(episode_number, ::flix_model::numbers::EpisodeNumber::new($episode) $(, $($skip),+)?),
859					count: notsettable!(count, 0 $(, $($skip),+)?),
860					library_id: notsettable!(library_id, LibraryId::from_raw($lid) $(, $($skip),+)?),
861					directory: notsettable!(directory, Path::new(concat!("SS Directory ", $id, ",", $season, $episode)).to_owned().into() $(, $($skip),+)?),
862					relative_media_path: notsettable!(relative_media_path, concat!("SS Media ", $id, ",", $season, $episode).to_owned() $(, $($skip),+)?),
863					relative_poster_path: notsettable!(relative_poster_path, Some(concat!("SS Poster ", $id, ",", $season, $episode).to_owned()) $(, $($skip),+)?),
864				}.insert($db).await
865			};
866		}
867
868		make_content_library!(&db, 1);
869		make_content_show!(&db, 1, 1, None);
870		make_content_season!(&db, 1, 1, 1);
871		assert_episode!(&db, 1, 1, 1, 1, ForeignKeyViolation);
872		make_info_episode!(&db, 1, 1, 1);
873		assert_episode!(&db, 1, 1, 1, 1, Success);
874
875		assert_episode!(&db, 1, 1, 1, 1, UniqueViolation);
876		make_info_episode!(&db, 1, 1, 3);
877		make_info_episode!(&db, 1, 1, 4);
878		make_info_episode!(&db, 1, 1, 5);
879		make_info_episode!(&db, 1, 1, 6);
880		make_info_episode!(&db, 1, 1, 7);
881		make_info_episode!(&db, 1, 1, 8);
882		make_info_episode!(&db, 1, 1, 9);
883		make_info_episode!(&db, 1, 1, 10);
884		assert_episode!(&db, 1, 1, 3, 1, NotNullViolation; show_id);
885		assert_episode!(&db, 1, 1, 4, 1, NotNullViolation; season_number);
886		assert_episode!(&db, 1, 1, 5, 1, NotNullViolation; episode_number);
887		assert_episode!(&db, 1, 1, 6, 1, NotNullViolation; library_id);
888		assert_episode!(&db, 1, 1, 7, 1, NotNullViolation; directory);
889		assert_episode!(&db, 1, 1, 8, 1, NotNullViolation; relative_media_path);
890		assert_episode!(&db, 1, 1, 9, 1, Success; relative_poster_path);
891	}
892}