Implemented backup and restore feature for configuration file and database

This commit is contained in:
morpheus65535 2022-02-22 22:55:07 -05:00 committed by GitHub
parent c020a9e892
commit 5bfaba9360
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 652 additions and 13 deletions

View File

@ -6,6 +6,7 @@ from flask_restful import Api
from .system import System
from .searches import Searches
from .account import SystemAccount
from .backups import SystemBackups
from .tasks import SystemTasks
from .logs import SystemLogs
from .status import SystemStatus
@ -22,6 +23,7 @@ api = Api(api_bp_system)
api.add_resource(System, '/system')
api.add_resource(Searches, '/system/searches')
api.add_resource(SystemAccount, '/system/account')
api.add_resource(SystemBackups, '/system/backups')
api.add_resource(SystemTasks, '/system/tasks')
api.add_resource(SystemLogs, '/system/logs')
api.add_resource(SystemStatus, '/system/status')

View File

@ -0,0 +1,37 @@
# coding=utf-8
from flask import jsonify, request
from flask_restful import Resource
from ..utils import authenticate
from backup import get_backup_files, prepare_restore, delete_backup_file, backup_to_zip
class SystemBackups(Resource):
@authenticate
def get(self):
backups = get_backup_files(fullpath=False)
return jsonify(data=backups)
@authenticate
def post(self):
backup_to_zip()
return '', 204
@authenticate
def patch(self):
filename = request.form.get('filename')
if filename:
restored = prepare_restore(filename)
if restored:
return '', 204
return '', 501
@authenticate
def delete(self):
filename = request.form.get('filename')
if filename:
deleted = delete_backup_file(filename)
if deleted:
return '', 204
return '', 501

197
bazarr/backup.py Normal file
View File

@ -0,0 +1,197 @@
# coding=utf-8
import os
import io
import sqlite3
import shutil
import logging
from datetime import datetime, timedelta
from zipfile import ZipFile, BadZipFile
from glob import glob
from get_args import args
from config import settings
def get_backup_path():
backup_dir = settings.backup.folder
if not os.path.isdir(backup_dir):
os.makedirs(backup_dir)
logging.debug(f'Backup directory path is: {backup_dir}')
return backup_dir
def get_restore_path():
restore_dir = os.path.join(args.config_dir, 'restore')
if not os.path.isdir(restore_dir):
os.makedirs(restore_dir)
logging.debug(f'Restore directory path is: {restore_dir}')
return restore_dir
def get_backup_files(fullpath=True):
backup_file_pattern = os.path.join(get_backup_path(), 'bazarr_backup_v*.zip')
file_list = glob(backup_file_pattern)
if fullpath:
return file_list
else:
return [{
'type': 'backup',
'filename': os.path.basename(x),
'date': datetime.fromtimestamp(os.path.getmtime(x)).strftime("%b %d %Y")
} for x in file_list]
def backup_to_zip():
now = datetime.now()
now_string = now.strftime("%Y.%m.%d_%H.%M.%S")
backup_filename = f"bazarr_backup_v{os.environ['BAZARR_VERSION']}_{now_string}.zip"
logging.debug(f'Backup filename will be: {backup_filename}')
database_src_file = os.path.join(args.config_dir, 'db', 'bazarr.db')
logging.debug(f'Database file path to backup is: {database_src_file}')
try:
database_src_con = sqlite3.connect(database_src_file)
database_backup_file = os.path.join(get_backup_path(), 'bazarr_temp.db')
database_backup_con = sqlite3.connect(database_backup_file)
with database_backup_con:
database_src_con.backup(database_backup_con)
database_backup_con.close()
database_src_con.close()
except Exception:
database_backup_file = None
logging.exception('Unable to backup database file.')
config_file = os.path.join(args.config_dir, 'config', 'config.ini')
logging.debug(f'Config file path to backup is: {config_file}')
with ZipFile(os.path.join(get_backup_path(), backup_filename), 'w') as backupZip:
if database_backup_file:
backupZip.write(database_backup_file, 'bazarr.db')
else:
logging.debug(f'Database file is not included in backup. See previous exception')
backupZip.write(config_file, 'config.ini')
try:
os.remove(database_backup_file)
except OSError:
logging.exception(f'Unable to delete temporary database backup file: {database_backup_file}')
def restore_from_backup():
restore_config_path = os.path.join(get_restore_path(), 'config.ini')
dest_config_path = os.path.join(args.config_dir, 'config', 'config.ini')
restore_database_path = os.path.join(get_restore_path(), 'bazarr.db')
dest_database_path = os.path.join(args.config_dir, 'db', 'bazarr.db')
if os.path.isfile(restore_config_path) and os.path.isfile(restore_database_path):
try:
shutil.copy(restore_config_path, dest_config_path)
os.remove(restore_config_path)
except OSError:
logging.exception(f'Unable to restore or delete config.ini to {dest_config_path}')
try:
shutil.copy(restore_database_path, dest_database_path)
os.remove(restore_database_path)
except OSError:
logging.exception(f'Unable to restore or delete bazarr.db to {dest_database_path}')
else:
try:
if os.path.isfile(dest_database_path + '-shm'):
os.remove(dest_database_path + '-shm')
if os.path.isfile(dest_database_path + '-wal'):
os.remove(dest_database_path + '-wal')
except OSError:
logging.exception('Unable to delete SHM and WAL file.')
logging.info('Backup restored successfully. Bazarr will restart.')
try:
restart_file = io.open(os.path.join(args.config_dir, "bazarr.restart"), "w", encoding='UTF-8')
except Exception as e:
logging.error('BAZARR Cannot create bazarr.restart file: ' + repr(e))
else:
logging.info('Bazarr is being restarted...')
restart_file.write(str(''))
restart_file.close()
os._exit(0)
elif os.path.isfile(restore_config_path) or os.path.isfile(restore_database_path):
logging.debug('Cannot restore a partial backup. You must have both config and database.')
else:
logging.debug('No backup to restore.')
return
try:
os.remove(restore_config_path)
except OSError:
logging.exception(f'Unable to delete {dest_config_path}')
try:
os.remove(restore_database_path)
except OSError:
logging.exception(f'Unable to delete {dest_database_path}')
def prepare_restore(filename):
src_zip_file_path = os.path.join(get_backup_path(), filename)
dest_zip_file_path = os.path.join(get_restore_path(), filename)
success = False
try:
shutil.copy(src_zip_file_path, dest_zip_file_path)
except OSError:
logging.exception(f'Unable to copy backup archive to {dest_zip_file_path}')
else:
try:
with ZipFile(dest_zip_file_path, 'r') as zipObj:
zipObj.extractall(path=get_restore_path())
except BadZipFile:
logging.exception(f'Unable to extract files from backup archive {dest_zip_file_path}')
success = True
finally:
try:
os.remove(dest_zip_file_path)
except OSError:
logging.exception(f'Unable to delete backup archive {dest_zip_file_path}')
if success:
logging.debug('time to restart')
from server import webserver
webserver.restart()
def backup_rotation():
backup_retention = settings.backup.retention
try:
int(backup_retention)
except ValueError:
logging.error('Backup retention time must be a valid integer. Please fix this in your settings.')
return
backup_files = get_backup_files()
logging.debug(f'Cleaning up backup files older than {backup_retention} days')
for file in backup_files:
if datetime.fromtimestamp(os.path.getmtime(file)) + timedelta(days=backup_retention) < datetime.utcnow():
logging.debug(f'Deleting old backup file {file}')
try:
os.remove(file)
except OSError:
logging.debug(f'Unable to delete backup file {file}')
logging.debug('Finished cleaning up old backup files')
def delete_backup_file(filename):
backup_file_path = os.path.join(get_backup_path(), filename)
try:
os.remove(backup_file_path)
return True
except OSError:
logging.debug(f'Unable to delete backup file {backup_file_path}')
return False

View File

@ -81,6 +81,13 @@ defaults = {
'username': '',
'password': ''
},
'backup': {
'folder': os.path.join(args.config_dir, 'backup'),
'retention': '31',
'frequency': 'Weekly',
'day': '6',
'hour': '3'
},
'sonarr': {
'ip': '127.0.0.1',
'port': '8989',
@ -383,7 +390,8 @@ def save_settings(settings_items):
'settings-sonarr-full_update', 'settings-sonarr-full_update_day', 'settings-sonarr-full_update_hour',
'settings-radarr-full_update', 'settings-radarr-full_update_day', 'settings-radarr-full_update_hour',
'settings-general-wanted_search_frequency', 'settings-general-wanted_search_frequency_movie',
'settings-general-upgrade_frequency']:
'settings-general-upgrade_frequency', 'settings-backup-frequency', 'settings-backup-day',
'settings-backup-hour']:
update_schedule = True
if key in ['settings-general-use_sonarr', 'settings-sonarr-ip', 'settings-sonarr-port',

View File

@ -10,6 +10,7 @@ from config import settings, configure_captcha_func
from get_args import args
from logger import configure_logging
from helper import path_mappings
from backup import restore_from_backup
from dogpile.cache.region import register_backend as register_cache_backend
import subliminal
@ -20,6 +21,9 @@ import time
global startTime
startTime = time.time()
# restore backup if required
restore_from_backup()
# set subliminal_patch user agent
os.environ["SZ_USER_AGENT"] = "Bazarr/{}".format(os.environ["BAZARR_VERSION"])
@ -46,6 +50,8 @@ if not os.path.exists(os.path.join(args.config_dir, 'log')):
os.mkdir(os.path.join(args.config_dir, 'log'))
if not os.path.exists(os.path.join(args.config_dir, 'cache')):
os.mkdir(os.path.join(args.config_dir, 'cache'))
if not os.path.exists(os.path.join(args.config_dir, 'restore')):
os.mkdir(os.path.join(args.config_dir, 'restore'))
configure_logging(settings.general.getboolean('debug') or args.debug)
import logging # noqa E402

View File

@ -1,5 +1,18 @@
# coding=utf-8
import os
import pretty
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.events import EVENT_JOB_SUBMITTED, EVENT_JOB_EXECUTED, EVENT_JOB_ERROR
from apscheduler.jobstores.base import JobLookupError
from datetime import datetime, timedelta
from calendar import day_name
from random import randrange
from get_episodes import sync_episodes, update_all_episodes
from get_movies import update_movies, update_all_movies
from get_series import update_series
@ -12,17 +25,8 @@ if not args.no_update:
from check_update import check_if_new_update, check_releases
else:
from check_update import check_releases
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.events import EVENT_JOB_SUBMITTED, EVENT_JOB_EXECUTED, EVENT_JOB_ERROR
from datetime import datetime, timedelta
from calendar import day_name
import pretty
from random import randrange
from event_handler import event_stream
import os
from backup import backup_to_zip
class Scheduler:
@ -62,6 +66,7 @@ class Scheduler:
self.__search_wanted_subtitles_task()
self.__upgrade_subtitles_task()
self.__randomize_interval_task()
self.__automatic_backup()
if args.no_tasks:
self.__no_task()
@ -166,6 +171,24 @@ class Scheduler:
self.aps_scheduler.add_job(check_health, IntervalTrigger(hours=6), max_instances=1, coalesce=True,
misfire_grace_time=15, id='check_health', name='Check health')
def __automatic_backup(self):
backup = settings.backup.frequency
if backup == "Daily":
self.aps_scheduler.add_job(
backup_to_zip, CronTrigger(hour=settings.backup.hour), max_instances=1, coalesce=True,
misfire_grace_time=15, id='backup', name='Backup database and configuration file',
replace_existing=True)
elif backup == "Weekly":
self.aps_scheduler.add_job(
backup_to_zip, CronTrigger(day_of_week=settings.backup.day, hour=settings.backup.hour),
max_instances=1, coalesce=True, misfire_grace_time=15, id='backup',
name='Backup database and configuration file', replace_existing=True)
elif backup == "Manually":
try:
self.aps_scheduler.remove_job(job_id='backup')
except JobLookupError:
pass
def __sonarr_full_update_task(self):
if settings.general.getboolean('use_sonarr'):
full_update = settings.sonarr.full_update

View File

@ -6,6 +6,7 @@ interface Settings {
analytics: Settings.Analytic;
sonarr: Settings.Sonarr;
radarr: Settings.Radarr;
backup: Settings.Backup;
// Anitcaptcha
anticaptcha: Settings.Anticaptcha;
deathbycaptcha: Settings.DeathByCaptche;
@ -87,6 +88,14 @@ declare namespace Settings {
password?: string;
}
interface Backup {
folder: string;
retention: number;
frequency: string;
day: number;
hour: number;
}
interface Auth {
type?: string;
username?: string;

View File

@ -20,6 +20,13 @@ declare namespace System {
start_time: number;
}
interface Backups {
type: string;
filename: string;
date: string;
id: number;
}
interface Health {
object: string;
issue: string;

View File

@ -36,6 +36,7 @@ import SystemTasksView from "pages/System/Tasks";
import WantedMoviesView from "pages/Wanted/Movies";
import WantedSeriesView from "pages/Wanted/Series";
import { useMemo } from "react";
import SystemBackupsView from "../pages/System/Backups";
import { Navigation } from "./nav";
import RootRedirect from "./RootRedirect";
@ -225,6 +226,11 @@ export function useNavigationItems() {
badge: data?.providers,
component: SystemProvidersView,
},
{
name: "Backup",
path: "/backups",
component: SystemBackupsView,
},
{
name: "Status",
path: "/status",

View File

@ -105,6 +105,50 @@ export function useRunTask() {
{
onSuccess: () => {
client.invalidateQueries([QueryKeys.System, QueryKeys.Tasks]);
client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]);
},
}
);
}
export function useSystemBackups() {
return useQuery([QueryKeys.System, "backups"], () => api.system.backups());
}
export function useCreateBackups() {
const client = useQueryClient();
return useMutation(
[QueryKeys.System, QueryKeys.Backups],
() => api.system.createBackups(),
{
onSuccess: () => {
client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]);
},
}
);
}
export function useRestoreBackups() {
const client = useQueryClient();
return useMutation(
[QueryKeys.System, QueryKeys.Backups],
(filename: string) => api.system.restoreBackups(filename),
{
onSuccess: () => {
client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]);
},
}
);
}
export function useDeleteBackups() {
const client = useQueryClient();
return useMutation(
[QueryKeys.System, QueryKeys.Backups],
(filename: string) => api.system.deleteBackups(filename),
{
onSuccess: () => {
client.invalidateQueries([QueryKeys.System, QueryKeys.Backups]);
},
}
);

View File

@ -14,6 +14,7 @@ export enum QueryKeys {
Search = "search",
Actions = "actions",
Tasks = "tasks",
Backups = "backups",
Logs = "logs",
Infos = "infos",
History = "history",

View File

@ -51,6 +51,23 @@ class SystemApi extends BaseApi {
return response.data;
}
async backups() {
const response = await this.get<DataWrapper<System.Backups[]>>("/backups");
return response.data;
}
async createBackups() {
await this.post("/backups");
}
async restoreBackups(filename: string) {
await this.patch("/backups", { filename });
}
async deleteBackups(filename: string) {
await this.delete("/backups", { filename });
}
async health() {
const response = await this.get<DataWrapper<System.Health[]>>("/health");
return response.data;

View File

@ -30,14 +30,14 @@ function extractPath(raw: string) {
}
}
interface Props {
export interface FileBrowserProps {
defaultValue?: string;
type: "sonarr" | "radarr" | "bazarr";
onChange?: (path: string) => void;
drop?: DropdownProps["drop"];
}
export const FileBrowser: FunctionComponent<Props> = ({
export const FileBrowser: FunctionComponent<FileBrowserProps> = ({
defaultValue,
type,
onChange,

View File

@ -12,6 +12,7 @@ import {
Check,
Chips,
CollapseBox,
File,
Group,
Input,
Message,
@ -175,6 +176,10 @@ const SettingsGeneralView: FunctionComponent = () => {
<Message>Debug logging should only be enabled temporarily</Message>
</Input>
</Group>
<Group header="Backups">
<File settingKey="settings-backup-folder" type="bazarr"></File>
<Message>Absolute path to the backup directory</Message>
</Group>
<Group header="Analytics">
<Input>
<Check label="Enable" settingKey="settings-analytics-enabled"></Check>

View File

@ -9,6 +9,7 @@ import {
SettingsProvider,
} from "../components";
import {
backupOptions,
dayOptions,
diskUpdateOptions,
episodesSyncOptions,
@ -143,6 +144,34 @@ const SettingsSchedulerView: FunctionComponent = () => {
></Selector>
</Input>
</Group>
<Group header="Backup">
<CollapseBox>
<CollapseBox.Control>
<Input name="Backup config and database">
<Selector
settingKey="settings-backup-frequency"
options={backupOptions}
></Selector>
</Input>
</CollapseBox.Control>
<CollapseBox.Content on={(k) => k === "Weekly"}>
<Input name="Day of The Week">
<Selector
settingKey="settings-backup-day"
options={dayOptions}
></Selector>
</Input>
</CollapseBox.Content>
<CollapseBox.Content on={(k) => k === "Daily" || k === "Weekly"}>
<Input name="Time of The Day">
<Selector
settingKey="settings-backup-hour"
options={timeOptions}
></Selector>
</Input>
</CollapseBox.Content>
</CollapseBox>
</Group>
</SettingsProvider>
);
};

View File

@ -17,6 +17,8 @@ export const diskUpdateOptions: SelectorOption<string>[] = [
{ label: "Weekly", value: "Weekly" },
];
export const backupOptions = diskUpdateOptions;
export const dayOptions: SelectorOption<number>[] = [
{ label: "Monday", value: 0 },
{ label: "Tuesday", value: 1 },

View File

@ -1,6 +1,8 @@
import {
Chips as CChips,
ChipsProps as CChipsProps,
FileBrowser,
FileBrowserProps,
Selector as CSelector,
SelectorProps as CSelectorProps,
Slider as CSlider,
@ -214,3 +216,23 @@ export const Button: FunctionComponent<Override<ButtonProps, BSButtonProps>> = (
></BSButton>
);
};
type FileProps = {} & BaseInput<string>;
export const File: FunctionComponent<Override<FileProps, FileBrowserProps>> = (
props
) => {
const { settingKey, override, ...file } = props;
const value = useLatest<string>(settingKey, isString);
const update = useSingleUpdate();
return (
<FileBrowser
defaultValue={value ?? undefined}
onChange={(p) => {
update(p, settingKey);
}}
{...file}
></FileBrowser>
);
};

View File

@ -0,0 +1,57 @@
import {
AsyncButton,
BaseModal,
BaseModalProps,
useCloseModal,
useModalPayload,
} from "components";
import React, { FunctionComponent } from "react";
import { Button } from "react-bootstrap";
import { useDeleteBackups } from "../../../apis/hooks";
interface Props extends BaseModalProps {}
const SystemBackupDeleteModal: FunctionComponent<Props> = ({ ...modal }) => {
const result = useModalPayload<string>(modal.modalKey);
const { mutateAsync } = useDeleteBackups();
const closeModal = useCloseModal();
const footer = (
<div className="d-flex flex-row-reverse flex-grow-1 justify-content-between">
<div>
<Button
variant="outline-secondary"
className="mr-2"
onClick={() => {
closeModal(modal.modalKey);
}}
>
Cancel
</Button>
<AsyncButton
noReset
promise={() => {
if (result) {
return mutateAsync(result);
} else {
return null;
}
}}
onSuccess={() => closeModal(modal.modalKey)}
>
Delete
</AsyncButton>
</div>
</div>
);
return (
<BaseModal title="Delete Backup" footer={footer} {...modal}>
Are you sure you want to delete the backup '{result}'?
</BaseModal>
);
};
export default SystemBackupDeleteModal;

View File

@ -0,0 +1,58 @@
import {
AsyncButton,
BaseModal,
BaseModalProps,
useCloseModal,
useModalPayload,
} from "components";
import React, { FunctionComponent } from "react";
import { Button } from "react-bootstrap";
import { useRestoreBackups } from "../../../apis/hooks";
interface Props extends BaseModalProps {}
const SystemBackupRestoreModal: FunctionComponent<Props> = ({ ...modal }) => {
const result = useModalPayload<string>(modal.modalKey);
const { mutateAsync } = useRestoreBackups();
const closeModal = useCloseModal();
const footer = (
<div className="d-flex flex-row-reverse flex-grow-1 justify-content-between">
<div>
<Button
variant="outline-secondary"
className="mr-2"
onClick={() => {
closeModal(modal.modalKey);
}}
>
Cancel
</Button>
<AsyncButton
noReset
promise={() => {
if (result) {
return mutateAsync(result);
} else {
return null;
}
}}
onSuccess={() => closeModal(modal.modalKey)}
>
Restore
</AsyncButton>
</div>
</div>
);
return (
<BaseModal title="Restore Backup" footer={footer} {...modal}>
Are you sure you want to restore the backup '{result}'? Bazarr will
automatically restart and reload the UI during the restore process.
</BaseModal>
);
};
export default SystemBackupRestoreModal;

View File

@ -0,0 +1,39 @@
import { faFileArchive } from "@fortawesome/free-solid-svg-icons";
import { useCreateBackups, useSystemBackups } from "apis/hooks";
import { ContentHeader, QueryOverlay } from "components";
import React, { FunctionComponent } from "react";
import { Container, Row } from "react-bootstrap";
import { Helmet } from "react-helmet";
import Table from "./table";
interface Props {}
const SystemBackupsView: FunctionComponent<Props> = () => {
const backups = useSystemBackups();
const { mutate: backup, isLoading: isResetting } = useCreateBackups();
return (
<QueryOverlay result={backups}>
<Container fluid>
<Helmet>
<title>Backups - Bazarr (System)</title>
</Helmet>
<ContentHeader>
<ContentHeader.Button
icon={faFileArchive}
updating={isResetting}
onClick={() => backup()}
>
Backup Now
</ContentHeader.Button>
</ContentHeader>
<Row>
<Table backups={backups.data ?? []}></Table>
</Row>
</Container>
</QueryOverlay>
);
};
export default SystemBackupsView;

View File

@ -0,0 +1,70 @@
import { faClock, faHistory, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ActionButton, PageTable, useShowModal } from "components";
import React, { FunctionComponent, useMemo } from "react";
import { ButtonGroup } from "react-bootstrap";
import { Column } from "react-table";
import SystemBackupDeleteModal from "./BackupDeleteModal";
import SystemBackupRestoreModal from "./BackupRestoreModal";
interface Props {
backups: readonly System.Backups[];
}
const Table: FunctionComponent<Props> = ({ backups }) => {
const backupModal = useShowModal();
const columns: Column<System.Backups>[] = useMemo<Column<System.Backups>[]>(
() => [
{
accessor: "type",
Cell: <FontAwesomeIcon icon={faClock}></FontAwesomeIcon>,
},
{
Header: "Name",
accessor: "filename",
className: "text-nowrap",
},
{
Header: "Time",
accessor: "date",
className: "text-nowrap",
},
{
accessor: "id",
Cell: (row) => {
return (
<ButtonGroup>
<ActionButton
icon={faHistory}
onClick={() =>
backupModal("restore", row.row.original.filename)
}
></ActionButton>
<ActionButton
icon={faTrash}
onClick={() => backupModal("delete", row.row.original.filename)}
></ActionButton>
</ButtonGroup>
);
},
},
],
[backupModal]
);
return (
<React.Fragment>
<PageTable columns={columns} data={backups}></PageTable>
<SystemBackupRestoreModal
modalKey="restore"
size="lg"
></SystemBackupRestoreModal>
<SystemBackupDeleteModal
modalKey="delete"
size="lg"
></SystemBackupDeleteModal>
</React.Fragment>
);
};
export default Table;