use async_graphql::SimpleObject; use async_trait::async_trait; use sea_orm::{ ActiveValue, Condition, FromJsonQueryResult, FromQueryResult, IntoSimpleExpr, JoinType, QuerySelect, entity::prelude::*, sea_query::{Alias, IntoCondition, OnConflict}, }; use serde::{Deserialize, Serialize}; use super::subscription_bangumi; use crate::{ app::AppContextTrait, errors::RecorderResult, extract::{ mikan::{ MikanBangumiHash, MikanBangumiMeta, build_mikan_bangumi_subscription_rss_url, scrape_mikan_poster_meta_from_image_url, }, origin::{OriginCompTrait, SeasonComp}, }, }; #[derive( Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult, SimpleObject, )] pub struct BangumiFilter { pub name: Option>, pub group: Option>, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, EnumIter, DeriveActiveEnum)] #[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "bangumi_type")] pub enum BangumiType { #[sea_orm(string_value = "mikan")] Mikan, } #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "bangumi")] pub struct Model { #[sea_orm(default_expr = "Expr::current_timestamp()")] pub created_at: DateTimeUtc, #[sea_orm(default_expr = "Expr::current_timestamp()")] pub updated_at: DateTimeUtc, #[sea_orm(primary_key)] pub id: i32, pub mikan_bangumi_id: Option, pub bangumi_type: BangumiType, pub subscriber_id: i32, pub display_name: String, pub origin_name: String, pub season: i32, pub season_raw: Option, pub fansub: Option, pub mikan_fansub_id: Option, pub filter: Option, pub rss_link: Option, pub poster_link: Option, pub origin_poster_link: Option, pub homepage: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] pub enum Relation { #[sea_orm(has_many = "super::subscriptions::Entity")] Subscription, #[sea_orm( belongs_to = "super::subscribers::Entity", from = "Column::SubscriberId", to = "super::subscribers::Column::Id", on_update = "Cascade", on_delete = "Cascade" )] Subscriber, #[sea_orm(has_many = "super::episodes::Entity")] Episode, #[sea_orm(has_many = "super::subscription_bangumi::Entity")] SubscriptionBangumi, } impl Related for Entity { fn to() -> RelationDef { Relation::Episode.def() } } impl Related for Entity { fn to() -> RelationDef { Relation::SubscriptionBangumi.def() } } impl Related for Entity { fn to() -> RelationDef { super::subscription_bangumi::Relation::Subscription.def() } fn via() -> Option { Some(super::subscription_bangumi::Relation::Bangumi.def().rev()) } } impl Related for Entity { fn to() -> RelationDef { Relation::Subscriber.def() } } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)] pub enum RelatedEntity { #[sea_orm(entity = "super::subscriptions::Entity")] Subscription, #[sea_orm(entity = "super::subscribers::Entity")] Subscriber, #[sea_orm(entity = "super::episodes::Entity")] Episode, #[sea_orm(entity = "super::subscription_bangumi::Entity")] SubscriptionBangumi, } impl ActiveModel { #[tracing::instrument(err, skip_all, fields(mikan_bangumi_id = %meta.mikan_bangumi_id, mikan_fansub_id = %meta.mikan_fansub_id, subscriber_id = %subscriber_id))] pub async fn from_mikan_bangumi_meta( ctx: &dyn AppContextTrait, meta: MikanBangumiMeta, subscriber_id: i32, _subscription_id: i32, ) -> RecorderResult { let mikan_client = ctx.mikan(); let mikan_base_url = mikan_client.base_url(); let season_comp = SeasonComp::parse_comp(&meta.bangumi_title) .ok() .map(|(_, s)| s); let season_index = season_comp.as_ref().map(|s| s.num).unwrap_or(1); let season_raw = season_comp.map(|s| s.source.into_owned()); let rss_url = build_mikan_bangumi_subscription_rss_url( mikan_base_url.clone(), &meta.mikan_bangumi_id, Some(&meta.mikan_fansub_id), ); let poster_link = if let Some(origin_poster_src) = meta.origin_poster_src.clone() { let poster_meta = scrape_mikan_poster_meta_from_image_url(ctx, origin_poster_src).await?; poster_meta.poster_src } else { None }; Ok(Self { mikan_bangumi_id: ActiveValue::Set(Some(meta.mikan_bangumi_id)), mikan_fansub_id: ActiveValue::Set(Some(meta.mikan_fansub_id)), subscriber_id: ActiveValue::Set(subscriber_id), display_name: ActiveValue::Set(meta.bangumi_title.clone()), origin_name: ActiveValue::Set(meta.bangumi_title), season: ActiveValue::Set(season_index), season_raw: ActiveValue::Set(season_raw), fansub: ActiveValue::Set(Some(meta.fansub)), poster_link: ActiveValue::Set(poster_link), origin_poster_link: ActiveValue::Set(meta.origin_poster_src.map(|src| src.to_string())), homepage: ActiveValue::Set(Some(meta.homepage.to_string())), rss_link: ActiveValue::Set(Some(rss_url.to_string())), ..Default::default() }) } } #[async_trait] impl ActiveModelBehavior for ActiveModel {} impl Model { pub async fn get_or_insert_from_mikan( ctx: &dyn AppContextTrait, hash: MikanBangumiHash, subscriber_id: i32, subscription_id: i32, create_bangumi_fn: F, ) -> RecorderResult where F: AsyncFnOnce() -> RecorderResult, { #[derive(FromQueryResult)] struct ModelWithIsSubscribed { #[sea_orm(nested)] bangumi: Model, is_subscribed: bool, } let db = ctx.db(); let subscription_bangumi_alias = Alias::new("sb"); let mut is_subscribed = false; let new_bangumi_model = if let Some(existed) = Entity::find() .filter( Condition::all() .add(Column::MikanBangumiId.eq(Some(hash.mikan_bangumi_id))) .add(Column::MikanFansubId.eq(Some(hash.mikan_fansub_id))) .add(Column::SubscriberId.eq(subscriber_id)), ) .column_as( Expr::col(( subscription_bangumi_alias.clone(), subscription_bangumi::Column::SubscriptionId, )) .is_not_null(), "is_subscribed", ) .join_as_rev( JoinType::LeftJoin, subscription_bangumi::Relation::Bangumi .def() .on_condition(move |left, _right| { Expr::col((left, subscription_bangumi::Column::SubscriptionId)) .eq(subscription_id) .into_condition() }), subscription_bangumi_alias.clone(), ) .into_model::() .one(db) .await? { is_subscribed = existed.is_subscribed; existed.bangumi } else { let new_bangumi_active_model = create_bangumi_fn().await?; Entity::insert(new_bangumi_active_model) .on_conflict( OnConflict::columns([ Column::MikanBangumiId, Column::MikanFansubId, Column::SubscriberId, ]) .update_columns([ Column::OriginName, Column::Fansub, Column::PosterLink, Column::OriginPosterLink, Column::Season, Column::SeasonRaw, Column::RssLink, Column::Homepage, ]) .to_owned(), ) .exec_with_returning(db) .await? }; if !is_subscribed { subscription_bangumi::Entity::insert(subscription_bangumi::ActiveModel { subscription_id: ActiveValue::Set(subscription_id), bangumi_id: ActiveValue::Set(new_bangumi_model.id), subscriber_id: ActiveValue::Set(subscriber_id), ..Default::default() }) .on_conflict( OnConflict::columns([ subscription_bangumi::Column::SubscriptionId, subscription_bangumi::Column::BangumiId, ]) .do_nothing() .to_owned(), ) .exec_without_returning(db) .await?; } Ok(new_bangumi_model) } pub async fn get_existed_mikan_bangumi_list( ctx: &dyn AppContextTrait, hashes: impl Iterator, subscriber_id: i32, _subscription_id: i32, ) -> RecorderResult> { Ok(Entity::find() .select_only() .column(Column::Id) .column(Column::MikanBangumiId) .column(Column::MikanFansubId) .filter( Expr::tuple([ Column::MikanBangumiId.into_simple_expr(), Column::MikanFansubId.into_simple_expr(), Column::SubscriberId.into_simple_expr(), ]) .in_tuples(hashes.map(|hash| { ( hash.mikan_bangumi_id.clone(), hash.mikan_fansub_id.clone(), subscriber_id, ) })), ) .into_tuple::<(i32, String, String)>() .all(ctx.db()) .await? .into_iter() .map(|(bangumi_id, mikan_bangumi_id, mikan_fansub_id)| { ( bangumi_id, MikanBangumiHash { mikan_bangumi_id, mikan_fansub_id, }, ) })) } pub async fn get_subsribed_bangumi_list_from_subscription( ctx: &dyn AppContextTrait, subscription_id: i32, ) -> RecorderResult> { let db = ctx.db(); let bangumi_list = Entity::find() .filter( Condition::all() .add(subscription_bangumi::Column::SubscriptionId.eq(subscription_id)), ) .join_rev( JoinType::InnerJoin, subscription_bangumi::Relation::Bangumi.def(), ) .all(db) .await?; Ok(bangumi_list) } }