File size: 7,668 Bytes
484ec23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Резервное копирование данных SalGram в приватный репозиторий Hugging Face.

Зачем: на бесплатном HF Space диск эфемерный — при пересборке/перезапуске/
«засыпании» Space всё в контейнере пропадает. Этот модуль раз в
HF_BACKUP_INTERVAL секунд снимает консистентную копию БД и выгружает её
(вместе с ключом шифрования и медиафайлами) в ПРИВАТНЫЙ dataset-репозиторий,
а при старте контейнера восстанавливает оттуда. Так код остаётся в публичном
Space, а данные лежат только в приватном репо — их видишь и качаешь только ты.

Включается только если заданы переменные окружения:
    HF_BACKUP_REPO      — id приватного репо, напр. "username/salgram-data"
    HF_TOKEN            — токен с правом записи в этот репо
    HF_BACKUP_INTERVAL  — (необяз.) период в секундах, по умолчанию 1200 (20 мин)

Без них модуль ничего не делает — локальная разработка и Amvera не затронуты.
"""
import json
import os
import shutil
import sqlite3
import tempfile
import threading
import time
import traceback

REPO_ENV = "HF_BACKUP_REPO"
TOKEN_ENV = "HF_TOKEN"
INTERVAL_ENV = "HF_BACKUP_INTERVAL"
DEFAULT_INTERVAL = 1200  # 20 минут

REPO_TYPE = "dataset"
DB_NAME = "salgram.db"
EXTRA_FILES = ("secret.key", "vapid.json")  # без них восстановленную БД не расшифровать
MEDIA_DIRS = ("avatars", "photos", "files", "wallpapers", "gifs", "audio")
_INDEX = ".hf_uploaded.json"  # локальный список уже выгруженных файлов (чтобы не слать повторно)


def _config():
    """(repo, token, interval) если бэкап настроен, иначе None."""
    repo = os.environ.get(REPO_ENV)
    token = os.environ.get(TOKEN_ENV)
    if not repo or not token:
        return None
    try:
        interval = int(os.environ.get(INTERVAL_ENV, DEFAULT_INTERVAL))
    except ValueError:
        interval = DEFAULT_INTERVAL
    return repo, token, max(60, interval)


def _log(msg):
    print(f"[hf_backup] {msg}", flush=True)


def _snapshot_db(data_dir, dst_path):
    """Консистентная копия БД через online-backup API SQLite (безопасно при WAL)."""
    src = sqlite3.connect(os.path.join(data_dir, DB_NAME), timeout=30)
    try:
        dst = sqlite3.connect(dst_path)
        try:
            with dst:
                src.backup(dst)
        finally:
            dst.close()
    finally:
        src.close()


def restore_on_boot(data_dir):
    """Если локальной БД нет — скачать последнюю копию из приватного репо.

    Вызывать ДО _load_key() и init_db(): восстановленные ключ и БД нужны им.
    """
    cfg = _config()
    if not cfg:
        return
    repo, token, _ = cfg
    if os.path.exists(os.path.join(data_dir, DB_NAME)):
        _log("локальная БД уже на месте — восстановление пропущено")
        return
    try:
        from huggingface_hub import snapshot_download
        snap = snapshot_download(repo_id=repo, repo_type=REPO_TYPE, token=token)
    except Exception as e:  # репо ещё нет / пустой / нет сети — стартуем с чистой БД
        _log(f"копий пока нет или репо недоступен ({e!r}) — старт с чистой БД")
        return
    count = 0
    for root, _dirs, files in os.walk(snap):
        for name in files:
            if name.startswith("."):
                continue  # служебные .gitattributes / .cache
            rel = os.path.relpath(os.path.join(root, name), snap)
            target = os.path.join(data_dir, rel)
            os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
            # snapshot_download кладёт симлинки на кеш — копируем именно содержимое.
            shutil.copyfile(os.path.realpath(os.path.join(root, name)), target)
            count += 1
    _log(f"восстановлено файлов из бэкапа: {count}")


def _remote_files(repo, token):
    """Список файлов, уже лежащих в репо (для первичного заполнения индекса)."""
    try:
        from huggingface_hub import HfApi
        return set(HfApi(token=token).list_repo_files(repo, repo_type=REPO_TYPE))
    except Exception:
        return set()


def backup_now(data_dir):
    """Один цикл бэкапа: снимок БД + новые медиа/ключи -> приватный репо."""
    cfg = _config()
    if not cfg:
        return
    repo, token, _ = cfg
    from huggingface_hub import CommitOperationAdd, HfApi
    api = HfApi(token=token)
    api.create_repo(repo, repo_type=REPO_TYPE, private=True, exist_ok=True)

    index_path = os.path.join(data_dir, _INDEX)
    if os.path.exists(index_path):
        with open(index_path) as f:
            uploaded = set(json.load(f))
    else:  # первый бэкап в этом контейнере — берём, что уже есть в репо
        uploaded = _remote_files(repo, token)

    with tempfile.TemporaryDirectory() as tmp:
        snap = os.path.join(tmp, DB_NAME)
        _snapshot_db(data_dir, snap)
        ops = [CommitOperationAdd(DB_NAME, snap)]  # БД меняется всегда — шлём каждый раз
        added = []

        for name in EXTRA_FILES:
            path = os.path.join(data_dir, name)
            if os.path.exists(path) and name not in uploaded:
                ops.append(CommitOperationAdd(name, path))
                added.append(name)

        for d in MEDIA_DIRS:
            ddir = os.path.join(data_dir, d)
            if not os.path.isdir(ddir):
                continue
            for name in sorted(os.listdir(ddir)):
                rel = f"{d}/{name}"
                path = os.path.join(ddir, name)
                if os.path.isfile(path) and rel not in uploaded:
                    ops.append(CommitOperationAdd(rel, path))
                    added.append(rel)

        api.create_commit(repo_id=repo, repo_type=REPO_TYPE, operations=ops,
                          commit_message="SalGram backup")

    uploaded.update(added)
    with open(index_path, "w") as f:
        json.dump(sorted(uploaded), f)
    return len(added)


def start(data_dir):
    """Запустить фоновый поток периодического бэкапа (no-op, если не настроено)."""
    cfg = _config()
    if not cfg:
        return
    _, _, interval = cfg

    def loop():
        while True:
            time.sleep(interval)
            try:
                n = backup_now(data_dir)
                _log(f"бэкап выполнен (+{n} новых файлов)")
            except Exception:
                _log("ошибка бэкапа:\n" + traceback.format_exc())

    threading.Thread(target=loop, name="hf-backup", daemon=True).start()
    _log(f"периодический бэкап включён: каждые {interval} с -> {os.environ[REPO_ENV]}")