|
| 1 | +use crate::backup::archive::create_7z_archive; |
| 2 | +use crate::backup::common::{ |
| 3 | + BackupOptions, BackupResult, cleanup_auto_backup_files, resolve_backup_dir, |
| 4 | +}; |
| 5 | +use sea_orm::DatabaseConnection; |
| 6 | +use std::fs; |
| 7 | +use std::path::Path; |
| 8 | +use tauri::{State, command}; |
| 9 | + |
| 10 | +/// 备份所有自定义封面(仅自定义封面,不含云端缓存) |
| 11 | +/// |
| 12 | +/// 将自定义封面文件复制到临时目录后压缩为 7z 文件, |
| 13 | +/// 备份路径跟随数据库备份路径逻辑。 |
| 14 | +/// |
| 15 | +/// 压缩包内结构(解压后直接覆盖到数据目录即可): |
| 16 | +/// ```text |
| 17 | +/// covers/ |
| 18 | +/// game_123/ |
| 19 | +/// cover_123_jpg_1703123456789.jpg |
| 20 | +/// game_456/ |
| 21 | +/// cover_456_png_1703123456789.png |
| 22 | +/// ``` |
| 23 | +#[command] |
| 24 | +pub async fn backup_custom_covers( |
| 25 | + db: State<'_, DatabaseConnection>, |
| 26 | + options: Option<BackupOptions>, |
| 27 | +) -> Result<BackupResult, String> { |
| 28 | + let options = options.unwrap_or_default(); |
| 29 | + let result = backup_custom_covers_archive(&db, options.auto).await?; |
| 30 | + |
| 31 | + if options.auto |
| 32 | + && let Some(max_auto_backups) = options.max_auto_backups |
| 33 | + { |
| 34 | + let backup_dir = resolve_backup_dir(&db).await?; |
| 35 | + if let Err(e) = |
| 36 | + cleanup_auto_backup_files(&backup_dir, "custom_covers_auto_", ".7z", max_auto_backups) |
| 37 | + { |
| 38 | + log::warn!("清理旧自定义封面自动备份失败: {}", e); |
| 39 | + } |
| 40 | + } |
| 41 | + |
| 42 | + Ok(result) |
| 43 | +} |
| 44 | + |
| 45 | +pub async fn backup_custom_covers_archive( |
| 46 | + db: &DatabaseConnection, |
| 47 | + auto: bool, |
| 48 | +) -> Result<BackupResult, String> { |
| 49 | + // 1. 获取封面根目录 |
| 50 | + let covers_dir = reina_path::get_base_data_dir()?.join("covers"); |
| 51 | + if !covers_dir.exists() { |
| 52 | + return Ok(BackupResult { |
| 53 | + success: true, |
| 54 | + path: None, |
| 55 | + message: "没有自定义封面需要备份".to_string(), |
| 56 | + }); |
| 57 | + } |
| 58 | + |
| 59 | + // 2. 创建临时目录,内层套 covers 文件夹方便用户直接覆盖 |
| 60 | + let timestamp = chrono::Local::now().timestamp_millis(); |
| 61 | + let temp_dir = std::env::temp_dir().join(format!("reina_covers_{}", timestamp)); |
| 62 | + let covers_temp_dir = temp_dir.join("covers"); |
| 63 | + |
| 64 | + // 3. 遍历 covers 目录,仅复制自定义封面文件 |
| 65 | + let mut has_covers = false; |
| 66 | + let scan_result = scan_and_copy_custom_covers(&covers_dir, &covers_temp_dir, &mut has_covers); |
| 67 | + |
| 68 | + // 扫描失败时清理临时目录 |
| 69 | + if let Err(e) = scan_result { |
| 70 | + fs::remove_dir_all(&temp_dir).ok(); |
| 71 | + return Err(e); |
| 72 | + } |
| 73 | + |
| 74 | + if !has_covers { |
| 75 | + fs::remove_dir_all(&temp_dir).ok(); |
| 76 | + return Ok(BackupResult { |
| 77 | + success: true, |
| 78 | + path: None, |
| 79 | + message: "没有自定义封面需要备份".to_string(), |
| 80 | + }); |
| 81 | + } |
| 82 | + |
| 83 | + // 4. 压缩为 7z 文件 |
| 84 | + let backup_dir = match resolve_backup_dir(db).await { |
| 85 | + Ok(dir) => dir, |
| 86 | + Err(e) => { |
| 87 | + fs::remove_dir_all(&temp_dir).ok(); |
| 88 | + return Err(e); |
| 89 | + } |
| 90 | + }; |
| 91 | + let archive_prefix = if auto { |
| 92 | + "custom_covers_auto" |
| 93 | + } else { |
| 94 | + "custom_covers" |
| 95 | + }; |
| 96 | + let archive_name = format!( |
| 97 | + "{}_{}.7z", |
| 98 | + archive_prefix, |
| 99 | + chrono::Local::now().format("%Y%m%d_%H%M%S") |
| 100 | + ); |
| 101 | + let archive_path = backup_dir.join(&archive_name); |
| 102 | + |
| 103 | + let size = match create_7z_archive(&temp_dir, &archive_path) { |
| 104 | + Ok(size) => size, |
| 105 | + Err(e) => { |
| 106 | + fs::remove_dir_all(&temp_dir).ok(); |
| 107 | + return Err(format!("压缩自定义封面失败: {}", e)); |
| 108 | + } |
| 109 | + }; |
| 110 | + |
| 111 | + // 5. 清理临时目录 |
| 112 | + fs::remove_dir_all(&temp_dir).ok(); |
| 113 | + |
| 114 | + log::info!( |
| 115 | + "自定义封面备份成功: {} ({} bytes)", |
| 116 | + archive_path.display(), |
| 117 | + size |
| 118 | + ); |
| 119 | + |
| 120 | + Ok(BackupResult { |
| 121 | + success: true, |
| 122 | + path: Some(archive_path.to_string_lossy().to_string()), |
| 123 | + message: "自定义封面备份成功".to_string(), |
| 124 | + }) |
| 125 | +} |
| 126 | + |
| 127 | +pub fn delete_all_covers_dir() -> Result<(), String> { |
| 128 | + let covers_dir = reina_path::get_base_data_dir()?.join("covers"); |
| 129 | + |
| 130 | + if !covers_dir.exists() { |
| 131 | + return Ok(()); |
| 132 | + } |
| 133 | + |
| 134 | + fs::remove_dir_all(&covers_dir) |
| 135 | + .map_err(|e| format!("无法删除封面目录 {}: {}", covers_dir.display(), e))?; |
| 136 | + |
| 137 | + Ok(()) |
| 138 | +} |
| 139 | + |
| 140 | +/// 扫描 covers 目录并将自定义封面文件复制到临时目录 |
| 141 | +/// |
| 142 | +/// 只复制匹配 `cover_{game_id}_*` 模式的文件,跳过云端缓存(`cloud_*`)。 |
| 143 | +/// 无自定义封面的 game 目录不会被创建。 |
| 144 | +fn scan_and_copy_custom_covers( |
| 145 | + covers_dir: &Path, |
| 146 | + temp_dir: &Path, |
| 147 | + has_covers: &mut bool, |
| 148 | +) -> Result<(), String> { |
| 149 | + let entries = fs::read_dir(covers_dir).map_err(|e| format!("无法读取封面目录: {}", e))?; |
| 150 | + |
| 151 | + for entry in entries { |
| 152 | + let entry = entry.map_err(|e| format!("读取目录项失败: {}", e))?; |
| 153 | + let entry_path = entry.path(); |
| 154 | + |
| 155 | + if !entry_path.is_dir() { |
| 156 | + continue; |
| 157 | + } |
| 158 | + |
| 159 | + let dir_name = entry.file_name().to_string_lossy().to_string(); |
| 160 | + |
| 161 | + // 只处理 game_{id} 格式的目录 |
| 162 | + let game_id_str = match dir_name.strip_prefix("game_") { |
| 163 | + Some(id) => id, |
| 164 | + None => continue, |
| 165 | + }; |
| 166 | + |
| 167 | + // 自定义封面文件名格式:cover_{game_id}_{ext}_{timestamp}.{ext} |
| 168 | + let expected_prefix = format!("cover_{}", game_id_str); |
| 169 | + let mut game_has_covers = false; |
| 170 | + |
| 171 | + let file_entries = fs::read_dir(&entry_path) |
| 172 | + .map_err(|e| format!("无法读取游戏封面目录 {}: {}", dir_name, e))?; |
| 173 | + |
| 174 | + for file_entry in file_entries { |
| 175 | + let file_entry = file_entry.map_err(|e| format!("读取游戏封面目录项失败: {}", e))?; |
| 176 | + |
| 177 | + if !file_entry.path().is_file() { |
| 178 | + continue; |
| 179 | + } |
| 180 | + |
| 181 | + let file_name = file_entry.file_name().to_string_lossy().to_string(); |
| 182 | + |
| 183 | + // 匹配 cover_{id}_ 前缀(自定义封面),精确匹配游戏ID避免 cover_1 匹配到 cover_10 |
| 184 | + if !file_name.starts_with(&expected_prefix) { |
| 185 | + continue; |
| 186 | + } |
| 187 | + // 确保是 cover_{id}_ 格式(前缀后面紧跟下划线),而不是 cover_{id}10 等 |
| 188 | + let rest = &file_name[expected_prefix.len()..]; |
| 189 | + if !rest.starts_with('_') { |
| 190 | + continue; |
| 191 | + } |
| 192 | + |
| 193 | + // 首次为该游戏创建目录 |
| 194 | + if !game_has_covers { |
| 195 | + let game_temp_dir = temp_dir.join(&dir_name); |
| 196 | + fs::create_dir_all(&game_temp_dir) |
| 197 | + .map_err(|e| format!("创建临时目录失败: {}", e))?; |
| 198 | + game_has_covers = true; |
| 199 | + } |
| 200 | + |
| 201 | + let target_path = temp_dir.join(&dir_name).join(&file_name); |
| 202 | + fs::copy(file_entry.path(), &target_path) |
| 203 | + .map_err(|e| format!("复制自定义封面文件失败 {}: {}", file_name, e))?; |
| 204 | + } |
| 205 | + |
| 206 | + if game_has_covers { |
| 207 | + *has_covers = true; |
| 208 | + } |
| 209 | + } |
| 210 | + |
| 211 | + Ok(()) |
| 212 | +} |
0 commit comments