db cleanup
This commit is contained in:
199
scripts/db_cleanup.py
Normal file
199
scripts/db_cleanup.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
import shutil
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path("/opt/pyTorrent/data/pytorrent.sqlite3")
|
||||
|
||||
DROP_COLUMNS = {
|
||||
"rss_feeds": [
|
||||
"user_id",
|
||||
],
|
||||
"rss_rules": [
|
||||
"user_id",
|
||||
],
|
||||
"rss_history": [
|
||||
"user_id",
|
||||
],
|
||||
"smart_queue_settings": [
|
||||
"user_id",
|
||||
],
|
||||
"smart_queue_exclusions": [
|
||||
"user_id",
|
||||
],
|
||||
"smart_queue_history": [
|
||||
"user_id",
|
||||
],
|
||||
"rtorrent_config_overrides": [
|
||||
"user_id",
|
||||
],
|
||||
"user_preferences": [
|
||||
"table_columns_json",
|
||||
"peers_refresh_seconds",
|
||||
"port_check_enabled",
|
||||
"tracker_favicons_enabled",
|
||||
"reverse_dns_enabled",
|
||||
"disk_monitor_paths_json",
|
||||
"disk_monitor_mode",
|
||||
"disk_monitor_selected_path",
|
||||
"disk_monitor_stop_enabled",
|
||||
"disk_monitor_stop_threshold",
|
||||
"torrent_sort_json",
|
||||
"active_filter",
|
||||
],
|
||||
}
|
||||
|
||||
DROP_INDEXES = [
|
||||
"idx_rss_feeds_user_profile",
|
||||
"idx_rss_rules_user_profile",
|
||||
"idx_rss_history_user_profile",
|
||||
"idx_smart_queue_settings_user_profile",
|
||||
"idx_smart_queue_exclusions_user_profile",
|
||||
"idx_smart_queue_history_user_profile",
|
||||
"idx_rtorrent_config_overrides_user_profile",
|
||||
]
|
||||
|
||||
EXPECTED_PROFILE_TABLES = {
|
||||
"rss_feeds": ["profile_id"],
|
||||
"rss_rules": ["profile_id"],
|
||||
"rss_history": ["profile_id"],
|
||||
"smart_queue_settings": ["profile_id"],
|
||||
"smart_queue_exclusions": ["profile_id"],
|
||||
"smart_queue_history": ["profile_id"],
|
||||
"rtorrent_config_overrides": ["profile_id"],
|
||||
}
|
||||
|
||||
|
||||
def table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(table,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def index_exists(conn: sqlite3.Connection, index: str) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='index' AND name=?",
|
||||
(index,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def columns(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||
return [row[1] for row in conn.execute(f'PRAGMA table_info("{table}")')]
|
||||
|
||||
|
||||
def quote_ident(name: str) -> str:
|
||||
return '"' + name.replace('"', '""') + '"'
|
||||
|
||||
|
||||
def validate_profile_tables(conn: sqlite3.Connection) -> None:
|
||||
print("Checking required profile scoped tables...")
|
||||
|
||||
for table, required_columns in EXPECTED_PROFILE_TABLES.items():
|
||||
if not table_exists(conn, table):
|
||||
print(f"SKIP table missing: {table}")
|
||||
continue
|
||||
|
||||
table_columns = columns(conn, table)
|
||||
|
||||
for column in required_columns:
|
||||
if column not in table_columns:
|
||||
raise RuntimeError(
|
||||
f"Unsafe cleanup: table {table} does not contain required column {column}"
|
||||
)
|
||||
|
||||
print(f"OK {table}: has {', '.join(required_columns)}")
|
||||
|
||||
|
||||
def drop_indexes(conn: sqlite3.Connection) -> None:
|
||||
print("\nDropping obsolete indexes if present...")
|
||||
|
||||
for index in DROP_INDEXES:
|
||||
if not index_exists(conn, index):
|
||||
print(f"SKIP index missing: {index}")
|
||||
continue
|
||||
|
||||
conn.execute(f"DROP INDEX {quote_ident(index)}")
|
||||
print(f"DROPPED index: {index}")
|
||||
|
||||
|
||||
def drop_obsolete_columns(conn: sqlite3.Connection) -> None:
|
||||
print("\nDropping obsolete columns if present...")
|
||||
|
||||
for table, obsolete_columns in DROP_COLUMNS.items():
|
||||
if not table_exists(conn, table):
|
||||
print(f"SKIP table missing: {table}")
|
||||
continue
|
||||
|
||||
current_columns = columns(conn, table)
|
||||
|
||||
for column in obsolete_columns:
|
||||
if column not in current_columns:
|
||||
print(f"SKIP column missing: {table}.{column}")
|
||||
continue
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
f"ALTER TABLE {quote_ident(table)} DROP COLUMN {quote_ident(column)}"
|
||||
)
|
||||
print(f"DROPPED column: {table}.{column}")
|
||||
current_columns.remove(column)
|
||||
except sqlite3.OperationalError as exc:
|
||||
print(f"FAILED column: {table}.{column} -> {exc}")
|
||||
print("This usually means the column is used by an index, constraint, or old SQLite version.")
|
||||
|
||||
|
||||
def vacuum(conn: sqlite3.Connection) -> None:
|
||||
print("\nRunning VACUUM...")
|
||||
conn.execute("VACUUM")
|
||||
print("VACUUM done.")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not DB_PATH.exists():
|
||||
raise SystemExit(f"Database not found: {DB_PATH}")
|
||||
|
||||
backup_path = DB_PATH.with_suffix(
|
||||
DB_PATH.suffix + f".cleanup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.bak"
|
||||
)
|
||||
|
||||
print(f"Database: {DB_PATH}")
|
||||
print(f"Backup: {backup_path}")
|
||||
|
||||
shutil.copy2(DB_PATH, backup_path)
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys = OFF")
|
||||
|
||||
validate_profile_tables(conn)
|
||||
|
||||
conn.execute("BEGIN")
|
||||
drop_indexes(conn)
|
||||
drop_obsolete_columns(conn)
|
||||
conn.commit()
|
||||
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
vacuum(conn)
|
||||
|
||||
print("\nCleanup completed successfully.")
|
||||
print(f"Backup saved as: {backup_path}")
|
||||
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
print("\nCleanup failed. Database rollback completed.")
|
||||
print(f"Backup is available at: {backup_path}")
|
||||
raise
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user