notebooklm-easy 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- notebooklm_easy-0.1.0/PKG-INFO +246 -0
- notebooklm_easy-0.1.0/README.md +231 -0
- notebooklm_easy-0.1.0/notebooklm_easy/__init__.py +21 -0
- notebooklm_easy-0.1.0/notebooklm_easy/_enums.py +46 -0
- notebooklm_easy-0.1.0/notebooklm_easy/_loop.py +60 -0
- notebooklm_easy-0.1.0/notebooklm_easy/client.py +496 -0
- notebooklm_easy-0.1.0/notebooklm_easy/exceptions.py +25 -0
- notebooklm_easy-0.1.0/notebooklm_easy.egg-info/PKG-INFO +246 -0
- notebooklm_easy-0.1.0/notebooklm_easy.egg-info/SOURCES.txt +13 -0
- notebooklm_easy-0.1.0/notebooklm_easy.egg-info/dependency_links.txt +1 -0
- notebooklm_easy-0.1.0/notebooklm_easy.egg-info/requires.txt +10 -0
- notebooklm_easy-0.1.0/notebooklm_easy.egg-info/top_level.txt +1 -0
- notebooklm_easy-0.1.0/pyproject.toml +31 -0
- notebooklm_easy-0.1.0/setup.cfg +4 -0
- notebooklm_easy-0.1.0/tests/test_smoke.py +223 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: notebooklm-easy
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A synchronous, one-call-per-task convenience wrapper around notebooklm-py
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: notebooklm-py<0.9,>=0.8.2
|
|
9
|
+
Provides-Extra: browser
|
|
10
|
+
Requires-Dist: notebooklm-py[browser]; extra == "browser"
|
|
11
|
+
Provides-Extra: cookies
|
|
12
|
+
Requires-Dist: notebooklm-py[cookies]; extra == "cookies"
|
|
13
|
+
Provides-Extra: headless
|
|
14
|
+
Requires-Dist: notebooklm-py[headless]; extra == "headless"
|
|
15
|
+
|
|
16
|
+
# notebooklm-easy
|
|
17
|
+
|
|
18
|
+
A synchronous, one-call-per-task convenience wrapper around
|
|
19
|
+
[`notebooklm-py`](https://github.com/teng-lin/notebooklm-py) — the unofficial
|
|
20
|
+
Python API for Google NotebookLM / Gemini Notebook.
|
|
21
|
+
|
|
22
|
+
`notebooklm-py` itself is fully async and, for every generated artifact
|
|
23
|
+
(podcast, video, quiz, ...), a three-step dance: `generate_*()` kicks it
|
|
24
|
+
off, `wait_for_completion()` polls it, and a matching `download_*()` fetches
|
|
25
|
+
the finished file. That's the right shape for a library embedded in an
|
|
26
|
+
existing async app. This wrapper is for the much more common case — a plain
|
|
27
|
+
script — and does two things:
|
|
28
|
+
|
|
29
|
+
1. **No `async`/`await`.** Every method is a normal blocking call. You don't
|
|
30
|
+
write `async def main(): ...; asyncio.run(main())` at all.
|
|
31
|
+
2. **One call per artifact**, not three. `nlm.audio(nb.id, "podcast.mp3")`
|
|
32
|
+
generates, waits, and downloads in a single line.
|
|
33
|
+
|
|
34
|
+
It does not reimplement anything — auth, RPC calls, polling, and downloads
|
|
35
|
+
are all still done by `notebooklm-py` itself. This is a thin facade over it.
|
|
36
|
+
Anything it doesn't wrap is still reachable (see [Escape hatch](#escape-hatch)).
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install notebooklm-py notebooklm-easy # or: pip install -e . from this folder
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`notebooklm-easy` depends on `notebooklm-py` and does not vendor it — you're
|
|
45
|
+
always running the real, upstream library underneath.
|
|
46
|
+
|
|
47
|
+
You still need to authenticate once with `notebooklm-py`'s own CLI (this
|
|
48
|
+
wrapper doesn't change auth at all — see its
|
|
49
|
+
[Installation guide](https://github.com/teng-lin/notebooklm-py/blob/main/docs/installation.md)
|
|
50
|
+
for the full picture):
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install "notebooklm-py[browser]"
|
|
54
|
+
notebooklm login # opens a browser once
|
|
55
|
+
notebooklm auth check --test --json # confirms it worked
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
After that, `notebooklm_easy.NotebookLM()` (no arguments) picks up the saved
|
|
59
|
+
session automatically, exactly like `NotebookLMClient.from_storage()` does.
|
|
60
|
+
|
|
61
|
+
## Quick start
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from notebooklm_easy import NotebookLM
|
|
65
|
+
|
|
66
|
+
with NotebookLM() as nlm:
|
|
67
|
+
# Reuses a notebook with this exact title if one exists, else creates it
|
|
68
|
+
nb = nlm.get_or_create_notebook("Research")
|
|
69
|
+
|
|
70
|
+
# Add sources — URLs and local file paths, auto-detected, uploaded
|
|
71
|
+
# concurrently, and waited-on by default so the next call can use them
|
|
72
|
+
nlm.add_sources(nb.id, [
|
|
73
|
+
"https://en.wikipedia.org/wiki/Artificial_intelligence",
|
|
74
|
+
"./paper.pdf",
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
# Ask a question — returns just the answer text
|
|
78
|
+
print(nlm.ask(nb.id, "What are the key themes?"))
|
|
79
|
+
|
|
80
|
+
# Generate + wait + download, in one call each
|
|
81
|
+
nlm.audio(nb.id, "podcast.mp3", instructions="make it engaging")
|
|
82
|
+
nlm.quiz(nb.id, "quiz.json", difficulty="hard")
|
|
83
|
+
nlm.report(nb.id, "briefing.md", format="briefing_doc")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
That's the whole shape of the library: a `NotebookLM` object, used as a
|
|
87
|
+
context manager (or with explicit `.close()`), with one method per thing
|
|
88
|
+
you want to do.
|
|
89
|
+
|
|
90
|
+
## Auth / multiple accounts
|
|
91
|
+
|
|
92
|
+
`NotebookLM(...)` accepts every keyword `NotebookLMClient.from_storage()`
|
|
93
|
+
does, and passes them straight through — most commonly `profile` for
|
|
94
|
+
switching between `notebooklm login --profile <name>` accounts, and
|
|
95
|
+
`backend="android"` to use the Android gRPC backend instead of the default
|
|
96
|
+
web one:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
with NotebookLM(profile="work") as nlm:
|
|
100
|
+
...
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Notebooks
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
nlm.create_notebook("Title") # always creates a new one
|
|
107
|
+
nlm.get_or_create_notebook("Title") # reuses an existing notebook with this exact title
|
|
108
|
+
nlm.list_notebooks() # -> list[Notebook]
|
|
109
|
+
nlm.delete_notebook(notebook_id)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Sources
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
# One source — auto-detects URL (http/https, including YouTube) vs. an
|
|
116
|
+
# existing local file path (PDF, text, Markdown, Word, EPUB, audio, video,
|
|
117
|
+
# image — whatever notebooklm-py's add_file supports).
|
|
118
|
+
nlm.add_source(nb.id, "https://example.com")
|
|
119
|
+
nlm.add_source(nb.id, "./report.pdf")
|
|
120
|
+
|
|
121
|
+
# Several at once, uploaded concurrently, in the same order back:
|
|
122
|
+
sources = nlm.add_sources(nb.id, ["https://a.example", "./b.pdf", "https://youtube.com/watch?v=..."])
|
|
123
|
+
|
|
124
|
+
# Raw pasted text has no URL/path to auto-detect, so it's its own method:
|
|
125
|
+
nlm.add_text(nb.id, "My Notes", "... text content ...")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
All three default to `wait=True` — they block until NotebookLM finishes
|
|
129
|
+
processing the source, so the very next `ask()` or `generate_*` call sees
|
|
130
|
+
it. Pass `wait=False` to fire-and-forget instead (matching
|
|
131
|
+
`notebooklm-py`'s own default).
|
|
132
|
+
|
|
133
|
+
## Chat
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
answer = nlm.ask(nb.id, "Summarize this") # -> str, just the answer
|
|
137
|
+
result = nlm.ask_full(nb.id, "Summarize this") # -> AskResult (citations, conversation_id, ...)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Generating content
|
|
141
|
+
|
|
142
|
+
One method per artifact type. Each does `generate_* -> wait_for_completion
|
|
143
|
+
-> download_*` and returns the path it wrote to. Every enum-typed option
|
|
144
|
+
(`format`, `difficulty`, `style`, ...) accepts a plain, case-insensitive
|
|
145
|
+
string as well as the real `notebooklm.types` enum member — `"hard"` and
|
|
146
|
+
`QuizDifficulty.HARD` both work.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
nlm.audio(nb.id, "podcast.mp3", instructions="make it fun", format="deep_dive", length="short")
|
|
150
|
+
nlm.video(nb.id, "overview.mp4", format="explainer", style="whiteboard")
|
|
151
|
+
nlm.cinematic_video(nb.id, "documentary.mp4", instructions="documentary-style summary")
|
|
152
|
+
nlm.report(nb.id, "briefing.md", format="briefing_doc") # or study_guide / blog_post / concept_explanation / custom
|
|
153
|
+
nlm.study_guide(nb.id, "study_guide.md")
|
|
154
|
+
nlm.quiz(nb.id, "quiz.json", difficulty="hard", quantity="more", output_format="json") # or markdown / html
|
|
155
|
+
nlm.flashcards(nb.id, "cards.json", difficulty="easy")
|
|
156
|
+
nlm.slide_deck(nb.id, "slides.pdf", format="presenter_slides", output_format="pdf") # or pptx
|
|
157
|
+
nlm.infographic(nb.id, "infographic.png", orientation="portrait", style="bento_grid")
|
|
158
|
+
nlm.data_table(nb.id, "data.csv", instructions="compare key concepts")
|
|
159
|
+
nlm.mind_map(nb.id, "mindmap.json", kind="interactive") # or "note_backed"
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Every one of these takes `source_ids=[...]` to scope generation to specific
|
|
163
|
+
sources (default: all of them), and `timeout=<seconds>` to override how
|
|
164
|
+
long it waits before giving up (defaults are generous — 300s for most
|
|
165
|
+
types, 600s for video/slide-deck/audio, which tend to run longer).
|
|
166
|
+
|
|
167
|
+
### Handling failure
|
|
168
|
+
|
|
169
|
+
If generation finishes in a `failed` or `removed` (delisted, e.g. after a
|
|
170
|
+
daily quota rejection) state, these methods raise `GenerationFailedError`
|
|
171
|
+
instead of silently returning a broken path:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from notebooklm_easy import GenerationFailedError
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
nlm.audio(nb.id, "podcast.mp3")
|
|
178
|
+
except GenerationFailedError as e:
|
|
179
|
+
print(e.status.error, e.status.error_code) # the raw GenerationStatus is on .status
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Every other error — auth problems, rate limits, network issues, a bad
|
|
183
|
+
notebook id — is exactly whatever `notebooklm-py` itself raises (see its
|
|
184
|
+
[`exceptions.py`](https://github.com/teng-lin/notebooklm-py/blob/main/src/notebooklm/exceptions.py)),
|
|
185
|
+
unchanged. This wrapper doesn't catch or reshape those.
|
|
186
|
+
|
|
187
|
+
## Escape hatch
|
|
188
|
+
|
|
189
|
+
Anything this wrapper doesn't cover — sharing, labels, research, notes,
|
|
190
|
+
collections, settings, the `raw` backend-selected wire access, or a
|
|
191
|
+
`generate_*`/`download_*` combination not wrapped as its own method — is
|
|
192
|
+
still one call away. Every `NotebookLM` instance exposes the real async
|
|
193
|
+
client:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
with NotebookLM() as nlm:
|
|
197
|
+
# nlm.client is the actual notebooklm.NotebookLMClient — every
|
|
198
|
+
# namespace (sharing, labels, research, notes, collections, raw, ...)
|
|
199
|
+
# is there, unwrapped.
|
|
200
|
+
coro = nlm.client.sharing.set_public(nb.id, view_level="editor")
|
|
201
|
+
nlm.run(coro) # runs it on the same background loop as everything else
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
**Important:** don't `await` `nlm.client.<...>(...)` yourself from a
|
|
205
|
+
different `asyncio` context (e.g. inside your own `async def`) — always go
|
|
206
|
+
through `nlm.run(coro)`. `notebooklm-py`'s client is loop-bound (it asserts
|
|
207
|
+
every call happens on the exact event loop it was opened on); `NotebookLM`
|
|
208
|
+
opens it on a dedicated background loop and `run()` is what submits work to
|
|
209
|
+
that same loop correctly. See `notebooklm_easy/_loop.py` for the full
|
|
210
|
+
rationale.
|
|
211
|
+
|
|
212
|
+
If your program is *already* async end-to-end, skip this wrapper entirely
|
|
213
|
+
and use `notebooklm.NotebookLMClient` directly — that's what it's for.
|
|
214
|
+
|
|
215
|
+
## Using this inside a bot (or any other async app)
|
|
216
|
+
|
|
217
|
+
`NotebookLM` is synchronous — every method blocks the calling thread until
|
|
218
|
+
it's done (`ask()` for a few seconds, a generated artifact for a minute or
|
|
219
|
+
more). That's fine in a plain script, but calling it directly inside an
|
|
220
|
+
`async def` handler of an asyncio-based bot (kurigram/pyrogram, aiogram,
|
|
221
|
+
discord.py, ...) freezes that bot's entire event loop — every other
|
|
222
|
+
chat/user — for as long as the call takes, since asyncio is single-threaded.
|
|
223
|
+
|
|
224
|
+
The fix: create **one** `NotebookLM()` for the whole bot process (not one
|
|
225
|
+
per request — see why in the example), and reach every blocking call
|
|
226
|
+
through `asyncio.to_thread(...)` from inside your `async def` handlers, so
|
|
227
|
+
it runs on a worker thread instead of the bot's event loop.
|
|
228
|
+
`examples/telegram_notes_bot.py` is a complete, runnable example of exactly
|
|
229
|
+
this: a kurigram/pyrogram bot with a `/notes <youtube_link>` command that
|
|
230
|
+
adds the video as a source and returns notes, done correctly.
|
|
231
|
+
|
|
232
|
+
## Testing without live Google auth
|
|
233
|
+
|
|
234
|
+
`tests/test_smoke.py` exercises the whole wrapper (notebook get-or-create,
|
|
235
|
+
source auto-detection, bulk add, chat, one-call generate+wait+download,
|
|
236
|
+
failure handling, enum coercion, and — importantly — that every call really
|
|
237
|
+
does run on the one background loop) against the real installed
|
|
238
|
+
`notebooklm-py` types, with only `NotebookLMClient.from_storage` swapped for
|
|
239
|
+
a fake that never touches the network. Useful both as a regression test and
|
|
240
|
+
as a template if you want to unit-test your own code that uses this wrapper
|
|
241
|
+
without a live Google session.
|
|
242
|
+
|
|
243
|
+
```bash
|
|
244
|
+
pip install pytest
|
|
245
|
+
pytest tests/
|
|
246
|
+
```
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# notebooklm-easy
|
|
2
|
+
|
|
3
|
+
A synchronous, one-call-per-task convenience wrapper around
|
|
4
|
+
[`notebooklm-py`](https://github.com/teng-lin/notebooklm-py) — the unofficial
|
|
5
|
+
Python API for Google NotebookLM / Gemini Notebook.
|
|
6
|
+
|
|
7
|
+
`notebooklm-py` itself is fully async and, for every generated artifact
|
|
8
|
+
(podcast, video, quiz, ...), a three-step dance: `generate_*()` kicks it
|
|
9
|
+
off, `wait_for_completion()` polls it, and a matching `download_*()` fetches
|
|
10
|
+
the finished file. That's the right shape for a library embedded in an
|
|
11
|
+
existing async app. This wrapper is for the much more common case — a plain
|
|
12
|
+
script — and does two things:
|
|
13
|
+
|
|
14
|
+
1. **No `async`/`await`.** Every method is a normal blocking call. You don't
|
|
15
|
+
write `async def main(): ...; asyncio.run(main())` at all.
|
|
16
|
+
2. **One call per artifact**, not three. `nlm.audio(nb.id, "podcast.mp3")`
|
|
17
|
+
generates, waits, and downloads in a single line.
|
|
18
|
+
|
|
19
|
+
It does not reimplement anything — auth, RPC calls, polling, and downloads
|
|
20
|
+
are all still done by `notebooklm-py` itself. This is a thin facade over it.
|
|
21
|
+
Anything it doesn't wrap is still reachable (see [Escape hatch](#escape-hatch)).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install notebooklm-py notebooklm-easy # or: pip install -e . from this folder
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`notebooklm-easy` depends on `notebooklm-py` and does not vendor it — you're
|
|
30
|
+
always running the real, upstream library underneath.
|
|
31
|
+
|
|
32
|
+
You still need to authenticate once with `notebooklm-py`'s own CLI (this
|
|
33
|
+
wrapper doesn't change auth at all — see its
|
|
34
|
+
[Installation guide](https://github.com/teng-lin/notebooklm-py/blob/main/docs/installation.md)
|
|
35
|
+
for the full picture):
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install "notebooklm-py[browser]"
|
|
39
|
+
notebooklm login # opens a browser once
|
|
40
|
+
notebooklm auth check --test --json # confirms it worked
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
After that, `notebooklm_easy.NotebookLM()` (no arguments) picks up the saved
|
|
44
|
+
session automatically, exactly like `NotebookLMClient.from_storage()` does.
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from notebooklm_easy import NotebookLM
|
|
50
|
+
|
|
51
|
+
with NotebookLM() as nlm:
|
|
52
|
+
# Reuses a notebook with this exact title if one exists, else creates it
|
|
53
|
+
nb = nlm.get_or_create_notebook("Research")
|
|
54
|
+
|
|
55
|
+
# Add sources — URLs and local file paths, auto-detected, uploaded
|
|
56
|
+
# concurrently, and waited-on by default so the next call can use them
|
|
57
|
+
nlm.add_sources(nb.id, [
|
|
58
|
+
"https://en.wikipedia.org/wiki/Artificial_intelligence",
|
|
59
|
+
"./paper.pdf",
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
# Ask a question — returns just the answer text
|
|
63
|
+
print(nlm.ask(nb.id, "What are the key themes?"))
|
|
64
|
+
|
|
65
|
+
# Generate + wait + download, in one call each
|
|
66
|
+
nlm.audio(nb.id, "podcast.mp3", instructions="make it engaging")
|
|
67
|
+
nlm.quiz(nb.id, "quiz.json", difficulty="hard")
|
|
68
|
+
nlm.report(nb.id, "briefing.md", format="briefing_doc")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
That's the whole shape of the library: a `NotebookLM` object, used as a
|
|
72
|
+
context manager (or with explicit `.close()`), with one method per thing
|
|
73
|
+
you want to do.
|
|
74
|
+
|
|
75
|
+
## Auth / multiple accounts
|
|
76
|
+
|
|
77
|
+
`NotebookLM(...)` accepts every keyword `NotebookLMClient.from_storage()`
|
|
78
|
+
does, and passes them straight through — most commonly `profile` for
|
|
79
|
+
switching between `notebooklm login --profile <name>` accounts, and
|
|
80
|
+
`backend="android"` to use the Android gRPC backend instead of the default
|
|
81
|
+
web one:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
with NotebookLM(profile="work") as nlm:
|
|
85
|
+
...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Notebooks
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
nlm.create_notebook("Title") # always creates a new one
|
|
92
|
+
nlm.get_or_create_notebook("Title") # reuses an existing notebook with this exact title
|
|
93
|
+
nlm.list_notebooks() # -> list[Notebook]
|
|
94
|
+
nlm.delete_notebook(notebook_id)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Sources
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
# One source — auto-detects URL (http/https, including YouTube) vs. an
|
|
101
|
+
# existing local file path (PDF, text, Markdown, Word, EPUB, audio, video,
|
|
102
|
+
# image — whatever notebooklm-py's add_file supports).
|
|
103
|
+
nlm.add_source(nb.id, "https://example.com")
|
|
104
|
+
nlm.add_source(nb.id, "./report.pdf")
|
|
105
|
+
|
|
106
|
+
# Several at once, uploaded concurrently, in the same order back:
|
|
107
|
+
sources = nlm.add_sources(nb.id, ["https://a.example", "./b.pdf", "https://youtube.com/watch?v=..."])
|
|
108
|
+
|
|
109
|
+
# Raw pasted text has no URL/path to auto-detect, so it's its own method:
|
|
110
|
+
nlm.add_text(nb.id, "My Notes", "... text content ...")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
All three default to `wait=True` — they block until NotebookLM finishes
|
|
114
|
+
processing the source, so the very next `ask()` or `generate_*` call sees
|
|
115
|
+
it. Pass `wait=False` to fire-and-forget instead (matching
|
|
116
|
+
`notebooklm-py`'s own default).
|
|
117
|
+
|
|
118
|
+
## Chat
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
answer = nlm.ask(nb.id, "Summarize this") # -> str, just the answer
|
|
122
|
+
result = nlm.ask_full(nb.id, "Summarize this") # -> AskResult (citations, conversation_id, ...)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Generating content
|
|
126
|
+
|
|
127
|
+
One method per artifact type. Each does `generate_* -> wait_for_completion
|
|
128
|
+
-> download_*` and returns the path it wrote to. Every enum-typed option
|
|
129
|
+
(`format`, `difficulty`, `style`, ...) accepts a plain, case-insensitive
|
|
130
|
+
string as well as the real `notebooklm.types` enum member — `"hard"` and
|
|
131
|
+
`QuizDifficulty.HARD` both work.
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
nlm.audio(nb.id, "podcast.mp3", instructions="make it fun", format="deep_dive", length="short")
|
|
135
|
+
nlm.video(nb.id, "overview.mp4", format="explainer", style="whiteboard")
|
|
136
|
+
nlm.cinematic_video(nb.id, "documentary.mp4", instructions="documentary-style summary")
|
|
137
|
+
nlm.report(nb.id, "briefing.md", format="briefing_doc") # or study_guide / blog_post / concept_explanation / custom
|
|
138
|
+
nlm.study_guide(nb.id, "study_guide.md")
|
|
139
|
+
nlm.quiz(nb.id, "quiz.json", difficulty="hard", quantity="more", output_format="json") # or markdown / html
|
|
140
|
+
nlm.flashcards(nb.id, "cards.json", difficulty="easy")
|
|
141
|
+
nlm.slide_deck(nb.id, "slides.pdf", format="presenter_slides", output_format="pdf") # or pptx
|
|
142
|
+
nlm.infographic(nb.id, "infographic.png", orientation="portrait", style="bento_grid")
|
|
143
|
+
nlm.data_table(nb.id, "data.csv", instructions="compare key concepts")
|
|
144
|
+
nlm.mind_map(nb.id, "mindmap.json", kind="interactive") # or "note_backed"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Every one of these takes `source_ids=[...]` to scope generation to specific
|
|
148
|
+
sources (default: all of them), and `timeout=<seconds>` to override how
|
|
149
|
+
long it waits before giving up (defaults are generous — 300s for most
|
|
150
|
+
types, 600s for video/slide-deck/audio, which tend to run longer).
|
|
151
|
+
|
|
152
|
+
### Handling failure
|
|
153
|
+
|
|
154
|
+
If generation finishes in a `failed` or `removed` (delisted, e.g. after a
|
|
155
|
+
daily quota rejection) state, these methods raise `GenerationFailedError`
|
|
156
|
+
instead of silently returning a broken path:
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from notebooklm_easy import GenerationFailedError
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
nlm.audio(nb.id, "podcast.mp3")
|
|
163
|
+
except GenerationFailedError as e:
|
|
164
|
+
print(e.status.error, e.status.error_code) # the raw GenerationStatus is on .status
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Every other error — auth problems, rate limits, network issues, a bad
|
|
168
|
+
notebook id — is exactly whatever `notebooklm-py` itself raises (see its
|
|
169
|
+
[`exceptions.py`](https://github.com/teng-lin/notebooklm-py/blob/main/src/notebooklm/exceptions.py)),
|
|
170
|
+
unchanged. This wrapper doesn't catch or reshape those.
|
|
171
|
+
|
|
172
|
+
## Escape hatch
|
|
173
|
+
|
|
174
|
+
Anything this wrapper doesn't cover — sharing, labels, research, notes,
|
|
175
|
+
collections, settings, the `raw` backend-selected wire access, or a
|
|
176
|
+
`generate_*`/`download_*` combination not wrapped as its own method — is
|
|
177
|
+
still one call away. Every `NotebookLM` instance exposes the real async
|
|
178
|
+
client:
|
|
179
|
+
|
|
180
|
+
```python
|
|
181
|
+
with NotebookLM() as nlm:
|
|
182
|
+
# nlm.client is the actual notebooklm.NotebookLMClient — every
|
|
183
|
+
# namespace (sharing, labels, research, notes, collections, raw, ...)
|
|
184
|
+
# is there, unwrapped.
|
|
185
|
+
coro = nlm.client.sharing.set_public(nb.id, view_level="editor")
|
|
186
|
+
nlm.run(coro) # runs it on the same background loop as everything else
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**Important:** don't `await` `nlm.client.<...>(...)` yourself from a
|
|
190
|
+
different `asyncio` context (e.g. inside your own `async def`) — always go
|
|
191
|
+
through `nlm.run(coro)`. `notebooklm-py`'s client is loop-bound (it asserts
|
|
192
|
+
every call happens on the exact event loop it was opened on); `NotebookLM`
|
|
193
|
+
opens it on a dedicated background loop and `run()` is what submits work to
|
|
194
|
+
that same loop correctly. See `notebooklm_easy/_loop.py` for the full
|
|
195
|
+
rationale.
|
|
196
|
+
|
|
197
|
+
If your program is *already* async end-to-end, skip this wrapper entirely
|
|
198
|
+
and use `notebooklm.NotebookLMClient` directly — that's what it's for.
|
|
199
|
+
|
|
200
|
+
## Using this inside a bot (or any other async app)
|
|
201
|
+
|
|
202
|
+
`NotebookLM` is synchronous — every method blocks the calling thread until
|
|
203
|
+
it's done (`ask()` for a few seconds, a generated artifact for a minute or
|
|
204
|
+
more). That's fine in a plain script, but calling it directly inside an
|
|
205
|
+
`async def` handler of an asyncio-based bot (kurigram/pyrogram, aiogram,
|
|
206
|
+
discord.py, ...) freezes that bot's entire event loop — every other
|
|
207
|
+
chat/user — for as long as the call takes, since asyncio is single-threaded.
|
|
208
|
+
|
|
209
|
+
The fix: create **one** `NotebookLM()` for the whole bot process (not one
|
|
210
|
+
per request — see why in the example), and reach every blocking call
|
|
211
|
+
through `asyncio.to_thread(...)` from inside your `async def` handlers, so
|
|
212
|
+
it runs on a worker thread instead of the bot's event loop.
|
|
213
|
+
`examples/telegram_notes_bot.py` is a complete, runnable example of exactly
|
|
214
|
+
this: a kurigram/pyrogram bot with a `/notes <youtube_link>` command that
|
|
215
|
+
adds the video as a source and returns notes, done correctly.
|
|
216
|
+
|
|
217
|
+
## Testing without live Google auth
|
|
218
|
+
|
|
219
|
+
`tests/test_smoke.py` exercises the whole wrapper (notebook get-or-create,
|
|
220
|
+
source auto-detection, bulk add, chat, one-call generate+wait+download,
|
|
221
|
+
failure handling, enum coercion, and — importantly — that every call really
|
|
222
|
+
does run on the one background loop) against the real installed
|
|
223
|
+
`notebooklm-py` types, with only `NotebookLMClient.from_storage` swapped for
|
|
224
|
+
a fake that never touches the network. Useful both as a regression test and
|
|
225
|
+
as a template if you want to unit-test your own code that uses this wrapper
|
|
226
|
+
without a live Google session.
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
pip install pytest
|
|
230
|
+
pytest tests/
|
|
231
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""notebooklm-easy — a synchronous, one-call-per-task wrapper around
|
|
2
|
+
notebooklm-py (https://github.com/teng-lin/notebooklm-py).
|
|
3
|
+
|
|
4
|
+
from notebooklm_easy import NotebookLM
|
|
5
|
+
|
|
6
|
+
with NotebookLM() as nlm:
|
|
7
|
+
nb = nlm.get_or_create_notebook("Research")
|
|
8
|
+
nlm.add_sources(nb.id, ["https://example.com"])
|
|
9
|
+
print(nlm.ask(nb.id, "Summarize this"))
|
|
10
|
+
nlm.audio(nb.id, "podcast.mp3")
|
|
11
|
+
|
|
12
|
+
See client.py's module docstring for the design rationale, and the
|
|
13
|
+
package README for the full usage guide.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .client import NotebookLM
|
|
17
|
+
from .exceptions import GenerationFailedError, NotebookLMEasyError
|
|
18
|
+
|
|
19
|
+
__all__ = ["NotebookLM", "NotebookLMEasyError", "GenerationFailedError"]
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Forgiving string -> notebooklm-py enum coercion.
|
|
2
|
+
|
|
3
|
+
notebooklm-py's ``generate_*`` methods take real ``Enum`` members
|
|
4
|
+
(``QuizDifficulty.HARD``, ``AudioFormat.DEEP_DIVE``, ...) — accurate, but it
|
|
5
|
+
means importing and remembering a dozen enum classes for what's usually a
|
|
6
|
+
single word. ``coerce()`` lets every convenience method here accept that
|
|
7
|
+
same enum member OR a plain, case-insensitive string ("hard", "deep_dive",
|
|
8
|
+
"deep-dive", "Deep Dive" all resolve to ``AudioFormat.DEEP_DIVE``) OR the
|
|
9
|
+
raw underlying value (an int, or the exact string an ``str, Enum`` uses,
|
|
10
|
+
e.g. "briefing_doc" for ``ReportFormat.BRIEFING_DOC``) — whichever is
|
|
11
|
+
easiest for the caller. ``None`` passes through unchanged so every optional
|
|
12
|
+
enum parameter keeps working un-set.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import TypeVar
|
|
19
|
+
|
|
20
|
+
_E = TypeVar("_E", bound=Enum)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def coerce(enum_cls: type[_E], value: _E | str | int | None) -> _E | None:
|
|
24
|
+
if value is None or isinstance(value, enum_cls):
|
|
25
|
+
return value
|
|
26
|
+
if isinstance(value, str):
|
|
27
|
+
key = value.strip().upper().replace(" ", "_").replace("-", "_")
|
|
28
|
+
try:
|
|
29
|
+
return enum_cls[key]
|
|
30
|
+
except KeyError:
|
|
31
|
+
pass
|
|
32
|
+
try:
|
|
33
|
+
return enum_cls(value) # value-based lookup, e.g. ReportFormat("briefing_doc")
|
|
34
|
+
except ValueError:
|
|
35
|
+
pass
|
|
36
|
+
try:
|
|
37
|
+
return enum_cls(value.lower())
|
|
38
|
+
except ValueError:
|
|
39
|
+
pass
|
|
40
|
+
else:
|
|
41
|
+
try:
|
|
42
|
+
return enum_cls(value) # e.g. a plain int for an int Enum
|
|
43
|
+
except ValueError:
|
|
44
|
+
pass
|
|
45
|
+
valid = ", ".join(m.name.lower() for m in enum_cls)
|
|
46
|
+
raise ValueError(f"Invalid {enum_cls.__name__} {value!r} — choose one of: {valid}")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A persistent background event loop that a synchronous caller can submit
|
|
3
|
+
coroutines to.
|
|
4
|
+
|
|
5
|
+
Why this exists instead of just wrapping every call in ``asyncio.run()``:
|
|
6
|
+
notebooklm-py's ``NotebookLMClient`` is explicitly loop-bound. It captures
|
|
7
|
+
the event loop it was opened on (``async with NotebookLMClient.from_storage()``)
|
|
8
|
+
and every subsequent call asserts it's running on *that same* loop
|
|
9
|
+
(see notebooklm's own ``_loop_affinity.py`` / ``_loop_bound.py`` /
|
|
10
|
+
``LoopBoundPrimitive``) — calling it from a different loop raises a
|
|
11
|
+
``RuntimeError`` rather than silently working. ``asyncio.run()`` tears down
|
|
12
|
+
and recreates a brand-new loop on every call, so a naive "just run() each
|
|
13
|
+
method" wrapper would break on the second call. Instead, this module opens
|
|
14
|
+
ONE loop in a dedicated background thread, keeps it alive for the lifetime
|
|
15
|
+
of a ``NotebookLM`` instance, and runs every coroutine on it via
|
|
16
|
+
``asyncio.run_coroutine_threadsafe`` — the client is opened on this loop
|
|
17
|
+
once, in ``NotebookLM.__init__``, and every method call is submitted to the
|
|
18
|
+
same loop for the object's whole lifetime.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import threading
|
|
25
|
+
from collections.abc import Coroutine
|
|
26
|
+
from typing import Any, TypeVar
|
|
27
|
+
|
|
28
|
+
_T = TypeVar("_T")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BackgroundLoop:
|
|
32
|
+
"""Owns one event loop running forever on a dedicated daemon thread."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self._loop = asyncio.new_event_loop()
|
|
36
|
+
self._ready = threading.Event()
|
|
37
|
+
self._thread = threading.Thread(target=self._run, name="notebooklm-easy-loop", daemon=True)
|
|
38
|
+
self._thread.start()
|
|
39
|
+
self._ready.wait()
|
|
40
|
+
|
|
41
|
+
def _run(self) -> None:
|
|
42
|
+
asyncio.set_event_loop(self._loop)
|
|
43
|
+
self._ready.set()
|
|
44
|
+
self._loop.run_forever()
|
|
45
|
+
|
|
46
|
+
def run(self, coro: Coroutine[Any, Any, _T], timeout: float | None = None) -> _T:
|
|
47
|
+
"""Submit `coro` to the background loop and block the calling
|
|
48
|
+
(synchronous) thread until it finishes. Re-raises whatever
|
|
49
|
+
exception the coroutine raised, with its original traceback."""
|
|
50
|
+
if self._loop.is_closed():
|
|
51
|
+
raise RuntimeError("This NotebookLM client is closed — create a new one.")
|
|
52
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
53
|
+
return future.result(timeout)
|
|
54
|
+
|
|
55
|
+
def close(self, timeout: float = 10.0) -> None:
|
|
56
|
+
if self._loop.is_closed():
|
|
57
|
+
return
|
|
58
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
59
|
+
self._thread.join(timeout=timeout)
|
|
60
|
+
self._loop.close()
|