Skip to content

API reference

Entry point

ranobelib.RanobeLib

RanobeLib(
    url: str,
    *,
    cache_dir: str | Path | None = None,
    cache_ttl: float | None = None,
    verbosity: Verbosity = False,
)

Entry point for interacting with a single ranobelib.me title.

Example
async with RanobeLib("https://ranobelib.me/ru/book/6712--high-school-dxd-novel") as lib:
    info = await lib.get_info()

Initialize the SDK for a title.

Parameters:

Name Type Description Default
url str

A ranobelib.me title URL, or a bare {id}--{slug} identifier.

required
cache_dir str | Path | None

Where to cache raw API responses (title metadata, chapter list, chapter content) on disk, so a repeated export or downloading newly added chapters doesn't re-fetch data already on hand. Defaults to .ranobelib_cache in the current working directory.

None
cache_ttl float | None

Seconds after which a cached response is treated as stale and re-fetched. None (the default) means cached responses never expire on their own — see refresh=True on individual methods to force one anyway.

None
verbosity Verbosity

Console output level. False (default): silent. "progress_only": progress bars during download_title()/export()/estimate_title_size(). "full": the same progress bars, plus a line logged for every title/ chapter-list/chapter-content fetch, noting whether it was served from the disk cache or the API.

False
Source code in src/ranobelib/sdk.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def __init__(
    self,
    url: str,
    *,
    cache_dir: str | Path | None = None,
    cache_ttl: float | None = None,
    verbosity: Verbosity = False,
) -> None:
    """Initialize the SDK for a title.

    Args:
        url: A ranobelib.me title URL, or a bare ``{id}--{slug}`` identifier.
        cache_dir: Where to cache raw API responses (title metadata, chapter list,
            chapter content) on disk, so a repeated export or downloading newly added
            chapters doesn't re-fetch data already on hand. Defaults to
            ``.ranobelib_cache`` in the current working directory.
        cache_ttl: Seconds after which a cached response is treated as stale and
            re-fetched. ``None`` (the default) means cached responses never expire on
            their own — see ``refresh=True`` on individual methods to force one anyway.
        verbosity: Console output level. ``False`` (default): silent. ``"progress_only"``:
            progress bars during ``download_title()``/``export()``/``estimate_title_size()``.
            ``"full"``: the same progress bars, plus a line logged for every title/
            chapter-list/chapter-content fetch, noting whether it was served from the
            disk cache or the API.
    """
    self._slug_url = parse_slug_url(url)
    self._client = ApiClient()
    self._cache = DiskCache(
        cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR, ttl=cache_ttl
    )
    self._reporter = Reporter(verbosity)

aclose async

aclose() -> None

Close the underlying HTTP client.

Source code in src/ranobelib/sdk.py
107
108
109
async def aclose(self) -> None:
    """Close the underlying HTTP client."""
    await self._client.aclose()

download_title async

download_title(
    *,
    branch_id: int | None = None,
    translation_index: int | None = None,
    chapter_delay: float = 0.0,
    on_chapter: Callable[[int, int], None] | None = None,
    max_rate_limit_retries: int = DEFAULT_RATE_LIMIT_RETRIES,
) -> list[Volume]

Download every chapter of the title, across all its volumes.

Fetches the chapter list once, then every chapter's content, sequentially, in the API's own order — same pacing/retry/backoff as any other chapter fetch (see ApiClient), plus chapter_delay on top if given.

Resolving translations is checked for the whole title up front, not chapter by chapter: if any chapters have more than one translation, this fetches nothing and raises MultipleTitleTranslationsError listing all of them at once (unless branch_id or translation_index resolves every one of them), rather than failing partway through a long download on the first ambiguous chapter.

A long title's sequential download can outlast ApiClient's own retry budget for 429s (tuned for a single one-off request, not hundreds/thousands of sequential fetches — see docs/api-notes.md). On top of that, each chapter fetch that still comes back rate-limited is retried again here, up to max_rate_limit_retries times, with its own (coarser) backoff. If a chapter fetch still fails after that — from rate limiting or anything else — the chapters already downloaded are not discarded: they're attached to a raised DownloadTitleInterruptedError instead of being lost.

Parameters:

Name Type Description Default
branch_id int | None

Translation to use for every chapter that has more than one — same meaning as get_chapter(..., branch_id=...), applied title-wide. Chapters with only one translation are unaffected. Mutually exclusive with translation_index.

None
translation_index int | None

Alternative to branch_id: pick the translation at this position in each ambiguous chapter's branches list (0 for the first, and so on) — useful when ambiguous chapters don't share one branch_id across the title. Mutually exclusive with branch_id.

None
chapter_delay float

Extra delay, in seconds, after fetching each chapter, on top of ApiClient's own per-request pacing — to be gentler on the API during a large bulk download. Defaults to no extra delay.

0.0
on_chapter Callable[[int, int], None] | None

Called with (completed, total) after each chapter is fetched, if given — a programmatic progress hook for callers that aren't printing to a console (e.g. a web app polling/streaming download progress to a browser), who would otherwise have no way to observe anything before this whole await returns. Independent of verbosity: both can be set at once, each drives its own output.

None
max_rate_limit_retries int

How many extra times to retry a single chapter fetch that comes back rate-limited, on top of ApiClient's own retries, before giving up on the whole download — see above. Each retry honors the API's Retry-After if it sent one, otherwise waits with its own capped exponential backoff. Set to 0 to disable this extra layer and let a persistent RateLimitError end the download immediately (still wrapped in DownloadTitleInterruptedError with whatever was already fetched).

DEFAULT_RATE_LIMIT_RETRIES

Raises:

Type Description
ValueError

If both branch_id and translation_index are given.

MultipleTitleTranslationsError

If the title has one or more chapters with more than one translation that branch_id/translation_index didn't resolve.

DownloadTitleInterruptedError

If fetching a chapter fails unrecoverably partway through the download — carries every chapter already fetched.

Source code in src/ranobelib/sdk.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
async def download_title(
    self,
    *,
    branch_id: int | None = None,
    translation_index: int | None = None,
    chapter_delay: float = 0.0,
    on_chapter: Callable[[int, int], None] | None = None,
    max_rate_limit_retries: int = DEFAULT_RATE_LIMIT_RETRIES,
) -> list[Volume]:
    """Download every chapter of the title, across all its volumes.

    Fetches the chapter list once, then every chapter's content, sequentially, in the
    API's own order — same pacing/retry/backoff as any other chapter fetch (see
    ``ApiClient``), plus ``chapter_delay`` on top if given.

    Resolving translations is checked for the whole title up front, not chapter by
    chapter: if any chapters have more than one translation, this fetches nothing and
    raises ``MultipleTitleTranslationsError`` listing *all* of them at once (unless
    ``branch_id`` or ``translation_index`` resolves every one of them), rather than
    failing partway through a long download on the first ambiguous chapter.

    A long title's sequential download can outlast ``ApiClient``'s own retry budget for
    429s (tuned for a single one-off request, not hundreds/thousands of sequential
    fetches — see docs/api-notes.md). On top of that, each chapter fetch that still comes
    back rate-limited is retried again here, up to ``max_rate_limit_retries`` times, with
    its own (coarser) backoff. If a chapter fetch still fails after that — from rate
    limiting or anything else — the chapters already downloaded are not discarded: they're
    attached to a raised ``DownloadTitleInterruptedError`` instead of being lost.

    Args:
        branch_id: Translation to use for every chapter that has more than one — same
            meaning as ``get_chapter(..., branch_id=...)``, applied title-wide. Chapters
            with only one translation are unaffected. Mutually exclusive with
            ``translation_index``.
        translation_index: Alternative to ``branch_id``: pick the translation at this
            position in each ambiguous chapter's ``branches`` list (``0`` for the first,
            and so on) — useful when ambiguous chapters don't share one ``branch_id``
            across the title. Mutually exclusive with ``branch_id``.
        chapter_delay: Extra delay, in seconds, after fetching each chapter, on top of
            ``ApiClient``'s own per-request pacing — to be gentler on the API during a
            large bulk download. Defaults to no extra delay.
        on_chapter: Called with ``(completed, total)`` after each chapter is fetched, if
            given — a programmatic progress hook for callers that aren't printing to a
            console (e.g. a web app polling/streaming download progress to a browser),
            who would otherwise have no way to observe anything before this whole
            ``await`` returns. Independent of ``verbosity``: both can be set at once,
            each drives its own output.
        max_rate_limit_retries: How many extra times to retry a single chapter fetch that
            comes back rate-limited, on top of ``ApiClient``'s own retries, before giving
            up on the whole download — see above. Each retry honors the API's
            ``Retry-After`` if it sent one, otherwise waits with its own capped
            exponential backoff. Set to ``0`` to disable this extra layer and let a
            persistent ``RateLimitError`` end the download immediately (still wrapped in
            ``DownloadTitleInterruptedError`` with whatever was already fetched).

    Raises:
        ValueError: If both ``branch_id`` and ``translation_index`` are given.
        MultipleTitleTranslationsError: If the title has one or more chapters with more
            than one translation that ``branch_id``/``translation_index`` didn't resolve.
        DownloadTitleInterruptedError: If fetching a chapter fails unrecoverably partway
            through the download — carries every chapter already fetched.
    """
    if branch_id is not None and translation_index is not None:
        raise ValueError("Pass at most one of branch_id or translation_index, not both.")

    raw_chapters = await self._get_chapters()

    planned: list[tuple[str, str, int | None]] = []
    unresolved: list[AmbiguousChapter] = []
    for item in raw_chapters:
        volume_str: str = item["volume"]
        number: str = item["number"]
        branches = item.get("branches") or []
        if len(branches) <= 1:
            planned.append((volume_str, number, None))
            continue

        resolved, selected = _resolve_bulk_branch_id(
            branches, branch_id=branch_id, translation_index=translation_index
        )
        if resolved:
            planned.append((volume_str, number, selected))
        else:
            unresolved.append(
                AmbiguousChapter(
                    volume=volume_str,
                    number=number,
                    branches=[ChapterBranch.model_validate(branch) for branch in branches],
                )
            )

    if unresolved:
        raise MultipleTitleTranslationsError(self._slug_url, chapters=unresolved)

    chapters: list[Chapter] = []
    total = len(planned)
    try:
        with self._reporter.progress(f"Downloading {self._slug_url}", total) as advance:
            for index, (volume_str, number, selected_branch_id) in enumerate(planned):
                chapters.append(
                    await self._fetch_chapter_riding_out_rate_limits(
                        volume_str,
                        number,
                        selected_branch_id,
                        max_rate_limit_retries=max_rate_limit_retries,
                    )
                )
                advance()
                if on_chapter is not None:
                    on_chapter(index + 1, total)
                if chapter_delay and index < total - 1:
                    await asyncio.sleep(chapter_delay)
    except RanobeLibError as exc:
        raise DownloadTitleInterruptedError(
            self._slug_url,
            volumes=_group_into_volumes(chapters),
            completed=len(chapters),
            total=total,
        ) from exc
    return _group_into_volumes(chapters)

estimate_title_size async

estimate_title_size(
    *,
    sample_size: int = DEFAULT_SAMPLE_SIZE,
    average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE,
    branch_id: int | None = None,
    translation_index: int | None = None,
) -> int

Estimate the whole title's download size in bytes, from a sample of its chapters.

Fetches the chapter list (cheap, cached), then the content of up to sample_size chapters spread evenly across the title — not just the first ones, since early and late chapters can differ in length — and extrapolates the sample's average sizing.chapter_size() across the title's total chapter count. This trades exactness for cost: an exact total requires fetching every chapter's content, the same cost as download_title() itself, which defeats the purpose of checking "is this worth downloading" before paying that cost.

Sampled chapters go through the same disk cache as any other chapter fetch, so a later download_title() call doesn't refetch them.

Parameters:

Name Type Description Default
sample_size int

How many chapters to sample. Clamped to the number of chapters the title actually has (or that resolve to a usable translation, see below) if that's fewer.

DEFAULT_SAMPLE_SIZE
average_image_size int

Forwarded to sizing.chapter_size() for each sampled chapter.

DEFAULT_AVERAGE_IMAGE_SIZE
branch_id int | None

Same meaning as download_title(branch_id=...) — which translation to sample for chapters that have more than one. Mutually exclusive with translation_index.

None
translation_index int | None

Same meaning as download_title(translation_index=...). Mutually exclusive with branch_id.

None

Returns:

Type Description
int

The estimated size in bytes, or 0 if the title has no chapters, or none of

int

its chapters have a translation branch_id/translation_index resolves.

Raises:

Type Description
ValueError

If both branch_id and translation_index are given, or if sample_size is less than 1.

Note

Unlike download_title(), a chapter with more than one translation that branch_id/translation_index doesn't resolve is skipped when picking the sample rather than raising MultipleTitleTranslationsError — an estimate doesn't need a specific translation to be correct, only a chapter's worth of this title's content to measure.

Source code in src/ranobelib/sdk.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
async def estimate_title_size(
    self,
    *,
    sample_size: int = DEFAULT_SAMPLE_SIZE,
    average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE,
    branch_id: int | None = None,
    translation_index: int | None = None,
) -> int:
    """Estimate the whole title's download size in bytes, from a sample of its chapters.

    Fetches the chapter list (cheap, cached), then the content of up to ``sample_size``
    chapters spread evenly across the title — not just the first ones, since early and
    late chapters can differ in length — and extrapolates the sample's average
    ``sizing.chapter_size()`` across the title's total chapter count. This trades
    exactness for cost: an exact total requires fetching every chapter's content, the
    same cost as ``download_title()`` itself, which defeats the purpose of checking
    "is this worth downloading" *before* paying that cost.

    Sampled chapters go through the same disk cache as any other chapter fetch, so a
    later ``download_title()`` call doesn't refetch them.

    Args:
        sample_size: How many chapters to sample. Clamped to the number of chapters the
            title actually has (or that resolve to a usable translation, see below) if
            that's fewer.
        average_image_size: Forwarded to ``sizing.chapter_size()`` for each sampled
            chapter.
        branch_id: Same meaning as ``download_title(branch_id=...)`` — which translation
            to sample for chapters that have more than one. Mutually exclusive with
            ``translation_index``.
        translation_index: Same meaning as ``download_title(translation_index=...)``.
            Mutually exclusive with ``branch_id``.

    Returns:
        The estimated size in bytes, or ``0`` if the title has no chapters, or none of
        its chapters have a translation ``branch_id``/``translation_index`` resolves.

    Raises:
        ValueError: If both ``branch_id`` and ``translation_index`` are given, or if
            ``sample_size`` is less than 1.

    Note:
        Unlike ``download_title()``, a chapter with more than one translation that
        ``branch_id``/``translation_index`` doesn't resolve is skipped when picking the
        sample rather than raising ``MultipleTitleTranslationsError`` — an estimate
        doesn't need a specific translation to be correct, only *a* chapter's worth of
        this title's content to measure.
    """
    if branch_id is not None and translation_index is not None:
        raise ValueError("Pass at most one of branch_id or translation_index, not both.")
    if sample_size < 1:
        raise ValueError(f"sample_size must be at least 1, got {sample_size}.")

    raw_chapters = await self._get_chapters()
    if not raw_chapters:
        return 0

    resolvable: list[tuple[str, str, int | None]] = []
    for item in raw_chapters:
        volume_str: str = item["volume"]
        number: str = item["number"]
        branches = item.get("branches") or []
        if len(branches) <= 1:
            resolvable.append((volume_str, number, None))
            continue
        resolved, selected = _resolve_bulk_branch_id(
            branches, branch_id=branch_id, translation_index=translation_index
        )
        if resolved:
            resolvable.append((volume_str, number, selected))

    if not resolvable:
        return 0

    size = min(sample_size, len(resolvable))
    step = max(1, len(resolvable) // size)
    sample = [resolvable[index] for index in range(0, len(resolvable), step)][:size]

    total_sample_bytes = 0
    with self._reporter.progress(f"Sampling {self._slug_url}", len(sample)) as advance:
        for volume_str, number, selected_branch_id in sample:
            chapter = await self._fetch_chapter(volume_str, number, selected_branch_id)
            total_sample_bytes += chapter_size(chapter, average_image_size=average_image_size)
            advance()

    average_bytes = total_sample_bytes / len(sample)
    return round(average_bytes * len(raw_chapters))

export async

export(
    chapters: list[Chapter], *, fmt: str, path: str | Path
) -> Path

Export chapters to a file.

Parameters:

Name Type Description Default
chapters list[Chapter]

The chapters to include, in the order they should appear.

required
path str | Path

Where to write the exported file.

required
fmt str

Export format — a key of ranobelib.exporters.EXPORTERS (currently: "txt", "fb2", "epub").

required

Raises:

Type Description
ValueError

If fmt isn't a registered export format.

Source code in src/ranobelib/sdk.py
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
async def export(self, chapters: list[Chapter], *, fmt: str, path: str | Path) -> Path:
    """Export chapters to a file.

    Args:
        chapters: The chapters to include, in the order they should appear.
        path: Where to write the exported file.
        fmt: Export format — a key of ``ranobelib.exporters.EXPORTERS``
            (currently: ``"txt"``, ``"fb2"``, ``"epub"``).

    Raises:
        ValueError: If ``fmt`` isn't a registered export format.
    """
    exporter_cls = EXPORTERS.get(fmt)
    if exporter_cls is None:
        available = ", ".join(sorted(EXPORTERS)) or "(none registered)"
        raise ValueError(f"Unknown export format {fmt!r}. Available: {available}")
    title = await self.get_info()
    with self._reporter.progress(f"Exporting to {fmt}", len(chapters)) as advance:
        return await exporter_cls().export(title, chapters, Path(path), on_chapter=advance)

get_chapter async

get_chapter(
    volume: int,
    number: str,
    *,
    branch_id: int | None = None,
    refresh: bool = False,
) -> Chapter

Fetch a single chapter, including its content.

When a chapter has more than one team's translation and branch_id isn't given, this raises MultipleTranslationsError rather than guessing — see get_translations() and docs/api-notes.md for why the API's own default (when branch_id is omitted) isn't relied on. Doing this check costs an extra request to fetch the chapter list, only when branch_id isn't already given.

Parameters:

Name Type Description Default
volume int

The chapter's volume number.

required
number str

The chapter number, as returned by the API — may contain a decimal (e.g. "51.6").

required
branch_id int | None

Which translation to fetch, as returned by get_translations(). Only required when the chapter has more than one.

None
refresh bool

Bypass the disk cache and re-fetch from the API even if a cached response is on hand.

False

Raises:

Type Description
MultipleTranslationsError

If the chapter has more than one translation and branch_id wasn't given.

Source code in src/ranobelib/sdk.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
async def get_chapter(
    self,
    volume: int,
    number: str,
    *,
    branch_id: int | None = None,
    refresh: bool = False,
) -> Chapter:
    """Fetch a single chapter, including its content.

    When a chapter has more than one team's translation and ``branch_id`` isn't given,
    this raises ``MultipleTranslationsError`` rather than guessing — see
    ``get_translations()`` and docs/api-notes.md for why the API's own default (when
    ``branch_id`` is omitted) isn't relied on. Doing this check costs an extra request
    to fetch the chapter list, only when ``branch_id`` isn't already given.

    Args:
        volume: The chapter's volume number.
        number: The chapter number, as returned by the API — may contain a decimal
            (e.g. ``"51.6"``).
        branch_id: Which translation to fetch, as returned by ``get_translations()``.
            Only required when the chapter has more than one.
        refresh: Bypass the disk cache and re-fetch from the API even if a cached
            response is on hand.

    Raises:
        MultipleTranslationsError: If the chapter has more than one translation and
            ``branch_id`` wasn't given.
    """
    volume_str = str(volume)
    if branch_id is None:
        raw_chapters = await self._get_chapters(refresh=refresh)
        branch_id = self._resolve_branch_id(volume_str, number, raw_chapters)
    return await self._fetch_chapter(volume_str, number, branch_id, refresh=refresh)

get_chapters async

get_chapters(
    chapters: list[tuple[int, str]],
) -> list[Chapter]

Fetch several chapters, including their content.

Fetches the chapter list once, shared across all requested chapters (also used to detect chapters with more than one translation — see get_chapter), then each chapter individually and sequentially, in the order given.

Parameters:

Name Type Description Default
chapters list[tuple[int, str]]

A list of (volume, number) pairs identifying each chapter.

required

Raises:

Type Description
ChapterNotFoundError

If any requested chapter doesn't exist.

MultipleTranslationsError

If any requested chapter has more than one translation.

Source code in src/ranobelib/sdk.py
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
async def get_chapters(self, chapters: list[tuple[int, str]]) -> list[Chapter]:
    """Fetch several chapters, including their content.

    Fetches the chapter list once, shared across all requested chapters (also used to
    detect chapters with more than one translation — see ``get_chapter``), then each
    chapter individually and sequentially, in the order given.

    Args:
        chapters: A list of ``(volume, number)`` pairs identifying each chapter.

    Raises:
        ChapterNotFoundError: If any requested chapter doesn't exist.
        MultipleTranslationsError: If any requested chapter has more than one
            translation.
    """
    raw_chapters = await self._get_chapters()
    result = []
    for volume, number in chapters:
        volume_str = str(volume)
        branch_id = self._resolve_branch_id(volume_str, number, raw_chapters)
        result.append(await self._fetch_chapter(volume_str, number, branch_id))
    return result

get_info async

get_info(*, refresh: bool = False) -> Title

Fetch title metadata: names, cover, summary, genres, tags, authors, teams.

Parameters:

Name Type Description Default
refresh bool

Bypass the disk cache and re-fetch from the API even if a cached response is on hand.

False
Source code in src/ranobelib/sdk.py
111
112
113
114
115
116
117
118
119
async def get_info(self, *, refresh: bool = False) -> Title:
    """Fetch title metadata: names, cover, summary, genres, tags, authors, teams.

    Args:
        refresh: Bypass the disk cache and re-fetch from the API even if a cached
            response is on hand.
    """
    data = await self._get_title(refresh=refresh)
    return Title.model_validate(data)

get_table_of_contents async

get_table_of_contents(
    *, refresh: bool = False
) -> list[Volume]

Fetch the title's volumes and chapter names/numbers, without chapter content.

Parameters:

Name Type Description Default
refresh bool

Bypass the disk cache and re-fetch from the API even if a cached response is on hand — needed to pick up newly published chapters, since otherwise the cached chapter list would keep being reused.

False
Source code in src/ranobelib/sdk.py
121
122
123
124
125
126
127
128
129
130
131
async def get_table_of_contents(self, *, refresh: bool = False) -> list[Volume]:
    """Fetch the title's volumes and chapter names/numbers, without chapter content.

    Args:
        refresh: Bypass the disk cache and re-fetch from the API even if a cached
            response is on hand — needed to pick up newly published chapters, since
            otherwise the cached chapter list would keep being reused.
    """
    raw_chapters = await self._get_chapters(refresh=refresh)
    chapters = [Chapter.model_validate(item) for item in raw_chapters]
    return _group_into_volumes(chapters)

get_translations async

get_translations(
    volume: int, number: str
) -> list[ChapterBranch]

Fetch the available translations (branches) for a chapter.

Parameters:

Name Type Description Default
volume int

The chapter's volume number.

required
number str

The chapter number.

required

Raises:

Type Description
ChapterNotFoundError

If no chapter exists for this volume/number.

Source code in src/ranobelib/sdk.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
async def get_translations(self, volume: int, number: str) -> list[ChapterBranch]:
    """Fetch the available translations (branches) for a chapter.

    Args:
        volume: The chapter's volume number.
        number: The chapter number.

    Raises:
        ChapterNotFoundError: If no chapter exists for this volume/number.
    """
    volume_str = str(volume)
    raw_chapters = await self._get_chapters()
    item = _find_raw_chapter(raw_chapters, volume_str, number)
    if item is None:
        raise ChapterNotFoundError(self._slug_url, volume=volume_str, number=number)
    return [ChapterBranch.model_validate(branch) for branch in item.get("branches") or []]

get_volume async

get_volume(volume: int) -> Volume

Fetch a whole volume: all its chapters, each including content.

The API has no bulk "volume content" endpoint (see docs/api-notes.md), so this fetches the chapter list once to find which numbers belong to the volume, then fetches each of those chapters individually and sequentially.

Parameters:

Name Type Description Default
volume int

The volume number.

required

Raises:

Type Description
VolumeNotFoundError

If the title has no chapters for this volume.

MultipleTranslationsError

If any of the volume's chapters has more than one translation.

Source code in src/ranobelib/sdk.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
async def get_volume(self, volume: int) -> Volume:
    """Fetch a whole volume: all its chapters, each including content.

    The API has no bulk "volume content" endpoint (see docs/api-notes.md), so this
    fetches the chapter list once to find which numbers belong to the volume, then
    fetches each of those chapters individually and sequentially.

    Args:
        volume: The volume number.

    Raises:
        VolumeNotFoundError: If the title has no chapters for this volume.
        MultipleTranslationsError: If any of the volume's chapters has more than one
            translation.
    """
    raw_chapters = await self._get_chapters()
    return await self._build_volume(volume, raw_chapters)

get_volumes async

get_volumes(volumes: list[int]) -> list[Volume]

Fetch several whole volumes, each including chapter content.

Fetches the chapter list once, shared across all requested volumes, then each chapter individually and sequentially — same approach as get_volume, applied to more than one volume without re-fetching the chapter list per volume.

Parameters:

Name Type Description Default
volumes list[int]

The volume numbers to fetch.

required

Raises:

Type Description
VolumeNotFoundError

If the title has no chapters for one of the volumes.

MultipleTranslationsError

If any of the volumes' chapters has more than one translation.

Source code in src/ranobelib/sdk.py
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
async def get_volumes(self, volumes: list[int]) -> list[Volume]:
    """Fetch several whole volumes, each including chapter content.

    Fetches the chapter list once, shared across all requested volumes, then each
    chapter individually and sequentially — same approach as ``get_volume``, applied
    to more than one volume without re-fetching the chapter list per volume.

    Args:
        volumes: The volume numbers to fetch.

    Raises:
        VolumeNotFoundError: If the title has no chapters for one of the volumes.
        MultipleTranslationsError: If any of the volumes' chapters has more than one
            translation.
    """
    raw_chapters = await self._get_chapters()
    return [await self._build_volume(volume, raw_chapters) for volume in volumes]

ranobelib.Catalog

Catalog(
    *,
    cache_dir: str | Path | None = None,
    cache_ttl: float | None = None,
)

Entry point for listing/searching the ranobelib.me catalog.

Example
async with Catalog() as catalog:
    page = await catalog.list_titles(query="dxd", genres=[34], status=1)
    for title in page.items:
        print(title.name)

Initialize the catalog client.

Parameters:

Name Type Description Default
cache_dir str | Path | None

Where to cache raw API responses on disk, one entry per distinct set of listing parameters (page/filters/sort) — same disk cache as RanobeLib uses (cache.py), just keyed by listing parameters instead of a title's slug. Defaults to .ranobelib_cache in the current working directory.

None
cache_ttl float | None

Seconds after which a cached page is treated as stale and re-fetched. None (the default) means cached pages never expire on their own — see refresh=True on list_titles() to force one anyway.

None
Source code in src/ranobelib/catalog.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
def __init__(
    self,
    *,
    cache_dir: str | Path | None = None,
    cache_ttl: float | None = None,
) -> None:
    """Initialize the catalog client.

    Args:
        cache_dir: Where to cache raw API responses on disk, one entry per distinct set
            of listing parameters (page/filters/sort) — same disk cache as ``RanobeLib``
            uses (``cache.py``), just keyed by listing parameters instead of a title's
            slug. Defaults to ``.ranobelib_cache`` in the current working directory.
        cache_ttl: Seconds after which a cached page is treated as stale and re-fetched.
            ``None`` (the default) means cached pages never expire on their own — see
            ``refresh=True`` on ``list_titles()`` to force one anyway.
    """
    self._client = ApiClient()
    self._cache = DiskCache(
        cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR, ttl=cache_ttl
    )

aclose async

aclose() -> None

Close the underlying HTTP client.

Source code in src/ranobelib/catalog.py
71
72
73
async def aclose(self) -> None:
    """Close the underlying HTTP client."""
    await self._client.aclose()

list_countries async

list_countries(*, refresh: bool = False) -> list[Country]

Fetch the full list of catalog countries (id → name), for use as filter options with list_titles(countries=...).

Mirrors list_genres() exactly, down to the network-wide, not-site-scoped shape of the underlying endpoint (GET /api/constants?fields[]=types — the API's own name for what this SDK calls "country" is "type", see docs/api-notes.md and Country's docstring). This filters that down to entries tagged for ranobelib.me before returning, same as list_genres() does for genres.

Parameters:

Name Type Description Default
refresh bool

Bypass the disk cache and re-fetch from the API even if the country list was already cached.

False

Returns:

Type Description
list[Country]

Every Country (id, name) that applies to ranobelib.me.

Source code in src/ranobelib/catalog.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
async def list_countries(self, *, refresh: bool = False) -> list[Country]:
    """Fetch the full list of catalog countries (id → name), for use as filter options
    with ``list_titles(countries=...)``.

    Mirrors ``list_genres()`` exactly, down to the network-wide, not-site-scoped shape
    of the underlying endpoint (``GET /api/constants?fields[]=types`` — the API's own
    name for what this SDK calls "country" is "type", see docs/api-notes.md and
    ``Country``'s docstring). This filters that down to entries tagged for ranobelib.me
    before returning, same as ``list_genres()`` does for genres.

    Args:
        refresh: Bypass the disk cache and re-fetch from the API even if the country
            list was already cached.

    Returns:
        Every ``Country`` (id, name) that applies to ranobelib.me.
    """
    key = "catalog:countries"
    if not refresh:
        cached = self._cache.get(key)
        if cached is not None:
            return _build_countries(cached)

    data = await self._client.list_countries()
    self._cache.set(key, data)
    return _build_countries(data)

list_genres async

list_genres(*, refresh: bool = False) -> list[Genre]

Fetch the full list of catalog genres (id → name), for use as filter options with list_titles(genres=[...]).

The underlying endpoint (GET /api/constants?fields[]=genres) has no site-scoping parameter — it returns the full genre list shared across the whole lib.social network in one shot, each tagged with the site ids it applies to (see docs/api-notes.md). This filters that down to genres tagged for ranobelib.me before returning, so callers don't get filter options (e.g. "Детское") that could never match any ranobelib title.

Parameters:

Name Type Description Default
refresh bool

Bypass the disk cache and re-fetch from the API even if the genre list was already cached.

False

Returns:

Type Description
list[Genre]

Every Genre (id, name, adult flag) that applies to ranobelib.me.

Source code in src/ranobelib/catalog.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
async def list_genres(self, *, refresh: bool = False) -> list[Genre]:
    """Fetch the full list of catalog genres (id → name), for use as filter options
    with ``list_titles(genres=[...])``.

    The underlying endpoint (``GET /api/constants?fields[]=genres``) has no site-scoping
    parameter — it returns the full genre list shared across the whole lib.social
    network in one shot, each tagged with the site ids it applies to (see
    docs/api-notes.md). This filters that down to genres tagged for ranobelib.me before
    returning, so callers don't get filter options (e.g. "Детское") that could never
    match any ranobelib title.

    Args:
        refresh: Bypass the disk cache and re-fetch from the API even if the genre list
            was already cached.

    Returns:
        Every ``Genre`` (id, name, adult flag) that applies to ranobelib.me.
    """
    key = "catalog:genres"
    if not refresh:
        cached = self._cache.get(key)
        if cached is not None:
            return _build_genres(cached)

    data = await self._client.list_genres()
    self._cache.set(key, data)
    return _build_genres(data)

list_titles async

list_titles(
    *,
    page: int = 1,
    per_page: int = 30,
    query: str | None = None,
    genres: list[int] | None = None,
    tags: list[int] | None = None,
    status: int | None = None,
    countries: list[int] | None = None,
    sort: str = DEFAULT_SORT,
    refresh: bool = False,
) -> CatalogPage

Fetch one page of catalog listing/search results.

Parameters:

Name Type Description Default
page int

1-based page number.

1
per_page int

How many titles per page. Must be between MIN_PER_PAGE (10) and MAX_PER_PAGE (60) — the API's own limits (see docs/api-notes.md); anything outside that range raises here instead of a confusing 422 from the API.

30
query str | None

Free-text search term, matched against name/rus_name/eng_name. None (the default) or an empty string lists without searching.

None
genres list[int] | None

Genre ids to filter by. A title must have all of them, not just one (AND, not OR — see docs/api-notes.md). Not validated against list_genres() before sending — an unrecognized id just matches nothing rather than erroring.

None
tags list[int] | None

Tag.ids (from Title.tags) to filter by. Same AND semantics as genres — a title must have all of them, not just any one (confirmed against the live API, see docs/api-notes.md; not the OR some might expect from tags being more numerous/specific than genres). Also not validated before sending, same as genres.

None
status int | None

A single Title.status.id to filter by (e.g. ongoing vs. completed).

None
countries list[int] | None

Country.ids to filter by. A title matches if its own country is any of these (OR, not AND like genres/tags — a title only has one country of origin, so requiring all of them could never match past the first, see docs/api-notes.md). None or an empty list applies no country filter. Ids come from list_countries(). Sent on the wire as the API's types[] parameter, not a country/countries[] one — see docs/api-notes.md for why (Country mirrors the API's own "type" concept, which isn't strictly limited to literal countries).

None
sort str

Sort order. Despite the name, this is sent as the API's sort_by parameter — an actual sort parameter exists but is silently ignored by the API (see docs/api-notes.md). Known accepted values: "name", "created_at", "views", "chap_count", "last_chapter_at" (the default), "rate_avg", "random" — this list isn't guaranteed exhaustive.

DEFAULT_SORT
refresh bool

Bypass the disk cache and re-fetch from the API even if this exact combination of parameters was already cached.

False

Returns:

Type Description
CatalogPage

A CatalogPage: the matching titles (as Title models) plus whether a next

CatalogPage

page exists.

Raises:

Type Description
ValueError

If page is less than 1, or per_page is outside MIN_PER_PAGE..MAX_PER_PAGE.

Source code in src/ranobelib/catalog.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
async def list_titles(
    self,
    *,
    page: int = 1,
    per_page: int = 30,
    query: str | None = None,
    genres: list[int] | None = None,
    tags: list[int] | None = None,
    status: int | None = None,
    countries: list[int] | None = None,
    sort: str = DEFAULT_SORT,
    refresh: bool = False,
) -> CatalogPage:
    """Fetch one page of catalog listing/search results.

    Args:
        page: 1-based page number.
        per_page: How many titles per page. Must be between ``MIN_PER_PAGE`` (10) and
            ``MAX_PER_PAGE`` (60) — the API's own limits (see docs/api-notes.md);
            anything outside that range raises here instead of a confusing 422 from
            the API.
        query: Free-text search term, matched against name/rus_name/eng_name. ``None``
            (the default) or an empty string lists without searching.
        genres: Genre ids to filter by. A title must have *all* of them, not just one
            (AND, not OR — see docs/api-notes.md). Not validated against ``list_genres()``
            before sending — an unrecognized id just matches nothing rather than erroring.
        tags: ``Tag.id``s (from ``Title.tags``) to filter by. Same AND semantics as
            ``genres`` — a title must have *all* of them, not just any one (confirmed
            against the live API, see docs/api-notes.md; not the OR some might expect
            from tags being more numerous/specific than genres). Also not validated
            before sending, same as ``genres``.
        status: A single ``Title.status.id`` to filter by (e.g. ongoing vs. completed).
        countries: ``Country.id``s to filter by. A title matches if its own country is
            *any* of these (OR, not AND like ``genres``/``tags`` — a title only has one
            country of origin, so requiring all of them could never match past the first,
            see docs/api-notes.md). ``None`` or an empty list applies no country filter.
            Ids come from ``list_countries()``. Sent on the wire as the API's ``types[]``
            parameter, not a ``country``/``countries[]`` one — see docs/api-notes.md for
            why (``Country`` mirrors the API's own "type" concept, which isn't strictly
            limited to literal countries).
        sort: Sort order. Despite the name, this is sent as the API's ``sort_by``
            parameter — an actual ``sort`` parameter exists but is silently ignored by
            the API (see docs/api-notes.md). Known accepted values: ``"name"``,
            ``"created_at"``, ``"views"``, ``"chap_count"``, ``"last_chapter_at"``
            (the default), ``"rate_avg"``, ``"random"`` — this list isn't guaranteed
            exhaustive.
        refresh: Bypass the disk cache and re-fetch from the API even if this exact
            combination of parameters was already cached.

    Returns:
        A ``CatalogPage``: the matching titles (as ``Title`` models) plus whether a next
        page exists.

    Raises:
        ValueError: If ``page`` is less than 1, or ``per_page`` is outside
            ``MIN_PER_PAGE..MAX_PER_PAGE``.
    """
    if page < 1:
        raise ValueError(f"page must be at least 1, got {page}.")
    if not MIN_PER_PAGE <= per_page <= MAX_PER_PAGE:
        raise ValueError(
            f"per_page must be between {MIN_PER_PAGE} and {MAX_PER_PAGE}, got {per_page}."
        )

    key = _cache_key(
        page=page,
        per_page=per_page,
        query=query,
        genres=genres,
        tags=tags,
        status=status,
        countries=countries,
        sort=sort,
    )
    if not refresh:
        cached = self._cache.get(key)
        if cached is not None:
            return _build_page(cached)

    data = await self._client.list_titles(
        page=page,
        per_page=per_page,
        query=query,
        genres=genres,
        tags=tags,
        status=status,
        countries=countries,
        sort=sort,
    )
    self._cache.set(key, data)
    return _build_page(data)

Models

ranobelib.CatalogPage

Bases: BaseModel

One page of catalog listing/search results, as returned by Catalog.list_titles().

items reuses Title as-is — a catalog list item has every field Title requires, the ones it doesn't send (genres, summary, chapter_count, ...) just come back at their defaults, same as any other partially-populated Title (see docs/api-notes.md).

ranobelib.Title

Bases: BaseModel

Metadata for a single title (novel).

ranobelib.Chapter

Bases: BaseModel

A chapter: volume, number, name, available translations, and optionally its content.

index/item_number/branches_count/branches come from the chapter-list endpoint (see RanobeLib.get_table_of_contents()) and are None/empty when a Chapter instead comes from fetching a single chapter's content, which has a different response shape (see docs/api-notes.md). content is the reverse: only populated by the single-chapter endpoint.

ranobelib.Volume

Bases: BaseModel

A volume: its number and the chapters it contains, in title order.

ranobelib.ChapterBranch

Bases: BaseModel

A single team's (or solo uploader's) translation of a chapter.

A chapter has more than one branch when several teams have translated it independently; see Chapter.branches_count.

ranobelib.Genre

Bases: BaseModel

A genre tag (e.g. Fantasy, Romance).

ranobelib.Country

Bases: BaseModel

A title's country/region of origin.

Despite the name (matching issue #48's requested public shape), this maps onto what the raw API itself calls "type" (Title's raw type field; the catalog filter's types[] parameter; GET /api/constants?fields[]=types) — not a country/countries[]/ fields[]=countries concept, which turned out to be something unrelated (see docs/api-notes.md). For ranobelib.me specifically, the values are three literal countries (Japan, Korea, China) plus three additional non-national origin categories the site groups the same way: original English-language work, original (non-translated) web novel, and fanfiction — surfaced as-is, not filtered down to "real" countries only.

Exceptions

ranobelib.RanobeLibError

Bases: Exception

Base class for all errors raised by the SDK.

ranobelib.TitleNotFoundError

TitleNotFoundError(slug_url: str)

Bases: RanobeLibError

Raised when a title cannot be found on ranobelib.me.

Attributes:

Name Type Description
slug_url

The title's {id}--{slug} identifier that couldn't be found.

Source code in src/ranobelib/exceptions.py
22
23
24
def __init__(self, slug_url: str) -> None:
    self.slug_url = slug_url
    super().__init__(f"Title not found: {slug_url!r}")

ranobelib.ChapterNotFoundError

ChapterNotFoundError(
    slug_url: str, *, volume: str, number: str
)

Bases: RanobeLibError

Raised when a chapter cannot be found for a given volume/number.

Attributes:

Name Type Description
slug_url

The title's {id}--{slug} identifier.

volume

The volume number that was requested.

number

The chapter number that was requested.

Source code in src/ranobelib/exceptions.py
36
37
38
39
40
def __init__(self, slug_url: str, *, volume: str, number: str) -> None:
    self.slug_url = slug_url
    self.volume = volume
    self.number = number
    super().__init__(f"Chapter not found: {slug_url!r} volume={volume!r} number={number!r}")

ranobelib.VolumeNotFoundError

VolumeNotFoundError(slug_url: str, *, volume: str)

Bases: RanobeLibError

Raised when a title has no chapters for a given volume number.

Attributes:

Name Type Description
slug_url

The title's {id}--{slug} identifier.

volume

The volume number that was requested.

Source code in src/ranobelib/exceptions.py
51
52
53
54
def __init__(self, slug_url: str, *, volume: str) -> None:
    self.slug_url = slug_url
    self.volume = volume
    super().__init__(f"Volume not found: {slug_url!r} volume={volume!r}")

ranobelib.MultipleTranslationsError

MultipleTranslationsError(
    slug_url: str,
    *,
    volume: str,
    number: str,
    branches: list[ChapterBranch],
)

Bases: RanobeLibError

Raised when a chapter has more than one team's translation and none was selected.

The SDK does not guess a default: the API's own default when branch_id is omitted is not simply the first branch listed, and the actual rule it uses isn't documented (see docs/api-notes.md) — returning it silently would be unpredictable. Call RanobeLib.get_translations() to list the available branches, then pass one's branch_id explicitly.

Attributes:

Name Type Description
slug_url

The title's {id}--{slug} identifier.

volume

The chapter's volume number.

number

The chapter number.

branches

The chapter's available translations, as returned by RanobeLib.get_translations().

Source code in src/ranobelib/exceptions.py
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def __init__(
    self, slug_url: str, *, volume: str, number: str, branches: list[ChapterBranch]
) -> None:
    self.slug_url = slug_url
    self.volume = volume
    self.number = number
    self.branches = branches
    options = ", ".join(
        f"branch_id={branch.branch_id} ({_describe_branch(branch)})" for branch in branches
    )
    super().__init__(
        f"Multiple translations for {slug_url!r} volume={volume!r} number={number!r}: "
        f"{options}. Pass branch_id to select one (see RanobeLib.get_translations())."
    )

ranobelib.AuthRequiredError

AuthRequiredError(url: str)

Bases: RanobeLibError

Raised when the requested content requires authorization (paid or early access).

Attributes:

Name Type Description
url

The request URL that returned 403.

Source code in src/ranobelib/exceptions.py
151
152
153
def __init__(self, url: str) -> None:
    self.url = url
    super().__init__(f"Authorization required to access: {url}")

ranobelib.RateLimitError

RateLimitError(retry_after: float | None = None)

Bases: RanobeLibError

Raised when the API responds with 429 Too Many Requests.

Attributes:

Name Type Description
retry_after

The response's Retry-After value, in seconds, if it sent one.

Source code in src/ranobelib/exceptions.py
163
164
165
166
def __init__(self, retry_after: float | None = None) -> None:
    self.retry_after = retry_after
    suffix = f" (retry after {retry_after}s)" if retry_after is not None else ""
    super().__init__(f"Rate limited by the API{suffix}")

ranobelib.DownloadTitleInterruptedError

DownloadTitleInterruptedError(
    slug_url: str,
    *,
    volumes: list[Volume],
    completed: int,
    total: int,
)

Bases: RanobeLibError

Raised by download_title() when a chapter fetch fails unrecoverably partway through.

download_title() fetches hundreds-to-thousands of chapters sequentially for a long title, which can outlast even the extra, bulk-specific rate-limit retry budget described in download_title()'s max_rate_limit_retries — or hit some other unrecoverable error (e.g. AuthRequiredError on a chapter that turns out to need a paywall/auth token). Rather than letting that error propagate on its own and silently discard every chapter already fetched, it's wrapped in this exception instead, carrying what was already downloaded — so a caller can keep that partial result, or simply call download_title() again: the SDK's disk cache means already-fetched chapters aren't re-requested, so a retry resumes close to where this one stopped rather than restarting the whole title. See docs/api-notes.md's "Rate limiting и retry" section and issue #41.

Attributes:

Name Type Description
slug_url

The title's {id}--{slug} identifier.

volumes

Chapters fetched before the failure, grouped into volumes the same way a successful download_title() call would return them.

completed

How many chapters were fetched before the failure.

total

How many chapters download_title() had planned to fetch in total.

Source code in src/ranobelib/exceptions.py
191
192
193
194
195
196
197
198
199
def __init__(self, slug_url: str, *, volumes: list[Volume], completed: int, total: int) -> None:
    self.slug_url = slug_url
    self.volumes = volumes
    self.completed = completed
    self.total = total
    super().__init__(
        f"download_title() for {slug_url!r} was interrupted after {completed}/{total} "
        "chapter(s) fetched — see __cause__ for why, .volumes for what was already fetched."
    )

Sizing

ranobelib.chapter_size

chapter_size(
    chapter: Chapter,
    *,
    average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE,
) -> int

Estimate a fetched chapter's size in bytes: exact text plus assumed-average images.

Parameters:

Name Type Description Default
chapter Chapter

A chapter with content already fetched (e.g. via get_chapter()).

required
average_image_size int

Assumed bytes per <img> referenced in the chapter's content.

DEFAULT_AVERAGE_IMAGE_SIZE

Raises:

Type Description
ValueError

If chapter.content is None (not yet fetched — see Chapter's docstring for when that happens).

Source code in src/ranobelib/sizing.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def chapter_size(chapter: Chapter, *, average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE) -> int:
    """Estimate a fetched chapter's size in bytes: exact text plus assumed-average images.

    Args:
        chapter: A chapter with content already fetched (e.g. via ``get_chapter()``).
        average_image_size: Assumed bytes per ``<img>`` referenced in the chapter's content.

    Raises:
        ValueError: If ``chapter.content`` is ``None`` (not yet fetched — see
            ``Chapter``'s docstring for when that happens).
    """
    if chapter.content is None:
        raise ValueError(
            f"Chapter {chapter.volume}/{chapter.number} has no content to size — "
            "fetch it first, e.g. via get_chapter()."
        )
    text_bytes = len(chapter.content.encode("utf-8"))
    image_count = len(extract_image_urls(chapter.content))
    return text_bytes + image_count * average_image_size

ranobelib.volume_size

volume_size(
    volume: Volume,
    *,
    average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE,
) -> int

Estimate a fetched volume's size in bytes: the sum of its chapters' chapter_size().

Parameters:

Name Type Description Default
volume Volume

A volume whose chapters have content already fetched (e.g. via get_volume()).

required
average_image_size int

Forwarded to chapter_size() for each chapter.

DEFAULT_AVERAGE_IMAGE_SIZE

Raises:

Type Description
ValueError

If any chapter in volume.chapters has no content — see chapter_size().

Source code in src/ranobelib/sizing.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
def volume_size(volume: Volume, *, average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE) -> int:
    """Estimate a fetched volume's size in bytes: the sum of its chapters' ``chapter_size()``.

    Args:
        volume: A volume whose chapters have content already fetched (e.g. via
            ``get_volume()``).
        average_image_size: Forwarded to ``chapter_size()`` for each chapter.

    Raises:
        ValueError: If any chapter in ``volume.chapters`` has no content — see
            ``chapter_size()``.
    """
    return sum(
        chapter_size(chapter, average_image_size=average_image_size) for chapter in volume.chapters
    )

Exporters

Not re-exported from the top-level ranobelib package — import from ranobelib.exporters. Adding a new export format is a new module in ranobelib/exporters/ implementing Exporter and decorated with @register; no changes needed elsewhere.

ranobelib.exporters.Exporter

Bases: Protocol

Renders a title's chapters to a single file in some format.

format class-attribute

format: str

The registry key this exporter is selected by, e.g. "txt".

export async

export(
    title: Title,
    chapters: list[Chapter],
    output_path: Path,
    *,
    on_chapter: Callable[[], None] | None = None,
) -> Path

Write chapters (in the given order) to output_path.

async since embedding illustrations (epub, pdf) requires downloading them — the SDK is async-only throughout (see CLAUDE.md), so this can't drop to a sync HTTP call. txt/fb2 do no I/O and just don't await anything in their bodies.

Parameters:

Name Type Description Default
title Title

The chapters' parent title, for metadata (name, authors, ...).

required
chapters list[Chapter]

The chapters to include, in the order they should appear.

required
output_path Path

Where to write the exported file.

required
on_chapter Callable[[], None] | None

Called once per chapter processed, if given — drives RanobeLib.export()'s progress bar (see CLAUDE.md's roadmap step 23). For epub/pdf this covers the per-chapter embedding step, not the earlier illustration-download step, which isn't itself progress-reported.

None

Returns:

Type Description
Path

output_path, once the file has been written.

Source code in src/ranobelib/exporters/__init__.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
async def export(
    self,
    title: Title,
    chapters: list[Chapter],
    output_path: Path,
    *,
    on_chapter: Callable[[], None] | None = None,
) -> Path:
    """Write ``chapters`` (in the given order) to ``output_path``.

    ``async`` since embedding illustrations (epub, pdf) requires downloading them —
    the SDK is async-only throughout (see CLAUDE.md), so this can't drop to a sync
    HTTP call. txt/fb2 do no I/O and just don't ``await`` anything in their bodies.

    Args:
        title: The chapters' parent title, for metadata (name, authors, ...).
        chapters: The chapters to include, in the order they should appear.
        output_path: Where to write the exported file.
        on_chapter: Called once per chapter processed, if given — drives
            ``RanobeLib.export()``'s progress bar (see CLAUDE.md's roadmap step 23).
            For epub/pdf this covers the per-chapter embedding step, not the earlier
            illustration-download step, which isn't itself progress-reported.

    Returns:
        ``output_path``, once the file has been written.
    """
    ...

ranobelib.exporters.register

register(exporter: ExporterT) -> ExporterT

Class decorator: register exporter under its format key.

Source code in src/ranobelib/exporters/__init__.py
61
62
63
64
def register(exporter: ExporterT) -> ExporterT:
    """Class decorator: register ``exporter`` under its ``format`` key."""
    EXPORTERS[exporter.format] = exporter
    return exporter

Registered exporters, keyed by Exporter.format.