nblm_core/models/
source.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 NotebookSource {
8    #[serde(skip_serializing_if = "Option::is_none")]
9    pub metadata: Option<NotebookSourceMetadata>,
10    pub name: String,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub settings: Option<NotebookSourceSettings>,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub source_id: Option<NotebookSourceId>,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub title: Option<String>,
17    #[serde(flatten)]
18    pub extra: HashMap<String, serde_json::Value>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22#[serde(rename_all = "camelCase")]
23pub struct NotebookSourceMetadata {
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub source_added_timestamp: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub word_count: Option<u64>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub youtube_metadata: Option<NotebookSourceYoutubeMetadata>,
30    #[serde(flatten)]
31    pub extra: HashMap<String, serde_json::Value>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35#[serde(rename_all = "camelCase")]
36pub struct NotebookSourceYoutubeMetadata {
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub channel_name: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub video_id: Option<String>,
41    #[serde(flatten)]
42    pub extra: HashMap<String, serde_json::Value>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, Default)]
46#[serde(rename_all = "camelCase")]
47pub struct NotebookSourceSettings {
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub status: Option<String>,
50    #[serde(flatten)]
51    pub extra: HashMap<String, serde_json::Value>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55#[serde(rename_all = "camelCase")]
56pub struct NotebookSourceId {
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub id: Option<String>,
59    #[serde(flatten)]
60    pub extra: HashMap<String, serde_json::Value>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[serde(untagged)]
65pub enum UserContent {
66    Web {
67        #[serde(rename = "webContent")]
68        web_content: WebContent,
69    },
70    Text {
71        #[serde(rename = "textContent")]
72        text_content: TextContent,
73    },
74    GoogleDrive {
75        #[serde(rename = "googleDriveContent")]
76        google_drive_content: GoogleDriveContent,
77    },
78    Video {
79        #[serde(rename = "videoContent")]
80        video_content: VideoContent,
81    },
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85#[serde(rename_all = "camelCase")]
86pub struct WebContent {
87    pub url: String,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub source_name: Option<String>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, Default)]
93#[serde(rename_all = "camelCase")]
94pub struct TextContent {
95    pub content: String,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub source_name: Option<String>,
98}
99
100/// Google Drive content for adding sources.
101///
102/// # Known Issues
103///
104/// **WARNING**: As of 2025-10-19, the NotebookLM API returns HTTP 500 Internal Server Error
105/// when attempting to add Google Drive sources. This functionality is currently unavailable.
106/// The error occurs even with proper authentication (`gcloud auth login --enable-gdrive-access`)
107/// and correct IAM permissions. No detailed error information is provided in API responses or
108/// Google Cloud logs, indicating a server-side issue with the NotebookLM API.
109#[derive(Debug, Clone, Serialize, Deserialize, Default)]
110#[serde(rename_all = "camelCase")]
111pub struct GoogleDriveContent {
112    pub document_id: String,
113    pub mime_type: String,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub source_name: Option<String>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, Default)]
119pub struct VideoContent {
120    #[serde(rename = "youtubeUrl")]
121    pub url: String,
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn user_content_untagged_web() {
130        let json = r#"{"webContent":{"url":"https://example.com"}}"#;
131        let content: UserContent = serde_json::from_str(json).unwrap();
132        match content {
133            UserContent::Web { web_content } => {
134                assert_eq!(web_content.url, "https://example.com");
135            }
136            _ => panic!("expected Web variant"),
137        }
138    }
139
140    #[test]
141    fn user_content_untagged_text() {
142        let json = r#"{"textContent":{"content":"sample text"}}"#;
143        let content: UserContent = serde_json::from_str(json).unwrap();
144        match content {
145            UserContent::Text { text_content } => {
146                assert_eq!(text_content.content, "sample text");
147            }
148            _ => panic!("expected Text variant"),
149        }
150    }
151
152    #[test]
153    fn user_content_untagged_google_drive() {
154        let json = r#"{"googleDriveContent":{"documentId":"123","mimeType":"application/vnd.google-apps.document"}}"#;
155        let content: UserContent = serde_json::from_str(json).unwrap();
156        match content {
157            UserContent::GoogleDrive {
158                google_drive_content,
159            } => {
160                assert_eq!(google_drive_content.document_id, "123");
161                assert_eq!(
162                    google_drive_content.mime_type,
163                    "application/vnd.google-apps.document"
164                );
165            }
166            _ => panic!("expected GoogleDrive variant"),
167        }
168    }
169
170    #[test]
171    fn user_content_untagged_video() {
172        let json = r#"{"videoContent":{"youtubeUrl":"https://youtube.com/watch?v=123"}}"#;
173        let content: UserContent = serde_json::from_str(json).unwrap();
174        match content {
175            UserContent::Video { video_content } => {
176                assert_eq!(video_content.url, "https://youtube.com/watch?v=123");
177            }
178            _ => panic!("expected Video variant"),
179        }
180    }
181
182    #[test]
183    fn user_content_video_serializes_correctly() {
184        let content = UserContent::Video {
185            video_content: VideoContent {
186                url: "https://youtube.com/watch?v=123".to_string(),
187            },
188        };
189        let json = serde_json::to_string(&content).unwrap();
190        assert!(
191            json.contains("videoContent"),
192            "JSON should contain videoContent, got: {}",
193            json
194        );
195        assert!(
196            json.contains(r#""youtubeUrl":"https://youtube.com/watch?v=123""#),
197            "JSON should contain youtubeUrl field, got: {}",
198            json
199        );
200    }
201}