diff --git a/app.py b/app.py index 9bc3094..5acb1e0 100644 --- a/app.py +++ b/app.py @@ -9,6 +9,7 @@ import logging import threading import subprocess import time +import uuid as _uuid_mod from datetime import datetime from pathlib import Path from flask import Flask, jsonify, request @@ -410,6 +411,9 @@ def do_copy(src_dev, dst_dev, cfg): copy_state['last_copy'] = datetime.now().isoformat() add_log(f'Fertig! {total} Dateien kopiert nach {dst_dir.name}') + # Upload zu Fernzielen starten (falls konfiguriert) + threading.Thread(target=run_uploads, args=(dst_dir, cfg), daemon=True).start() + except Exception as e: log.exception('Copy failed') with copy_lock: @@ -453,6 +457,94 @@ def usb_monitor(): except ImportError: log.warning('pyudev nicht verfügbar') +# ── Upload-Ziele (rclone) ───────────────────────────────────────────────────── + +RCLONE_CONF = BASE_DIR / 'rclone.conf' + +upload_state = { + 'running': False, + 'current': '', + 'results': [], +} +upload_lock = threading.Lock() + + +def _rclone(*args, timeout=60): + return subprocess.run( + ['rclone', '--config', str(RCLONE_CONF)] + list(args), + capture_output=True, text=True, timeout=timeout + ) + + +def _rclone_obscure(pw): + r = subprocess.run(['rclone', 'obscure', pw], + capture_output=True, text=True, timeout=10) + return r.stdout.strip() + + +def _remote_name(tid): + return f'picopy_{tid}' + + +def configure_smb_remote(tid, host, share, user, pw): + rn = _remote_name(tid) + _rclone('config', 'delete', rn) + args = ['config', 'create', rn, 'smb', f'host={host}', f'share={share}'] + if user: + args += [f'user={user}'] + if pw: + args += [f'pass={_rclone_obscure(pw)}'] + r = _rclone(*args) + return r.returncode == 0, r.stderr.strip() + + + + + +def delete_remote(tid): + _rclone('config', 'delete', _remote_name(tid)) + + +def test_remote(tid): + r = _rclone('lsd', f'{_remote_name(tid)}:', timeout=20) + return r.returncode == 0, r.stderr.strip() + + +def run_uploads(local_dir: Path, cfg: dict): + """Lädt local_dir zu allen aktiven Fernzielen hoch. Läuft im Background-Thread.""" + targets = [t for t in cfg.get('upload_targets', []) if t.get('enabled')] + if not targets: + return + + with upload_lock: + upload_state.update(running=True, results=[], current='') + + for t in targets: + name = t.get('name', t['id']) + with upload_lock: + upload_state['current'] = name + + add_log(f'Upload → {name}...') + dest_root = t.get('dest_path', 'PiCopy').strip('/') + dest = f'{_remote_name(t["id"])}:{dest_root}' + + r = _rclone('copy', str(local_dir), dest, + '--create-empty-src-dirs', + '--retries', '3', + timeout=7200) + ok = r.returncode == 0 + err = (r.stderr.strip().splitlines()[-1] + if r.stderr.strip() else '') if not ok else '' + + with upload_lock: + upload_state['results'].append({'name': name, 'ok': ok, 'msg': err}) + add_log(f'Upload {name}: {"✓ OK" if ok else "✗ Fehler – " + err}') + + with upload_lock: + upload_state['running'] = False + upload_state['current'] = '' + + # ── Flask Routes ────────────────────────────────────────────────────────────── @app.route('/') @@ -564,6 +656,74 @@ def r_wifi_status(): return jsonify(dict(wifi_state)) +# ── Upload Routes ────────────────────────────────────────────────────────────── + +@app.route('/api/upload/targets', methods=['GET']) +def r_upload_list(): + return jsonify(load_cfg().get('upload_targets', [])) + + +@app.route('/api/upload/targets', methods=['POST']) +def r_upload_add(): + data = request.get_json(force=True) + cfg = load_cfg() + tid = data.get('id') or _uuid_mod.uuid4().hex[:8] + ctype = data.get('type', 'smb') + + if ctype != 'smb': + return jsonify(error='Nur SMB/NAS wird unterstützt'), 400 + ok, err = configure_smb_remote( + tid, data.get('host', ''), data.get('share', ''), + data.get('user', ''), data.get('pass', '')) + + if not ok: + return jsonify(error=f'rclone: {err}'), 500 + + entry = { + 'id': tid, 'type': ctype, + 'name': data.get('name', ctype), + 'dest_path': data.get('dest_path', 'PiCopy'), + 'enabled': True, + } + targets = [t for t in cfg.get('upload_targets', []) if t['id'] != tid] + targets.append(entry) + cfg['upload_targets'] = targets + save_cfg(cfg) + return jsonify(ok=True, id=tid) + + +@app.route('/api/upload/targets/', methods=['DELETE']) +def r_upload_del(tid): + cfg = load_cfg() + cfg['upload_targets'] = [t for t in cfg.get('upload_targets', []) if t['id'] != tid] + save_cfg(cfg) + delete_remote(tid) + return jsonify(ok=True) + + +@app.route('/api/upload/targets//toggle', methods=['POST']) +def r_upload_toggle(tid): + cfg = load_cfg() + for t in cfg.get('upload_targets', []): + if t['id'] == tid: + t['enabled'] = not t.get('enabled', True) + break + save_cfg(cfg) + return jsonify(ok=True) + + +@app.route('/api/upload/targets//test', methods=['POST']) +def r_upload_test(tid): + ok, err = test_remote(tid) + return jsonify(ok=ok, error=err) + + +@app.route('/api/upload/status') +def r_upload_status(): + with upload_lock: + return jsonify(dict(upload_state)) + + # ── Browse (persistente Mounts für File-Explorer) ───────────────────────────── _browse_mounts = {} # usb_port -> mount_point @@ -666,6 +826,7 @@ def r_browse(): + # ── HTML Template ───────────────────────────────────────────────────────────── HTML = r""" @@ -675,606 +836,808 @@ HTML = r""" PiCopy -
- -
-

- + +
+

-
-
-
-
Verbinde…
-
+
+
+
+ Verbinde… + +
+ + +
+ + +
+
+
+ Kopierstatus + + +
+
+
Bereit
+ + + +
+ + + + +
+ + +
- -
-

Kopierstatus

-
Bereit
-