#!/usr/bin/env python3 """Snapshot/restore mixer-managed files and service states, not apt packages.""" import json import os from pathlib import Path import shutil import subprocess import sys UNITS = ['shairport-sync', 'nqptp', 'bluealsa', 'bluealsa-aplay', 'radxa-bt-output', 'radxa-bose-output', 'radxa-mixer-web', 'avahi-daemon', 'bluetooth'] PATHS = [ '/etc/systemd/system/radxa-mixer-web.service', '/etc/dbus-1/system.d/shairport-sync-dbus.conf', '/usr/local/lib/radxa-mixer/input_controls.py', '/usr/local/lib/radxa-mixer/web_server.py', '/usr/local/lib/radxa-mixer/web/index.html', '/usr/local/lib/radxa-mixer/web/app.js', '/usr/local/lib/radxa-mixer/web/style.css', '/etc/asound.conf', '/etc/shairport-sync.conf', '/etc/radxa-audio/mixer.json', '/etc/radxa-audio/asound-dac.conf', '/etc/radxa-audio/input-memory.json', '/etc/radxa-audio/asound-bluetooth.conf', '/etc/systemd/system/shairport-sync.service', '/etc/systemd/system/nqptp.service', '/etc/systemd/system/bluealsa.service.d/10-radxa-sink.conf', '/etc/systemd/system/bluealsa-aplay.service.d/10-radxa-mixer.conf', '/etc/systemd/system/radxa-bt-output.service', '/etc/modules-load.d/radxa-audio-loopback.conf', '/etc/modprobe.d/radxa-audio-loopback.conf', '/etc/modprobe.d/90-radxa-airplay-wifi.conf', '/usr/local/bin/shairport-sync', '/usr/local/bin/nqptp', '/usr/local/bin/radxa-audio', '/usr/local/bin/radxa-audio-output', ] def ctl(*args): return subprocess.run(['systemctl', *args], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) def snapshot(directory): directory.mkdir(parents=True, exist_ok=False, mode=0o700) files = {} for name in PATHS: path = Path(name) files[name] = os.path.lexists(path) if files[name]: target = directory / 'files' / name.lstrip('/') target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(path, target, follow_symlinks=False) states = {u: {'active': ctl('is-active', '--quiet', u).returncode == 0, 'enabled': ctl('is-enabled', '--quiet', u).returncode == 0} for u in UNITS} (directory / 'manifest.json').write_text(json.dumps({'files': files, 'units': states}, indent=2)) def restore(directory): manifest = json.loads((directory / 'manifest.json').read_text()) # Only restore allowlisted paths from our own snapshot. if not set(manifest['files']).issubset(PATHS) or not set(manifest['units']).issubset(UNITS): raise ValueError('알 수 없는 백업 형식입니다.') for unit in UNITS[:7]: ctl('stop', unit) for name, existed in manifest['files'].items(): target = Path(name) if existed: target.parent.mkdir(parents=True, exist_ok=True) target.unlink(missing_ok=True) shutil.copy2(directory / 'files' / name.lstrip('/'), target, follow_symlinks=False) else: target.unlink(missing_ok=True) ctl('daemon-reload') for unit in UNITS: state = manifest['units'].get(unit, {'active': False, 'enabled': False}) ctl('enable' if state['enabled'] else 'disable', unit) # Bluetooth and Avahi may have been started by apt during installation. for unit in reversed(UNITS): state = manifest['units'].get(unit, {'active': False, 'enabled': False}) result = ctl('restart' if state['active'] else 'stop', unit) if state['active'] and result.returncode: print(result.stdout, file=sys.stderr) raise ValueError('서비스 복원에 실패했습니다: ' + unit) print('복구 완료:', directory) print('apt로 설치한 의존 패키지는 유지합니다. 블루투스 페어링 기록은 삭제하지 않습니다.') if __name__ == '__main__': if os.geteuid() != 0: sys.exit('sudo로 실행해 주세요.') if len(sys.argv) != 3 or sys.argv[1] not in ('save', 'restore'): sys.exit('Usage: backup.py save|restore DIRECTORY') if sys.argv[1] == 'save': snapshot(Path(sys.argv[2])) else: restore(Path(sys.argv[2]))