Add rust implementation

This commit is contained in:
2022-12-14 22:33:38 +01:00
parent e36487b5ad
commit a5827721cd
39 changed files with 2473 additions and 1410 deletions

View File

@@ -1,11 +0,0 @@
from fastapi import Security, HTTPException, status
from core.auth import default_security
from core.config import env_config
async def check_token(api_key: str = Security(default_security)):
if api_key != env_config.API_KEY:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Wrong api key!"
)

View File

@@ -1,9 +0,0 @@
from typing import Protocol, Optional, AsyncIterator
class BaseDownloader(Protocol):
@classmethod
async def download(
cls, remote_id: int, file_type: str, source_id: int
) -> Optional[tuple[AsyncIterator[bytes], str]]:
...

View File

@@ -1,86 +0,0 @@
from datetime import date
from typing import Generic, TypeVar, Optional
import httpx
from pydantic import BaseModel
from core.config import env_config
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
size: int
class Source(BaseModel):
id: int
name: str
class BookAuthor(BaseModel):
id: int
first_name: str
last_name: str
middle_name: str
class Book(BaseModel):
id: int
title: str
lang: str
file_type: str
uploaded: date
authors: list[BookAuthor]
class BookDetail(Book):
remote_id: int
class BookLibraryClient:
API_KEY = env_config.BOOK_LIBRARY_API_KEY
BASE_URL = env_config.BOOK_LIBRARY_URL
_sources_cache: Optional[list[Source]] = None
@classmethod
@property
def auth_headers(cls):
return {"Authorization": cls.API_KEY}
@classmethod
async def _make_request(cls, url) -> dict:
async with httpx.AsyncClient(timeout=60) as client:
return (await client.get(url, headers=cls.auth_headers)).json()
@classmethod
async def get_sources(cls) -> list[Source]:
if cls._sources_cache:
return cls._sources_cache
data = await cls._make_request(f"{cls.BASE_URL}/api/v1/sources")
page = Page[Source].parse_obj(data)
sources = [Source.parse_obj(item) for item in page.items]
cls._sources_cache = sources
return sources
@classmethod
async def get_book(cls, book_id: int) -> BookDetail:
data = await cls._make_request(f"{cls.BASE_URL}/api/v1/books/{book_id}")
return BookDetail.parse_obj(data)
@classmethod
async def get_remote_book(cls, source_id: int, book_id: int) -> Book:
data = await cls._make_request(
f"{cls.BASE_URL}/api/v1/books/remote/{source_id}/{book_id}"
)
return Book.parse_obj(data)

View File

@@ -1,28 +0,0 @@
from app.services.base import BaseDownloader
from app.services.book_library import BookLibraryClient
from app.services.fl_downloader import FLDownloader
class DownloadersManager:
SOURCES_TABLE: dict[int, str] = {}
DOWNLOADERS_TABLE: dict[str, type[BaseDownloader]] = {
"flibusta": FLDownloader,
}
PREPARED = False
@classmethod
async def _prepare(cls):
sources = await BookLibraryClient.get_sources()
for source in sources:
cls.SOURCES_TABLE[source.id] = source.name
@classmethod
async def get_downloader(cls, source_id: int):
if not cls.PREPARED:
await cls._prepare()
name = cls.SOURCES_TABLE[source_id]
return cls.DOWNLOADERS_TABLE[name]

View File

@@ -1,10 +0,0 @@
class NotSuccess(Exception):
pass
class ReceivedHTML(Exception):
pass
class ConvertationError(Exception):
pass

View File

@@ -1,314 +0,0 @@
import asyncio
from typing import Optional, AsyncIterator, cast
import zipfile
import aiofiles
import aiofiles.os
import asynctempfile
import httpx
from app.services.base import BaseDownloader
from app.services.book_library import BookLibraryClient
from app.services.exceptions import NotSuccess, ReceivedHTML, ConvertationError
from app.services.utils import (
zip,
unzip,
get_filename,
process_pool_executor,
async_retry,
)
from core.config import env_config, SourceConfig
class FLDownloader(BaseDownloader):
EXCLUDE_UNZIP = ["html"]
def __init__(self, book_id: int, file_type: str, source_id: int):
self.book_id = book_id
self.original_file_type = file_type
self.source_id = source_id
self.get_book_data_task = asyncio.create_task(self._get_book_data())
self.get_content_task = asyncio.create_task(self._get_content())
@property
def file_type(self):
return self.original_file_type.replace("zip", "")
@property
def need_zip(self):
return "zip" in self.original_file_type
async def get_filename(self) -> str:
if not self.get_book_data_task.done():
await asyncio.wait_for(self.get_book_data_task, None)
book = self.get_book_data_task.result()
if book is None:
raise ValueError("Book is None!")
return get_filename(self.book_id, book, self.file_type)
async def get_final_filename(self, force_zip: bool = False) -> str:
if self.need_zip or force_zip:
return (await self.get_filename()) + ".zip"
return await self.get_filename()
@async_retry(NotSuccess, times=5, delay=10)
async def _download_from_source(
self, source_config: SourceConfig, file_type: Optional[str] = None
) -> tuple[httpx.AsyncClient, httpx.Response, bool]:
basic_url: str = source_config.URL
proxy: Optional[str] = source_config.PROXY
file_type_ = file_type or self.file_type
if self.file_type in ("fb2", "epub", "mobi"):
url = basic_url + f"/b/{self.book_id}/{file_type_}"
else:
url = basic_url + f"/b/{self.book_id}/download"
client_kwargs = {
"timeout": httpx.Timeout(10 * 60, connect=15, read=60),
"follow_redirects": True,
}
if proxy is not None:
client = httpx.AsyncClient(proxies=httpx.Proxy(url=proxy), **client_kwargs)
else:
client = httpx.AsyncClient(**client_kwargs)
request = client.build_request(
"GET",
url,
)
try:
response = await client.send(request, stream=True)
except (asyncio.CancelledError, httpx.HTTPError) as e:
await client.aclose()
raise NotSuccess(str(e))
try:
if response.status_code != 200:
raise NotSuccess(f"Status code is {response.status_code}!")
content_type = response.headers.get("Content-Type")
content_disposition = response.headers.get("Content-Disposition", "")
if (
"text/html" in content_type
and self.file_type.lower() != "html"
and "html" not in content_disposition.lower()
):
raise ReceivedHTML()
return client, response, "application/zip" in content_type
except (asyncio.CancelledError, httpx.HTTPError, NotSuccess, ReceivedHTML) as e:
await response.aclose()
await client.aclose()
if isinstance(e, httpx.HTTPError):
raise NotSuccess(str(e))
else:
raise e
@classmethod
async def _close_other_done(
cls,
done_tasks: set[asyncio.Task[tuple[httpx.AsyncClient, httpx.Response, bool]]],
):
for task in done_tasks:
try:
data = await task
await data[0].aclose()
await data[1].aclose()
except (
NotSuccess,
ReceivedHTML,
ConvertationError,
FileNotFoundError,
ValueError,
asyncio.InvalidStateError,
asyncio.CancelledError,
):
continue
async def _wait_until_some_done(
self, tasks: set[asyncio.Task[tuple[httpx.AsyncClient, httpx.Response, bool]]]
) -> Optional[tuple[httpx.AsyncClient, httpx.Response, bool]]:
tasks_ = tasks
while tasks_:
done, pending = await asyncio.wait(
tasks_, return_when=asyncio.FIRST_COMPLETED
)
for task in done:
try:
data = task.result()
for t_task in pending:
t_task.cancel()
await self._close_other_done(pending)
await self._close_other_done(
{ttask for ttask in done if ttask != task}
)
return data
except:
continue
tasks_ = pending
return None
async def _write_response_content_to_ntf(self, temp_file, response: httpx.Response):
async for chunk in response.aiter_bytes(2048):
await temp_file.write(chunk)
await temp_file.flush()
await temp_file.seek(0)
async def _unzip(self, response: httpx.Response, file_type: str) -> Optional[str]:
async with asynctempfile.NamedTemporaryFile(delete=True) as temp_file:
try:
await self._write_response_content_to_ntf(temp_file, response)
except httpx.HTTPError:
return None
await temp_file.flush()
try:
return await asyncio.get_event_loop().run_in_executor(
process_pool_executor, unzip, temp_file.name, file_type
)
except (FileNotFoundError, zipfile.BadZipFile):
return None
async def _download_with_converting(
self,
) -> tuple[httpx.AsyncClient, httpx.Response, bool]:
tasks = set()
for source in env_config.FL_SOURCES:
tasks.add(
asyncio.create_task(self._download_from_source(source, file_type="fb2"))
)
data = await self._wait_until_some_done(tasks)
if data is None:
raise ValueError
client, response, is_zip = data
try:
if is_zip:
filename_to_convert = await self._unzip(response, "fb2")
else:
async with asynctempfile.NamedTemporaryFile(delete=False) as temp_file:
await self._write_response_content_to_ntf(temp_file, response)
filename_to_convert = temp_file.name
finally:
await response.aclose()
await client.aclose()
if filename_to_convert is None:
raise ValueError
form = {"format": self.file_type}
files = {"file": open(filename_to_convert, "rb")}
converter_client = httpx.AsyncClient(timeout=5 * 60)
converter_request = converter_client.build_request(
"POST", env_config.CONVERTER_URL, data=form, files=files
)
try:
converter_response = await converter_client.send(
converter_request, stream=True
)
except (httpx.ConnectError, httpx.ReadTimeout, asyncio.CancelledError):
await converter_client.aclose()
raise ConvertationError
finally:
await aiofiles.os.remove(filename_to_convert)
try:
if response.status_code != 200:
raise ConvertationError
return converter_client, converter_response, False
except (asyncio.CancelledError, ConvertationError):
await converter_response.aclose()
await converter_client.aclose()
await aiofiles.os.remove(filename_to_convert)
raise
async def _get_content(self) -> Optional[tuple[AsyncIterator[bytes], str]]:
tasks = set()
for source in env_config.FL_SOURCES:
tasks.add(asyncio.create_task(self._download_from_source(source)))
if self.file_type.lower() in ["epub", "mobi"]:
tasks.add(asyncio.create_task(self._download_with_converting()))
data = await self._wait_until_some_done(tasks)
if data is None:
return None
client, response, is_zip = data
try:
if is_zip and self.file_type.lower() not in self.EXCLUDE_UNZIP:
temp_filename = await self._unzip(response, self.file_type)
else:
async with asynctempfile.NamedTemporaryFile(delete=False) as temp_file:
temp_filename = temp_file.name
await self._write_response_content_to_ntf(temp_file, response)
finally:
await response.aclose()
await client.aclose()
if temp_filename is None:
return None
if self.need_zip:
content_filename = await asyncio.get_event_loop().run_in_executor(
process_pool_executor, zip, await self.get_filename(), temp_filename
)
await aiofiles.os.remove(temp_filename)
else:
content_filename = temp_filename
force_zip = is_zip and self.file_type.lower() in self.EXCLUDE_UNZIP
async def _content_iterator() -> AsyncIterator[bytes]:
try:
async with aiofiles.open(content_filename, "rb") as temp_file:
while chunk := await temp_file.read(2048):
yield cast(bytes, chunk)
finally:
await aiofiles.os.remove(content_filename)
return _content_iterator(), await self.get_final_filename(force_zip)
async def _get_book_data(self):
return await BookLibraryClient.get_remote_book(self.source_id, self.book_id)
async def _download(self) -> Optional[tuple[AsyncIterator[bytes], str]]:
await asyncio.wait([self.get_book_data_task, self.get_content_task])
return self.get_content_task.result()
@classmethod
async def download(
cls, remote_id: int, file_type: str, source_id: int
) -> Optional[tuple[AsyncIterator[bytes], str]]:
downloader = cls(remote_id, file_type, source_id)
return await downloader._download()

View File

@@ -1,154 +0,0 @@
import asyncio
from concurrent.futures.process import ProcessPoolExecutor
import os
import re
import tempfile
from typing import Optional
import zipfile
import transliterate
import transliterate.exceptions
from app.services.book_library import Book, BookAuthor
process_pool_executor = ProcessPoolExecutor(2)
def remove_temp_file(filename: str) -> bool:
try:
os.remove(filename)
return True
except OSError:
return False
def unzip(temp_zipfile: str, file_type: str) -> Optional[str]:
zip_file = zipfile.ZipFile(temp_zipfile)
result = tempfile.NamedTemporaryFile(delete=False)
for name in zip_file.namelist():
if file_type.lower() in name.lower() or name.lower() == "elector":
with zip_file.open(name, "r") as internal_file:
while chunk := internal_file.read(2048):
result.write(chunk)
result.seek(0)
return result.name
result.close()
remove_temp_file(result.name)
raise FileNotFoundError
def zip(
filename: str,
content_filename: str,
) -> str:
result = tempfile.NamedTemporaryFile(delete=False)
zip_file = zipfile.ZipFile(
file=result,
mode="w",
compression=zipfile.ZIP_DEFLATED,
allowZip64=False,
compresslevel=9,
)
with open(content_filename, "rb") as content:
with zip_file.open(filename, "w") as internal_file:
while chunk := content.read(2048):
internal_file.write(chunk)
for zfile in zip_file.filelist:
zfile.create_system = 0
zip_file.close()
result.close()
return result.name
def get_short_name(author: BookAuthor) -> str:
name_parts = []
if author.last_name:
name_parts.append(author.last_name)
if author.first_name:
name_parts.append(author.first_name[:1])
if author.middle_name:
name_parts.append(author.middle_name[:1])
return " ".join(name_parts)
def get_filename(book_id: int, book: Book, file_type: str) -> str:
filename_parts = []
file_type_ = "fb2.zip" if file_type == "fb2zip" else file_type
if book.authors:
filename_parts.append(
"_".join([get_short_name(a) for a in book.authors]) + "_-_"
)
if book.title.startswith(" "):
filename_parts.append(book.title[1:])
else:
filename_parts.append(book.title)
filename = "".join(filename_parts)
try:
filename = transliterate.translit(filename, reversed=True)
except transliterate.exceptions.LanguageDetectionError:
pass
for c in "(),….!\"?»«':":
filename = filename.replace(c, "")
for c, r in (
("", "-"),
("/", "_"),
("", "N"),
(" ", "_"),
("", "-"),
("á", "a"),
(" ", "_"),
("'", ""),
):
filename = filename.replace(c, r)
filename = re.sub(r"[^\x00-\x7f]", r"", filename)
right_part = f".{book_id}.{file_type_}"
return filename[: 64 - len(right_part) - 1] + right_part
def async_retry(*exceptions: type[Exception], times: int = 1, delay: float = 1.0):
"""
:param times: retry count
:param delay: delay time
:param default_content: set default content
:return
"""
def func_wrapper(f):
async def wrapper(*args, **kwargs):
for retry in range(times):
try:
return await f(*args, **kwargs)
except exceptions as e:
if retry + 1 == times:
raise e
await asyncio.sleep(delay)
return wrapper
return func_wrapper

View File

@@ -1,44 +0,0 @@
from fastapi import APIRouter, Depends, Response, status
from fastapi.responses import StreamingResponse
from app.depends import check_token
from app.services.book_library import BookLibraryClient
from app.services.dowloaders_manager import DownloadersManager
from app.services.utils import get_filename as _get_filename
router = APIRouter(
tags=["downloader"],
dependencies=[Depends(check_token)],
)
@router.get("/download/{source_id}/{remote_id}/{file_type}")
async def download(source_id: int, remote_id: int, file_type: str):
downloader = await DownloadersManager.get_downloader(source_id)
result = await downloader.download(remote_id, file_type, source_id)
if result is None:
return Response(status_code=status.HTTP_204_NO_CONTENT)
content, filename = result
return StreamingResponse(
content, headers={"Content-Disposition": f"attachment; filename={filename}"}
)
@router.get("/filename/{book_id}/{file_type}", response_model=str)
async def get_filename(book_id: int, file_type: str):
book = await BookLibraryClient.get_book(book_id)
return _get_filename(book.remote_id, book, file_type)
healthcheck_router = APIRouter(tags=["healthcheck"])
@healthcheck_router.get("/healthcheck")
async def healthcheck():
return "Ok!"

45
src/config.rs Normal file
View File

@@ -0,0 +1,45 @@
use serde::Deserialize;
fn get_env(env: &'static str) -> String {
std::env::var(env).unwrap_or_else(|_| panic!("Cannot get the {} env variable", env))
}
#[derive(Deserialize, Clone)]
pub struct SourceConfig {
pub url: String,
pub proxy: Option<String>
}
pub struct Config {
pub api_key: String,
pub fl_sources: Vec<SourceConfig>,
pub book_library_api_key: String,
pub book_library_url: String,
pub converter_url: String,
pub sentry_dsn: String
}
impl Config {
pub fn load() -> Config {
Config {
api_key: get_env("API_KEY"),
fl_sources: serde_json::from_str(&get_env("FL_SOURCES")).unwrap(),
book_library_api_key: get_env("BOOK_LIBRARY_API_KEY"),
book_library_url: get_env("BOOK_LIBRARY_URL"),
converter_url: get_env("CONVERTER_URL"),
sentry_dsn: get_env("SENTRY_DSN")
}
}
}
lazy_static! {
pub static ref CONFIG: Config = Config::load();
}

View File

@@ -1,23 +0,0 @@
from fastapi import FastAPI
from prometheus_fastapi_instrumentator import Instrumentator
import sentry_sdk
from app.views import router, healthcheck_router
from core.config import env_config
sentry_sdk.init(
env_config.SENTRY_DSN,
)
def start_app() -> FastAPI:
app = FastAPI()
app.include_router(router)
app.include_router(healthcheck_router)
Instrumentator().instrument(app).expose(app, include_in_schema=True)
return app

View File

@@ -1,4 +0,0 @@
from fastapi.security import APIKeyHeader
default_security = APIKeyHeader(name="Authorization")

View File

@@ -1,24 +0,0 @@
from typing import Optional
from pydantic import BaseSettings, BaseModel
class SourceConfig(BaseModel):
URL: str
PROXY: Optional[str]
class EnvConfig(BaseSettings):
API_KEY: str
FL_SOURCES: list[SourceConfig]
BOOK_LIBRARY_API_KEY: str
BOOK_LIBRARY_URL: str
CONVERTER_URL: str
SENTRY_DSN: str
env_config = EnvConfig()

View File

@@ -1,4 +0,0 @@
from core.app import start_app
app = start_app()

29
src/main.rs Normal file
View File

@@ -0,0 +1,29 @@
#[macro_use]
extern crate lazy_static;
pub mod config;
pub mod views;
pub mod services;
use std::net::SocketAddr;
use axum::{Router, routing::get};
use views::{download, get_filename};
#[tokio::main]
async fn main() {
env_logger::init();
let app = Router::new()
.route("/download/:source_id/:remote_id/:file_type", get(download))
.route("/filename/:book_id/:file_type", get(get_filename));
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
log::info!("Start webserver...");
axum::Server::bind(&addr)
.serve(app.into_make_service())
.await
.unwrap();
log::info!("Webserver shutdown...")
}

View File

@@ -0,0 +1,58 @@
pub mod types;
use serde::de::DeserializeOwned;
use crate::config;
async fn _make_request<T>(
url: &str,
params: Vec<(&str, String)>,
) -> Result<T, Box<dyn std::error::Error + Send + Sync>>
where
T: DeserializeOwned,
{
let client = reqwest::Client::new();
let formated_url = format!("{}{}", &config::CONFIG.book_library_url, url);
log::debug!("{}", formated_url);
let response = client
.get(formated_url)
.query(&params)
.header("Authorization", &config::CONFIG.book_library_api_key)
.send()
.await;
let response = match response {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
let response = match response.error_for_status() {
Ok(v) => v,
Err(err) => return Err(Box::new(err)),
};
match response.json::<T>().await {
Ok(v) => Ok(v),
Err(err) => Err(Box::new(err)),
}
}
pub async fn get_sources() -> Result<types::Source, Box<dyn std::error::Error + Send + Sync>> {
_make_request("/api/v1/sources", vec![]).await
}
pub async fn get_book(
book_id: u32,
) -> Result<types::Book, Box<dyn std::error::Error + Send + Sync>> {
_make_request(format!("/api/v1/books/{book_id}").as_str(), vec![]).await
}
pub async fn get_remote_book(
source_id: u32,
book_id: u32,
) -> Result<types::Book, Box<dyn std::error::Error + Send + Sync>> {
_make_request(format!("/api/v1/books/remote/{source_id}/{book_id}").as_ref(), vec![]).await
}

View File

@@ -0,0 +1,27 @@
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone)]
pub struct Source {
// id: u32,
// name: String
}
#[derive(Deserialize, Debug, Clone)]
pub struct BookAuthor {
pub id: u32,
pub first_name: String,
pub last_name: String,
pub middle_name: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Book {
pub id: u32,
pub remote_id: u32,
pub title: String,
pub lang: String,
pub file_type: String,
pub uploaded: String,
pub authors: Vec<BookAuthor>,
}

38
src/services/covert.rs Normal file
View File

@@ -0,0 +1,38 @@
use reqwest::{Response, multipart::{Form, Part}, Body};
use tempfile::SpooledTempFile;
use tokio_util::io::ReaderStream;
use crate::config;
use super::downloader::types::SpooledTempAsyncRead;
pub async fn convert_file(file: SpooledTempFile, file_type: String) -> Option<Response> {
let client = reqwest::Client::new();
let async_file = Body::wrap_stream(ReaderStream::new(SpooledTempAsyncRead::new(file)));
let file_part = Part::stream(async_file).file_name("file");
let form = Form::new()
.text("format", file_type.clone())
.part("file", file_part);
let response = client
.post(&config::CONFIG.converter_url)
.multipart(form)
.send().await;
let response = match response {
Ok(v) => v,
Err(_) => {
return None
},
};
let response = match response.error_for_status() {
Ok(v) => v,
Err(_) => {
return None
},
};
Some(response)
}

View File

@@ -0,0 +1,202 @@
pub mod types;
pub mod utils;
pub mod zip;
use reqwest::Response;
use crate::config;
use self::types::{DownloadResult, Data, SpooledTempAsyncRead};
use self::utils::response_to_tempfile;
use self::zip::{unzip, zip};
use super::book_library::types::Book;
use super::covert::convert_file;
use super::{book_library::get_remote_book, filename_getter::get_filename_by_book};
use futures::stream::FuturesUnordered;
use futures::StreamExt;
pub async fn download<'a>(
book_id: &'a u32,
book_file_type: &'a str,
source_config: &'a config::SourceConfig,
) -> Option<(Response, bool)> {
let basic_url = &source_config.url;
let proxy = &source_config.proxy;
let url = if book_file_type == "fb2" || book_file_type == "epub" || book_file_type == "mobi" {
format!("{basic_url}/b/{book_id}/{book_file_type}")
} else {
format!("{basic_url}/b/{book_id}/download")
};
let client = match proxy {
Some(v) => {
let proxy_data = reqwest::Proxy::http(v);
reqwest::Client::builder()
.proxy(proxy_data.unwrap())
.build()
.unwrap()
}
None => reqwest::Client::new(),
};
let response = client.get(url).send().await;
let response = match response {
Ok(v) => v,
Err(_) => return None,
};
let response = match response.error_for_status() {
Ok(v) => v,
Err(_) => return None,
};
let headers = response.headers();
let content_type = match headers.get("Content-Type") {
Some(v) => v.to_str().unwrap(),
None => "",
};
if book_file_type.to_lowercase() == "html" && content_type.contains("text/html") {
return Some((response, false));
}
if content_type.contains("text/html")
{
return None;
}
let is_zip = content_type.contains("application/zip");
Some((response, is_zip))
}
pub async fn download_chain<'a>(
book: &'a Book,
file_type: &'a str,
source_config: &'a config::SourceConfig,
converting: bool
) -> Option<DownloadResult> {
let final_need_zip = file_type == "fb2zip";
let file_type_ = if converting {
&book.file_type
} else {
file_type
};
let (mut response, is_zip) = match download(&book.remote_id, file_type_, source_config).await {
Some(v) => v,
None => return None,
};
if is_zip && book.file_type.to_lowercase() == "html" {
let filename = get_filename_by_book(book, file_type, true);
return Some(DownloadResult::new(Data::Response(response), filename));
}
if !is_zip && !final_need_zip && !converting {
let filename = get_filename_by_book(book, &book.file_type, false);
return Some(DownloadResult::new(Data::Response(response), filename));
};
let unziped_temp_file = {
let temp_file_to_unzip_result = response_to_tempfile(&mut response).await;
let temp_file_to_unzip = match temp_file_to_unzip_result {
Some(v) => v,
None => return None,
};
match unzip(temp_file_to_unzip, "fb2") {
Some(v) => v,
None => return None,
}
};
let mut clean_file = if converting {
match convert_file(unziped_temp_file, file_type.to_string()).await {
Some(mut response) => {
match response_to_tempfile(&mut response).await {
Some(v) => v,
None => return None,
}
},
None => return None,
}
} else {
unziped_temp_file
};
if !final_need_zip {
let t = SpooledTempAsyncRead::new(clean_file);
let filename = get_filename_by_book(book, file_type, false);
return Some(DownloadResult::new(Data::SpooledTempAsyncRead(t), filename));
};
let t_file_type = if file_type == "fb2zip" { "fb2" } else { file_type };
let filename = get_filename_by_book(book, t_file_type, false);
match zip(&mut clean_file, filename.as_str()) {
Some(v) => {
let t = SpooledTempAsyncRead::new(v);
let filename = get_filename_by_book(book, file_type, true);
Some(DownloadResult::new(Data::SpooledTempAsyncRead(t), filename))
},
None => None,
}
}
pub async fn start_download_futures(
book: &Book,
file_type: &str,
) -> Option<DownloadResult> {
let mut futures = FuturesUnordered::new();
for source_config in &config::CONFIG.fl_sources {
futures.push(download_chain(
book,
file_type,
source_config,
false
));
if file_type == "epub" || file_type == "fb2" {
futures.push(download_chain(
book,
file_type.clone(),
source_config,
true
))
}
}
while let Some(result) = futures.next().await {
match result {
Some(v) => return Some(v),
None => (),
}
}
None
}
pub async fn book_download(
source_id: u32,
remote_id: u32,
file_type: &str,
) -> Result<Option<(DownloadResult, String)>, Box<dyn std::error::Error + Send + Sync>> {
let book = match get_remote_book(source_id, remote_id).await {
Ok(v) => v,
Err(err) => return Err(err),
};
let filename = get_filename_by_book(&book, file_type, false);
match start_download_futures(&book, file_type).await {
Some(v) => Ok(Some((v, filename))),
None => Ok(None),
}
}

View File

@@ -0,0 +1,64 @@
use reqwest::Response;
use std::pin::Pin;
use tempfile::SpooledTempFile;
use tokio::io::AsyncRead;
use futures::TryStreamExt;
use tokio_util::compat::FuturesAsyncReadCompatExt;
pub enum Data {
Response(Response),
SpooledTempAsyncRead(SpooledTempAsyncRead),
}
pub struct DownloadResult {
pub data: Data,
pub filename: String,
}
pub fn get_response_async_read(it: Response) -> impl AsyncRead {
it.bytes_stream()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
.into_async_read()
.compat()
}
impl DownloadResult {
pub fn new(data: Data, filename: String) -> Self {
Self { data, filename }
}
pub fn get_async_read(self) -> Pin<Box<dyn AsyncRead + Send>> {
match self.data {
Data::Response(v) => Box::pin(get_response_async_read(v)),
Data::SpooledTempAsyncRead(v) => Box::pin(v),
}
}
}
pub struct SpooledTempAsyncRead {
file: SpooledTempFile,
}
impl SpooledTempAsyncRead {
pub fn new(file: SpooledTempFile) -> Self {
Self { file }
}
}
impl AsyncRead for SpooledTempAsyncRead {
fn poll_read(
self: std::pin::Pin<&mut Self>,
_cx: &mut std::task::Context<'_>,
buf: &mut tokio::io::ReadBuf<'_>,
) -> std::task::Poll<std::io::Result<()>> {
let result = match std::io::Read::read(&mut self.get_mut().file, buf.initialize_unfilled()) {
Ok(v) => v,
Err(err) => return std::task::Poll::Ready(Err(err)),
};
buf.set_filled(result);
std::task::Poll::Ready(Ok(()))
}
}

View File

@@ -0,0 +1,36 @@
use reqwest::Response;
use tempfile::SpooledTempFile;
use bytes::Buf;
use std::io::{Seek, SeekFrom, Write};
pub async fn response_to_tempfile(res: &mut Response) -> Option<SpooledTempFile> {
let mut tmp_file = tempfile::spooled_tempfile(5 * 1024 * 1024);
{
loop {
let chunk = res.chunk().await;
let result = match chunk {
Ok(v) => v,
Err(_) => return None,
};
let data = match result {
Some(v) => v,
None => break,
};
match tmp_file.write(data.chunk()) {
Ok(_) => (),
Err(_) => return None,
}
}
tmp_file.seek(SeekFrom::Start(0)).unwrap();
}
Some(tmp_file)
}

View File

@@ -0,0 +1,60 @@
use std::io::{Seek, SeekFrom};
use tempfile::SpooledTempFile;
use zip::write::FileOptions;
pub fn unzip(tmp_file: SpooledTempFile, file_type: &str) -> Option<SpooledTempFile> {
let mut archive = zip::ZipArchive::new(tmp_file).unwrap();
let file_type_lower = file_type.to_lowercase();
for i in 0..archive.len() {
let mut file = archive.by_index(i).unwrap();
let filename = file.name();
if filename.contains(&file_type_lower) || file.name().to_lowercase() == "elector" {
let mut output_file = tempfile::spooled_tempfile(5 * 1024 * 1024);
match std::io::copy(&mut file, &mut output_file) {
Ok(_) => (),
Err(_) => return None,
};
output_file.seek(SeekFrom::Start(0)).unwrap();
return Some(output_file);
}
}
return None;
}
pub fn zip(tmp_file: &mut SpooledTempFile, filename: &str) -> Option<SpooledTempFile> {
let output_file = tempfile::spooled_tempfile(5 * 1024 * 1024);
let mut archive = zip::ZipWriter::new(output_file);
let options = FileOptions::default()
.compression_level(Some(9))
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o755);
match archive.start_file(filename, options) {
Ok(_) => (),
Err(_) => return None,
};
match std::io::copy(tmp_file, &mut archive) {
Ok(_) => (),
Err(_) => return None,
};
let mut archive_result = match archive.finish() {
Ok(v) => v,
Err(_) => return None,
};
archive_result.seek(SeekFrom::Start(0)).unwrap();
Some(archive_result)
}

View File

@@ -0,0 +1,77 @@
use translit::{gost779b_ru, CharsMapping, Transliterator};
use super::book_library::types::{BookAuthor, Book};
pub fn get_author_short_name(author: BookAuthor) -> String {
let mut parts: Vec<String> = vec![];
if author.last_name.len() != 0 {
parts.push(author.last_name);
}
if author.first_name.len() != 0 {
let first_char = author.first_name.chars().next().unwrap();
parts.push(first_char.to_string());
}
if author.middle_name.len() != 0 {
let first_char = author.middle_name.chars().next().unwrap();
parts.push(first_char.to_string());
}
parts.join(" ")
}
pub fn get_filename_by_book(book: &Book, file_type: &str, force_zip: bool) -> String {
let book_id = book.remote_id;
let mut filename_parts: Vec<String> = vec![];
let file_type_: String = if let "fb2zip" = file_type {
"fb2.zip".to_string()
} else if force_zip {
format!("{file_type}.zip")
} else {
file_type.to_string()
};
filename_parts.push(
book.authors
.clone()
.into_iter()
.map(|author| get_author_short_name(author))
.collect::<Vec<String>>()
.join("_-_"),
);
filename_parts.push(book.title.trim().to_string());
let transliterator = Transliterator::new(gost779b_ru());
let mut filename_without_type = transliterator.convert(&filename_parts.join(""), false);
for char in "(),….!\"?»«':".get(..) {
filename_without_type = filename_without_type.replace(char, "");
}
let replace_char_map: CharsMapping = [
("", "-"),
("/", "_"),
("", "N"),
(" ", "_"),
("", "-"),
("á", "a"),
(" ", "_"),
("'", ""),
("`", ""),
]
.iter()
.cloned()
.collect();
let replace_transliterator = Transliterator::new(replace_char_map);
let normal_filename = replace_transliterator.convert(&filename_without_type, false);
let right_part = format!(".{book_id}.{file_type_}");
let normal_filename_slice = std::cmp::min(64 - right_part.len() - 1, normal_filename.len());
let left_part = normal_filename.get(..normal_filename_slice).unwrap();
format!("{left_part}{right_part}")
}

4
src/services/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod book_library;
pub mod filename_getter;
pub mod downloader;
pub mod covert;

68
src/views.rs Normal file
View File

@@ -0,0 +1,68 @@
use axum::{
body::StreamBody,
extract::Path,
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, AppendHeaders},
};
use tokio_util::io::ReaderStream;
use crate::{config, services::{book_library::get_book, filename_getter::get_filename_by_book, downloader::book_download}};
pub async fn download(
Path((source_id, remote_id, file_type)): Path<(u32, u32, String)>,
headers: HeaderMap
) -> impl IntoResponse {
let config_api_key = config::CONFIG.api_key.clone();
let api_key = match headers.get("Authorization") {
Some(v) => v,
None => return Err((StatusCode::FORBIDDEN, "No api-key!".to_string())),
};
if config_api_key != api_key.to_str().unwrap() {
return Err((StatusCode::FORBIDDEN, "Wrong api-key!".to_string()))
}
let download_result = match book_download(source_id, remote_id, file_type.as_str()).await {
Ok(v) => v,
Err(_) => return Err((StatusCode::NO_CONTENT, "Can't download!".to_string())),
};
let (data, filename) = match download_result {
Some(v) => v,
None => return Err((StatusCode::NO_CONTENT, "Can't download!".to_string())),
};
let reader = data.get_async_read();
let stream = ReaderStream::new(reader);
let body = StreamBody::new(stream);
let headers = AppendHeaders([
(header::CONTENT_DISPOSITION, format!("attachment; filename={filename}"))
]);
Ok((headers, body))
}
pub async fn get_filename(
Path((book_id, file_type)): Path<(u32, String)>,
headers: HeaderMap
) -> (StatusCode, String){
let config_api_key = config::CONFIG.api_key.clone();
let api_key = match headers.get("Authorization") {
Some(v) => v,
None => return (StatusCode::FORBIDDEN, "No api-key!".to_string()),
};
if config_api_key != api_key.to_str().unwrap() {
return (StatusCode::FORBIDDEN, "Wrong api-key!".to_string())
}
let filename = match get_book(book_id).await {
Ok(book) => get_filename_by_book(&book, file_type.as_str(), false),
Err(_) => return (StatusCode::BAD_REQUEST, "Book not found!".to_string()),
};
(StatusCode::OK, filename)
}