1use anyhow::Context;
2use entertainarr_domain::language::Language;
3use entertainarr_domain::tvshow::entity::{
4 ExternalTvShow, ListTvShowParams, TvShow, TvShowSource, TvShowSubscription,
5 TvShowWithSubscription,
6};
7use sqlx::types::Json;
8use sqlx::types::chrono::{DateTime, NaiveDate, Utc};
9
10use super::Wrapper;
11use crate::IndexIter;
12use crate::prelude::HasAnyOf;
13
14const FIND_TVSHOW_BY_ID_QUERY: &str = r#"select id, source, original_name, original_language, origin_country, poster_url, backdrop_url, homepage, name, language, overview, tagline, first_air_date, in_production, adult, tvshows.created_at, tvshows.updated_at
15from tvshows
16join tvshow_labels on tvshows.id = tvshow_labels.tvshow_id
17where tvshows.id = ? and tvshow_labels.language = ?
18limit 1"#;
19
20impl super::Pool {
21 async fn find_tvshow_by_id(
22 &self,
23 tvshow_id: u64,
24 language: Language,
25 ) -> anyhow::Result<Option<TvShow>> {
26 sqlx::query_as(FIND_TVSHOW_BY_ID_QUERY)
27 .bind(tvshow_id as i64)
28 .bind(language.as_str())
29 .fetch_optional(self.as_ref())
30 .await
31 .inspect(crate::record_optional)
32 .inspect_err(crate::record_error)
33 .map(Wrapper::maybe_inner)
34 .context("couldn't query tvshow by id")
35 }
36}
37
38const FIND_TVSHOW_BY_ORIGIN_QUERY: &str = r#"select id, source, original_name, original_language, origin_country, poster_url, backdrop_url, homepage, name, language, overview, tagline, first_air_date, in_production, adult, tvshows.created_at, tvshows.updated_at
39from tvshows
40join tvshow_labels on tvshows.id = tvshow_labels.tvshow_id
41where tvshows.source = ? and tvshow_labels.language = ?
42limit 1"#;
43
44impl super::Pool {
45 async fn find_tvshow_by_source(
46 &self,
47 source: TvShowSource,
48 language: Language,
49 ) -> anyhow::Result<Option<TvShow>> {
50 sqlx::query_as(FIND_TVSHOW_BY_ORIGIN_QUERY)
51 .bind(Wrapper(source))
52 .bind(language.as_str())
53 .fetch_optional(self.as_ref())
54 .await
55 .inspect(crate::record_optional)
56 .inspect_err(crate::record_error)
57 .map(Wrapper::maybe_inner)
58 .context("couldn't query tvshow by source")
59 }
60}
61
62const LIST_TVSHOW_QUERY: &str = r#"select id, source, original_name, original_language, origin_country, poster_url, backdrop_url, homepage, name, language, overview, tagline, first_air_date, in_production, adult, tvshows.created_at, tvshows.updated_at
63from tvshows
64join tvshow_labels on tvshows.id = tvshow_labels.tvshow_id"#;
65
66impl super::Pool {
67 #[tracing::instrument(
68 skip_all,
69 fields(
70 otel.kind = "client",
71 db.system = "sqlite",
72 db.name = "tvshow",
73 db.operation = "SELECT",
74 db.sql.table = "tvshows",
75 db.query.text = tracing::field::Empty,
76 db.response.returned_rows = tracing::field::Empty,
77 error.type = tracing::field::Empty,
78 error.message = tracing::field::Empty,
79 error.stacktrace = tracing::field::Empty,
80 ),
81 err(Debug),
82 )]
83 async fn list_tvshows<'a>(&self, params: ListTvShowParams<'a>) -> anyhow::Result<Vec<TvShow>> {
84 let mut qb = sqlx::QueryBuilder::new(LIST_TVSHOW_QUERY);
85 if params.filter.subscribed.is_some() {
86 qb.push(" left outer join user_tvshows on user_tvshows.tvshow_id = tvshows.id and user_tvshows.user_id = ").push_bind(params.user_id as i64);
87 }
88 qb.push(" where language = ")
89 .push_bind(Wrapper(params.language));
90 match params.filter.subscribed {
91 Some(true) => {
92 qb.push(" and user_tvshows.created_at is not null");
93 }
94 Some(false) => {
95 qb.push(" and user_tvshows.created_at is null");
96 }
97 None => {}
98 }
99 if !params.filter.tvshow_ids.is_empty() {
100 qb.push(" and ( ");
101 for (idx, tvshow_id) in params.filter.tvshow_ids.iter().enumerate() {
102 if idx > 0 {
103 qb.push(" or");
104 }
105 qb.push(" tvshows.id = ").push_bind(*tvshow_id as i64);
106 }
107 qb.push(" ) ");
108 }
109
110 qb.push(" limit ")
111 .push_bind(params.page.limit)
112 .push(" offset ")
113 .push_bind(params.page.offset);
114
115 tracing::Span::current().record("db.query.text", qb.sql());
116
117 qb.build_query_as()
118 .fetch_all(self.as_ref())
119 .await
120 .inspect(crate::record_all)
121 .inspect_err(crate::record_error)
122 .map(Wrapper::list)
123 .context("couldn't list tvshows")
124 }
125}
126
127impl super::Pool {
128 #[tracing::instrument(
129 skip_all,
130 fields(
131 otel.kind = "client",
132 db.system = "sqlite",
133 db.name = "tvshow",
134 db.operation = "SELECT",
135 db.sql.table = "tvshow_labels",
136 db.query.text = tracing::field::Empty,
137 db.response.returned_rows = tracing::field::Empty,
138 error.type = tracing::field::Empty,
139 error.message = tracing::field::Empty,
140 error.stacktrace = tracing::field::Empty,
141 ),
142 err(Debug),
143 )]
144 async fn list_tvshow_languages(
145 &self,
146 tvshow_ids: &[u64],
147 ) -> anyhow::Result<Vec<(u64, Language)>> {
148 let mut qb = sqlx::QueryBuilder::new("select tvshow_id, language from tvshow_labels");
149 if !tvshow_ids.is_empty() {
150 qb.push(" where ");
151 qb.push_any("tvshow_id", tvshow_ids);
152 }
153 tracing::Span::current().record("db.query.text", qb.sql());
154 qb.build_query_as()
155 .fetch_all(self.as_ref())
156 .await
157 .inspect(crate::record_all)
158 .inspect_err(crate::record_error)
159 .map(|list: Vec<(u64, Wrapper<Language>)>| {
160 list.into_iter()
161 .map(|(tvshow_id, lang)| (tvshow_id, lang.inner()))
162 .collect()
163 })
164 .context("couldn't query tvshow languages")
165 }
166}
167
168const UPSERT_TVSHOW_QUERY: &str = r#"insert into tvshows (source, original_name, original_name_slug, original_language, origin_country, homepage, first_air_date, in_production, adult)
169values (?, ?, ?, ?, ?, ?, ?, ?, ?)
170on conflict (source) do update set
171 original_name = excluded.original_name,
172 original_name_slug = excluded.original_name_slug,
173 original_language = excluded.original_language,
174 origin_country = excluded.origin_country,
175 homepage = excluded.homepage,
176 first_air_date = excluded.first_air_date,
177 in_production = excluded.in_production,
178 adult = excluded.adult,
179 updated_at = current_timestamp
180returning id, source, original_name, original_language, origin_country, homepage, first_air_date, in_production, adult, created_at, updated_at"#;
181
182const UPSERT_TVSHOW_LABEL_QUERY: &str = r#"insert into tvshow_labels (tvshow_id, language, name, name_slug, overview, tagline, poster_url, backdrop_url)
183values (?, ?, ?, ?, ?, ?, ?, ?)
184on conflict (tvshow_id, language) do update set
185 name = excluded.name,
186 name_slug = excluded.name_slug,
187 overview = excluded.overview,
188 tagline = excluded.tagline,
189 poster_url = excluded.poster_url,
190 backdrop_url = excluded.backdrop_url,
191 updated_at = current_timestamp
192returning language, name, overview, tagline, poster_url, backdrop_url"#;
193
194impl super::Pool {
195 #[tracing::instrument(
196 skip_all,
197 fields(
198 otel.kind = "client",
199 db.system = "sqlite",
200 db.name = "tvshow",
201 db.operation = "INSERT",
202 db.sql.table = "tvshows",
203 db.query.text = UPSERT_TVSHOW_QUERY,
204 db.response.returned_rows = tracing::field::Empty,
205 error.type = tracing::field::Empty,
206 error.message = tracing::field::Empty,
207 error.stacktrace = tracing::field::Empty,
208 ),
209 err(Debug),
210 )]
211 async fn upsert_tvshow_row<'c, E: sqlx::SqliteExecutor<'c>>(
212 &self,
213 executor: E,
214 input: &ExternalTvShow,
215 ) -> anyhow::Result<TvShowRow> {
216 sqlx::query_as(UPSERT_TVSHOW_QUERY)
217 .bind(Wrapper(input.source))
218 .bind(&input.original_name)
219 .bind(slug::slugify(input.original_name.as_str()))
220 .bind(&input.original_language)
221 .bind(Json(&input.origin_country))
222 .bind(&input.homepage)
223 .bind(input.first_air_date)
224 .bind(input.in_production)
225 .bind(input.adult)
226 .fetch_one(executor)
227 .await
228 .inspect(crate::record_one)
229 .inspect_err(crate::record_error)
230 .context("couldn't upsert tvshow")
231 }
232
233 #[tracing::instrument(
234 skip_all,
235 fields(
236 otel.kind = "client",
237 db.system = "sqlite",
238 db.name = "tvshow",
239 db.operation = "INSERT",
240 db.sql.table = "tvshows",
241 db.query.text = UPSERT_TVSHOW_LABEL_QUERY,
242 db.response.returned_rows = tracing::field::Empty,
243 error.type = tracing::field::Empty,
244 error.message = tracing::field::Empty,
245 error.stacktrace = tracing::field::Empty,
246 ),
247 err(Debug),
248 )]
249 async fn upsert_tvshow_label_row<'c, E: sqlx::SqliteExecutor<'c>>(
250 &self,
251 executor: E,
252 tvshow_id: u64,
253 input: &ExternalTvShow,
254 ) -> anyhow::Result<TvShowLabelRow> {
255 sqlx::query_as(UPSERT_TVSHOW_LABEL_QUERY)
256 .bind(tvshow_id as i64)
257 .bind(input.language.as_str())
258 .bind(&input.name)
259 .bind(slug::slugify(input.name.as_str()))
260 .bind(&input.overview)
261 .bind(&input.tagline)
262 .bind(&input.poster_url)
263 .bind(&input.backdrop_url)
264 .fetch_one(executor)
265 .await
266 .inspect(crate::record_one)
267 .inspect_err(crate::record_error)
268 .context("couldn't upsert tvshow label")
269 }
270
271 pub(crate) async fn upsert_tvshow(&self, input: &ExternalTvShow) -> anyhow::Result<TvShow> {
272 let mut tx = self
273 .as_ref()
274 .begin()
275 .await
276 .context("unable to create transaction")?;
277 let tvshow = self
278 .upsert_tvshow_row(&mut *tx, input)
279 .await
280 .context("unable to upsert tvshow base")?;
281 let tvshow_label = self
282 .upsert_tvshow_label_row(&mut *tx, tvshow.id, input)
283 .await
284 .context("unable to upsert tvshow labels")?;
285 tx.commit().await?;
286 Ok(Wrapper::<TvShow>::from((tvshow, tvshow_label)).inner())
287 }
288}
289
290impl entertainarr_domain::tvshow::prelude::TvShowRepository for super::Pool {
291 async fn find_by_id(
292 &self,
293 tvshow_id: u64,
294 language: Language,
295 ) -> anyhow::Result<Option<TvShow>> {
296 self.find_tvshow_by_id(tvshow_id, language).await
297 }
298
299 async fn find_by_source(
300 &self,
301 source: TvShowSource,
302 language: Language,
303 ) -> anyhow::Result<Option<TvShow>> {
304 self.find_tvshow_by_source(source, language).await
305 }
306
307 async fn list<'a>(&self, params: ListTvShowParams<'a>) -> anyhow::Result<Vec<TvShow>> {
308 self.list_tvshows(params).await
309 }
310
311 async fn list_languages(&self, tvshow_ids: &[u64]) -> anyhow::Result<Vec<(u64, Language)>> {
312 self.list_tvshow_languages(tvshow_ids).await
313 }
314
315 async fn upsert(&self, input: &ExternalTvShow) -> anyhow::Result<TvShow> {
316 self.upsert_tvshow(input).await
317 }
318}
319
320impl crate::Pool {
321 const CREATE_TVSHOW_SUBSCRIPTION_QUERY: &str =
322 r#"insert into user_tvshows (user_id, tvshow_id) values (?, ?) on conflict do nothing"#;
323
324 #[tracing::instrument(
325 skip_all,
326 fields(
327 otel.kind = "client",
328 db.system = "sqlite",
329 db.name = "tvshow",
330 db.operation = "INSERT",
331 db.sql.table = "tvshow_subscriptions",
332 db.query.text = Self::CREATE_TVSHOW_SUBSCRIPTION_QUERY,
333 db.response.returned_rows = tracing::field::Empty,
334 error.type = tracing::field::Empty,
335 error.message = tracing::field::Empty,
336 error.stacktrace = tracing::field::Empty,
337 ),
338 err(Debug),
339 )]
340 async fn create_tvshow_subscription(&self, user_id: u64, tvshow_id: u64) -> anyhow::Result<()> {
341 sqlx::query(Self::CREATE_TVSHOW_SUBSCRIPTION_QUERY)
342 .bind(user_id as i64)
343 .bind(tvshow_id as i64)
344 .execute(self.as_ref())
345 .await
346 .inspect_err(crate::record_error)
347 .map(|_| ())
348 .context("unable to create tvshow subscription")
349 }
350}
351
352impl crate::Pool {
353 const DELETE_TVSHOW_SUBSCRIPTION_QUERY: &str =
354 r#"delete from user_tvshows where user_id = ? and tvshow_id = ?"#;
355
356 #[tracing::instrument(
357 skip_all,
358 fields(
359 otel.kind = "client",
360 db.system = "sqlite",
361 db.name = "tvshow",
362 db.operation = "DELETE",
363 db.sql.table = "user_tvshows",
364 db.query.text = Self::DELETE_TVSHOW_SUBSCRIPTION_QUERY,
365 db.response.returned_rows = tracing::field::Empty,
366 error.type = tracing::field::Empty,
367 error.message = tracing::field::Empty,
368 error.stacktrace = tracing::field::Empty,
369 ),
370 err(Debug),
371 )]
372 async fn delete_tvshow_subscription(&self, user_id: u64, tvshow_id: u64) -> anyhow::Result<()> {
373 sqlx::query(Self::DELETE_TVSHOW_SUBSCRIPTION_QUERY)
374 .bind(user_id as i64)
375 .bind(tvshow_id as i64)
376 .execute(self.as_ref())
377 .await
378 .inspect_err(crate::record_error)
379 .map(|_| ())
380 .context("unable to delete tvshow subscription")
381 }
382}
383
384impl crate::Pool {
385 const LIST_TVSHOW_SUBSCRIPTION_QUERY: &str = r#"select
386 tvshows.id,
387 tvshows.source,
388 tvshows.original_name,
389 tvshows.original_language,
390 tvshows.origin_country,
391 tvshow_labels.poster_url,
392 tvshow_labels.backdrop_url,
393 tvshows.homepage,
394 tvshow_labels.name,
395 tvshow_labels.language,
396 tvshow_labels.overview,
397 tvshow_labels.tagline,
398 tvshows.first_air_date,
399 tvshows.in_production,
400 tvshows.adult,
401 tvshows.created_at,
402 tvshows.updated_at,
403 user_tvshows.user_id,
404 user_tvshows.tvshow_id,
405 user_tvshows.created_at
406from user_tvshows
407join tvshows on user_tvshows.tvshow_id = tvshows.id
408join tvshow_labels on tvshows.id = tvshow_labels.tvshow_id
409 and tvshow_labels.language = ?
410where user_tvshows.user_id = ?"#;
411
412 #[tracing::instrument(
413 skip_all,
414 fields(
415 otel.kind = "client",
416 db.system = "sqlite",
417 db.name = "tvshow",
418 db.operation = "select",
419 db.sql.table = "user_tvshows",
420 db.query.text = Self::LIST_TVSHOW_SUBSCRIPTION_QUERY,
421 db.response.returned_rows = tracing::field::Empty,
422 error.type = tracing::field::Empty,
423 error.message = tracing::field::Empty,
424 error.stacktrace = tracing::field::Empty,
425 ),
426 err(Debug),
427 )]
428 async fn list_tvshow_subscription(
429 &self,
430 user_id: u64,
431 language: Language,
432 ) -> anyhow::Result<Vec<TvShowWithSubscription>> {
433 sqlx::query_as(Self::LIST_TVSHOW_SUBSCRIPTION_QUERY)
434 .bind(Wrapper(language))
435 .bind(user_id as i64)
436 .fetch_all(self.as_ref())
437 .await
438 .inspect(crate::record_all)
439 .inspect_err(crate::record_error)
440 .map(Wrapper::list)
441 .context("unable to list tvshow subscription")
442 }
443}
444
445impl crate::Pool {
446 #[tracing::instrument(
447 skip_all,
448 fields(
449 otel.kind = "client",
450 db.system = "sqlite",
451 db.name = "tvshow",
452 db.operation = "select",
453 db.sql.table = "user_tvshows",
454 db.query.text = tracing::field::Empty,
455 db.response.returned_rows = tracing::field::Empty,
456 error.type = tracing::field::Empty,
457 error.message = tracing::field::Empty,
458 error.stacktrace = tracing::field::Empty,
459 ),
460 err(Debug),
461 )]
462 async fn list_tvshow_subscription_by_ids(
463 &self,
464 user_id: u64,
465 tvshow_ids: &[u64],
466 ) -> anyhow::Result<Vec<TvShowSubscription>> {
467 if tvshow_ids.is_empty() {
468 return Ok(Vec::default());
469 }
470 let mut qb = sqlx::QueryBuilder::new(
471 "select user_tvshows.user_id, user_tvshows.tvshow_id, user_tvshows.created_at from user_tvshows",
472 );
473 qb.push(" where user_tvshows.user_id = ")
474 .push_bind(user_id as i64)
475 .push("and ");
476 qb.push_any("user_tvshows.tvshow_id", tvshow_ids);
477 tracing::Span::current().record("db.query.text", qb.sql());
478 qb.build_query_as()
479 .fetch_all(self.as_ref())
480 .await
481 .inspect(crate::record_all)
482 .inspect_err(crate::record_error)
483 .map(Wrapper::list)
484 .context("unable to fetch tvshow subscription")
485 }
486}
487
488impl entertainarr_domain::tvshow::prelude::TvShowSubscriptionRepository for crate::Pool {
489 async fn list_by_ids(
490 &self,
491 user_id: u64,
492 tvshow_ids: &[u64],
493 ) -> anyhow::Result<Vec<TvShowSubscription>> {
494 self.list_tvshow_subscription_by_ids(user_id, tvshow_ids)
495 .await
496 }
497
498 async fn create(&self, user_id: u64, tvshow_id: u64) -> anyhow::Result<()> {
499 self.create_tvshow_subscription(user_id, tvshow_id).await
500 }
501
502 async fn delete(&self, user_id: u64, tvshow_id: u64) -> anyhow::Result<()> {
503 self.delete_tvshow_subscription(user_id, tvshow_id).await
504 }
505
506 async fn list(
507 &self,
508 user_id: u64,
509 language: Language,
510 ) -> anyhow::Result<Vec<TvShowWithSubscription>> {
511 self.list_tvshow_subscription(user_id, language).await
512 }
513}
514
515impl<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow> for super::Wrapper<TvShowSubscription> {
516 fn from_row(row: &'r sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
517 let mut idx = IndexIter::default();
518
519 Wrapper::<TvShowSubscription>::from_row_index(row, &mut idx)
520 }
521}
522
523impl<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow> for super::Wrapper<TvShowWithSubscription> {
524 fn from_row(row: &'r sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
525 let mut idx = IndexIter::default();
526
527 let Wrapper(tvshow) = Wrapper::<TvShow>::from_row_index(row, &mut idx)?;
528 let Wrapper(subscription) = Wrapper::<TvShowSubscription>::from_row_index(row, &mut idx)?;
529
530 Ok(super::Wrapper(TvShowWithSubscription {
531 tvshow,
532 subscription,
533 }))
534 }
535}
536
537impl super::Wrapper<TvShow> {
538 fn from_row_index(
539 row: &sqlx::sqlite::SqliteRow,
540 idx: &mut IndexIter,
541 ) -> Result<Self, sqlx::Error> {
542 use sqlx::Row;
543
544 Ok(Self(TvShow {
545 id: row.try_get(idx.next())?,
546 source: row.try_get(idx.next()).map(Wrapper::inner)?,
547 original_name: row.try_get(idx.next())?,
548 original_language: row.try_get(idx.next())?,
549 origin_country: row.try_get(idx.next()).map(|Json(value)| value)?,
550 poster_url: row.try_get(idx.next())?,
551 backdrop_url: row.try_get(idx.next())?,
552 homepage: row.try_get(idx.next())?,
553 name: row.try_get(idx.next())?,
554 language: row.try_get(idx.next()).map(Wrapper::inner)?,
555 overview: row.try_get(idx.next())?,
556 tagline: row.try_get(idx.next())?,
557 first_air_date: row.try_get(idx.next())?,
558 in_production: row.try_get(idx.next())?,
559 adult: row.try_get(idx.next())?,
560 created_at: row.try_get(idx.next())?,
561 updated_at: row.try_get(idx.next())?,
562 }))
563 }
564}
565
566impl super::Wrapper<TvShowSubscription> {
567 fn from_row_index(
568 row: &sqlx::sqlite::SqliteRow,
569 idx: &mut IndexIter,
570 ) -> Result<Self, sqlx::Error> {
571 use sqlx::Row;
572
573 Ok(Wrapper(TvShowSubscription {
574 user_id: row.try_get(idx.next())?,
575 tvshow_id: row.try_get(idx.next())?,
576 created_at: row.try_get(idx.next())?,
577 }))
578 }
579}
580
581impl std::fmt::Display for Wrapper<TvShowSource> {
582 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583 match self.0 {
584 TvShowSource::Tmdb { tmdb_id } => write!(f, "tmdb:{tmdb_id}"),
585 }
586 }
587}
588
589#[derive(Debug, thiserror::Error)]
590#[allow(clippy::large_enum_variant)]
591pub enum InvalidTvShowSource {
592 #[error("invalid tvshow source format {input:?}")]
593 Format { input: String },
594 #[error("invalid tvshow source kind {input:?}")]
595 SourceKind { input: String },
596 #[error("invalid tvshow source value {input:?}")]
597 Value {
598 input: String,
599 #[source]
600 error: anyhow::Error,
601 },
602}
603
604impl std::str::FromStr for Wrapper<TvShowSource> {
605 type Err = InvalidTvShowSource;
606
607 fn from_str(input: &str) -> Result<Self, Self::Err> {
608 let Some((kind, value)) = input.split_once(':') else {
609 return Err(InvalidTvShowSource::Format {
610 input: input.to_string(),
611 });
612 };
613 match kind {
614 "tmdb" => value
615 .parse::<u64>()
616 .map(|tmdb_id| Wrapper(TvShowSource::Tmdb { tmdb_id }))
617 .map_err(|error| InvalidTvShowSource::Value {
618 input: input.to_string(),
619 error: anyhow::Error::from(error),
620 }),
621 other => Err(InvalidTvShowSource::SourceKind {
622 input: other.to_string(),
623 }),
624 }
625 }
626}
627
628impl sqlx::Type<sqlx::Sqlite> for Wrapper<TvShowSource> {
629 fn type_info() -> <sqlx::Sqlite as sqlx::Database>::TypeInfo {
630 <String as sqlx::Type<sqlx::Sqlite>>::type_info()
631 }
632
633 fn compatible(ty: &<sqlx::Sqlite as sqlx::Database>::TypeInfo) -> bool {
634 <String as sqlx::Type<sqlx::Sqlite>>::compatible(ty)
635 }
636}
637
638impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for Wrapper<TvShowSource> {
639 fn encode_by_ref(
640 &self,
641 buf: &mut <sqlx::Sqlite as sqlx::Database>::ArgumentBuffer<'q>,
642 ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
643 <String as sqlx::Encode<'q, sqlx::Sqlite>>::encode(self.to_string(), buf)
644 }
645}
646
647impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for Wrapper<TvShowSource> {
648 fn decode(
649 value: <sqlx::Sqlite as sqlx::Database>::ValueRef<'r>,
650 ) -> Result<Self, sqlx::error::BoxDynError> {
651 use std::str::FromStr;
652
653 <String as sqlx::Decode<'r, sqlx::Sqlite>>::decode(value).and_then(|value| {
654 Wrapper::<TvShowSource>::from_str(value.as_str())
655 .map_err(|err| Box::new(err) as Box<dyn std::error::Error + Send + Sync>)
656 })
657 }
658}
659
660struct TvShowRow {
661 id: u64,
662 source: TvShowSource,
663 original_name: String,
664 original_language: String,
665 origin_country: Vec<String>,
666 homepage: String,
667 first_air_date: Option<NaiveDate>,
668 in_production: bool,
669 adult: bool,
670 created_at: DateTime<Utc>,
671 updated_at: DateTime<Utc>,
672}
673
674impl<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow> for TvShowRow {
675 fn from_row(row: &'r sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
676 use sqlx::Row;
677
678 Ok(TvShowRow {
679 id: row.try_get(0)?,
680 source: row.try_get(1).map(Wrapper::inner)?,
681 original_name: row.try_get(2)?,
682 original_language: row.try_get(3)?,
683 origin_country: row.try_get(4).map(|Json(value)| value)?,
684 homepage: row.try_get(5)?,
685 first_air_date: row.try_get(6)?,
686 in_production: row.try_get(7)?,
687 adult: row.try_get(8)?,
688 created_at: row.try_get(9)?,
689 updated_at: row.try_get(10)?,
690 })
691 }
692}
693
694struct TvShowLabelRow {
695 language: Language,
696 name: String,
697 overview: Option<String>,
698 tagline: Option<String>,
699 poster_url: Option<String>,
700 backdrop_url: Option<String>,
701}
702
703impl<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow> for TvShowLabelRow {
704 fn from_row(row: &'r sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
705 use sqlx::Row;
706
707 let mut idx = IndexIter::default();
708
709 Ok(TvShowLabelRow {
710 language: row.try_get(idx.next()).map(Wrapper::inner)?,
711 name: row.try_get(idx.next())?,
712 overview: row.try_get(idx.next())?,
713 tagline: row.try_get(idx.next())?,
714 poster_url: row.try_get(idx.next())?,
715 backdrop_url: row.try_get(idx.next())?,
716 })
717 }
718}
719
720impl From<(TvShowRow, TvShowLabelRow)> for Wrapper<TvShow> {
721 fn from((tvshow, label): (TvShowRow, TvShowLabelRow)) -> Wrapper<TvShow> {
722 Wrapper(TvShow {
723 id: tvshow.id,
724 source: tvshow.source,
725 original_name: tvshow.original_name,
726 original_language: tvshow.original_language,
727 origin_country: tvshow.origin_country,
728 poster_url: label.poster_url,
729 backdrop_url: label.backdrop_url,
730 homepage: tvshow.homepage,
731 name: label.name,
732 language: label.language,
733 overview: label.overview,
734 tagline: label.tagline,
735 first_air_date: tvshow.first_air_date,
736 in_production: tvshow.in_production,
737 adult: tvshow.adult,
738 created_at: tvshow.created_at,
739 updated_at: tvshow.updated_at,
740 })
741 }
742}
743
744impl<'r> sqlx::FromRow<'r, sqlx::sqlite::SqliteRow> for Wrapper<TvShow> {
745 fn from_row(row: &'r sqlx::sqlite::SqliteRow) -> Result<Self, sqlx::Error> {
746 let mut idx = IndexIter::default();
747
748 Wrapper::<TvShow>::from_row_index(row, &mut idx)
749 }
750}
751
752#[cfg(test)]
753pub mod tests {
754 use entertainarr_domain::language::Language;
755 use entertainarr_domain::tvshow::entity::{ExternalTvShow, TvShowSource};
756 use entertainarr_domain::tvshow::prelude::TvShowRepository;
757 use sqlx::types::chrono::NaiveDate;
758
759 pub fn breaking_bad() -> ExternalTvShow {
760 ExternalTvShow {
761 source: TvShowSource::Tmdb { tmdb_id: 1396 },
762 original_name: "Breaking Bad".into(),
763 original_language: "en".into(),
764 origin_country: vec!["US".into()],
765 homepage: "https://www.sonypictures.com/tv/breakingbad".into(),
766 name: "Breaking Bad".into(),
767 language: Language::En,
768 overview: Some("Walter White, a New Mexico chemistry teacher, is diagnosed with Stage III cancer and given a prognosis of only two years left to live. He becomes filled with a sense of fearlessness and an unrelenting desire to secure his family's financial future at any cost as he enters the dangerous world of drugs and crime.".into()),
769 tagline: Some("Change the equation.".into()),
770 backdrop_url: Some("https://...".into()),
771 poster_url: Some("https://...".into()),
772 first_air_date: NaiveDate::from_ymd_opt(2008, 1, 20),
773 number_of_seasons: 0,
774 in_production: false,
775 adult: false,
776 popularity: 153.4357,
777 vote_average: 8.935,
778 vote_count: 16519,
779 }
780 }
781
782 #[tokio::test]
783 async fn should_upsert_missing_tvshow() {
784 let tmpdir = tempfile::tempdir().unwrap();
785 let pool = crate::Pool::test(&tmpdir.path().join("db")).await;
786
787 let value = pool.upsert(&breaking_bad()).await.unwrap();
788 assert_eq!(value.language, Language::En);
789 }
790
791 #[tokio::test]
792 async fn should_upsert_existing_tvshow() {
793 let tmpdir = tempfile::tempdir().unwrap();
794 let pool = crate::Pool::test(&tmpdir.path().join("db")).await;
795
796 let value = pool
797 .upsert(&ExternalTvShow {
798 source: TvShowSource::Tmdb { tmdb_id: 1396 },
799 original_name: "Breaking Bad".into(),
800 original_language: "en".into(),
801 origin_country: vec!["US".into()],
802 homepage: "https://www.sonypictures.com/tv/breakingbad".into(),
803 name: "Breaking Bad".into(),
804 language: Language::En,
805 overview: Some("Old overview...".into()),
806 tagline: None,
807 backdrop_url: None,
808 poster_url: None,
809 first_air_date: None,
810 number_of_seasons: 0,
811 in_production: false,
812 adult: false,
813 popularity: 153.4357,
814 vote_average: 8.935,
815 vote_count: 16519,
816 })
817 .await
818 .unwrap();
819 assert_eq!(value.language, Language::En);
820
821 let value = pool.upsert(&breaking_bad()).await.unwrap();
822 assert_eq!(value.tagline.unwrap(), "Change the equation.");
823 }
824
825 #[tokio::test]
826 async fn should_find_by_source() {
827 let tmpdir = tempfile::tempdir().unwrap();
828 let pool = crate::Pool::test(&tmpdir.path().join("db")).await;
829
830 let value = pool.upsert(&breaking_bad()).await.unwrap();
831 assert_eq!(value.language, Language::En);
832
833 let value = pool
834 .find_tvshow_by_source(TvShowSource::Tmdb { tmdb_id: 1396 }, Language::En)
835 .await
836 .unwrap()
837 .unwrap();
838 assert_eq!(value.tagline.unwrap(), "Change the equation.");
839 }
840
841 #[tokio::test]
842 async fn should_find_tvshow_by_id() {
843 let tmpdir = tempfile::tempdir().unwrap();
844 let pool = crate::Pool::test(&tmpdir.path().join("db")).await;
845
846 let value = pool.upsert(&breaking_bad()).await.unwrap();
847 assert_eq!(value.language, Language::En);
848
849 let value = pool
850 .find_tvshow_by_id(value.id, Language::En)
851 .await
852 .unwrap()
853 .unwrap();
854 assert_eq!(value.tagline.unwrap(), "Change the equation.");
855 }
856
857 #[tokio::test]
858 async fn should_find_tvshow_subscriptions() {
859 let tmpdir = tempfile::tempdir().unwrap();
860 let pool = crate::Pool::test(&tmpdir.path().join("db")).await;
861
862 let user = pool
863 .create_user("user@example.com", "password")
864 .await
865 .unwrap();
866
867 let value = pool.upsert(&breaking_bad()).await.unwrap();
868 assert_eq!(value.language, Language::En);
869
870 pool.create_tvshow_subscription(user.id, value.id)
871 .await
872 .unwrap();
873
874 let list = pool
875 .list_tvshow_subscription(user.id, Language::En)
876 .await
877 .unwrap();
878 assert_eq!(list.len(), 1);
879 }
880}