ranobelib-python-sdk 0.3.0__tar.gz → 0.4.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.
Files changed (21) hide show
  1. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/PKG-INFO +2 -1
  2. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/pyproject.toml +2 -1
  3. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/pyproject.toml.orig +2 -1
  4. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/__init__.py +3 -0
  5. ranobelib_python_sdk-0.4.0/src/ranobelib/console.py +72 -0
  6. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/__init__.py +13 -1
  7. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/epub.py +13 -1
  8. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/fb2.py +14 -3
  9. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/pdf.py +18 -4
  10. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/txt.py +12 -1
  11. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/sdk.py +111 -5
  12. ranobelib_python_sdk-0.4.0/src/ranobelib/sizing.py +62 -0
  13. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/README.md +0 -0
  14. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/cache.py +0 -0
  15. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/client.py +0 -0
  16. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exceptions.py +0 -0
  17. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/_illustrations.py +0 -0
  18. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/exporters/_shared.py +0 -0
  19. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/models.py +0 -0
  20. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/numbering.py +0 -0
  21. {ranobelib_python_sdk-0.3.0 → ranobelib_python_sdk-0.4.0}/src/ranobelib/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ranobelib-python-sdk
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf
5
5
  Keywords: ranobelib,ranobe,novel,webnovel,epub,fb2,sdk
6
6
  Author: LLIJIYAPNIK
@@ -18,6 +18,7 @@ Classifier: Typing :: Typed
18
18
  Requires-Dist: ebooklib>=0.20
19
19
  Requires-Dist: httpx>=0.27
20
20
  Requires-Dist: pydantic>=2.7
21
+ Requires-Dist: rich>=15.0.0
21
22
  Requires-Dist: weasyprint>=69.0
22
23
  Requires-Python: >=3.11
23
24
  Project-URL: Homepage, https://github.com/LLIJIYAPNIK/ranobelib-python-sdk
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -29,6 +29,7 @@ dependencies = [
29
29
  "ebooklib>=0.20",
30
30
  "httpx>=0.27",
31
31
  "pydantic>=2.7",
32
+ "rich>=15.0.0",
32
33
  "weasyprint>=69.0",
33
34
  ]
34
35
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ranobelib-python-sdk"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Async Python SDK for ranobelib.me: title metadata, chapter/volume downloads, and export to epub/fb2/txt/pdf"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,6 +24,7 @@ dependencies = [
24
24
  "ebooklib>=0.20",
25
25
  "httpx>=0.27",
26
26
  "pydantic>=2.7",
27
+ "rich>=15.0.0",
27
28
  "weasyprint>=69.0",
28
29
  ]
29
30
 
@@ -13,6 +13,7 @@ from ranobelib.exceptions import (
13
13
  )
14
14
  from ranobelib.models import Chapter, ChapterBranch, Title, Volume
15
15
  from ranobelib.sdk import RanobeLib
16
+ from ranobelib.sizing import chapter_size, volume_size
16
17
 
17
18
  __all__ = [
18
19
  "AmbiguousChapter",
@@ -29,4 +30,6 @@ __all__ = [
29
30
  "TitleNotFoundError",
30
31
  "Volume",
31
32
  "VolumeNotFoundError",
33
+ "chapter_size",
34
+ "volume_size",
32
35
  ]
@@ -0,0 +1,72 @@
1
+ """Console output for RanobeLib: progress bars and full-mode operation logging.
2
+
3
+ Behind ``RanobeLib``'s ``verbosity`` parameter (``False`` by default, ``"progress_only"``,
4
+ or ``"full"`` — see CLAUDE.md's roadmap entry for why these three, and why this prints
5
+ directly via ``rich`` rather than going through ``logging``). ``Reporter`` is the single
6
+ object that knows which of the three modes is active; every call site (``sdk.py``, the
7
+ exporters) just calls ``log()``/``progress()`` unconditionally and lets ``Reporter`` decide
8
+ whether that produces any output.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable, Iterator
14
+ from contextlib import contextmanager
15
+ from typing import Literal
16
+
17
+ from rich.console import Console
18
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
19
+
20
+ Verbosity = Literal["full", "progress_only"] | Literal[False]
21
+ """``False``: silent. ``"progress_only"``: progress bars only. ``"full"``: progress bars
22
+ plus a line logged for every title/chapters-list/chapter-content fetch (cache hit or API)."""
23
+
24
+
25
+ class Reporter:
26
+ """Console output gated by a ``Verbosity`` level, backed by a single ``rich.Console``."""
27
+
28
+ def __init__(self, verbosity: Verbosity, *, console: Console | None = None) -> None:
29
+ """Initialize the reporter.
30
+
31
+ Args:
32
+ verbosity: The active output level.
33
+ console: Custom ``rich.console.Console`` to print through, e.g. one writing to
34
+ an in-memory stream for tests. Defaults to a new ``Console()`` (stdout) —
35
+ only constructed at all when ``verbosity`` isn't ``False``, so a silent
36
+ ``RanobeLib`` never touches the terminal.
37
+ """
38
+ self._verbosity = verbosity
39
+ if not verbosity:
40
+ self._console = None
41
+ else:
42
+ self._console = console if console is not None else Console()
43
+
44
+ def log(self, message: str) -> None:
45
+ """Print ``message``, only when verbosity is ``"full"``.
46
+
47
+ ``markup=False``: messages embed literal ``[cache]``/``[api]`` prefixes (see
48
+ ``sdk.py``'s choke points), which rich's default markup parsing would otherwise try
49
+ to interpret as style tags.
50
+ """
51
+ if self._verbosity == "full" and self._console is not None:
52
+ self._console.log(message, markup=False)
53
+
54
+ @contextmanager
55
+ def progress(self, description: str, total: int) -> Iterator[Callable[[], None]]:
56
+ """A progress bar shown for ``"progress_only"``/``"full"``, a no-op otherwise.
57
+
58
+ Yields a zero-argument callable: call it once per completed step.
59
+ """
60
+ if not self._verbosity or total == 0:
61
+ yield lambda: None
62
+ return
63
+
64
+ with Progress(
65
+ TextColumn("[progress.description]{task.description}"),
66
+ BarColumn(),
67
+ MofNCompleteColumn(),
68
+ TimeElapsedColumn(),
69
+ console=self._console,
70
+ ) as progress:
71
+ task_id = progress.add_task(description, total=total)
72
+ yield lambda: progress.advance(task_id)
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import importlib
11
11
  import pkgutil
12
+ from collections.abc import Callable
12
13
  from pathlib import Path
13
14
  from typing import ClassVar, Protocol, TypeVar, runtime_checkable
14
15
 
@@ -22,7 +23,14 @@ class Exporter(Protocol):
22
23
  format: ClassVar[str]
23
24
  """The registry key this exporter is selected by, e.g. ``"txt"``."""
24
25
 
25
- async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
26
+ async def export(
27
+ self,
28
+ title: Title,
29
+ chapters: list[Chapter],
30
+ output_path: Path,
31
+ *,
32
+ on_chapter: Callable[[], None] | None = None,
33
+ ) -> Path:
26
34
  """Write ``chapters`` (in the given order) to ``output_path``.
27
35
 
28
36
  ``async`` since embedding illustrations (epub, pdf) requires downloading them —
@@ -33,6 +41,10 @@ class Exporter(Protocol):
33
41
  title: The chapters' parent title, for metadata (name, authors, ...).
34
42
  chapters: The chapters to include, in the order they should appear.
35
43
  output_path: Where to write the exported file.
44
+ on_chapter: Called once per chapter processed, if given — drives
45
+ ``RanobeLib.export()``'s progress bar (see CLAUDE.md's roadmap step 23).
46
+ For epub/pdf this covers the per-chapter embedding step, not the earlier
47
+ illustration-download step, which isn't itself progress-reported.
36
48
 
37
49
  Returns:
38
50
  ``output_path``, once the file has been written.
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import html
6
+ from collections.abc import Callable
6
7
  from pathlib import Path
7
8
  from typing import ClassVar
8
9
 
@@ -39,7 +40,14 @@ class EpubExporter:
39
40
  """
40
41
  self._transport = transport
41
42
 
42
- async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
43
+ async def export(
44
+ self,
45
+ title: Title,
46
+ chapters: list[Chapter],
47
+ output_path: Path,
48
+ *,
49
+ on_chapter: Callable[[], None] | None = None,
50
+ ) -> Path:
43
51
  """Write ``chapters`` to ``output_path`` as an EPUB file.
44
52
 
45
53
  Downloads the title's cover and every image referenced in the chapters' content and
@@ -50,6 +58,8 @@ class EpubExporter:
50
58
  title: The chapters' parent title; supplies metadata, cover, and authors.
51
59
  chapters: The chapters to include, in the order they should appear.
52
60
  output_path: Where to write the ``.epub`` file.
61
+ on_chapter: Called once per chapter embedded, if given — after illustrations
62
+ have already been downloaded, see the ``Exporter`` protocol's docstring.
53
63
 
54
64
  Returns:
55
65
  ``output_path``.
@@ -107,6 +117,8 @@ class EpubExporter:
107
117
  book.add_item(doc)
108
118
  spine.append(doc)
109
119
  toc.append(doc)
120
+ if on_chapter is not None:
121
+ on_chapter()
110
122
 
111
123
  book.toc = toc
112
124
  book.add_item(epub.EpubNcx())
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import datetime
6
6
  import xml.etree.ElementTree as ET
7
+ from collections.abc import Callable
7
8
  from html.parser import HTMLParser
8
9
  from pathlib import Path
9
10
  from typing import ClassVar
@@ -126,7 +127,7 @@ def _build_description(title: Title) -> ET.Element:
126
127
  return description
127
128
 
128
129
 
129
- def _build_body(chapters: list[Chapter]) -> ET.Element:
130
+ def _build_body(chapters: list[Chapter], on_chapter: Callable[[], None] | None) -> ET.Element:
130
131
  body = ET.Element(_tag("body"))
131
132
  for chapter in chapters:
132
133
  section = ET.SubElement(body, _tag("section"))
@@ -134,6 +135,8 @@ def _build_body(chapters: list[Chapter]) -> ET.Element:
134
135
  ET.SubElement(section_title, _tag("p")).text = chapter_heading(chapter)
135
136
  for paragraph in html_to_fb2_paragraphs(chapter.content or ""):
136
137
  section.append(paragraph)
138
+ if on_chapter is not None:
139
+ on_chapter()
137
140
  return body
138
141
 
139
142
 
@@ -143,20 +146,28 @@ class Fb2Exporter:
143
146
 
144
147
  format: ClassVar[str] = "fb2"
145
148
 
146
- async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
149
+ async def export(
150
+ self,
151
+ title: Title,
152
+ chapters: list[Chapter],
153
+ output_path: Path,
154
+ *,
155
+ on_chapter: Callable[[], None] | None = None,
156
+ ) -> Path:
147
157
  """Write ``chapters`` to ``output_path`` as FB2 XML.
148
158
 
149
159
  Args:
150
160
  title: The chapters' parent title; supplies ``description`` metadata.
151
161
  chapters: The chapters to include, in the order they should appear.
152
162
  output_path: Where to write the ``.fb2`` file.
163
+ on_chapter: Called once per chapter written, if given.
153
164
 
154
165
  Returns:
155
166
  ``output_path``.
156
167
  """
157
168
  root = ET.Element(_tag("FictionBook"))
158
169
  root.append(_build_description(title))
159
- root.append(_build_body(chapters))
170
+ root.append(_build_body(chapters, on_chapter))
160
171
 
161
172
  tree = ET.ElementTree(root)
162
173
  ET.indent(tree, space=" ")
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import base64
7
7
  import html
8
+ from collections.abc import Callable
8
9
  from pathlib import Path
9
10
  from typing import ClassVar
10
11
 
@@ -98,13 +99,23 @@ if weasyprint is not None:
98
99
  """
99
100
  self._transport = transport
100
101
 
101
- async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
102
+ async def export(
103
+ self,
104
+ title: Title,
105
+ chapters: list[Chapter],
106
+ output_path: Path,
107
+ *,
108
+ on_chapter: Callable[[], None] | None = None,
109
+ ) -> Path:
102
110
  """Write ``chapters`` to ``output_path`` as a PDF file.
103
111
 
104
112
  Args:
105
113
  title: The chapters' parent title; supplies metadata, cover, and authors.
106
114
  chapters: The chapters to include, in the order they should appear.
107
115
  output_path: Where to write the ``.pdf`` file.
116
+ on_chapter: Called once per chapter rendered to HTML, if given — after
117
+ illustrations have already been downloaded, see the ``Exporter``
118
+ protocol's docstring.
108
119
 
109
120
  Returns:
110
121
  ``output_path``.
@@ -135,9 +146,12 @@ if weasyprint is not None:
135
146
  for url, data in images.items()
136
147
  }
137
148
 
138
- body = _title_page_html(title, cover_data_uri) + "".join(
139
- _chapter_html(chapter, url_to_data_uri) for chapter in chapters
140
- )
149
+ chapter_html_parts = []
150
+ for chapter in chapters:
151
+ chapter_html_parts.append(_chapter_html(chapter, url_to_data_uri))
152
+ if on_chapter is not None:
153
+ on_chapter()
154
+ body = _title_page_html(title, cover_data_uri) + "".join(chapter_html_parts)
141
155
  document = (
142
156
  f"<html><head><meta charset='utf-8'><style>{_PAGE_CSS}</style></head>"
143
157
  f"<body>{body}</body></html>"
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Callable
5
6
  from html.parser import HTMLParser
6
7
  from pathlib import Path
7
8
  from typing import ClassVar
@@ -69,13 +70,21 @@ class TxtExporter:
69
70
 
70
71
  format: ClassVar[str] = "txt"
71
72
 
72
- async def export(self, title: Title, chapters: list[Chapter], output_path: Path) -> Path:
73
+ async def export(
74
+ self,
75
+ title: Title,
76
+ chapters: list[Chapter],
77
+ output_path: Path,
78
+ *,
79
+ on_chapter: Callable[[], None] | None = None,
80
+ ) -> Path:
73
81
  """Write ``chapters`` to ``output_path`` as plain text.
74
82
 
75
83
  Args:
76
84
  title: The chapters' parent title; only its name is used.
77
85
  chapters: The chapters to include, in the order they should appear.
78
86
  output_path: Where to write the ``.txt`` file.
87
+ on_chapter: Called once per chapter written, if given.
79
88
 
80
89
  Returns:
81
90
  ``output_path``.
@@ -84,6 +93,8 @@ class TxtExporter:
84
93
  for chapter in chapters:
85
94
  body = html_to_text(chapter.content or "")
86
95
  sections.append(f"{chapter_heading(chapter)}\n\n{body}")
96
+ if on_chapter is not None:
97
+ on_chapter()
87
98
 
88
99
  output_path.write_text("\n\n\n".join(sections) + "\n", encoding="utf-8")
89
100
  return output_path
@@ -9,6 +9,7 @@ from typing import Any, Self
9
9
 
10
10
  from ranobelib.cache import DEFAULT_CACHE_DIR, DiskCache
11
11
  from ranobelib.client import ApiClient
12
+ from ranobelib.console import Reporter, Verbosity
12
13
  from ranobelib.exceptions import (
13
14
  AmbiguousChapter,
14
15
  ChapterNotFoundError,
@@ -19,6 +20,7 @@ from ranobelib.exceptions import (
19
20
  from ranobelib.exporters import EXPORTERS
20
21
  from ranobelib.models import Chapter, ChapterBranch, Title, Volume
21
22
  from ranobelib.numbering import parse_slug_url
23
+ from ranobelib.sizing import DEFAULT_AVERAGE_IMAGE_SIZE, DEFAULT_SAMPLE_SIZE, chapter_size
22
24
 
23
25
  _INFO_FIELDS = [
24
26
  "background",
@@ -51,6 +53,7 @@ class RanobeLib:
51
53
  *,
52
54
  cache_dir: str | Path | None = None,
53
55
  cache_ttl: float | None = None,
56
+ verbosity: Verbosity = False,
54
57
  ) -> None:
55
58
  """Initialize the SDK for a title.
56
59
 
@@ -63,12 +66,18 @@ class RanobeLib:
63
66
  cache_ttl: Seconds after which a cached response is treated as stale and
64
67
  re-fetched. ``None`` (the default) means cached responses never expire on
65
68
  their own — see ``refresh=True`` on individual methods to force one anyway.
69
+ verbosity: Console output level. ``False`` (default): silent. ``"progress_only"``:
70
+ progress bars during ``download_title()``/``export()``/``estimate_title_size()``.
71
+ ``"full"``: the same progress bars, plus a line logged for every title/
72
+ chapter-list/chapter-content fetch, noting whether it was served from the
73
+ disk cache or the API.
66
74
  """
67
75
  self._slug_url = parse_slug_url(url)
68
76
  self._client = ApiClient()
69
77
  self._cache = DiskCache(
70
78
  cache_dir if cache_dir is not None else DEFAULT_CACHE_DIR, ttl=cache_ttl
71
79
  )
80
+ self._reporter = Reporter(verbosity)
72
81
 
73
82
  async def __aenter__(self) -> Self:
74
83
  return self
@@ -288,12 +297,102 @@ class RanobeLib:
288
297
  raise MultipleTitleTranslationsError(self._slug_url, chapters=unresolved)
289
298
 
290
299
  chapters = []
291
- for index, (volume_str, number, selected_branch_id) in enumerate(planned):
292
- chapters.append(await self._fetch_chapter(volume_str, number, selected_branch_id))
293
- if chapter_delay and index < len(planned) - 1:
294
- await asyncio.sleep(chapter_delay)
300
+ with self._reporter.progress(f"Downloading {self._slug_url}", len(planned)) as advance:
301
+ for index, (volume_str, number, selected_branch_id) in enumerate(planned):
302
+ chapters.append(await self._fetch_chapter(volume_str, number, selected_branch_id))
303
+ advance()
304
+ if chapter_delay and index < len(planned) - 1:
305
+ await asyncio.sleep(chapter_delay)
295
306
  return _group_into_volumes(chapters)
296
307
 
308
+ async def estimate_title_size(
309
+ self,
310
+ *,
311
+ sample_size: int = DEFAULT_SAMPLE_SIZE,
312
+ average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE,
313
+ branch_id: int | None = None,
314
+ translation_index: int | None = None,
315
+ ) -> int:
316
+ """Estimate the whole title's download size in bytes, from a sample of its chapters.
317
+
318
+ Fetches the chapter list (cheap, cached), then the content of up to ``sample_size``
319
+ chapters spread evenly across the title — not just the first ones, since early and
320
+ late chapters can differ in length — and extrapolates the sample's average
321
+ ``sizing.chapter_size()`` across the title's total chapter count. This trades
322
+ exactness for cost: an exact total requires fetching every chapter's content, the
323
+ same cost as ``download_title()`` itself, which defeats the purpose of checking
324
+ "is this worth downloading" *before* paying that cost.
325
+
326
+ Sampled chapters go through the same disk cache as any other chapter fetch, so a
327
+ later ``download_title()`` call doesn't refetch them.
328
+
329
+ Args:
330
+ sample_size: How many chapters to sample. Clamped to the number of chapters the
331
+ title actually has (or that resolve to a usable translation, see below) if
332
+ that's fewer.
333
+ average_image_size: Forwarded to ``sizing.chapter_size()`` for each sampled
334
+ chapter.
335
+ branch_id: Same meaning as ``download_title(branch_id=...)`` — which translation
336
+ to sample for chapters that have more than one. Mutually exclusive with
337
+ ``translation_index``.
338
+ translation_index: Same meaning as ``download_title(translation_index=...)``.
339
+ Mutually exclusive with ``branch_id``.
340
+
341
+ Returns:
342
+ The estimated size in bytes, or ``0`` if the title has no chapters, or none of
343
+ its chapters have a translation ``branch_id``/``translation_index`` resolves.
344
+
345
+ Raises:
346
+ ValueError: If both ``branch_id`` and ``translation_index`` are given, or if
347
+ ``sample_size`` is less than 1.
348
+
349
+ Note:
350
+ Unlike ``download_title()``, a chapter with more than one translation that
351
+ ``branch_id``/``translation_index`` doesn't resolve is skipped when picking the
352
+ sample rather than raising ``MultipleTitleTranslationsError`` — an estimate
353
+ doesn't need a specific translation to be correct, only *a* chapter's worth of
354
+ this title's content to measure.
355
+ """
356
+ if branch_id is not None and translation_index is not None:
357
+ raise ValueError("Pass at most one of branch_id or translation_index, not both.")
358
+ if sample_size < 1:
359
+ raise ValueError(f"sample_size must be at least 1, got {sample_size}.")
360
+
361
+ raw_chapters = await self._get_chapters()
362
+ if not raw_chapters:
363
+ return 0
364
+
365
+ resolvable: list[tuple[str, str, int | None]] = []
366
+ for item in raw_chapters:
367
+ volume_str: str = item["volume"]
368
+ number: str = item["number"]
369
+ branches = item.get("branches") or []
370
+ if len(branches) <= 1:
371
+ resolvable.append((volume_str, number, None))
372
+ continue
373
+ resolved, selected = _resolve_bulk_branch_id(
374
+ branches, branch_id=branch_id, translation_index=translation_index
375
+ )
376
+ if resolved:
377
+ resolvable.append((volume_str, number, selected))
378
+
379
+ if not resolvable:
380
+ return 0
381
+
382
+ size = min(sample_size, len(resolvable))
383
+ step = max(1, len(resolvable) // size)
384
+ sample = [resolvable[index] for index in range(0, len(resolvable), step)][:size]
385
+
386
+ total_sample_bytes = 0
387
+ with self._reporter.progress(f"Sampling {self._slug_url}", len(sample)) as advance:
388
+ for volume_str, number, selected_branch_id in sample:
389
+ chapter = await self._fetch_chapter(volume_str, number, selected_branch_id)
390
+ total_sample_bytes += chapter_size(chapter, average_image_size=average_image_size)
391
+ advance()
392
+
393
+ average_bytes = total_sample_bytes / len(sample)
394
+ return round(average_bytes * len(raw_chapters))
395
+
297
396
  async def export(self, chapters: list[Chapter], *, fmt: str, path: str | Path) -> Path:
298
397
  """Export chapters to a file.
299
398
 
@@ -311,7 +410,8 @@ class RanobeLib:
311
410
  available = ", ".join(sorted(EXPORTERS)) or "(none registered)"
312
411
  raise ValueError(f"Unknown export format {fmt!r}. Available: {available}")
313
412
  title = await self.get_info()
314
- return await exporter_cls().export(title, chapters, Path(path))
413
+ with self._reporter.progress(f"Exporting to {fmt}", len(chapters)) as advance:
414
+ return await exporter_cls().export(title, chapters, Path(path), on_chapter=advance)
315
415
 
316
416
  async def _build_volume(self, volume: int, raw_chapters: list[dict[str, Any]]) -> Volume:
317
417
  volume_str = str(volume)
@@ -330,7 +430,9 @@ class RanobeLib:
330
430
  if not refresh:
331
431
  cached = self._cache.get(key)
332
432
  if cached is not None:
433
+ self._reporter.log(f"[cache] title metadata for {self._slug_url}")
333
434
  return cached # type: ignore[no-any-return]
435
+ self._reporter.log(f"[api] fetching title metadata for {self._slug_url}")
334
436
  data = await self._client.get_title(self._slug_url, fields=_INFO_FIELDS)
335
437
  self._cache.set(key, data)
336
438
  return data
@@ -340,7 +442,9 @@ class RanobeLib:
340
442
  if not refresh:
341
443
  cached = self._cache.get(key)
342
444
  if cached is not None:
445
+ self._reporter.log(f"[cache] chapter list for {self._slug_url}")
343
446
  return cached # type: ignore[no-any-return]
447
+ self._reporter.log(f"[api] fetching chapter list for {self._slug_url}")
344
448
  data = await self._client.get_chapters(self._slug_url)
345
449
  self._cache.set(key, data)
346
450
  return data
@@ -352,7 +456,9 @@ class RanobeLib:
352
456
  if not refresh:
353
457
  cached = self._cache.get(key)
354
458
  if cached is not None:
459
+ self._reporter.log(f"[cache] chapter {volume_str}/{number}")
355
460
  return Chapter.model_validate(cached)
461
+ self._reporter.log(f"[api] fetching chapter {volume_str}/{number}")
356
462
  data = await self._client.get_chapter(
357
463
  self._slug_url, number=number, volume=volume_str, branch_id=branch_id
358
464
  )
@@ -0,0 +1,62 @@
1
+ """Rough size estimation for already-fetched chapters/volumes, and whole-title sampling.
2
+
3
+ The API exposes no content-length or image-size field anywhere (see docs/api-notes.md), so
4
+ any size here is necessarily approximate for the image portion: chapter text is measured
5
+ exactly (the UTF-8 byte length of ``Chapter.content``), but each embedded ``<img>`` is
6
+ assumed to weigh ``average_image_size`` bytes rather than downloaded to measure precisely —
7
+ doing that would cost the same network traffic this module exists to help avoid paying
8
+ upfront. The result approximates the underlying content size, not a specific export
9
+ format's file size (epub's zip compression, pdf's layout overhead, fb2's XML verbosity are
10
+ not modeled) — enough to decide whether a title is worth downloading, not to predict an
11
+ exported file's exact byte count.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from ranobelib.exporters._illustrations import extract_image_urls
17
+ from ranobelib.models import Chapter, Volume
18
+
19
+ DEFAULT_AVERAGE_IMAGE_SIZE = 150_000
20
+ """Assumed bytes per embedded image when its real size isn't fetched. A rough guess, not
21
+ measured — override with a better estimate for a given title if you have one."""
22
+
23
+ DEFAULT_SAMPLE_SIZE = 5
24
+ """Default number of chapters ``RanobeLib.estimate_title_size()`` samples."""
25
+
26
+
27
+ def chapter_size(chapter: Chapter, *, average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE) -> int:
28
+ """Estimate a fetched chapter's size in bytes: exact text plus assumed-average images.
29
+
30
+ Args:
31
+ chapter: A chapter with content already fetched (e.g. via ``get_chapter()``).
32
+ average_image_size: Assumed bytes per ``<img>`` referenced in the chapter's content.
33
+
34
+ Raises:
35
+ ValueError: If ``chapter.content`` is ``None`` (not yet fetched — see
36
+ ``Chapter``'s docstring for when that happens).
37
+ """
38
+ if chapter.content is None:
39
+ raise ValueError(
40
+ f"Chapter {chapter.volume}/{chapter.number} has no content to size — "
41
+ "fetch it first, e.g. via get_chapter()."
42
+ )
43
+ text_bytes = len(chapter.content.encode("utf-8"))
44
+ image_count = len(extract_image_urls(chapter.content))
45
+ return text_bytes + image_count * average_image_size
46
+
47
+
48
+ def volume_size(volume: Volume, *, average_image_size: int = DEFAULT_AVERAGE_IMAGE_SIZE) -> int:
49
+ """Estimate a fetched volume's size in bytes: the sum of its chapters' ``chapter_size()``.
50
+
51
+ Args:
52
+ volume: A volume whose chapters have content already fetched (e.g. via
53
+ ``get_volume()``).
54
+ average_image_size: Forwarded to ``chapter_size()`` for each chapter.
55
+
56
+ Raises:
57
+ ValueError: If any chapter in ``volume.chapters`` has no content — see
58
+ ``chapter_size()``.
59
+ """
60
+ return sum(
61
+ chapter_size(chapter, average_image_size=average_image_size) for chapter in volume.chapters
62
+ )