import { Context } from '@kit.AbilityKit'; import { fileIo } from '@kit.CoreFileKit'; import { picker } from '@kit.CoreFileKit'; import { http } from '@kit.NetworkKit'; import { rcp } from '@kit.RemoteCommunicationKit'; import { buffer, util } from '@kit.ArkTS'; import { cryptoFramework } from '@kit.CryptoArchitectureKit'; import { PreferencesUtil, ToastUtil, AppUtil } from '@pura/harmony-utils'; import Logger from './Logger'; import PlaylistTable from './PlaylistTable'; import { PlaylistBackupData, ImportResult, BackupHistoryItem, BACKUP_FORMAT_VERSION, WebDavAccountBackupItem, SettingsBackupData, SettingBoolItem, SettingNumberItem, SettingStringItem } from '../../viewmodel/PlaylistBackup'; import { RemoteDriveManager } from './RemoteDriveManager'; import { WebDavAccount } from '../../viewmodel/WebDavAccount'; import { RemoteDriveType } from '../enums/RemoteDriveType'; const TAG = 'heanup PlaylistBackupManager'; const BACKUP_HISTORY_KEY = 'playlist_backup_history'; const AUTO_BACKUP_ENABLED_KEY = 'playlist_auto_backup_enabled'; const MAX_HISTORY_ITEMS = 20; const AUTO_BACKUP_DELAY_MS = 5 * 60 * 1000; // 5分钟 /** * 备份JSON校验结果 */ export interface ValidateResult { valid: boolean; errorMessage: string; data: PlaylistBackupData | null; } /** * 网盘账户导入统计 */ interface AccountImportResult { imported: number; skipped: number; } /** * 加密结果 */ interface EncryptionResult { cipherText: string; // base64 salt: string; // base64 iv: string; // base64 tag: string; // base64 } /** * 歌单备份管理器 */ export class PlaylistBackupManager { private static instance: PlaylistBackupManager | null = null; private context: Context | null = null; private autoBackupTimer: number = -1; private constructor() { } static getInstance(): PlaylistBackupManager { if (!PlaylistBackupManager.instance) { PlaylistBackupManager.instance = new PlaylistBackupManager(); } return PlaylistBackupManager.instance; } setContext(context: Context): void { this.context = context; // 注册自动备份回调到 PlaylistTable(避免循环依赖) PlaylistTable.registerAutoBackupCallback(() => { this.scheduleAutoBackup(); }); } // ========== 序列化 ========== /** * 导出完整备份为 JSON 字符串(v2:歌单 + 网盘配置 + 设置项) * @param passphrase 可选加密密码,不为空时对密码字段加密 */ async exportToJson(passphrase: string = ''): Promise { if (!this.context) { Logger.error(TAG, 'exportToJson: context 未设置'); return ''; } try { // 1. 导出歌单 const playlistTable = new PlaylistTable(this.context); const playlistData = await playlistTable.exportAllPlaylists(); // 2. 导出网盘账户 const accounts = this.exportWebDavAccounts(); // 3. 导出用户设置 const settings = this.exportUserSettings(); // 4. 构建 v2 备份数据 const backupData: PlaylistBackupData = { version: BACKUP_FORMAT_VERSION, exportTime: new Date().toISOString(), appVersion: AppUtil.getVersionName(), playlists: playlistData.playlists, webDavAccounts: accounts, userSettings: settings }; // 5. 加密敏感数据(密码) if (passphrase.length > 0 && accounts.length > 0) { const passwords: string[] = []; for (let i = 0; i < accounts.length; i++) { passwords.push(accounts[i].password); accounts[i].password = ''; // 清除明文密码 } const encResult = await this.encryptData(JSON.stringify(passwords), passphrase); backupData.encrypted = true; backupData.encryptionSalt = encResult.salt; backupData.encryptionIv = encResult.iv; backupData.encryptionTag = encResult.tag; backupData.encryptedPasswords = encResult.cipherText; Logger.info(TAG, 'exportToJson: 密码已加密'); } const jsonStr = JSON.stringify(backupData); Logger.info(TAG, `exportToJson: 导出成功, ${backupData.playlists.length} 个歌单, ${accounts.length} 个网盘账户, 加密=${backupData.encrypted === true}, ${jsonStr.length} 字节`); return jsonStr; } catch (error) { Logger.error(TAG, `exportToJson: 导出失败: ${(error as Error).message}`); return ''; } } /** * 检查备份 JSON 是否已加密 */ isBackupEncrypted(jsonStr: string): boolean { try { const parsed = JSON.parse(jsonStr) as PlaylistBackupData; return parsed.encrypted === true; } catch (e) { return false; } } /** * 导出所有网盘账户 */ private exportWebDavAccounts(): WebDavAccountBackupItem[] { const backupItems: WebDavAccountBackupItem[] = []; try { const manager = RemoteDriveManager.getInstance(); const allAccounts = manager.getAllWebDavAccounts(); for (let i = 0; i < allAccounts.length; i++) { const acct = allAccounts[i]; const item: WebDavAccountBackupItem = { name: acct.name, isActivate: acct.isActivate, sortOrder: acct.sortOrder, host: acct.host, localHost: acct.localHost, isUseLocalHost: acct.isUseLocalHost, port: acct.port, enableHttps: acct.enableHttps, filepath: acct.filepath, imageFilePath: acct.imageFilePath, lyricFilePath: acct.lyricFilePath, uploadFilePath: acct.uploadFilePath, coverPath: acct.coverPath || '', account: acct.account, password: acct.password, webType: acct.webType, smbShare: acct.smbShare, smbDomain: acct.smbDomain, navidromeBasePath: acct.navidromeBasePath, jellyfinBasePath: acct.jellyfinBasePath, embyBasePath: acct.embyBasePath, ftpEncoding: acct.ftpEncoding }; backupItems.push(item); } Logger.info(TAG, `exportWebDavAccounts: 导出 ${backupItems.length} 个网盘账户`); } catch (error) { Logger.error(TAG, `exportWebDavAccounts: 失败: ${(error as Error).message}`); } return backupItems; } /** * 导出用户设置 */ private exportUserSettings(): SettingsBackupData { const boolKeys: string[] = [ 'isBgPlayOpen', 'isAutoRatate', 'isMemoryPlay', 'isMusicMemoryPlay', 'isStartAutoPlay', 'isMemoryLastPlay', 'isSameTimePlay', 'isSavePlayMode', 'isMediacodec', 'preload_next_song', 'is_customize_bg', 'isMusicBGCover', 'is_grid_music', 'isCoverRectangle', 'isCircleBtn', 'isAutoScrollHide', 'IS_SHOW_ALLBAR', 'IS_SHOW_TITLTBAR', 'IS_SHOW_PLAYPAGE_BACK', 'IS_SHOW_SLLYRIC', 'IS_SHOW_HEADER', 'IS_COVER_TOP_BIG', 'IS_PLAYLIST_BG_GRASS', 'IS_AUTO_HIDE_PROGRESS', 'isSwipe', 'isShowSimi', 'isShowFAV', 'isShowHistory', 'isShowPrecious', 'isShowBackFast', 'openSkipSongAnimate', 'isShowFileName', 'isLongNameRoLL', 'showFindLocation', 'showZMIndex', 'autoHideTitle', 'autoParseMusicName', 'isNoJumpToHome', 'isCopyFileToDownLoad', 'isDeleteYuan', 'isDeletePicture', 'isDeleteLrc', 'webdavUploadAutoClear', 'webdavUploadAllowMobile', 'volumeSmall' ]; const numberKeys: string[] = [ 'longPressSpeed', 'twoFingerType', 'sonTwoFingerType', 'musicSortType', 'webDavSortType', 'navidromeSortType', 'customize_bg_blur', 'bg_brightness', 'lastColumns', 'defalut_home_type', 'themeMode', 'webdavUploadRetryCount' ]; const stringKeys: string[] = [ 'THEME_COLOR', 'is_customize_bg_path', 'fastForwardSeconds', 'webdavUploadDuplicateAction', 'LRC_API', 'COVER_API' ]; const boolSettings: SettingBoolItem[] = []; for (let i = 0; i < boolKeys.length; i++) { const key = boolKeys[i]; boolSettings.push({ key: key, value: PreferencesUtil.getBooleanSync(key, false) }); } const numberSettings: SettingNumberItem[] = []; for (let i = 0; i < numberKeys.length; i++) { const key = numberKeys[i]; numberSettings.push({ key: key, value: PreferencesUtil.getNumberSync(key, 0) }); } const stringSettings: SettingStringItem[] = []; for (let i = 0; i < stringKeys.length; i++) { const key = stringKeys[i]; const value = PreferencesUtil.getStringSync(key, ''); // 跳过未设置的 string(空字符串),避免恢复时覆盖默认值 if (value.length > 0) { stringSettings.push({ key: key, value: value }); } } Logger.info(TAG, `exportUserSettings: 导出 ${boolSettings.length + numberSettings.length + stringSettings.length} 个设置项`); return { booleanSettings: boolSettings, numberSettings: numberSettings, stringSettings: stringSettings }; } /** * 校验备份 JSON 字符串 */ validateBackupJson(jsonStr: string): ValidateResult { const result: ValidateResult = { valid: false, errorMessage: '', data: null }; if (!jsonStr || jsonStr.length === 0) { result.errorMessage = '备份文件内容为空'; return result; } try { const parsed = JSON.parse(jsonStr) as PlaylistBackupData; if (parsed.version === undefined || parsed.version === null) { result.errorMessage = '备份文件缺少版本号'; return result; } if (parsed.version > BACKUP_FORMAT_VERSION) { result.errorMessage = `备份文件版本 (${parsed.version}) 不兼容,当前支持版本 ${BACKUP_FORMAT_VERSION}`; return result; } if (!parsed.playlists || !Array.isArray(parsed.playlists)) { result.errorMessage = '备份文件格式无效:缺少 playlists 字段'; return result; } if (!parsed.exportTime) { result.errorMessage = '备份文件格式无效:缺少 exportTime 字段'; return result; } // 校验每个歌单的必要字段 for (let i = 0; i < parsed.playlists.length; i++) { const playlist = parsed.playlists[i]; if (!playlist.name) { result.errorMessage = `备份文件格式无效:第 ${i + 1} 个歌单缺少名称`; return result; } if (!playlist.songs || !Array.isArray(playlist.songs)) { result.errorMessage = `备份文件格式无效:歌单 "${playlist.name}" 缺少 songs 字段`; return result; } } // v2 格式校验网盘账户(可选字段) if (parsed.version >= 2 && parsed.webDavAccounts !== undefined) { if (!Array.isArray(parsed.webDavAccounts)) { result.errorMessage = '备份文件格式无效:webDavAccounts 必须是数组'; return result; } for (let i = 0; i < parsed.webDavAccounts.length; i++) { const acct = parsed.webDavAccounts[i]; if (!acct.host || acct.port === undefined) { result.errorMessage = `备份文件格式无效:第 ${i + 1} 个网盘账户缺少必要字段`; return result; } } } // v2 格式校验用户设置(可选字段) if (parsed.version >= 2 && parsed.userSettings !== undefined) { const settings = parsed.userSettings; if (!settings.booleanSettings || !settings.numberSettings || !settings.stringSettings) { result.errorMessage = '备份文件格式无效:userSettings 缺少必要字段'; return result; } } result.valid = true; result.data = parsed; return result; } catch (error) { result.errorMessage = `备份文件不是有效的 JSON 格式: ${(error as Error).message}`; return result; } } /** * 从 JSON 字符串导入完整备份(歌单 + 网盘配置 + 设置项) * @param passphrase 解密密码(若备份已加密则必填) */ async importFromJson( jsonStr: string, conflictsToSkip: Set, conflictsToOverwrite: Set, conflictsToRename: Set, passphrase: string = '' ): Promise { if (!this.context) { Logger.error(TAG, 'importFromJson: context 未设置'); return null; } const validateResult = this.validateBackupJson(jsonStr); if (!validateResult.valid || !validateResult.data) { ToastUtil.showToast(validateResult.errorMessage); return null; } const backupData = validateResult.data; // 解密密码字段 if (backupData.encrypted === true && backupData.encryptedPasswords && backupData.webDavAccounts && backupData.webDavAccounts.length > 0) { if (passphrase.length === 0) { ToastUtil.showToast('该备份已加密,请输入备份密码'); return null; } try { const decryptedJson = await this.decryptData( backupData.encryptedPasswords, passphrase, backupData.encryptionSalt || '', backupData.encryptionIv || '', backupData.encryptionTag || '' ); const passwords = JSON.parse(decryptedJson) as string[]; for (let i = 0; i < backupData.webDavAccounts.length && i < passwords.length; i++) { backupData.webDavAccounts[i].password = passwords[i]; } Logger.info(TAG, 'importFromJson: 密码解密成功'); } catch (error) { Logger.error(TAG, `importFromJson: 密码解密失败: ${(error as Error).message}`); ToastUtil.showToast('备份密码错误,解密失败'); return null; } } try { // 1. 导入歌单 const playlistTable = new PlaylistTable(this.context); const playlistResult = await playlistTable.importPlaylists( backupData, conflictsToSkip, conflictsToOverwrite, conflictsToRename ); const result: ImportResult = { totalPlaylists: playlistResult.totalPlaylists, importedPlaylists: playlistResult.importedPlaylists, skippedPlaylists: playlistResult.skippedPlaylists, overwrittenPlaylists: playlistResult.overwrittenPlaylists, renamedPlaylists: playlistResult.renamedPlaylists, totalSongs: playlistResult.totalSongs, importedSongs: playlistResult.importedSongs, importedAccounts: 0, skippedAccounts: 0, settingsImported: false }; // 2. 导入网盘账户(v2) if (backupData.version >= 2 && backupData.webDavAccounts && backupData.webDavAccounts.length > 0) { const accountResult = await this.importWebDavAccounts(backupData.webDavAccounts); result.importedAccounts = accountResult.imported; result.skippedAccounts = accountResult.skipped; } // 3. 导入用户设置(v2) if (backupData.version >= 2 && backupData.userSettings) { result.settingsImported = this.importUserSettings(backupData.userSettings); } Logger.info(TAG, `importFromJson: 导入完成 - 歌单 ${result.importedPlaylists}, 网盘账户 ${result.importedAccounts}, 设置 ${result.settingsImported}`); return result; } catch (error) { Logger.error(TAG, `importFromJson: 导入失败: ${(error as Error).message}`); return null; } } /** * 导入网盘账户(按 host+port+account 去重) */ private async importWebDavAccounts(accounts: WebDavAccountBackupItem[]): Promise { const result: AccountImportResult = { imported: 0, skipped: 0 }; try { const manager = RemoteDriveManager.getInstance(); const existingAccounts = manager.getAllWebDavAccounts(); for (let i = 0; i < accounts.length; i++) { const backupAcct = accounts[i]; // 检查重复 let isDuplicate = false; for (let j = 0; j < existingAccounts.length; j++) { const existing = existingAccounts[j]; if (existing.host === backupAcct.host && existing.port === backupAcct.port && existing.account === backupAcct.account) { isDuplicate = true; break; } } if (isDuplicate) { result.skipped++; Logger.info(TAG, `importWebDavAccounts: 跳过重复账户 ${backupAcct.name} (${backupAcct.host}:${backupAcct.port})`); continue; } // 插入账户(注意参数顺序与 RemoteDriveManager.insertAccount 一致) await manager.insertAccount( backupAcct.name, backupAcct.host, backupAcct.localHost, backupAcct.isUseLocalHost, backupAcct.port, backupAcct.filepath, backupAcct.lyricFilePath, backupAcct.uploadFilePath, backupAcct.imageFilePath, backupAcct.account, backupAcct.password, backupAcct.enableHttps, backupAcct.coverPath, backupAcct.webType, backupAcct.smbShare, backupAcct.smbDomain, backupAcct.navidromeBasePath, backupAcct.jellyfinBasePath, backupAcct.embyBasePath, backupAcct.ftpEncoding, '', '', 0, // baiduAccessToken, baiduRefreshToken, baiduTokenExpiresAt(不恢复临时凭证) backupAcct.sortOrder ); result.imported++; Logger.info(TAG, `importWebDavAccounts: 导入账户 ${backupAcct.name}`); } Logger.info(TAG, `importWebDavAccounts: 完成 - 导入 ${result.imported}, 跳过 ${result.skipped}`); } catch (error) { Logger.error(TAG, `importWebDavAccounts: 失败: ${(error as Error).message}`); } return result; } /** * 导入用户设置 */ private importUserSettings(settings: SettingsBackupData): boolean { try { let count = 0; // 导入 boolean 设置 for (let i = 0; i < settings.booleanSettings.length; i++) { const item = settings.booleanSettings[i]; PreferencesUtil.putSync(item.key, item.value); count++; } // 导入 number 设置 for (let i = 0; i < settings.numberSettings.length; i++) { const item = settings.numberSettings[i]; PreferencesUtil.putSync(item.key, item.value); count++; } // 导入 string 设置 for (let i = 0; i < settings.stringSettings.length; i++) { const item = settings.stringSettings[i]; // 跳过空字符串,避免覆盖默认值(如主题色) if (item.value.length === 0) { continue; } PreferencesUtil.putSync(item.key, item.value); // 特殊处理:THEME_COLOR 需要同步到 AppStorage 的 themeColor if (item.key === 'THEME_COLOR') { AppStorage.setOrCreate('themeColor', item.value); } count++; } Logger.info(TAG, `importUserSettings: 导入 ${count} 个设置项`); return true; } catch (error) { Logger.error(TAG, `importUserSettings: 失败: ${(error as Error).message}`); return false; } } /** * 检测冲突 */ async detectConflicts(jsonStr: string): Promise { if (!this.context) { return []; } const validateResult = this.validateBackupJson(jsonStr); if (!validateResult.valid || !validateResult.data) { return []; } const playlistTable = new PlaylistTable(this.context); return playlistTable.detectConflicts(validateResult.data); } // ========== 本地文件操作 ========== /** * 导出备份到本地文件 * @param passphrase 可选加密密码 */ async saveToLocal(passphrase: string = ''): Promise { try { const jsonStr = await this.exportToJson(passphrase); if (!jsonStr || jsonStr.length === 0) { ToastUtil.showToast('导出备份数据失败'); return false; } const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); const fileName = `ttmusic_backup_${year}${month}${day}_${hours}${minutes}${seconds}.json`; const documentSaveOptions = new picker.DocumentSaveOptions(); documentSaveOptions.newFileNames = [fileName]; documentSaveOptions.fileSuffixChoices = ['.json']; const documentViewPicker = new picker.DocumentViewPicker(); const saveResult = await documentViewPicker.save(documentSaveOptions); if (!saveResult || saveResult.length === 0) { Logger.info(TAG, 'saveToLocal: 用户取消了文件选择'); return false; } const uri = saveResult[0]; const file = fileIo.openSync(uri, fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.CREATE); fileIo.writeSync(file.fd, jsonStr); fileIo.closeSync(file.fd); // 记录历史 const validateResult = this.validateBackupJson(jsonStr); const data = validateResult.data; const playlistCount = data ? data.playlists.length : 0; const accountCount = (data && data.webDavAccounts) ? data.webDavAccounts.length : 0; const hasSettings = !!(data && data.userSettings); this.addHistoryRecord('local', playlistCount, fileName, accountCount, hasSettings); ToastUtil.showToast(`备份成功: ${fileName}`); Logger.info(TAG, `saveToLocal: 文件保存成功: ${uri}`); return true; } catch (error) { const err = error as Error; Logger.error(TAG, `saveToLocal: 保存失败: ${err.message}`); ToastUtil.showToast(`备份失败: ${err.message}`); return false; } } /** * 从本地文件加载备份 */ async loadFromLocal(): Promise { try { const documentSelectOptions = new picker.DocumentSelectOptions(); documentSelectOptions.fileSuffixFilters = ['.json']; documentSelectOptions.maxSelectNumber = 1; const documentViewPicker = new picker.DocumentViewPicker(); const selectResult = await documentViewPicker.select(documentSelectOptions); if (!selectResult || selectResult.length === 0) { Logger.info(TAG, 'loadFromLocal: 用户取消了文件选择'); return ''; } const uri = selectResult[0]; const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); const stat = fileIo.statSync(file.fd); const buffer = new ArrayBuffer(stat.size); fileIo.readSync(file.fd, buffer); fileIo.closeSync(file.fd); const textDecoder = new util.TextDecoder('utf-8'); const jsonStr = textDecoder.decodeWithStream(new Uint8Array(buffer)); Logger.info(TAG, `loadFromLocal: 文件读取成功, ${jsonStr.length} 字节`); return jsonStr; } catch (error) { const err = error as Error; Logger.error(TAG, `loadFromLocal: 读取失败: ${err.message}`); ToastUtil.showToast(`读取备份文件失败: ${err.message}`); return ''; } } // ========== WebDAV 备份 ========== /** * 生成备份文件名 */ private generateBackupFileName(): string { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const seconds = String(now.getSeconds()).padStart(2, '0'); return `ttmusic_backup_${year}${month}${day}_${hours}${minutes}${seconds}.json`; } /** * 获取 WebDAV 账户的基准路径 */ private getWebDavBasePath(account: WebDavAccount): string { const base = account.filepath || '/'; if (base.length === 0) { return '/'; } // 确保以 / 开头并以 / 结尾(用于拼接) let normalized = base; if (!normalized.startsWith('/')) { normalized = '/' + normalized; } return normalized.replace(/\/+$/, ''); } /** * 确保 WebDAV 备份目录存在 */ private async ensureWebDavBackupDir(account: WebDavAccount): Promise { const manager = RemoteDriveManager.getInstance(); const basePath = this.getWebDavBasePath(account); try { await manager.createWebDavFolder(account, 'TTMusic', basePath); } catch (e) { // 目录可能已存在 } try { await manager.createWebDavFolder(account, 'backups', basePath + '/TTMusic'); } catch (e) { // 目录可能已存在 } } /** * 上传备份到 WebDAV * @param passphrase 可选加密密码 */ async uploadToWebDav(account: WebDavAccount, passphrase: string = ''): Promise { try { const jsonStr = await this.exportToJson(passphrase); if (!jsonStr || jsonStr.length === 0) { ToastUtil.showToast('导出备份数据失败'); return false; } if (!this.context) { ToastUtil.showToast('上下文未初始化'); return false; } const manager = RemoteDriveManager.getInstance(); // 确保备份目录存在 await this.ensureWebDavBackupDir(account); const fileName = this.generateBackupFileName(); // 写入临时文件 const tempPath = this.context.cacheDir + '/' + fileName; const tempFile = fileIo.openSync(tempPath, fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.CREATE); fileIo.writeSync(tempFile.fd, jsonStr); fileIo.closeSync(tempFile.fd); // 通过 rcpSocket 上传 const basePath = this.getWebDavBasePath(account); const remotePath = `${basePath}/TTMusic/backups/${fileName}`; const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host; await manager.rcpSocket.uploadFile( tempPath, remotePath, host, account.port, account.account, account.password, account.enableHttps, () => {} // 备份文件很小,不需要进度回调 ); // 清理临时文件 try { fileIo.unlinkSync(tempPath); } catch (e) { // 忽略 } // 记录历史 const validateResult = this.validateBackupJson(jsonStr); const data = validateResult.data; const playlistCount = data ? data.playlists.length : 0; const accountCount = (data && data.webDavAccounts) ? data.webDavAccounts.length : 0; const hasSettings = !!(data && data.userSettings); this.addHistoryRecord('webdav', playlistCount, fileName, accountCount, hasSettings); ToastUtil.showToast(`WebDAV备份成功: ${fileName}`); Logger.info(TAG, `uploadToWebDav: 上传成功: ${remotePath}`); return true; } catch (error) { const err = error as Error; Logger.error(TAG, `uploadToWebDav: 上传失败: ${err.message}`); ToastUtil.showToast(`WebDAV备份失败: ${err.message}`); return false; } } /** * 列出 WebDAV 上的备份文件 */ async listWebDavBackups(account: WebDavAccount): Promise { try { const manager = RemoteDriveManager.getInstance(); const basePath = this.getWebDavBasePath(account); const files = await manager.rcpSocket.getFileList( account.host, account.localHost, account.isUseLocalHost, account.port, `${basePath}/TTMusic/backups/`, account.account, account.password, account.enableHttps ); const jsonFiles: string[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; const name = file.fileName ? file.fileName : ''; if (name.endsWith('.json')) { jsonFiles.push(name); } } // 按文件名倒序排列(最新的在前) jsonFiles.sort((a, b) => b.localeCompare(a)); Logger.info(TAG, `listWebDavBackups: 找到 ${jsonFiles.length} 个备份文件`); return jsonFiles; } catch (error) { Logger.error(TAG, `listWebDavBackups: 列表失败: ${(error as Error).message}`); return []; } } /** * 从 WebDAV 下载备份文件(通过 rcp HTTP GET) */ async downloadFromWebDav(account: WebDavAccount, fileName: string): Promise { try { if (!this.context) { return ''; } const basePath = this.getWebDavBasePath(account); const remotePath = `${basePath}/TTMusic/backups/${fileName}`; const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host; const protocol = account.enableHttps ? 'https' : 'http'; const url = `${protocol}://${host}:${account.port}${remotePath}`; const encodedCredentials = buffer.from(`${account.account}:${account.password}`).toString('base64'); const secCfg: rcp.SecurityConfiguration = { remoteValidation: 'skip' }; const reqCfg: rcp.Configuration = { security: secCfg, transfer: { timeout: { connectMs: 15000 } } }; const sessionCfg: rcp.SessionConfiguration = { requestConfiguration: reqCfg }; const rcpSession = rcp.createSession(sessionCfg); const headers: rcp.RequestHeaders = { 'Authorization': `Basic ${encodedCredentials}`, 'Accept': '*/*' }; const req = new rcp.Request(url, 'GET', headers); const response = await rcpSession.fetch(req); rcpSession.close(); if (response.statusCode !== 200) { throw new Error(`HTTP ${response.statusCode}`); } let jsonStr = ''; if (response.body) { const textDecoder = new util.TextDecoder('utf-8'); jsonStr = textDecoder.decodeWithStream(new Uint8Array(response.body as ArrayBuffer)); } Logger.info(TAG, `downloadFromWebDav: 下载成功: ${fileName}, ${jsonStr.length} 字节`); return jsonStr; } catch (error) { Logger.error(TAG, `downloadFromWebDav: 下载失败: ${(error as Error).message}`); ToastUtil.showToast(`下载备份失败: ${(error as Error).message}`); return ''; } } // ========== 服务器备份(VIP) ========== /** * 检查 VIP 状态 */ isVipUser(): boolean { return PreferencesUtil.getBooleanSync('hasActiveSubscription', false); } /** * 上传备份到服务器 * @param passphrase 可选加密密码 */ async uploadToServer(passphrase: string = ''): Promise { if (!this.isVipUser()) { ToastUtil.showToast('该功能仅限VIP用户使用'); return false; } try { const jsonStr = await this.exportToJson(passphrase); if (!jsonStr || jsonStr.length === 0) { ToastUtil.showToast('导出备份数据失败'); return false; } const token = PreferencesUtil.getStringSync('userToken', ''); if (!token) { ToastUtil.showToast('请先登录'); return false; } const httpRequest = http.createHttp(); const options: http.HttpRequestOptions = { method: http.RequestMethod.POST, readTimeout: 10000, connectTimeout: 10000, header: { 'Content-Type': 'application/json' }, extraData: JSON.stringify({ token: token, backup_data: jsonStr }) }; const response = await httpRequest.request( 'https://pay.ss5.xyz/backup/playlist/upload', options ); if (response.responseCode === 200) { const res = response.result as string; const json = JSON.parse(res) as ServerBackupResponse; if (json.code === 0) { const validateResult = this.validateBackupJson(jsonStr); const data = validateResult.data; const playlistCount = data ? data.playlists.length : 0; const accountCount = (data && data.webDavAccounts) ? data.webDavAccounts.length : 0; const hasSettings = !!(data && data.userSettings); this.addHistoryRecord('server', playlistCount, '', accountCount, hasSettings); ToastUtil.showToast('服务器备份成功'); Logger.info(TAG, 'uploadToServer: 上传成功'); return true; } else if (json.code === 40002 || json.code === 40003) { ToastUtil.showToast('登录已过期,请重新登录'); this.pauseAutoBackup(); return false; } else { ToastUtil.showToast(`服务器备份失败: ${json.msg || '未知错误'}`); return false; } } else { ToastUtil.showToast('服务器备份请求失败'); return false; } } catch (error) { Logger.error(TAG, `uploadToServer: 上传失败: ${(error as Error).message}`); ToastUtil.showToast(`服务器备份失败: ${(error as Error).message}`); return false; } } /** * 从服务器下载备份 */ async downloadFromServer(backupId: string): Promise { if (!this.isVipUser()) { ToastUtil.showToast('该功能仅限VIP用户使用'); return ''; } try { const token = PreferencesUtil.getStringSync('userToken', ''); if (!token) { ToastUtil.showToast('请先登录'); return ''; } const httpRequest = http.createHttp(); const url = `https://pay.ss5.xyz/backup/playlist/download?token=${encodeURIComponent(token)}&id=${encodeURIComponent(backupId)}`; const options: http.HttpRequestOptions = { method: http.RequestMethod.GET, readTimeout: 10000, connectTimeout: 10000, }; const response = await httpRequest.request(url, options); if (response.responseCode === 200) { const res = response.result as string; const json = JSON.parse(res) as ServerBackupDownloadResponse; if (json.code === 0 && json.data && json.data.backup_data) { Logger.info(TAG, 'downloadFromServer: 下载成功'); return json.data.backup_data; } else if (json.code === 40002 || json.code === 40003) { ToastUtil.showToast('登录已过期,请重新登录'); return ''; } else { ToastUtil.showToast(`下载失败: ${json.msg || '未知错误'}`); return ''; } } else { ToastUtil.showToast('下载请求失败'); return ''; } } catch (error) { Logger.error(TAG, `downloadFromServer: 下载失败: ${(error as Error).message}`); ToastUtil.showToast(`下载失败: ${(error as Error).message}`); return ''; } } /** * 获取服务器备份列表 */ async listServerBackups(): Promise { if (!this.isVipUser()) { return []; } try { const token = PreferencesUtil.getStringSync('userToken', ''); if (!token) { return []; } const httpRequest = http.createHttp(); const url = `https://pay.ss5.xyz/backup/playlist/lists?token=${encodeURIComponent(token)}`; const options: http.HttpRequestOptions = { method: http.RequestMethod.GET, readTimeout: 6000, connectTimeout: 6000, }; const response = await httpRequest.request(url, options); if (response.responseCode === 200) { const res = response.result as string; const json = JSON.parse(res) as ServerBackupListResponse; if (json.code === 0 && json.data && json.data.backups) { Logger.info(TAG, `listServerBackups: 获取到 ${json.data.backups.length} 条备份`); return json.data.backups; } } return []; } catch (error) { Logger.error(TAG, `listServerBackups: 获取列表失败: ${(error as Error).message}`); return []; } } // ========== 自动备份防抖 ========== /** * 调度自动备份(防抖) */ scheduleAutoBackup(): void { if (!this.isAutoBackupEnabled()) { return; } if (!this.isVipUser()) { this.setAutoBackupEnabled(false); Logger.info(TAG, 'scheduleAutoBackup: VIP已过期,关闭自动备份'); return; } // 清除之前的定时器 if (this.autoBackupTimer !== -1) { clearTimeout(this.autoBackupTimer); } // 设置新的 5 分钟定时器 this.autoBackupTimer = setTimeout(() => { this.autoBackupTimer = -1; this.uploadToServer().then((success: boolean) => { if (success) { Logger.info(TAG, 'scheduleAutoBackup: 自动备份完成'); } }).catch((error: Error) => { Logger.error(TAG, `scheduleAutoBackup: 自动备份失败: ${error.message}`); }); }, AUTO_BACKUP_DELAY_MS); Logger.info(TAG, 'scheduleAutoBackup: 已设置 5 分钟后自动备份'); } /** * 暂停自动备份 */ private pauseAutoBackup(): void { if (this.autoBackupTimer !== -1) { clearTimeout(this.autoBackupTimer); this.autoBackupTimer = -1; } } isAutoBackupEnabled(): boolean { return PreferencesUtil.getBooleanSync(AUTO_BACKUP_ENABLED_KEY, false); } setAutoBackupEnabled(enabled: boolean): void { PreferencesUtil.putSync(AUTO_BACKUP_ENABLED_KEY, enabled); } // ========== 备份历史 ========== /** * 添加备份历史记录 */ addHistoryRecord(type: string, playlistCount: number, filePath: string, accountCount: number = 0, hasSettings: boolean = false): void { try { const history = this.getBackupHistory(); const record: BackupHistoryItem = { type: type, timestamp: new Date().toISOString(), playlistCount: playlistCount, filePath: filePath, accountCount: accountCount, hasSettings: hasSettings }; history.unshift(record); // 保留最多 20 条 while (history.length > MAX_HISTORY_ITEMS) { history.pop(); } PreferencesUtil.putSync(BACKUP_HISTORY_KEY, JSON.stringify(history)); } catch (error) { Logger.error(TAG, `addHistoryRecord: 保存历史失败: ${(error as Error).message}`); } } /** * 获取备份历史 */ getBackupHistory(): BackupHistoryItem[] { try { const jsonStr = PreferencesUtil.getStringSync(BACKUP_HISTORY_KEY, '[]'); return JSON.parse(jsonStr) as BackupHistoryItem[]; } catch (error) { return []; } } /** * 获取仅WebDAV类型的账户列表 */ getWebDavAccounts(): WebDavAccount[] { const manager = RemoteDriveManager.getInstance(); const allAccounts = manager.getAllWebDavAccounts(); const webdavAccounts: WebDavAccount[] = []; for (let i = 0; i < allAccounts.length; i++) { if (allAccounts[i].webType === RemoteDriveType.WebDav) { webdavAccounts.push(allAccounts[i]); } } return webdavAccounts; } // ========== 加密/解密(AES-256-GCM) ========== /** * 使用 AES-256-GCM 加密数据 * 密钥由 passphrase + 随机 salt 通过 SHA-256 派生 */ private async encryptData(plaintext: string, passphrase: string): Promise { // 生成随机 salt (16字节) 和 IV (12字节) const random = cryptoFramework.createRandom(); const saltBlob = await random.generateRandom(16); const ivBlob = await random.generateRandom(12); // 派生密钥: SHA-256(passphrase + salt) const symKey = await this.deriveKey(passphrase, saltBlob); // AES-256-GCM 加密 const cipher = cryptoFramework.createCipher('AES256|GCM|NoPadding'); const gcmParams: cryptoFramework.GcmParamsSpec = { iv: { data: ivBlob.data }, aad: { data: new Uint8Array(0) }, authTag: { data: new Uint8Array(16) }, algName: 'GcmParamsSpec' }; await cipher.init(cryptoFramework.CryptoMode.ENCRYPT_MODE, symKey, gcmParams); const encoder = new util.TextEncoder(); const plaintextBytes = encoder.encodeInto(plaintext); // GCM doFinal 输出 = 密文 + authTag(末尾16字节) const cipherOutput = await cipher.doFinal({ data: plaintextBytes }); const fullOutput = new Uint8Array(cipherOutput.data); const tagLen = 16; const cipherBytes = fullOutput.slice(0, fullOutput.byteLength - tagLen); const tagBytes = fullOutput.slice(fullOutput.byteLength - tagLen); // 使用 Base64Helper 编码 const base64Helper = new util.Base64Helper(); const result: EncryptionResult = { cipherText: base64Helper.encodeToStringSync(cipherBytes), salt: base64Helper.encodeToStringSync(saltBlob.data), iv: base64Helper.encodeToStringSync(ivBlob.data), tag: base64Helper.encodeToStringSync(tagBytes) }; Logger.info(TAG, `encryptData: fullOutput=${fullOutput.byteLength}B, cipher=${cipherBytes.byteLength}B, tag=${tagBytes.byteLength}B, tagHead=[${tagBytes[0]},${tagBytes[1]},${tagBytes[2]},${tagBytes[3]}]`); return result; } /** * 使用 AES-256-GCM 解密数据 */ private async decryptData( cipherTextB64: string, passphrase: string, saltB64: string, ivB64: string, tagB64: string ): Promise { // 使用 Base64Helper 解码,避免 buffer 内部池导致的数据错位 const base64Helper = new util.Base64Helper(); const saltData = base64Helper.decodeSync(saltB64); const ivData = base64Helper.decodeSync(ivB64); const tagData = base64Helper.decodeSync(tagB64); const cipherData = base64Helper.decodeSync(cipherTextB64); Logger.info(TAG, `decryptData: cipher=${cipherData.byteLength}B, salt=${saltData.byteLength}B, iv=${ivData.byteLength}B, tag=${tagData.byteLength}B`); // 同样方式派生密钥 const symKey = await this.deriveKey(passphrase, { data: saltData }); // AES-256-GCM 解密 const cipher = cryptoFramework.createCipher('AES256|GCM|NoPadding'); const gcmParams: cryptoFramework.GcmParamsSpec = { iv: { data: ivData }, aad: { data: new Uint8Array(0) }, authTag: { data: tagData }, algName: 'GcmParamsSpec' }; await cipher.init(cryptoFramework.CryptoMode.DECRYPT_MODE, symKey, gcmParams); // GCM 解密:密文(不含authTag)传入 doFinal,authTag 通过 gcmParams 传入 const decryptOutput = await cipher.doFinal({ data: cipherData }); const textDecoder = new util.TextDecoder('utf-8'); return textDecoder.decodeWithStream(new Uint8Array(decryptOutput.data)); } /** * 从 passphrase + salt 派生 AES-256 密钥(SHA-256) */ private async deriveKey(passphrase: string, salt: cryptoFramework.DataBlob): Promise { const md = cryptoFramework.createMd('SHA256'); const encoder = new util.TextEncoder(); await md.update({ data: encoder.encodeInto(passphrase) }); await md.update(salt); const hash = await md.digest(); const keyGenerator = cryptoFramework.createSymKeyGenerator('AES256'); return await keyGenerator.convertKey(hash); } } // ========== 服务器API响应接口 ========== interface ServerBackupResponse { code: number; msg: string; } export interface ServerBackupItem { id: string; timestamp: string; playlist_count: number; } interface ServerBackupDownloadResponse { code: number; msg: string; data: ServerBackupDownloadData; } interface ServerBackupDownloadData { backup_data: string; } interface ServerBackupListResponse { code: number; msg: string; data: ServerBackupListData; } interface ServerBackupListData { backups: ServerBackupItem[]; }