|
|
@@ -0,0 +1,1289 @@
|
|
|
+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<string> {
|
|
|
+ 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<string>,
|
|
|
+ conflictsToOverwrite: Set<string>,
|
|
|
+ conflictsToRename: Set<string>,
|
|
|
+ passphrase: string = ''
|
|
|
+ ): Promise<ImportResult | null> {
|
|
|
+ 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<AccountImportResult> {
|
|
|
+ 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<string[]> {
|
|
|
+ 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<boolean> {
|
|
|
+ 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<string> {
|
|
|
+ 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<void> {
|
|
|
+ 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<boolean> {
|
|
|
+ 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<string[]> {
|
|
|
+ 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<string> {
|
|
|
+ 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<boolean> {
|
|
|
+ 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<string> {
|
|
|
+ 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<ServerBackupItem[]> {
|
|
|
+ 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<EncryptionResult> {
|
|
|
+ // 生成随机 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<string> {
|
|
|
+ // 使用 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<cryptoFramework.SymKey> {
|
|
|
+ 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[];
|
|
|
+}
|