Skip to main content

mangofetch_core/models/
settings.rs

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"); // Should return default
315        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        // Create initial JSON with an extra key
326        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        // Save settings
333        let settings = AppSettings::default();
334        settings.save_to_path(&path).unwrap();
335
336        // Verify other key is preserved
337        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}