New J-Novel API Module, should be finished
All checks were successful
Run Tests on Code / run-tests (push) Successful in 0s

This commit is contained in:
Neshura 2023-12-16 03:09:18 +01:00
parent b128f243fc
commit 30e5fcbf72
Signed by: Neshura
GPG key ID: B6983AAA6B9A7A6C

359
src/jnovel/mod.rs Normal file
View file

@ -0,0 +1,359 @@
use std::cmp::Ordering;
use std::collections::HashMap;
use std::error::Error;
use std::ops::Sub;
use chrono::{DateTime, Duration, Utc};
use lemmy_db_schema::source::post::Post;
use serde_derive::{Deserialize, Serialize};
use url::Url;
use crate::{HTTP_CLIENT};
use crate::jnovel::PartInfo::{NoParts, Part};
use crate::jnovel::PostInfo::{Chapter, Volume};
static PAST_DAYS_ELIGIBLE: u8 = 4;
macro_rules! api_url {
() => {
"https://labs.j-novel.club/app/v1".to_string()
};
}
macro_rules! jnc_base_url {
() => {
"https://j-novel.club".to_string()
};
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
struct VolumesWrapper {
volumes: Vec<VolumeDetail>,
pagination: PaginationInfo,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
struct ChapterWrapper {
parts: Vec<ChapterDetail>,
pagination: PaginationInfo,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
struct PaginationInfo {
limit: usize,
skip: usize,
#[serde(alias = "lastPage")]
last_page: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub(crate) struct Cover {
#[serde(alias = "coverUrl")]
pub(crate) cover: String,
#[serde(alias = "thumbnailUrl")]
pub(crate) thumbnail: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub(crate) struct VolumeDetail {
pub(crate) title: String,
pub(crate) slug: String,
pub(crate) number: u8,
pub(crate) publishing: String,
#[serde(alias = "shortDescription")]
pub(crate) short_description: String,
pub(crate) cover: Cover,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub(crate) struct ChapterDetail {
pub(crate) title: String,
pub(crate) slug: String,
pub(crate) launch: String,
pub(crate) cover: Option<Cover>,
}
#[derive(Debug, Clone)]
pub(crate) struct LemmyPostInfo {
title: String,
url: Url,
}
#[derive(Debug, Copy, Clone)]
pub(crate) enum PartInfo {
NoParts,
Part(u8),
}
impl PartInfo {
pub(crate) fn is_parts(&self) -> bool {
match self {
Part(_) => true,
NoParts => false,
}
}
pub(crate) fn is_no_parts(&self) -> bool {
!self.is_parts()
}
}
impl PartialEq for PartInfo {
fn eq(&self, other: &Self) -> bool {
let self_numeric = match self {
Part(number) => number,
NoParts => &0,
};
let other_numeric = match other {
Part(number) => number,
NoParts => &0,
};
self_numeric == other_numeric
}
}
impl PartialOrd for PartInfo {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
if self.gt(other) { Some(Ordering::Greater) }
else if self.eq(other) { Some(Ordering::Equal) }
else { Some(Ordering::Less) }
}
fn lt(&self, other: &Self) -> bool {
let self_numeric = match self {
Part(number) => number,
NoParts => &0,
};
let other_numeric = match other {
Part(number) => number,
NoParts => &0,
};
self_numeric < other_numeric
}
fn le(&self, other: &Self) -> bool {
!self.gt(other)
}
fn gt(&self, other: &Self) -> bool {
let self_numeric = match self {
Part(number) => number,
NoParts => &0,
};
let other_numeric = match other {
Part(number) => number,
NoParts => &0,
};
self_numeric > other_numeric
}
fn ge(&self, other: &Self) -> bool {
!self.lt(other)
}
}
#[derive(Debug, Clone)]
pub(crate) enum PostInfo {
Chapter { part: PartInfo, volume: u8, lemmy_info: LemmyPostInfo },
Volume { part: PartInfo, lemmy_info: LemmyPostInfo },
}
impl PartialEq for PostInfo {
fn eq(&self, other: &Self) -> bool {
let self_part = match self {
Chapter {part, ..} => part,
Volume {part, ..} => part,
};
let other_part = match other {
Chapter {part, ..} => part,
Volume {part, ..} => part,
};
self_part.eq(other_part)
}
}
impl PartialOrd for PostInfo {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
if self.gt(other) { Some(Ordering::Greater) }
else if self.eq(other) { Some(Ordering::Equal) }
else { Some(Ordering::Less) }
}
fn lt(&self, other: &Self) -> bool {
let self_part = match self {
Chapter {part, ..} => part,
Volume {part, ..} => part,
};
let other_part = match other {
Chapter {part, ..} => part,
Volume {part, ..} => part,
};
self_part < other_part
}
fn le(&self, other: &Self) -> bool {
!self.gt(other)
}
fn gt(&self, other: &Self) -> bool {
let self_part = match self {
Chapter {part, ..} => part,
Volume {part, ..} => part,
};
let other_part = match other {
Chapter {part, ..} => part,
Volume {part, ..} => part,
};
self_part > other_part
}
fn ge(&self, other: &Self) -> bool {
!self.lt(other)
}
}
pub(crate) async fn check_feed(series_slug: &str, series_has_parts: bool) -> Result<Vec<PostInfo>, Box<dyn Error>> {
let response = HTTP_CLIENT
.get(api_url!() + "/series/" + series_slug + "/volumes?format=json")
.send()
.await?
.text()
.await?;
let mut volume_brief_data: VolumesWrapper = serde_json::from_str(&response)?;
volume_brief_data.volumes.reverse(); // Makes breaking out of the volume loop easier
// If no parts just use 0 as Part indicator as no Series with Parts has a Part 0
let mut volume_map: HashMap<u8, PostInfo> = HashMap::new();
let mut chapter_map: HashMap<u8, PostInfo> = HashMap::new();
for volume in volume_brief_data.volumes.iter() {
let publishing_date = DateTime::parse_from_rfc3339(&volume.publishing).unwrap();
if publishing_date < Utc::now().sub(Duration::days(PAST_DAYS_ELIGIBLE as i64)) {
match series_has_parts {
true => continue,
false => break,
}
}
let new_part_info: PartInfo;
if series_has_parts {
let mut part_number: Option<u8> = None;
let splits: Vec<&str> = volume.slug.split('-').collect();
for (index, split) in splits.clone().into_iter().enumerate() {
if split == "part" {
part_number = Some(splits[index +1]
.parse::<u8>()
.expect("Split Element after 'Part' should always be a number"));
break;
}
}
match part_number {
Some(number) => new_part_info = Part(number),
None => {
println!("No Part found, assuming 1");
new_part_info = Part(1);
},
}
}
else {
new_part_info = NoParts;
}
let post_url = format!("{}/series/{series_slug}#volume-{}", jnc_base_url!(), volume.number);
let post_details = LemmyPostInfo {
title: volume.title.clone(),
url: Url::parse(&post_url).unwrap(),
};
let new_post_info = Volume {
part: new_part_info,
lemmy_info: post_details,
};
let part_id = match new_part_info {
Part(number) => number,
NoParts => 0,
};
if publishing_date <= Utc::now() {
volume_map
.entry(part_id)
.and_modify(|val| {
if *val < new_post_info {
*val = new_post_info.clone()
}
})
.or_insert(new_post_info);
}
if let Some(part_info) = get_latest_part(&volume.slug).await? {
let part_post_info = Chapter {
part: new_part_info,
volume: volume.number,
lemmy_info: part_info
};
chapter_map
.entry(part_id)
.and_modify(|val| {
if *val < part_post_info {
*val = part_post_info.clone()
}
})
.or_insert(part_post_info);
}
}
let mut result_vec: Vec<PostInfo> = volume_map.values().cloned().collect();
let mut chapter_vec: Vec<PostInfo> = chapter_map.values().cloned().collect();
result_vec.append(&mut chapter_vec);
Ok(result_vec)
}
async fn get_latest_part(volume_slug: &str) -> Result<Option<LemmyPostInfo>, Box<dyn Error>> {
let response = HTTP_CLIENT
.get(api_url!() + "/volumes/" + volume_slug + "/parts?format=json")
.send()
.await?
.text()
.await?;
let mut volume_parts_data: ChapterWrapper = serde_json::from_str(&response)?;
volume_parts_data.parts.reverse(); // Makes breaking out of the parts loop easier
let mut post_details: Option<LemmyPostInfo> = None;
for part in volume_parts_data.parts.iter() {
let publishing_date = DateTime::parse_from_rfc3339(&part.launch).unwrap();
if publishing_date > Utc::now() {
break
}
else if publishing_date < Utc::now().sub(Duration::days(PAST_DAYS_ELIGIBLE as i64)) {
continue
}
let post_url = format!("{}/read/{}", jnc_base_url!(), part.slug);
post_details = Some(LemmyPostInfo {
title: part.title.clone(),
url: Url::parse(&post_url).unwrap(),
});
}
Ok(post_details)
}