nblm_core/
models.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Serialize, Deserialize, Default)]
6#[serde(rename_all = "camelCase")]
7pub struct Notebook {
8    pub name: Option<String>,
9    pub title: String,
10    #[serde(rename = "notebookId", skip_serializing_if = "Option::is_none")]
11    pub notebook_id: Option<String>,
12    #[serde(flatten)]
13    pub extra: HashMap<String, serde_json::Value>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct NotebookRef {
19    pub notebook_id: String,
20    pub name: String,
21}
22
23#[derive(Debug, Clone, Serialize)]
24#[serde(rename_all = "camelCase")]
25pub struct CreateNotebookRequest {
26    pub title: String,
27}
28
29/// Batch delete notebooks request.
30///
31/// # Known Issues (as of 2025-10-19)
32///
33/// Despite the API being named "batchDelete" and accepting an array of names,
34/// the API returns HTTP 400 error when multiple notebook names are provided.
35/// Only single notebook deletion works (array with 1 element).
36///
37/// To delete multiple notebooks, call this API multiple times with one notebook at a time.
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub struct BatchDeleteNotebooksRequest {
40    pub names: Vec<String>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44pub struct BatchDeleteNotebooksResponse {
45    // API returns empty response or status information
46    #[serde(flatten)]
47    pub extra: HashMap<String, serde_json::Value>,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, Default)]
51#[serde(rename_all = "camelCase")]
52pub struct BatchCreateSourcesRequest {
53    #[serde(rename = "userContents")]
54    pub user_contents: Vec<UserContent>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct BatchDeleteSourcesRequest {
59    pub names: Vec<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, Default)]
63pub struct BatchDeleteSourcesResponse {
64    // API may return empty response or status information
65    #[serde(flatten)]
66    pub extra: HashMap<String, serde_json::Value>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(untagged)]
71pub enum UserContent {
72    Web {
73        #[serde(rename = "webContent")]
74        web_content: WebContent,
75    },
76    Text {
77        #[serde(rename = "textContent")]
78        text_content: TextContent,
79    },
80    GoogleDrive {
81        #[serde(rename = "googleDriveContent")]
82        google_drive_content: GoogleDriveContent,
83    },
84    Video {
85        #[serde(rename = "videoContent")]
86        video_content: VideoContent,
87    },
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, Default)]
91#[serde(rename_all = "camelCase")]
92pub struct WebContent {
93    pub url: String,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub source_name: Option<String>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, Default)]
99#[serde(rename_all = "camelCase")]
100pub struct TextContent {
101    pub content: String,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub source_name: Option<String>,
104}
105
106/// Google Drive content for adding sources.
107///
108/// # Known Issues
109///
110/// **WARNING**: As of 2025-10-19, the NotebookLM API returns HTTP 500 Internal Server Error
111/// when attempting to add Google Drive sources. This functionality is currently unavailable.
112/// The error occurs even with proper authentication (`gcloud auth login --enable-gdrive-access`)
113/// and correct IAM permissions. No detailed error information is provided in API responses or
114/// Google Cloud logs, indicating a server-side issue with the NotebookLM API.
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116#[serde(rename_all = "camelCase")]
117pub struct GoogleDriveContent {
118    pub document_id: String,
119    pub mime_type: String,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub source_name: Option<String>,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize, Default)]
125pub struct VideoContent {
126    #[serde(rename = "youtubeUrl")]
127    pub url: String,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, Default)]
131#[serde(rename_all = "camelCase")]
132pub struct BatchCreateSourcesResponse {
133    #[serde(default)]
134    pub sources: Vec<SourceResult>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub error_count: Option<i32>,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, Default)]
140#[serde(rename_all = "camelCase")]
141pub struct SourceResult {
142    pub url: Option<String>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub name: Option<String>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub status: Option<String>,
147    #[serde(flatten)]
148    pub extra: HashMap<String, serde_json::Value>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, Default)]
152#[serde(rename_all = "camelCase")]
153pub struct ShareRequest {
154    pub account_and_roles: Vec<AccountRole>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize, Default)]
158#[serde(rename_all = "camelCase")]
159pub struct AccountRole {
160    pub email: String,
161    pub role: ProjectRole,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, Default)]
165#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
166pub enum ProjectRole {
167    ProjectRoleOwner,
168    ProjectRoleWriter,
169    #[default]
170    ProjectRoleReader,
171    ProjectRoleNotShared,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, Default)]
175#[serde(rename_all = "camelCase")]
176pub struct ShareResponse {
177    #[serde(default)]
178    pub granted: Option<i32>,
179    #[serde(flatten)]
180    pub extra: HashMap<String, serde_json::Value>,
181}
182
183/// Response from list recently viewed notebooks API.
184///
185/// Note: The `next_page_token` field is defined for future compatibility,
186/// but the NotebookLM API does not currently implement pagination and
187/// never returns this field in responses.
188#[derive(Debug, Clone, Serialize, Deserialize, Default)]
189#[serde(rename_all = "camelCase")]
190pub struct ListRecentlyViewedResponse {
191    #[serde(default)]
192    pub notebooks: Vec<serde_json::Value>,
193    /// Pagination token (not currently implemented by NotebookLM API)
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub next_page_token: Option<String>,
196}
197
198/// Audio Overview creation request.
199///
200/// # Known Issues (as of 2025-10-19)
201///
202/// Despite the API documentation mentioning fields like `sourceIds`, `episodeFocus`,
203/// and `languageCode`, the actual API only accepts an empty request body `{}`.
204/// Any fields sent result in "Unknown name" errors.
205/// These configuration options are likely set through the NotebookLM UI after creation.
206///
207/// The fields below are commented out but kept for future compatibility if the API
208/// implements them.
209#[derive(Debug, Clone, Serialize, Deserialize, Default)]
210pub struct AudioOverviewRequest {
211    // TODO: Uncomment when API supports these fields
212    // #[serde(skip_serializing_if = "Option::is_none", rename = "sourceIds")]
213    // pub source_ids: Option<Vec<SourceId>>,
214    // #[serde(skip_serializing_if = "Option::is_none", rename = "episodeFocus")]
215    // pub episode_focus: Option<String>,
216    // #[serde(skip_serializing_if = "Option::is_none", rename = "languageCode")]
217    // pub language_code: Option<String>,
218}
219
220// TODO: Uncomment when API supports sourceIds field
221// #[derive(Debug, Clone, Serialize, Deserialize)]
222// pub struct SourceId {
223//     pub id: String,
224// }
225
226#[derive(Debug, Clone, Serialize, Deserialize, Default)]
227#[serde(rename_all = "camelCase")]
228pub struct AudioOverviewResponse {
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub name: Option<String>,
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub state: Option<String>,
233    #[serde(flatten)]
234    pub extra: HashMap<String, serde_json::Value>,
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn user_content_untagged_web() {
243        let json = r#"{"webContent":{"url":"https://example.com"}}"#;
244        let content: UserContent = serde_json::from_str(json).unwrap();
245        match content {
246            UserContent::Web { web_content } => {
247                assert_eq!(web_content.url, "https://example.com");
248            }
249            _ => panic!("expected Web variant"),
250        }
251    }
252
253    #[test]
254    fn user_content_untagged_text() {
255        let json = r#"{"textContent":{"content":"sample text"}}"#;
256        let content: UserContent = serde_json::from_str(json).unwrap();
257        match content {
258            UserContent::Text { text_content } => {
259                assert_eq!(text_content.content, "sample text");
260            }
261            _ => panic!("expected Text variant"),
262        }
263    }
264
265    #[test]
266    fn user_content_untagged_google_drive() {
267        let json = r#"{"googleDriveContent":{"documentId":"123","mimeType":"application/vnd.google-apps.document"}}"#;
268        let content: UserContent = serde_json::from_str(json).unwrap();
269        match content {
270            UserContent::GoogleDrive {
271                google_drive_content,
272            } => {
273                assert_eq!(google_drive_content.document_id, "123");
274                assert_eq!(
275                    google_drive_content.mime_type,
276                    "application/vnd.google-apps.document"
277                );
278            }
279            _ => panic!("expected GoogleDrive variant"),
280        }
281    }
282
283    #[test]
284    fn user_content_untagged_video() {
285        let json = r#"{"videoContent":{"youtubeUrl":"https://youtube.com/watch?v=123"}}"#;
286        let content: UserContent = serde_json::from_str(json).unwrap();
287        match content {
288            UserContent::Video { video_content } => {
289                assert_eq!(video_content.url, "https://youtube.com/watch?v=123");
290            }
291            _ => panic!("expected Video variant"),
292        }
293    }
294
295    #[test]
296    fn user_content_video_serializes_correctly() {
297        let content = UserContent::Video {
298            video_content: VideoContent {
299                url: "https://youtube.com/watch?v=123".to_string(),
300            },
301        };
302        let json = serde_json::to_string(&content).unwrap();
303        assert!(
304            json.contains("videoContent"),
305            "JSON should contain videoContent, got: {}",
306            json
307        );
308        assert!(
309            json.contains(r#""youtubeUrl":"https://youtube.com/watch?v=123""#),
310            "JSON should contain youtubeUrl field, got: {}",
311            json
312        );
313    }
314
315    #[test]
316    fn notebook_skips_notebook_id_when_none() {
317        let notebook = Notebook {
318            name: Some("test".to_string()),
319            title: "Test Notebook".to_string(),
320            notebook_id: None,
321            extra: Default::default(),
322        };
323        let json = serde_json::to_string(&notebook).unwrap();
324        assert!(!json.contains("notebookId"));
325    }
326
327    #[test]
328    fn notebook_includes_notebook_id_when_some() {
329        let notebook = Notebook {
330            name: Some("test".to_string()),
331            title: "Test Notebook".to_string(),
332            notebook_id: Some("nb123".to_string()),
333            extra: Default::default(),
334        };
335        let json = serde_json::to_string(&notebook).unwrap();
336        assert!(json.contains("notebookId"));
337        assert!(json.contains("nb123"));
338    }
339}