libsocial-core 1.0.0__py3-none-any.whl

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.
libsocial/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ """Site-independent core of the lib.social SDKs (ranobelib.me, mangalib.me, ...)."""
2
+
3
+ from libsocial._http import browser_headers, browser_headers_for
4
+ from libsocial.cache import CacheStats, cache_stats, clear_cache
5
+ from libsocial.catalog import Catalog
6
+ from libsocial.client import ApiClient
7
+ from libsocial.console import Reporter, Verbosity
8
+ from libsocial.exceptions import (
9
+ AccessBlockedError,
10
+ AmbiguousChapter,
11
+ AuthRequiredError,
12
+ ChapterNotFoundError,
13
+ DownloadTitleInterruptedError,
14
+ LibSocialError,
15
+ MultipleTitleTranslationsError,
16
+ MultipleTranslationsError,
17
+ RateLimitError,
18
+ TitleNotFoundError,
19
+ VolumeNotFoundError,
20
+ )
21
+ from libsocial.models import (
22
+ CatalogPage,
23
+ Chapter,
24
+ ChapterBranch,
25
+ ChapterUser,
26
+ Country,
27
+ Cover,
28
+ Genre,
29
+ Label,
30
+ Person,
31
+ Tag,
32
+ Team,
33
+ Title,
34
+ Volume,
35
+ )
36
+ from libsocial.numbering import parse_slug_url
37
+ from libsocial.pacing import Pacer, shared_pacer
38
+ from libsocial.site import Site
39
+ from libsocial.title import PlannedChapter, TitleClient
40
+
41
+ __all__ = [
42
+ "AccessBlockedError",
43
+ "AmbiguousChapter",
44
+ "ApiClient",
45
+ "AuthRequiredError",
46
+ "CacheStats",
47
+ "Catalog",
48
+ "CatalogPage",
49
+ "Chapter",
50
+ "ChapterBranch",
51
+ "ChapterNotFoundError",
52
+ "ChapterUser",
53
+ "Country",
54
+ "Cover",
55
+ "DownloadTitleInterruptedError",
56
+ "Genre",
57
+ "Label",
58
+ "LibSocialError",
59
+ "MultipleTitleTranslationsError",
60
+ "MultipleTranslationsError",
61
+ "Pacer",
62
+ "Person",
63
+ "PlannedChapter",
64
+ "RateLimitError",
65
+ "Reporter",
66
+ "Site",
67
+ "Tag",
68
+ "Team",
69
+ "Title",
70
+ "TitleClient",
71
+ "TitleNotFoundError",
72
+ "Verbosity",
73
+ "Volume",
74
+ "VolumeNotFoundError",
75
+ "browser_headers",
76
+ "browser_headers_for",
77
+ "cache_stats",
78
+ "clear_cache",
79
+ "parse_slug_url",
80
+ "shared_pacer",
81
+ ]
libsocial/_http.py ADDED
@@ -0,0 +1,45 @@
1
+ """HTTP headers shared by every client the SDKs open (not public API).
2
+
3
+ Lives in its own module rather than in ``client.py`` because it isn't specific to the
4
+ api.cdnlibs.org JSON API: a site package's image-downloading clients (cover.cdnlibs.org,
5
+ the site's own uploads) need the same headers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+
12
+ import httpx
13
+
14
+ BROWSER_USER_AGENT = (
15
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 18_5 like Mac OS X) AppleWebKit/605.1.15 "
16
+ "(KHTML, like Gecko) Version/18.5 Mobile/15E148 Safari/604.1"
17
+ )
18
+ """A real browser's ``User-Agent``, sent instead of advertising ``python-httpx``."""
19
+
20
+
21
+ def browser_headers_for(origin: str) -> dict[str, str]:
22
+ """Headers a browser on the site at ``origin`` sends, so requests look like the site's.
23
+
24
+ The DDoS-Guard edge in front of api.cdnlibs.org and cover.cdnlibs.org rejects requests
25
+ without a non-empty ``Referer`` with an HTML 403 page (see docs/api-notes.md, section
26
+ "WAF 403"). Any ``Referer`` value was enough when checked; the site's own one is sent,
27
+ together with ``Origin`` and a real browser ``User-Agent``, so the request as a whole
28
+ matches what the site itself sends.
29
+
30
+ Args:
31
+ origin: The site's web origin, e.g. ``"https://ranobelib.me"`` (see ``Site.origin``).
32
+ """
33
+ return {"Origin": origin, "Referer": f"{origin}/", "User-Agent": BROWSER_USER_AGENT}
34
+
35
+
36
+ def browser_headers(origin: str, overrides: Mapping[str, str] | None = None) -> httpx.Headers:
37
+ """``browser_headers_for(origin)`` with ``overrides`` merged over them.
38
+
39
+ A key in ``overrides`` replaces the default one, matched case-insensitively (``httpx``
40
+ headers semantics) — the same merge ``ApiClient(headers=...)`` does for API requests.
41
+ """
42
+ headers = httpx.Headers(browser_headers_for(origin))
43
+ if overrides:
44
+ headers.update(overrides)
45
+ return headers
libsocial/cache.py ADDED
@@ -0,0 +1,180 @@
1
+ """Disk cache for raw JSON API responses, keyed by request identity.
2
+
3
+ Also the cache-maintenance helpers, ``cache_stats()`` and ``clear_cache()`` — the
4
+ on-disk layout is internal, so code outside the SDKs should go through these rather than
5
+ measure or delete the cache's files itself.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import re
13
+ import time
14
+ from collections.abc import Callable, Iterator
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ DEFAULT_CACHE_DIR = Path(".libsocial_cache")
20
+ """Default cache directory, relative to the current working directory."""
21
+
22
+ _ENTRY_NAME = re.compile(r"[0-9a-f]{64}\.json")
23
+ """Name of a file ``DiskCache`` writes (``_path_for``) — anything else in the directory
24
+ isn't the cache's and is never counted or removed."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class CacheStats:
29
+ """How many cache entries there are (or were removed), and their total size on disk."""
30
+
31
+ entries: int
32
+ bytes: int
33
+
34
+
35
+ class DiskCache:
36
+ """Caches JSON-serializable values to disk, keyed by an arbitrary string.
37
+
38
+ One file per key, named by the key's hash (so keys built from slugs, chapter numbers,
39
+ etc. are always safe filenames), holding the cached value plus the time it was written
40
+ so ``ttl`` can be checked on read without touching any other entry. Corrupted or
41
+ unreadable entries are treated as a cache miss rather than raised as an error, since a
42
+ stale/broken cache file shouldn't stop the SDK from just re-fetching the data.
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ cache_dir: str | Path,
48
+ *,
49
+ ttl: float | None = None,
50
+ clock: Callable[[], float] = time.time,
51
+ ) -> None:
52
+ """Initialize the cache.
53
+
54
+ Args:
55
+ cache_dir: Directory to store cache files in. Created lazily on first write.
56
+ ttl: Seconds after which a cache entry is treated as a miss. ``None`` (the
57
+ default) means entries never expire.
58
+ clock: Time source used to stamp and check entries. Overridable for tests.
59
+ """
60
+ self._dir = Path(cache_dir)
61
+ self._ttl = ttl
62
+ self._clock = clock
63
+
64
+ def get(self, key: str) -> Any | None:
65
+ """Return the cached value for ``key``, or ``None`` on a miss or expiry."""
66
+ try:
67
+ payload = json.loads(self._path_for(key).read_text(encoding="utf-8"))
68
+ except (OSError, json.JSONDecodeError, KeyError):
69
+ return None
70
+ if self._ttl is not None and self._clock() - payload["cached_at"] > self._ttl:
71
+ return None
72
+ return payload["data"]
73
+
74
+ def set(self, key: str, data: Any) -> None:
75
+ """Write ``data`` to disk under ``key``, creating the cache directory if needed."""
76
+ path = self._path_for(key)
77
+ path.parent.mkdir(parents=True, exist_ok=True)
78
+ payload = {"cached_at": self._clock(), "data": data}
79
+ path.write_text(json.dumps(payload), encoding="utf-8")
80
+
81
+ def stats(self) -> CacheStats:
82
+ """Count the cache's entries and their total size. See ``cache_stats()``."""
83
+ entries = size = 0
84
+ for path in self._entry_paths():
85
+ try:
86
+ size += path.stat().st_size
87
+ except OSError: # Removed since it was listed.
88
+ continue
89
+ entries += 1
90
+ return CacheStats(entries=entries, bytes=size)
91
+
92
+ def clear(self, *, older_than: float | None = None) -> CacheStats:
93
+ """Remove the cache's entries, or only those older than ``older_than`` seconds.
94
+
95
+ See ``clear_cache()``.
96
+ """
97
+ if older_than is not None and older_than < 0:
98
+ raise ValueError(f"older_than must be >= 0, got {older_than}")
99
+ now = self._clock()
100
+ entries = size = 0
101
+ for path in self._entry_paths():
102
+ if older_than is not None and not self._is_older_than(path, now - older_than):
103
+ continue
104
+ try:
105
+ entry_size = path.stat().st_size
106
+ path.unlink()
107
+ except OSError:
108
+ # Already removed by someone else, or (on Windows) open in another process
109
+ # right now — either way, not ours to count as removed.
110
+ continue
111
+ entries += 1
112
+ size += entry_size
113
+ return CacheStats(entries=entries, bytes=size)
114
+
115
+ def _is_older_than(self, path: Path, cutoff: float) -> bool:
116
+ try:
117
+ cached_at = float(json.loads(path.read_text(encoding="utf-8"))["cached_at"])
118
+ except FileNotFoundError:
119
+ return False
120
+ except (OSError, ValueError, KeyError, TypeError):
121
+ # Unreadable/corrupted: get() can never serve it, so it's as good as expired.
122
+ return True
123
+ return cached_at < cutoff
124
+
125
+ def _entry_paths(self) -> Iterator[Path]:
126
+ try:
127
+ children = list(self._dir.iterdir())
128
+ except (FileNotFoundError, NotADirectoryError):
129
+ return
130
+ for path in children:
131
+ if _ENTRY_NAME.fullmatch(path.name) and path.is_file() and not path.is_symlink():
132
+ yield path
133
+
134
+ def _path_for(self, key: str) -> Path:
135
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
136
+ return self._dir / f"{digest}.json"
137
+
138
+
139
+ def cache_stats(cache_dir: str | Path = DEFAULT_CACHE_DIR) -> CacheStats:
140
+ """Count the entries in a disk cache and their total size on disk.
141
+
142
+ Only files the cache itself writes are counted, not anything else that happens to be in
143
+ the directory.
144
+
145
+ Args:
146
+ cache_dir: The cache directory — the same ``cache_dir`` passed to ``TitleClient`` /
147
+ ``Catalog`` (default: ``DEFAULT_CACHE_DIR``, ``.libsocial_cache`` in the current
148
+ directory; a site package may use its own default).
149
+
150
+ Returns:
151
+ The number of entries and their total size in bytes. A missing directory is an empty
152
+ cache, ``CacheStats(entries=0, bytes=0)``, not an error.
153
+ """
154
+ return DiskCache(cache_dir).stats()
155
+
156
+
157
+ def clear_cache(
158
+ cache_dir: str | Path = DEFAULT_CACHE_DIR, *, older_than: float | None = None
159
+ ) -> CacheStats:
160
+ """Remove entries from a disk cache.
161
+
162
+ Only files the cache itself writes are removed — anything else in the directory, and the
163
+ directory itself, is left alone. Safe to call while clients are using the same cache: an
164
+ entry being read as it's removed is just a cache miss, and one written after it's
165
+ removed is simply recreated.
166
+
167
+ Args:
168
+ cache_dir: The cache directory — see ``cache_stats()``.
169
+ older_than: If given, only remove entries written more than this many seconds ago
170
+ (the same age check ``cache_ttl`` uses), plus any corrupted entry, which the
171
+ cache can't serve anyway. ``None`` (the default) removes every entry.
172
+
173
+ Returns:
174
+ The number of entries removed and the bytes they took. A missing directory removes
175
+ nothing, ``CacheStats(entries=0, bytes=0)``, rather than raising.
176
+
177
+ Raises:
178
+ ValueError: If ``older_than`` is negative.
179
+ """
180
+ return DiskCache(cache_dir).clear(older_than=older_than)
libsocial/catalog.py ADDED
@@ -0,0 +1,350 @@
1
+ """Catalog listing/search: the ``Catalog`` class.
2
+
3
+ Unlike ``TitleClient`` (scoped to one title via its URL/slug), catalog listing isn't scoped
4
+ to any title at all — see CLAUDE.md's roadmap entry for why this is a separate entry point
5
+ with its own ``ApiClient``/``DiskCache`` rather than a method on the title client.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+ from types import TracebackType
13
+ from typing import Any, Generic, Self, TypeVar
14
+
15
+ from libsocial.cache import DEFAULT_CACHE_DIR, DiskCache
16
+ from libsocial.client import ApiClient
17
+ from libsocial.models import CatalogPage, Country, Genre, Label, TitleT
18
+ from libsocial.site import Site
19
+
20
+ DEFAULT_SORT = "last_chapter_at"
21
+ """Default sort order: title's last-chapter timestamp — the closest real equivalent to
22
+ "recently updated" (the API has no ``updated_at`` sort value, see docs/api-notes.md)."""
23
+
24
+ MIN_PER_PAGE = 10
25
+ MAX_PER_PAGE = 60
26
+
27
+
28
+ class Catalog(Generic[TitleT]):
29
+ """Listing/searching one site's catalog.
30
+
31
+ Not used directly: a site package subclasses it, setting ``site`` and ``page_model``
32
+ (and optionally ``default_cache_dir``) as class attributes — so its own ``Catalog``
33
+ keeps a plain constructor and returns its own page and ``Title`` models.
34
+
35
+ Example:
36
+ ```python
37
+ class RanobeLibCatalog(Catalog[Title]):
38
+ site = Site(id=3, origin="https://ranobelib.me")
39
+ page_model = CatalogPage[Title]
40
+
41
+ async with RanobeLibCatalog() as catalog:
42
+ page = await catalog.list_titles(query="dxd", genres=[34], statuses=[1])
43
+ for title in page.items:
44
+ print(title.name)
45
+ ```
46
+
47
+ Attributes:
48
+ site: The site whose catalog is listed. Set by the subclass.
49
+ page_model: The model a listing page is validated as — ``CatalogPage[ItsTitle]``
50
+ or a subclass of it, which also decides the model of its items. Set by the
51
+ subclass.
52
+ default_cache_dir: ``cache_dir`` used when none is passed.
53
+ """
54
+
55
+ site: Site
56
+ page_model: type[CatalogPage[TitleT]]
57
+ default_cache_dir: Path = DEFAULT_CACHE_DIR
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ cache_dir: str | Path | None = None,
63
+ cache_ttl: float | None = None,
64
+ headers: Mapping[str, str] | None = None,
65
+ ) -> None:
66
+ """Initialize the catalog client.
67
+
68
+ Args:
69
+ cache_dir: Where to cache raw API responses on disk, one entry per distinct set
70
+ of listing parameters (page/filters/sort) — same disk cache as
71
+ ``TitleClient`` uses (``cache.py``), just keyed by listing parameters instead
72
+ of a title's slug. Defaults to ``default_cache_dir``.
73
+ cache_ttl: Seconds after which a cached page is treated as stale and re-fetched.
74
+ ``None`` (the default) means cached pages never expire on their own — see
75
+ ``refresh=True`` on ``list_titles()`` to force one anyway.
76
+ headers: Extra HTTP headers merged over the SDK's defaults, same as
77
+ ``TitleClient(headers=...)`` — see there.
78
+ """
79
+ self._client = self._create_client(headers)
80
+ self._cache = DiskCache(
81
+ cache_dir if cache_dir is not None else self.default_cache_dir, ttl=cache_ttl
82
+ )
83
+
84
+ def _create_client(self, headers: Mapping[str, str] | None) -> ApiClient:
85
+ """The ``ApiClient`` requests go through — a site package may return its own subclass.
86
+
87
+ Args:
88
+ headers: The ``headers`` passed to the constructor.
89
+ """
90
+ return ApiClient(self.site, headers=headers)
91
+
92
+ async def __aenter__(self) -> Self:
93
+ return self
94
+
95
+ async def __aexit__(
96
+ self,
97
+ exc_type: type[BaseException] | None,
98
+ exc: BaseException | None,
99
+ traceback: TracebackType | None,
100
+ ) -> None:
101
+ await self.aclose()
102
+
103
+ async def aclose(self) -> None:
104
+ """Close the underlying HTTP client."""
105
+ await self._client.aclose()
106
+
107
+ async def list_titles(
108
+ self,
109
+ *,
110
+ page: int = 1,
111
+ per_page: int = 30,
112
+ query: str | None = None,
113
+ genres: list[int] | None = None,
114
+ tags: list[int] | None = None,
115
+ statuses: list[int] | None = None,
116
+ countries: list[int] | None = None,
117
+ min_chapters: int | None = None,
118
+ max_chapters: int | None = None,
119
+ sort: str = DEFAULT_SORT,
120
+ refresh: bool = False,
121
+ ) -> CatalogPage[TitleT]:
122
+ """Fetch one page of catalog listing/search results.
123
+
124
+ Args:
125
+ page: 1-based page number.
126
+ per_page: How many titles per page. Must be between ``MIN_PER_PAGE`` (10) and
127
+ ``MAX_PER_PAGE`` (60) — the API's own limits (see docs/api-notes.md);
128
+ anything outside that range raises here instead of a confusing 422 from
129
+ the API.
130
+ query: Free-text search term, matched against name/rus_name/eng_name. ``None``
131
+ (the default) or an empty string lists without searching.
132
+ genres: Genre ids to filter by. A title must have *all* of them, not just one
133
+ (AND, not OR — see docs/api-notes.md). Not validated against ``list_genres()``
134
+ before sending — an unrecognized id just matches nothing rather than erroring.
135
+ tags: ``Tag.id``s (from ``Title.tags``) to filter by. Same AND semantics as
136
+ ``genres`` — a title must have *all* of them, not just any one (confirmed
137
+ against the live API, see docs/api-notes.md; not the OR some might expect
138
+ from tags being more numerous/specific than genres). Also not validated
139
+ before sending, same as ``genres``.
140
+ statuses: ``Title.status.id``s to filter by (e.g. ongoing, completed). A title
141
+ matches if its own status is *any* of these (OR, same as ``countries`` — a
142
+ title only has one status, see docs/api-notes.md). ``None`` or an empty list
143
+ applies no status filter. Ids come from ``list_statuses()``.
144
+ countries: ``Country.id``s to filter by. A title matches if its own country is
145
+ *any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
146
+ country of origin, so requiring all of them could never match past the first,
147
+ see docs/api-notes.md). ``None`` or an empty list applies no country filter.
148
+ Ids come from ``list_countries()``. Sent on the wire as the API's ``types[]``
149
+ parameter, not a ``country``/``countries[]`` one — see docs/api-notes.md for
150
+ why (``Country`` mirrors the API's own "type" concept, which isn't strictly
151
+ limited to literal countries).
152
+ min_chapters: Only titles with at least this many chapters (inclusive). Counts
153
+ the same number as ``Title.chapter_count`` (uploaded chapters). ``None``
154
+ (the default) applies no lower bound.
155
+ max_chapters: Only titles with at most this many chapters (inclusive). ``None``
156
+ (the default) applies no upper bound.
157
+ sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
158
+ parameter — an actual ``sort`` parameter exists but is silently ignored by
159
+ the API (see docs/api-notes.md). Known accepted values: ``"name"``,
160
+ ``"created_at"``, ``"views"``, ``"chap_count"``, ``"last_chapter_at"``
161
+ (the default), ``"rate_avg"``, ``"random"`` — this list isn't guaranteed
162
+ exhaustive.
163
+ refresh: Bypass the disk cache and re-fetch from the API even if this exact
164
+ combination of parameters was already cached.
165
+
166
+ Returns:
167
+ A ``page_model`` page: the matching titles plus whether a next page exists.
168
+
169
+ Raises:
170
+ ValueError: If ``page`` is less than 1, ``per_page`` is outside
171
+ ``MIN_PER_PAGE..MAX_PER_PAGE``, ``min_chapters``/``max_chapters`` is
172
+ negative, or ``min_chapters`` is greater than ``max_chapters``.
173
+ """
174
+ if page < 1:
175
+ raise ValueError(f"page must be at least 1, got {page}.")
176
+ if not MIN_PER_PAGE <= per_page <= MAX_PER_PAGE:
177
+ raise ValueError(
178
+ f"per_page must be between {MIN_PER_PAGE} and {MAX_PER_PAGE}, got {per_page}."
179
+ )
180
+ for name, value in (("min_chapters", min_chapters), ("max_chapters", max_chapters)):
181
+ if value is not None and value < 0:
182
+ raise ValueError(f"{name} must be at least 0, got {value}.")
183
+ if min_chapters is not None and max_chapters is not None and min_chapters > max_chapters:
184
+ # The API itself silently swaps the bounds instead (see docs/api-notes.md) —
185
+ # raising here rather than relying on that undocumented behavior.
186
+ raise ValueError(
187
+ f"min_chapters ({min_chapters}) must not be greater than "
188
+ f"max_chapters ({max_chapters})."
189
+ )
190
+
191
+ key = _cache_key(
192
+ site_id=self.site.id,
193
+ page=page,
194
+ per_page=per_page,
195
+ query=query,
196
+ genres=genres,
197
+ tags=tags,
198
+ statuses=statuses,
199
+ countries=countries,
200
+ min_chapters=min_chapters,
201
+ max_chapters=max_chapters,
202
+ sort=sort,
203
+ )
204
+ if not refresh:
205
+ cached = self._cache.get(key)
206
+ if cached is not None:
207
+ return self._build_page(cached)
208
+
209
+ data = await self._client.list_titles(
210
+ page=page,
211
+ per_page=per_page,
212
+ query=query,
213
+ genres=genres,
214
+ tags=tags,
215
+ statuses=statuses,
216
+ countries=countries,
217
+ min_chapters=min_chapters,
218
+ max_chapters=max_chapters,
219
+ sort=sort,
220
+ )
221
+ self._cache.set(key, data)
222
+ return self._build_page(data)
223
+
224
+ async def list_genres(self, *, refresh: bool = False) -> list[Genre]:
225
+ """Fetch the full list of catalog genres (id → name), for use as filter options
226
+ with ``list_titles(genres=[...])``.
227
+
228
+ The underlying endpoint (``GET /api/constants?fields[]=genres``) has no site-scoping
229
+ parameter — it returns the full genre list shared across the whole lib.social
230
+ network in one shot, each tagged with the site ids it applies to (see
231
+ docs/api-notes.md). This filters that down to genres tagged for ``site`` before
232
+ returning, so callers don't get filter options that could never match any of the
233
+ site's titles (e.g. "Детское", tagged only for site 5, on ranobelib.me).
234
+
235
+ Args:
236
+ refresh: Bypass the disk cache and re-fetch from the API even if the genre list
237
+ was already cached.
238
+
239
+ Returns:
240
+ Every ``Genre`` (id, name, adult flag) that applies to ``site``.
241
+ """
242
+ # No site in the key, unlike list_titles(): the response is the whole network's
243
+ # list, and _for_site() filters it on every read, cached or not.
244
+ key = "catalog:genres"
245
+ if not refresh:
246
+ cached = self._cache.get(key)
247
+ if cached is not None:
248
+ return _for_site(Genre, cached, self.site)
249
+
250
+ data = await self._client.list_genres()
251
+ self._cache.set(key, data)
252
+ return _for_site(Genre, data, self.site)
253
+
254
+ async def list_countries(self, *, refresh: bool = False) -> list[Country]:
255
+ """Fetch the full list of catalog countries (id → name), for use as filter options
256
+ with ``list_titles(countries=...)``.
257
+
258
+ Mirrors ``list_genres()`` exactly, down to the network-wide, not-site-scoped shape
259
+ of the underlying endpoint (``GET /api/constants?fields[]=types`` — the API's own
260
+ name for what this SDK calls "country" is "type", see docs/api-notes.md and
261
+ ``Country``'s docstring). This filters that down to entries tagged for ``site`` before
262
+ returning, same as ``list_genres()`` does for genres.
263
+
264
+ Args:
265
+ refresh: Bypass the disk cache and re-fetch from the API even if the country
266
+ list was already cached.
267
+
268
+ Returns:
269
+ Every ``Country`` (id, name) that applies to ``site``.
270
+ """
271
+ key = "catalog:countries"
272
+ if not refresh:
273
+ cached = self._cache.get(key)
274
+ if cached is not None:
275
+ return _for_site(Country, cached, self.site)
276
+
277
+ data = await self._client.list_countries()
278
+ self._cache.set(key, data)
279
+ return _for_site(Country, data, self.site)
280
+
281
+ async def list_statuses(self, *, refresh: bool = False) -> list[Label]:
282
+ """Fetch the full list of title statuses (id → label), for use as filter options
283
+ with ``list_titles(statuses=...)``.
284
+
285
+ Mirrors ``list_genres()``/``list_countries()``: the underlying endpoint
286
+ (``GET /api/constants?fields[]=status``) isn't site-scoped, so entries not tagged for
287
+ ``site`` are dropped before returning (see docs/api-notes.md). Returns the same
288
+ ``Label`` model as ``Title.status``, so a title's status can be compared against
289
+ these directly.
290
+
291
+ Args:
292
+ refresh: Bypass the disk cache and re-fetch from the API even if the status list
293
+ was already cached.
294
+
295
+ Returns:
296
+ Every status (``Label``: id, label) that applies to ``site``.
297
+ """
298
+ key = "catalog:statuses"
299
+ if not refresh:
300
+ cached = self._cache.get(key)
301
+ if cached is not None:
302
+ return _for_site(Label, cached, self.site)
303
+
304
+ data = await self._client.list_statuses()
305
+ self._cache.set(key, data)
306
+ return _for_site(Label, data, self.site)
307
+
308
+ def _build_page(self, data: dict[str, Any]) -> CatalogPage[TitleT]:
309
+ meta = data["meta"]
310
+ return self.page_model.model_validate(
311
+ {
312
+ "items": data["data"],
313
+ "page": meta["current_page"],
314
+ "has_next_page": meta["has_next_page"],
315
+ }
316
+ )
317
+
318
+
319
+ def _cache_key(
320
+ *,
321
+ site_id: int,
322
+ page: int,
323
+ per_page: int,
324
+ query: str | None,
325
+ genres: list[int] | None,
326
+ tags: list[int] | None,
327
+ statuses: list[int] | None,
328
+ countries: list[int] | None,
329
+ min_chapters: int | None,
330
+ max_chapters: int | None,
331
+ sort: str,
332
+ ) -> str:
333
+ genres_part = ",".join(str(genre_id) for genre_id in genres or [])
334
+ tags_part = ",".join(str(tag_id) for tag_id in tags or [])
335
+ statuses_part = ",".join(str(status_id) for status_id in statuses or [])
336
+ countries_part = ",".join(str(country_id) for country_id in countries or [])
337
+ # The listing is scoped to the site (site_id[]), so catalogs for different sites sharing
338
+ # a cache_dir must not read each other's pages.
339
+ return (
340
+ f"catalog:{site_id}:{page}:{per_page}:{query or ''}:{genres_part}:{tags_part}:"
341
+ f"{statuses_part}:{countries_part}:{min_chapters}:{max_chapters}:{sort}"
342
+ )
343
+
344
+
345
+ _ConstantT = TypeVar("_ConstantT", Genre, Country, Label)
346
+
347
+
348
+ def _for_site(model: type[_ConstantT], data: list[dict[str, Any]], site: Site) -> list[_ConstantT]:
349
+ """Validate a raw ``/constants`` list as ``model``, keeping entries tagged for ``site``."""
350
+ return [model.model_validate(item) for item in data if site.id in item.get("site_ids", [])]