feat: Implement database export/import and cleanup functionality
This commit is contained in:
14
.gitignore
vendored
14
.gitignore
vendored
@@ -48,16 +48,10 @@ backend/uploads/images/*
|
||||
!backend/uploads/.gitkeep
|
||||
!backend/uploads/videos/.gitkeep
|
||||
!backend/uploads/images/.gitkeep
|
||||
# Ignore the videos database
|
||||
backend/data/videos.json
|
||||
backend/data/collections.json
|
||||
backend/data/*.db
|
||||
backend/data/*.db-journal
|
||||
backend/data/status.json
|
||||
backend/data/settings.json
|
||||
|
||||
# Sensitive data
|
||||
backend/data/cookies.txt
|
||||
# Ignore entire data directory
|
||||
backend/data/*
|
||||
# But keep the directory structure if needed
|
||||
!backend/data/.gitkeep
|
||||
|
||||
# Large video files (test files)
|
||||
*.webm
|
||||
|
||||
@@ -11,6 +11,7 @@ import { NotFoundError, ValidationError } from "../errors/DownloadErrors";
|
||||
import downloadManager from "../services/downloadManager";
|
||||
import * as loginAttemptService from "../services/loginAttemptService";
|
||||
import * as storageService from "../services/storageService";
|
||||
import { generateTimestamp } from "../utils/helpers";
|
||||
import { logger } from "../utils/logger";
|
||||
import { successMessage } from "../utils/response";
|
||||
|
||||
@@ -461,3 +462,204 @@ export const resetPassword = async (
|
||||
"Password has been reset. Check backend logs for the new password.",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Export database as backup file
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const exportDatabase = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { DATA_DIR } = require("../config/paths");
|
||||
const dbPath = path.join(DATA_DIR, "mytube.db");
|
||||
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
throw new NotFoundError("Database file", "mytube.db");
|
||||
}
|
||||
|
||||
// Generate filename with date and time
|
||||
const filename = `mytube-backup-${generateTimestamp()}.db`;
|
||||
|
||||
// Set headers for file download
|
||||
res.setHeader("Content-Type", "application/octet-stream");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
|
||||
// Send the database file
|
||||
res.sendFile(dbPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Import database from backup file
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const importDatabase = async (
|
||||
req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
if (!req.file) {
|
||||
throw new ValidationError("No file uploaded", "file");
|
||||
}
|
||||
|
||||
const Database = require("better-sqlite3");
|
||||
const { DATA_DIR } = require("../config/paths");
|
||||
const dbPath = path.join(DATA_DIR, "mytube.db");
|
||||
|
||||
// Generate backup filename with date and time
|
||||
const backupFilename = `mytube-backup-${generateTimestamp()}.db.backup`;
|
||||
const backupPath = path.join(DATA_DIR, backupFilename);
|
||||
|
||||
let sourceDb: any = null;
|
||||
|
||||
try {
|
||||
// Validate file extension
|
||||
if (!req.file.originalname.endsWith(".db")) {
|
||||
throw new ValidationError("Only .db files are allowed", "file");
|
||||
}
|
||||
|
||||
// Validate the uploaded file is a valid SQLite database
|
||||
sourceDb = new Database(req.file.path, { readonly: true });
|
||||
try {
|
||||
// Try to query the database to verify it's valid
|
||||
sourceDb
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1")
|
||||
.get();
|
||||
} catch (validationError) {
|
||||
sourceDb.close();
|
||||
throw new ValidationError(
|
||||
"Invalid database file. The file is not a valid SQLite database.",
|
||||
"file"
|
||||
);
|
||||
}
|
||||
sourceDb.close();
|
||||
sourceDb = null;
|
||||
|
||||
// Create backup of current database before import
|
||||
if (fs.existsSync(dbPath)) {
|
||||
fs.copyFileSync(dbPath, backupPath);
|
||||
logger.info(`Created backup of current database at ${backupPath}`);
|
||||
}
|
||||
|
||||
// Close the current database connection before replacing the file
|
||||
const { sqlite } = require("../db");
|
||||
sqlite.close();
|
||||
logger.info("Closed current database connection for import");
|
||||
|
||||
// Simply copy the uploaded file to replace the database
|
||||
// Since we've closed the connection and validated the file, this is safe
|
||||
fs.copyFileSync(req.file.path, dbPath);
|
||||
logger.info(
|
||||
`Database file replaced successfully from ${req.file.originalname}`
|
||||
);
|
||||
|
||||
// Reinitialize the database connection with the new file
|
||||
const { reinitializeDatabase } = require("../db");
|
||||
reinitializeDatabase();
|
||||
logger.info("Database connection reinitialized after import");
|
||||
|
||||
// Clean up uploaded temp file
|
||||
if (fs.existsSync(req.file.path)) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
}
|
||||
|
||||
res.json(
|
||||
successMessage(
|
||||
"Database imported successfully. Existing data has been overwritten with the backup data."
|
||||
)
|
||||
);
|
||||
} catch (error: any) {
|
||||
// Close connection if still open
|
||||
if (sourceDb) {
|
||||
try {
|
||||
sourceDb.close();
|
||||
} catch (e) {
|
||||
logger.error("Error closing source database:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up uploaded temp file if it exists
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
try {
|
||||
fs.unlinkSync(req.file.path);
|
||||
} catch (e) {
|
||||
logger.error("Error cleaning up temp file:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore backup if import failed
|
||||
if (fs.existsSync(backupPath)) {
|
||||
try {
|
||||
fs.copyFileSync(backupPath, dbPath);
|
||||
logger.info("Restored database from backup after failed import");
|
||||
} catch (restoreError) {
|
||||
logger.error("Failed to restore database from backup:", restoreError);
|
||||
}
|
||||
}
|
||||
|
||||
// Log the actual error for debugging
|
||||
logger.error(
|
||||
"Database import failed:",
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up backup database files
|
||||
* Errors are automatically handled by asyncHandler middleware
|
||||
*/
|
||||
export const cleanupBackupDatabases = async (
|
||||
_req: Request,
|
||||
res: Response
|
||||
): Promise<void> => {
|
||||
const { DATA_DIR } = require("../config/paths");
|
||||
const backupPattern = /^mytube-backup-.*\.db\.backup$/;
|
||||
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(DATA_DIR);
|
||||
|
||||
for (const file of files) {
|
||||
if (backupPattern.test(file)) {
|
||||
const filePath = path.join(DATA_DIR, file);
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
deletedCount++;
|
||||
logger.info(`Deleted backup database file: ${file}`);
|
||||
} catch (error: any) {
|
||||
failedCount++;
|
||||
const errorMsg = `Failed to delete ${file}: ${error.message}`;
|
||||
errors.push(errorMsg);
|
||||
logger.error(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedCount === 0 && failedCount === 0) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: "No backup database files found to clean up.",
|
||||
deleted: deletedCount,
|
||||
failed: failedCount,
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Cleaned up ${deletedCount} backup database file(s).${
|
||||
failedCount > 0 ? ` ${failedCount} file(s) failed to delete.` : ""
|
||||
}`,
|
||||
deleted: deletedCount,
|
||||
failed: failedCount,
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("Error cleaning up backup databases:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,14 +1,55 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { DATA_DIR } from '../config/paths';
|
||||
import * as schema from './schema';
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import { DATA_DIR } from "../config/paths";
|
||||
import * as schema from "./schema";
|
||||
|
||||
// Ensure data directory exists
|
||||
fs.ensureDirSync(DATA_DIR);
|
||||
|
||||
const dbPath = path.join(DATA_DIR, 'mytube.db');
|
||||
export const sqlite = new Database(dbPath);
|
||||
const dbPath = path.join(DATA_DIR, "mytube.db");
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
// Create database connection with getters that auto-reopen if closed
|
||||
let sqliteInstance: Database.Database = new Database(dbPath);
|
||||
let dbInstance = drizzle(sqliteInstance, { schema });
|
||||
|
||||
// Helper to ensure connection is open
|
||||
function ensureConnection(): void {
|
||||
if (!sqliteInstance.open) {
|
||||
sqliteInstance = new Database(dbPath);
|
||||
dbInstance = drizzle(sqliteInstance, { schema });
|
||||
}
|
||||
}
|
||||
|
||||
// Export sqlite with auto-reconnect
|
||||
// Using an empty object as target so we always use the current sqliteInstance
|
||||
export const sqlite = new Proxy({} as Database.Database, {
|
||||
get(_target, prop) {
|
||||
ensureConnection();
|
||||
return (sqliteInstance as any)[prop];
|
||||
},
|
||||
set(_target, prop, value) {
|
||||
ensureConnection();
|
||||
(sqliteInstance as any)[prop] = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// Export db with auto-reconnect
|
||||
// Using an empty object as target so we always use the current dbInstance
|
||||
export const db = new Proxy({} as ReturnType<typeof drizzle>, {
|
||||
get(_target, prop) {
|
||||
ensureConnection();
|
||||
return (dbInstance as any)[prop];
|
||||
},
|
||||
});
|
||||
|
||||
// Function to reinitialize the database connection
|
||||
export function reinitializeDatabase(): void {
|
||||
if (sqliteInstance.open) {
|
||||
sqliteInstance.close();
|
||||
}
|
||||
sqliteInstance = new Database(dbPath);
|
||||
dbInstance = drizzle(sqliteInstance, { schema });
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ import multer from "multer";
|
||||
import os from "os";
|
||||
import {
|
||||
checkCookies,
|
||||
cleanupBackupDatabases,
|
||||
deleteCookies,
|
||||
deleteLegacyData,
|
||||
exportDatabase,
|
||||
formatFilenames,
|
||||
getPasswordEnabled,
|
||||
getSettings,
|
||||
importDatabase,
|
||||
migrateData,
|
||||
resetPassword,
|
||||
updateSettings,
|
||||
@@ -34,5 +37,12 @@ router.post(
|
||||
);
|
||||
router.post("/delete-cookies", asyncHandler(deleteCookies));
|
||||
router.get("/check-cookies", asyncHandler(checkCookies));
|
||||
router.get("/export-database", asyncHandler(exportDatabase));
|
||||
router.post(
|
||||
"/import-database",
|
||||
upload.single("file"),
|
||||
asyncHandler(importDatabase)
|
||||
);
|
||||
router.post("/cleanup-backup-databases", asyncHandler(cleanupBackupDatabases));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -297,3 +297,18 @@ export function formatVideoFilename(
|
||||
|
||||
return `${cleanTitle}${fullSuffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a timestamp string for backup filenames
|
||||
* Format: YYYY-MM-DD-HH-MM-SS
|
||||
*/
|
||||
export function generateTimestamp(): 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 `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { Box, Button, FormControlLabel, Switch, Typography } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { Close, Delete, Download, Upload } from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Switch,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
|
||||
interface DatabaseSettingsProps {
|
||||
onMigrate: () => void;
|
||||
onDeleteLegacy: () => void;
|
||||
onFormatFilenames: () => void;
|
||||
onExportDatabase: () => void;
|
||||
onImportDatabase: (file: File) => void;
|
||||
onCleanupBackupDatabases: () => void;
|
||||
isSaving: boolean;
|
||||
moveSubtitlesToVideoFolder: boolean;
|
||||
onMoveSubtitlesToVideoFolderChange: (checked: boolean) => void;
|
||||
@@ -17,6 +33,9 @@ const DatabaseSettings: React.FC<DatabaseSettingsProps> = ({
|
||||
onMigrate,
|
||||
onDeleteLegacy,
|
||||
onFormatFilenames,
|
||||
onExportDatabase,
|
||||
onImportDatabase,
|
||||
onCleanupBackupDatabases,
|
||||
isSaving,
|
||||
moveSubtitlesToVideoFolder,
|
||||
onMoveSubtitlesToVideoFolderChange,
|
||||
@@ -24,6 +43,53 @@ const DatabaseSettings: React.FC<DatabaseSettingsProps> = ({
|
||||
onMoveThumbnailsToVideoFolderChange
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [cleanupModalOpen, setCleanupModalOpen] = useState(false);
|
||||
|
||||
const handleOpenImportModal = () => {
|
||||
setImportModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseImportModal = () => {
|
||||
setImportModalOpen(false);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.db')) {
|
||||
alert(t('onlyDbFilesAllowed') || 'Only .db files are allowed');
|
||||
return;
|
||||
}
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmImport = () => {
|
||||
if (selectedFile) {
|
||||
onImportDatabase(selectedFile);
|
||||
handleCloseImportModal();
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCleanupModal = () => {
|
||||
setCleanupModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseCleanupModal = () => {
|
||||
setCleanupModalOpen(false);
|
||||
};
|
||||
|
||||
const handleConfirmCleanup = () => {
|
||||
onCleanupBackupDatabases();
|
||||
handleCloseCleanupModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
@@ -103,6 +169,148 @@ const DatabaseSettings: React.FC<DatabaseSettingsProps> = ({
|
||||
{t('moveThumbnailsToVideoFolderDescription')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>{t('exportImportDatabase')}</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('exportImportDatabaseDescription')}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<Download />}
|
||||
onClick={onExportDatabase}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('exportDatabase')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
startIcon={<Upload />}
|
||||
onClick={handleOpenImportModal}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('importDatabase')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
startIcon={<Delete />}
|
||||
onClick={handleOpenCleanupModal}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('cleanupBackupDatabases')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Dialog
|
||||
open={importModalOpen}
|
||||
onClose={handleCloseImportModal}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
|
||||
{t('importDatabase')}
|
||||
</Typography>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={handleCloseImportModal}
|
||||
sx={{
|
||||
color: (theme) => theme.palette.grey[500],
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<DialogContentText sx={{ mb: 2, color: 'text.primary' }}>
|
||||
{t('importDatabaseWarning')}
|
||||
</DialogContentText>
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<Upload />}
|
||||
fullWidth
|
||||
sx={{ borderStyle: 'dashed', height: 56 }}
|
||||
>
|
||||
{selectedFile ? selectedFile.name : t('selectDatabaseFile')}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
hidden
|
||||
accept=".db"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</Button>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={handleCloseImportModal} disabled={isSaving}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmImport}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
disabled={!selectedFile || isSaving}
|
||||
>
|
||||
{t('importDatabase')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={cleanupModalOpen}
|
||||
onClose={handleCloseCleanupModal}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: { borderRadius: 2 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ m: 0, p: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography variant="h6" component="div" sx={{ fontWeight: 600 }}>
|
||||
{t('cleanupBackupDatabases')}
|
||||
</Typography>
|
||||
<IconButton
|
||||
aria-label="close"
|
||||
onClick={handleCloseCleanupModal}
|
||||
sx={{
|
||||
color: (theme) => theme.palette.grey[500],
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<DialogContentText sx={{ mb: 2, color: 'text.primary' }}>
|
||||
{t('cleanupBackupDatabasesWarning')}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2 }}>
|
||||
<Button onClick={handleCloseCleanupModal} disabled={isSaving}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmCleanup}
|
||||
variant="contained"
|
||||
color="warning"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('cleanupBackupDatabases')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Settings } from '../types';
|
||||
import ConsoleManager from '../utils/consoleManager';
|
||||
import { SNACKBAR_AUTO_HIDE_DURATION } from '../utils/constants';
|
||||
import { generateTimestamp } from '../utils/formatUtils';
|
||||
import { Language } from '../utils/translations';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
@@ -311,7 +312,107 @@ const SettingsPage: React.FC = () => {
|
||||
setSettings(prev => ({ ...prev, tags: newTags }));
|
||||
};
|
||||
|
||||
const isSaving = saveMutation.isPending || migrateMutation.isPending || cleanupMutation.isPending || deleteLegacyMutation.isPending || formatFilenamesMutation.isPending;
|
||||
// Export database mutation
|
||||
const exportDatabaseMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/settings/export-database`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
return response;
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
// Create a blob URL and trigger download
|
||||
const blob = new Blob([response.data], { type: 'application/octet-stream' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// Generate filename with timestamp using helper (same format as backend)
|
||||
const timestamp = generateTimestamp();
|
||||
const filename = `mytube-backup-${timestamp}.db`;
|
||||
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
setMessage({ text: t('databaseExportedSuccess'), type: 'success' });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorDetails = error.response?.data?.details || error.message;
|
||||
setMessage({
|
||||
text: `${t('databaseExportFailed')}${errorDetails ? `: ${errorDetails}` : ''}`,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Import database mutation
|
||||
const importDatabaseMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await axios.post(`${API_URL}/settings/import-database`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('success'),
|
||||
message: t('databaseImportedSuccess'),
|
||||
type: 'success'
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorDetails = error.response?.data?.details || error.message;
|
||||
setInfoModal({
|
||||
isOpen: true,
|
||||
title: t('error'),
|
||||
message: `${t('databaseImportFailed')}${errorDetails ? `: ${errorDetails}` : ''}`,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleExportDatabase = () => {
|
||||
exportDatabaseMutation.mutate();
|
||||
};
|
||||
|
||||
const handleImportDatabase = (file: File) => {
|
||||
importDatabaseMutation.mutate(file);
|
||||
};
|
||||
|
||||
// Cleanup backup databases mutation
|
||||
const cleanupBackupDatabasesMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await axios.post(`${API_URL}/settings/cleanup-backup-databases`);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setMessage({
|
||||
text: data.message || t('backupDatabasesCleanedUp'),
|
||||
type: 'success'
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const errorDetails = error.response?.data?.details || error.message;
|
||||
setMessage({
|
||||
text: `${t('backupDatabasesCleanupFailed')}${errorDetails ? `: ${errorDetails}` : ''}`,
|
||||
type: 'error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleCleanupBackupDatabases = () => {
|
||||
cleanupBackupDatabasesMutation.mutate();
|
||||
};
|
||||
|
||||
const isSaving = saveMutation.isPending || migrateMutation.isPending || cleanupMutation.isPending || deleteLegacyMutation.isPending || formatFilenamesMutation.isPending || exportDatabaseMutation.isPending || importDatabaseMutation.isPending || cleanupBackupDatabasesMutation.isPending;
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
@@ -407,6 +508,9 @@ const SettingsPage: React.FC = () => {
|
||||
onMigrate={() => setShowMigrateConfirmModal(true)}
|
||||
onDeleteLegacy={() => setShowDeleteLegacyModal(true)}
|
||||
onFormatFilenames={() => setShowFormatConfirmModal(true)}
|
||||
onExportDatabase={handleExportDatabase}
|
||||
onImportDatabase={handleImportDatabase}
|
||||
onCleanupBackupDatabases={handleCleanupBackupDatabases}
|
||||
isSaving={isSaving}
|
||||
moveSubtitlesToVideoFolder={settings.moveSubtitlesToVideoFolder || false}
|
||||
onMoveSubtitlesToVideoFolderChange={(checked) => handleChange('moveSubtitlesToVideoFolder', checked)}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
|
||||
/**
|
||||
* Helper to parse duration to seconds
|
||||
*/
|
||||
export const parseDuration = (duration: string | number | undefined): number => {
|
||||
export const parseDuration = (
|
||||
duration: string | number | undefined
|
||||
): number => {
|
||||
if (!duration) return 0;
|
||||
if (typeof duration === 'number') return duration;
|
||||
if (typeof duration === "number") return duration;
|
||||
|
||||
if (duration.includes(':')) {
|
||||
const parts = duration.split(':').map(part => parseInt(part, 10));
|
||||
if (duration.includes(":")) {
|
||||
const parts = duration.split(":").map((part) => parseInt(part, 10));
|
||||
if (parts.length === 3) {
|
||||
const result = parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
return isNaN(result) ? 0 : result;
|
||||
@@ -24,41 +25,45 @@ export const parseDuration = (duration: string | number | undefined): number =>
|
||||
/**
|
||||
* Format duration (seconds or MM:SS or H:MM:SS)
|
||||
*/
|
||||
export const formatDuration = (duration: string | number | undefined): string => {
|
||||
if (!duration) return '00:00';
|
||||
export const formatDuration = (
|
||||
duration: string | number | undefined
|
||||
): string => {
|
||||
if (!duration) return "00:00";
|
||||
|
||||
// If it's already a string with colon, assume it's formatted
|
||||
if (typeof duration === 'string' && duration.includes(':')) {
|
||||
if (typeof duration === "string" && duration.includes(":")) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
const seconds = parseDuration(duration);
|
||||
if (isNaN(seconds) || seconds === 0) return '00:00';
|
||||
if (isNaN(seconds) || seconds === 0) return "00:00";
|
||||
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
return `${h}:${m.toString().padStart(2, "0")}:${s
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format file size in bytes to human readable string
|
||||
*/
|
||||
export const formatSize = (bytes: string | number | undefined): string => {
|
||||
if (!bytes) return '0 B';
|
||||
const size = typeof bytes === 'string' ? parseInt(bytes, 10) : bytes;
|
||||
if (isNaN(size)) return '0 B';
|
||||
if (!bytes) return "0 B";
|
||||
const size = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
|
||||
if (isNaN(size)) return "0 B";
|
||||
|
||||
if (size === 0) return '0 B';
|
||||
if (size === 0) return "0 B";
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(size) / Math.log(k));
|
||||
return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
return parseFloat((size / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -66,7 +71,7 @@ export const formatSize = (bytes: string | number | undefined): string => {
|
||||
*/
|
||||
export const formatDate = (dateString?: string) => {
|
||||
if (!dateString || dateString.length !== 8) {
|
||||
return 'Unknown date';
|
||||
return "Unknown date";
|
||||
}
|
||||
|
||||
const year = dateString.substring(0, 4);
|
||||
@@ -75,3 +80,18 @@ export const formatDate = (dateString?: string) => {
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate timestamp string in format YYYY-MM-DD-HH-MM-SS
|
||||
* Matches the backend generateTimestamp() function format
|
||||
*/
|
||||
export const generateTimestamp = (): 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 `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`;
|
||||
};
|
||||
|
||||
@@ -468,4 +468,23 @@ export const ar = {
|
||||
hide: "إخفاء",
|
||||
reset: "إعادة تعيين",
|
||||
more: "المزيد",
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "تصدير/استيراد قاعدة البيانات",
|
||||
exportImportDatabaseDescription:
|
||||
"قم بتصدير قاعدة البيانات الخاصة بك كملف نسخ احتياطي أو قم باستيراد نسخة احتياطية تم تصديرها مسبقًا. سيؤدي الاستيراد إلى استبدال البيانات الموجودة ببيانات النسخ الاحتياطي.",
|
||||
exportDatabase: "تصدير قاعدة البيانات",
|
||||
importDatabase: "استيراد قاعدة البيانات",
|
||||
onlyDbFilesAllowed: "يسمح فقط بملفات .db",
|
||||
importDatabaseWarning:
|
||||
"تحذير: سيؤدي استيراد قاعدة البيانات إلى استبدال جميع البيانات الموجودة. تأكد من تصدير قاعدة البيانات الحالية أولاً كنسخة احتياطية.",
|
||||
selectDatabaseFile: "اختر ملف قاعدة البيانات",
|
||||
databaseExportedSuccess: "تم تصدير قاعدة البيانات بنجاح",
|
||||
databaseExportFailed: "فشل تصدير قاعدة البيانات",
|
||||
databaseImportedSuccess: "تم استيراد قاعدة البيانات بنجاح. تم استبدال البيانات الموجودة ببيانات النسخ الاحتياطي.",
|
||||
databaseImportFailed: "فشل استيراد قاعدة البيانات",
|
||||
cleanupBackupDatabases: "تنظيف قواعد البيانات الاحتياطية",
|
||||
cleanupBackupDatabasesWarning: "تحذير: سيؤدي هذا إلى حذف جميع ملفات قاعدة البيانات الاحتياطية (mytube-backup-*.db.backup) التي تم إنشاؤها أثناء عمليات الاستيراد السابقة بشكل دائم. لا يمكن التراجع عن هذا الإجراء. هل أنت متأكد أنك تريد المتابعة؟",
|
||||
backupDatabasesCleanedUp: "تم تنظيف قواعد البيانات الاحتياطية بنجاح",
|
||||
backupDatabasesCleanupFailed: "فشل تنظيف قواعد البيانات الاحتياطية",
|
||||
};
|
||||
|
||||
@@ -448,4 +448,23 @@ export const de = {
|
||||
moveThumbnailsToVideoFolderOn: 'Zusammen mit Video',
|
||||
moveThumbnailsToVideoFolderOff: 'In isoliertem Bilderordner',
|
||||
moveThumbnailsToVideoFolderDescription: 'Wenn aktiviert, werden Thumbnail-Dateien in denselben Ordner wie die Videodatei verschoben. Wenn deaktiviert, werden sie in den isolierten Bilderordner verschoben.',
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "Datenbank Exportieren/Importieren",
|
||||
exportImportDatabaseDescription:
|
||||
"Exportieren Sie Ihre Datenbank als Backup-Datei oder importieren Sie ein zuvor exportiertes Backup. Der Import überschreibt vorhandene Daten mit den Backup-Daten.",
|
||||
exportDatabase: "Datenbank Exportieren",
|
||||
importDatabase: "Datenbank Importieren",
|
||||
onlyDbFilesAllowed: "Nur .db-Dateien erlaubt",
|
||||
importDatabaseWarning:
|
||||
"Warnung: Das Importieren einer Datenbank überschreibt alle vorhandenen Daten. Stellen Sie sicher, dass Sie zuerst Ihre aktuelle Datenbank als Backup exportieren.",
|
||||
selectDatabaseFile: "Datenbankdatei Auswählen",
|
||||
databaseExportedSuccess: "Datenbank erfolgreich exportiert",
|
||||
databaseExportFailed: "Fehler beim Exportieren der Datenbank",
|
||||
databaseImportedSuccess: "Datenbank erfolgreich importiert. Vorhandene Daten wurden mit den Backup-Daten überschrieben.",
|
||||
databaseImportFailed: "Fehler beim Importieren der Datenbank",
|
||||
cleanupBackupDatabases: "Backup-Datenbanken Bereinigen",
|
||||
cleanupBackupDatabasesWarning: "Warnung: Dies löscht dauerhaft alle Backup-Datenbankdateien (mytube-backup-*.db.backup), die bei vorherigen Importen erstellt wurden. Diese Aktion kann nicht rückgängig gemacht werden. Sind Sie sicher, dass Sie fortfahren möchten?",
|
||||
backupDatabasesCleanedUp: "Backup-Datenbanken erfolgreich bereinigt",
|
||||
backupDatabasesCleanupFailed: "Fehler beim Bereinigen der Backup-Datenbanken",
|
||||
};
|
||||
|
||||
@@ -481,4 +481,23 @@ export const en = {
|
||||
moveThumbnailsToVideoFolderOff: "In isolated images folder",
|
||||
moveThumbnailsToVideoFolderDescription:
|
||||
"When enabled, thumbnail files will be moved to the same folder as the video file. When disabled, they will be moved to the isolated images folder.",
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "Export/Import Database",
|
||||
exportImportDatabaseDescription:
|
||||
"Export your database as a backup file or import a previously exported backup. Importing will overwrite existing data with the backup data.",
|
||||
exportDatabase: "Export Database",
|
||||
importDatabase: "Import Database",
|
||||
onlyDbFilesAllowed: "Only .db files are allowed",
|
||||
importDatabaseWarning:
|
||||
"Warning: Importing a database will overwrite all existing data. Make sure to export your current database first as a backup.",
|
||||
selectDatabaseFile: "Select Database File",
|
||||
databaseExportedSuccess: "Database exported successfully",
|
||||
databaseExportFailed: "Failed to export database",
|
||||
databaseImportedSuccess: "Database imported successfully. Existing data has been overwritten with the backup data.",
|
||||
databaseImportFailed: "Failed to import database",
|
||||
cleanupBackupDatabases: "Clean Up Backup Databases",
|
||||
cleanupBackupDatabasesWarning: "Warning: This will permanently delete all backup database files (mytube-backup-*.db.backup) that were created during previous imports. This action cannot be undone. Are you sure you want to continue?",
|
||||
backupDatabasesCleanedUp: "Backup databases cleaned up successfully",
|
||||
backupDatabasesCleanupFailed: "Failed to clean up backup databases",
|
||||
};
|
||||
|
||||
@@ -454,4 +454,23 @@ export const es = {
|
||||
hide: "Ocultar",
|
||||
reset: "Restablecer",
|
||||
more: "Más",
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "Exportar/Importar Base de Datos",
|
||||
exportImportDatabaseDescription:
|
||||
"Exporte su base de datos como archivo de respaldo o importe una copia de seguridad previamente exportada. La importación sobrescribirá los datos existentes con los datos de respaldo.",
|
||||
exportDatabase: "Exportar Base de Datos",
|
||||
importDatabase: "Importar Base de Datos",
|
||||
onlyDbFilesAllowed: "Solo se permiten archivos .db",
|
||||
importDatabaseWarning:
|
||||
"Advertencia: Importar una base de datos sobrescribirá todos los datos existentes. Asegúrese de exportar primero su base de datos actual como respaldo.",
|
||||
selectDatabaseFile: "Seleccionar Archivo de Base de Datos",
|
||||
databaseExportedSuccess: "Base de datos exportada exitosamente",
|
||||
databaseExportFailed: "Error al exportar la base de datos",
|
||||
databaseImportedSuccess: "Base de datos importada exitosamente. Los datos existentes han sido sobrescritos con los datos de respaldo.",
|
||||
databaseImportFailed: "Error al importar la base de datos",
|
||||
cleanupBackupDatabases: "Limpiar Bases de Datos de Respaldo",
|
||||
cleanupBackupDatabasesWarning: "Advertencia: Esto eliminará permanentemente todos los archivos de base de datos de respaldo (mytube-backup-*.db.backup) que se crearon durante importaciones anteriores. Esta acción no se puede deshacer. ¿Está seguro de que desea continuar?",
|
||||
backupDatabasesCleanedUp: "Bases de datos de respaldo limpiadas exitosamente",
|
||||
backupDatabasesCleanupFailed: "Error al limpiar las bases de datos de respaldo",
|
||||
};
|
||||
|
||||
@@ -486,4 +486,23 @@ export const fr = {
|
||||
moveThumbnailsToVideoFolderOn: 'Avec la vidéo',
|
||||
moveThumbnailsToVideoFolderOff: 'Dans un dossier d\'images isolé',
|
||||
moveThumbnailsToVideoFolderDescription: 'Si activé, les fichiers de miniatures seront déplacés dans le même dossier que le fichier vidéo. Si désactivé, ils seront déplacés dans le dossier d\'images isolé.',
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "Exporter/Importer la Base de Données",
|
||||
exportImportDatabaseDescription:
|
||||
"Exportez votre base de données en tant que fichier de sauvegarde ou importez une sauvegarde précédemment exportée. L'importation remplacera les données existantes par les données de sauvegarde.",
|
||||
exportDatabase: "Exporter la Base de Données",
|
||||
importDatabase: "Importer la Base de Données",
|
||||
onlyDbFilesAllowed: "Seuls les fichiers .db sont autorisés",
|
||||
importDatabaseWarning:
|
||||
"Avertissement : L'importation d'une base de données remplacera toutes les données existantes. Assurez-vous d'abord d'exporter votre base de données actuelle en tant que sauvegarde.",
|
||||
selectDatabaseFile: "Sélectionner un Fichier de Base de Données",
|
||||
databaseExportedSuccess: "Base de données exportée avec succès",
|
||||
databaseExportFailed: "Échec de l'exportation de la base de données",
|
||||
databaseImportedSuccess: "Base de données importée avec succès. Les données existantes ont été remplacées par les données de sauvegarde.",
|
||||
databaseImportFailed: "Échec de l'importation de la base de données",
|
||||
cleanupBackupDatabases: "Nettoyer les Bases de Données de Sauvegarde",
|
||||
cleanupBackupDatabasesWarning: "Avertissement : Cela supprimera définitivement tous les fichiers de base de données de sauvegarde (mytube-backup-*.db.backup) qui ont été créés lors d'importations précédentes. Cette action ne peut pas être annulée. Êtes-vous sûr de vouloir continuer ?",
|
||||
backupDatabasesCleanedUp: "Bases de données de sauvegarde nettoyées avec succès",
|
||||
backupDatabasesCleanupFailed: "Échec du nettoyage des bases de données de sauvegarde",
|
||||
};
|
||||
|
||||
@@ -475,4 +475,23 @@ export const ja = {
|
||||
moveThumbnailsToVideoFolderOn: '動画と一緒',
|
||||
moveThumbnailsToVideoFolderOff: '独立した画像フォルダ',
|
||||
moveThumbnailsToVideoFolderDescription: '有効にすると、サムネイルファイルは動画ファイルと同じフォルダに移動されます。無効にすると、独立した画像フォルダに移動されます。',
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "データベースのエクスポート/インポート",
|
||||
exportImportDatabaseDescription:
|
||||
"データベースをバックアップファイルとしてエクスポートするか、以前にエクスポートしたバックアップをインポートします。インポートすると、既存のデータがバックアップデータで上書きされます。",
|
||||
exportDatabase: "データベースをエクスポート",
|
||||
importDatabase: "データベースをインポート",
|
||||
onlyDbFilesAllowed: ".dbファイルのみ許可されています",
|
||||
importDatabaseWarning:
|
||||
"警告:データベースをインポートすると、既存のすべてのデータが上書きされます。まず現在のデータベースをバックアップとしてエクスポートしてください。",
|
||||
selectDatabaseFile: "データベースファイルを選択",
|
||||
databaseExportedSuccess: "データベースのエクスポートが成功しました",
|
||||
databaseExportFailed: "データベースのエクスポートに失敗しました",
|
||||
databaseImportedSuccess: "データベースのインポートが成功しました。既存のデータがバックアップデータで上書きされました。",
|
||||
databaseImportFailed: "データベースのインポートに失敗しました",
|
||||
cleanupBackupDatabases: "バックアップデータベースをクリーンアップ",
|
||||
cleanupBackupDatabasesWarning: "警告:これにより、以前のインポート時に作成されたすべてのバックアップデータベースファイル(mytube-backup-*.db.backup)が永続的に削除されます。この操作は元に戻せません。続行してもよろしいですか?",
|
||||
backupDatabasesCleanedUp: "バックアップデータベースのクリーンアップが成功しました",
|
||||
backupDatabasesCleanupFailed: "バックアップデータベースのクリーンアップに失敗しました",
|
||||
};
|
||||
|
||||
@@ -469,4 +469,23 @@ export const ko = {
|
||||
moveThumbnailsToVideoFolderOn: '동영상과 함께',
|
||||
moveThumbnailsToVideoFolderOff: '분리된 이미지 폴더',
|
||||
moveThumbnailsToVideoFolderDescription: '활성화하면 썸네일 파일이 동영상 파일과 같은 폴더로 이동합니다. 비활성화하면 분리된 이미지 폴더로 이동합니다.',
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "데이터베이스 내보내기/가져오기",
|
||||
exportImportDatabaseDescription:
|
||||
"데이터베이스를 백업 파일로 내보내거나 이전에 내보낸 백업을 가져옵니다. 가져오기는 기존 데이터를 백업 데이터로 덮어씁니다.",
|
||||
exportDatabase: "데이터베이스 내보내기",
|
||||
importDatabase: "데이터베이스 가져오기",
|
||||
onlyDbFilesAllowed: ".db 파일만 허용됩니다",
|
||||
importDatabaseWarning:
|
||||
"경고: 데이터베이스를 가져오면 모든 기존 데이터가 덮어씌워집니다. 먼저 현재 데이터베이스를 백업으로 내보내야 합니다.",
|
||||
selectDatabaseFile: "데이터베이스 파일 선택",
|
||||
databaseExportedSuccess: "데이터베이스 내보내기 성공",
|
||||
databaseExportFailed: "데이터베이스 내보내기 실패",
|
||||
databaseImportedSuccess: "데이터베이스 가져오기 성공. 기존 데이터가 백업 데이터로 덮어씌워졌습니다.",
|
||||
databaseImportFailed: "데이터베이스 가져오기 실패",
|
||||
cleanupBackupDatabases: "백업 데이터베이스 정리",
|
||||
cleanupBackupDatabasesWarning: "경고: 이 작업은 이전 가져오기 중에 생성된 모든 백업 데이터베이스 파일(mytube-backup-*.db.backup)을 영구적으로 삭제합니다. 이 작업은 취소할 수 없습니다. 계속하시겠습니까?",
|
||||
backupDatabasesCleanedUp: "백업 데이터베이스 정리 성공",
|
||||
backupDatabasesCleanupFailed: "백업 데이터베이스 정리 실패",
|
||||
};
|
||||
|
||||
@@ -480,4 +480,23 @@ export const pt = {
|
||||
moveThumbnailsToVideoFolderOn: 'Junto com o vídeo',
|
||||
moveThumbnailsToVideoFolderOff: 'Em pasta de imagens isolada',
|
||||
moveThumbnailsToVideoFolderDescription: 'Quando ativado, os arquivos de miniatura serão movidos para a mesma pasta do arquivo de vídeo. Quando desativado, eles serão movidos para a pasta de imagens isolada.',
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "Exportar/Importar Banco de Dados",
|
||||
exportImportDatabaseDescription:
|
||||
"Exporte seu banco de dados como arquivo de backup ou importe um backup previamente exportado. A importação substituirá os dados existentes pelos dados de backup.",
|
||||
exportDatabase: "Exportar Banco de Dados",
|
||||
importDatabase: "Importar Banco de Dados",
|
||||
onlyDbFilesAllowed: "Apenas arquivos .db são permitidos",
|
||||
importDatabaseWarning:
|
||||
"Aviso: Importar um banco de dados substituirá todos os dados existentes. Certifique-se de exportar primeiro seu banco de dados atual como backup.",
|
||||
selectDatabaseFile: "Selecionar Arquivo de Banco de Dados",
|
||||
databaseExportedSuccess: "Banco de dados exportado com sucesso",
|
||||
databaseExportFailed: "Falha ao exportar banco de dados",
|
||||
databaseImportedSuccess: "Banco de dados importado com sucesso. Os dados existentes foram substituídos pelos dados de backup.",
|
||||
databaseImportFailed: "Falha ao importar banco de dados",
|
||||
cleanupBackupDatabases: "Limpar Bancos de Dados de Backup",
|
||||
cleanupBackupDatabasesWarning: "Aviso: Isso excluirá permanentemente todos os arquivos de banco de dados de backup (mytube-backup-*.db.backup) que foram criados durante importações anteriores. Esta ação não pode ser desfeita. Tem certeza de que deseja continuar?",
|
||||
backupDatabasesCleanedUp: "Bancos de dados de backup limpos com sucesso",
|
||||
backupDatabasesCleanupFailed: "Falha ao limpar bancos de dados de backup",
|
||||
};
|
||||
|
||||
@@ -473,4 +473,23 @@ export const ru = {
|
||||
hide: "Скрыть",
|
||||
reset: "Сбросить",
|
||||
more: "Ещё",
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "Экспорт/Импорт Базы Данных",
|
||||
exportImportDatabaseDescription:
|
||||
"Экспортируйте базу данных как файл резервной копии или импортируйте ранее экспортированную резервную копию. Импорт перезапишет существующие данные данными из резервной копии.",
|
||||
exportDatabase: "Экспортировать Базу Данных",
|
||||
importDatabase: "Импортировать Базу Данных",
|
||||
onlyDbFilesAllowed: "Разрешены только файлы .db",
|
||||
importDatabaseWarning:
|
||||
"Предупреждение: Импорт базы данных перезапишет все существующие данные. Убедитесь, что вы сначала экспортировали текущую базу данных в качестве резервной копии.",
|
||||
selectDatabaseFile: "Выбрать Файл Базы Данных",
|
||||
databaseExportedSuccess: "База данных успешно экспортирована",
|
||||
databaseExportFailed: "Не удалось экспортировать базу данных",
|
||||
databaseImportedSuccess: "База данных успешно импортирована. Существующие данные были перезаписаны данными из резервной копии.",
|
||||
databaseImportFailed: "Не удалось импортировать базу данных",
|
||||
cleanupBackupDatabases: "Очистить Резервные Копии Базы Данных",
|
||||
cleanupBackupDatabasesWarning: "Предупреждение: Это навсегда удалит все файлы резервных копий базы данных (mytube-backup-*.db.backup), которые были созданы во время предыдущих импортов. Это действие нельзя отменить. Вы уверены, что хотите продолжить?",
|
||||
backupDatabasesCleanedUp: "Резервные копии базы данных успешно очищены",
|
||||
backupDatabasesCleanupFailed: "Не удалось очистить резервные копии базы данных",
|
||||
};
|
||||
|
||||
@@ -465,4 +465,23 @@ export const zh = {
|
||||
moveThumbnailsToVideoFolderOn: '与视频在一起',
|
||||
moveThumbnailsToVideoFolderOff: '在独立的图片文件夹中',
|
||||
moveThumbnailsToVideoFolderDescription: '启用后,封面文件将被移动到与视频文件相同的文件夹中。禁用后,它们将被移动到独立的图片文件夹中。',
|
||||
|
||||
// Database Export/Import
|
||||
exportImportDatabase: "导出/导入数据库",
|
||||
exportImportDatabaseDescription:
|
||||
"将数据库导出为备份文件或导入之前导出的备份。导入将使用备份数据覆盖现有数据。",
|
||||
exportDatabase: "导出数据库",
|
||||
importDatabase: "导入数据库",
|
||||
onlyDbFilesAllowed: "仅允许 .db 文件",
|
||||
importDatabaseWarning:
|
||||
"警告:导入数据库将覆盖所有现有数据。请确保首先导出当前数据库作为备份。",
|
||||
selectDatabaseFile: "选择数据库文件",
|
||||
databaseExportedSuccess: "数据库导出成功",
|
||||
databaseExportFailed: "数据库导出失败",
|
||||
databaseImportedSuccess: "数据库导入成功。现有数据已被备份数据覆盖。",
|
||||
databaseImportFailed: "数据库导入失败",
|
||||
cleanupBackupDatabases: "清理备份数据库",
|
||||
cleanupBackupDatabasesWarning: "警告:此操作将永久删除所有在之前导入时创建的备份数据库文件(mytube-backup-*.db.backup)。此操作无法撤销。您确定要继续吗?",
|
||||
backupDatabasesCleanedUp: "备份数据库清理成功",
|
||||
backupDatabasesCleanupFailed: "清理备份数据库失败",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user