feat: Add option to delete legacy data from disk

This commit is contained in:
Peifan Li
2025-11-24 23:43:35 -05:00
parent 89a1451f20
commit b2244bc4e6
7 changed files with 176 additions and 22 deletions

View File

@@ -132,6 +132,16 @@ MyTube 允许您将视频整理到收藏夹中:
- **添加到收藏夹**:直接从视频播放器或管理页面将视频添加到一个或多个收藏夹。
- **从收藏夹中移除**:轻松从收藏夹中移除视频。
- **浏览收藏夹**:在侧边栏查看所有收藏夹,并按收藏夹浏览视频。
- **删除选项**:选择仅删除收藏夹分组,或连同所有视频文件一起从磁盘删除。
## 数据迁移
MyTube 现在使用 SQLite 数据库以获得更好的性能和可靠性。如果您是从使用 JSON 文件的旧版本升级:
1. 进入 **设置**。
2. 向下滚动到 **数据库** 部分。
3. 点击 **从 JSON 迁移数据**。
4. 该工具将把您现有的视频、收藏夹和下载历史导入到新数据库中。
## 用户界面

View File

@@ -133,6 +133,16 @@ MyTube allows you to organize your videos into collections:
- **Add to Collections**: Add videos to one or more collections directly from the video player or manage page.
- **Remove from Collections**: Remove videos from collections easily.
- **Browse Collections**: View all your collections in the sidebar and browse videos by collection.
- **Delete Options**: Choose to delete just the collection grouping or delete the collection along with all its video files from the disk.
## Data Migration
MyTube now uses a SQLite database for better performance and reliability. If you are upgrading from an older version that used JSON files:
1. Go to **Settings**.
2. Scroll down to the **Database** section.
3. Click **Migrate Data from JSON**.
4. The tool will import your existing videos, collections, and download history into the new database.
## User Interface

View File

@@ -1,4 +1,4 @@
CREATE TABLE `collection_videos` (
CREATE TABLE IF NOT EXISTS `collection_videos` (
`collection_id` text NOT NULL,
`video_id` text NOT NULL,
`order` integer,
@@ -7,7 +7,7 @@ CREATE TABLE `collection_videos` (
FOREIGN KEY (`video_id`) REFERENCES `videos`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `collections` (
CREATE TABLE IF NOT EXISTS `collections` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`title` text,
@@ -15,7 +15,7 @@ CREATE TABLE `collections` (
`updated_at` text
);
--> statement-breakpoint
CREATE TABLE `downloads` (
CREATE TABLE IF NOT EXISTS `downloads` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`timestamp` integer,
@@ -27,12 +27,12 @@ CREATE TABLE `downloads` (
`status` text DEFAULT 'active' NOT NULL
);
--> statement-breakpoint
CREATE TABLE `settings` (
CREATE TABLE IF NOT EXISTS `settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `videos` (
CREATE TABLE IF NOT EXISTS `videos` (
`id` text PRIMARY KEY NOT NULL,
`title` text NOT NULL,
`author` text,

View File

@@ -1,5 +1,8 @@
import bcrypt from 'bcryptjs';
import { Request, Response } from 'express';
import fs from 'fs-extra';
import path from 'path';
import { COLLECTIONS_DATA_PATH, STATUS_DATA_PATH, VIDEOS_DATA_PATH } from '../config/paths';
import downloadManager from '../services/downloadManager';
import * as storageService from '../services/storageService';
@@ -54,6 +57,40 @@ export const migrateData = async (req: Request, res: Response) => {
}
};
export const deleteLegacyData = async (req: Request, res: Response) => {
try {
const SETTINGS_DATA_PATH = path.join(path.dirname(VIDEOS_DATA_PATH), 'settings.json');
const filesToDelete = [
VIDEOS_DATA_PATH,
COLLECTIONS_DATA_PATH,
STATUS_DATA_PATH,
SETTINGS_DATA_PATH
];
const results: { deleted: string[], failed: string[] } = {
deleted: [],
failed: []
};
for (const file of filesToDelete) {
if (fs.existsSync(file)) {
try {
fs.unlinkSync(file);
results.deleted.push(path.basename(file));
} catch (err) {
console.error(`Failed to delete ${file}:`, err);
results.failed.push(path.basename(file));
}
}
}
res.json({ success: true, results });
} catch (error: any) {
console.error('Error deleting legacy data:', error);
res.status(500).json({ error: 'Failed to delete legacy data', details: error.message });
}
};
export const updateSettings = async (req: Request, res: Response) => {
try {
const newSettings: Settings = req.body;

View File

@@ -1,5 +1,5 @@
import express from 'express';
import { getSettings, migrateData, updateSettings, verifyPassword } from '../controllers/settingsController';
import { deleteLegacyData, getSettings, migrateData, updateSettings, verifyPassword } from '../controllers/settingsController';
const router = express.Router();
@@ -7,5 +7,6 @@ router.get('/', getSettings);
router.post('/', updateSettings);
router.post('/verify-password', verifyPassword);
router.post('/migrate', migrateData);
router.post('/delete-legacy', deleteLegacyData);
export default router;

View File

@@ -25,7 +25,9 @@ import {
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import ConfirmationModal from '../components/ConfirmationModal';
import { useLanguage } from '../contexts/LanguageContext';
import { Language } from '../utils/translations';
const API_URL = import.meta.env.VITE_API_URL;
@@ -49,7 +51,8 @@ const SettingsPage: React.FC = () => {
language: 'en'
});
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' | 'warning' | 'info' } | null>(null);
const [showDeleteLegacyModal, setShowDeleteLegacyModal] = useState(false);
const { t, setLanguage } = useLanguage();
useEffect(() => {
@@ -90,10 +93,10 @@ const SettingsPage: React.FC = () => {
}
};
const handleChange = (field: keyof Settings, value: any) => {
const handleChange = (field: keyof Settings, value: string | boolean | number) => {
setSettings(prev => ({ ...prev, [field]: value }));
if (field === 'language') {
setLanguage(value);
setLanguage(value as Language);
}
};
@@ -221,27 +224,26 @@ const SettingsPage: React.FC = () => {
{/* Database Settings */}
<Grid size={12}>
<Typography variant="h6" gutterBottom>Database</Typography>
<Typography variant="h6" gutterBottom>{t('database')}</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Migrate data from legacy JSON files to the new SQLite database.
This action is safe to run multiple times (duplicates will be skipped).
{t('migrateDataDescription')}
</Typography>
<Button
variant="outlined"
color="warning"
onClick={async () => {
if (window.confirm('Are you sure you want to migrate data? This may take a few moments.')) {
if (window.confirm(t('migrateConfirmation'))) {
setSaving(true);
try {
const res = await axios.post(`${API_URL}/settings/migrate`);
const results = res.data.results;
console.log('Migration results:', results);
let msg = 'Migration Report:\n';
let msg = `${t('migrationReport')}:\n`;
let hasData = false;
if (results.warnings && results.warnings.length > 0) {
msg += `\n⚠ WARNINGS:\n${results.warnings.join('\n')}\n`;
msg += `\n⚠ ${t('migrationWarnings')}:\n${results.warnings.join('\n')}\n`;
}
const categories = ['videos', 'collections', 'settings', 'downloads'];
@@ -249,28 +251,28 @@ const SettingsPage: React.FC = () => {
const data = results[cat];
if (data) {
if (data.found) {
msg += `\n✅ ${cat}: ${data.count} items migrated`;
msg += `\n✅ ${cat}: ${data.count} ${t('itemsMigrated')}`;
hasData = true;
} else {
msg += `\n❌ ${cat}: File not found at ${data.path}`;
msg += `\n❌ ${cat}: ${t('fileNotFound')} ${data.path}`;
}
}
});
if (results.errors && results.errors.length > 0) {
msg += `\n\n⛔ ERRORS:\n${results.errors.join('\n')}`;
msg += `\n\n⛔ ${t('migrationErrors')}:\n${results.errors.join('\n')}`;
}
if (!hasData && (!results.errors || results.errors.length === 0)) {
msg += '\n\n⚠ No data files were found to migrate. Please check your volume mappings.';
msg += `\n\n⚠ ${t('noDataFilesFound')}`;
}
alert(msg);
setMessage({ text: hasData ? 'Migration completed. See details in alert.' : 'Migration finished but no data found.', type: hasData ? 'success' : 'warning' });
setMessage({ text: hasData ? t('migrationSuccess') : t('migrationNoData'), type: hasData ? 'success' : 'warning' });
} catch (error: any) {
console.error('Migration failed:', error);
setMessage({
text: `Migration failed: ${error.response?.data?.details || error.message}`,
text: `${t('migrationFailed')}: ${error.response?.data?.details || error.message}`,
type: 'error'
});
} finally {
@@ -280,8 +282,23 @@ const SettingsPage: React.FC = () => {
}}
disabled={saving}
>
Migrate Data from JSON
{t('migrateDataButton')}
</Button>
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>{t('removeLegacyData')}</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
{t('removeLegacyDataDescription')}
</Typography>
<Button
variant="outlined"
color="error"
onClick={() => setShowDeleteLegacyModal(true)}
disabled={saving}
>
{t('deleteLegacyDataButton')}
</Button>
</Box>
</Grid>
<Grid size={12}>
@@ -311,6 +328,41 @@ const SettingsPage: React.FC = () => {
{message?.text}
</Alert>
</Snackbar>
<ConfirmationModal
isOpen={showDeleteLegacyModal}
onClose={() => setShowDeleteLegacyModal(false)}
onConfirm={async () => {
setSaving(true);
try {
const res = await axios.post(`${API_URL}/settings/delete-legacy`);
const results = res.data.results;
console.log('Delete legacy results:', results);
let msg = `${t('legacyDataDeleted')}\n`;
if (results.deleted.length > 0) {
msg += `\nDeleted: ${results.deleted.join(', ')}`;
}
if (results.failed.length > 0) {
msg += `\nFailed: ${results.failed.join(', ')}`;
}
alert(msg);
setMessage({ text: t('legacyDataDeleted'), type: 'success' });
} catch (error: any) {
console.error('Failed to delete legacy data:', error);
setMessage({
text: `Failed to delete legacy data: ${error.response?.data?.details || error.message}`,
type: 'error'
});
} finally {
setSaving(false);
}
}}
title={t('removeLegacyDataConfirmTitle')}
message={t('removeLegacyDataConfirmMessage')}
confirmText={t('delete')}
isDanger={true}
/>
</Container>
);
};

View File

@@ -48,6 +48,28 @@ export const translations = {
settingsSaved: "Settings saved successfully",
settingsFailed: "Failed to save settings",
// Database
database: "Database",
migrateDataDescription: "Migrate data from legacy JSON files to the new SQLite database. This action is safe to run multiple times (duplicates will be skipped).",
migrateDataButton: "Migrate Data from JSON",
migrateConfirmation: "Are you sure you want to migrate data? This may take a few moments.",
migrationResults: "Migration Results",
migrationReport: "Migration Report",
migrationSuccess: "Migration completed. See details in alert.",
migrationNoData: "Migration finished but no data found.",
migrationFailed: "Migration failed",
migrationWarnings: "WARNINGS",
migrationErrors: "ERRORS",
itemsMigrated: "items migrated",
fileNotFound: "File not found at",
noDataFilesFound: "No data files were found to migrate. Please check your volume mappings.",
removeLegacyData: "Remove Legacy Data",
removeLegacyDataDescription: "Delete the old JSON files (videos.json, collections.json, etc.) to clean up disk space. Only do this after verifying your data has been successfully migrated.",
removeLegacyDataConfirmTitle: "Delete Legacy Data?",
removeLegacyDataConfirmMessage: "Are you sure you want to delete the legacy JSON data files? This action cannot be undone.",
legacyDataDeleted: "Legacy data deleted successfully.",
deleteLegacyDataButton: "Delete Legacy Data",
// Manage
manageContent: "Manage Content",
videos: "Videos",
@@ -222,6 +244,28 @@ export const translations = {
settingsSaved: "设置保存成功",
settingsFailed: "保存设置失败",
// Database
database: "数据库",
migrateDataDescription: "从旧版 JSON 文件迁移数据到新的 SQLite 数据库。此操作可以安全地多次运行(将跳过重复项)。",
migrateDataButton: "从 JSON 迁移数据",
migrateConfirmation: "确定要迁移数据吗?这可能需要一些时间。",
migrationResults: "迁移结果",
migrationReport: "迁移报告",
migrationSuccess: "迁移完成。请查看警报中的详细信息。",
migrationNoData: "迁移完成但未找到数据。",
migrationFailed: "迁移失败",
migrationWarnings: "警告",
migrationErrors: "错误",
itemsMigrated: "项已迁移",
fileNotFound: "未找到文件于",
noDataFilesFound: "未找到可迁移的数据文件。请检查您的卷映射。",
removeLegacyData: "删除旧数据",
removeLegacyDataDescription: "删除旧的 JSON 文件videos.json, collections.json 等)以释放磁盘空间。请仅在确认数据已成功迁移后执行此操作。",
removeLegacyDataConfirmTitle: "删除旧数据?",
removeLegacyDataConfirmMessage: "确定要删除旧的 JSON 数据文件吗?此操作无法撤销。",
legacyDataDeleted: "旧数据删除成功。",
deleteLegacyDataButton: "删除旧数据",
// Manage
manageContent: "内容管理",
videos: "视频",