Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions modalapi/archive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# This file is part of pi-stomp.
#
# pi-stomp is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# pi-Stomp is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with pi-stomp. If not, see <https://www.gnu.org/licenses/>.

"""Backup/restore archiving on a worker thread, with progress the UI can poll.

zip and unzip both name each entry as they finish it. Weighting those lines by
the entry's uncompressed size gives a bar that tracks wall-clock rather than
file count — 306 NAM models at ~295KB each dominate the run, while hundreds of
small .ttl files would otherwise sprint the bar to nowhere.
"""

import enum
import logging
import os
import re
import signal
import subprocess
import threading
import zipfile

# " adding: path/to/file (deflated 12%)" — the path may contain spaces and
# parens, so anchor on the trailing method/ratio rather than splitting.
_ZIP_ENTRY = re.compile(r"^\s*(?:adding|updating):\s+(.*?)\s+\((?:stored|deflated)\s+\d+%\)\s*$")
_UNZIP_ENTRY = re.compile(r"^\s*(?:extracting|inflating|linking|creating):\s+(.+?)\s*$")


class JobState(enum.Enum):
RUNNING = "running"
DONE = "done"
FAILED = "failed"
CANCELLED = "cancelled"


class ArchiveJob:
"""Runs one archiving subprocess off of the UI thread. Poll progress()/state
from the main thread; nothing here touches the LCD."""

def __init__(self, argv: list[str], weights: dict[str, int], entry_re: re.Pattern[str]) -> None:
self._argv = argv
self._weights = weights
self._entry_re = entry_re
self._total = sum(weights.values()) or 1
self._done = 0
self._entry = ""
self._lock = threading.Lock()
self._state = JobState.RUNNING
self._error = ""
self._proc: subprocess.Popen[str] | None = None
self._cancelled = False
self._thread = threading.Thread(target=self._run, daemon=True, name="archive-job")
self._thread.start()

@staticmethod
def backup(script: str, dest: str, src_dir: str) -> "ArchiveJob":
weights = {}
for root, dirs, files in os.walk(src_dir):
if os.path.relpath(root, src_dir) == ".":
dirs[:] = [d for d in dirs if d != ".lv2"]
for name in files:
path = os.path.join(root, name)
rel = os.path.relpath(path, src_dir)
try:
weights[rel] = os.stat(path, follow_symlinks=True).st_size
except OSError:
weights[rel] = 0
return ArchiveJob([script, dest, src_dir], weights, _ZIP_ENTRY)

@staticmethod
def restore(script: str, username: str, archive: str, target_dir: str) -> "ArchiveJob":
with zipfile.ZipFile(archive) as zf:
weights = {i.filename: i.file_size for i in zf.infolist() if not i.is_dir()}
return ArchiveJob(["sudo", "-u", username, script, archive, target_dir], weights, _UNZIP_ENTRY)

def progress(self) -> float:
with self._lock:
return min(1.0, self._done / self._total)

@property
def current_entry(self) -> str:
with self._lock:
return self._entry

@property
def done_bytes(self) -> int:
with self._lock:
return min(self._done, self._total)

@property
def total_bytes(self) -> int:
return self._total

@property
def state(self) -> JobState:
with self._lock:
return self._state

@property
def error(self) -> str:
with self._lock:
return self._error

def cancel(self) -> None:
with self._lock:
self._cancelled = True
proc = self._proc
if proc is None or proc.poll() is not None:
return
# The script is a bash parent of zip; signal the group or only bash dies.
try:
os.killpg(proc.pid, signal.SIGTERM)
except (ProcessLookupError, PermissionError) as e:
logging.warning("archive cancel: %s", e)

def _run(self) -> None:
try:
proc = subprocess.Popen(
self._argv,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
start_new_session=True,
)
except OSError as e:
with self._lock:
self._state = JobState.FAILED
self._error = str(e)
return

with self._lock:
self._proc = proc

tail: list[str] = []
assert proc.stdout is not None
for line in proc.stdout:
m = self._entry_re.match(line)
if m is None:
tail.append(line.rstrip())
del tail[:-8]
continue
name = m.group(1)
weight = self._weights.get(name)
if weight is None:
continue
with self._lock:
self._done += weight
self._entry = name

rc = proc.wait()
with self._lock:
if self._cancelled:
self._state = JobState.CANCELLED
elif rc == 0:
self._state = JobState.DONE
self._done = self._total
else:
self._state = JobState.FAILED
self._error = "\n".join(tail) or f"exited {rc}"
108 changes: 84 additions & 24 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from modalapi.sync import SyncMode, SyncModeSetter

import bisect
import datetime
import json
import logging
import os
Expand Down Expand Up @@ -1603,6 +1604,29 @@ def _human_size(num_bytes: int) -> str:
value /= 1000
return f"{value:.1f}GB"

def _drive_detail(self, backup_dir: str) -> str:
"""Drive name plus free space — the thing that decides whether a backup fits."""
mount = os.path.dirname(backup_dir)
name = os.path.basename(mount)
try:
usage = shutil.disk_usage(mount)
except OSError:
return name
return f"{name} · {self._human_size(usage.free)} free of {self._human_size(usage.total)}"

def _archive_detail(self, backup_dir: str) -> str:
"""Drive name plus the archive's size and age — restore overwrites data/,
so which vintage is about to land matters more than free space."""
mount = os.path.dirname(backup_dir)
name = os.path.basename(mount)
path = os.path.join(backup_dir, self.backup_file)
try:
st = os.stat(path)
except OSError:
return name
when = datetime.datetime.fromtimestamp(st.st_mtime).strftime("%d %b %H:%M")
return f"{name} · {self._human_size(st.st_size)} from {when}"

def _drive_label(self, backup_dir: str) -> str:
mount = os.path.dirname(backup_dir)
try:
Expand All @@ -1627,17 +1651,22 @@ def user_backup_data(self, arg):
self._choose_usb_drive(self.check_usb(), self._do_backup_data)

def _do_backup_data(self, backup_dir: str):
self.lcd.draw_info_message("Backing up, please wait...", refresh=True)
from modalapi.archive import ArchiveJob
from ui.archive_panel import ArchiveProgressPanel

logging.info("Data backup...")
cmd = os.path.join(self.homedir, "util", "data-backup.sh")
try:
subprocess.check_output([cmd, os.path.join(backup_dir, self.backup_file), self.data_dir])
self.lcd.draw_message_dialog("Backup complete", "Info")
logging.info("Backup complete")
except subprocess.CalledProcessError as e:
logging.error("user_backup_data:" + str(e.output))
finally:
self.lcd.draw_info_message("", refresh=True)
job = ArchiveJob.backup(cmd, os.path.join(backup_dir, self.backup_file), self.data_dir)
self.lcd.pstack.push_panel(
ArchiveProgressPanel(
title="Backing up",
noun="Backup",
subtitle=self._drive_detail(backup_dir),
job=job,
on_dismiss=self._dismiss_archive_panel,
cancellable=True,
)
)

def user_restore_data(self, arg, on_success=None):
# Only offer drives that actually hold a backup — no point asking the
Expand All @@ -1646,24 +1675,44 @@ def user_restore_data(self, arg, on_success=None):
self._choose_usb_drive(restorable, lambda d: self._do_restore_data(d, on_success=on_success))

def _do_restore_data(self, backup_dir: str, on_success=None):
self.lcd.draw_info_message("Restoring, please wait...", refresh=True)
from modalapi.archive import ArchiveJob
from ui.archive_panel import ArchiveProgressPanel

logging.info("Restoring data backup...")
cmd = os.path.join(self.homedir, "util", "data-restore.sh")
try:
subprocess.check_output(
["sudo", "-u", self.username, cmd, os.path.join(backup_dir, self.backup_file), self.data_dir]
)
logging.info("Restore complete")
if on_success is not None:
on_success()
self.lcd.draw_message_dialog(
"Restore complete. Press OK to restart.", "Info", on_dismiss=lambda: self.system_menu_restart_sound(None)
job = ArchiveJob.restore(cmd, self.username, os.path.join(backup_dir, self.backup_file), self.data_dir)
self.lcd.pstack.push_panel(
ArchiveProgressPanel(
title="Restoring",
noun="Restore",
subtitle=self._archive_detail(backup_dir),
job=job,
on_dismiss=lambda: self._dismiss_restore_panel(on_success),
cancellable=False,
done_label="Restart to continue",
)
except subprocess.CalledProcessError as e:
self.lcd.draw_message_dialog(e.output.decode("utf-8"))
logging.error("user_restore_data: " + e.output.decode("utf-8"))
finally:
self.lcd.draw_info_message("", refresh=True)
)

def _dismiss_archive_panel(self) -> None:
from ui.archive_panel import ArchiveProgressPanel

panel = self.lcd.pstack.find_panel_type(ArchiveProgressPanel)
if panel is not None:
self.lcd.pstack.pop_panel(panel)
self.lcd.draw_main_panel()

def _dismiss_restore_panel(self, on_success=None) -> None:
from modalapi.archive import JobState
from ui.archive_panel import ArchiveProgressPanel

panel = self.lcd.pstack.find_panel_type(ArchiveProgressPanel)
restored = panel is not None and panel.job_state is JobState.DONE
self._dismiss_archive_panel()
if not restored:
return
if on_success is not None:
on_success()
self.restart_ui_stack()

def system_menu_save_current_pb(self, _arg: None):
if self._current is None:
Expand Down Expand Up @@ -1697,6 +1746,17 @@ def system_menu_reload(self, arg):
logging.info("Exiting main process, systemctl should restart if enabled")
sys.exit(0)

def restart_ui_stack(self) -> None:
logging.info("Restarting mod-ui + deps")
try:
subprocess.Popen(
["sudo", "systemctl", "--no-block", "restart", "mod-ui"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except OSError as e:
logging.error("restart_ui_stack: %s", e)

def system_menu_restart_sound(self, arg):
self.lcd.splash_show()
logging.info("Restart sound engine (jack)")
Expand Down
Loading
Loading