Skip to content

Commit 6b9624c

Browse files
committed
Added backend of db backup and import system (#207)
1 parent 2ee1e4e commit 6b9624c

12 files changed

Lines changed: 595 additions & 31 deletions

File tree

backend/base/custom_exceptions.py

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from backend.base.definitions import (ApiResponse, BrokenClientReason,
1010
DownloadService, DownloadType,
1111
EnqueuingDownloadFailureReason,
12-
KapowarrException)
12+
InvalidDatabaseReason, KapowarrException)
1313
from backend.base.logging import LOGGER
1414

1515

@@ -141,6 +141,52 @@ def api_response(self) -> ApiResponse:
141141
}
142142

143143

144+
class InvalidDatabaseFile(KapowarrException):
145+
"The uploaded database file is invalid or not supported"
146+
147+
def __init__(self, filepath_db: str, reason: InvalidDatabaseReason) -> None:
148+
self.filepath_db = filepath_db
149+
self.reason = reason
150+
LOGGER.warning(
151+
"The given database file is invalid: %s (reason=%s)",
152+
filepath_db, reason
153+
)
154+
return
155+
156+
@property
157+
def api_response(self) -> ApiResponse:
158+
return {
159+
'code': 400,
160+
'error': self.__class__.__name__,
161+
'result': {
162+
'filepath_db': self.filepath_db,
163+
'reason': self.reason.value
164+
}
165+
}
166+
167+
168+
class DatabaseFileNotFound(KapowarrException):
169+
"The index of the database backup is invalid"
170+
171+
def __init__(self, backup_index: int) -> None:
172+
self.backup_index = backup_index
173+
LOGGER.warning(
174+
"The given database backup index is invalid: %d",
175+
backup_index
176+
)
177+
return
178+
179+
@property
180+
def api_response(self) -> ApiResponse:
181+
return {
182+
'code': 400,
183+
'error': self.__class__.__name__,
184+
'result': {
185+
'index': self.backup_index
186+
}
187+
}
188+
189+
144190
# region Rootfolders
145191
class RootFolderNotFound(KapowarrException):
146192
"Rootfolder with given ID not found"

backend/base/definitions.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,18 @@ class Constants:
5050
DB_NAME = "Kapowarr.db"
5151
"Name of database file itself"
5252

53+
DB_ORIGINAL_NAME = "Kapowarr_original.db"
54+
"Name of database file when backed up because a new database is imported"
55+
5356
DB_TIMEOUT = 10.0 # seconds
5457
"Seconds to wait on database command before timing out"
5558

59+
DB_REVERT_TIME = 60.0 # seconds
60+
"""
61+
After a new database is imported, how long the user has to access the web-UI
62+
before the import is reverted
63+
"""
64+
5665
DB_MAX_CONCURRENT_CONNECTIONS = 32
5766
"Maximum allowed database connections to be open at the same time"
5867

@@ -289,6 +298,24 @@ class StartType(BaseEnum):
289298
"A normal restart"
290299
RESTART_HOSTING_CHANGES = 132
291300
"A restart because changes to the hosting settings were made"
301+
RESTART_DB_CHANGES = 133
302+
"A restart because a database import was done"
303+
304+
305+
class InvalidDatabaseReason(BaseEnum):
306+
"The reason that a database file is invalid"
307+
308+
DOES_NOT_EXIST = "does_not_exist"
309+
"Database file does not exist"
310+
311+
NOT_KAPOWARR_DB = "not_kapowarr_db"
312+
"Uploaded database is not a Kapowarr database file"
313+
314+
VERSION_NOT_SUPPORTED = "version_not_supported"
315+
"""
316+
Uploaded database is higher version than this Kapowarr installation can\
317+
support
318+
"""
292319

293320

294321
class ProxyType(BaseEnum):
@@ -619,6 +646,13 @@ class StatusData(TypedDict):
619646
"""
620647

621648

649+
class DatabaseBackupEntry(TypedDict):
650+
index: int
651+
creation_date: int
652+
filepath: str
653+
filename: str
654+
655+
622656
class FilenameData(TypedDict):
623657
series: str
624658
year: Union[int, None]

backend/base/files.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -762,7 +762,23 @@ def set_volume_folder_owner_group(
762762

763763

764764
# region Moving
765-
def __copy2(src, dst, *, follow_symlinks=True):
765+
def copy(
766+
src,
767+
dst,
768+
*,
769+
follow_symlinks=True
770+
) -> str:
771+
"""Copy a file or folder.
772+
773+
Args:
774+
src (str): The source file or folder.
775+
dst (str): The destination of the copy.
776+
follow_symlinks (bool, optional): Whether to follow symlinks.
777+
Defaults to True.
778+
779+
Returns:
780+
str: The destination.
781+
"""
766782
try:
767783
return copy2(src, dst, follow_symlinks=follow_symlinks)
768784

@@ -805,12 +821,12 @@ def rename_file(
805821
# Cannot move folder into itself
806822
old_before = before
807823
before = old_before + '_temp'
808-
move(old_before, before, copy_function=__copy2)
824+
move(old_before, before, copy_function=copy)
809825

810826
create_folder(dirname(after))
811827

812828
# Move file into folder
813-
move(before, after, copy_function=__copy2)
829+
move(before, after, copy_function=copy)
814830

815831
return
816832

@@ -822,7 +838,7 @@ def copy_directory(source: str, target: str) -> None:
822838
source (str): The current folderpath of the source directory.
823839
target (str): The desired folderpath to where the directory should be copied.
824840
"""
825-
copytree(source, target, copy_function=__copy2)
841+
copytree(source, target, copy_function=copy)
826842
return
827843

828844

backend/base/helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1377,7 +1377,7 @@ def __init__(self, max_processes: Union[int, None] = None) -> None:
13771377
log_filepath = get_log_filepath()
13781378
log_folder = dirname(log_filepath)
13791379
log_file = basename(log_filepath)
1380-
db_folder = dirname(DBConnection.file)
1380+
db_folder = dirname(DBConnection.default_file)
13811381
ws_queue = WebSocket().client_manager.queue
13821382

13831383
super().__init__(

backend/features/tasks.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from backend.implementations.naming import mass_rename
1818
from backend.implementations.volumes import Volume, refresh_and_scan
1919
from backend.internals.db import get_db
20+
from backend.internals.db_backup_import import backup_database
2021
from backend.internals.server import (Server, TaskAddedEvent, TaskEndedEvent,
2122
TaskStatusEvent, WebSocket)
2223

@@ -29,7 +30,8 @@
2930
# Note: If there are tasks that should be run at the same time,
3031
# but per se after each other, put them in that order in the dict.
3132
'update_all': '0 * * * *', # every hour at minute 0
32-
'search_all': '0 0 * * *' # every day at midnight
33+
'search_all': '0 0 * * *', # every day at 00:00
34+
'backup_db': '0 0 * * 1' # every Monday at 00:00
3335
}
3436

3537

@@ -445,7 +447,7 @@ def delete_task_history() -> None:
445447
class LibraryTask(Task):
446448
"""
447449
Tasks that inherit from this class signify that they don't work
448-
on one specific volume or issue
450+
on one specific volume or issue but on all of them
449451
"""
450452

451453

@@ -868,3 +870,31 @@ def run(self):
868870
for result in results
869871
]
870872
return downloads
873+
874+
875+
# region System tasks
876+
@TaskHandler.register_task("backup_db")
877+
class BackupDatabase(Task):
878+
"Create a backup of the database"
879+
880+
stop = False
881+
message = ''
882+
display_title = 'Database Backup'
883+
884+
@property
885+
def volume_id(self) -> None:
886+
return None
887+
888+
@property
889+
def issue_id(self) -> None:
890+
return None
891+
892+
def __init__(self) -> None:
893+
return
894+
895+
def run(self):
896+
self.message = 'Creating database backup'
897+
WebSocket().emit(TaskStatusEvent(self.message))
898+
899+
backup_database()
900+
return

backend/internals/db.py

Lines changed: 51 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ class DBConnectionManager(type):
102102
instances: Dict[int, DBConnection] = {}
103103

104104
def __call__(cls, **kwargs: Any) -> DBConnection:
105+
if kwargs.get('db_file'):
106+
return super().__call__(**kwargs)
107+
105108
thread_id = current_thread_id()
106109

107110
if (
@@ -126,23 +129,32 @@ def close_connection_of_thread(cls) -> None:
126129

127130

128131
class DBConnection(Connection, metaclass=DBConnectionManager):
129-
file = ''
132+
default_file = ''
130133

131134
def __init__(
132135
self, *,
136+
db_file: Union[str, None] = None,
133137
timeout: float = Constants.DB_TIMEOUT
134138
) -> None:
135139
"""Create a connection with a database
136140
137141
Args:
142+
db_file (Union[str, None], optional): The database file to connect
143+
to. If `None`, the default file will be used. If something else
144+
than the default file is given, then a new connection will
145+
always be returned.
146+
Defaults to None.
147+
138148
timeout (float, optional): How long to wait before giving up
139149
on a command.
140150
Defaults to Constants.DB_TIMEOUT.
141151
"""
142152
self.closed = False
153+
self.db_file = db_file or self.default_file
154+
143155
LOGGER.debug(f'Creating connection {self}')
144156
super().__init__(
145-
self.file,
157+
self.db_file,
146158
timeout=timeout,
147159
detect_types=PARSE_DECLTYPES
148160
)
@@ -164,20 +176,40 @@ def cursor( # type: ignore
164176
KapowarrCursor: The database cursor.
165177
"""
166178
if not hasattr(g, 'cursors'):
167-
g.cursors = []
179+
g.cursors = {}
180+
181+
if self.db_file not in g.cursors:
182+
g.cursors[self.db_file] = []
168183

169-
if not g.cursors:
184+
if not g.cursors[self.db_file]:
170185
c = KapowarrCursor(self)
171186
c.row_factory = Row
172-
g.cursors.append(c)
187+
g.cursors[self.db_file].append(c)
173188

174189
if not force_new:
175-
return g.cursors[0]
190+
return g.cursors[self.db_file][0]
176191
else:
177192
c = KapowarrCursor(self)
178193
c.row_factory = Row
179-
g.cursors.append(c)
180-
return g.cursors[-1]
194+
g.cursors[self.db_file].append(c)
195+
return g.cursors[self.db_file][-1]
196+
197+
def create_backup(self, filepath: str) -> None:
198+
"""Create a backup of the current database.
199+
200+
Args:
201+
filepath (str): What the filepath of the backup will be.
202+
"""
203+
self.execute(
204+
"VACUUM INTO ?;",
205+
(filepath,)
206+
)
207+
return
208+
209+
def merge_wal_files(self) -> None:
210+
"Merge the WAL files into the main database file"
211+
self.execute("PRAGMA wal_checkpoint(TRUNCATE);")
212+
return
181213

182214
def close(self) -> None:
183215
"""Close the database connection"""
@@ -204,6 +236,8 @@ def set_db_location(
204236
Raises:
205237
ValueError: Value of `db_folder` exists but is not a folder.
206238
"""
239+
from backend.internals.settings import SettingsValues
240+
207241
if db_folder:
208242
if exists(db_folder) and not isdir(db_folder):
209243
raise ValueError('Database location is not a folder')
@@ -217,7 +251,8 @@ def set_db_location(
217251

218252
create_folder(dirname(db_file_location))
219253

220-
DBConnection.file = db_file_location
254+
DBConnection.default_file = db_file_location
255+
SettingsValues.db_backup_folder = dirname(db_file_location)
221256

222257
return
223258

@@ -278,13 +313,14 @@ def close_db(e: Union[BaseException, None] = None) -> None:
278313

279314
try:
280315
cursors = g.cursors
281-
db: DBConnection = cursors[0].connection
282-
for c in cursors:
283-
c.close()
316+
for cursors in g.cursors.values():
317+
db: DBConnection = cursors[0].connection
318+
for c in cursors:
319+
c.close()
320+
db.commit()
321+
if not current_thread().name.startswith('waitress-'):
322+
DBConnectionManager.close_connection_of_thread()
284323
delattr(g, 'cursors')
285-
db.commit()
286-
if not current_thread().name.startswith('waitress-'):
287-
DBConnectionManager.close_connection_of_thread()
288324

289325
except ProgrammingError:
290326
pass

0 commit comments

Comments
 (0)