notebooklm-easy
A synchronous, one-call-per-task convenience wrapper around
notebooklm-py — the unofficial
Python API for Google NotebookLM / Gemini Notebook.
notebooklm-py itself is fully async and, for every generated artifact
(podcast, video, quiz, ...), a three-step dance: generate_*() kicks it
off, wait_for_completion() polls it, and a matching download_*() fetches
the finished file. That's the right shape for a library embedded in an
existing async app. This wrapper is for the much more common case — a plain
script — and does two things:
- No
async/await. Every method is a normal blocking call. You don't writeasync def main(): ...; asyncio.run(main())at all. - One call per artifact, not three.
nlm.audio(nb.id, "podcast.mp3")generates, waits, and downloads in a single line.
It does not reimplement anything — auth, RPC calls, polling, and downloads
are all still done by notebooklm-py itself. This is a thin facade over it.
Anything it doesn't wrap is still reachable (see Escape hatch).
Install
pip install notebooklm-py notebooklm-easy # or: pip install -e . from this folder
notebooklm-easy depends on notebooklm-py and does not vendor it — you're
always running the real, upstream library underneath.
You still need to authenticate once with notebooklm-py's own CLI (this
wrapper doesn't change auth at all — see its
Installation guide
for the full picture):
pip install "notebooklm-py[browser]"
notebooklm login # opens a browser once
notebooklm auth check --test --json # confirms it worked
After that, notebooklm_easy.NotebookLM() (no arguments) picks up the saved
session automatically, exactly like NotebookLMClient.from_storage() does.
Quick start
from notebooklm_easy import NotebookLM
with NotebookLM() as nlm:
# Reuses a notebook with this exact title if one exists, else creates it
nb = nlm.get_or_create_notebook("Research")
# Add sources — URLs and local file paths, auto-detected, uploaded
# concurrently, and waited-on by default so the next call can use them
nlm.add_sources(nb.id, [
"https://en.wikipedia.org/wiki/Artificial_intelligence",
"./paper.pdf",
])
# Ask a question — returns just the answer text
print(nlm.ask(nb.id, "What are the key themes?"))
# Generate + wait + download, in one call each
nlm.audio(nb.id, "podcast.mp3", instructions="make it engaging")
nlm.quiz(nb.id, "quiz.json", difficulty="hard")
nlm.report(nb.id, "briefing.md", format="briefing_doc")
That's the whole shape of the library: a NotebookLM object, used as a
context manager (or with explicit .close()), with one method per thing
you want to do.
Auth / multiple accounts
NotebookLM(...) accepts every keyword NotebookLMClient.from_storage()
does, and passes them straight through — most commonly profile for
switching between notebooklm login --profile <name> accounts, and
backend="android" to use the Android gRPC backend instead of the default
web one:
with NotebookLM(profile="work") as nlm:
...
Notebooks
nlm.create_notebook("Title") # always creates a new one
nlm.get_or_create_notebook("Title") # reuses an existing notebook with this exact title
nlm.list_notebooks() # -> list[Notebook]
nlm.delete_notebook(notebook_id)
Sources
# One source — auto-detects URL (http/https, including YouTube) vs. an
# existing local file path (PDF, text, Markdown, Word, EPUB, audio, video,
# image — whatever notebooklm-py's add_file supports).
nlm.add_source(nb.id, "https://example.com")
nlm.add_source(nb.id, "./report.pdf")
# Several at once, uploaded concurrently, in the same order back:
sources = nlm.add_sources(nb.id, ["https://a.example", "./b.pdf", "https://youtube.com/watch?v=..."])
# Raw pasted text has no URL/path to auto-detect, so it's its own method:
nlm.add_text(nb.id, "My Notes", "... text content ...")
All three default to wait=True — they block until NotebookLM finishes
processing the source, so the very next ask() or generate_* call sees
it. Pass wait=False to fire-and-forget instead (matching
notebooklm-py's own default).
Chat
answer = nlm.ask(nb.id, "Summarize this") # -> str, just the answer
result = nlm.ask_full(nb.id, "Summarize this") # -> AskResult (citations, conversation_id, ...)
Generating content
One method per artifact type. Each does generate_* -> wait_for_completion
-> download_* and returns the path it wrote to. Every enum-typed option
(format, difficulty, style, ...) accepts a plain, case-insensitive
string as well as the real notebooklm.types enum member — "hard" and
QuizDifficulty.HARD both work.
nlm.audio(nb.id, "podcast.mp3", instructions="make it fun", format="deep_dive", length="short")
nlm.video(nb.id, "overview.mp4", format="explainer", style="whiteboard")
nlm.cinematic_video(nb.id, "documentary.mp4", instructions="documentary-style summary")
nlm.report(nb.id, "briefing.md", format="briefing_doc") # or study_guide / blog_post / concept_explanation / custom
nlm.study_guide(nb.id, "study_guide.md")
nlm.quiz(nb.id, "quiz.json", difficulty="hard", quantity="more", output_format="json") # or markdown / html
nlm.flashcards(nb.id, "cards.json", difficulty="easy")
nlm.slide_deck(nb.id, "slides.pdf", format="presenter_slides", output_format="pdf") # or pptx
nlm.infographic(nb.id, "infographic.png", orientation="portrait", style="bento_grid")
nlm.data_table(nb.id, "data.csv", instructions="compare key concepts")
nlm.mind_map(nb.id, "mindmap.json", kind="interactive") # or "note_backed"
Every one of these takes source_ids=[...] to scope generation to specific
sources (default: all of them), and timeout=<seconds> to override how
long it waits before giving up (defaults are generous — 300s for most
types, 600s for video/slide-deck/audio, which tend to run longer).
Handling failure
If generation finishes in a failed or removed (delisted, e.g. after a
daily quota rejection) state, these methods raise GenerationFailedError
instead of silently returning a broken path:
from notebooklm_easy import GenerationFailedError
try:
nlm.audio(nb.id, "podcast.mp3")
except GenerationFailedError as e:
print(e.status.error, e.status.error_code) # the raw GenerationStatus is on .status
Every other error — auth problems, rate limits, network issues, a bad
notebook id — is exactly whatever notebooklm-py itself raises (see its
exceptions.py),
unchanged. This wrapper doesn't catch or reshape those.
Escape hatch
Anything this wrapper doesn't cover — sharing, labels, research, notes,
collections, settings, the raw backend-selected wire access, or a
generate_*/download_* combination not wrapped as its own method — is
still one call away. Every NotebookLM instance exposes the real async
client:
with NotebookLM() as nlm:
# nlm.client is the actual notebooklm.NotebookLMClient — every
# namespace (sharing, labels, research, notes, collections, raw, ...)
# is there, unwrapped.
coro = nlm.client.sharing.set_public(nb.id, view_level="editor")
nlm.run(coro) # runs it on the same background loop as everything else
Important: don't await nlm.client.<...>(...) yourself from a
different asyncio context (e.g. inside your own async def) — always go
through nlm.run(coro). notebooklm-py's client is loop-bound (it asserts
every call happens on the exact event loop it was opened on); NotebookLM
opens it on a dedicated background loop and run() is what submits work to
that same loop correctly. See notebooklm_easy/_loop.py for the full
rationale.
If your program is already async end-to-end, skip this wrapper entirely
and use notebooklm.NotebookLMClient directly — that's what it's for.
Using this inside a bot (or any other async app)
NotebookLM is synchronous — every method blocks the calling thread until
it's done (ask() for a few seconds, a generated artifact for a minute or
more). That's fine in a plain script, but calling it directly inside an
async def handler of an asyncio-based bot (kurigram/pyrogram, aiogram,
discord.py, ...) freezes that bot's entire event loop — every other
chat/user — for as long as the call takes, since asyncio is single-threaded.
The fix: create one NotebookLM() for the whole bot process (not one
per request — see why in the example), and reach every blocking call
through asyncio.to_thread(...) from inside your async def handlers, so
it runs on a worker thread instead of the bot's event loop.
examples/telegram_notes_bot.py is a complete, runnable example of exactly
this: a kurigram/pyrogram bot with a /notes <youtube_link> command that
adds the video as a source and returns notes, done correctly.
Testing without live Google auth
tests/test_smoke.py exercises the whole wrapper (notebook get-or-create,
source auto-detection, bulk add, chat, one-call generate+wait+download,
failure handling, enum coercion, and — importantly — that every call really
does run on the one background loop) against the real installed
notebooklm-py types, with only NotebookLMClient.from_storage swapped for
a fake that never touches the network. Useful both as a regression test and
as a template if you want to unit-test your own code that uses this wrapper
without a live Google session.
pip install pytest
pytest tests/
Metadata
Release files for notebooklm-easy 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| notebooklm_easy-0.1.0.tar.gz | 18.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| notebooklm_easy-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 32.3 kB
Release files / notebooklm_easy-0.1.0.tar.gz
| Download URL | notebooklm_easy-0.1.0.tar.gz |
|---|---|
| Size | 18.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bed4dae62b6117fa2bb1e91b96670307726f8e563a8bc5d80d3322fa527dd0b4
|
|
BLAKE2b-256 checksum How to use checksums |
f02bc01b08fbf8c0225479ad9eefca08ca6d238da4a9bca751f02e4c4b969a42
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.11.5
|
Release files / notebooklm_easy-0.1.0-py3-none-any.whl
| Download URL | notebooklm_easy-0.1.0-py3-none-any.whl |
|---|---|
| Size | 13.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
74c1b51509957c63ff874bcb61898b08daa2e67a45aff127f1fcd0048a22be7f
|
|
BLAKE2b-256 checksum How to use checksums |
8d620bc49bb4a2684023686b309cd0d89237766dd7f9a8e062db1a21152738e3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.1.0 CPython/3.11.5
|