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 #[serde(default = "default_video_format")]
108 pub video_format: String,
109 #[serde(default = "default_audio_format")]
110 pub audio_format: String,
111 #[serde(default = "default_audio_quality")]
112 pub audio_quality: String,
113 pub skip_existing: bool,
114 pub download_attachments: bool,
115 pub download_descriptions: bool,
116 #[serde(default = "default_true")]
117 pub embed_metadata: bool,
118 #[serde(default = "default_true")]
119 pub embed_thumbnail: bool,
120 #[serde(default)]
121 pub clipboard_detection: bool,
122 #[serde(default = "default_filename_template")]
123 pub filename_template: String,
124 #[serde(default)]
125 pub organize_by_platform: bool,
126 #[serde(default)]
127 pub download_subtitles: bool,
128 #[serde(default)]
129 pub include_auto_subtitles: bool,
130 #[serde(default)]
131 pub translate_metadata: bool,
132 #[serde(default)]
133 pub youtube_sponsorblock: bool,
134 #[serde(default)]
135 pub split_by_chapters: bool,
136 #[serde(default)]
137 pub hotkey_enabled: bool,
138 #[serde(default = "default_true")]
139 pub always_ask_confirm: bool,
140 #[serde(default = "default_hotkey_binding")]
141 pub hotkey_binding: String,
142 #[serde(default)]
143 pub extra_ytdlp_flags: Vec<String>,
144 #[serde(default = "default_true")]
145 pub copy_to_clipboard_on_hotkey: bool,
146 #[serde(default)]
147 pub cookie_file: String,
148}
149
150impl Default for DownloadSettings {
151 fn default() -> Self {
152 Self {
153 default_output_dir: dirs::download_dir().unwrap_or_else(|| PathBuf::from(".")),
154 always_ask_path: true,
155 always_ask_confirm: true,
156 video_quality: DEFAULT_VIDEO_QUALITY.into(),
157 video_format: default_video_format(),
158 audio_format: default_audio_format(),
159 audio_quality: default_audio_quality(),
160 skip_existing: true,
161 download_attachments: true,
162 download_descriptions: true,
163 embed_metadata: true,
164 embed_thumbnail: true,
165 clipboard_detection: false,
166 filename_template: DEFAULT_FILENAME_TEMPLATE.into(),
167 organize_by_platform: false,
168 download_subtitles: false,
169 include_auto_subtitles: false,
170 translate_metadata: false,
171 youtube_sponsorblock: false,
172 split_by_chapters: false,
173 hotkey_enabled: false,
174 hotkey_binding: DEFAULT_HOTKEY_BINDING.into(),
175 extra_ytdlp_flags: Vec::new(),
176 copy_to_clipboard_on_hotkey: true,
177 cookie_file: String::new(),
178 }
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct AdvancedSettings {
184 pub max_concurrent_segments: u32,
185 pub max_retries: u32,
186 #[serde(default = "default_max_concurrent_downloads")]
187 pub max_concurrent_downloads: u32,
188 #[serde(default = "default_concurrent_fragments")]
189 pub concurrent_fragments: u32,
190 #[serde(default = "default_stagger_delay_ms")]
191 pub stagger_delay_ms: u64,
192 #[serde(default = "default_torrent_listen_port")]
193 pub torrent_listen_port: u16,
194 #[serde(default)]
195 pub cookies_from_browser: String,
196 #[serde(default)]
197 pub twitter_manual_cookie: String,
198}
199
200impl Default for AdvancedSettings {
201 fn default() -> Self {
202 Self {
203 max_concurrent_segments: 20,
204 max_retries: 3,
205 max_concurrent_downloads: DEFAULT_MAX_CONCURRENT_DOWNLOADS,
206 concurrent_fragments: DEFAULT_CONCURRENT_FRAGMENTS,
207 stagger_delay_ms: DEFAULT_STAGGER_DELAY_MS,
208 torrent_listen_port: DEFAULT_TORRENT_LISTEN_PORT,
209 cookies_from_browser: String::new(),
210 twitter_manual_cookie: String::new(),
211 }
212 }
213}
214
215fn default_concurrent_fragments() -> u32 {
216 DEFAULT_CONCURRENT_FRAGMENTS
217}
218
219fn default_max_concurrent_downloads() -> u32 {
220 DEFAULT_MAX_CONCURRENT_DOWNLOADS
221}
222
223fn default_stagger_delay_ms() -> u64 {
224 DEFAULT_STAGGER_DELAY_MS
225}
226
227fn default_torrent_listen_port() -> u16 {
228 DEFAULT_TORRENT_LISTEN_PORT
229}
230
231fn default_true() -> bool {
232 true
233}
234
235pub fn default_filename_template() -> String {
236 DEFAULT_FILENAME_TEMPLATE.into()
237}
238
239pub fn default_video_format() -> String {
240 "mp4".into()
241}
242
243pub fn default_audio_format() -> String {
244 "mp3".into()
245}
246
247pub fn default_audio_quality() -> String {
248 "320K".into()
249}
250
251fn default_hotkey_binding() -> String {
252 DEFAULT_HOTKEY_BINDING.into()
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct TelegramSettings {
257 pub concurrent_downloads: u32,
258 pub fix_file_extensions: bool,
259}
260
261impl Default for TelegramSettings {
262 fn default() -> Self {
263 Self {
264 concurrent_downloads: 3,
265 fix_file_extensions: true,
266 }
267 }
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize, Default)]
271pub struct ProxySettings {
272 #[serde(default)]
273 pub enabled: bool,
274 #[serde(default = "default_proxy_type")]
275 pub proxy_type: String,
276 #[serde(default)]
277 pub host: String,
278 #[serde(default = "default_proxy_port")]
279 pub port: u16,
280 #[serde(default)]
281 pub username: String,
282 #[serde(default)]
283 pub password: String,
284}
285
286impl ProxySettings {
287 fn default_internal() -> Self {
288 Self {
289 enabled: false,
290 proxy_type: DEFAULT_PROXY_TYPE.into(),
291 host: String::new(),
292 port: DEFAULT_PROXY_PORT,
293 username: String::new(),
294 password: String::new(),
295 }
296 }
297}
298
299fn default_proxy_type() -> String {
300 DEFAULT_PROXY_TYPE.into()
301}
302
303fn default_proxy_port() -> u16 {
304 DEFAULT_PROXY_PORT
305}
306
307impl Default for AppSettings {
308 fn default() -> Self {
309 Self {
310 schema_version: 1,
311 appearance: AppearanceSettings::default(),
312 download: DownloadSettings::default(),
313 advanced: AdvancedSettings::default(),
314 telegram: TelegramSettings::default(),
315 proxy: ProxySettings::default_internal(),
316 onboarding_completed: false,
317 start_with_windows: false,
318 portable_mode: false,
319 legal_acknowledged: false,
320 last_download_options: LastDownloadOptions::default(),
321 }
322 }
323}
324
325impl AppSettings {
326 pub fn load_from_disk() -> Self {
327 crate::core::paths::app_data_dir()
328 .map(|d| Self::load_from_path(&d.join("settings.json")))
329 .unwrap_or_default()
330 }
331
332 pub fn load_from_path(store_path: &Path) -> Self {
333 let content = match std::fs::read_to_string(store_path) {
334 Ok(c) => c,
335 Err(_) => return Self::default(),
336 };
337
338 let mut json: serde_json::Value = match serde_json::from_str(&content) {
339 Ok(v) => v,
340 Err(_) => return Self::default(),
341 };
342
343 if let Some(val) = json.get_mut("app_settings") {
344 serde_json::from_value::<Self>(val.take()).unwrap_or_default()
346 } else {
347 Self::default()
348 }
349 }
350
351 pub fn save_to_disk(&self) -> anyhow::Result<()> {
352 let data_dir = crate::core::paths::app_data_dir()
353 .ok_or_else(|| anyhow::anyhow!("Could not find app data dir"))?;
354 self.save_to_path(&data_dir.join("settings.json"))
355 }
356
357 pub fn save_to_path(&self, store_path: &Path) -> anyhow::Result<()> {
358 let mut json = if store_path.exists() {
359 let content = std::fs::read_to_string(store_path)?;
360 serde_json::from_str::<serde_json::Value>(&content).unwrap_or(serde_json::json!({}))
361 } else {
362 serde_json::json!({})
363 };
364
365 if let Some(obj) = json.as_object_mut() {
366 obj.insert("app_settings".to_string(), serde_json::to_value(self)?);
367 }
368
369 let serialized = serde_json::to_string_pretty(&json)?;
370 std::fs::write(store_path, serialized)?;
371 Ok(())
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use std::fs;
379
380 #[test]
381 fn test_load_default_when_no_file() {
382 let uuid = uuid::Uuid::new_v4().to_string();
383 let path = std::env::temp_dir().join(&uuid).join("settings.json");
384 let settings = AppSettings::load_from_path(&path);
385 assert_eq!(settings.appearance.theme, "system");
386 }
387
388 #[test]
389 fn test_save_and_load_settings() {
390 let uuid = uuid::Uuid::new_v4().to_string();
391 let dir = std::env::temp_dir().join(&uuid);
392 fs::create_dir_all(&dir).unwrap();
393 let path = dir.join("settings.json");
394
395 let mut settings = AppSettings::default();
396 settings.appearance.theme = "dark".into();
397 settings.save_to_path(&path).unwrap();
398
399 let loaded = AppSettings::load_from_path(&path);
400 assert_eq!(loaded.appearance.theme, "dark");
401 let _ = fs::remove_dir_all(dir);
402 }
403
404 #[test]
405 fn test_load_invalid_json() {
406 let uuid = uuid::Uuid::new_v4().to_string();
407 let dir = std::env::temp_dir().join(&uuid);
408 fs::create_dir_all(&dir).unwrap();
409 let path = dir.join("settings.json");
410
411 fs::write(&path, "{ invalid json }").unwrap();
412
413 let settings = AppSettings::load_from_path(&path);
414 assert_eq!(settings.appearance.theme, "system"); let _ = fs::remove_dir_all(dir);
416 }
417
418 #[test]
419 fn test_save_preserves_other_keys() {
420 let uuid = uuid::Uuid::new_v4().to_string();
421 let dir = std::env::temp_dir().join(&uuid);
422 fs::create_dir_all(&dir).unwrap();
423 let path = dir.join("settings.json");
424
425 let initial_json = serde_json::json!({
427 "other_plugin_data": { "key": "value" },
428 "app_settings": {}
429 });
430 fs::write(&path, serde_json::to_string(&initial_json).unwrap()).unwrap();
431
432 let settings = AppSettings::default();
434 settings.save_to_path(&path).unwrap();
435
436 let content = fs::read_to_string(&path).unwrap();
438 let saved_json: serde_json::Value = serde_json::from_str(&content).unwrap();
439
440 assert!(saved_json.get("other_plugin_data").is_some());
441 assert_eq!(saved_json["other_plugin_data"]["key"], "value");
442 assert!(saved_json.get("app_settings").is_some());
443 let _ = fs::remove_dir_all(dir);
444 }
445}