File size: 6,260 Bytes
921d377 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | """
restore_avatars.py — additive recovery script for orphaned avatar /
image assets that exist on disk but aren't tracked in ``file_assets``.
Use case
--------
The user lost their persona projects (the entries in
``projects_metadata.json`` were wiped) but the underlying image
files are still on disk in ``UPLOAD_DIR``. This script walks the
upload directory, finds image files that aren't registered in the
``file_assets`` table, and registers them under the admin user so
they're owned and can be re-attached to new persona projects.
Behaviour
---------
- Idempotent. Running twice is safe — the script skips files
already present in ``file_assets`` (matched by ``rel_path``).
- Conservative. Only image MIME types are touched. Other files
(PDFs, text, etc.) are left alone.
- ``--dry-run`` prints what would be done without writing anything.
- Reports a summary: scanned N, already-registered M, restored K,
errors E.
Invocation
----------
python -m app.scripts.restore_avatars [--dry-run] [--user <id>]
When ``--user`` is omitted the script falls back to the admin
account (``get_or_create_default_user``). When no users exist at
all, ``admin`` is auto-created — matches the rest of HomePilot's
single-user bootstrap.
"""
from __future__ import annotations
import argparse
import mimetypes
import os
import sys
from pathlib import Path
from typing import Dict, List, Optional
# Allow running as ``python backend/app/scripts/restore_avatars.py``
# from anywhere by anchoring on the package root.
_HERE = Path(__file__).resolve()
_BACKEND_ROOT = _HERE.parents[2] # .../backend
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
def _iter_image_files(root: Path) -> List[Path]:
"""Yield every image file under ``root``. Skips dotfiles and
obvious non-asset directories. Recursive — picks up
users/<id>/projects/<pid>/* layouts as well as flat uploads."""
out: List[Path] = []
for p in root.rglob("*"):
if not p.is_file():
continue
if p.name.startswith("."):
continue
if p.suffix.lower() not in _IMAGE_EXTS:
continue
out.append(p)
return out
def _existing_rel_paths(con) -> set:
"""All rel_paths already in file_assets — for dedupe."""
rows = con.execute("SELECT rel_path FROM file_assets").fetchall()
return {str(r[0]) for r in rows if r[0]}
def _resolve_admin_user_id(explicit: Optional[str]) -> str:
"""Pick the user to assign orphan avatars to. ``explicit`` wins;
else the admin / default user (created on demand)."""
from app.users import (
ensure_users_tables,
get_or_create_default_user,
list_users,
)
ensure_users_tables()
if explicit:
for u in list_users():
if u.get("id") == explicit or u.get("username") == explicit:
return str(u["id"])
raise SystemExit(f"--user '{explicit}' not found in users table")
admin = get_or_create_default_user()
return str(admin["id"])
def restore(dry_run: bool, user_id_override: Optional[str]) -> Dict[str, int]:
"""Walk UPLOAD_DIR, register orphan images under the chosen user.
Returns a counters dict: ``{'scanned', 'already_registered',
'restored', 'errors'}``."""
from app.config import UPLOAD_DIR
from app.files import insert_asset
from app.storage import _get_db_path # internal — same connection target
upload_root = Path(UPLOAD_DIR)
if not upload_root.is_dir():
raise SystemExit(f"UPLOAD_DIR does not exist: {upload_root}")
admin_id = _resolve_admin_user_id(user_id_override)
import sqlite3
con = sqlite3.connect(_get_db_path())
try:
con.row_factory = sqlite3.Row
existing = _existing_rel_paths(con)
finally:
con.close()
counters = {"scanned": 0, "already_registered": 0, "restored": 0, "errors": 0}
for path in _iter_image_files(upload_root):
counters["scanned"] += 1
try:
rel = str(path.relative_to(upload_root))
except ValueError:
counters["errors"] += 1
continue
if rel in existing:
counters["already_registered"] += 1
continue
mime, _ = mimetypes.guess_type(str(path))
size = path.stat().st_size
if dry_run:
print(f"[DRY] would register rel_path={rel!r} "
f"mime={mime or '?'} size={size}")
counters["restored"] += 1
continue
try:
asset_id = insert_asset(
user_id=admin_id,
kind="image",
rel_path=rel,
mime=mime or "image/png",
size_bytes=size,
original_name=path.name,
)
print(f" + restored {rel} ({asset_id}, {size} bytes)")
counters["restored"] += 1
except Exception as exc: # noqa: BLE001 — best-effort batch
print(f" ! error on {rel}: {exc}")
counters["errors"] += 1
return counters
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dry-run", action="store_true",
help="Show what would be done without writing to file_assets.",
)
parser.add_argument(
"--user", default=None,
help="Target user id or username. Defaults to the admin / "
"default user.",
)
args = parser.parse_args()
print(f"[restore-avatars] mode={'DRY-RUN' if args.dry_run else 'WRITE'} "
f"target={'admin (default)' if not args.user else args.user}")
counters = restore(dry_run=args.dry_run, user_id_override=args.user)
print()
print("─" * 60)
print(f"Scanned: {counters['scanned']}")
print(f"Already registered: {counters['already_registered']}")
print(f"Restored: {counters['restored']}")
print(f"Errors: {counters['errors']}")
print("─" * 60)
if args.dry_run and counters["restored"]:
print("Re-run without --dry-run to actually register the files.")
return 0
if __name__ == "__main__":
sys.exit(main())
|