Files
manga-dl/manga.py
T

170 lines
6.4 KiB
Python

# manga.py
import asyncio
import tempfile
import xml.etree.ElementTree as XML
from pathlib import Path
import aiofiles
import aiohttp
import questionary
from aiohttp import ClientError
from rich import print, status
from rich.progress import MofNCompleteColumn, Progress, SpinnerColumn, TextColumn
from packager import zip_files
from sites import Provider
class Manga:
def __init__(self, provider: Provider, cached: list[float] = []) -> None:
self.site = provider
self.cached = cached
spinner = status.Status("Fetching manga info")
spinner.start()
self.info, self.chapters = self.site.get_mediainfo()
spinner.stop()
self.slug = self.slugify(self.info.get("series")) # type: ignore
@staticmethod
def slugify(string: str) -> str:
string = "".join(x for x in string if not x in '<>:"/\\|?*')
return str(string)
def _generateComicInfo(self, chapter: dict, dir: Path):
filename = dir.joinpath("ComicInfo.xml")
ComicInfo = XML.Element("ComicInfo")
XML.SubElement(ComicInfo, "Series").text = self.info.get("series")
XML.SubElement(ComicInfo, "Writer").text = ",".join(self.info.get("writer", [])) # type: ignore
XML.SubElement(ComicInfo, "Penciller").text = ",".join(self.info.get("penciller", [])) # type: ignore
XML.SubElement(ComicInfo, "Genre").text = ",".join(self.info.get("genre", [])) # type: ignore
XML.SubElement(ComicInfo, "Summary").text = self.info.get("summary")
XML.SubElement(ComicInfo, "Number").text = str(chapter.get("nr"))
XML.SubElement(ComicInfo, "Title").text = chapter.get("title")
XML.SubElement(ComicInfo, "LanguageISO").text = self.info.get("languageISO")
XML.SubElement(ComicInfo, "PageCount").text = str(len(chapter.get("images"))) # type: ignore
XML.SubElement(ComicInfo, "ScanInformation").text = self.info.get(
"scanInformation"
)
tree = XML.ElementTree(ComicInfo)
XML.indent(tree, space="\t", level=0)
tree.write(str(filename), encoding="utf-8")
return filename
@staticmethod
def printMangaInfo(info: dict):
print(info)
def choose_chapters(self, auto):
if auto:
self.chapters = [
chapter
for chapter in self.chapters
if chapter.get("nr") not in self.cached
]
return
choices = [
questionary.Choice(
f"{chapter.get('nr'):g}: {chapter.get('title')}",
chapter,
checked=chapter.get("nr") not in self.cached,
)
for chapter in self.chapters
]
self.chapters = questionary.checkbox(
"Choose which chapters to download", choices
).ask()
def download(self, save_dir: Path):
series_name = self.info.get('series', 'Unknown')
if not self.chapters:
print(f" [yellow]↳ No new chapters found for {series_name}.[/yellow]", flush=True)
return
print(f" [green]↳ Found {len(self.chapters)} new chapter(s) to download for {series_name}.[/green]", flush=True)
(save_dir / self.slug).mkdir(parents=True, exist_ok=True)
async def process_all_chapters():
chapter_sem = asyncio.Semaphore(8)
image_sem = asyncio.Semaphore(40)
connector = aiohttp.TCPConnector(limit=100, ttl_dns_cache=300)
async with aiohttp.ClientSession(
headers=self.site.headers, connector=connector
) as session:
async def download_and_zip(chapter):
async with chapter_sem:
chapter_nr = chapter.get("nr")
chapter_temp = Path(temp_dir) / f"ch_{chapter_nr:g}"
chapter_temp.mkdir(parents=True, exist_ok=True)
info_file = self._generateComicInfo(chapter, chapter_temp)
img_urls = chapter.get("images", [])
img_files = await self._async_download_images_internal(
img_urls, chapter_temp, session, image_sem
)
all_files = [info_file] + [f for f in img_files if f]
zip_files(self.slug, chapter, all_files, save_dir)
self.cached.append(chapter_nr)
print(f" [green]✓ Finished:[/] {series_name} Ch. {chapter_nr:g}", flush=True)
await asyncio.gather(
*(download_and_zip(ch) for ch in self.chapters)
)
with tempfile.TemporaryDirectory(prefix="manga_") as temp_dir:
asyncio.run(process_all_chapters())
async def _async_download_images_internal(self, urls, temp_dir, session, semaphore):
"""Worker to manage the image download tasks for a single chapter."""
async def bounded_download(url):
async with semaphore:
return await self.download_image(
session, url, temp_dir, self.site.headers
)
tasks = [bounded_download(url) for url in urls]
return await asyncio.gather(*tasks)
@staticmethod
async def download_image(
session: aiohttp.ClientSession,
url: str,
temp_dir: Path,
headers: dict,
retries=3,
timeout=10,
):
filename = temp_dir.joinpath(Path(url).name)
for attempt in range(1, retries + 1):
try:
async with session.get(url, headers=headers, timeout=timeout) as response: # type: ignore
if response.status == 200:
async with aiofiles.open(filename, "wb") as f:
await f.write(await response.read())
return filename
else:
raise ClientError(f"Bad status {response.status} for {url}")
except (
asyncio.TimeoutError,
ClientError,
aiohttp.ClientConnectorError,
) as e:
if attempt < retries:
await asyncio.sleep(2**attempt)
else:
print(f"Failed to download {url}: {e}")
return None