mirror of
https://github.com/flibusta-apps/books_downloader.git
synced 2025-12-06 06:55:37 +01:00
Add rust implementation
This commit is contained in:
@@ -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!"
|
||||
)
|
||||
@@ -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]]:
|
||||
...
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -1,10 +0,0 @@
|
||||
class NotSuccess(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ReceivedHTML(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ConvertationError(Exception):
|
||||
pass
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
45
src/config.rs
Normal 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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
from fastapi.security import APIKeyHeader
|
||||
|
||||
|
||||
default_security = APIKeyHeader(name="Authorization")
|
||||
@@ -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()
|
||||
@@ -1,4 +0,0 @@
|
||||
from core.app import start_app
|
||||
|
||||
|
||||
app = start_app()
|
||||
29
src/main.rs
Normal file
29
src/main.rs
Normal 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...")
|
||||
}
|
||||
58
src/services/book_library/mod.rs
Normal file
58
src/services/book_library/mod.rs
Normal 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(¶ms)
|
||||
.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
|
||||
}
|
||||
27
src/services/book_library/types.rs
Normal file
27
src/services/book_library/types.rs
Normal 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
38
src/services/covert.rs
Normal 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)
|
||||
}
|
||||
202
src/services/downloader/mod.rs
Normal file
202
src/services/downloader/mod.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
64
src/services/downloader/types.rs
Normal file
64
src/services/downloader/types.rs
Normal 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(()))
|
||||
}
|
||||
}
|
||||
36
src/services/downloader/utils.rs
Normal file
36
src/services/downloader/utils.rs
Normal 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)
|
||||
}
|
||||
60
src/services/downloader/zip.rs
Normal file
60
src/services/downloader/zip.rs
Normal 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)
|
||||
}
|
||||
77
src/services/filename_getter.rs
Normal file
77
src/services/filename_getter.rs
Normal 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
4
src/services/mod.rs
Normal 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
68
src/views.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user