feat: add rss feeds and episode enclosure

This commit is contained in:
master 2025-06-22 01:04:23 +08:00
parent 16429a44b4
commit f055011b86
44 changed files with 1466 additions and 480 deletions

View File

@ -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()); let episode_torrent_doppel_path = MikanDoppelPath::new(episode_torrent_url.clone());
tracing::info!(title = rss_item.title, "Scraping episode torrent..."); tracing::info!(title = rss_item.title, "Scraping episode torrent...");
if !episode_torrent_doppel_path.exists_any() { if !episode_torrent_doppel_path.exists_any() {

View File

@ -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()); let episode_torrent_doppel_path = MikanDoppelPath::new(episode_torrent_url.clone());
tracing::info!(title = rss_item.title, "Scraping episode torrent..."); tracing::info!(title = rss_item.title, "Scraping episode torrent...");
if !episode_torrent_doppel_path.exists_any() { 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 = let episode_torrent_doppel_path =
MikanDoppelPath::new(episode_torrent_url.clone()); MikanDoppelPath::new(episode_torrent_url.clone());
tracing::info!(title = rss_item.title, "Scraping episode torrent..."); tracing::info!(title = rss_item.title, "Scraping episode torrent...");

View File

@ -13,6 +13,8 @@ use crate::{
}, },
}; };
pub const PROJECT_NAME: &str = "konobangu";
pub struct App { pub struct App {
pub context: Arc<dyn AppContextTrait>, pub context: Arc<dyn AppContextTrait>,
pub builder: AppBuilder, pub builder: AppBuilder,

View File

@ -4,7 +4,7 @@ pub mod context;
pub mod core; pub mod core;
pub mod env; pub mod env;
pub use core::App; pub use core::{App, PROJECT_NAME};
pub use builder::AppBuilder; pub use builder::AppBuilder;
pub use config::AppConfig; pub use config::AppConfig;

View File

@ -9,7 +9,7 @@ use super::{
service::{AuthServiceTrait, AuthUserInfo}, service::{AuthServiceTrait, AuthUserInfo},
}; };
use crate::{ use crate::{
app::AppContextTrait, app::{AppContextTrait, PROJECT_NAME},
models::{auth::AuthType, subscribers::SEED_SUBSCRIBER}, models::{auth::AuthType, subscribers::SEED_SUBSCRIBER},
}; };
@ -86,7 +86,7 @@ impl AuthServiceTrait for BasicAuthService {
} }
fn www_authenticate_header_value(&self) -> Option<HeaderValue> { fn www_authenticate_header_value(&self) -> Option<HeaderValue> {
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 { fn auth_type(&self) -> AuthType {

View File

@ -32,7 +32,11 @@ use super::{
errors::{AuthError, OidcProviderUrlSnafu, OidcRequestRedirectUriSnafu}, errors::{AuthError, OidcProviderUrlSnafu, OidcRequestRedirectUriSnafu},
service::{AuthServiceTrait, AuthUserInfo}, 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<HttpClient>); pub struct OidcHttpClient(pub Arc<HttpClient>);
@ -351,7 +355,7 @@ impl AuthServiceTrait for OidcAuthService {
} }
fn www_authenticate_header_value(&self) -> Option<HeaderValue> { fn www_authenticate_header_value(&self) -> Option<HeaderValue> {
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 { fn auth_type(&self) -> AuthType {

View File

@ -47,8 +47,12 @@ pub enum RecorderError {
RegexError { source: regex::Error }, RegexError { source: regex::Error },
#[snafu(display("Invalid method"))] #[snafu(display("Invalid method"))]
InvalidMethodError, InvalidMethodError,
#[snafu(display("Invalid header value"))]
InvalidHeaderValueError,
#[snafu(display("Invalid header name"))] #[snafu(display("Invalid header name"))]
InvalidHeaderNameError, InvalidHeaderNameError,
#[snafu(display("Missing origin (protocol or host) in headers and forwarded info"))]
MissingOriginError,
#[snafu(transparent)] #[snafu(transparent)]
TracingAppenderInitError { TracingAppenderInitError {
source: tracing_appender::rolling::InitError, source: tracing_appender::rolling::InitError,
@ -87,8 +91,6 @@ pub enum RecorderError {
#[snafu(source(from(opendal::Error, Box::new)))] #[snafu(source(from(opendal::Error, Box::new)))]
source: Box<opendal::Error>, source: Box<opendal::Error>,
}, },
#[snafu(display("Invalid header value"))]
InvalidHeaderValueError,
#[snafu(transparent)] #[snafu(transparent)]
HttpClientError { source: HttpClientError }, HttpClientError { source: HttpClientError },
#[cfg(feature = "testcontainers")] #[cfg(feature = "testcontainers")]
@ -248,6 +250,11 @@ impl IntoResponse for RecorderError {
) )
.into_response() .into_response()
} }
Self::ModelEntityNotFound { entity } => (
StatusCode::NOT_FOUND,
Json::<StandardErrorResponse>(StandardErrorResponse::from(entity.to_string())),
)
.into_response(),
err => ( err => (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json::<StandardErrorResponse>(StandardErrorResponse::from(err.to_string())), Json::<StandardErrorResponse>(StandardErrorResponse::from(err.to_string())),

View File

@ -1,3 +1,4 @@
use chrono::{DateTime, Utc};
use fancy_regex::Regex as FancyRegex; use fancy_regex::Regex as FancyRegex;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use quirks_path::Path; use quirks_path::Path;
@ -33,6 +34,14 @@ lazy_static! {
Regex::new(r"([Ss]|Season )(\d{1,3})").unwrap(); Regex::new(r"([Ss]|Season )(\d{1,3})").unwrap();
} }
#[derive(Clone, Debug)]
pub struct EpisodeEnclosureMeta {
pub magnet_link: Option<String>,
pub torrent_link: Option<String>,
pub pub_date: Option<DateTime<Utc>>,
pub content_length: Option<u64>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct TorrentEpisodeMediaMeta { pub struct TorrentEpisodeMediaMeta {
pub fansub: Option<String>, pub fansub: Option<String>,

View File

@ -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 itertools::Itertools;
use url::Url; 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) /// Fields from a "Forwarded" header per [RFC7239 sec 4](https://www.rfc-editor.org/rfc/rfc7239#section-4)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ForwardedHeader { pub struct ForwardedHeader {
@ -101,9 +106,13 @@ pub struct ForwardedRelatedInfo {
pub origin: Option<String>, pub origin: Option<String>,
} }
impl ForwardedRelatedInfo { impl<T> FromRequestParts<T> for ForwardedRelatedInfo {
pub fn from_request_parts(request_parts: &Parts) -> ForwardedRelatedInfo { type Rejection = RecorderError;
let headers = &request_parts.headers; fn from_request_parts(
parts: &mut Parts,
_state: &T,
) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
let headers = &parts.headers;
let forwarded = headers let forwarded = headers
.get(header::FORWARDED) .get(header::FORWARDED)
.and_then(|s| ForwardedHeader::try_from(s.clone()).ok()); .and_then(|s| ForwardedHeader::try_from(s.clone()).ok());
@ -132,17 +141,19 @@ impl ForwardedRelatedInfo {
.get(header::ORIGIN) .get(header::ORIGIN)
.and_then(|s| s.to_str().map(String::from).ok()); .and_then(|s| s.to_str().map(String::from).ok());
ForwardedRelatedInfo { futures::future::ready(Ok(ForwardedRelatedInfo {
host, host,
x_forwarded_for, x_forwarded_for,
x_forwarded_host, x_forwarded_host,
x_forwarded_proto, x_forwarded_proto,
forwarded, forwarded,
uri: request_parts.uri.clone(), uri: parts.uri.clone(),
origin, origin,
}))
} }
} }
impl ForwardedRelatedInfo {
pub fn resolved_protocol(&self) -> Option<&str> { pub fn resolved_protocol(&self) -> Option<&str> {
self.forwarded self.forwarded
.as_ref() .as_ref()

View File

@ -20,13 +20,16 @@ use super::scrape_mikan_bangumi_meta_stream_from_season_flow_url;
use crate::{ use crate::{
app::AppContextTrait, app::AppContextTrait,
errors::{RecorderError, RecorderResult}, errors::{RecorderError, RecorderResult},
extract::mikan::{ extract::{
bittorrent::EpisodeEnclosureMeta,
mikan::{
MikanBangumiHash, MikanBangumiMeta, MikanEpisodeHash, MikanEpisodeMeta, MikanBangumiHash, MikanBangumiMeta, MikanEpisodeHash, MikanEpisodeMeta,
MikanRssEpisodeItem, MikanSeasonFlowUrlMeta, MikanSeasonStr, MikanRssEpisodeItem, MikanSeasonFlowUrlMeta, MikanSeasonStr,
MikanSubscriberSubscriptionRssUrlMeta, build_mikan_bangumi_subscription_rss_url, MikanSubscriberSubscriptionRssUrlMeta, build_mikan_bangumi_subscription_rss_url,
build_mikan_season_flow_url, build_mikan_subscriber_subscription_rss_url, build_mikan_season_flow_url, build_mikan_subscriber_subscription_rss_url,
scrape_mikan_episode_meta_from_episode_homepage_url, scrape_mikan_episode_meta_from_episode_homepage_url,
}, },
},
models::{ models::{
bangumi, episodes, subscription_bangumi, subscription_episode, bangumi, episodes, subscription_bangumi, subscription_episode,
subscriptions::{self, SubscriptionTrait}, subscriptions::{self, SubscriptionTrait},
@ -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))) .map(|(episode_id, hash, bangumi_id)| (hash.mikan_episode_id, (episode_id, bangumi_id)))
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
let mut new_episode_meta_list: Vec<MikanEpisodeMeta> = vec![]; let mut new_episode_meta_list: Vec<(MikanEpisodeMeta, EpisodeEnclosureMeta)> = vec![];
let mikan_client = ctx.mikan(); let mikan_client = ctx.mikan();
for to_insert_rss_item in rss_item_list.into_iter().filter(|rss_item| { 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()), to_insert_rss_item.build_homepage_url(mikan_base_url.clone()),
) )
.await?; .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) (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< let new_episode_meta_list_group_by_bangumi_hash: HashMap<
MikanBangumiHash, MikanBangumiHash,
Vec<MikanEpisodeMeta>, Vec<(MikanEpisodeMeta, EpisodeEnclosureMeta)>,
> = { > = {
let mut m = hashmap! {}; 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(); let bangumi_hash = episode_meta.bangumi_hash();
m.entry(bangumi_hash) m.entry(bangumi_hash)
.or_insert_with(Vec::new) .or_insert_with(Vec::new)
.push(episode_meta); .push((episode_meta, episode_enclosure_meta));
} }
m m
}; };
for (group_bangumi_hash, group_episode_meta_list) in new_episode_meta_list_group_by_bangumi_hash 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( let group_bangumi_model = bangumi::Model::get_or_insert_from_mikan(
ctx, ctx,
group_bangumi_hash, group_bangumi_hash,
@ -126,9 +130,12 @@ async fn sync_mikan_feeds_from_rss_item_list(
}, },
) )
.await?; .await?;
let group_episode_creation_list = group_episode_meta_list let group_episode_creation_list =
group_episode_meta_list
.into_iter() .into_iter()
.map(|episode_meta| (&group_bangumi_model, episode_meta)); .map(|(episode_meta, episode_enclosure_meta)| {
(&group_bangumi_model, episode_meta, episode_enclosure_meta)
});
episodes::Model::add_mikan_episodes_for_subscription( episodes::Model::add_mikan_episodes_for_subscription(
ctx, 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 struct MikanSeasonSubscription {
pub subscription_id: i32, pub subscription_id: i32,
pub year: i32, pub year: i32,

View File

@ -2,7 +2,7 @@ use std::{borrow::Cow, fmt, str::FromStr, sync::Arc};
use async_stream::try_stream; use async_stream::try_stream;
use bytes::Bytes; use bytes::Bytes;
use chrono::DateTime; use chrono::{DateTime, Utc};
use downloader::bittorrent::defs::BITTORRENT_MIME_TYPE; use downloader::bittorrent::defs::BITTORRENT_MIME_TYPE;
use fetch::{html::fetch_html, image::fetch_image}; use fetch::{html::fetch_html, image::fetch_image};
use futures::{Stream, TryStreamExt, pin_mut}; use futures::{Stream, TryStreamExt, pin_mut};
@ -17,6 +17,7 @@ use crate::{
app::AppContextTrait, app::AppContextTrait,
errors::app_error::{RecorderError, RecorderResult}, errors::app_error::{RecorderError, RecorderResult},
extract::{ extract::{
bittorrent::EpisodeEnclosureMeta,
html::{extract_background_image_src_from_style_attr, extract_inner_text_from_element_ref}, html::{extract_background_image_src_from_style_attr, extract_inner_text_from_element_ref},
media::extract_image_src_from_str, media::extract_image_src_from_str,
mikan::{ mikan::{
@ -39,11 +40,12 @@ use crate::{
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MikanRssEpisodeItem { pub struct MikanRssEpisodeItem {
pub title: String, pub title: String,
pub url: Url, pub torrent_link: Url,
pub content_length: Option<u64>, pub content_length: Option<u64>,
pub mime: String, pub mime: String,
pub pub_date: Option<i64>, pub pub_date: Option<DateTime<Utc>>,
pub mikan_episode_id: String, pub mikan_episode_id: String,
pub magnet_link: Option<String>,
} }
impl MikanRssEpisodeItem { impl MikanRssEpisodeItem {
@ -95,18 +97,30 @@ impl TryFrom<rss::Item> for MikanRssEpisodeItem {
Ok(MikanRssEpisodeItem { Ok(MikanRssEpisodeItem {
title, title,
url: enclosure_url, torrent_link: enclosure_url,
content_length: enclosure.length.parse().ok(), content_length: enclosure.length.parse().ok(),
mime: mime_type, mime: mime_type,
pub_date: item pub_date: item.pub_date.and_then(|s| {
.pub_date DateTime::parse_from_rfc2822(&s)
.and_then(|s| DateTime::parse_from_rfc2822(&s).ok()) .ok()
.map(|s| s.timestamp_millis()), .map(|s| s.with_timezone(&Utc))
}),
mikan_episode_id, mikan_episode_id,
magnet_link: None,
}) })
} }
} }
impl From<MikanRssEpisodeItem> 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct MikanSubscriberSubscriptionRssUrlMeta { pub struct MikanSubscriberSubscriptionRssUrlMeta {
pub mikan_subscription_token: String, pub mikan_subscription_token: String,

View File

@ -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::<bangumi::Entity>(context, &bangumi::Column::SubscriberId);
}
pub fn register_bangumi_to_schema_builder(mut builder: SeaographyBuilder) -> SeaographyBuilder {
builder.register_enumeration::<bangumi::BangumiType>();
seaography::register_entity!(builder, bangumi);
builder
}

View File

@ -3,12 +3,22 @@ use std::sync::Arc;
use async_graphql::dynamic::{ use async_graphql::dynamic::{
Field, FieldFuture, FieldValue, InputObject, InputValue, Object, TypeRef, Field, FieldFuture, FieldValue, InputObject, InputValue, Object, TypeRef,
}; };
use seaography::Builder as SeaographyBuilder; use seaography::{Builder as SeaographyBuilder, BuilderContext};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use util_derive::DynamicGraphql; use util_derive::DynamicGraphql;
use crate::{ 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)] #[derive(DynamicGraphql, Serialize, Deserialize, Clone, Debug)]
@ -63,9 +73,52 @@ impl Credential3rdCheckAvailableInfo {
} }
} }
pub fn register_credential3rd_to_schema_context(
context: &mut BuilderContext,
ctx: Arc<dyn AppContextTrait>,
) {
restrict_subscriber_for_entity::<credential_3rd::Entity>(
context,
&credential_3rd::Column::SubscriberId,
);
register_crypto_column_input_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx.clone(),
&credential_3rd::Column::Cookies,
);
register_crypto_column_input_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx.clone(),
&credential_3rd::Column::Username,
);
register_crypto_column_input_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx.clone(),
&credential_3rd::Column::Password,
);
register_crypto_column_output_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx.clone(),
&credential_3rd::Column::Cookies,
);
register_crypto_column_output_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx.clone(),
&credential_3rd::Column::Username,
);
register_crypto_column_output_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx,
&credential_3rd::Column::Password,
);
}
pub fn register_credential3rd_to_schema_builder( pub fn register_credential3rd_to_schema_builder(
mut builder: SeaographyBuilder, mut builder: SeaographyBuilder,
) -> SeaographyBuilder { ) -> SeaographyBuilder {
builder.register_enumeration::<credential_3rd::Credential3rdType>();
seaography::register_entity!(builder, credential_3rd);
builder.schema = builder builder.schema = builder
.schema .schema
.register(Credential3rdCheckAvailableInput::generate_input_object()); .register(Credential3rdCheckAvailableInput::generate_input_object());

View File

@ -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::<downloaders::Entity>(
context,
&downloaders::Column::SubscriberId,
);
}
pub fn register_downloaders_to_schema_builder(mut builder: SeaographyBuilder) -> SeaographyBuilder {
builder.register_enumeration::<downloaders::DownloaderCategory>();
seaography::register_entity!(builder, downloaders);
builder
}

View File

@ -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::<downloads::Entity>(context, &downloads::Column::SubscriberId);
}
pub fn register_downloads_to_schema_builder(mut builder: SeaographyBuilder) -> SeaographyBuilder {
builder.register_enumeration::<downloads::DownloadStatus>();
builder.register_enumeration::<downloads::DownloadMime>();
seaography::register_entity!(builder, downloads);
builder
}

View File

@ -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::<episodes::Entity>(context, &episodes::Column::SubscriberId);
}
pub fn register_episodes_to_schema_builder(mut builder: SeaographyBuilder) -> SeaographyBuilder {
builder.register_enumeration::<episodes::EpisodeType>();
seaography::register_entity!(builder, episodes);
builder
}

View File

@ -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::<feeds::Entity>(context, &feeds::Column::SubscriberId);
}
pub fn register_feeds_to_schema_builder(mut builder: SeaographyBuilder) -> SeaographyBuilder {
builder.register_enumeration::<feeds::FeedType>();
builder.register_enumeration::<feeds::FeedSource>();
seaography::register_entity!(builder, feeds);
builder
}

View File

@ -1,5 +1,12 @@
pub mod credential_3rd; 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 subscriber_tasks;
pub mod subscribers; pub mod subscribers;
pub mod subscription_bangumi;
pub mod subscription_episode;
pub mod subscriptions; pub mod subscriptions;

View File

@ -320,6 +320,7 @@ where
} }
pub fn register_subscribers_to_schema_context(context: &mut BuilderContext) { pub fn register_subscribers_to_schema_context(context: &mut BuilderContext) {
restrict_subscriber_for_entity::<subscribers::Entity>(context, &subscribers::Column::Id);
for column in subscribers::Column::iter() { for column in subscribers::Column::iter() {
if !matches!(column, subscribers::Column::Id) { if !matches!(column, subscribers::Column::Id) {
let key = get_entity_column_key::<subscribers::Entity>(context, &column); let key = get_entity_column_key::<subscribers::Entity>(context, &column);

View File

@ -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::<subscription_bangumi::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
}

View File

@ -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::<subscription_episode::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
}

View File

@ -3,13 +3,16 @@ use std::sync::Arc;
use async_graphql::dynamic::{FieldValue, TypeRef}; use async_graphql::dynamic::{FieldValue, TypeRef};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use seaography::{ use seaography::{
Builder as SeaographyBuilder, EntityObjectBuilder, EntityQueryFieldBuilder, Builder as SeaographyBuilder, BuilderContext, EntityObjectBuilder, EntityQueryFieldBuilder,
get_filter_conditions, get_filter_conditions,
}; };
use crate::{ use crate::{
errors::RecorderError, 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::{ models::{
subscriber_tasks, subscriber_tasks,
subscriptions::{self, SubscriptionTrait}, subscriptions::{self, SubscriptionTrait},
@ -17,9 +20,19 @@ use crate::{
task::SubscriberTask, task::SubscriberTask,
}; };
pub fn register_subscriptions_to_schema_context(context: &mut BuilderContext) {
restrict_subscriber_for_entity::<subscriptions::Entity>(
context,
&subscriptions::Column::SubscriberId,
);
}
pub fn register_subscriptions_to_schema_builder( pub fn register_subscriptions_to_schema_builder(
mut builder: SeaographyBuilder, mut builder: SeaographyBuilder,
) -> SeaographyBuilder { ) -> SeaographyBuilder {
builder.register_enumeration::<subscriptions::SubscriptionCategory>();
seaography::register_entity!(builder, subscriptions);
let context = builder.context; let context = builder.context;
let entity_object_builder = EntityObjectBuilder { context }; let entity_object_builder = EntityObjectBuilder { context };

View File

@ -7,10 +7,9 @@ use seaography::{BuilderContext, SeaResult};
use crate::{ use crate::{
app::AppContextTrait, app::AppContextTrait,
graphql::infra::util::{get_column_key, get_entity_key}, graphql::infra::util::{get_column_key, get_entity_key},
models::credential_3rd,
}; };
fn register_crypto_column_input_conversion_to_schema_context<T>( pub fn register_crypto_column_input_conversion_to_schema_context<T>(
context: &mut BuilderContext, context: &mut BuilderContext,
ctx: Arc<dyn AppContextTrait>, ctx: Arc<dyn AppContextTrait>,
column: &T::Column, column: &T::Column,
@ -37,7 +36,7 @@ fn register_crypto_column_input_conversion_to_schema_context<T>(
); );
} }
fn register_crypto_column_output_conversion_to_schema_context<T>( pub fn register_crypto_column_output_conversion_to_schema_context<T>(
context: &mut BuilderContext, context: &mut BuilderContext,
ctx: Arc<dyn AppContextTrait>, ctx: Arc<dyn AppContextTrait>,
column: &T::Column, column: &T::Column,
@ -68,39 +67,3 @@ fn register_crypto_column_output_conversion_to_schema_context<T>(
), ),
); );
} }
pub fn register_crypto_to_schema_context(
context: &mut BuilderContext,
ctx: Arc<dyn AppContextTrait>,
) {
register_crypto_column_input_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx.clone(),
&credential_3rd::Column::Cookies,
);
register_crypto_column_input_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx.clone(),
&credential_3rd::Column::Username,
);
register_crypto_column_input_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx.clone(),
&credential_3rd::Column::Password,
);
register_crypto_column_output_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx.clone(),
&credential_3rd::Column::Cookies,
);
register_crypto_column_output_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx.clone(),
&credential_3rd::Column::Username,
);
register_crypto_column_output_conversion_to_schema_context::<credential_3rd::Entity>(
context,
ctx,
&credential_3rd::Column::Password,
);
}

View File

@ -1,3 +1,4 @@
pub mod crypto;
pub mod custom; pub mod custom;
pub mod json; pub mod json;
pub mod util; pub mod util;

View File

@ -8,17 +8,36 @@ use crate::{
app::AppContextTrait, app::AppContextTrait,
graphql::{ graphql::{
domains::{ domains::{
credential_3rd::register_credential3rd_to_schema_builder, bangumi::{register_bangumi_to_schema_builder, register_bangumi_to_schema_context},
crypto::register_crypto_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::{ subscriber_tasks::{
register_subscriber_tasks_to_schema_builder, register_subscriber_tasks_to_schema_builder,
register_subscriber_tasks_to_schema_context, register_subscriber_tasks_to_schema_context,
}, },
subscribers::{ subscribers::{
register_subscribers_to_schema_builder, register_subscribers_to_schema_context, 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, infra::json::register_jsonb_input_filter_to_schema_builder,
}, },
@ -31,7 +50,6 @@ pub fn build_schema(
depth: Option<usize>, depth: Option<usize>,
complexity: Option<usize>, complexity: Option<usize>,
) -> Result<Schema, SchemaError> { ) -> Result<Schema, SchemaError> {
use crate::models::*;
let database = app_ctx.db().as_ref().clone(); let database = app_ctx.db().as_ref().clone();
let context = CONTEXT.get_or_init(|| { let context = CONTEXT.get_or_init(|| {
@ -39,45 +57,17 @@ pub fn build_schema(
{ {
// domains // domains
register_feeds_to_schema_context(&mut context);
register_subscribers_to_schema_context(&mut context); register_subscribers_to_schema_context(&mut context);
register_subscriptions_to_schema_context(&mut context);
{
restrict_subscriber_for_entity::<downloaders::Entity>(
&mut context,
&downloaders::Column::SubscriberId,
);
restrict_subscriber_for_entity::<downloads::Entity>(
&mut context,
&downloads::Column::SubscriberId,
);
restrict_subscriber_for_entity::<episodes::Entity>(
&mut context,
&episodes::Column::SubscriberId,
);
restrict_subscriber_for_entity::<subscriptions::Entity>(
&mut context,
&subscriptions::Column::SubscriberId,
);
restrict_subscriber_for_entity::<subscribers::Entity>(
&mut context,
&subscribers::Column::Id,
);
restrict_subscriber_for_entity::<subscription_bangumi::Entity>(
&mut context,
&subscription_bangumi::Column::SubscriberId,
);
restrict_subscriber_for_entity::<subscription_episode::Entity>(
&mut context,
&subscription_episode::Column::SubscriberId,
);
restrict_subscriber_for_entity::<credential_3rd::Entity>(
&mut context,
&credential_3rd::Column::SubscriberId,
);
}
register_crypto_to_schema_context(&mut context, app_ctx.clone());
register_subscriber_tasks_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 context
}); });
@ -91,32 +81,16 @@ pub fn build_schema(
{ {
// domains // domains
builder = register_subscribers_to_schema_builder(builder); builder = register_subscribers_to_schema_builder(builder);
builder = register_feeds_to_schema_builder(builder);
seaography::register_entities!( builder = register_episodes_to_schema_builder(builder);
builder, builder = register_subscription_bangumi_to_schema_builder(builder);
[ builder = register_subscription_episode_to_schema_builder(builder);
bangumi, builder = register_downloaders_to_schema_builder(builder);
downloaders, builder = register_downloads_to_schema_builder(builder);
downloads,
episodes,
subscription_bangumi,
subscription_episode,
subscriptions,
credential_3rd
]
);
{
builder.register_enumeration::<downloads::DownloadStatus>();
builder.register_enumeration::<subscriptions::SubscriptionCategory>();
builder.register_enumeration::<downloaders::DownloaderCategory>();
builder.register_enumeration::<downloads::DownloadMime>();
builder.register_enumeration::<credential_3rd::Credential3rdType>();
}
builder = register_subscriptions_to_schema_builder(builder); builder = register_subscriptions_to_schema_builder(builder);
builder = register_credential3rd_to_schema_builder(builder); builder = register_credential3rd_to_schema_builder(builder);
builder = register_subscriber_tasks_to_schema_builder(builder); builder = register_subscriber_tasks_to_schema_builder(builder);
builder = register_bangumi_to_schema_builder(builder);
} }
let schema = builder.schema_builder(); let schema = builder.schema_builder();

View File

@ -52,8 +52,12 @@ pub enum Bangumi {
RssLink, RssLink,
PosterLink, PosterLink,
OriginPosterLink, OriginPosterLink,
/**
* @deprecated
*/
SavePath, SavePath,
Homepage, Homepage,
BangumiType,
} }
#[derive(DeriveIden)] #[derive(DeriveIden)]
@ -86,7 +90,11 @@ pub enum Episodes {
Homepage, Homepage,
Subtitle, Subtitle,
Source, Source,
Extra, EpisodeType,
EnclosureTorrentLink,
EnclosureMagnetLink,
EnclosurePubDate,
EnclosureContentLength,
} }
#[derive(DeriveIden)] #[derive(DeriveIden)]
@ -149,6 +157,17 @@ pub enum Credential3rd {
UserAgent, UserAgent,
} }
#[derive(DeriveIden)]
pub enum Feeds {
Table,
Id,
Token,
FeedType,
FeedSource,
SubscriberId,
SubscriptionId,
}
macro_rules! create_postgres_enum_for_active_enum { macro_rules! create_postgres_enum_for_active_enum {
($manager: expr, $active_enum: expr, $($enum_value:expr),+) => { ($manager: expr, $active_enum: expr, $($enum_value:expr),+) => {
{ {

View File

@ -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(())
}
}

View File

@ -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(())
}
}

View File

@ -8,6 +8,8 @@ pub mod m20240224_082543_add_downloads;
pub mod m20241231_000001_auth; pub mod m20241231_000001_auth;
pub mod m20250501_021523_credential_3rd; pub mod m20250501_021523_credential_3rd;
pub mod m20250520_021135_subscriber_tasks; pub mod m20250520_021135_subscriber_tasks;
pub mod m20250622_015618_feeds;
pub mod m20250622_020819_bangumi_and_episode_type;
pub struct Migrator; pub struct Migrator;
@ -20,6 +22,8 @@ impl MigratorTrait for Migrator {
Box::new(m20241231_000001_auth::Migration), Box::new(m20241231_000001_auth::Migration),
Box::new(m20250501_021523_credential_3rd::Migration), Box::new(m20250501_021523_credential_3rd::Migration),
Box::new(m20250520_021135_subscriber_tasks::Migration), Box::new(m20250520_021135_subscriber_tasks::Migration),
Box::new(m20250622_015618_feeds::Migration),
Box::new(m20250622_020819_bangumi_and_episode_type::Migration),
] ]
} }
} }

View File

@ -29,7 +29,14 @@ pub struct BangumiFilter {
pub group: Option<Vec<String>>, pub group: Option<Vec<String>>,
} }
#[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")] #[sea_orm(table_name = "bangumi")]
pub struct Model { pub struct Model {
#[sea_orm(default_expr = "Expr::current_timestamp()")] #[sea_orm(default_expr = "Expr::current_timestamp()")]
@ -39,6 +46,7 @@ pub struct Model {
#[sea_orm(primary_key)] #[sea_orm(primary_key)]
pub id: i32, pub id: i32,
pub mikan_bangumi_id: Option<String>, pub mikan_bangumi_id: Option<String>,
pub bangumi_type: BangumiType,
pub subscriber_id: i32, pub subscriber_id: i32,
pub display_name: String, pub display_name: String,
pub origin_name: String, pub origin_name: String,
@ -50,7 +58,6 @@ pub struct Model {
pub rss_link: Option<String>, pub rss_link: Option<String>,
pub poster_link: Option<String>, pub poster_link: Option<String>,
pub origin_poster_link: Option<String>, pub origin_poster_link: Option<String>,
pub save_path: Option<String>,
pub homepage: Option<String>, pub homepage: Option<String>,
} }
@ -152,10 +159,7 @@ impl ActiveModel {
season_raw: ActiveValue::Set(season_raw), season_raw: ActiveValue::Set(season_raw),
fansub: ActiveValue::Set(Some(meta.fansub)), fansub: ActiveValue::Set(Some(meta.fansub)),
poster_link: ActiveValue::Set(poster_link), poster_link: ActiveValue::Set(poster_link),
origin_poster_link: ActiveValue::Set( origin_poster_link: ActiveValue::Set(meta.origin_poster_src.map(|src| src.to_string())),
meta.origin_poster_src
.map(|src| src[url::Position::BeforePath..].to_string()),
),
homepage: ActiveValue::Set(Some(meta.homepage.to_string())), homepage: ActiveValue::Set(Some(meta.homepage.to_string())),
rss_link: ActiveValue::Set(Some(rss_url.to_string())), rss_link: ActiveValue::Set(Some(rss_url.to_string())),
..Default::default() ..Default::default()
@ -234,6 +238,7 @@ impl Model {
Column::OriginName, Column::OriginName,
Column::Fansub, Column::Fansub,
Column::PosterLink, Column::PosterLink,
Column::OriginPosterLink,
Column::Season, Column::Season,
Column::SeasonRaw, Column::SeasonRaw,
Column::RssLink, Column::RssLink,

View File

@ -9,11 +9,19 @@ use crate::{
app::AppContextTrait, app::AppContextTrait,
errors::RecorderResult, errors::RecorderResult,
extract::{ extract::{
bittorrent::EpisodeEnclosureMeta,
mikan::{MikanEpisodeHash, MikanEpisodeMeta, build_mikan_episode_homepage_url}, mikan::{MikanEpisodeHash, MikanEpisodeMeta, build_mikan_episode_homepage_url},
origin::{OriginCompTrait, OriginNameRoot}, 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)] #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "episodes")] #[sea_orm(table_name = "episodes")]
pub struct Model { pub struct Model {
@ -25,11 +33,15 @@ pub struct Model {
pub id: i32, pub id: i32,
#[sea_orm(indexed)] #[sea_orm(indexed)]
pub mikan_episode_id: Option<String>, pub mikan_episode_id: Option<String>,
pub enclosure_torrent_link: Option<String>,
pub enclosure_magnet_link: Option<String>,
pub enclosure_pub_date: Option<DateTimeUtc>,
pub enclosure_content_length: Option<u64>,
pub episode_type: EpisodeType,
pub origin_name: String, pub origin_name: String,
pub display_name: String, pub display_name: String,
pub bangumi_id: i32, pub bangumi_id: i32,
pub subscriber_id: i32, pub subscriber_id: i32,
pub save_path: Option<String>,
pub resolution: Option<String>, pub resolution: Option<String>,
pub season: i32, pub season: i32,
pub season_raw: Option<String>, pub season_raw: Option<String>,
@ -122,6 +134,7 @@ impl ActiveModel {
ctx: &dyn AppContextTrait, ctx: &dyn AppContextTrait,
bangumi: &bangumi::Model, bangumi: &bangumi::Model,
episode: MikanEpisodeMeta, episode: MikanEpisodeMeta,
enclosure_meta: EpisodeEnclosureMeta,
) -> RecorderResult<Self> { ) -> RecorderResult<Self> {
let mikan_base_url = ctx.mikan().base_url().clone(); let mikan_base_url = ctx.mikan().base_url().clone();
let episode_extention_meta = OriginNameRoot::parse_comp(&episode.episode_title) let episode_extention_meta = OriginNameRoot::parse_comp(&episode.episode_title)
@ -149,6 +162,10 @@ impl ActiveModel {
poster_link: ActiveValue::Set(bangumi.poster_link.clone()), poster_link: ActiveValue::Set(bangumi.poster_link.clone()),
origin_poster_link: ActiveValue::Set(bangumi.origin_poster_link.clone()), origin_poster_link: ActiveValue::Set(bangumi.origin_poster_link.clone()),
episode_index: ActiveValue::Set(0), 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() ..Default::default()
}; };
@ -216,14 +233,19 @@ impl Model {
pub async fn add_mikan_episodes_for_subscription( pub async fn add_mikan_episodes_for_subscription(
ctx: &dyn AppContextTrait, ctx: &dyn AppContextTrait,
creations: impl Iterator<Item = (&bangumi::Model, MikanEpisodeMeta)>, creations: impl Iterator<Item = (&bangumi::Model, MikanEpisodeMeta, EpisodeEnclosureMeta)>,
subscriber_id: i32, subscriber_id: i32,
subscription_id: i32, subscription_id: i32,
) -> RecorderResult<()> { ) -> RecorderResult<()> {
let db = ctx.db(); let db = ctx.db();
let new_episode_active_modes: Vec<ActiveModel> = creations let new_episode_active_modes: Vec<ActiveModel> = creations
.map(|(bangumi, episode_meta)| { .map(|(bangumi, episode_meta, enclosure_meta)| {
ActiveModel::from_mikan_bangumi_and_episode_meta(ctx, bangumi, episode_meta) ActiveModel::from_mikan_bangumi_and_episode_meta(
ctx,
bangumi,
episode_meta,
enclosure_meta,
)
}) })
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
@ -234,7 +256,23 @@ impl Model {
let new_episode_ids = Entity::insert_many(new_episode_active_modes) let new_episode_ids = Entity::insert_many(new_episode_active_modes)
.on_conflict( .on_conflict(
OnConflict::columns([Column::MikanEpisodeId, Column::SubscriberId]) 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(), .to_owned(),
) )
.exec_with_returning_columns(db, [Column::Id]) .exec_with_returning_columns(db, [Column::Id])

View File

@ -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<i32>,
pub subscription_id: Option<i32>,
}
#[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<super::subscriptions::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subscription.def()
}
}
impl Related<super::subscribers::Entity> 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<C>(mut self, _db: &C, insert: bool) -> Result<Self, DbErr>
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<Channel> {
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)
}
}

View File

@ -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<Self> {
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<Channel> {
match self {
Self::SubscritpionEpisodes(feed) => feed.into_channel(ctx, api_base),
}
}
}

View File

@ -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<Cow<'_, str>>;
fn get_enclosure_mime(&self) -> Option<Cow<'_, str>>;
fn get_enclosure_link(&self, ctx: &dyn AppContextTrait, api_base: &Url)
-> Option<Cow<'_, str>>;
fn get_enclosure_pub_date(&self) -> Option<DateTime<Utc>>;
fn get_enclosure_content_length(&self) -> Option<u64>;
fn into_item(self, ctx: &dyn AppContextTrait, api_base: &Url) -> RecorderResult<Item> {
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<Cow<'_, str>>;
fn items(&self) -> impl Iterator<Item = &Self::Item>;
fn into_items(self) -> impl Iterator<Item = Self::Item>;
fn into_channel(self, ctx: &dyn AppContextTrait, api_base: &Url) -> RecorderResult<Channel> {
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::<RecorderResult<Vec<_>>>()?
})
.build();
Ok(channel)
}
}

View File

@ -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<episodes::Model>,
}
impl SubscriptionEpisodesFeed {
pub fn from_model(
feed: feeds::Model,
subscription: subscriptions::Model,
episodes: Vec<episodes::Model>,
) -> 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<Cow<'_, str>> {
self.homepage.as_deref().map(Cow::Borrowed)
}
fn get_enclosure_mime(&self) -> Option<Cow<'_, str>> {
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<Cow<'_, str>> {
self.enclosure_torrent_link.as_deref().map(Cow::Borrowed)
}
fn get_enclosure_pub_date(&self) -> Option<DateTime<Utc>> {
self.enclosure_pub_date
}
fn get_enclosure_content_length(&self) -> Option<u64> {
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<Cow<'_, str>> {
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<Item = &Self::Item> {
self.episodes.iter()
}
fn into_items(self) -> impl Iterator<Item = Self::Item> {
self.episodes.into_iter()
}
}

View File

@ -4,6 +4,7 @@ pub mod credential_3rd;
pub mod downloaders; pub mod downloaders;
pub mod downloads; pub mod downloads;
pub mod episodes; pub mod episodes;
pub mod feeds;
pub mod query; pub mod query;
pub mod subscriber_tasks; pub mod subscriber_tasks;
pub mod subscribers; pub mod subscribers;

View File

@ -3,11 +3,11 @@ use sea_orm::{ActiveValue, FromJsonQueryResult, TransactionTrait, entity::prelud
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{ use crate::{
app::AppContextTrait, app::{AppContextTrait, PROJECT_NAME},
errors::app_error::{RecorderError, RecorderResult}, 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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)]
pub struct SubscriberBangumiConfig { pub struct SubscriberBangumiConfig {

View File

@ -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<i32>,
}
#[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<super::subscribers::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subscriber.def()
}
}
impl Related<super::subscription_bangumi::Entity> for Entity {
fn to() -> RelationDef {
Relation::SubscriptionBangumi.def()
}
}
impl Related<super::subscription_episode::Entity> for Entity {
fn to() -> RelationDef {
Relation::SubscriptionEpisode.def()
}
}
impl Related<super::bangumi::Entity> for Entity {
fn to() -> RelationDef {
super::subscription_bangumi::Relation::Bangumi.def()
}
fn via() -> Option<RelationDef> {
Some(
super::subscription_bangumi::Relation::Subscription
.def()
.rev(),
)
}
}
impl Related<super::episodes::Entity> for Entity {
fn to() -> RelationDef {
super::subscription_episode::Relation::Episode.def()
}
fn via() -> Option<RelationDef> {
Some(
super::subscription_episode::Relation::Subscription
.def()
.rev(),
)
}
}
impl Related<super::credential_3rd::Entity> 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<Item = i32>,
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<Item = i32>,
) -> 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<Self> {
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<dyn AppContextTrait>) -> RecorderResult<()>;
async fn sync_feeds_full(&self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()>;
async fn sync_sources(&self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()>;
fn try_from_model(model: &Model) -> RecorderResult<Self>;
}
#[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<dyn AppContextTrait>) -> 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<dyn AppContextTrait>) -> 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<dyn AppContextTrait>) -> 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<Self> {
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, Self::Error> {
Self::try_from_model(model)
}
}

View File

@ -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<dyn AppContextTrait>) -> RecorderResult<()>;
async fn sync_feeds_full(&self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()>;
async fn sync_sources(&self, ctx: Arc<dyn AppContextTrait>) -> RecorderResult<()>;
fn try_from_model(model: &subscriptions::Model) -> RecorderResult<Self>;
}

View File

@ -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<i32>,
}
#[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<super::subscribers::Entity> for Entity {
fn to() -> RelationDef {
Relation::Subscriber.def()
}
}
impl Related<super::subscription_bangumi::Entity> for Entity {
fn to() -> RelationDef {
Relation::SubscriptionBangumi.def()
}
}
impl Related<super::subscription_episode::Entity> for Entity {
fn to() -> RelationDef {
Relation::SubscriptionEpisode.def()
}
}
impl Related<super::bangumi::Entity> for Entity {
fn to() -> RelationDef {
super::subscription_bangumi::Relation::Bangumi.def()
}
fn via() -> Option<RelationDef> {
Some(
super::subscription_bangumi::Relation::Subscription
.def()
.rev(),
)
}
}
impl Related<super::episodes::Entity> for Entity {
fn to() -> RelationDef {
super::subscription_episode::Relation::Episode.def()
}
fn via() -> Option<RelationDef> {
Some(
super::subscription_episode::Relation::Subscription
.def()
.rev(),
)
}
}
impl Related<super::credential_3rd::Entity> 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<Item = i32>,
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<Item = i32>,
) -> 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<Self> {
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)
}
}

View File

@ -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<dyn AppContextTrait>) -> 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<dyn AppContextTrait>) -> 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<dyn AppContextTrait>) -> 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<Self> {
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, Self::Error> {
Self::try_from_model(model)
}
}

View File

@ -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<Arc<dyn AppContextTrait>>,
Path(token): Path<String>,
forwarded_info: Extension<ForwardedRelatedInfo>,
) -> RecorderResult<impl IntoResponse> {
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<dyn AppContextTrait>) -> RecorderResult<Controller> {
let router = Router::<Arc<dyn AppContextTrait>>::new().route("rss/{token}", get(rss_handler));
Ok(Controller::from_prefix(CONTROLLER_PREFIX, router))
}

View File

@ -1,4 +1,5 @@
pub mod core; pub mod core;
pub mod feeds;
pub mod graphql; pub mod graphql;
pub mod metadata; pub mod metadata;
pub mod oidc; pub mod oidc;

View File

@ -1,8 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Json, Router, Extension, Json, Router,
extract::{Query, Request, State}, extract::{Query, State},
routing::get, routing::get,
}; };
use snafu::ResultExt; use snafu::ResultExt;
@ -42,12 +42,11 @@ async fn oidc_callback(
async fn oidc_auth( async fn oidc_auth(
State(ctx): State<Arc<dyn AppContextTrait>>, State(ctx): State<Arc<dyn AppContextTrait>>,
request: Request, forwarded_info: Extension<ForwardedRelatedInfo>,
) -> Result<Json<OidcAuthRequest>, AuthError> { ) -> Result<Json<OidcAuthRequest>, AuthError> {
let auth_service = ctx.auth(); let auth_service = ctx.auth();
if let AuthService::Oidc(oidc_auth_service) = auth_service { if let AuthService::Oidc(oidc_auth_service) = auth_service {
let (parts, _) = request.into_parts(); let mut redirect_uri = forwarded_info
let mut redirect_uri = ForwardedRelatedInfo::from_request_parts(&parts)
.resolved_origin() .resolved_origin()
.ok_or(url::ParseError::EmptyHost) .ok_or(url::ParseError::EmptyHost)
.context(OidcRequestRedirectUriSnafu)?; .context(OidcRequestRedirectUriSnafu)?;