Офлайн-лаунчер Minecraft: аккаунты без Microsoft, установка версий и лоадеров (Fabric, Quilt, Forge, NeoForge), каталог модпаков Modrinth, поиск и обновление модов, разбор вылетов, системный трей, автозагрузка, плагины, прокси и зеркала.
146 lines
4.5 KiB
Python
146 lines
4.5 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Список миров и резервные копии по одному миру.
|
|
|
|
Копии хранятся в .launcher/backups/<мир>/ (не путать со старыми
|
|
общими saves-*.zip в game_dir/backups — их делает автобэкап).
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import time
|
|
import zipfile
|
|
|
|
|
|
def saves_dir(game_dir):
|
|
return os.path.join(game_dir, "saves")
|
|
|
|
|
|
def _safe(name):
|
|
return "".join(c for c in str(name)
|
|
if c not in '\\/:*?"<>|').strip() or "world"
|
|
|
|
|
|
def backups_dir(game_dir, world=""):
|
|
d = os.path.join(game_dir, ".launcher", "backups")
|
|
if world:
|
|
d = os.path.join(d, _safe(world))
|
|
return d
|
|
|
|
|
|
def _dir_size(path):
|
|
total = 0
|
|
for root, _dirs, files in os.walk(path):
|
|
for f in files:
|
|
try:
|
|
total += os.path.getsize(os.path.join(root, f))
|
|
except OSError:
|
|
pass
|
|
return total
|
|
|
|
|
|
def list_worlds(game_dir):
|
|
"""[{name, size, mtime}] — папки в saves/ (свежие первые)."""
|
|
sd = saves_dir(game_dir)
|
|
out = []
|
|
if not os.path.isdir(sd):
|
|
return out
|
|
for name in sorted(os.listdir(sd), key=str.lower):
|
|
p = os.path.join(sd, name)
|
|
if not os.path.isdir(p):
|
|
continue
|
|
mtime = 0.0
|
|
for cand in ("level.dat", None):
|
|
try:
|
|
mtime = os.path.getmtime(
|
|
os.path.join(p, cand) if cand else p)
|
|
break
|
|
except OSError:
|
|
continue
|
|
out.append({"name": name, "size": _dir_size(p), "mtime": mtime})
|
|
out.sort(key=lambda w: w["mtime"], reverse=True)
|
|
return out
|
|
|
|
|
|
def list_backups(game_dir, world):
|
|
"""[{name, size, mtime}] — zip-копии мира (свежие первые)."""
|
|
d = backups_dir(game_dir, world)
|
|
out = []
|
|
if not os.path.isdir(d):
|
|
return out
|
|
for name in sorted(os.listdir(d), reverse=True):
|
|
p = os.path.join(d, name)
|
|
if name.lower().endswith(".zip") and os.path.isfile(p):
|
|
try:
|
|
out.append({"name": name, "size": os.path.getsize(p),
|
|
"mtime": os.path.getmtime(p)})
|
|
except OSError:
|
|
pass
|
|
return out
|
|
|
|
|
|
def prune_backups(game_dir, world, cap):
|
|
"""Оставляет cap последних копий. Возвращает число удалённых."""
|
|
cap = max(1, int(cap or 1))
|
|
d = backups_dir(game_dir, world)
|
|
removed = 0
|
|
for b in list_backups(game_dir, world)[cap:]:
|
|
try:
|
|
os.remove(os.path.join(d, b["name"]))
|
|
removed += 1
|
|
except OSError:
|
|
pass
|
|
return removed
|
|
|
|
|
|
def backup_world(game_dir, world, cap=5, progress=None, log=None):
|
|
"""ZIP-копия одного мира + удаление старых копий сверх cap.
|
|
|
|
Возвращает путь к архиву.
|
|
"""
|
|
src = os.path.join(saves_dir(game_dir), world)
|
|
if not os.path.isdir(src):
|
|
raise RuntimeError("мир не найден: " + world)
|
|
d = backups_dir(game_dir, world)
|
|
os.makedirs(d, exist_ok=True)
|
|
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
path = os.path.join(d, f"{_safe(world)}-{stamp}.zip")
|
|
n = 1
|
|
while os.path.exists(path): # две копии в одну секунду — суффикс
|
|
n += 1
|
|
path = os.path.join(d, f"{_safe(world)}-{stamp}-{n}.zip")
|
|
files = []
|
|
for root, _dirs, fnames in os.walk(src):
|
|
for f in fnames:
|
|
fp = os.path.join(root, f)
|
|
files.append((fp, os.path.relpath(fp, src)))
|
|
if log:
|
|
log(f"Копия мира {world}: {len(files)} файлов…")
|
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
|
|
for i, (fp, rel) in enumerate(files, 1):
|
|
try:
|
|
z.write(fp, rel)
|
|
except OSError:
|
|
continue
|
|
if progress and (i % 20 == 0 or i == len(files)):
|
|
progress(i, len(files))
|
|
prune_backups(game_dir, world, cap)
|
|
return path
|
|
|
|
|
|
def remove_backup(game_dir, world, name):
|
|
"""Удаляет одну копию. True при успехе."""
|
|
try:
|
|
os.remove(os.path.join(backups_dir(game_dir, world), _safe(name)))
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def remove_world(game_dir, world):
|
|
"""Удаляет мир целиком. True при успехе."""
|
|
p = os.path.join(saves_dir(game_dir), world)
|
|
if not os.path.isdir(p):
|
|
return False
|
|
shutil.rmtree(p, ignore_errors=True)
|
|
return True
|