mangalib-python-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ Metadata-Version: 2.4
2
+ Name: mangalib-python-sdk
3
+ Version: 0.1.0
4
+ Summary: Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images
5
+ Keywords: mangalib,manga,manhwa,comics,lib.social,sdk
6
+ Author: LLIJIYAPNIK
7
+ Author-email: LLIJIYAPNIK <kas4wot@yandex.ru>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 2 - Pre-Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: libsocial-core>=1.0,<2
20
+ Requires-Dist: pydantic>=2.7
21
+ Requires-Python: >=3.11
22
+ Project-URL: Homepage, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
23
+ Project-URL: Repository, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
24
+ Project-URL: Issues, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues
25
+ Project-URL: Changelog, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/packages/mangalib/CHANGELOG.md
26
+ Description-Content-Type: text/markdown
27
+
28
+ # mangalib-python-sdk
29
+
30
+ [![PyPI](https://img.shields.io/pypi/v/mangalib-python-sdk)](https://pypi.org/project/mangalib-python-sdk/)
31
+ [![License: MIT](https://img.shields.io/github/license/LLIJIYAPNIK/ranobelib-python-sdk)](../../LICENSE)
32
+ [![Python versions](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](pyproject.toml)
33
+ [![CI](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml)
34
+
35
+ Async Python SDK for [mangalib.me](https://mangalib.me): title metadata, table of contents,
36
+ translations, catalog search, and chapter pages as images.
37
+
38
+ Like [`ranobelib-python-sdk`](https://pypi.org/project/ranobelib-python-sdk/), it talks to
39
+ the undocumented but open JSON API behind the site (`api.cdnlibs.org`, shared by the whole
40
+ lib.social network) instead of scraping HTML. The shared part (API client, request pacing,
41
+ cache, titles, chapters, catalog) comes from `libsocial-core`, installed with it, so a
42
+ process using both SDKs paces its requests to the API together.
43
+
44
+ > **Status:** early. Titles, chapters, translations, the catalog and page downloads work;
45
+ > export to CBZ/PDF is planned. There is no login, so paid and early-access chapters can't
46
+ > be read: fetching one raises `AuthRequiredError`.
47
+
48
+ ## Installation
49
+
50
+ ```bash
51
+ uv add mangalib-python-sdk
52
+ ```
53
+
54
+ or
55
+
56
+ ```bash
57
+ pip install mangalib-python-sdk
58
+ ```
59
+
60
+ Requires Python 3.11+.
61
+
62
+ ## Quickstart
63
+
64
+ ```python
65
+ from mangalib import Catalog, MangaLib
66
+
67
+ async with MangaLib("https://mangalib.me/ru/manga/7965--chainsaw-man") as lib:
68
+ info = await lib.get_info()
69
+ toc = await lib.get_table_of_contents()
70
+
71
+ # Chapter 1 has two translations: pick one (see get_translations()).
72
+ translations = await lib.get_translations(volume=1, number="1")
73
+ chapter = await lib.get_chapter(volume=1, number="1", branch_id=translations[0].branch_id)
74
+
75
+ for page in chapter.pages:
76
+ print(page.number, page.width, page.height)
77
+ images = await lib.download_pages(chapter) # list[bytes], in reading order
78
+
79
+ async with Catalog() as catalog:
80
+ page = await catalog.list_titles(query="chainsaw")
81
+ ```
82
+
83
+ - `get_chapter()` / `get_chapters()` / `get_volume()` / `get_volumes()` /
84
+ `download_title()` return chapters with `pages` (`Page`: position, size, path on an image
85
+ server).
86
+ - `ChapterBranch.is_open` (from `get_table_of_contents()` / `get_translations()`) says
87
+ whether a translation can be read without an account.
88
+ - `download_page()` / `download_pages()` fall back from one image server to the next; see
89
+ `list_image_servers()` and the API notes for why `compress` is tried first.
90
+
91
+ ## Documentation
92
+
93
+ Full API reference: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**. What the API
94
+ returns for MangaLib, and what was checked: the "MangaLib" section of
95
+ [`docs/api-notes.md`](../../docs/api-notes.md). [`CHANGELOG.md`](CHANGELOG.md) lists what
96
+ changed in each release.
97
+
98
+ ## License
99
+
100
+ MIT — see [LICENSE](../../LICENSE).
@@ -0,0 +1,73 @@
1
+ # mangalib-python-sdk
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/mangalib-python-sdk)](https://pypi.org/project/mangalib-python-sdk/)
4
+ [![License: MIT](https://img.shields.io/github/license/LLIJIYAPNIK/ranobelib-python-sdk)](../../LICENSE)
5
+ [![Python versions](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue)](pyproject.toml)
6
+ [![CI](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/actions/workflows/ci.yml)
7
+
8
+ Async Python SDK for [mangalib.me](https://mangalib.me): title metadata, table of contents,
9
+ translations, catalog search, and chapter pages as images.
10
+
11
+ Like [`ranobelib-python-sdk`](https://pypi.org/project/ranobelib-python-sdk/), it talks to
12
+ the undocumented but open JSON API behind the site (`api.cdnlibs.org`, shared by the whole
13
+ lib.social network) instead of scraping HTML. The shared part (API client, request pacing,
14
+ cache, titles, chapters, catalog) comes from `libsocial-core`, installed with it, so a
15
+ process using both SDKs paces its requests to the API together.
16
+
17
+ > **Status:** early. Titles, chapters, translations, the catalog and page downloads work;
18
+ > export to CBZ/PDF is planned. There is no login, so paid and early-access chapters can't
19
+ > be read: fetching one raises `AuthRequiredError`.
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ uv add mangalib-python-sdk
25
+ ```
26
+
27
+ or
28
+
29
+ ```bash
30
+ pip install mangalib-python-sdk
31
+ ```
32
+
33
+ Requires Python 3.11+.
34
+
35
+ ## Quickstart
36
+
37
+ ```python
38
+ from mangalib import Catalog, MangaLib
39
+
40
+ async with MangaLib("https://mangalib.me/ru/manga/7965--chainsaw-man") as lib:
41
+ info = await lib.get_info()
42
+ toc = await lib.get_table_of_contents()
43
+
44
+ # Chapter 1 has two translations: pick one (see get_translations()).
45
+ translations = await lib.get_translations(volume=1, number="1")
46
+ chapter = await lib.get_chapter(volume=1, number="1", branch_id=translations[0].branch_id)
47
+
48
+ for page in chapter.pages:
49
+ print(page.number, page.width, page.height)
50
+ images = await lib.download_pages(chapter) # list[bytes], in reading order
51
+
52
+ async with Catalog() as catalog:
53
+ page = await catalog.list_titles(query="chainsaw")
54
+ ```
55
+
56
+ - `get_chapter()` / `get_chapters()` / `get_volume()` / `get_volumes()` /
57
+ `download_title()` return chapters with `pages` (`Page`: position, size, path on an image
58
+ server).
59
+ - `ChapterBranch.is_open` (from `get_table_of_contents()` / `get_translations()`) says
60
+ whether a translation can be read without an account.
61
+ - `download_page()` / `download_pages()` fall back from one image server to the next; see
62
+ `list_image_servers()` and the API notes for why `compress` is tried first.
63
+
64
+ ## Documentation
65
+
66
+ Full API reference: **<https://LLIJIYAPNIK.github.io/ranobelib-python-sdk/>**. What the API
67
+ returns for MangaLib, and what was checked: the "MangaLib" section of
68
+ [`docs/api-notes.md`](../../docs/api-notes.md). [`CHANGELOG.md`](CHANGELOG.md) lists what
69
+ changed in each release.
70
+
71
+ ## License
72
+
73
+ MIT — see [LICENSE](../../LICENSE).
@@ -0,0 +1,60 @@
1
+ [project]
2
+ name = "mangalib-python-sdk"
3
+ version = "0.1.0"
4
+ description = "Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ keywords = [
9
+ "mangalib",
10
+ "manga",
11
+ "manhwa",
12
+ "comics",
13
+ "lib.social",
14
+ "sdk",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 2 - Pre-Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "httpx>=0.27",
29
+ "libsocial-core>=1.0,<2",
30
+ "pydantic>=2.7",
31
+ ]
32
+
33
+ [[project.authors]]
34
+ name = "LLIJIYAPNIK"
35
+ email = "kas4wot@yandex.ru"
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
39
+ Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
40
+ Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
41
+ Changelog = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/packages/mangalib/CHANGELOG.md"
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.11.26,<0.12.0"]
45
+ build-backend = "uv_build"
46
+
47
+ [tool.uv.build-backend]
48
+ module-name = "mangalib"
49
+
50
+ [tool.uv.sources.libsocial-core]
51
+ workspace = true
52
+
53
+ [dependency-groups]
54
+ dev = [
55
+ "pytest>=8.0",
56
+ "pytest-asyncio>=0.24",
57
+ "pytest-cov>=5.0",
58
+ "pytest-recording>=0.13",
59
+ "vcrpy>=6.0",
60
+ ]
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "mangalib-python-sdk"
3
+ version = "0.1.0"
4
+ description = "Async Python SDK for mangalib.me: title metadata, chapters, translations, catalog and page images"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ authors = [
9
+ { name = "LLIJIYAPNIK", email = "kas4wot@yandex.ru" }
10
+ ]
11
+ keywords = ["mangalib", "manga", "manhwa", "comics", "lib.social", "sdk"]
12
+ classifiers = [
13
+ "Development Status :: 2 - Pre-Alpha",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "httpx>=0.27",
25
+ "libsocial-core>=1.0,<2",
26
+ "pydantic>=2.7",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
31
+ Repository = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk"
32
+ Issues = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/issues"
33
+ Changelog = "https://github.com/LLIJIYAPNIK/ranobelib-python-sdk/blob/main/packages/mangalib/CHANGELOG.md"
34
+
35
+ [build-system]
36
+ requires = ["uv_build>=0.11.26,<0.12.0"]
37
+ build-backend = "uv_build"
38
+
39
+ [tool.uv.build-backend]
40
+ module-name = "mangalib"
41
+
42
+ # Inside this repository the core comes from the workspace, not PyPI.
43
+ [tool.uv.sources]
44
+ libsocial-core = { workspace = true }
45
+
46
+ # Test stack for this package only, same reason as ranobelib's: `uv run --package
47
+ # mangalib-python-sdk` installs the package's own groups, not the root's.
48
+ [dependency-groups]
49
+ dev = [
50
+ "pytest>=8.0",
51
+ "pytest-asyncio>=0.24",
52
+ "pytest-cov>=5.0",
53
+ "pytest-recording>=0.13",
54
+ "vcrpy>=6.0",
55
+ ]
@@ -0,0 +1,65 @@
1
+ """Async Python SDK for mangalib.me."""
2
+
3
+ from mangalib.cache import CacheStats, cache_stats, clear_cache
4
+ from mangalib.catalog import Catalog
5
+ from mangalib.exceptions import (
6
+ AccessBlockedError,
7
+ AmbiguousChapter,
8
+ AuthRequiredError,
9
+ ChapterNotFoundError,
10
+ DownloadTitleInterruptedError,
11
+ MangaLibError,
12
+ MultipleTitleTranslationsError,
13
+ MultipleTranslationsError,
14
+ PageDownloadError,
15
+ RateLimitError,
16
+ TitleNotFoundError,
17
+ VolumeNotFoundError,
18
+ )
19
+ from mangalib.images import page_url
20
+ from mangalib.models import (
21
+ CatalogPage,
22
+ Chapter,
23
+ ChapterBranch,
24
+ Country,
25
+ Genre,
26
+ ImageServer,
27
+ Label,
28
+ Page,
29
+ RestrictedView,
30
+ Title,
31
+ Volume,
32
+ )
33
+ from mangalib.sdk import MangaLib
34
+
35
+ __all__ = [
36
+ "AccessBlockedError",
37
+ "AmbiguousChapter",
38
+ "AuthRequiredError",
39
+ "CacheStats",
40
+ "Catalog",
41
+ "CatalogPage",
42
+ "Chapter",
43
+ "ChapterBranch",
44
+ "ChapterNotFoundError",
45
+ "Country",
46
+ "DownloadTitleInterruptedError",
47
+ "Genre",
48
+ "ImageServer",
49
+ "Label",
50
+ "MangaLib",
51
+ "MangaLibError",
52
+ "MultipleTitleTranslationsError",
53
+ "MultipleTranslationsError",
54
+ "Page",
55
+ "PageDownloadError",
56
+ "RateLimitError",
57
+ "RestrictedView",
58
+ "Title",
59
+ "TitleNotFoundError",
60
+ "Volume",
61
+ "VolumeNotFoundError",
62
+ "cache_stats",
63
+ "clear_cache",
64
+ "page_url",
65
+ ]
@@ -0,0 +1,30 @@
1
+ """mangalib.me's site identity and browser headers (not public API)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+
7
+ import httpx
8
+
9
+ from libsocial import Site, browser_headers_for
10
+
11
+ SITE_ORIGIN = "https://mangalib.me"
12
+ """Origin of the mangalib.me website, as its own pages send it."""
13
+
14
+ MANGALIB = Site(id=1, origin=SITE_ORIGIN)
15
+ """mangalib.me on the shared lib.social API: ``Site-Id: 1`` (see docs/api-notes.md)."""
16
+
17
+ IMAGE_ACCEPT = "image/*"
18
+ """``Accept`` sent for page images, as a browser's ``<img>`` request would."""
19
+
20
+
21
+ def image_headers(overrides: Mapping[str, str] | None = None) -> httpx.Headers:
22
+ """Headers for downloading page images: mangalib.me's browser headers, ``Accept: image/*``.
23
+
24
+ A key in ``overrides`` replaces the default one, matched case-insensitively — the same
25
+ merge ``ApiClient(headers=...)`` does for API requests.
26
+ """
27
+ headers = httpx.Headers({**browser_headers_for(SITE_ORIGIN), "Accept": IMAGE_ACCEPT})
28
+ if overrides:
29
+ headers.update(overrides)
30
+ return headers
@@ -0,0 +1,55 @@
1
+ """Disk cache maintenance: ``cache_stats()`` and ``clear_cache()``.
2
+
3
+ The cache itself is ``libsocial-core``'s; this module only gives its helpers mangalib's
4
+ default directory, ``.mangalib_cache``, the one ``MangaLib``/``Catalog`` use. The on-disk
5
+ layout is internal, so code outside the SDK should go through these rather than measure or
6
+ delete the cache's files itself.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from libsocial import cache as _core
14
+ from libsocial.cache import CacheStats
15
+
16
+ DEFAULT_CACHE_DIR = Path(".mangalib_cache")
17
+ """Default cache directory, relative to the current working directory."""
18
+
19
+ __all__ = ["DEFAULT_CACHE_DIR", "CacheStats", "cache_stats", "clear_cache"]
20
+
21
+
22
+ def cache_stats(cache_dir: str | Path = DEFAULT_CACHE_DIR) -> CacheStats:
23
+ """Count the entries in an SDK disk cache and their total size on disk.
24
+
25
+ Args:
26
+ cache_dir: The cache directory — the same ``cache_dir`` passed to ``MangaLib`` /
27
+ ``Catalog`` (default: theirs, ``.mangalib_cache`` in the current directory).
28
+
29
+ Returns:
30
+ The number of entries and their total size in bytes. A missing directory is an empty
31
+ cache, ``CacheStats(entries=0, bytes=0)``, not an error.
32
+ """
33
+ return _core.cache_stats(cache_dir)
34
+
35
+
36
+ def clear_cache(
37
+ cache_dir: str | Path = DEFAULT_CACHE_DIR, *, older_than: float | None = None
38
+ ) -> CacheStats:
39
+ """Remove entries from an SDK disk cache.
40
+
41
+ Only files the cache itself writes are removed — anything else in the directory, and the
42
+ directory itself, is left alone.
43
+
44
+ Args:
45
+ cache_dir: The cache directory — see ``cache_stats()``.
46
+ older_than: If given, only remove entries written more than this many seconds ago,
47
+ plus any corrupted entry. ``None`` (the default) removes every entry.
48
+
49
+ Returns:
50
+ The number of entries removed and the bytes they took.
51
+
52
+ Raises:
53
+ ValueError: If ``older_than`` is negative.
54
+ """
55
+ return _core.clear_cache(cache_dir, older_than=older_than)
@@ -0,0 +1,68 @@
1
+ """Catalog listing/search: the ``Catalog`` class.
2
+
3
+ Catalog listing isn't scoped to any title, so it's a separate entry point rather than a
4
+ method on ``MangaLib``. The listing itself is ``libsocial-core``'s (``libsocial.Catalog``);
5
+ this class points it at mangalib.me and its own models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+
13
+ from libsocial import Catalog as _CoreCatalog
14
+ from libsocial.catalog import DEFAULT_SORT, MAX_PER_PAGE, MIN_PER_PAGE
15
+ from mangalib._http import MANGALIB
16
+ from mangalib.cache import DEFAULT_CACHE_DIR
17
+ from mangalib.client import ApiClient
18
+ from mangalib.models import CatalogPage, Title
19
+
20
+ __all__ = ["DEFAULT_SORT", "MAX_PER_PAGE", "MIN_PER_PAGE", "Catalog"]
21
+
22
+
23
+ class Catalog(_CoreCatalog[Title]):
24
+ """Entry point for listing/searching the mangalib.me catalog.
25
+
26
+ Lists titles (``list_titles()``) and the filter options for them (``list_genres()``,
27
+ ``list_countries()`` — on mangalib.me these are the title types: manga, manhwa, manhua,
28
+ ... — and ``list_statuses()``), each filtered to mangalib.me.
29
+
30
+ Example:
31
+ ```python
32
+ async with Catalog() as catalog:
33
+ page = await catalog.list_titles(query="chainsaw", statuses=[1])
34
+ for title in page.items:
35
+ print(title.name)
36
+ ```
37
+ """
38
+
39
+ site = MANGALIB
40
+ page_model = CatalogPage
41
+
42
+ def __init__(
43
+ self,
44
+ *,
45
+ cache_dir: str | Path | None = None,
46
+ cache_ttl: float | None = None,
47
+ headers: Mapping[str, str] | None = None,
48
+ ) -> None:
49
+ """Initialize the catalog client.
50
+
51
+ Args:
52
+ cache_dir: Where to cache raw API responses on disk, one entry per distinct set
53
+ of listing parameters. Defaults to ``.mangalib_cache`` in the current
54
+ working directory, the same as ``MangaLib``'s.
55
+ cache_ttl: Seconds after which a cached page is treated as stale and re-fetched.
56
+ ``None`` (the default) means cached pages never expire on their own — see
57
+ ``refresh=True`` on ``list_titles()`` to force one anyway.
58
+ headers: Extra HTTP headers merged over the SDK's defaults, same as
59
+ ``MangaLib(headers=...)`` — see there.
60
+ """
61
+ super().__init__(
62
+ cache_dir=cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR,
63
+ cache_ttl=cache_ttl,
64
+ headers=headers,
65
+ )
66
+
67
+ def _create_client(self, headers: Mapping[str, str] | None) -> ApiClient:
68
+ return ApiClient(headers=headers)
@@ -0,0 +1,99 @@
1
+ """Low-level HTTP client for the api.cdnlibs.org JSON API, scoped to mangalib.me.
2
+
3
+ The client is ``libsocial-core``'s (``libsocial.ApiClient``); this subclass fixes its site to
4
+ mangalib.me and adds what only a manga chapter needs: refusing closed (paid/early-access)
5
+ chapters, and the list of image servers its pages are served from.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from libsocial import ApiClient as _CoreApiClient
15
+ from libsocial.exceptions import AuthRequiredError, LibSocialError
16
+ from mangalib._http import MANGALIB
17
+
18
+ __all__ = ["ApiClient"]
19
+
20
+
21
+ class ApiClient(_CoreApiClient):
22
+ """``libsocial.ApiClient`` for mangalib.me.
23
+
24
+ Bounds concurrency, paces request starts and retries 429/5xx with backoff — see
25
+ ``libsocial.ApiClient``. Pacing is shared with every other client in the process talking
26
+ to api.cdnlibs.org (a ranobelib client too), unless ``pacer`` is given.
27
+ """
28
+
29
+ headers_hint = "MangaLib(..., headers=...) / Catalog(headers=...)"
30
+
31
+ def __init__(self, **kwargs: Any) -> None:
32
+ """Initialize the client.
33
+
34
+ Args:
35
+ **kwargs: The keyword arguments of ``libsocial.ApiClient`` (``base_url``,
36
+ ``timeout``, ``transport``, ``headers``, ``max_concurrency``,
37
+ ``request_delay``, ``max_retries``, ``retry_base_delay``, ``sleep``,
38
+ ``pacer``) — everything but ``site``, which is always mangalib.me.
39
+ """
40
+ super().__init__(MANGALIB, **kwargs)
41
+
42
+ async def get_chapter(
43
+ self,
44
+ slug_url: str,
45
+ *,
46
+ number: str,
47
+ volume: str,
48
+ branch_id: int | None = None,
49
+ ) -> dict[str, Any]:
50
+ """Fetch a single chapter's metadata and pages.
51
+
52
+ A closed chapter (paid, or still in early access) doesn't come back as a 403: the API
53
+ answers 200 with ``restricted_view.is_open: false`` and no ``pages`` at all (see
54
+ docs/api-notes.md, "MangaLib"). That's turned into ``AuthRequiredError`` here, before
55
+ the response reaches the disk cache — a cached refusal would outlive the chapter
56
+ opening up when its early access ends.
57
+
58
+ Args:
59
+ slug_url: The title's ``{id}--{slug}`` identifier.
60
+ number: The chapter number, as returned by the API (may contain a decimal).
61
+ volume: The volume number.
62
+ branch_id: Which translation to fetch, for chapters with more than one.
63
+
64
+ Returns:
65
+ The ``data`` object from the API response.
66
+
67
+ Raises:
68
+ AuthRequiredError: If the chapter is closed to readers without an account.
69
+ """
70
+ data = await super().get_chapter(
71
+ slug_url, number=number, volume=volume, branch_id=branch_id
72
+ )
73
+ restricted_view = data.get("restricted_view")
74
+ if isinstance(restricted_view, dict) and not restricted_view.get("is_open", True):
75
+ params = {"number": number, "volume": volume}
76
+ if branch_id is not None:
77
+ params["branch_id"] = str(branch_id)
78
+ url = self._http.build_request("GET", f"/manga/{slug_url}/chapter", params=params).url
79
+ raise AuthRequiredError(str(url))
80
+ return data
81
+
82
+ async def list_image_servers(self) -> list[dict[str, Any]]:
83
+ """Fetch the raw image server list shared across the whole lib.social network.
84
+
85
+ Same shape and caveats as ``list_genres``: not site-scoped by any request parameter,
86
+ every server known to the network at once, each tagged with the site ids it serves
87
+ via ``site_ids``.
88
+
89
+ Returns:
90
+ The raw ``data.imageServers`` array from the API response, unfiltered by site.
91
+ """
92
+ response = await self._get(
93
+ "/constants", params=httpx.QueryParams([("fields[]", "imageServers")])
94
+ )
95
+ self._raise_for_status(
96
+ response, not_found=LibSocialError("Unexpected 404 from image server list")
97
+ )
98
+ data: list[dict[str, Any]] = response.json()["data"]["imageServers"]
99
+ return data
@@ -0,0 +1,58 @@
1
+ """Exceptions raised by the SDK.
2
+
3
+ Everything but ``PageDownloadError`` is ``libsocial-core``'s, re-exported here: the core
4
+ raises them, so ``except mangalib.TitleNotFoundError`` and ``except libsocial.TitleNotFoundError``
5
+ catch the same thing. ``MangaLibError`` is the core's base class, so ``except MangaLibError``
6
+ catches every error the SDK raises.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from libsocial.exceptions import (
12
+ AccessBlockedError,
13
+ AmbiguousChapter,
14
+ AuthRequiredError,
15
+ ChapterNotFoundError,
16
+ DownloadTitleInterruptedError,
17
+ LibSocialError,
18
+ MultipleTitleTranslationsError,
19
+ MultipleTranslationsError,
20
+ RateLimitError,
21
+ TitleNotFoundError,
22
+ VolumeNotFoundError,
23
+ )
24
+
25
+ MangaLibError = LibSocialError
26
+ """Base class for all errors raised by the SDK (``libsocial.LibSocialError``)."""
27
+
28
+
29
+ class PageDownloadError(LibSocialError):
30
+ """Raised when a page image couldn't be downloaded from any image server.
31
+
32
+ Each server is tried in turn (see ``MangaLib.download_page()``); this is raised only
33
+ after the last one failed too.
34
+
35
+ Attributes:
36
+ failures: ``(url, reason)`` for every attempt, in the order they were made.
37
+ """
38
+
39
+ def __init__(self, failures: list[tuple[str, str]]) -> None:
40
+ self.failures = failures
41
+ attempts = "; ".join(f"{url}: {reason}" for url, reason in failures)
42
+ super().__init__(f"Couldn't download the page from any image server ({attempts})")
43
+
44
+
45
+ __all__ = [
46
+ "AccessBlockedError",
47
+ "AmbiguousChapter",
48
+ "AuthRequiredError",
49
+ "ChapterNotFoundError",
50
+ "DownloadTitleInterruptedError",
51
+ "MangaLibError",
52
+ "MultipleTitleTranslationsError",
53
+ "MultipleTranslationsError",
54
+ "PageDownloadError",
55
+ "RateLimitError",
56
+ "TitleNotFoundError",
57
+ "VolumeNotFoundError",
58
+ ]
@@ -0,0 +1,89 @@
1
+ """Page images: their URLs on an image server, and downloading them with server fallback.
2
+
3
+ The API lists several image servers (``ImageServer``) and doesn't say which one a page is
4
+ on — every server serves every page under the same path. They don't all let a script in,
5
+ though: when checked, the DDoS-Guard edge in front of ``main``/``secondary`` answered every
6
+ request with an HTML 403 whatever the headers, while ``compress``/``download`` served the
7
+ full-size image (see docs/api-notes.md, "MangaLib"). So downloads start from
8
+ ``compress`` and fall back through the others.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import httpx
14
+
15
+ from mangalib.exceptions import PageDownloadError
16
+ from mangalib.models import ImageServer, Page
17
+
18
+ __all__ = ["DEFAULT_SERVER_ORDER", "page_url"]
19
+
20
+ DEFAULT_SERVER_ORDER = ("compress", "download", "main", "secondary")
21
+ """Image servers tried, in this order, when no server is chosen. ``crop`` isn't one of
22
+ them: it serves 310×450 previews, not the page itself — choose it explicitly if that's what
23
+ you want."""
24
+
25
+
26
+ def page_url(page: Page, server: ImageServer) -> str:
27
+ """The full URL of ``page``'s image on ``server``.
28
+
29
+ ``Page.url`` starts with ``//``; the slashes are collapsed to one (both forms serve the
30
+ same image, checked on every server).
31
+ """
32
+ return f"{server.url.rstrip('/')}/{page.url.lstrip('/')}"
33
+
34
+
35
+ def server_order(servers: list[ImageServer], server: str | None) -> list[ImageServer]:
36
+ """The servers to try for a page, in order (not public API).
37
+
38
+ ``server`` first, if given, then ``DEFAULT_SERVER_ORDER``, skipping any that has the
39
+ same URL as one already in the list — retrying the same host under another name
40
+ wouldn't help.
41
+
42
+ Raises:
43
+ ValueError: If ``server`` isn't one of ``servers``, or none of the servers is known.
44
+ """
45
+ by_id = {item.id: item for item in servers}
46
+ if server is not None and server not in by_id:
47
+ available = ", ".join(sorted(by_id)) or "(none)"
48
+ raise ValueError(f"Unknown image server {server!r}. Available: {available}")
49
+
50
+ ids = [server] if server is not None else []
51
+ ids += [item for item in DEFAULT_SERVER_ORDER if item != server]
52
+ order: list[ImageServer] = []
53
+ for item in ids:
54
+ candidate = by_id.get(item)
55
+ if candidate is not None and all(other.url != candidate.url for other in order):
56
+ order.append(candidate)
57
+ if not order:
58
+ raise ValueError(
59
+ "None of the API's image servers is one the SDK knows how to use "
60
+ f"({', '.join(sorted(by_id)) or 'none listed'}); pass server= explicitly."
61
+ )
62
+ return order
63
+
64
+
65
+ async def download_page(client: httpx.AsyncClient, page: Page, servers: list[ImageServer]) -> bytes:
66
+ """Download ``page`` from the first of ``servers`` that serves it (not public API).
67
+
68
+ A server fails over to the next one on a network error, a non-2xx status, or a response
69
+ that isn't an image — DDoS-Guard's refusal is an HTML page.
70
+
71
+ Raises:
72
+ PageDownloadError: If every server failed.
73
+ """
74
+ failures: list[tuple[str, str]] = []
75
+ for server in servers:
76
+ url = page_url(page, server)
77
+ try:
78
+ response = await client.get(url)
79
+ except httpx.HTTPError as exc:
80
+ failures.append((url, f"{type(exc).__name__}: {exc}"))
81
+ continue
82
+ content_type = response.headers.get("Content-Type", "")
83
+ if not response.is_success:
84
+ failures.append((url, f"HTTP {response.status_code} ({content_type or 'no type'})"))
85
+ elif not content_type.lower().startswith("image/"):
86
+ failures.append((url, f"not an image ({content_type or 'no type'})"))
87
+ else:
88
+ return response.content
89
+ raise PageDownloadError(failures)
@@ -0,0 +1,185 @@
1
+ """Pydantic models: Title, Chapter, Page, Volume, and related types.
2
+
3
+ Everything that isn't specific to mangalib.me is ``libsocial-core``'s and re-exported here
4
+ unchanged (``Cover``, ``Label``, ``Genre``, ``Tag``, ``Country``, ``Person``, ``Team``,
5
+ ``ChapterUser``). ``Title``, ``ChapterBranch``, ``Chapter``, ``Volume`` and ``CatalogPage``
6
+ subclass the core's with what is: a chapter's ``pages`` instead of text, whether a
7
+ translation is closed to readers without an account (``restricted_view``), and
8
+ ``Title.base_names`` without mangalib's format suffixes.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from datetime import datetime
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+
18
+ from libsocial import models as _core
19
+ from libsocial.models import ChapterUser, Country, Cover, Genre, Label, Person, Tag, Team
20
+
21
+ __all__ = [
22
+ "CatalogPage",
23
+ "Chapter",
24
+ "ChapterBranch",
25
+ "ChapterUser",
26
+ "Country",
27
+ "Cover",
28
+ "Genre",
29
+ "ImageServer",
30
+ "Label",
31
+ "Page",
32
+ "Person",
33
+ "RestrictedView",
34
+ "Tag",
35
+ "Team",
36
+ "Title",
37
+ "Volume",
38
+ ]
39
+
40
+ _FORMAT_WORD = (
41
+ r"manga|манга|manhwa|манхва|manhua|маньхуа|webtoon|вебтун"
42
+ r"|web\s*-?\s*comics?|веб\s*-?\s*комикс|comics?|comic\s+book|комикс"
43
+ )
44
+ _FORMAT_SUFFIX = re.compile(
45
+ rf"[\s,:/]*(?:\(\s*(?:{_FORMAT_WORD})\s*\)|\[\s*(?:{_FORMAT_WORD})\s*\])\s*$",
46
+ re.IGNORECASE,
47
+ )
48
+ """A bare format marker at the end of a title name — ``(Manhwa)``, ``(webtoon)``,
49
+ ``[Вебтун]``, ``(Манга)``, ``(Комикс)``, ... — plus the separator (``,``/``:``/``/``) sometimes
50
+ left before it. The word list comes from every name in the live catalog (see
51
+ docs/api-notes.md, "Title names without a format marker"). Tails that say more than the
52
+ format — ``(манга-версия)``, ``(Color Webtoon)``, ``(Fan Comics)``, ``(Graphic Novel)`` — tell
53
+ versions of a work apart, so they're kept."""
54
+
55
+
56
+ def _strip_format_suffix(name: str) -> str:
57
+ """``name`` without its trailing format marker(s) — the same rule as ranobelib's."""
58
+ while (match := _FORMAT_SUFFIX.search(name)) is not None:
59
+ name = name[: match.start()]
60
+ return name.strip()
61
+
62
+
63
+ class Title(_core.Title):
64
+ """Metadata for a single title (manga, manhwa, manhua, comic, ...)."""
65
+
66
+ @property
67
+ def base_names(self) -> list[str]:
68
+ """The title's names without a trailing format marker.
69
+
70
+ ``rus_name``, ``eng_name``, ``name`` and ``other_names``, in that order, each with a
71
+ bare format marker at the end removed — ``"Return of the Runebound Professor
72
+ (webtoon)"`` → ``"Return of the Runebound Professor"``, likewise ``(Manhwa)``,
73
+ ``(Манга)``, ``[Вебтун]``, ``(Комикс)``, ... Unlike ranobelib.me's ``(Новелла)``,
74
+ mangalib.me doesn't append one itself: only a few dozen names have one, typed by
75
+ hand. Other bracketed tails — ``(Doujinshi)``, ``(Colored)``, ``(перезапуск)``, a
76
+ year, ``(манга-версия)`` — tell titles apart and are kept. Whitespace is trimmed;
77
+ empty names and duplicates (keeping the first) are dropped. Nothing else is
78
+ normalized (case, punctuation): that's up to whatever compares the names.
79
+
80
+ The original fields are left as they are. On ``Catalog.list_titles()`` items
81
+ ``other_names`` is empty (the listing doesn't return it), so it contributes nothing.
82
+ """
83
+ names = [self.rus_name, self.eng_name, self.name, *self.other_names]
84
+ stripped = (_strip_format_suffix(name) for name in names if name)
85
+ return list(dict.fromkeys(name for name in stripped if name))
86
+
87
+
88
+ class RestrictedView(BaseModel):
89
+ """Access to a paid or early-access chapter translation.
90
+
91
+ Present only on translations that are (or were) restricted. Logging in isn't supported
92
+ by the SDK, so a closed one can't be read through it — see ``ChapterBranch.is_open``.
93
+ """
94
+
95
+ is_open: bool
96
+ """Whether anyone can read it now, without an account."""
97
+ expired_at: datetime | None = None
98
+ """When early access ends and the chapter opens for everyone; ``None`` for a chapter
99
+ that stays paid."""
100
+ price: int | None = None
101
+ """The price the site asks, in its own currency. The chapter list leaves it ``None``;
102
+ fetching the chapter fills it in."""
103
+
104
+
105
+ class ChapterBranch(_core.ChapterBranch):
106
+ """A single team's (or solo uploader's) translation of a chapter.
107
+
108
+ A chapter has more than one branch when several teams have translated it
109
+ independently; see ``Chapter.branches_count``.
110
+ """
111
+
112
+ restricted_view: RestrictedView | None = None
113
+ """Set when this translation is paid or in early access — see ``is_open``."""
114
+
115
+ @property
116
+ def is_open(self) -> bool:
117
+ """Whether this translation can be read without an account.
118
+
119
+ ``False`` means ``MangaLib.get_chapter()`` raises ``AuthRequiredError`` for it.
120
+ """
121
+ return self.restricted_view is None or self.restricted_view.is_open
122
+
123
+
124
+ class Page(BaseModel):
125
+ """One page image of a chapter.
126
+
127
+ ``url`` is relative to an image server (see ``ImageServer`` and ``mangalib.page_url()``):
128
+ the API doesn't say which server a page is on, every server serves every page.
129
+ """
130
+
131
+ model_config = ConfigDict(populate_by_name=True)
132
+
133
+ id: int
134
+ number: int = Field(alias="slug")
135
+ """1-based position in the chapter — the API's ``slug``."""
136
+ image: str
137
+ """The image's file name. Its extension isn't reliable as a format: the ``compress``
138
+ server sends a JPEG for a ``.png`` name (see docs/api-notes.md)."""
139
+ url: str
140
+ """Path of the image on an image server, e.g. ``"//manga/{slug}/chapters/1-1/00.jpg"``."""
141
+ width: int | None = None
142
+ height: int | None = None
143
+
144
+
145
+ class Chapter(_core.Chapter):
146
+ """A chapter: volume, number, name, available translations, and optionally its pages.
147
+
148
+ ``index``/``item_number``/``branches_count``/``branches`` come from the chapter-list
149
+ endpoint (see ``MangaLib.get_table_of_contents()``) and are ``None``/empty when a
150
+ ``Chapter`` instead comes from fetching a single chapter, which has a different response
151
+ shape. ``pages`` is the reverse: only filled in by fetching the chapter
152
+ (``MangaLib.get_chapter()`` and friends), in reading order.
153
+ """
154
+
155
+ # list[ChapterBranch] here vs list[core ChapterBranch] in the base class: lists are
156
+ # invariant, so mypy flags the narrower item type, which pydantic itself is fine with.
157
+ branches: list[ChapterBranch] = Field(default_factory=list) # type: ignore[assignment]
158
+ pages: list[Page] = Field(default_factory=list)
159
+ restricted_view: RestrictedView | None = None
160
+ """Set on a fetched chapter whose translation is (or was) paid or in early access."""
161
+
162
+
163
+ class ImageServer(BaseModel):
164
+ """A server page images are downloaded from (``GET /constants?fields[]=imageServers``).
165
+
166
+ A page's full URL is the server's ``url`` followed by ``Page.url``.
167
+ """
168
+
169
+ id: str
170
+ """The API's name for it: ``"main"``, ``"secondary"``, ``"compress"``, ``"download"``,
171
+ ``"crop"`` (see docs/api-notes.md for which of them actually serve pages)."""
172
+ label: str
173
+ url: str
174
+
175
+
176
+ class Volume(_core.Volume[Chapter]):
177
+ """A volume: its number and the chapters it contains, in title order."""
178
+
179
+
180
+ class CatalogPage(_core.CatalogPage[Title]):
181
+ """One page of catalog listing/search results, as returned by ``Catalog.list_titles()``.
182
+
183
+ ``items`` reuses ``Title`` as-is: the fields a listing doesn't send (``genres``,
184
+ ``summary``, ``chapter_count``, ``last_chapter_at``, ...) come back at their defaults.
185
+ """
File without changes
@@ -0,0 +1,207 @@
1
+ """Public facade of the SDK: the ``MangaLib`` class.
2
+
3
+ Fetching a title, its chapters and translations is ``libsocial-core``'s
4
+ (``libsocial.TitleClient``); ``MangaLib`` points it at mangalib.me and its own models, and
5
+ adds what depends on a chapter being page images: the image servers and page downloads.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+
14
+ import httpx
15
+
16
+ from libsocial import TitleClient
17
+ from libsocial.console import Verbosity
18
+ from libsocial.exceptions import ChapterNotFoundError
19
+ from libsocial.title import DEFAULT_RATE_LIMIT_RETRIES
20
+ from mangalib import images
21
+ from mangalib._http import MANGALIB, image_headers
22
+ from mangalib.cache import DEFAULT_CACHE_DIR
23
+ from mangalib.client import ApiClient
24
+ from mangalib.models import Chapter, ChapterBranch, ImageServer, Page, Title, Volume
25
+
26
+ __all__ = ["DEFAULT_RATE_LIMIT_RETRIES", "MangaLib"]
27
+
28
+ _IMAGE_CONCURRENCY = 5
29
+ """Page images downloaded at once by ``download_pages()`` — the same conservative value as
30
+ ``ApiClient``'s ``max_concurrency``: the image servers' limits are unknown."""
31
+
32
+ _IMAGE_SERVERS_CACHE_KEY = "constants:imageServers"
33
+ """Disk-cache key of the image server list. Not per site: the cached response covers the
34
+ whole network, and is filtered to mangalib.me on every read."""
35
+
36
+
37
+ class MangaLib(TitleClient[Title, Chapter, Volume]):
38
+ """Entry point for interacting with a single mangalib.me title.
39
+
40
+ Example:
41
+ ```python
42
+ async with MangaLib("https://mangalib.me/ru/manga/7965--chainsaw-man") as lib:
43
+ chapter = await lib.get_chapter(volume=1, number="1", branch_id=4667)
44
+ first_page = await lib.download_page(chapter.pages[0])
45
+ ```
46
+ """
47
+
48
+ site = MANGALIB
49
+ title_model = Title
50
+ chapter_model = Chapter
51
+ volume_model = Volume
52
+ _client: ApiClient
53
+
54
+ def __init__(
55
+ self,
56
+ url: str,
57
+ *,
58
+ cache_dir: str | Path | None = None,
59
+ cache_ttl: float | None = None,
60
+ verbosity: Verbosity = False,
61
+ headers: Mapping[str, str] | None = None,
62
+ ) -> None:
63
+ """Initialize the SDK for a title.
64
+
65
+ Args:
66
+ url: A mangalib.me title URL, or a bare ``{id}--{slug}`` identifier.
67
+ cache_dir: Where to cache raw API responses (title metadata, chapter list,
68
+ chapter pages, image servers) on disk. Page images themselves aren't
69
+ cached. Defaults to ``.mangalib_cache`` in the current working directory.
70
+ cache_ttl: Seconds after which a cached response is treated as stale and
71
+ re-fetched. ``None`` (the default) means cached responses never expire on
72
+ their own — see ``refresh=True`` on individual methods to force one anyway.
73
+ verbosity: Console output level. ``False`` (default): silent. ``"progress_only"``:
74
+ progress bars during ``download_title()``. ``"full"``: the same, plus a line
75
+ logged for every title/chapter-list/chapter fetch, noting whether it was
76
+ served from the disk cache or the API.
77
+ headers: Extra HTTP headers merged over the SDK's defaults (browser-like
78
+ ``Origin``/``Referer``/``User-Agent``, see docs/api-notes.md, section "WAF
79
+ 403") — a key given here replaces the default one, matched
80
+ case-insensitively. Applied to every API request and to page image
81
+ downloads. Meant for when the site's edge protection changes which requests
82
+ it lets through (``AccessBlockedError``). ``None`` (the default) sends the
83
+ defaults as-is.
84
+ """
85
+ super().__init__(
86
+ url,
87
+ cache_dir=cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR,
88
+ cache_ttl=cache_ttl,
89
+ verbosity=verbosity,
90
+ headers=headers,
91
+ )
92
+ self._image_client: httpx.AsyncClient | None = None
93
+ self._image_servers: list[ImageServer] | None = None
94
+
95
+ def _create_client(self, headers: Mapping[str, str] | None) -> ApiClient:
96
+ return ApiClient(headers=headers)
97
+
98
+ def _create_image_client(self) -> httpx.AsyncClient:
99
+ """The HTTP client page images are downloaded with — separate from the API's: other
100
+ hosts, no ``Site-Id``, ``Accept: image/*``."""
101
+ return httpx.AsyncClient(headers=image_headers(self._headers), timeout=30.0)
102
+
103
+ async def aclose(self) -> None:
104
+ """Close the underlying HTTP clients."""
105
+ await super().aclose()
106
+ if self._image_client is not None:
107
+ await self._image_client.aclose()
108
+
109
+ # The base class returns list[libsocial.ChapterBranch]; this returns mangalib's subclass
110
+ # of it (with restricted_view), which lists' invariance makes mypy reject as an override.
111
+ async def get_translations( # type: ignore[override]
112
+ self, volume: int, number: str
113
+ ) -> list[ChapterBranch]:
114
+ """Fetch the available translations (branches) for a chapter.
115
+
116
+ Each one says whether it's open to read without an account (``ChapterBranch.is_open``).
117
+
118
+ Args:
119
+ volume: The chapter's volume number.
120
+ number: The chapter number.
121
+
122
+ Raises:
123
+ ChapterNotFoundError: If no chapter exists for this volume/number.
124
+ """
125
+ volume_str = str(volume)
126
+ for item in await self._get_chapters():
127
+ if item.get("volume") == volume_str and item.get("number") == number:
128
+ return [ChapterBranch.model_validate(branch) for branch in item["branches"] or []]
129
+ raise ChapterNotFoundError(self.slug_url, volume=volume_str, number=number)
130
+
131
+ async def list_image_servers(self, *, refresh: bool = False) -> list[ImageServer]:
132
+ """List the servers mangalib.me serves page images from.
133
+
134
+ Not every one serves pages to a script: see ``download_page()`` for which are tried.
135
+
136
+ Args:
137
+ refresh: Bypass the disk cache and re-fetch from the API.
138
+ """
139
+ if self._image_servers is not None and not refresh:
140
+ return self._image_servers
141
+ data = None if refresh else self._cache.get(_IMAGE_SERVERS_CACHE_KEY)
142
+ if data is None:
143
+ data = await self._client.list_image_servers()
144
+ self._cache.set(_IMAGE_SERVERS_CACHE_KEY, data)
145
+ self._image_servers = [
146
+ ImageServer.model_validate(item)
147
+ for item in data
148
+ if self.site.id in item.get("site_ids", []) and item.get("url")
149
+ ]
150
+ return self._image_servers
151
+
152
+ async def download_page(self, page: Page, *, server: str | None = None) -> bytes:
153
+ """Download one page image.
154
+
155
+ Tries ``server`` first, if given, then ``images.DEFAULT_SERVER_ORDER`` (``compress``,
156
+ ``download``, ``main``, ``secondary``), moving on to the next on a network error,
157
+ an HTTP error or a response that isn't an image. ``compress`` comes first because
158
+ it's the one that served full-size pages when checked; ``main``/``secondary`` sit
159
+ behind a DDoS-Guard check scripts don't pass (see docs/api-notes.md, "MangaLib").
160
+
161
+ Args:
162
+ page: The page, from a fetched chapter's ``pages``.
163
+ server: The ``ImageServer.id`` to try first (see ``list_image_servers()``).
164
+
165
+ Returns:
166
+ The image file's bytes, as the server sent them.
167
+
168
+ Raises:
169
+ ValueError: If ``server`` isn't one of ``list_image_servers()``.
170
+ PageDownloadError: If no server served the image.
171
+ """
172
+ order = images.server_order(await self.list_image_servers(), server)
173
+ if self._image_client is None:
174
+ self._image_client = self._create_image_client()
175
+ return await images.download_page(self._image_client, page, order)
176
+
177
+ async def download_pages(self, chapter: Chapter, *, server: str | None = None) -> list[bytes]:
178
+ """Download every page image of a fetched chapter, in reading order.
179
+
180
+ Up to five pages are downloaded at once, each with ``download_page()``'s server
181
+ fallback.
182
+
183
+ Args:
184
+ chapter: A chapter fetched with ``get_chapter()`` (or ``get_chapters()``,
185
+ ``get_volume()``, ...): one from ``get_table_of_contents()`` has no pages.
186
+ server: The ``ImageServer.id`` to try first — see ``download_page()``.
187
+
188
+ Raises:
189
+ ValueError: If ``server`` isn't one of ``list_image_servers()``.
190
+ PageDownloadError: If a page couldn't be downloaded from any server; the other
191
+ downloads still in flight are cancelled.
192
+ """
193
+ semaphore = asyncio.Semaphore(_IMAGE_CONCURRENCY)
194
+
195
+ async def fetch(page: Page) -> bytes:
196
+ async with semaphore:
197
+ return await self.download_page(page, server=server)
198
+
199
+ # Resolve the servers once, before starting the downloads, so a bad ``server``
200
+ # raises ValueError itself rather than inside a task group's ExceptionGroup.
201
+ images.server_order(await self.list_image_servers(), server)
202
+ try:
203
+ async with asyncio.TaskGroup() as group:
204
+ tasks = [group.create_task(fetch(page)) for page in chapter.pages]
205
+ except* Exception as errors:
206
+ raise errors.exceptions[0] from None
207
+ return [task.result() for task in tasks]