Skip to main content

mangofetch_core/models/
settings.rs

1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4// Default constants
5const 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            // Use take() to avoid cloning the Value
324            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"); // Should return default
394        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        // Create initial JSON with an extra key
405        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        // Save settings
412        let settings = AppSettings::default();
413        settings.save_to_path(&path).unwrap();
414
415        // Verify other key is preserved
416        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}