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