entertainarr_adapter_sqlite/
tvshow.rs

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}