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    #[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            // Use take() to avoid cloning the Value
345            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"); // Should return default
415        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        // Create initial JSON with an extra key
426        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        // Save settings
433        let settings = AppSettings::default();
434        settings.save_to_path(&path).unwrap();
435
436        // Verify other key is preserved
437        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}