1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct AppSettings {
6 pub schema_version: u32,
7 pub appearance: AppearanceSettings,
8 pub download: DownloadSettings,
9 pub advanced: AdvancedSettings,
10 #[serde(default)]
11 pub telegram: TelegramSettings,
12 #[serde(default)]
13 pub proxy: ProxySettings,
14 #[serde(default)]
15 pub onboarding_completed: bool,
16 #[serde(default)]
17 pub start_with_windows: bool,
18 #[serde(default)]
19 pub portable_mode: bool,
20 #[serde(default)]
21 pub legal_acknowledged: bool,
22 #[serde(default)]
23 pub last_download_options: LastDownloadOptions,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct LastDownloadOptions {
28 #[serde(default)]
29 pub mode: Option<String>,
30 #[serde(default)]
31 pub quality: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct AppearanceSettings {
36 pub theme: String,
37 pub language: String,
38 #[serde(default = "default_tui_theme")]
39 pub tui_theme: String,
40 #[serde(default)]
41 pub use_nerd_fonts: bool,
42}
43
44fn default_tui_theme() -> String {
45 "mango".into()
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct DownloadSettings {
50 pub default_output_dir: PathBuf,
51 pub always_ask_path: bool,
52 pub video_quality: String,
53 pub skip_existing: bool,
54 pub download_attachments: bool,
55 pub download_descriptions: bool,
56 #[serde(default = "default_true")]
57 pub embed_metadata: bool,
58 #[serde(default = "default_true")]
59 pub embed_thumbnail: bool,
60 #[serde(default)]
61 pub clipboard_detection: bool,
62 #[serde(default = "default_filename_template")]
63 pub filename_template: String,
64 #[serde(default)]
65 pub organize_by_platform: bool,
66 #[serde(default)]
67 pub download_subtitles: bool,
68 #[serde(default)]
69 pub include_auto_subtitles: bool,
70 #[serde(default)]
71 pub translate_metadata: bool,
72 #[serde(default)]
73 pub youtube_sponsorblock: bool,
74 #[serde(default)]
75 pub split_by_chapters: bool,
76 #[serde(default)]
77 pub hotkey_enabled: bool,
78 #[serde(default = "default_hotkey_binding")]
79 pub hotkey_binding: String,
80 #[serde(default)]
81 pub extra_ytdlp_flags: Vec<String>,
82 #[serde(default = "default_true")]
83 pub copy_to_clipboard_on_hotkey: bool,
84 #[serde(default)]
85 pub cookie_file: String,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct AdvancedSettings {
90 pub max_concurrent_segments: u32,
91 pub max_retries: u32,
92 #[serde(default = "default_max_concurrent_downloads")]
93 pub max_concurrent_downloads: u32,
94 #[serde(default = "default_concurrent_fragments")]
95 pub concurrent_fragments: u32,
96 #[serde(default = "default_stagger_delay_ms")]
97 pub stagger_delay_ms: u64,
98 #[serde(default = "default_torrent_listen_port")]
99 pub torrent_listen_port: u16,
100 #[serde(default)]
101 pub cookies_from_browser: String,
102 #[serde(default)]
103 pub twitter_manual_cookie: String,
104}
105
106fn default_concurrent_fragments() -> u32 {
107 8
108}
109
110fn default_max_concurrent_downloads() -> u32 {
111 2
112}
113
114fn default_stagger_delay_ms() -> u64 {
115 150
116}
117
118fn default_torrent_listen_port() -> u16 {
119 6881
120}
121
122fn default_true() -> bool {
123 true
124}
125
126pub fn default_filename_template() -> String {
127 "%(title).200s [%(id)s].%(ext)s".into()
128}
129
130fn default_hotkey_binding() -> String {
131 "CmdOrCtrl+Shift+D".into()
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct TelegramSettings {
136 pub concurrent_downloads: u32,
137 pub fix_file_extensions: bool,
138}
139
140impl Default for TelegramSettings {
141 fn default() -> Self {
142 Self {
143 concurrent_downloads: 3,
144 fix_file_extensions: true,
145 }
146 }
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize, Default)]
150pub struct ProxySettings {
151 #[serde(default)]
152 pub enabled: bool,
153 #[serde(default = "default_proxy_type")]
154 pub proxy_type: String,
155 #[serde(default)]
156 pub host: String,
157 #[serde(default = "default_proxy_port")]
158 pub port: u16,
159 #[serde(default)]
160 pub username: String,
161 #[serde(default)]
162 pub password: String,
163}
164
165fn default_proxy_type() -> String {
166 "http".into()
167}
168
169fn default_proxy_port() -> u16 {
170 8080
171}
172
173impl Default for AppSettings {
174 fn default() -> Self {
175 Self {
176 schema_version: 1,
177 appearance: AppearanceSettings {
178 theme: "system".into(),
179 language: "en".into(),
180 tui_theme: "mango".into(),
181 use_nerd_fonts: false,
182 },
183 download: DownloadSettings {
184 default_output_dir: dirs::download_dir().unwrap_or_else(|| PathBuf::from(".")),
185 always_ask_path: true,
186 video_quality: "720p".into(),
187 skip_existing: true,
188 download_attachments: true,
189 download_descriptions: true,
190 embed_metadata: true,
191 embed_thumbnail: true,
192 clipboard_detection: false,
193 filename_template: default_filename_template(),
194 organize_by_platform: false,
195 download_subtitles: false,
196 include_auto_subtitles: false,
197 translate_metadata: false,
198 youtube_sponsorblock: false,
199 split_by_chapters: false,
200 hotkey_enabled: false,
201 hotkey_binding: default_hotkey_binding(),
202 extra_ytdlp_flags: Vec::new(),
203 copy_to_clipboard_on_hotkey: true,
204 cookie_file: String::new(),
205 },
206 advanced: AdvancedSettings {
207 max_concurrent_segments: 20,
208 max_retries: 3,
209 max_concurrent_downloads: 2,
210 concurrent_fragments: 8,
211 stagger_delay_ms: 150,
212 torrent_listen_port: 6881,
213 cookies_from_browser: String::new(),
214 twitter_manual_cookie: String::new(),
215 },
216 telegram: TelegramSettings::default(),
217 proxy: ProxySettings::default(),
218 onboarding_completed: false,
219 start_with_windows: false,
220 portable_mode: false,
221 legal_acknowledged: false,
222 last_download_options: LastDownloadOptions::default(),
223 }
224 }
225}
226
227impl AppSettings {
228 pub fn load_from_disk() -> Self {
229 let data_dir = match crate::core::paths::app_data_dir() {
230 Some(d) => d,
231 None => return Self::default(),
232 };
233 Self::load_from_path(&data_dir.join("settings.json"))
234 }
235
236 pub fn load_from_path(store_path: &std::path::Path) -> Self {
237 let content = match std::fs::read_to_string(store_path) {
238 Ok(c) => c,
239 Err(_) => return Self::default(),
240 };
241 let json: serde_json::Value = match serde_json::from_str(&content) {
242 Ok(v) => v,
243 Err(_) => return Self::default(),
244 };
245 match json.get("app_settings") {
246 Some(val) => serde_json::from_value::<Self>(val.clone()).unwrap_or_default(),
247 None => Self::default(),
248 }
249 }
250
251 pub fn save_to_disk(&self) -> anyhow::Result<()> {
252 let data_dir = crate::core::paths::app_data_dir()
253 .ok_or_else(|| anyhow::anyhow!("Could not find app data dir"))?;
254 self.save_to_path(&data_dir.join("settings.json"))
255 }
256
257 pub fn save_to_path(&self, store_path: &std::path::Path) -> anyhow::Result<()> {
258 let mut json = if store_path.exists() {
259 let content = std::fs::read_to_string(store_path)?;
260 serde_json::from_str::<serde_json::Value>(&content).unwrap_or(serde_json::json!({}))
261 } else {
262 serde_json::json!({})
263 };
264
265 if let Some(obj) = json.as_object_mut() {
266 obj.insert("app_settings".to_string(), serde_json::to_value(self)?);
267 }
268
269 let serialized = serde_json::to_string_pretty(&json)?;
270 std::fs::write(store_path, serialized)?;
271 Ok(())
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use std::fs;
279
280 #[test]
281 fn test_load_default_when_no_file() {
282 let uuid = uuid::Uuid::new_v4().to_string();
283 let path = std::env::temp_dir().join(&uuid).join("settings.json");
284 let settings = AppSettings::load_from_path(&path);
285 assert_eq!(settings.appearance.theme, "system");
286 }
287
288 #[test]
289 fn test_save_and_load_settings() {
290 let uuid = uuid::Uuid::new_v4().to_string();
291 let dir = std::env::temp_dir().join(&uuid);
292 fs::create_dir_all(&dir).unwrap();
293 let path = dir.join("settings.json");
294
295 let mut settings = AppSettings::default();
296 settings.appearance.theme = "dark".into();
297 settings.save_to_path(&path).unwrap();
298
299 let loaded = AppSettings::load_from_path(&path);
300 assert_eq!(loaded.appearance.theme, "dark");
301 let _ = fs::remove_dir_all(dir);
302 }
303
304 #[test]
305 fn test_load_invalid_json() {
306 let uuid = uuid::Uuid::new_v4().to_string();
307 let dir = std::env::temp_dir().join(&uuid);
308 fs::create_dir_all(&dir).unwrap();
309 let path = dir.join("settings.json");
310
311 fs::write(&path, "{ invalid json }").unwrap();
312
313 let settings = AppSettings::load_from_path(&path);
314 assert_eq!(settings.appearance.theme, "system"); let _ = fs::remove_dir_all(dir);
316 }
317
318 #[test]
319 fn test_save_preserves_other_keys() {
320 let uuid = uuid::Uuid::new_v4().to_string();
321 let dir = std::env::temp_dir().join(&uuid);
322 fs::create_dir_all(&dir).unwrap();
323 let path = dir.join("settings.json");
324
325 let initial_json = serde_json::json!({
327 "other_plugin_data": { "key": "value" },
328 "app_settings": {}
329 });
330 fs::write(&path, serde_json::to_string(&initial_json).unwrap()).unwrap();
331
332 let settings = AppSettings::default();
334 settings.save_to_path(&path).unwrap();
335
336 let content = fs::read_to_string(&path).unwrap();
338 let saved_json: serde_json::Value = serde_json::from_str(&content).unwrap();
339
340 assert!(saved_json.get("other_plugin_data").is_some());
341 assert_eq!(saved_json["other_plugin_data"]["key"], "value");
342 assert!(saved_json.get("app_settings").is_some());
343 let _ = fs::remove_dir_all(dir);
344 }
345}