diff --git a/app.py b/app.py index 052676c..950ea5a 100644 --- a/app.py +++ b/app.py @@ -51,7 +51,8 @@ WIFI_BOOT_WAIT = 25 # Sekunden warten beim Start bevor AP gestartet wird DEFAULT_CONFIG = { # USB - 'source_port': None, 'source_label': '', + 'source_ports': [], # [{port, label}, ...] + 'source_port': None, 'source_label': '', # Migration legacy 'dest_port': None, 'dest_label': '', 'folder_format': '%Y-%m-%d', 'add_time': True, 'subfolder': True, 'auto_copy': True, @@ -572,9 +573,19 @@ def _fmt_bytes(b): return f'{b/1024**3:.2f} GB' -def do_copy(src_dev, dst_dev, cfg): - src_mp = dst_mp = None - src_owned = dst_owned = False +def _resolve_source_ports(cfg) -> list: + """Gibt source_ports als [{port, label}]-Liste zurück. Migriert altes source_port-Feld.""" + ports = cfg.get('source_ports') or [] + if not ports and cfg.get('source_port'): + ports = [{'port': cfg['source_port'], 'label': cfg.get('source_label', '')}] + return ports + + +def do_copy(src_devs, dst_dev, cfg): + """Kopiert von einer oder mehreren Quellen auf ein Ziel.""" + dst_mp = None + dst_owned = False + src_mounts = [] # [(src_dev, src_mp, src_owned)] try: with copy_lock: copy_state.update(running=True, progress=0, error=None, @@ -583,12 +594,8 @@ def do_copy(src_dev, dst_dev, cfg): start_ts=time.time(), eta_sec=None, speed_bps=0, phase='copy') save_state() - add_log('Kopiervorgang gestartet') - - src_mp, src_owned = ensure_mount(src_dev) - if not src_mp: - raise RuntimeError(f'Quelle nicht mountbar: {src_dev["device"]}') - add_log(f'Quelle: {src_mp} ({src_dev["label"]})') + n = len(src_devs) + add_log(f'Kopiervorgang gestartet ({n} Quelle{"n" if n != 1 else ""})') dst_mp, dst_owned = ensure_mount(dst_dev) if not dst_mp: @@ -599,115 +606,135 @@ def do_copy(src_dev, dst_dev, cfg): date_str = ts.strftime(cfg['folder_format']) if cfg.get('add_time'): date_str += '_' + ts.strftime('%H%M%S') - label = re.sub(r'[^\w\-]', '_', src_dev.get('label', 'source')) - dst_dir = Path(dst_mp) / date_str - if cfg.get('subfolder'): - dst_dir = dst_dir / label - dst_dir.mkdir(parents=True, exist_ok=True) - add_log(f'Zielordner: {dst_dir}') + # -- Alle Quellen mounten & Dateien sammeln ------------------------- + # source_data: [(src_dev, src_path, files, dst_dir, incomplete_marker)] + source_data = [] + total = 0 + bytes_total = 0 - # Halbkopierte .picopy_tmp-Dateien aus vorherigen Unterbrechungen entfernen - for stale in dst_dir.rglob('*.picopy_tmp'): - log.info(f'Bereinige Temp-Datei: {stale}') - stale.unlink(missing_ok=True) - - # Incomplete-Marker: existiert dieser nach Neustart, war die letzte Kopie unterbrochen - incomplete_marker = dst_dir / '.picopy_incomplete' - incomplete_marker.write_text(json.dumps({ - 'started': datetime.now().isoformat(), - 'source': src_dev.get('label', ''), - })) - - # -- Dateien sammeln & filtern -------------------------------------- - src_path = Path(src_mp) - all_files = [f for f in src_path.rglob('*') if f.is_file()] - files = [f for f in all_files if _should_copy(f, cfg)] - n_filtered = len(all_files) - len(files) - if n_filtered: - add_log(f'{n_filtered} Dateien durch Filter ausgeschlossen') - - total = len(files) - bytes_total = sum(f.stat().st_size for f in files) - with copy_lock: - copy_state['total'] = total - copy_state['bytes_total'] = bytes_total - add_log(f'{total} Dateien ({_fmt_bytes(bytes_total)})') - save_state() - - dup_mode = cfg.get('duplicate_handling', 'skip') - copied_pairs = [] # [(src, dst)] erfolgreich kopiert - skipped = 0 - io_errors = 0 - - # -- Phase 1: Kopieren ---------------------------------------------- - for i, f in enumerate(files): + for src_dev in src_devs: with copy_lock: cancelled = not copy_state['running'] if cancelled: add_log('Abgebrochen') return - rel = f.relative_to(src_path) - dst_f = dst_dir / rel - try: - dst_f.parent.mkdir(parents=True, exist_ok=True) - except OSError as mkdir_err: - io_errors += 1 - add_log(f'⚠ Verzeichnis nicht erstellbar ({dst_f.parent.name}): {mkdir_err}') - with copy_lock: - copy_state.update(done=i+1, - progress=int((i+1)/total*100) if total else 100, - current=str(f.name)) + + src_mp_i, src_owned_i = ensure_mount(src_dev) + src_mounts.append((src_dev, src_mp_i, src_owned_i)) + if not src_mp_i: + add_log(f'Quelle nicht mountbar: {src_dev["device"]} - übersprungen') continue - if dst_f.exists(): - if dup_mode == 'skip': - # Größenvergleich: stimmt die Größe nicht überein, war die Datei - # beim letzten Kopieren möglicherweise durch Stromausfall abgeschnitten - if dst_f.stat().st_size == f.stat().st_size: - skipped += 1 - with copy_lock: - copy_state.update(done=i+1, - progress=int((i+1)/total*100) if total else 100, - current=str(f.name)) - continue - else: - add_log(f'Unvollständige Datei gefunden, wird neu kopiert: {f.name}') - elif dup_mode == 'rename': - dst_f = _unique_path(dst_f) - # overwrite: einfach weitermachen + add_log(f'Quelle: {src_mp_i} ({src_dev["label"]})') + src_path = Path(src_mp_i) + all_files = [f for f in src_path.rglob('*') if f.is_file()] + files = [f for f in all_files if _should_copy(f, cfg)] + n_filtered = len(all_files) - len(files) + if n_filtered: + add_log(f'{n_filtered} Dateien gefiltert ({src_dev["label"]})') - fsize = f.stat().st_size - tmp_f = dst_f.with_name(dst_f.name + '.picopy_tmp') - try: - shutil.copy2(f, tmp_f) # Erst in Temp-Datei kopieren - os.replace(str(tmp_f), str(dst_f)) # Dann atomar umbenennen - except OSError as copy_err: - try: tmp_f.unlink(missing_ok=True) - except Exception: pass - io_errors += 1 - add_log(f'⚠ Fehler bei {f.name}: {copy_err}') + label = re.sub(r'[^\w\-]', '_', src_dev.get('label', 'source')) + dst_dir_i = Path(dst_mp) / date_str + if cfg.get('subfolder'): + dst_dir_i = dst_dir_i / label + dst_dir_i.mkdir(parents=True, exist_ok=True) + add_log(f'Zielordner: {dst_dir_i}') + + for stale in dst_dir_i.rglob('*.picopy_tmp'): + stale.unlink(missing_ok=True) + + incomplete_marker_i = dst_dir_i / '.picopy_incomplete' + incomplete_marker_i.write_text(json.dumps({ + 'started': datetime.now().isoformat(), + 'source': src_dev.get('label', ''), + })) + + total += len(files) + bytes_total += sum(f.stat().st_size for f in files) + source_data.append((src_dev, src_path, files, dst_dir_i, incomplete_marker_i)) + + with copy_lock: + copy_state['total'] = total + copy_state['bytes_total'] = bytes_total + add_log(f'{total} Dateien gesamt ({_fmt_bytes(bytes_total)})') + save_state() + + # -- Phase 1: Kopieren (alle Quellen) -------------------------------- + dup_mode = cfg.get('duplicate_handling', 'skip') + all_copied_pairs = [] + skipped = 0 + io_errors = 0 + global_done = 0 + + for src_dev_i, src_path_i, files_i, dst_dir_i, _ in source_data: + if len(src_devs) > 1: + add_log(f'Kopiere: {src_dev_i["label"]}') + for f in files_i: with copy_lock: - copy_state.update(done=i+1, - progress=int((i+1)/total*100) if total else 100, - current=str(f.name)) - continue - copied_pairs.append((f, dst_f)) + cancelled = not copy_state['running'] + if cancelled: + add_log('Abgebrochen') + return + global_done += 1 + rel = f.relative_to(src_path_i) + dst_f = dst_dir_i / rel + try: + dst_f.parent.mkdir(parents=True, exist_ok=True) + except OSError as mkdir_err: + io_errors += 1 + add_log(f'⚠ Verzeichnis nicht erstellbar ({dst_f.parent.name}): {mkdir_err}') + with copy_lock: + copy_state.update(done=global_done, + progress=int(global_done/total*100) if total else 100, + current=str(f.name)) + continue - with copy_lock: - copy_state['bytes_done'] += fsize - bd = copy_state['bytes_done'] - bt = copy_state['bytes_total'] - elapsed = time.time() - copy_state['start_ts'] - speed = bd / elapsed if elapsed > 1 else 0 - eta = int((bt - bd) / speed) if speed > 0 and bt > bd else 0 - copy_state.update(done=i+1, - progress=int((i+1)/total*100) if total else 100, - current=str(f.name), speed_bps=int(speed), eta_sec=eta) - if (i+1) % 20 == 0: - save_state() + if dst_f.exists(): + if dup_mode == 'skip': + if dst_f.stat().st_size == f.stat().st_size: + skipped += 1 + with copy_lock: + copy_state.update(done=global_done, + progress=int(global_done/total*100) if total else 100, + current=str(f.name)) + continue + else: + add_log(f'Unvollständige Datei, wird neu kopiert: {f.name}') + elif dup_mode == 'rename': + dst_f = _unique_path(dst_f) - msg_parts = [f'{len(copied_pairs)} kopiert'] + fsize = f.stat().st_size + tmp_f = dst_f.with_name(dst_f.name + '.picopy_tmp') + try: + shutil.copy2(f, tmp_f) + os.replace(str(tmp_f), str(dst_f)) + except OSError as copy_err: + try: tmp_f.unlink(missing_ok=True) + except Exception: pass + io_errors += 1 + add_log(f'⚠ Fehler bei {f.name}: {copy_err}') + with copy_lock: + copy_state.update(done=global_done, + progress=int(global_done/total*100) if total else 100, + current=str(f.name)) + continue + all_copied_pairs.append((f, dst_f)) + + with copy_lock: + copy_state['bytes_done'] += fsize + bd = copy_state['bytes_done'] + bt = copy_state['bytes_total'] + elapsed = time.time() - copy_state['start_ts'] + speed = bd / elapsed if elapsed > 1 else 0 + eta = int((bt - bd) / speed) if speed > 0 and bt > bd else 0 + copy_state.update(done=global_done, + progress=int(global_done/total*100) if total else 100, + current=str(f.name), speed_bps=int(speed), eta_sec=eta) + if global_done % 20 == 0: + save_state() + + msg_parts = [f'{len(all_copied_pairs)} kopiert'] if skipped: msg_parts.append(f'{skipped} übersprungen') if io_errors: @@ -715,22 +742,22 @@ def do_copy(src_dev, dst_dev, cfg): # -- Phase 2: Verifizieren ------------------------------------------ verify_errors = 0 - verified_pairs = list(copied_pairs) + verified_pairs = list(all_copied_pairs) - if cfg.get('verify_checksum') and copied_pairs: + if cfg.get('verify_checksum') and all_copied_pairs: with copy_lock: copy_state.update(phase='verify', progress=0, done=0, - total=len(copied_pairs), current='', + total=len(all_copied_pairs), current='', eta_sec=None, speed_bps=0) - add_log(f'Verifiziere {len(copied_pairs)} Dateien...') + add_log(f'Verifiziere {len(all_copied_pairs)} Dateien...') verified_pairs = [] - for i, (src_f, dst_f) in enumerate(copied_pairs): + for i, (src_f, dst_f) in enumerate(all_copied_pairs): with copy_lock: cancelled = not copy_state['running'] if not cancelled: copy_state.update(done=i+1, - progress=int((i+1)/len(copied_pairs)*100), + progress=int((i+1)/len(all_copied_pairs)*100), current=src_f.name) if cancelled: add_log('Abgebrochen') @@ -769,16 +796,17 @@ def do_copy(src_dev, dst_dev, cfg): else: add_log('Quelle geleert ✓') - # Alle Daten auf den Datenträger schreiben bevor wir abmelden subprocess.run(['sync'], capture_output=True) - try: incomplete_marker.unlink(missing_ok=True) - except Exception: pass + for _, _, _, _, incomplete_marker_i in source_data: + try: incomplete_marker_i.unlink(missing_ok=True) + except Exception: pass with copy_lock: copy_state['last_copy'] = datetime.now().isoformat() add_log('Fertig! ' + ', '.join(msg_parts)) - threading.Thread(target=run_uploads, args=(dst_dir, cfg), daemon=True).start() + dst_dir_root = Path(dst_mp) / date_str + threading.Thread(target=run_uploads, args=(dst_dir_root, cfg), daemon=True).start() except Exception as e: log.exception('Copy failed') @@ -787,9 +815,10 @@ def do_copy(src_dev, dst_dev, cfg): add_log(f'Fehler: {e}') finally: - subprocess.run(['sync'], capture_output=True) # Sicherheits-Sync vor Unmount - if src_owned and src_mp: - subprocess.run(['umount', src_mp], capture_output=True) + subprocess.run(['sync'], capture_output=True) + for _, src_mp_i, src_owned_i in src_mounts: + if src_owned_i and src_mp_i: + subprocess.run(['umount', src_mp_i], capture_output=True) if dst_owned and dst_mp: subprocess.run(['umount', dst_mp], capture_output=True) with copy_lock: @@ -800,17 +829,19 @@ def do_copy(src_dev, dst_dev, cfg): def check_auto_copy(): cfg = load_cfg() - if not cfg.get('auto_copy') or not cfg.get('source_port') or not cfg.get('dest_port'): + src_ports = _resolve_source_ports(cfg) + if not cfg.get('auto_copy') or not src_ports or not cfg.get('dest_port'): return with copy_lock: if copy_state['running'] or copy_state['error']: return devs = usb_devices() - src = next((d for d in devs if d['usb_port'] == cfg['source_port']), None) - dst = next((d for d in devs if d['usb_port'] == cfg['dest_port']), None) - if src and dst: - log.info('Auto-Copy: beide Geräte verbunden') - threading.Thread(target=do_copy, args=(src, dst, cfg), daemon=True).start() + srcs = [next((d for d in devs if d['usb_port'] == sp['port']), None) for sp in src_ports] + srcs = [s for s in srcs if s is not None] + dst = next((d for d in devs if d['usb_port'] == cfg['dest_port']), None) + if srcs and dst: + log.info(f'Auto-Copy: {len(srcs)} Quelle(n) und Ziel verbunden') + threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True).start() def usb_monitor(): try: @@ -952,11 +983,13 @@ def r_start(): return jsonify(error='Abbruch wird noch abgeschlossen - bitte kurz warten und erneut versuchen.'), 400 cfg = load_cfg() devs = usb_devices() - src = next((d for d in devs if d['usb_port'] == cfg.get('source_port')), None) - dst = next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None) - if not src: return jsonify(error='Quellgerät nicht gefunden'), 400 + src_ports = _resolve_source_ports(cfg) + srcs = [next((d for d in devs if d['usb_port'] == sp['port']), None) for sp in src_ports] + srcs = [s for s in srcs if s is not None] + if not srcs: return jsonify(error='Keine Quellgeräte gefunden (Ports nicht verbunden)'), 400 + dst = next((d for d in devs if d['usb_port'] == cfg.get('dest_port')), None) if not dst: return jsonify(error='Zielgerät nicht gefunden'), 400 - _copy_thread = threading.Thread(target=do_copy, args=(src, dst, cfg), daemon=True) + _copy_thread = threading.Thread(target=do_copy, args=(srcs, dst, cfg), daemon=True) _copy_thread.start() return jsonify(ok=True) @@ -1642,29 +1675,25 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys