Metadata-Version: 2.4
Name: notebooklm-easy
Version: 0.1.0
Summary: A synchronous, one-call-per-task convenience wrapper around notebooklm-py
License: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: notebooklm-py<0.9,>=0.8.2
Provides-Extra: browser
Requires-Dist: notebooklm-py[browser]; extra == "browser"
Provides-Extra: cookies
Requires-Dist: notebooklm-py[cookies]; extra == "cookies"
Provides-Extra: headless
Requires-Dist: notebooklm-py[headless]; extra == "headless"

# notebooklm-easy

A synchronous, one-call-per-task convenience wrapper around
[`notebooklm-py`](https://github.com/teng-lin/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:

1. **No `async`/`await`.** Every method is a normal blocking call. You don't
   write `async def main(): ...; asyncio.run(main())` at all.
2. **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](#escape-hatch)).

## Install

```bash
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](https://github.com/teng-lin/notebooklm-py/blob/main/docs/installation.md)
for the full picture):

```bash
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

```python
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:

```python
with NotebookLM(profile="work") as nlm:
    ...
```

## Notebooks

```python
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

```python
# 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

```python
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.

```python
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:

```python
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`](https://github.com/teng-lin/notebooklm-py/blob/main/src/notebooklm/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:

```python
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.

```bash
pip install pytest
pytest tests/
```
