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