Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/notebooklm/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ async def open(self) -> None:
self._http_client = httpx.AsyncClient(
headers={
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"Cookie": self.auth.cookie_header,
},
cookies=self.auth.cookies,
timeout=timeout,
)

Expand Down Expand Up @@ -171,7 +171,8 @@ def update_auth_headers(self) -> None:
"""
if not self._http_client:
raise RuntimeError("Client not initialized. Use 'async with' context.")
self._http_client.headers["Cookie"] = self.auth.cookie_header
self._http_client.cookies.clear()
self._http_client.cookies.update(self.auth.cookies)

Comment on lines +174 to 176
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Clearing the cookie jar here will discard any session cookies or updates received from the server during previous requests (including the refresh request itself) that haven't been manually synced back to self.auth.cookies. Since self.auth.cookies is a simple dictionary, it doesn't automatically track changes in the httpx cookie jar. To maintain session continuity, consider updating self.auth.cookies from the jar before resetting, or simply updating the jar without clearing it.

Suggested change
self._http_client.cookies.clear()
self._http_client.cookies.update(self.auth.cookies)
self.auth.cookies.update(self._http_client.cookies)
self._http_client.cookies.clear()
self._http_client.cookies.update(self.auth.cookies)

def _build_url(self, rpc_method: RPCMethod, source_path: str = "/") -> str:
"""Build the batchexecute URL for an RPC call.
Expand Down
13 changes: 4 additions & 9 deletions src/notebooklm/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
# Includes googleusercontent.com for authenticated media downloads
ALLOWED_COOKIE_DOMAINS = {
".google.com",
"accounts.google.com",
"notebooklm.google.com",
".googleusercontent.com",
}
Expand Down Expand Up @@ -659,15 +660,9 @@ async def fetch_tokens(cookies: dict[str, str]) -> tuple[str, str]:
ValueError: If tokens cannot be extracted from response
"""
logger.debug("Fetching CSRF and session tokens from NotebookLM")
cookie_header = "; ".join(f"{k}={v}" for k, v in cookies.items())

async with httpx.AsyncClient() as client:
response = await client.get(
"https://notebooklm.google.com/",
headers={"Cookie": cookie_header},
follow_redirects=True,
timeout=30.0,
)

async with httpx.AsyncClient(cookies=cookies, follow_redirects=True, timeout=30.0) as client:
response = await client.get("https://notebooklm.google.com/")
response.raise_for_status()

Comment on lines +664 to 667
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The httpx.AsyncClient correctly handles cookies during redirects, but any new cookies set by the server are currently lost when the client is closed. Updating the cookies dictionary ensures these updates are preserved. Additionally, when using httpx.AsyncClient, configure granular timeouts with a shorter connect timeout and a longer read timeout to improve network resilience.

Suggested change
async with httpx.AsyncClient(cookies=cookies, follow_redirects=True, timeout=30.0) as client:
response = await client.get("https://notebooklm.google.com/")
response.raise_for_status()
async with httpx.AsyncClient(cookies=cookies, follow_redirects=True, timeout=httpx.Timeout(10.0, read=60.0)) as client:
response = await client.get("https://notebooklm.google.com/")
response.raise_for_status()
cookies.update(client.cookies)
References
  1. When using httpx.AsyncClient, configure granular timeouts with a shorter connect timeout and a longer read timeout (e.g., httpx.Timeout(10.0, read=60.0)) to improve network resilience.

final_url = str(response.url)
Expand Down
10 changes: 8 additions & 2 deletions tests/unit/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ def test_extracts_all_google_domain_cookies(self):
"value": "osid_value",
"domain": "notebooklm.google.com",
},
{
"name": "ACCOUNT_CHOOSER",
"value": "chooser_value",
"domain": "accounts.google.com",
},
{"name": "OTHER", "value": "other_value", "domain": "other.com"},
]
}
Expand All @@ -78,6 +83,7 @@ def test_extracts_all_google_domain_cookies(self):
assert cookies["HSID"] == "hsid_value"
assert cookies["__Secure-1PSID"] == "secure_value"
assert cookies["OSID"] == "osid_value"
assert cookies["ACCOUNT_CHOOSER"] == "chooser_value"
assert "OTHER" not in cookies

def test_raises_if_missing_sid(self):
Expand Down Expand Up @@ -541,8 +547,8 @@ async def test_fetch_tokens_redirect_to_login(self, httpx_mock: HTTPXMock):
await fetch_tokens(cookies)

@pytest.mark.asyncio
async def test_fetch_tokens_includes_cookie_header(self, httpx_mock: HTTPXMock):
"""Test that fetch_tokens includes cookie header."""
async def test_fetch_tokens_includes_cookie_jar_cookies(self, httpx_mock: HTTPXMock):
"""Test that fetch_tokens sends cookies via the client cookie jar."""
html = '"SNlM0e":"csrf" "FdrFJe":"sess"'
httpx_mock.add_response(content=html.encode())

Expand Down
39 changes: 39 additions & 0 deletions tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,45 @@ def test_client_is_connected_before_open(self, mock_auth):
# =============================================================================


class TestClientCoreCookies:
@pytest.mark.asyncio
async def test_open_uses_cookie_jar(self, mock_auth):
"""Test ClientCore initializes httpx with auth cookies in the cookie jar."""
core = ClientCore(mock_auth)

await core.open()
try:
assert core._http_client is not None
assert core._http_client.headers.get("Cookie") is None
assert core._http_client.cookies.get("SID") == "test_sid"
assert core._http_client.cookies.get("HSID") == "test_hsid"
finally:
await core.close()

@pytest.mark.asyncio
async def test_update_auth_headers_refreshes_cookie_jar(self, mock_auth):
"""Test auth refresh updates the cookie jar, not a raw Cookie header."""
core = ClientCore(mock_auth)

await core.open()
try:
core.auth = AuthTokens(
cookies={"SID": "new_sid", "SAPISID": "new_sapisid"},
csrf_token="new_csrf",
session_id="new_session",
)

core.update_auth_headers()

assert core._http_client is not None
assert core._http_client.headers.get("Cookie") is None
assert core._http_client.cookies.get("SID") == "new_sid"
assert core._http_client.cookies.get("SAPISID") == "new_sapisid"
assert core._http_client.cookies.get("HSID") is None
finally:
await core.close()


class TestClientContextManager:
@pytest.mark.asyncio
async def test_context_manager_opens_and_closes(self, mock_auth):
Expand Down
Loading