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())