feat: add api client
This commit is contained in:
parent
5e51b2752d
commit
8a03dc28a2
@ -1,12 +0,0 @@
|
|||||||
use bytes::Bytes;
|
|
||||||
use reqwest::IntoUrl;
|
|
||||||
|
|
||||||
use super::defs::DEFAULT_USER_AGENT;
|
|
||||||
|
|
||||||
pub async fn download_bytes<T: IntoUrl>(url: T) -> eyre::Result<Bytes> {
|
|
||||||
let request_client = reqwest::Client::builder()
|
|
||||||
.user_agent(DEFAULT_USER_AGENT)
|
|
||||||
.build()?;
|
|
||||||
let bytes = request_client.get(url).send().await?.bytes().await?;
|
|
||||||
Ok(bytes)
|
|
||||||
}
|
|
@ -1,3 +1,4 @@
|
|||||||
|
use bytes::Bytes;
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use librqbit_core::{
|
use librqbit_core::{
|
||||||
@ -9,10 +10,20 @@ pub use qbit_rs::model::{
|
|||||||
TorrentFilter as QbitTorrentFilter, TorrentSource as QbitTorrentSource,
|
TorrentFilter as QbitTorrentFilter, TorrentSource as QbitTorrentSource,
|
||||||
};
|
};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use reqwest::{header::HeaderMap, IntoUrl};
|
||||||
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
|
use tokio_utils::RateLimiter;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::downloaders::{bytes::download_bytes, error::DownloaderError};
|
use super::error::DownloaderError;
|
||||||
|
|
||||||
|
async fn download_bytes<T: IntoUrl>(url: T) -> eyre::Result<Bytes> {
|
||||||
|
let request_client = reqwest::Client::builder()
|
||||||
|
.user_agent(DEFAULT_USER_AGENT)
|
||||||
|
.build()?;
|
||||||
|
let bytes = request_client.get(url).send().await?.bytes().await?;
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
pub const BITTORRENT_MIME_TYPE: &str = "application/x-bittorrent";
|
pub const BITTORRENT_MIME_TYPE: &str = "application/x-bittorrent";
|
||||||
pub const MAGNET_SCHEMA: &str = "magnet";
|
pub const MAGNET_SCHEMA: &str = "magnet";
|
||||||
@ -247,3 +258,73 @@ impl Torrent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct ApiClient {
|
||||||
|
headers: HeaderMap,
|
||||||
|
rate_limiter: RateLimiter,
|
||||||
|
fetch_client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiClient {
|
||||||
|
pub fn new(
|
||||||
|
throttle_duration: std::time::Duration,
|
||||||
|
override_headers: Option<HeaderMap>,
|
||||||
|
) -> eyre::Result<Self> {
|
||||||
|
Ok(Self {
|
||||||
|
headers: override_headers.unwrap_or_else(HeaderMap::new),
|
||||||
|
rate_limiter: RateLimiter::new(throttle_duration),
|
||||||
|
fetch_client: reqwest::Client::builder()
|
||||||
|
.user_agent(DEFAULT_USER_AGENT)
|
||||||
|
.build()?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_json<R, F>(&self, f: F) -> Result<R, reqwest::Error>
|
||||||
|
where
|
||||||
|
F: FnOnce(&reqwest::Client) -> reqwest::RequestBuilder,
|
||||||
|
R: DeserializeOwned,
|
||||||
|
{
|
||||||
|
self.rate_limiter
|
||||||
|
.throttle(|| async {
|
||||||
|
f(&self.fetch_client)
|
||||||
|
.headers(self.headers.clone())
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.json::<R>()
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_bytes<F>(&self, f: F) -> Result<Bytes, reqwest::Error>
|
||||||
|
where
|
||||||
|
F: FnOnce(&reqwest::Client) -> reqwest::RequestBuilder,
|
||||||
|
{
|
||||||
|
self.rate_limiter
|
||||||
|
.throttle(|| async {
|
||||||
|
f(&self.fetch_client)
|
||||||
|
.headers(self.headers.clone())
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_text<F>(&self, f: F) -> Result<String, reqwest::Error>
|
||||||
|
where
|
||||||
|
F: FnOnce(&reqwest::Client) -> reqwest::RequestBuilder,
|
||||||
|
{
|
||||||
|
self.rate_limiter
|
||||||
|
.throttle(|| async {
|
||||||
|
f(&self.fetch_client)
|
||||||
|
.headers(self.headers.clone())
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,11 +0,0 @@
|
|||||||
use reqwest::IntoUrl;
|
|
||||||
|
|
||||||
use super::defs::DEFAULT_USER_AGENT;
|
|
||||||
|
|
||||||
pub async fn download_html<U: IntoUrl>(url: U) -> eyre::Result<String> {
|
|
||||||
let request_client = reqwest::Client::builder()
|
|
||||||
.user_agent(DEFAULT_USER_AGENT)
|
|
||||||
.build()?;
|
|
||||||
let content = request_client.get(url).send().await?.text().await?;
|
|
||||||
Ok(content)
|
|
||||||
}
|
|
@ -1,8 +0,0 @@
|
|||||||
use bytes::Bytes;
|
|
||||||
use reqwest::IntoUrl;
|
|
||||||
|
|
||||||
use super::bytes::download_bytes;
|
|
||||||
|
|
||||||
pub async fn download_image<U: IntoUrl>(url: U) -> eyre::Result<Bytes> {
|
|
||||||
download_bytes(url).await
|
|
||||||
}
|
|
@ -1,7 +1,4 @@
|
|||||||
pub mod bytes;
|
|
||||||
pub mod defs;
|
pub mod defs;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod html;
|
|
||||||
pub mod qbitorrent;
|
pub mod qbitorrent;
|
||||||
pub mod torrent_downloader;
|
pub mod torrent_downloader;
|
||||||
pub mod image;
|
|
||||||
|
@ -15,3 +15,5 @@ pub mod views;
|
|||||||
pub mod workers;
|
pub mod workers;
|
||||||
|
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
|
|
||||||
|
pub mod subscribe;
|
||||||
|
@ -42,7 +42,7 @@ impl MigrationTrait for Migration {
|
|||||||
subscriptions::SubscriptionCategoryEnum,
|
subscriptions::SubscriptionCategoryEnum,
|
||||||
&[
|
&[
|
||||||
subscriptions::SubscriptionCategory::Mikan,
|
subscriptions::SubscriptionCategory::Mikan,
|
||||||
subscriptions::SubscriptionCategory::Manual,
|
subscriptions::SubscriptionCategory::Tmdb,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -2,6 +2,7 @@ use regex::Regex;
|
|||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
|
||||||
pub use super::entities::bangumi::*;
|
pub use super::entities::bangumi::*;
|
||||||
|
use crate::models::downloads;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
@ -23,3 +24,8 @@ impl BangumiFilter {
|
|||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
pub async fn search_all() {}
|
||||||
|
pub async fn match_list(dnlds: Vec<downloads::Model>) {}
|
||||||
|
}
|
||||||
|
83
crates/recorder/src/models/db_utils.rs
Normal file
83
crates/recorder/src/models/db_utils.rs
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
use sea_orm::{
|
||||||
|
sea_query::{Expr, InsertStatement, IntoColumnRef, Query, SimpleExpr},
|
||||||
|
ActiveModelTrait, ActiveValue, ColumnTrait, ConnectionTrait, EntityName, EntityTrait,
|
||||||
|
FromQueryResult, Iterable, SelectModel, SelectorRaw, TryGetable,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(FromQueryResult)]
|
||||||
|
pub(crate) struct OnlyIdsModel<Id>
|
||||||
|
where
|
||||||
|
Id: TryGetable,
|
||||||
|
{
|
||||||
|
pub id: Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn insert_many_with_returning_columns<M, D, V, T, F>(
|
||||||
|
db: &D,
|
||||||
|
insert_values: impl IntoIterator<Item = V>,
|
||||||
|
returning_columns: impl IntoIterator<Item = T>,
|
||||||
|
extra_config: F,
|
||||||
|
) -> eyre::Result<Vec<M>>
|
||||||
|
where
|
||||||
|
D: ConnectionTrait,
|
||||||
|
V: ActiveModelTrait,
|
||||||
|
T: Into<SimpleExpr>,
|
||||||
|
F: FnOnce(&mut InsertStatement),
|
||||||
|
M: FromQueryResult,
|
||||||
|
{
|
||||||
|
let db_backend = db.get_database_backend();
|
||||||
|
assert!(
|
||||||
|
db_backend.support_returning(),
|
||||||
|
"db backend must support returning!"
|
||||||
|
);
|
||||||
|
let ent = V::Entity::default();
|
||||||
|
let mut insert = Query::insert();
|
||||||
|
let mut insert_statement = insert
|
||||||
|
.into_table(ent.table_ref())
|
||||||
|
.returning(Query::returning().exprs(returning_columns));
|
||||||
|
|
||||||
|
{
|
||||||
|
extra_config(&mut insert_statement);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut columns = vec![];
|
||||||
|
|
||||||
|
for new_item in insert_values {
|
||||||
|
let mut values = vec![];
|
||||||
|
for c in <V::Entity as EntityTrait>::Column::iter() {
|
||||||
|
if let ActiveValue::Set(value) = new_item.get(c.clone()) {
|
||||||
|
columns.push(c);
|
||||||
|
values.push(SimpleExpr::Value(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
insert_statement.values(values)?;
|
||||||
|
}
|
||||||
|
insert_statement.columns(columns);
|
||||||
|
|
||||||
|
let result = SelectorRaw::<SelectModel<M>>::from_statement(db_backend.build(insert_statement))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn insert_many_with_returning_all<D, V, F>(
|
||||||
|
db: &D,
|
||||||
|
insert_values: impl IntoIterator<Item = V>,
|
||||||
|
extra_config: F,
|
||||||
|
) -> eyre::Result<Vec<<V::Entity as EntityTrait>::Model>>
|
||||||
|
where
|
||||||
|
D: ConnectionTrait,
|
||||||
|
V: ActiveModelTrait,
|
||||||
|
F: FnOnce(&mut InsertStatement),
|
||||||
|
{
|
||||||
|
let result: Vec<<V::Entity as EntityTrait>::Model> = insert_many_with_returning_columns(
|
||||||
|
db,
|
||||||
|
insert_values,
|
||||||
|
<V::Entity as EntityTrait>::Column::iter().map(|c| c.select_as(Expr::col(c))),
|
||||||
|
extra_config,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
@ -1,10 +1,19 @@
|
|||||||
|
use itertools::Itertools;
|
||||||
use loco_rs::app::AppContext;
|
use loco_rs::app::AppContext;
|
||||||
use sea_orm::{prelude::*, sea_query::OnConflict, ActiveValue, Condition, QueryOrder, QuerySelect};
|
use sea_orm::{
|
||||||
|
prelude::*,
|
||||||
|
sea_query::{InsertStatement, OnConflict},
|
||||||
|
};
|
||||||
|
|
||||||
pub use crate::models::entities::downloads::*;
|
pub use crate::models::entities::downloads::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
models::subscriptions::{self, SubscriptionCategory},
|
models::{
|
||||||
parsers::mikan::{parse_mikan_rss_items_from_rss_link, MikanRssItem},
|
db_utils::insert_many_with_returning_all,
|
||||||
|
subscriptions::{self, SubscriptionCategory},
|
||||||
|
},
|
||||||
|
parsers::mikan::{
|
||||||
|
mikan_client::MikanClient, parse_mikan_rss_items_from_rss_link, MikanRssItem,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@ -12,18 +21,6 @@ impl ActiveModelBehavior for ActiveModel {}
|
|||||||
|
|
||||||
impl ActiveModel {
|
impl ActiveModel {
|
||||||
pub fn from_mikan_rss_item(m: MikanRssItem, subscription_id: i32) -> Self {
|
pub fn from_mikan_rss_item(m: MikanRssItem, subscription_id: i32) -> Self {
|
||||||
let _ = Self {
|
|
||||||
origin_name: ActiveValue::Set(m.title.clone()),
|
|
||||||
display_name: ActiveValue::Set(m.title),
|
|
||||||
subscription_id: ActiveValue::Set(subscription_id),
|
|
||||||
status: ActiveValue::Set(DownloadStatus::Pending),
|
|
||||||
mime: ActiveValue::Set(DownloadMime::BitTorrent),
|
|
||||||
url: ActiveValue::Set(m.url),
|
|
||||||
curr_size: ActiveValue::Set(m.content_length.as_ref().map(|_| 0)),
|
|
||||||
all_size: ActiveValue::Set(m.content_length),
|
|
||||||
homepage: ActiveValue::Set(m.homepage),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
todo!()
|
todo!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -31,56 +28,34 @@ impl ActiveModel {
|
|||||||
impl Model {
|
impl Model {
|
||||||
pub async fn pull_subscription(
|
pub async fn pull_subscription(
|
||||||
ctx: AppContext,
|
ctx: AppContext,
|
||||||
item: &subscriptions::Model,
|
subscription: &subscriptions::Model,
|
||||||
) -> eyre::Result<Vec<i32>> {
|
) -> eyre::Result<Vec<Model>> {
|
||||||
let db = &ctx.db;
|
let db = &ctx.db;
|
||||||
match &item.category {
|
match &subscription.category {
|
||||||
SubscriptionCategory::Mikan => {
|
SubscriptionCategory::Mikan => {
|
||||||
let items = parse_mikan_rss_items_from_rss_link(&item.source_url).await?;
|
let subscriber_id = subscription.subscriber_id;
|
||||||
|
let client = MikanClient::new(subscriber_id).await?;
|
||||||
|
let items =
|
||||||
|
parse_mikan_rss_items_from_rss_link(&client, &subscription.source_url).await?;
|
||||||
let all_items = items.collect::<Vec<_>>();
|
let all_items = items.collect::<Vec<_>>();
|
||||||
|
|
||||||
let last_old_id = {
|
|
||||||
Entity::find()
|
|
||||||
.select_only()
|
|
||||||
.column(Column::Id)
|
|
||||||
.order_by_desc(Column::Id)
|
|
||||||
.filter(Column::SubscriptionId.eq(item.id))
|
|
||||||
.one(db)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
.map(|i| i.id);
|
|
||||||
|
|
||||||
if all_items.is_empty() {
|
if all_items.is_empty() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
let new_items = all_items
|
let new_items = all_items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|i| ActiveModel::from_mikan_rss_item(i, item.id));
|
.map(|i| ActiveModel::from_mikan_rss_item(i, subscription.id))
|
||||||
|
.collect_vec();
|
||||||
|
|
||||||
let insert_result = Entity::insert_many(new_items)
|
// insert and filter out duplicated items
|
||||||
.on_conflict(OnConflict::column(Column::Url).do_nothing().to_owned())
|
let new_items: Vec<Model> =
|
||||||
.exec(db)
|
insert_many_with_returning_all(db, new_items, |stat: &mut InsertStatement| {
|
||||||
.await?;
|
stat.on_conflict(OnConflict::column(Column::Url).do_nothing().to_owned());
|
||||||
|
|
||||||
let insert_ids = Entity::find()
|
|
||||||
.select_only()
|
|
||||||
.column(Column::Id)
|
|
||||||
.filter({
|
|
||||||
let mut cond = Condition::all()
|
|
||||||
.add(Column::SubscriptionId.eq(item.id))
|
|
||||||
.add(Column::Id.lte(insert_result.last_insert_id));
|
|
||||||
|
|
||||||
if let Some(last_old_id) = last_old_id {
|
|
||||||
cond = cond.add(Column::Id.gt(last_old_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
cond
|
|
||||||
})
|
})
|
||||||
.all(db)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(insert_ids.into_iter().map(|i| i.id).collect::<Vec<_>>())
|
Ok(new_items)
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
todo!("other subscription categories")
|
todo!("other subscription categories")
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::{entity::prelude::*, FromJsonQueryResult};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(
|
#[derive(
|
||||||
|
@ -13,8 +13,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
pub enum SubscriptionCategory {
|
pub enum SubscriptionCategory {
|
||||||
#[sea_orm(string_value = "mikan")]
|
#[sea_orm(string_value = "mikan")]
|
||||||
Mikan,
|
Mikan,
|
||||||
#[sea_orm(string_value = "manual")]
|
#[sea_orm(string_value = "tmdb")]
|
||||||
Manual,
|
Tmdb,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
@ -1,6 +1,37 @@
|
|||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::{entity::prelude::*, ActiveValue};
|
||||||
|
|
||||||
pub use super::entities::episodes::*;
|
pub use super::entities::episodes::*;
|
||||||
|
use crate::models::{bangumi, downloads};
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
||||||
|
impl ActiveModel {
|
||||||
|
pub async fn from_mikan_rss_item(dl: &downloads::Model, bgm: &bangumi::Model) -> Self {
|
||||||
|
let _ = Self {
|
||||||
|
raw_name: ActiveValue::Set(dl.origin_name.clone()),
|
||||||
|
official_title: ActiveValue::Set(bgm.official_title.clone()),
|
||||||
|
display_name: ActiveValue::Set(bgm.display_name.clone()),
|
||||||
|
name_zh: Default::default(),
|
||||||
|
name_jp: Default::default(),
|
||||||
|
name_en: Default::default(),
|
||||||
|
s_name_zh: Default::default(),
|
||||||
|
s_name_jp: Default::default(),
|
||||||
|
s_name_en: Default::default(),
|
||||||
|
bangumi_id: Default::default(),
|
||||||
|
download_id: Default::default(),
|
||||||
|
save_path: Default::default(),
|
||||||
|
resolution: Default::default(),
|
||||||
|
season: Default::default(),
|
||||||
|
season_raw: Default::default(),
|
||||||
|
fansub: Default::default(),
|
||||||
|
poster_link: Default::default(),
|
||||||
|
home_page: Default::default(),
|
||||||
|
subtitle: Default::default(),
|
||||||
|
deleted: Default::default(),
|
||||||
|
source: Default::default(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
pub mod bangumi;
|
pub mod bangumi;
|
||||||
|
pub(crate) mod db_utils;
|
||||||
pub mod downloaders;
|
pub mod downloaders;
|
||||||
pub mod downloads;
|
pub mod downloads;
|
||||||
pub mod entities;
|
pub mod entities;
|
||||||
|
31
crates/recorder/src/parsers/mikan/mikan_client.rs
Normal file
31
crates/recorder/src/parsers/mikan/mikan_client.rs
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
use std::{ops::Deref, sync::Arc};
|
||||||
|
|
||||||
|
use tokio::sync::OnceCell;
|
||||||
|
|
||||||
|
use crate::downloaders::defs::ApiClient;
|
||||||
|
|
||||||
|
pub struct MikanClient {
|
||||||
|
api_client: ApiClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
static MIKAN_CLIENT: OnceCell<Arc<MikanClient>> = OnceCell::const_new();
|
||||||
|
|
||||||
|
impl MikanClient {
|
||||||
|
pub async fn new(_subscriber_id: i32) -> eyre::Result<Arc<Self>> {
|
||||||
|
let res = MIKAN_CLIENT
|
||||||
|
.get_or_try_init(|| async {
|
||||||
|
ApiClient::new(std::time::Duration::from_millis(50), None)
|
||||||
|
.map(|api_client| Arc::new(Self { api_client }))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(res.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for MikanClient {
|
||||||
|
type Target = ApiClient;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.api_client
|
||||||
|
}
|
||||||
|
}
|
@ -5,9 +5,9 @@ use lightningcss::{properties::Property, values::image::Image};
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::parsers::{
|
||||||
downloaders::{html::download_html, image::download_image},
|
html::{get_tag_style, query_selector_first_tag},
|
||||||
parsers::html::{get_tag_style, query_selector_first_tag},
|
mikan::mikan_client::MikanClient,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct MikanEpisodeMeta {
|
pub struct MikanEpisodeMeta {
|
||||||
@ -22,10 +22,11 @@ lazy_static! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn parse_episode_meta_from_mikan_homepage(
|
pub async fn parse_episode_meta_from_mikan_homepage(
|
||||||
|
client: &MikanClient,
|
||||||
url: Url,
|
url: Url,
|
||||||
) -> eyre::Result<Option<MikanEpisodeMeta>> {
|
) -> eyre::Result<Option<MikanEpisodeMeta>> {
|
||||||
let url_host = url.origin().unicode_serialization();
|
let url_host = url.origin().unicode_serialization();
|
||||||
let content = download_html(url.as_str()).await?;
|
let content = client.fetch_text(|f| f.get(url.as_str())).await?;
|
||||||
let dom = tl::parse(&content, tl::ParserOptions::default())?;
|
let dom = tl::parse(&content, tl::ParserOptions::default())?;
|
||||||
let parser = dom.parser();
|
let parser = dom.parser();
|
||||||
let poster_node = query_selector_first_tag(&dom, r"div.bangumi-poster", parser);
|
let poster_node = query_selector_first_tag(&dom, r"div.bangumi-poster", parser);
|
||||||
@ -62,7 +63,7 @@ pub async fn parse_episode_meta_from_mikan_homepage(
|
|||||||
p
|
p
|
||||||
});
|
});
|
||||||
let poster_data = if let Some(p) = origin_poster_src.as_ref() {
|
let poster_data = if let Some(p) = origin_poster_src.as_ref() {
|
||||||
download_image(p.clone()).await.ok()
|
client.fetch_bytes(|f| f.get(p.clone())).await.ok()
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@ -93,6 +94,7 @@ mod test {
|
|||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use super::parse_episode_meta_from_mikan_homepage;
|
use super::parse_episode_meta_from_mikan_homepage;
|
||||||
|
use crate::parsers::mikan::mikan_client::MikanClient;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_parse_mikan() {
|
async fn test_parse_mikan() {
|
||||||
@ -101,7 +103,11 @@ mod test {
|
|||||||
"https://mikanani.me/Home/Episode/475184dce83ea2b82902592a5ac3343f6d54b36a";
|
"https://mikanani.me/Home/Episode/475184dce83ea2b82902592a5ac3343f6d54b36a";
|
||||||
let url = Url::parse(url_str)?;
|
let url = Url::parse(url_str)?;
|
||||||
|
|
||||||
if let Some(ep_meta) = parse_episode_meta_from_mikan_homepage(url.clone()).await? {
|
let client = MikanClient::new(0).await.expect("should get mikan client");
|
||||||
|
|
||||||
|
if let Some(ep_meta) =
|
||||||
|
parse_episode_meta_from_mikan_homepage(&client, url.clone()).await?
|
||||||
|
{
|
||||||
assert_eq!(ep_meta.homepage, url);
|
assert_eq!(ep_meta.homepage, url);
|
||||||
assert_eq!(ep_meta.official_title, "葬送的芙莉莲");
|
assert_eq!(ep_meta.official_title, "葬送的芙莉莲");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
@ -3,8 +3,8 @@ use reqwest::IntoUrl;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
downloaders::{bytes::download_bytes, defs::BITTORRENT_MIME_TYPE},
|
downloaders::defs::BITTORRENT_MIME_TYPE,
|
||||||
parsers::errors::ParseError,
|
parsers::{errors::ParseError, mikan::mikan_client::MikanClient},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@ -50,9 +50,10 @@ impl TryFrom<rss::Item> for MikanRssItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn parse_mikan_rss_items_from_rss_link(
|
pub async fn parse_mikan_rss_items_from_rss_link(
|
||||||
|
client: &MikanClient,
|
||||||
url: impl IntoUrl,
|
url: impl IntoUrl,
|
||||||
) -> eyre::Result<impl Iterator<Item = MikanRssItem>> {
|
) -> eyre::Result<impl Iterator<Item = MikanRssItem>> {
|
||||||
let bytes = download_bytes(url).await?;
|
let bytes = client.fetch_bytes(|f| f.get(url)).await?;
|
||||||
|
|
||||||
let channel = rss::Channel::read_from(&bytes[..])?;
|
let channel = rss::Channel::read_from(&bytes[..])?;
|
||||||
|
|
||||||
@ -62,14 +63,17 @@ pub async fn parse_mikan_rss_items_from_rss_link(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::parse_mikan_rss_items_from_rss_link;
|
use super::parse_mikan_rss_items_from_rss_link;
|
||||||
use crate::downloaders::defs::BITTORRENT_MIME_TYPE;
|
use crate::{
|
||||||
|
downloaders::defs::BITTORRENT_MIME_TYPE, parsers::mikan::mikan_client::MikanClient,
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
pub async fn test_mikan_subscription_items_from_rss_url() {
|
pub async fn test_mikan_subscription_items_from_rss_url() {
|
||||||
let url = "https://mikanani.me/RSS/Bangumi?bangumiId=3141&subgroupid=370";
|
let url = "https://mikanani.me/RSS/Bangumi?bangumiId=3141&subgroupid=370";
|
||||||
let items = parse_mikan_rss_items_from_rss_link(url)
|
let client = MikanClient::new(0).await.expect("should get mikan client");
|
||||||
|
let items = parse_mikan_rss_items_from_rss_link(&client, url)
|
||||||
.await
|
.await
|
||||||
.expect("should get subscription items from rss url")
|
.expect("should get subscription items from subscription url")
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let first_sub_item = items
|
let first_sub_item = items
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
pub mod mikan_client;
|
||||||
pub mod mikan_ep_parser;
|
pub mod mikan_ep_parser;
|
||||||
pub mod mikan_rss_parser;
|
pub mod mikan_rss_parser;
|
||||||
|
|
||||||
|
@ -3,6 +3,5 @@ pub mod errors;
|
|||||||
pub mod html;
|
pub mod html;
|
||||||
pub mod mikan;
|
pub mod mikan;
|
||||||
pub mod raw;
|
pub mod raw;
|
||||||
pub mod rss;
|
|
||||||
pub mod tmdb;
|
pub mod tmdb;
|
||||||
pub mod torrent;
|
pub mod torrent;
|
||||||
|
@ -1,15 +0,0 @@
|
|||||||
use crate::{
|
|
||||||
models::entities::subscriptions,
|
|
||||||
parsers::mikan::{parse_episode_meta_from_mikan_homepage, MikanRssItem},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub enum RssItem {
|
|
||||||
Mikan(MikanRssItem),
|
|
||||||
}
|
|
||||||
|
|
||||||
// pub async fn parse_official_title_from_rss_item (rss: &subscriptions::Model)
|
|
||||||
// -> String { if rss.category == subscriptions::SubscriptionCategory::Mikan
|
|
||||||
// { let res = parse_episode_meta_from_mikan_homepage(rss.source_url)
|
|
||||||
// }
|
|
||||||
// }
|
|
@ -86,14 +86,17 @@ pub async fn search_tmdb_items_from_title_and_lang(
|
|||||||
let mut items = vec![];
|
let mut items = vec![];
|
||||||
let page_num = {
|
let page_num = {
|
||||||
let search_url = build_tmdb_search_api_url(title, lang, 1);
|
let search_url = build_tmdb_search_api_url(title, lang, 1);
|
||||||
let first_page: TmdbSearchMultiPageDto =
|
let first_page: TmdbSearchMultiPageDto = tmdb_client
|
||||||
tmdb_client.fetch(|fetch| fetch.get(search_url)).await?;
|
.fetch_json(|fetch| fetch.get(search_url))
|
||||||
|
.await?;
|
||||||
items.extend(first_page.results);
|
items.extend(first_page.results);
|
||||||
first_page.total_pages
|
first_page.total_pages
|
||||||
};
|
};
|
||||||
for i in 2..=page_num {
|
for i in 2..=page_num {
|
||||||
let search_url = build_tmdb_search_api_url(title, lang, i);
|
let search_url = build_tmdb_search_api_url(title, lang, i);
|
||||||
let page: TmdbSearchMultiPageDto = tmdb_client.fetch(|fetch| fetch.get(search_url)).await?;
|
let page: TmdbSearchMultiPageDto = tmdb_client
|
||||||
|
.fetch_json(|fetch| fetch.get(search_url))
|
||||||
|
.await?;
|
||||||
items.extend(page.results);
|
items.extend(page.results);
|
||||||
}
|
}
|
||||||
Ok(items)
|
Ok(items)
|
||||||
@ -107,11 +110,12 @@ pub async fn get_tmdb_info_from_id_lang_and_distribution(
|
|||||||
) -> eyre::Result<TmdbMediaDetailDto> {
|
) -> eyre::Result<TmdbMediaDetailDto> {
|
||||||
let info_url = build_tmdb_info_api_url(id, lang, distribution);
|
let info_url = build_tmdb_info_api_url(id, lang, distribution);
|
||||||
let info = if distribution == &BangumiDistribution::Movie {
|
let info = if distribution == &BangumiDistribution::Movie {
|
||||||
let info: Box<TmdbMovieDetailDto> = tmdb_client.fetch(|fetch| fetch.get(info_url)).await?;
|
let info: Box<TmdbMovieDetailDto> =
|
||||||
|
tmdb_client.fetch_json(|fetch| fetch.get(info_url)).await?;
|
||||||
TmdbMediaDetailDto::Movie(info)
|
TmdbMediaDetailDto::Movie(info)
|
||||||
} else {
|
} else {
|
||||||
let info: Box<TmdbTvSeriesDetailDto> =
|
let info: Box<TmdbTvSeriesDetailDto> =
|
||||||
tmdb_client.fetch(|fetch| fetch.get(info_url)).await?;
|
tmdb_client.fetch_json(|fetch| fetch.get(info_url)).await?;
|
||||||
TmdbMediaDetailDto::Tv(info)
|
TmdbMediaDetailDto::Tv(info)
|
||||||
};
|
};
|
||||||
Ok(info)
|
Ok(info)
|
||||||
|
@ -1,21 +1,20 @@
|
|||||||
use std::sync::{Arc, Weak};
|
use std::{
|
||||||
|
ops::Deref,
|
||||||
|
sync::{Arc, Weak},
|
||||||
|
};
|
||||||
|
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION};
|
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION};
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tokio_utils::RateLimiter;
|
|
||||||
use weak_table::WeakValueHashMap;
|
use weak_table::WeakValueHashMap;
|
||||||
|
|
||||||
use crate::downloaders::defs::DEFAULT_USER_AGENT;
|
use crate::downloaders::defs::{ApiClient, DEFAULT_USER_AGENT};
|
||||||
|
|
||||||
pub(crate) const TMDB_API_ORIGIN: &str = "https://api.themoviedb.org";
|
pub(crate) const TMDB_API_ORIGIN: &str = "https://api.themoviedb.org";
|
||||||
|
|
||||||
pub struct TmdbApiClient {
|
pub struct TmdbApiClient {
|
||||||
api_token: String,
|
api_token: String,
|
||||||
rate_limiter: RateLimiter,
|
api_client: ApiClient,
|
||||||
fetch_client: reqwest::Client,
|
|
||||||
headers: HeaderMap,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
@ -34,19 +33,18 @@ impl TmdbApiClient {
|
|||||||
}
|
}
|
||||||
let client = Arc::new(TmdbApiClient {
|
let client = Arc::new(TmdbApiClient {
|
||||||
api_token: api_token.to_string(),
|
api_token: api_token.to_string(),
|
||||||
rate_limiter: RateLimiter::new(std::time::Duration::from_millis(50)),
|
api_client: ApiClient::new(
|
||||||
fetch_client: reqwest::Client::builder()
|
std::time::Duration::from_millis(50),
|
||||||
.user_agent(DEFAULT_USER_AGENT)
|
Some({
|
||||||
.build()?,
|
let mut header_map = HeaderMap::new();
|
||||||
headers: {
|
header_map.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||||
let mut header_map = HeaderMap::new();
|
header_map.insert(
|
||||||
header_map.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
AUTHORIZATION,
|
||||||
header_map.insert(
|
HeaderValue::from_str(&format!("Bearer {api_token}"))?,
|
||||||
AUTHORIZATION,
|
);
|
||||||
HeaderValue::from_str(&format!("Bearer {api_token}"))?,
|
header_map
|
||||||
);
|
}),
|
||||||
header_map
|
)?,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
{
|
{
|
||||||
let mut map_write = TMDB_API_CLIENT_MAP.write().await;
|
let mut map_write = TMDB_API_CLIENT_MAP.write().await;
|
||||||
@ -58,22 +56,13 @@ impl TmdbApiClient {
|
|||||||
pub fn get_api_token(&self) -> &str {
|
pub fn get_api_token(&self) -> &str {
|
||||||
&self.api_token
|
&self.api_token
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn fetch<R, F>(&self, f: F) -> Result<R, reqwest::Error>
|
impl Deref for TmdbApiClient {
|
||||||
where
|
type Target = ApiClient;
|
||||||
F: FnOnce(&reqwest::Client) -> reqwest::RequestBuilder,
|
|
||||||
R: DeserializeOwned,
|
fn deref(&self) -> &Self::Target {
|
||||||
{
|
&self.api_client
|
||||||
self.rate_limiter
|
|
||||||
.throttle(|| async {
|
|
||||||
f(&self.fetch_client)
|
|
||||||
.headers(self.headers.clone())
|
|
||||||
.send()
|
|
||||||
.await?
|
|
||||||
.json::<R>()
|
|
||||||
.await
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ pub async fn parse_tmdb_list_items_from_list_api(
|
|||||||
|
|
||||||
let page_num = {
|
let page_num = {
|
||||||
let first_page: TmdbListPageDto = tmdb_client
|
let first_page: TmdbListPageDto = tmdb_client
|
||||||
.fetch(|fetch| fetch.get(build_tmdb_list_api_url(list_id, lang, 1)))
|
.fetch_json(|fetch| fetch.get(build_tmdb_list_api_url(list_id, lang, 1)))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
items.extend(first_page.results);
|
items.extend(first_page.results);
|
||||||
@ -37,7 +37,7 @@ pub async fn parse_tmdb_list_items_from_list_api(
|
|||||||
|
|
||||||
for i in 2..=page_num {
|
for i in 2..=page_num {
|
||||||
let page: TmdbListPageDto = tmdb_client
|
let page: TmdbListPageDto = tmdb_client
|
||||||
.fetch(|fetch| fetch.get(build_tmdb_list_api_url(list_id, lang, i)))
|
.fetch_json(|fetch| fetch.get(build_tmdb_list_api_url(list_id, lang, i)))
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
items.extend(page.results);
|
items.extend(page.results);
|
||||||
|
@ -52,7 +52,7 @@ fn get_fansub(group_and_title: &str) -> (Option<&str>, &str) {
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
match (n.get(0), n.get(1)) {
|
match (n.first(), n.get(1)) {
|
||||||
(None, None) => (None, ""),
|
(None, None) => (None, ""),
|
||||||
(Some(n0), None) => (None, *n0),
|
(Some(n0), None) => (None, *n0),
|
||||||
(Some(n0), Some(n1)) => {
|
(Some(n0), Some(n1)) => {
|
||||||
|
1
crates/recorder/src/subscribe/mod.rs
Normal file
1
crates/recorder/src/subscribe/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
Loading…
Reference in New Issue
Block a user