diff --git a/apps/recorder/examples/mikan_doppel_season_subscription.rs b/apps/recorder/examples/mikan_doppel_season_subscription.rs index d534a4b..146f96b 100644 --- a/apps/recorder/examples/mikan_doppel_season_subscription.rs +++ b/apps/recorder/examples/mikan_doppel_season_subscription.rs @@ -212,7 +212,7 @@ async fn main() -> Result<()> { }; } { - let episode_torrent_url = rss_item.url; + let episode_torrent_url = rss_item.torrent_link; let episode_torrent_doppel_path = MikanDoppelPath::new(episode_torrent_url.clone()); tracing::info!(title = rss_item.title, "Scraping episode torrent..."); if !episode_torrent_doppel_path.exists_any() { diff --git a/apps/recorder/examples/mikan_doppel_subscriber_subscription.rs b/apps/recorder/examples/mikan_doppel_subscriber_subscription.rs index a4e3dfb..ee5e1a6 100644 --- a/apps/recorder/examples/mikan_doppel_subscriber_subscription.rs +++ b/apps/recorder/examples/mikan_doppel_subscriber_subscription.rs @@ -72,7 +72,7 @@ async fn main() -> RecorderResult<()> { }?; { - let episode_torrent_url = rss_item.url; + let episode_torrent_url = rss_item.torrent_link; let episode_torrent_doppel_path = MikanDoppelPath::new(episode_torrent_url.clone()); tracing::info!(title = rss_item.title, "Scraping episode torrent..."); if !episode_torrent_doppel_path.exists_any() { @@ -173,7 +173,7 @@ async fn main() -> RecorderResult<()> { }; { - let episode_torrent_url = rss_item.url; + let episode_torrent_url = rss_item.torrent_link; let episode_torrent_doppel_path = MikanDoppelPath::new(episode_torrent_url.clone()); tracing::info!(title = rss_item.title, "Scraping episode torrent..."); diff --git a/apps/recorder/src/app/core.rs b/apps/recorder/src/app/core.rs index 4730f41..22d7f70 100644 --- a/apps/recorder/src/app/core.rs +++ b/apps/recorder/src/app/core.rs @@ -13,6 +13,8 @@ use crate::{ }, }; +pub const PROJECT_NAME: &str = "konobangu"; + pub struct App { pub context: Arc, pub builder: AppBuilder, diff --git a/apps/recorder/src/app/mod.rs b/apps/recorder/src/app/mod.rs index 50b39bd..9bce8a7 100644 --- a/apps/recorder/src/app/mod.rs +++ b/apps/recorder/src/app/mod.rs @@ -4,7 +4,7 @@ pub mod context; pub mod core; pub mod env; -pub use core::App; +pub use core::{App, PROJECT_NAME}; pub use builder::AppBuilder; pub use config::AppConfig; diff --git a/apps/recorder/src/auth/basic.rs b/apps/recorder/src/auth/basic.rs index 827ad81..167b5c5 100644 --- a/apps/recorder/src/auth/basic.rs +++ b/apps/recorder/src/auth/basic.rs @@ -9,7 +9,7 @@ use super::{ service::{AuthServiceTrait, AuthUserInfo}, }; use crate::{ - app::AppContextTrait, + app::{AppContextTrait, PROJECT_NAME}, models::{auth::AuthType, subscribers::SEED_SUBSCRIBER}, }; @@ -86,7 +86,7 @@ impl AuthServiceTrait for BasicAuthService { } fn www_authenticate_header_value(&self) -> Option { - Some(HeaderValue::from_static(r#"Basic realm="konobangu""#)) + Some(HeaderValue::from_str(format!("Basic realm=\"{PROJECT_NAME}\"").as_str()).unwrap()) } fn auth_type(&self) -> AuthType { diff --git a/apps/recorder/src/auth/oidc.rs b/apps/recorder/src/auth/oidc.rs index a1e27b9..18a83c3 100644 --- a/apps/recorder/src/auth/oidc.rs +++ b/apps/recorder/src/auth/oidc.rs @@ -32,7 +32,11 @@ use super::{ errors::{AuthError, OidcProviderUrlSnafu, OidcRequestRedirectUriSnafu}, service::{AuthServiceTrait, AuthUserInfo}, }; -use crate::{app::AppContextTrait, errors::RecorderError, models::auth::AuthType}; +use crate::{ + app::{AppContextTrait, PROJECT_NAME}, + errors::RecorderError, + models::auth::AuthType, +}; pub struct OidcHttpClient(pub Arc); @@ -351,7 +355,7 @@ impl AuthServiceTrait for OidcAuthService { } fn www_authenticate_header_value(&self) -> Option { - Some(HeaderValue::from_static(r#"Bearer realm="konobangu""#)) + Some(HeaderValue::from_str(format!("Bearer realm=\"{PROJECT_NAME}\"").as_str()).unwrap()) } fn auth_type(&self) -> AuthType { diff --git a/apps/recorder/src/errors/app_error.rs b/apps/recorder/src/errors/app_error.rs index 0b202af..bc337fc 100644 --- a/apps/recorder/src/errors/app_error.rs +++ b/apps/recorder/src/errors/app_error.rs @@ -47,8 +47,12 @@ pub enum RecorderError { RegexError { source: regex::Error }, #[snafu(display("Invalid method"))] InvalidMethodError, + #[snafu(display("Invalid header value"))] + InvalidHeaderValueError, #[snafu(display("Invalid header name"))] InvalidHeaderNameError, + #[snafu(display("Missing origin (protocol or host) in headers and forwarded info"))] + MissingOriginError, #[snafu(transparent)] TracingAppenderInitError { source: tracing_appender::rolling::InitError, @@ -87,8 +91,6 @@ pub enum RecorderError { #[snafu(source(from(opendal::Error, Box::new)))] source: Box, }, - #[snafu(display("Invalid header value"))] - InvalidHeaderValueError, #[snafu(transparent)] HttpClientError { source: HttpClientError }, #[cfg(feature = "testcontainers")] @@ -248,6 +250,11 @@ impl IntoResponse for RecorderError { ) .into_response() } + Self::ModelEntityNotFound { entity } => ( + StatusCode::NOT_FOUND, + Json::(StandardErrorResponse::from(entity.to_string())), + ) + .into_response(), err => ( StatusCode::INTERNAL_SERVER_ERROR, Json::(StandardErrorResponse::from(err.to_string())), diff --git a/apps/recorder/src/extract/bittorrent/extract.rs b/apps/recorder/src/extract/bittorrent/extract.rs index 8e723ac..7af4c09 100644 --- a/apps/recorder/src/extract/bittorrent/extract.rs +++ b/apps/recorder/src/extract/bittorrent/extract.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Utc}; use fancy_regex::Regex as FancyRegex; use lazy_static::lazy_static; use quirks_path::Path; @@ -33,6 +34,14 @@ lazy_static! { Regex::new(r"([Ss]|Season )(\d{1,3})").unwrap(); } +#[derive(Clone, Debug)] +pub struct EpisodeEnclosureMeta { + pub magnet_link: Option, + pub torrent_link: Option, + pub pub_date: Option>, + pub content_length: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct TorrentEpisodeMediaMeta { pub fansub: Option, diff --git a/apps/recorder/src/extract/http.rs b/apps/recorder/src/extract/http.rs index ab711a2..9810369 100644 --- a/apps/recorder/src/extract/http.rs +++ b/apps/recorder/src/extract/http.rs @@ -1,7 +1,12 @@ -use axum::http::{HeaderName, HeaderValue, Uri, header, request::Parts}; +use axum::{ + extract::FromRequestParts, + http::{HeaderName, HeaderValue, Uri, header, request::Parts}, +}; use itertools::Itertools; use url::Url; +use crate::errors::RecorderError; + /// Fields from a "Forwarded" header per [RFC7239 sec 4](https://www.rfc-editor.org/rfc/rfc7239#section-4) #[derive(Debug, Clone)] pub struct ForwardedHeader { @@ -101,9 +106,13 @@ pub struct ForwardedRelatedInfo { pub origin: Option, } -impl ForwardedRelatedInfo { - pub fn from_request_parts(request_parts: &Parts) -> ForwardedRelatedInfo { - let headers = &request_parts.headers; +impl FromRequestParts for ForwardedRelatedInfo { + type Rejection = RecorderError; + fn from_request_parts( + parts: &mut Parts, + _state: &T, + ) -> impl Future> + Send { + let headers = &parts.headers; let forwarded = headers .get(header::FORWARDED) .and_then(|s| ForwardedHeader::try_from(s.clone()).ok()); @@ -132,17 +141,19 @@ impl ForwardedRelatedInfo { .get(header::ORIGIN) .and_then(|s| s.to_str().map(String::from).ok()); - ForwardedRelatedInfo { + futures::future::ready(Ok(ForwardedRelatedInfo { host, x_forwarded_for, x_forwarded_host, x_forwarded_proto, forwarded, - uri: request_parts.uri.clone(), + uri: parts.uri.clone(), origin, - } + })) } +} +impl ForwardedRelatedInfo { pub fn resolved_protocol(&self) -> Option<&str> { self.forwarded .as_ref() diff --git a/apps/recorder/src/extract/mikan/subscription.rs b/apps/recorder/src/extract/mikan/subscription.rs index 4c53339..a53dbbd 100644 --- a/apps/recorder/src/extract/mikan/subscription.rs +++ b/apps/recorder/src/extract/mikan/subscription.rs @@ -20,12 +20,15 @@ use super::scrape_mikan_bangumi_meta_stream_from_season_flow_url; use crate::{ app::AppContextTrait, errors::{RecorderError, RecorderResult}, - extract::mikan::{ - MikanBangumiHash, MikanBangumiMeta, MikanEpisodeHash, MikanEpisodeMeta, - MikanRssEpisodeItem, MikanSeasonFlowUrlMeta, MikanSeasonStr, - MikanSubscriberSubscriptionRssUrlMeta, build_mikan_bangumi_subscription_rss_url, - build_mikan_season_flow_url, build_mikan_subscriber_subscription_rss_url, - scrape_mikan_episode_meta_from_episode_homepage_url, + extract::{ + bittorrent::EpisodeEnclosureMeta, + mikan::{ + MikanBangumiHash, MikanBangumiMeta, MikanEpisodeHash, MikanEpisodeMeta, + MikanRssEpisodeItem, MikanSeasonFlowUrlMeta, MikanSeasonStr, + MikanSubscriberSubscriptionRssUrlMeta, build_mikan_bangumi_subscription_rss_url, + build_mikan_season_flow_url, build_mikan_subscriber_subscription_rss_url, + scrape_mikan_episode_meta_from_episode_homepage_url, + }, }, models::{ bangumi, episodes, subscription_bangumi, subscription_episode, @@ -54,7 +57,7 @@ async fn sync_mikan_feeds_from_rss_item_list( .map(|(episode_id, hash, bangumi_id)| (hash.mikan_episode_id, (episode_id, bangumi_id))) .collect::>(); - let mut new_episode_meta_list: Vec = vec![]; + let mut new_episode_meta_list: Vec<(MikanEpisodeMeta, EpisodeEnclosureMeta)> = vec![]; let mikan_client = ctx.mikan(); for to_insert_rss_item in rss_item_list.into_iter().filter(|rss_item| { @@ -65,7 +68,8 @@ async fn sync_mikan_feeds_from_rss_item_list( to_insert_rss_item.build_homepage_url(mikan_base_url.clone()), ) .await?; - new_episode_meta_list.push(episode_meta); + let episode_enclosure_meta = EpisodeEnclosureMeta::from(to_insert_rss_item); + new_episode_meta_list.push((episode_meta, episode_enclosure_meta)); } (new_episode_meta_list, existed_episode_hash2id_map) @@ -92,22 +96,22 @@ async fn sync_mikan_feeds_from_rss_item_list( let new_episode_meta_list_group_by_bangumi_hash: HashMap< MikanBangumiHash, - Vec, + Vec<(MikanEpisodeMeta, EpisodeEnclosureMeta)>, > = { let mut m = hashmap! {}; - for episode_meta in new_episode_meta_list { + for (episode_meta, episode_enclosure_meta) in new_episode_meta_list { let bangumi_hash = episode_meta.bangumi_hash(); m.entry(bangumi_hash) .or_insert_with(Vec::new) - .push(episode_meta); + .push((episode_meta, episode_enclosure_meta)); } m }; for (group_bangumi_hash, group_episode_meta_list) in new_episode_meta_list_group_by_bangumi_hash { - let first_episode_meta = group_episode_meta_list.first().unwrap(); + let (first_episode_meta, _) = group_episode_meta_list.first().unwrap(); let group_bangumi_model = bangumi::Model::get_or_insert_from_mikan( ctx, group_bangumi_hash, @@ -126,9 +130,12 @@ async fn sync_mikan_feeds_from_rss_item_list( }, ) .await?; - let group_episode_creation_list = group_episode_meta_list - .into_iter() - .map(|episode_meta| (&group_bangumi_model, episode_meta)); + let group_episode_creation_list = + group_episode_meta_list + .into_iter() + .map(|(episode_meta, episode_enclosure_meta)| { + (&group_bangumi_model, episode_meta, episode_enclosure_meta) + }); episodes::Model::add_mikan_episodes_for_subscription( ctx, @@ -273,7 +280,7 @@ impl MikanSubscriberSubscription { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, InputObject, SimpleObject)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MikanSeasonSubscription { pub subscription_id: i32, pub year: i32, diff --git a/apps/recorder/src/extract/mikan/web.rs b/apps/recorder/src/extract/mikan/web.rs index deb1b0d..f59e774 100644 --- a/apps/recorder/src/extract/mikan/web.rs +++ b/apps/recorder/src/extract/mikan/web.rs @@ -2,7 +2,7 @@ use std::{borrow::Cow, fmt, str::FromStr, sync::Arc}; use async_stream::try_stream; use bytes::Bytes; -use chrono::DateTime; +use chrono::{DateTime, Utc}; use downloader::bittorrent::defs::BITTORRENT_MIME_TYPE; use fetch::{html::fetch_html, image::fetch_image}; use futures::{Stream, TryStreamExt, pin_mut}; @@ -17,6 +17,7 @@ use crate::{ app::AppContextTrait, errors::app_error::{RecorderError, RecorderResult}, extract::{ + bittorrent::EpisodeEnclosureMeta, html::{extract_background_image_src_from_style_attr, extract_inner_text_from_element_ref}, media::extract_image_src_from_str, mikan::{ @@ -39,11 +40,12 @@ use crate::{ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MikanRssEpisodeItem { pub title: String, - pub url: Url, + pub torrent_link: Url, pub content_length: Option, pub mime: String, - pub pub_date: Option, + pub pub_date: Option>, pub mikan_episode_id: String, + pub magnet_link: Option, } impl MikanRssEpisodeItem { @@ -95,18 +97,30 @@ impl TryFrom for MikanRssEpisodeItem { Ok(MikanRssEpisodeItem { title, - url: enclosure_url, + torrent_link: enclosure_url, content_length: enclosure.length.parse().ok(), mime: mime_type, - pub_date: item - .pub_date - .and_then(|s| DateTime::parse_from_rfc2822(&s).ok()) - .map(|s| s.timestamp_millis()), + pub_date: item.pub_date.and_then(|s| { + DateTime::parse_from_rfc2822(&s) + .ok() + .map(|s| s.with_timezone(&Utc)) + }), mikan_episode_id, + magnet_link: None, }) } } +impl From for EpisodeEnclosureMeta { + fn from(item: MikanRssEpisodeItem) -> Self { + Self { + magnet_link: item.magnet_link, + torrent_link: Some(item.torrent_link.to_string()), + pub_date: item.pub_date, + content_length: item.content_length, + } + } +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct MikanSubscriberSubscriptionRssUrlMeta { pub mikan_subscription_token: String, diff --git a/apps/recorder/src/graphql/domains/bangumi.rs b/apps/recorder/src/graphql/domains/bangumi.rs new file mode 100644 index 0000000..d011b1f --- /dev/null +++ b/apps/recorder/src/graphql/domains/bangumi.rs @@ -0,0 +1,14 @@ +use seaography::{Builder as SeaographyBuilder, BuilderContext}; + +use crate::{graphql::domains::subscribers::restrict_subscriber_for_entity, models::bangumi}; + +pub fn register_bangumi_to_schema_context(context: &mut BuilderContext) { + restrict_subscriber_for_entity::(context, &bangumi::Column::SubscriberId); +} + +pub fn register_bangumi_to_schema_builder(mut builder: SeaographyBuilder) -> SeaographyBuilder { + builder.register_enumeration::(); + seaography::register_entity!(builder, bangumi); + + builder +} diff --git a/apps/recorder/src/graphql/domains/credential_3rd.rs b/apps/recorder/src/graphql/domains/credential_3rd.rs index 872134e..42ec864 100644 --- a/apps/recorder/src/graphql/domains/credential_3rd.rs +++ b/apps/recorder/src/graphql/domains/credential_3rd.rs @@ -3,12 +3,22 @@ use std::sync::Arc; use async_graphql::dynamic::{ Field, FieldFuture, FieldValue, InputObject, InputValue, Object, TypeRef, }; -use seaography::Builder as SeaographyBuilder; +use seaography::{Builder as SeaographyBuilder, BuilderContext}; use serde::{Deserialize, Serialize}; use util_derive::DynamicGraphql; use crate::{ - app::AppContextTrait, auth::AuthUserInfo, errors::RecorderError, models::credential_3rd, + app::AppContextTrait, + auth::AuthUserInfo, + errors::RecorderError, + graphql::{ + domains::subscribers::restrict_subscriber_for_entity, + infra::crypto::{ + register_crypto_column_input_conversion_to_schema_context, + register_crypto_column_output_conversion_to_schema_context, + }, + }, + models::credential_3rd, }; #[derive(DynamicGraphql, Serialize, Deserialize, Clone, Debug)] @@ -63,9 +73,52 @@ impl Credential3rdCheckAvailableInfo { } } +pub fn register_credential3rd_to_schema_context( + context: &mut BuilderContext, + ctx: Arc, +) { + restrict_subscriber_for_entity::( + context, + &credential_3rd::Column::SubscriberId, + ); + register_crypto_column_input_conversion_to_schema_context::( + context, + ctx.clone(), + &credential_3rd::Column::Cookies, + ); + register_crypto_column_input_conversion_to_schema_context::( + context, + ctx.clone(), + &credential_3rd::Column::Username, + ); + register_crypto_column_input_conversion_to_schema_context::( + context, + ctx.clone(), + &credential_3rd::Column::Password, + ); + register_crypto_column_output_conversion_to_schema_context::( + context, + ctx.clone(), + &credential_3rd::Column::Cookies, + ); + register_crypto_column_output_conversion_to_schema_context::( + context, + ctx.clone(), + &credential_3rd::Column::Username, + ); + register_crypto_column_output_conversion_to_schema_context::( + context, + ctx, + &credential_3rd::Column::Password, + ); +} + pub fn register_credential3rd_to_schema_builder( mut builder: SeaographyBuilder, ) -> SeaographyBuilder { + builder.register_enumeration::(); + seaography::register_entity!(builder, credential_3rd); + builder.schema = builder .schema .register(Credential3rdCheckAvailableInput::generate_input_object()); diff --git a/apps/recorder/src/graphql/domains/downloaders.rs b/apps/recorder/src/graphql/domains/downloaders.rs new file mode 100644 index 0000000..a797c56 --- /dev/null +++ b/apps/recorder/src/graphql/domains/downloaders.rs @@ -0,0 +1,17 @@ +use seaography::{Builder as SeaographyBuilder, BuilderContext}; + +use crate::{graphql::domains::subscribers::restrict_subscriber_for_entity, models::downloaders}; + +pub fn register_downloaders_to_schema_context(context: &mut BuilderContext) { + restrict_subscriber_for_entity::( + context, + &downloaders::Column::SubscriberId, + ); +} + +pub fn register_downloaders_to_schema_builder(mut builder: SeaographyBuilder) -> SeaographyBuilder { + builder.register_enumeration::(); + seaography::register_entity!(builder, downloaders); + + builder +} diff --git a/apps/recorder/src/graphql/domains/downloads.rs b/apps/recorder/src/graphql/domains/downloads.rs new file mode 100644 index 0000000..8fb1309 --- /dev/null +++ b/apps/recorder/src/graphql/domains/downloads.rs @@ -0,0 +1,15 @@ +use seaography::{Builder as SeaographyBuilder, BuilderContext}; + +use crate::{graphql::domains::subscribers::restrict_subscriber_for_entity, models::downloads}; + +pub fn register_downloads_to_schema_context(context: &mut BuilderContext) { + restrict_subscriber_for_entity::(context, &downloads::Column::SubscriberId); +} + +pub fn register_downloads_to_schema_builder(mut builder: SeaographyBuilder) -> SeaographyBuilder { + builder.register_enumeration::(); + builder.register_enumeration::(); + seaography::register_entity!(builder, downloads); + + builder +} diff --git a/apps/recorder/src/graphql/domains/episodes.rs b/apps/recorder/src/graphql/domains/episodes.rs new file mode 100644 index 0000000..9963710 --- /dev/null +++ b/apps/recorder/src/graphql/domains/episodes.rs @@ -0,0 +1,14 @@ +use seaography::{Builder as SeaographyBuilder, BuilderContext}; + +use crate::{graphql::domains::subscribers::restrict_subscriber_for_entity, models::episodes}; + +pub fn register_episodes_to_schema_context(context: &mut BuilderContext) { + restrict_subscriber_for_entity::(context, &episodes::Column::SubscriberId); +} + +pub fn register_episodes_to_schema_builder(mut builder: SeaographyBuilder) -> SeaographyBuilder { + builder.register_enumeration::(); + seaography::register_entity!(builder, episodes); + + builder +} diff --git a/apps/recorder/src/graphql/domains/feeds.rs b/apps/recorder/src/graphql/domains/feeds.rs new file mode 100644 index 0000000..b9c9d16 --- /dev/null +++ b/apps/recorder/src/graphql/domains/feeds.rs @@ -0,0 +1,15 @@ +use seaography::{Builder as SeaographyBuilder, BuilderContext}; + +use crate::{graphql::domains::subscribers::restrict_subscriber_for_entity, models::feeds}; + +pub fn register_feeds_to_schema_context(context: &mut BuilderContext) { + restrict_subscriber_for_entity::(context, &feeds::Column::SubscriberId); +} + +pub fn register_feeds_to_schema_builder(mut builder: SeaographyBuilder) -> SeaographyBuilder { + builder.register_enumeration::(); + builder.register_enumeration::(); + seaography::register_entity!(builder, feeds); + + builder +} diff --git a/apps/recorder/src/graphql/domains/mod.rs b/apps/recorder/src/graphql/domains/mod.rs index 0f1aaf6..eb07557 100644 --- a/apps/recorder/src/graphql/domains/mod.rs +++ b/apps/recorder/src/graphql/domains/mod.rs @@ -1,5 +1,12 @@ pub mod credential_3rd; -pub mod crypto; + +pub mod bangumi; +pub mod downloaders; +pub mod downloads; +pub mod episodes; +pub mod feeds; pub mod subscriber_tasks; pub mod subscribers; +pub mod subscription_bangumi; +pub mod subscription_episode; pub mod subscriptions; diff --git a/apps/recorder/src/graphql/domains/subscribers.rs b/apps/recorder/src/graphql/domains/subscribers.rs index 7c7e7f8..6b47afe 100644 --- a/apps/recorder/src/graphql/domains/subscribers.rs +++ b/apps/recorder/src/graphql/domains/subscribers.rs @@ -320,6 +320,7 @@ where } pub fn register_subscribers_to_schema_context(context: &mut BuilderContext) { + restrict_subscriber_for_entity::(context, &subscribers::Column::Id); for column in subscribers::Column::iter() { if !matches!(column, subscribers::Column::Id) { let key = get_entity_column_key::(context, &column); diff --git a/apps/recorder/src/graphql/domains/subscription_bangumi.rs b/apps/recorder/src/graphql/domains/subscription_bangumi.rs new file mode 100644 index 0000000..b189835 --- /dev/null +++ b/apps/recorder/src/graphql/domains/subscription_bangumi.rs @@ -0,0 +1,20 @@ +use seaography::{Builder as SeaographyBuilder, BuilderContext}; + +use crate::{ + graphql::domains::subscribers::restrict_subscriber_for_entity, models::subscription_bangumi, +}; + +pub fn register_subscription_bangumi_to_schema_context(context: &mut BuilderContext) { + restrict_subscriber_for_entity::( + context, + &subscription_bangumi::Column::SubscriberId, + ); +} + +pub fn register_subscription_bangumi_to_schema_builder( + mut builder: SeaographyBuilder, +) -> SeaographyBuilder { + seaography::register_entity!(builder, subscription_bangumi); + + builder +} diff --git a/apps/recorder/src/graphql/domains/subscription_episode.rs b/apps/recorder/src/graphql/domains/subscription_episode.rs new file mode 100644 index 0000000..ed73a52 --- /dev/null +++ b/apps/recorder/src/graphql/domains/subscription_episode.rs @@ -0,0 +1,20 @@ +use seaography::{Builder as SeaographyBuilder, BuilderContext}; + +use crate::{ + graphql::domains::subscribers::restrict_subscriber_for_entity, models::subscription_episode, +}; + +pub fn register_subscription_episode_to_schema_context(context: &mut BuilderContext) { + restrict_subscriber_for_entity::( + context, + &subscription_episode::Column::SubscriberId, + ); +} + +pub fn register_subscription_episode_to_schema_builder( + mut builder: SeaographyBuilder, +) -> SeaographyBuilder { + seaography::register_entity!(builder, subscription_episode); + + builder +} diff --git a/apps/recorder/src/graphql/domains/subscriptions.rs b/apps/recorder/src/graphql/domains/subscriptions.rs index 1e653a9..1dd2e66 100644 --- a/apps/recorder/src/graphql/domains/subscriptions.rs +++ b/apps/recorder/src/graphql/domains/subscriptions.rs @@ -3,13 +3,16 @@ use std::sync::Arc; use async_graphql::dynamic::{FieldValue, TypeRef}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use seaography::{ - Builder as SeaographyBuilder, EntityObjectBuilder, EntityQueryFieldBuilder, + Builder as SeaographyBuilder, BuilderContext, EntityObjectBuilder, EntityQueryFieldBuilder, get_filter_conditions, }; use crate::{ errors::RecorderError, - graphql::infra::custom::generate_entity_filter_mutation_field, + graphql::{ + domains::subscribers::restrict_subscriber_for_entity, + infra::custom::generate_entity_filter_mutation_field, + }, models::{ subscriber_tasks, subscriptions::{self, SubscriptionTrait}, @@ -17,9 +20,19 @@ use crate::{ task::SubscriberTask, }; +pub fn register_subscriptions_to_schema_context(context: &mut BuilderContext) { + restrict_subscriber_for_entity::( + context, + &subscriptions::Column::SubscriberId, + ); +} + pub fn register_subscriptions_to_schema_builder( mut builder: SeaographyBuilder, ) -> SeaographyBuilder { + builder.register_enumeration::(); + seaography::register_entity!(builder, subscriptions); + let context = builder.context; let entity_object_builder = EntityObjectBuilder { context }; diff --git a/apps/recorder/src/graphql/domains/crypto.rs b/apps/recorder/src/graphql/infra/crypto.rs similarity index 63% rename from apps/recorder/src/graphql/domains/crypto.rs rename to apps/recorder/src/graphql/infra/crypto.rs index 36cac8b..a978962 100644 --- a/apps/recorder/src/graphql/domains/crypto.rs +++ b/apps/recorder/src/graphql/infra/crypto.rs @@ -7,10 +7,9 @@ use seaography::{BuilderContext, SeaResult}; use crate::{ app::AppContextTrait, graphql::infra::util::{get_column_key, get_entity_key}, - models::credential_3rd, }; -fn register_crypto_column_input_conversion_to_schema_context( +pub fn register_crypto_column_input_conversion_to_schema_context( context: &mut BuilderContext, ctx: Arc, column: &T::Column, @@ -37,7 +36,7 @@ fn register_crypto_column_input_conversion_to_schema_context( ); } -fn register_crypto_column_output_conversion_to_schema_context( +pub fn register_crypto_column_output_conversion_to_schema_context( context: &mut BuilderContext, ctx: Arc, column: &T::Column, @@ -68,39 +67,3 @@ fn register_crypto_column_output_conversion_to_schema_context( ), ); } - -pub fn register_crypto_to_schema_context( - context: &mut BuilderContext, - ctx: Arc, -) { - register_crypto_column_input_conversion_to_schema_context::( - context, - ctx.clone(), - &credential_3rd::Column::Cookies, - ); - register_crypto_column_input_conversion_to_schema_context::( - context, - ctx.clone(), - &credential_3rd::Column::Username, - ); - register_crypto_column_input_conversion_to_schema_context::( - context, - ctx.clone(), - &credential_3rd::Column::Password, - ); - register_crypto_column_output_conversion_to_schema_context::( - context, - ctx.clone(), - &credential_3rd::Column::Cookies, - ); - register_crypto_column_output_conversion_to_schema_context::( - context, - ctx.clone(), - &credential_3rd::Column::Username, - ); - register_crypto_column_output_conversion_to_schema_context::( - context, - ctx, - &credential_3rd::Column::Password, - ); -} diff --git a/apps/recorder/src/graphql/infra/mod.rs b/apps/recorder/src/graphql/infra/mod.rs index af8f548..4079fd7 100644 --- a/apps/recorder/src/graphql/infra/mod.rs +++ b/apps/recorder/src/graphql/infra/mod.rs @@ -1,3 +1,4 @@ +pub mod crypto; pub mod custom; pub mod json; pub mod util; diff --git a/apps/recorder/src/graphql/schema.rs b/apps/recorder/src/graphql/schema.rs index 3d93c6d..2a3a9bc 100644 --- a/apps/recorder/src/graphql/schema.rs +++ b/apps/recorder/src/graphql/schema.rs @@ -8,17 +8,36 @@ use crate::{ app::AppContextTrait, graphql::{ domains::{ - credential_3rd::register_credential3rd_to_schema_builder, - crypto::register_crypto_to_schema_context, + bangumi::{register_bangumi_to_schema_builder, register_bangumi_to_schema_context}, + credential_3rd::{ + register_credential3rd_to_schema_builder, register_credential3rd_to_schema_context, + }, + downloaders::{ + register_downloaders_to_schema_builder, register_downloaders_to_schema_context, + }, + downloads::{ + register_downloads_to_schema_builder, register_downloads_to_schema_context, + }, + episodes::{register_episodes_to_schema_builder, register_episodes_to_schema_context}, + feeds::{register_feeds_to_schema_builder, register_feeds_to_schema_context}, subscriber_tasks::{ register_subscriber_tasks_to_schema_builder, register_subscriber_tasks_to_schema_context, }, subscribers::{ register_subscribers_to_schema_builder, register_subscribers_to_schema_context, - restrict_subscriber_for_entity, }, - subscriptions::register_subscriptions_to_schema_builder, + subscription_bangumi::{ + register_subscription_bangumi_to_schema_builder, + register_subscription_bangumi_to_schema_context, + }, + subscription_episode::{ + register_subscription_episode_to_schema_builder, + register_subscription_episode_to_schema_context, + }, + subscriptions::{ + register_subscriptions_to_schema_builder, register_subscriptions_to_schema_context, + }, }, infra::json::register_jsonb_input_filter_to_schema_builder, }, @@ -31,7 +50,6 @@ pub fn build_schema( depth: Option, complexity: Option, ) -> Result { - use crate::models::*; let database = app_ctx.db().as_ref().clone(); let context = CONTEXT.get_or_init(|| { @@ -39,45 +57,17 @@ pub fn build_schema( { // domains + register_feeds_to_schema_context(&mut context); register_subscribers_to_schema_context(&mut context); - - { - restrict_subscriber_for_entity::( - &mut context, - &downloaders::Column::SubscriberId, - ); - restrict_subscriber_for_entity::( - &mut context, - &downloads::Column::SubscriberId, - ); - restrict_subscriber_for_entity::( - &mut context, - &episodes::Column::SubscriberId, - ); - restrict_subscriber_for_entity::( - &mut context, - &subscriptions::Column::SubscriberId, - ); - restrict_subscriber_for_entity::( - &mut context, - &subscribers::Column::Id, - ); - restrict_subscriber_for_entity::( - &mut context, - &subscription_bangumi::Column::SubscriberId, - ); - restrict_subscriber_for_entity::( - &mut context, - &subscription_episode::Column::SubscriberId, - ); - restrict_subscriber_for_entity::( - &mut context, - &credential_3rd::Column::SubscriberId, - ); - } - - register_crypto_to_schema_context(&mut context, app_ctx.clone()); + register_subscriptions_to_schema_context(&mut context); register_subscriber_tasks_to_schema_context(&mut context); + register_credential3rd_to_schema_context(&mut context, app_ctx.clone()); + register_downloaders_to_schema_context(&mut context); + register_downloads_to_schema_context(&mut context); + register_episodes_to_schema_context(&mut context); + register_subscription_bangumi_to_schema_context(&mut context); + register_subscription_episode_to_schema_context(&mut context); + register_bangumi_to_schema_context(&mut context); } context }); @@ -91,32 +81,16 @@ pub fn build_schema( { // domains builder = register_subscribers_to_schema_builder(builder); - - seaography::register_entities!( - builder, - [ - bangumi, - downloaders, - downloads, - episodes, - subscription_bangumi, - subscription_episode, - subscriptions, - credential_3rd - ] - ); - - { - builder.register_enumeration::(); - builder.register_enumeration::(); - builder.register_enumeration::(); - builder.register_enumeration::(); - builder.register_enumeration::(); - } - + builder = register_feeds_to_schema_builder(builder); + builder = register_episodes_to_schema_builder(builder); + builder = register_subscription_bangumi_to_schema_builder(builder); + builder = register_subscription_episode_to_schema_builder(builder); + builder = register_downloaders_to_schema_builder(builder); + builder = register_downloads_to_schema_builder(builder); builder = register_subscriptions_to_schema_builder(builder); builder = register_credential3rd_to_schema_builder(builder); builder = register_subscriber_tasks_to_schema_builder(builder); + builder = register_bangumi_to_schema_builder(builder); } let schema = builder.schema_builder(); diff --git a/apps/recorder/src/migrations/defs.rs b/apps/recorder/src/migrations/defs.rs index a3477be..b1e5959 100644 --- a/apps/recorder/src/migrations/defs.rs +++ b/apps/recorder/src/migrations/defs.rs @@ -52,8 +52,12 @@ pub enum Bangumi { RssLink, PosterLink, OriginPosterLink, + /** + * @deprecated + */ SavePath, Homepage, + BangumiType, } #[derive(DeriveIden)] @@ -86,7 +90,11 @@ pub enum Episodes { Homepage, Subtitle, Source, - Extra, + EpisodeType, + EnclosureTorrentLink, + EnclosureMagnetLink, + EnclosurePubDate, + EnclosureContentLength, } #[derive(DeriveIden)] @@ -149,6 +157,17 @@ pub enum Credential3rd { UserAgent, } +#[derive(DeriveIden)] +pub enum Feeds { + Table, + Id, + Token, + FeedType, + FeedSource, + SubscriberId, + SubscriptionId, +} + macro_rules! create_postgres_enum_for_active_enum { ($manager: expr, $active_enum: expr, $($enum_value:expr),+) => { { diff --git a/apps/recorder/src/migrations/m20250622_015618_feeds.rs b/apps/recorder/src/migrations/m20250622_015618_feeds.rs new file mode 100644 index 0000000..3fed4d8 --- /dev/null +++ b/apps/recorder/src/migrations/m20250622_015618_feeds.rs @@ -0,0 +1,98 @@ +use async_trait::async_trait; +use sea_orm_migration::{ + prelude::*, + schema::{enumeration, integer_null, pk_auto, text}, +}; + +use crate::{ + migrations::defs::{ + CustomSchemaManagerExt, Feeds, GeneralIds, Subscribers, Subscriptions, table_auto_z, + }, + models::feeds::{FeedSource, FeedSourceEnum, FeedType, FeedTypeEnum}, +}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_postgres_enum_for_active_enum!(manager, FeedTypeEnum, FeedType::Rss).await?; + create_postgres_enum_for_active_enum!( + manager, + FeedSourceEnum, + FeedSource::SubscriptionEpisode + ) + .await?; + + manager + .create_table( + table_auto_z(Feeds::Table) + .col(pk_auto(Feeds::Id)) + .col(text(Feeds::Token)) + .col(enumeration( + Feeds::FeedType, + FeedTypeEnum, + FeedType::iden_values(), + )) + .col( + enumeration(Feeds::FeedSource, FeedSourceEnum, FeedSource::iden_values()) + .not_null(), + ) + .col(integer_null(Feeds::SubscriberId)) + .col(integer_null(Feeds::SubscriptionId)) + .index( + Index::create() + .if_not_exists() + .name("idx_feeds_token") + .table(Feeds::Table) + .col(Feeds::Token) + .unique(), + ) + .foreign_key( + ForeignKey::create() + .name("fk_feeds_subscriber_id") + .from(Feeds::Table, Feeds::SubscriberId) + .to(Subscribers::Table, Subscribers::Id) + .on_update(ForeignKeyAction::Cascade) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_feeds_subscription_id") + .from(Feeds::Table, Feeds::SubscriptionId) + .to(Subscriptions::Table, Subscriptions::Id) + .on_update(ForeignKeyAction::Cascade) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_postgres_auto_update_ts_trigger_for_col(Feeds::Table, GeneralIds::UpdatedAt) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_postgres_auto_update_ts_trigger_for_col(Feeds::Table, GeneralIds::UpdatedAt) + .await?; + + manager + .drop_table(Table::drop().if_exists().table(Feeds::Table).to_owned()) + .await?; + + manager + .drop_postgres_enum_for_active_enum(FeedTypeEnum) + .await?; + + manager + .drop_postgres_enum_for_active_enum(FeedSourceEnum) + .await?; + + Ok(()) + } +} diff --git a/apps/recorder/src/migrations/m20250622_020819_bangumi_and_episode_type.rs b/apps/recorder/src/migrations/m20250622_020819_bangumi_and_episode_type.rs new file mode 100644 index 0000000..d9e066e --- /dev/null +++ b/apps/recorder/src/migrations/m20250622_020819_bangumi_and_episode_type.rs @@ -0,0 +1,135 @@ +use async_trait::async_trait; +use sea_orm_migration::{ + prelude::*, + schema::{ + enumeration, enumeration_null, integer_null, text_null, timestamp_with_time_zone_null, + }, +}; + +use crate::{ + migrations::defs::{Bangumi, CustomSchemaManagerExt, Episodes}, + models::{ + bangumi::{BangumiType, BangumiTypeEnum}, + episodes::{EpisodeType, EpisodeTypeEnum}, + }, +}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + create_postgres_enum_for_active_enum!(manager, EpisodeTypeEnum, EpisodeType::Mikan).await?; + + { + create_postgres_enum_for_active_enum!(manager, BangumiTypeEnum, BangumiType::Mikan) + .await?; + manager + .alter_table( + Table::alter() + .table(Bangumi::Table) + .add_column_if_not_exists(enumeration_null( + Bangumi::BangumiType, + BangumiTypeEnum, + BangumiType::iden_values(), + )) + .drop_column(Bangumi::SavePath) + .to_owned(), + ) + .await?; + + manager + .exec_stmt( + UpdateStatement::new() + .table(Bangumi::Table) + .value( + Bangumi::BangumiType, + BangumiType::Mikan.as_enum(BangumiTypeEnum), + ) + .and_where(Expr::col(Bangumi::BangumiType).is_null()) + .and_where(Expr::col(Bangumi::MikanBangumiId).is_not_null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Bangumi::Table) + .modify_column(enumeration( + Bangumi::BangumiType, + BangumiTypeEnum, + BangumiType::iden_values(), + )) + .to_owned(), + ) + .await?; + } + + { + create_postgres_enum_for_active_enum!(manager, EpisodeTypeEnum, EpisodeType::Mikan) + .await?; + + manager + .alter_table( + Table::alter() + .table(Episodes::Table) + .add_column_if_not_exists(enumeration_null( + Episodes::EpisodeType, + EpisodeTypeEnum, + EpisodeType::enum_type_name(), + )) + .add_column_if_not_exists(text_null(Episodes::EnclosureMagnetLink)) + .add_column_if_not_exists(text_null(Episodes::EnclosureTorrentLink)) + .add_column_if_not_exists(timestamp_with_time_zone_null( + Episodes::EnclosurePubDate, + )) + .add_column_if_not_exists(integer_null(Episodes::EnclosureContentLength)) + .to_owned(), + ) + .await?; + + manager + .exec_stmt( + UpdateStatement::new() + .table(Episodes::Table) + .value( + Episodes::EpisodeType, + EpisodeType::Mikan.as_enum(EpisodeTypeEnum), + ) + .and_where(Expr::col(Episodes::EpisodeType).is_null()) + .and_where(Expr::col(Episodes::MikanEpisodeId).is_not_null()) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Episodes::Table) + .modify_column(enumeration( + Episodes::EpisodeType, + EpisodeTypeEnum, + EpisodeType::enum_type_name(), + )) + .to_owned(), + ) + .await?; + } + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_postgres_enum_for_active_enum(BangumiTypeEnum) + .await?; + + manager + .drop_postgres_enum_for_active_enum(EpisodeTypeEnum) + .await?; + + Ok(()) + } +} diff --git a/apps/recorder/src/migrations/mod.rs b/apps/recorder/src/migrations/mod.rs index b2bfaae..bcda402 100644 --- a/apps/recorder/src/migrations/mod.rs +++ b/apps/recorder/src/migrations/mod.rs @@ -8,6 +8,8 @@ pub mod m20240224_082543_add_downloads; pub mod m20241231_000001_auth; pub mod m20250501_021523_credential_3rd; pub mod m20250520_021135_subscriber_tasks; +pub mod m20250622_015618_feeds; +pub mod m20250622_020819_bangumi_and_episode_type; pub struct Migrator; @@ -20,6 +22,8 @@ impl MigratorTrait for Migrator { Box::new(m20241231_000001_auth::Migration), Box::new(m20250501_021523_credential_3rd::Migration), Box::new(m20250520_021135_subscriber_tasks::Migration), + Box::new(m20250622_015618_feeds::Migration), + Box::new(m20250622_020819_bangumi_and_episode_type::Migration), ] } } diff --git a/apps/recorder/src/models/bangumi.rs b/apps/recorder/src/models/bangumi.rs index f3688d1..ac767d4 100644 --- a/apps/recorder/src/models/bangumi.rs +++ b/apps/recorder/src/models/bangumi.rs @@ -29,7 +29,14 @@ pub struct BangumiFilter { pub group: Option>, } -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize, SimpleObject)] +#[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()")] @@ -39,6 +46,7 @@ pub struct Model { #[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, @@ -50,7 +58,6 @@ pub struct Model { pub rss_link: Option, pub poster_link: Option, pub origin_poster_link: Option, - pub save_path: Option, pub homepage: Option, } @@ -152,10 +159,7 @@ impl ActiveModel { 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[url::Position::BeforePath..].to_string()), - ), + 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() @@ -234,6 +238,7 @@ impl Model { Column::OriginName, Column::Fansub, Column::PosterLink, + Column::OriginPosterLink, Column::Season, Column::SeasonRaw, Column::RssLink, diff --git a/apps/recorder/src/models/episodes.rs b/apps/recorder/src/models/episodes.rs index 6daa055..95f6a4f 100644 --- a/apps/recorder/src/models/episodes.rs +++ b/apps/recorder/src/models/episodes.rs @@ -9,11 +9,19 @@ use crate::{ app::AppContextTrait, errors::RecorderResult, extract::{ + bittorrent::EpisodeEnclosureMeta, mikan::{MikanEpisodeHash, MikanEpisodeMeta, build_mikan_episode_homepage_url}, origin::{OriginCompTrait, OriginNameRoot}, }, }; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, EnumIter, DeriveActiveEnum)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "episode_type")] +pub enum EpisodeType { + #[sea_orm(string_value = "mikan")] + Mikan, +} + #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] #[sea_orm(table_name = "episodes")] pub struct Model { @@ -25,11 +33,15 @@ pub struct Model { pub id: i32, #[sea_orm(indexed)] pub mikan_episode_id: Option, + pub enclosure_torrent_link: Option, + pub enclosure_magnet_link: Option, + pub enclosure_pub_date: Option, + pub enclosure_content_length: Option, + pub episode_type: EpisodeType, pub origin_name: String, pub display_name: String, pub bangumi_id: i32, pub subscriber_id: i32, - pub save_path: Option, pub resolution: Option, pub season: i32, pub season_raw: Option, @@ -122,6 +134,7 @@ impl ActiveModel { ctx: &dyn AppContextTrait, bangumi: &bangumi::Model, episode: MikanEpisodeMeta, + enclosure_meta: EpisodeEnclosureMeta, ) -> RecorderResult { let mikan_base_url = ctx.mikan().base_url().clone(); let episode_extention_meta = OriginNameRoot::parse_comp(&episode.episode_title) @@ -149,6 +162,10 @@ impl ActiveModel { poster_link: ActiveValue::Set(bangumi.poster_link.clone()), origin_poster_link: ActiveValue::Set(bangumi.origin_poster_link.clone()), episode_index: ActiveValue::Set(0), + enclosure_torrent_link: ActiveValue::Set(enclosure_meta.torrent_link), + enclosure_magnet_link: ActiveValue::Set(enclosure_meta.magnet_link), + enclosure_pub_date: ActiveValue::Set(enclosure_meta.pub_date), + enclosure_content_length: ActiveValue::Set(enclosure_meta.content_length), ..Default::default() }; @@ -216,14 +233,19 @@ impl Model { pub async fn add_mikan_episodes_for_subscription( ctx: &dyn AppContextTrait, - creations: impl Iterator, + creations: impl Iterator, subscriber_id: i32, subscription_id: i32, ) -> RecorderResult<()> { let db = ctx.db(); let new_episode_active_modes: Vec = creations - .map(|(bangumi, episode_meta)| { - ActiveModel::from_mikan_bangumi_and_episode_meta(ctx, bangumi, episode_meta) + .map(|(bangumi, episode_meta, enclosure_meta)| { + ActiveModel::from_mikan_bangumi_and_episode_meta( + ctx, + bangumi, + episode_meta, + enclosure_meta, + ) }) .collect::>()?; @@ -234,7 +256,23 @@ impl Model { let new_episode_ids = Entity::insert_many(new_episode_active_modes) .on_conflict( OnConflict::columns([Column::MikanEpisodeId, Column::SubscriberId]) - .update_columns([Column::OriginName, Column::PosterLink, Column::Homepage]) + .update_columns([ + Column::OriginName, + Column::PosterLink, + Column::OriginPosterLink, + Column::Homepage, + Column::EnclosureContentLength, + Column::EnclosurePubDate, + Column::EnclosureTorrentLink, + Column::EnclosureMagnetLink, + Column::EpisodeIndex, + Column::Subtitle, + Column::Source, + Column::Resolution, + Column::Season, + Column::SeasonRaw, + Column::Fansub, + ]) .to_owned(), ) .exec_with_returning_columns(db, [Column::Id]) diff --git a/apps/recorder/src/models/feeds/mod.rs b/apps/recorder/src/models/feeds/mod.rs new file mode 100644 index 0000000..317edd1 --- /dev/null +++ b/apps/recorder/src/models/feeds/mod.rs @@ -0,0 +1,131 @@ +mod registry; +mod rss; +mod subscription_episodes_feed; + +use ::rss::Channel; +use async_trait::async_trait; +pub use registry::Feed; +pub use rss::{RssFeedItemTrait, RssFeedTrait}; +use sea_orm::{ActiveValue, DeriveEntityModel, entity::prelude::*}; +use serde::{Deserialize, Serialize}; +pub use subscription_episodes_feed::SubscriptionEpisodesFeed; +use url::Url; + +use crate::{ + app::AppContextTrait, + errors::{RecorderError, RecorderResult}, +}; + +#[derive( + Debug, Serialize, Deserialize, Clone, PartialEq, Eq, DeriveActiveEnum, EnumIter, DeriveDisplay, +)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "feed_type")] +#[serde(rename_all = "snake_case")] +pub enum FeedType { + #[sea_orm(string_value = "rss")] + Rss, +} + +#[derive( + Debug, Serialize, Deserialize, Clone, PartialEq, Eq, DeriveActiveEnum, EnumIter, DeriveDisplay, +)] +#[sea_orm(rs_type = "String", db_type = "Enum", enum_name = "feed_source")] +#[serde(rename_all = "snake_case")] +pub enum FeedSource { + #[sea_orm(string_value = "subscription_episode")] + SubscriptionEpisode, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "feeds")] +pub struct Model { + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(indexed)] + pub token: String, + #[sea_orm(indexed)] + pub feed_type: FeedType, + #[sea_orm(indexed)] + pub feed_source: FeedSource, + pub subscriber_id: Option, + pub subscription_id: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::subscriptions::Entity", + from = "Column::SubscriptionId", + to = "super::subscriptions::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Subscription, + #[sea_orm( + belongs_to = "super::subscribers::Entity", + from = "Column::SubscriberId", + to = "super::subscribers::Column::Id", + on_update = "Cascade", + on_delete = "Cascade" + )] + Subscriber, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Subscription.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Subscriber.def() + } +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)] +pub enum RelatedEntity { + #[sea_orm(entity = "super::subscribers::Entity")] + Subscriber, + #[sea_orm(entity = "super::subscriptions::Entity")] + Subscription, +} + +#[async_trait] +impl ActiveModelBehavior for ActiveModel { + async fn before_save(mut self, _db: &C, insert: bool) -> Result + where + C: ConnectionTrait, + { + if insert && let ActiveValue::NotSet = self.token { + let token = nanoid::nanoid!(10); + self.token = ActiveValue::Set(token); + } + Ok(self) + } +} + +impl Model { + pub async fn find_rss_feed_by_token( + ctx: &dyn AppContextTrait, + token: &str, + api_base: &Url, + ) -> RecorderResult { + let db = ctx.db(); + + let feed_model = Entity::find() + .filter(Column::Token.eq(token)) + .filter(Column::FeedType.eq(FeedType::Rss)) + .one(db) + .await? + .ok_or(RecorderError::ModelEntityNotFound { + entity: "Feed".into(), + })?; + + let feed = Feed::from_model(ctx, feed_model).await?; + + feed.into_rss_channel(ctx, api_base) + } +} diff --git a/apps/recorder/src/models/feeds/registry.rs b/apps/recorder/src/models/feeds/registry.rs new file mode 100644 index 0000000..90cc63f --- /dev/null +++ b/apps/recorder/src/models/feeds/registry.rs @@ -0,0 +1,54 @@ +use rss::Channel; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use url::Url; + +use crate::{ + app::AppContextTrait, + errors::{RecorderError, RecorderResult}, + models::{ + episodes, + feeds::{self, FeedSource, RssFeedTrait, SubscriptionEpisodesFeed}, + subscriptions, + }, +}; + +pub enum Feed { + SubscritpionEpisodes(SubscriptionEpisodesFeed), +} + +impl Feed { + pub async fn from_model(ctx: &dyn AppContextTrait, m: feeds::Model) -> RecorderResult { + match m.feed_source { + FeedSource::SubscriptionEpisode => { + let db = ctx.db(); + let (subscription, episodes) = if let Some(subscription_id) = m.subscription_id + && let Some((subscription, episodes)) = subscriptions::Entity::find() + .filter(subscriptions::Column::Id.eq(subscription_id)) + .find_with_related(episodes::Entity) + .all(db) + .await? + .pop() + { + (subscription, episodes) + } else { + return Err(RecorderError::ModelEntityNotFound { + entity: "Subscription".into(), + }); + }; + Ok(Feed::SubscritpionEpisodes( + SubscriptionEpisodesFeed::from_model(m, subscription, episodes), + )) + } + } + } + + pub fn into_rss_channel( + self, + ctx: &dyn AppContextTrait, + api_base: &Url, + ) -> RecorderResult { + match self { + Self::SubscritpionEpisodes(feed) => feed.into_channel(ctx, api_base), + } + } +} diff --git a/apps/recorder/src/models/feeds/rss.rs b/apps/recorder/src/models/feeds/rss.rs new file mode 100644 index 0000000..f1f4df1 --- /dev/null +++ b/apps/recorder/src/models/feeds/rss.rs @@ -0,0 +1,142 @@ +use std::borrow::Cow; + +use chrono::{DateTime, Utc}; +use downloader::bittorrent::BITTORRENT_MIME_TYPE; +use maplit::btreemap; +use rss::{ + Channel, ChannelBuilder, EnclosureBuilder, GuidBuilder, Item, ItemBuilder, + extension::{ExtensionBuilder, ExtensionMap}, +}; +use url::Url; + +use crate::{ + app::AppContextTrait, + errors::{RecorderError, RecorderResult}, +}; + +pub trait RssFeedItemTrait: Sized { + fn get_guid_value(&self) -> Cow<'_, str>; + fn get_title(&self) -> Cow<'_, str>; + fn get_description(&self) -> Cow<'_, str>; + fn get_link(&self, ctx: &dyn AppContextTrait, api_base: &Url) -> Option>; + fn get_enclosure_mime(&self) -> Option>; + fn get_enclosure_link(&self, ctx: &dyn AppContextTrait, api_base: &Url) + -> Option>; + fn get_enclosure_pub_date(&self) -> Option>; + fn get_enclosure_content_length(&self) -> Option; + fn into_item(self, ctx: &dyn AppContextTrait, api_base: &Url) -> RecorderResult { + let enclosure_mime_type = + self.get_enclosure_mime() + .ok_or_else(|| RecorderError::MikanRssInvalidFieldError { + field: "enclosure_mime_type".into(), + source: None.into(), + })?; + let enclosure_link = self.get_enclosure_link(ctx, api_base).ok_or_else(|| { + RecorderError::MikanRssInvalidFieldError { + field: "enclosure_link".into(), + source: None.into(), + } + })?; + let enclosure_content_length = self.get_enclosure_content_length().ok_or_else(|| { + RecorderError::MikanRssInvalidFieldError { + field: "enclosure_content_length".into(), + source: None.into(), + } + })?; + let enclosure_pub_date = self.get_enclosure_pub_date().ok_or_else(|| { + RecorderError::MikanRssInvalidFieldError { + field: "enclosure_pub_date".into(), + source: None.into(), + } + })?; + let link = self.get_link(ctx, api_base).ok_or_else(|| { + RecorderError::MikanRssInvalidFieldError { + field: "link".into(), + source: None.into(), + } + })?; + + let mut extensions = ExtensionMap::default(); + if enclosure_mime_type == BITTORRENT_MIME_TYPE { + extensions.insert( + "torrent".to_string(), + btreemap! { + "link".to_string() => vec![ + ExtensionBuilder::default().name( + "link" + ).value(enclosure_link.to_string()).build() + ], + "contentLength".to_string() => vec![ + ExtensionBuilder::default().name( + "contentLength" + ).value(enclosure_content_length.to_string()).build() + ], + "pubDate".to_string() => vec![ + ExtensionBuilder::default().name( + "pubDate" + ).value(enclosure_pub_date.to_rfc3339()).build() + ], + }, + ); + }; + + let enclosure = EnclosureBuilder::default() + .mime_type(enclosure_mime_type) + .url(enclosure_link.to_string()) + .length(enclosure_content_length.to_string()) + .build(); + + let guid = GuidBuilder::default() + .value(self.get_guid_value()) + .permalink(false) + .build(); + + let item = ItemBuilder::default() + .guid(guid) + .title(self.get_title().to_string()) + .description(self.get_description().to_string()) + .link(link.to_string()) + .enclosure(enclosure) + .pub_date(enclosure_pub_date.to_rfc3339()) + .extensions(extensions) + .build(); + + Ok(item) + } +} + +pub trait RssFeedTrait: Sized { + type Item: RssFeedItemTrait; + + fn get_description(&self) -> Cow<'_, str>; + + fn get_title(&self) -> Cow<'_, str>; + + fn get_link(&self, ctx: &dyn AppContextTrait, api_base: &Url) -> Option>; + + fn items(&self) -> impl Iterator; + + fn into_items(self) -> impl Iterator; + + fn into_channel(self, ctx: &dyn AppContextTrait, api_base: &Url) -> RecorderResult { + let link = self.get_link(ctx, api_base).ok_or_else(|| { + RecorderError::MikanRssInvalidFieldError { + field: "link".into(), + source: None.into(), + } + })?; + + let channel = ChannelBuilder::default() + .title(self.get_title()) + .link(link.to_string()) + .description(self.get_description()) + .items({ + self.into_items() + .map(|item| item.into_item(ctx, api_base)) + .collect::>>()? + }) + .build(); + + Ok(channel) + } +} diff --git a/apps/recorder/src/models/feeds/subscription_episodes_feed.rs b/apps/recorder/src/models/feeds/subscription_episodes_feed.rs new file mode 100644 index 0000000..1839b43 --- /dev/null +++ b/apps/recorder/src/models/feeds/subscription_episodes_feed.rs @@ -0,0 +1,114 @@ +use std::borrow::Cow; + +use chrono::{DateTime, Utc}; +use downloader::bittorrent::BITTORRENT_MIME_TYPE; +use url::Url; + +use crate::{ + app::{AppContextTrait, PROJECT_NAME}, + models::{ + episodes, + feeds::{ + self, + rss::{RssFeedItemTrait, RssFeedTrait}, + }, + subscriptions, + }, + web::controller, +}; + +pub struct SubscriptionEpisodesFeed { + pub feed: feeds::Model, + pub subscription: subscriptions::Model, + pub episodes: Vec, +} + +impl SubscriptionEpisodesFeed { + pub fn from_model( + feed: feeds::Model, + subscription: subscriptions::Model, + episodes: Vec, + ) -> Self { + Self { + feed, + subscription, + episodes, + } + } +} + +impl RssFeedItemTrait for episodes::Model { + fn get_guid_value(&self) -> Cow<'_, str> { + Cow::Owned(format!("{PROJECT_NAME}:episode:{}", self.id)) + } + + fn get_title(&self) -> Cow<'_, str> { + Cow::Borrowed(&self.display_name) + } + + fn get_description(&self) -> Cow<'_, str> { + Cow::Borrowed(&self.display_name) + } + + fn get_link(&self, _ctx: &dyn AppContextTrait, _api_base: &Url) -> Option> { + self.homepage.as_deref().map(Cow::Borrowed) + } + + fn get_enclosure_mime(&self) -> Option> { + if self.enclosure_torrent_link.is_some() { + Some(Cow::Borrowed(BITTORRENT_MIME_TYPE)) + } else { + None + } + } + + fn get_enclosure_link( + &self, + _ctx: &dyn AppContextTrait, + _api_base: &Url, + ) -> Option> { + self.enclosure_torrent_link.as_deref().map(Cow::Borrowed) + } + + fn get_enclosure_pub_date(&self) -> Option> { + self.enclosure_pub_date + } + + fn get_enclosure_content_length(&self) -> Option { + self.enclosure_content_length + } +} + +impl RssFeedTrait for SubscriptionEpisodesFeed { + type Item = episodes::Model; + + fn get_description(&self) -> Cow<'_, str> { + Cow::Owned(format!( + "{PROJECT_NAME} - episodes of subscription \"{}\"", + self.subscription.display_name + )) + } + + fn get_title(&self) -> Cow<'_, str> { + Cow::Owned(format!("{PROJECT_NAME} - subscription episodes")) + } + + fn get_link(&self, _ctx: &dyn AppContextTrait, api_base: &Url) -> Option> { + let api_base = api_base + .join(&format!( + "{}/{}", + controller::feeds::CONTROLLER_PREFIX, + self.feed.token + )) + .ok()?; + Some(Cow::Owned(api_base.to_string())) + } + + fn items(&self) -> impl Iterator { + self.episodes.iter() + } + + fn into_items(self) -> impl Iterator { + self.episodes.into_iter() + } +} diff --git a/apps/recorder/src/models/mod.rs b/apps/recorder/src/models/mod.rs index 3ef7b58..adbd234 100644 --- a/apps/recorder/src/models/mod.rs +++ b/apps/recorder/src/models/mod.rs @@ -4,6 +4,7 @@ pub mod credential_3rd; pub mod downloaders; pub mod downloads; pub mod episodes; +pub mod feeds; pub mod query; pub mod subscriber_tasks; pub mod subscribers; diff --git a/apps/recorder/src/models/subscribers.rs b/apps/recorder/src/models/subscribers.rs index 71fd95a..010b7c3 100644 --- a/apps/recorder/src/models/subscribers.rs +++ b/apps/recorder/src/models/subscribers.rs @@ -3,11 +3,11 @@ use sea_orm::{ActiveValue, FromJsonQueryResult, TransactionTrait, entity::prelud use serde::{Deserialize, Serialize}; use crate::{ - app::AppContextTrait, + app::{AppContextTrait, PROJECT_NAME}, errors::app_error::{RecorderError, RecorderResult}, }; -pub const SEED_SUBSCRIBER: &str = "konobangu"; +pub const SEED_SUBSCRIBER: &str = PROJECT_NAME; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)] pub struct SubscriberBangumiConfig { diff --git a/apps/recorder/src/models/subscriptions.rs b/apps/recorder/src/models/subscriptions.rs deleted file mode 100644 index a0ff808..0000000 --- a/apps/recorder/src/models/subscriptions.rs +++ /dev/null @@ -1,311 +0,0 @@ -use std::{fmt::Debug, sync::Arc}; - -use async_trait::async_trait; -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -use crate::{ - app::AppContextTrait, - errors::{RecorderError, RecorderResult}, - extract::mikan::{ - MikanBangumiSubscription, MikanSeasonSubscription, MikanSubscriberSubscription, - }, -}; - -#[derive( - Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, DeriveDisplay, -)] -#[sea_orm( - rs_type = "String", - db_type = "Enum", - enum_name = "subscription_category" -)] -#[serde(rename_all = "snake_case")] -pub enum SubscriptionCategory { - #[sea_orm(string_value = "mikan_subscriber")] - MikanSubscriber, - #[sea_orm(string_value = "mikan_season")] - MikanSeason, - #[sea_orm(string_value = "mikan_bangumi")] - MikanBangumi, - #[sea_orm(string_value = "manual")] - Manual, -} - -#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] -#[sea_orm(table_name = "subscriptions")] -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 display_name: String, - pub subscriber_id: i32, - pub category: SubscriptionCategory, - pub source_url: String, - pub enabled: bool, - pub credential_id: Option, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[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::bangumi::Entity")] - Bangumi, - #[sea_orm(has_many = "super::episodes::Entity")] - Episodes, - #[sea_orm(has_many = "super::subscription_episode::Entity")] - SubscriptionEpisode, - #[sea_orm(has_many = "super::subscription_bangumi::Entity")] - SubscriptionBangumi, - #[sea_orm( - belongs_to = "super::credential_3rd::Entity", - from = "Column::CredentialId", - to = "super::credential_3rd::Column::Id", - on_update = "Cascade", - on_delete = "SetNull" - )] - Credential3rd, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Subscriber.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::SubscriptionBangumi.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::SubscriptionEpisode.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - super::subscription_bangumi::Relation::Bangumi.def() - } - - fn via() -> Option { - Some( - super::subscription_bangumi::Relation::Subscription - .def() - .rev(), - ) - } -} - -impl Related for Entity { - fn to() -> RelationDef { - super::subscription_episode::Relation::Episode.def() - } - - fn via() -> Option { - Some( - super::subscription_episode::Relation::Subscription - .def() - .rev(), - ) - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Credential3rd.def() - } -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)] -pub enum RelatedEntity { - #[sea_orm(entity = "super::subscribers::Entity")] - Subscriber, - #[sea_orm(entity = "super::bangumi::Entity")] - Bangumi, - #[sea_orm(entity = "super::episodes::Entity")] - Episode, - #[sea_orm(entity = "super::subscription_episode::Entity")] - SubscriptionEpisode, - #[sea_orm(entity = "super::subscription_bangumi::Entity")] - SubscriptionBangumi, - #[sea_orm(entity = "super::credential_3rd::Entity")] - Credential3rd, -} - -#[async_trait] -impl ActiveModelBehavior for ActiveModel {} - -impl ActiveModel {} - -impl Model { - pub async fn toggle_with_ids( - ctx: &dyn AppContextTrait, - ids: impl Iterator, - enabled: bool, - ) -> RecorderResult<()> { - let db = ctx.db(); - Entity::update_many() - .col_expr(Column::Enabled, Expr::value(enabled)) - .filter(Column::Id.is_in(ids)) - .exec(db) - .await?; - Ok(()) - } - - pub async fn delete_with_ids( - ctx: &dyn AppContextTrait, - ids: impl Iterator, - ) -> RecorderResult<()> { - let db = ctx.db(); - Entity::delete_many() - .filter(Column::Id.is_in(ids)) - .exec(db) - .await?; - Ok(()) - } - - pub async fn find_by_id_and_subscriber_id( - ctx: &dyn AppContextTrait, - subscriber_id: i32, - subscription_id: i32, - ) -> RecorderResult { - let db = ctx.db(); - let subscription_model = Entity::find_by_id(subscription_id) - .one(db) - .await? - .ok_or_else(|| RecorderError::ModelEntityNotFound { - entity: "Subscription".into(), - })?; - - if subscription_model.subscriber_id != subscriber_id { - Err(RecorderError::ModelEntityNotFound { - entity: "Subscription".into(), - })?; - } - - Ok(subscription_model) - } -} - -#[async_trait] -pub trait SubscriptionTrait: Sized + Debug { - fn get_subscriber_id(&self) -> i32; - - fn get_subscription_id(&self) -> i32; - - async fn sync_feeds_incremental(&self, ctx: Arc) -> RecorderResult<()>; - - async fn sync_feeds_full(&self, ctx: Arc) -> RecorderResult<()>; - - async fn sync_sources(&self, ctx: Arc) -> RecorderResult<()>; - - fn try_from_model(model: &Model) -> RecorderResult; -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "category")] -pub enum Subscription { - #[serde(rename = "mikan_subscriber")] - MikanSubscriber(MikanSubscriberSubscription), - #[serde(rename = "mikan_season")] - MikanSeason(MikanSeasonSubscription), - #[serde(rename = "mikan_bangumi")] - MikanBangumi(MikanBangumiSubscription), - #[serde(rename = "manual")] - Manual, -} - -impl Subscription { - pub fn category(&self) -> SubscriptionCategory { - match self { - Self::MikanSubscriber(_) => SubscriptionCategory::MikanSubscriber, - Self::MikanSeason(_) => SubscriptionCategory::MikanSeason, - Self::MikanBangumi(_) => SubscriptionCategory::MikanBangumi, - Self::Manual => SubscriptionCategory::Manual, - } - } -} - -#[async_trait] -impl SubscriptionTrait for Subscription { - fn get_subscriber_id(&self) -> i32 { - match self { - Self::MikanSubscriber(subscription) => subscription.get_subscriber_id(), - Self::MikanSeason(subscription) => subscription.get_subscriber_id(), - Self::MikanBangumi(subscription) => subscription.get_subscriber_id(), - Self::Manual => unreachable!(), - } - } - - fn get_subscription_id(&self) -> i32 { - match self { - Self::MikanSubscriber(subscription) => subscription.get_subscription_id(), - Self::MikanSeason(subscription) => subscription.get_subscription_id(), - Self::MikanBangumi(subscription) => subscription.get_subscription_id(), - Self::Manual => unreachable!(), - } - } - - async fn sync_feeds_incremental(&self, ctx: Arc) -> RecorderResult<()> { - match self { - Self::MikanSubscriber(subscription) => subscription.sync_feeds_incremental(ctx).await, - Self::MikanSeason(subscription) => subscription.sync_feeds_incremental(ctx).await, - Self::MikanBangumi(subscription) => subscription.sync_feeds_incremental(ctx).await, - Self::Manual => Ok(()), - } - } - - async fn sync_feeds_full(&self, ctx: Arc) -> RecorderResult<()> { - match self { - Self::MikanSubscriber(subscription) => subscription.sync_feeds_full(ctx).await, - Self::MikanSeason(subscription) => subscription.sync_feeds_full(ctx).await, - Self::MikanBangumi(subscription) => subscription.sync_feeds_full(ctx).await, - Self::Manual => Ok(()), - } - } - - async fn sync_sources(&self, ctx: Arc) -> RecorderResult<()> { - match self { - Self::MikanSubscriber(subscription) => subscription.sync_sources(ctx).await, - Self::MikanSeason(subscription) => subscription.sync_sources(ctx).await, - Self::MikanBangumi(subscription) => subscription.sync_sources(ctx).await, - Self::Manual => Ok(()), - } - } - - fn try_from_model(model: &Model) -> RecorderResult { - match model.category { - SubscriptionCategory::MikanSubscriber => { - MikanSubscriberSubscription::try_from_model(model).map(Self::MikanSubscriber) - } - SubscriptionCategory::MikanSeason => { - MikanSeasonSubscription::try_from_model(model).map(Self::MikanSeason) - } - SubscriptionCategory::MikanBangumi => { - MikanBangumiSubscription::try_from_model(model).map(Self::MikanBangumi) - } - SubscriptionCategory::Manual => Ok(Self::Manual), - } - } -} - -impl TryFrom<&Model> for Subscription { - type Error = RecorderError; - - fn try_from(model: &Model) -> Result { - Self::try_from_model(model) - } -} diff --git a/apps/recorder/src/models/subscriptions/core.rs b/apps/recorder/src/models/subscriptions/core.rs new file mode 100644 index 0000000..504ea2c --- /dev/null +++ b/apps/recorder/src/models/subscriptions/core.rs @@ -0,0 +1,20 @@ +use std::{fmt::Debug, sync::Arc}; + +use async_trait::async_trait; + +use crate::{app::AppContextTrait, errors::RecorderResult, models::subscriptions}; + +#[async_trait] +pub trait SubscriptionTrait: Sized + Debug { + fn get_subscriber_id(&self) -> i32; + + fn get_subscription_id(&self) -> i32; + + async fn sync_feeds_incremental(&self, ctx: Arc) -> RecorderResult<()>; + + async fn sync_feeds_full(&self, ctx: Arc) -> RecorderResult<()>; + + async fn sync_sources(&self, ctx: Arc) -> RecorderResult<()>; + + fn try_from_model(model: &subscriptions::Model) -> RecorderResult; +} diff --git a/apps/recorder/src/models/subscriptions/mod.rs b/apps/recorder/src/models/subscriptions/mod.rs new file mode 100644 index 0000000..6371d59 --- /dev/null +++ b/apps/recorder/src/models/subscriptions/mod.rs @@ -0,0 +1,185 @@ +mod core; +mod registry; +pub use core::SubscriptionTrait; +use std::fmt::Debug; + +use async_trait::async_trait; +pub use registry::{ + Subscription, SubscriptionCategory, SubscriptionCategoryEnum, SubscriptionCategoryIter, + SubscriptionCategoryVariant, SubscriptionCategoryVariantIter, +}; +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::{ + app::AppContextTrait, + errors::{RecorderError, RecorderResult}, +}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "subscriptions")] +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 display_name: String, + pub subscriber_id: i32, + pub category: SubscriptionCategory, + pub source_url: String, + pub enabled: bool, + pub credential_id: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[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::bangumi::Entity")] + Bangumi, + #[sea_orm(has_many = "super::episodes::Entity")] + Episodes, + #[sea_orm(has_many = "super::subscription_episode::Entity")] + SubscriptionEpisode, + #[sea_orm(has_many = "super::subscription_bangumi::Entity")] + SubscriptionBangumi, + #[sea_orm( + belongs_to = "super::credential_3rd::Entity", + from = "Column::CredentialId", + to = "super::credential_3rd::Column::Id", + on_update = "Cascade", + on_delete = "SetNull" + )] + Credential3rd, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Subscriber.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::SubscriptionBangumi.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::SubscriptionEpisode.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::subscription_bangumi::Relation::Bangumi.def() + } + + fn via() -> Option { + Some( + super::subscription_bangumi::Relation::Subscription + .def() + .rev(), + ) + } +} + +impl Related for Entity { + fn to() -> RelationDef { + super::subscription_episode::Relation::Episode.def() + } + + fn via() -> Option { + Some( + super::subscription_episode::Relation::Subscription + .def() + .rev(), + ) + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Credential3rd.def() + } +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelatedEntity)] +pub enum RelatedEntity { + #[sea_orm(entity = "super::subscribers::Entity")] + Subscriber, + #[sea_orm(entity = "super::bangumi::Entity")] + Bangumi, + #[sea_orm(entity = "super::episodes::Entity")] + Episode, + #[sea_orm(entity = "super::subscription_episode::Entity")] + SubscriptionEpisode, + #[sea_orm(entity = "super::subscription_bangumi::Entity")] + SubscriptionBangumi, + #[sea_orm(entity = "super::credential_3rd::Entity")] + Credential3rd, +} + +#[async_trait] +impl ActiveModelBehavior for ActiveModel {} + +impl ActiveModel {} + +impl Model { + pub async fn toggle_with_ids( + ctx: &dyn AppContextTrait, + ids: impl Iterator, + enabled: bool, + ) -> RecorderResult<()> { + let db = ctx.db(); + Entity::update_many() + .col_expr(Column::Enabled, Expr::value(enabled)) + .filter(Column::Id.is_in(ids)) + .exec(db) + .await?; + Ok(()) + } + + pub async fn delete_with_ids( + ctx: &dyn AppContextTrait, + ids: impl Iterator, + ) -> RecorderResult<()> { + let db = ctx.db(); + Entity::delete_many() + .filter(Column::Id.is_in(ids)) + .exec(db) + .await?; + Ok(()) + } + + pub async fn find_by_id_and_subscriber_id( + ctx: &dyn AppContextTrait, + subscriber_id: i32, + subscription_id: i32, + ) -> RecorderResult { + let db = ctx.db(); + let subscription_model = Entity::find_by_id(subscription_id) + .one(db) + .await? + .ok_or_else(|| RecorderError::ModelEntityNotFound { + entity: "Subscription".into(), + })?; + + if subscription_model.subscriber_id != subscriber_id { + Err(RecorderError::ModelEntityNotFound { + entity: "Subscription".into(), + })?; + } + + Ok(subscription_model) + } +} diff --git a/apps/recorder/src/models/subscriptions/registry.rs b/apps/recorder/src/models/subscriptions/registry.rs new file mode 100644 index 0000000..432225a --- /dev/null +++ b/apps/recorder/src/models/subscriptions/registry.rs @@ -0,0 +1,129 @@ +use std::{fmt::Debug, sync::Arc}; + +use async_trait::async_trait; +use sea_orm::{DeriveActiveEnum, DeriveDisplay, EnumIter}; +use serde::{Deserialize, Serialize}; + +use crate::{ + app::AppContextTrait, + errors::{RecorderError, RecorderResult}, + extract::mikan::{ + MikanBangumiSubscription, MikanSeasonSubscription, MikanSubscriberSubscription, + }, + models::subscriptions::{self, SubscriptionTrait}, +}; + +#[derive( + Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, DeriveDisplay, +)] +#[sea_orm( + rs_type = "String", + db_type = "Enum", + enum_name = "subscription_category" +)] +#[serde(rename_all = "snake_case")] +pub enum SubscriptionCategory { + #[sea_orm(string_value = "mikan_subscriber")] + MikanSubscriber, + #[sea_orm(string_value = "mikan_season")] + MikanSeason, + #[sea_orm(string_value = "mikan_bangumi")] + MikanBangumi, + #[sea_orm(string_value = "manual")] + Manual, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "category")] +pub enum Subscription { + #[serde(rename = "mikan_subscriber")] + MikanSubscriber(MikanSubscriberSubscription), + #[serde(rename = "mikan_season")] + MikanSeason(MikanSeasonSubscription), + #[serde(rename = "mikan_bangumi")] + MikanBangumi(MikanBangumiSubscription), + #[serde(rename = "manual")] + Manual, +} + +impl Subscription { + pub fn category(&self) -> SubscriptionCategory { + match self { + Self::MikanSubscriber(_) => SubscriptionCategory::MikanSubscriber, + Self::MikanSeason(_) => SubscriptionCategory::MikanSeason, + Self::MikanBangumi(_) => SubscriptionCategory::MikanBangumi, + Self::Manual => SubscriptionCategory::Manual, + } + } +} + +#[async_trait] +impl SubscriptionTrait for Subscription { + fn get_subscriber_id(&self) -> i32 { + match self { + Self::MikanSubscriber(subscription) => subscription.get_subscriber_id(), + Self::MikanSeason(subscription) => subscription.get_subscriber_id(), + Self::MikanBangumi(subscription) => subscription.get_subscriber_id(), + Self::Manual => unreachable!(), + } + } + + fn get_subscription_id(&self) -> i32 { + match self { + Self::MikanSubscriber(subscription) => subscription.get_subscription_id(), + Self::MikanSeason(subscription) => subscription.get_subscription_id(), + Self::MikanBangumi(subscription) => subscription.get_subscription_id(), + Self::Manual => unreachable!(), + } + } + + async fn sync_feeds_incremental(&self, ctx: Arc) -> RecorderResult<()> { + match self { + Self::MikanSubscriber(subscription) => subscription.sync_feeds_incremental(ctx).await, + Self::MikanSeason(subscription) => subscription.sync_feeds_incremental(ctx).await, + Self::MikanBangumi(subscription) => subscription.sync_feeds_incremental(ctx).await, + Self::Manual => Ok(()), + } + } + + async fn sync_feeds_full(&self, ctx: Arc) -> RecorderResult<()> { + match self { + Self::MikanSubscriber(subscription) => subscription.sync_feeds_full(ctx).await, + Self::MikanSeason(subscription) => subscription.sync_feeds_full(ctx).await, + Self::MikanBangumi(subscription) => subscription.sync_feeds_full(ctx).await, + Self::Manual => Ok(()), + } + } + + async fn sync_sources(&self, ctx: Arc) -> RecorderResult<()> { + match self { + Self::MikanSubscriber(subscription) => subscription.sync_sources(ctx).await, + Self::MikanSeason(subscription) => subscription.sync_sources(ctx).await, + Self::MikanBangumi(subscription) => subscription.sync_sources(ctx).await, + Self::Manual => Ok(()), + } + } + + fn try_from_model(model: &subscriptions::Model) -> RecorderResult { + match model.category { + SubscriptionCategory::MikanSubscriber => { + MikanSubscriberSubscription::try_from_model(model).map(Self::MikanSubscriber) + } + SubscriptionCategory::MikanSeason => { + MikanSeasonSubscription::try_from_model(model).map(Self::MikanSeason) + } + SubscriptionCategory::MikanBangumi => { + MikanBangumiSubscription::try_from_model(model).map(Self::MikanBangumi) + } + SubscriptionCategory::Manual => Ok(Self::Manual), + } + } +} + +impl TryFrom<&subscriptions::Model> for Subscription { + type Error = RecorderError; + + fn try_from(model: &subscriptions::Model) -> Result { + Self::try_from_model(model) + } +} diff --git a/apps/recorder/src/web/controller/feeds/mod.rs b/apps/recorder/src/web/controller/feeds/mod.rs new file mode 100644 index 0000000..694bdfd --- /dev/null +++ b/apps/recorder/src/web/controller/feeds/mod.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use axum::{ + Extension, Router, + extract::{Path, State}, + response::IntoResponse, + routing::get, +}; +use http::StatusCode; + +use crate::{ + app::AppContextTrait, + errors::{RecorderError, RecorderResult}, + extract::http::ForwardedRelatedInfo, + models::feeds, + web::controller::Controller, +}; + +pub const CONTROLLER_PREFIX: &str = "/api/feeds"; + +async fn rss_handler( + State(ctx): State>, + Path(token): Path, + forwarded_info: Extension, +) -> RecorderResult { + let api_base = forwarded_info + .resolved_origin() + .ok_or(RecorderError::MissingOriginError)?; + let channel = feeds::Model::find_rss_feed_by_token(ctx.as_ref(), &token, &api_base).await?; + Ok(( + StatusCode::OK, + [("Content-Type", "application/rss+xml")], + channel.to_string(), + )) +} + +pub async fn create(_ctx: Arc) -> RecorderResult { + let router = Router::>::new().route("rss/{token}", get(rss_handler)); + + Ok(Controller::from_prefix(CONTROLLER_PREFIX, router)) +} diff --git a/apps/recorder/src/web/controller/mod.rs b/apps/recorder/src/web/controller/mod.rs index 0508dde..be777ed 100644 --- a/apps/recorder/src/web/controller/mod.rs +++ b/apps/recorder/src/web/controller/mod.rs @@ -1,4 +1,5 @@ pub mod core; +pub mod feeds; pub mod graphql; pub mod metadata; pub mod oidc; diff --git a/apps/recorder/src/web/controller/oidc/mod.rs b/apps/recorder/src/web/controller/oidc/mod.rs index eb1e904..a780533 100644 --- a/apps/recorder/src/web/controller/oidc/mod.rs +++ b/apps/recorder/src/web/controller/oidc/mod.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use axum::{ - Json, Router, - extract::{Query, Request, State}, + Extension, Json, Router, + extract::{Query, State}, routing::get, }; use snafu::ResultExt; @@ -42,12 +42,11 @@ async fn oidc_callback( async fn oidc_auth( State(ctx): State>, - request: Request, + forwarded_info: Extension, ) -> Result, AuthError> { let auth_service = ctx.auth(); if let AuthService::Oidc(oidc_auth_service) = auth_service { - let (parts, _) = request.into_parts(); - let mut redirect_uri = ForwardedRelatedInfo::from_request_parts(&parts) + let mut redirect_uri = forwarded_info .resolved_origin() .ok_or(url::ParseError::EmptyHost) .context(OidcRequestRedirectUriSnafu)?;