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