Coverage for seedboxsync/core/database/database.py: 100%
66 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-26 17:14 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-26 17:14 +0000
1#
2# Copyright (C) 2015-2026 Guillaume Kulakowski <guillaume@kulakowski.fr>
3#
4# For the full copyright and license information, please view the LICENSE
5# file that was distributed with this source code.
6#
7"""Database module."""
9from datetime import datetime
10from os import fspath
11from pathlib import Path
12from typing import ClassVar, cast
13from flask import Flask
14from humanize import filesize, time
15from peewee import SqliteDatabase
16from playhouse.flask_utils import FlaskDB
17from seedboxsync.core import utils
18from seedboxsync.core.database.migration import DatabaseMigration
19from seedboxsync.core.database.models import ApiKey, Download, SeedboxSync, TaskStatus, Torrent, User
22class Database:
23 """
24 Database manager for SeedboxSync using Peewee ORM.
26 Handles database path resolution, connection binding, SQLite optimization
27 pragmas, schema migrations, and custom SQLite functions.
29 Attributes:
30 DB_PATHS (ClassVar[list[Path]]): Candidate database paths checked in order of preference.
31 app (Flask): The Flask application instance bound to this database.
32 db (SqliteDatabase): The initialized Peewee SQLite database instance.
33 """
35 DB_PATHS: ClassVar[list[Path]] = [
36 Path("~/.config/seedboxsync/seedboxsync.db").expanduser().resolve(),
37 Path("~/.seedboxsync.db").expanduser().resolve(),
38 Path("~/.seedboxsync/config/seedboxsync.db").expanduser().resolve(),
39 Path("/etc/seedboxsync/seedboxsync.db"),
40 ]
41 db: SqliteDatabase
43 def __init__(self, app: Flask) -> None:
44 """
45 Initialize a new Database instance.
47 Args:
48 app (Flask): The Flask application to bind to the database.
49 """
50 self.app = app
51 self._load_database()
52 self._register_functions()
54 def _load_database(self) -> None:
55 """
56 Locate, configure, and initialize the SQLite database.
58 Checks candidate file paths or pre-configured settings, creates parent
59 directories if necessary, builds the schema for new files, and executes
60 pending migrations.
62 Raises:
63 RuntimeError: If a required migration method is missing.
64 """
65 if self.app.config.get("DATABASE", False):
66 # Load from testing
67 self._db_file = self.app.config.get("DATABASE", "")
68 self.app.config["DATABASE"] = f"sqlite:///{Path(self._db_file).as_posix()}"
69 else:
70 # Get DB from path
71 db_path = utils.get_database_path_from_paths()
72 self.app.config["DATABASE"] = fspath(db_path)
73 self.app.logger.debug("Use database path %s", db_path)
74 self._db_file = self.app.config["DATABASE"]
75 self.app.config["DATABASE"] = f"sqlite:///{Path(self._db_file).as_posix()}"
77 if not Path(self._db_file).exists():
78 self.app.logger.warning(f'Database "{self._db_file}" not found — creating new file...')
79 self.app.config["MIGRATE_DB"] = True
81 self._init_and_bind()
83 def _init_and_bind(self) -> None:
84 """
85 Initialize the Flask-Peewee wrapper and apply performance tuning pragmas.
87 Binds model classes to the database connection and configures WAL mode,
88 cache size, and foreign key constraints.
89 """
90 db_wrapper = FlaskDB(self.app)
91 self.db = cast(SqliteDatabase, db_wrapper.database)
92 self.app.extensions["flaskdb"] = db_wrapper
93 self.db.journal_mode = "wal"
94 self.db.cache_size = -64000
95 self.db.foreign_keys = 1
96 self.db.bind([ApiKey, Download, SeedboxSync, TaskStatus, Torrent, User])
97 self.app.logger.debug(
98 "Database initialized %s / journal_mode=%s, cache_size=%s, foreign_keys=%s",
99 self.app.config["DATABASE"],
100 self.db.journal_mode,
101 self.db.cache_size,
102 self.db.foreign_keys,
103 )
104 if self.app.config.get("MIGRATE_DB", False):
105 DatabaseMigration(self.app, self.db).upgrade()
107 def _register_functions(self) -> None:
108 """Register custom SQLite scalar functions for SQL queries."""
110 @self.db.func("byte_to_gi")
111 def db_byte_to_gi(num: float, suffix: str = "B") -> str: # pyright: ignore [reportUnusedFunction]
112 """Convert byte counts to human-readable binary unit strings."""
113 return utils.byte_to_gi(num, suffix)
115 @self.db.func("humanize")
116 def db_humanize(num: float | None) -> str: # pyright: ignore [reportUnusedFunction]
117 """Format file size numbers into human-readable representations."""
118 if num is None:
119 return ""
120 try:
121 # Treat None or invalid type as 0
122 num = float(num or 0)
123 except (ValueError, TypeError):
124 return ""
125 return filesize.naturalsize(num, True)
127 @self.db.func("short_datetime")
128 def db_short_datetime(value: str | None) -> str | None: # pyright: ignore[reportUnusedFunction]
129 """Format a datetime without microseconds."""
130 if value is None:
131 return None
132 return datetime.fromisoformat(value).strftime("%Y-%m-%d %H:%M:%S")
134 @self.db.func("naturaldelta")
135 def db_naturaldelta(num: float) -> str: # pyright: ignore [reportUnusedFunction]
136 """Format second intervals into human-readable duration strings."""
137 try:
138 # Treat None or invalid type as 0
139 num = float(num or 0)
140 except (ValueError, TypeError):
141 num = 0.0
142 return time.naturaldelta(num, minimum_unit="seconds", months=False)