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.
@@ -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()