diff --git a/app.py b/app.py index 18b26f4..4c59f6e 100644 --- a/app.py +++ b/app.py @@ -40,6 +40,9 @@ DEFAULT_CONFIG = { 'dest_port': None, 'dest_label': '', 'folder_format': '%Y-%m-%d', 'add_time': True, 'subfolder': True, 'auto_copy': True, + 'file_filter': '', 'exclude_system': True, + 'duplicate_handling': 'skip', + 'verify_checksum': False, 'delete_source': False, # WiFi 'wifi_ssid': '', 'wifi_password': '', 'ap_ssid': 'PiCopy', 'ap_password': 'PiCopy,', @@ -53,6 +56,7 @@ copy_state = { 'error': None, 'last_copy': None, 'logs': [], 'bytes_total': 0, 'bytes_done': 0, 'start_ts': None, 'eta_sec': None, 'speed_bps': 0, + 'phase': 'idle', } copy_lock = threading.Lock() @@ -330,6 +334,46 @@ def add_log(msg): copy_state['logs'].append({'t': datetime.now().strftime('%H:%M:%S'), 'm': msg}) copy_state['logs'] = copy_state['logs'][-200:] +import hashlib as _hashlib + +SYSTEM_EXCLUDES = { + '.DS_Store', 'Thumbs.db', 'thumbs.db', 'desktop.ini', + '.Spotlight-V100', '.Trashes', '.fseventsd', '.TemporaryItems', + '.VolumeIcon.icns', 'RECYCLER', '$RECYCLE.BIN', + 'System Volume Information', '.DocumentRevisions-V100', +} + +def _should_copy(f: Path, cfg: dict) -> bool: + if cfg.get('exclude_system'): + for part in f.parts: + if part in SYSTEM_EXCLUDES: + return False + if f.name.startswith('._'): + return False + filt = cfg.get('file_filter', '').strip() + if filt: + allowed = {e.strip().lower().lstrip('.') for e in filt.split(',') if e.strip()} + if f.suffix.lower().lstrip('.') not in allowed: + return False + return True + +def _unique_path(p: Path) -> Path: + stem, suffix, parent = p.stem, p.suffix, p.parent + i = 1 + while True: + candidate = parent / f'{stem}_({i}){suffix}' + if not candidate.exists(): + return candidate + i += 1 + +def _file_md5(p: Path) -> str: + h = _hashlib.md5() + with open(p, 'rb') as f: + for chunk in iter(lambda: f.read(65536), b''): + h.update(chunk) + return h.hexdigest() + + def _fmt_bytes(b): if b < 1024: return f'{b} B' if b < 1024**2: return f'{b/1024:.1f} KB' @@ -345,7 +389,8 @@ def do_copy(src_dev, dst_dev, cfg): copy_state.update(running=True, progress=0, error=None, done=0, total=0, logs=[], current='', bytes_total=0, bytes_done=0, - start_ts=time.time(), eta_sec=None, speed_bps=0) + start_ts=time.time(), eta_sec=None, speed_bps=0, + phase='copy') save_state() add_log('Kopiervorgang gestartet') @@ -371,25 +416,52 @@ def do_copy(src_dev, dst_dev, cfg): dst_dir.mkdir(parents=True, exist_ok=True) add_log(f'Zielordner: {dst_dir}') + # ── Dateien sammeln & filtern ────────────────────────────────────── src_path = Path(src_mp) - files = [f for f in src_path.rglob('*') if f.is_file()] + 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 gefunden ({_fmt_bytes(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 + + # ── Phase 1: Kopieren ────────────────────────────────────────────── for i, f in enumerate(files): with copy_lock: if not copy_state['running']: add_log('Abgebrochen') return - dst_f = dst_dir / f.relative_to(src_path) + rel = f.relative_to(src_path) + dst_f = dst_dir / rel dst_f.parent.mkdir(parents=True, exist_ok=True) + + if dst_f.exists(): + if dup_mode == 'skip': + 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 + elif dup_mode == 'rename': + dst_f = _unique_path(dst_f) + # overwrite: einfach weitermachen + fsize = f.stat().st_size shutil.copy2(f, dst_f) + copied_pairs.append((f, dst_f)) + with copy_lock: copy_state['bytes_done'] += fsize bd = copy_state['bytes_done'] @@ -397,21 +469,74 @@ def do_copy(src_dev, dst_dev, cfg): 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, - ) + 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() + msg_parts = [f'{len(copied_pairs)} kopiert'] + if skipped: + msg_parts.append(f'{skipped} übersprungen') + + # ── Phase 2: Verifizieren ────────────────────────────────────────── + verify_errors = 0 + verified_pairs = list(copied_pairs) + + if cfg.get('verify_checksum') and copied_pairs: + with copy_lock: + copy_state.update(phase='verify', progress=0, done=0, + total=len(copied_pairs), current='', + eta_sec=None, speed_bps=0) + add_log(f'Verifiziere {len(copied_pairs)} Dateien…') + verified_pairs = [] + + for i, (src_f, dst_f) in enumerate(copied_pairs): + with copy_lock: + if not copy_state['running']: + add_log('Abgebrochen') + return + copy_state.update(done=i+1, + progress=int((i+1)/len(copied_pairs)*100), + current=src_f.name) + if _file_md5(src_f) == _file_md5(dst_f): + verified_pairs.append((src_f, dst_f)) + else: + verify_errors += 1 + add_log(f'⚠ Prüfsummenfehler: {src_f.name}') + try: dst_f.unlink() + except Exception: pass + + if verify_errors: + msg_parts.append(f'{verify_errors} Prüfsummenfehler!') + add_log(f'Verifizierung: {verify_errors} Fehler!') + else: + add_log(f'Alle {len(verified_pairs)} Dateien verifiziert ✓') + + # ── Phase 3: Quelle löschen ──────────────────────────────────────── + if cfg.get('delete_source') and verified_pairs: + if verify_errors: + add_log('Quelldateien NICHT gelöscht (Prüfsummenfehler)') + else: + with copy_lock: + copy_state.update(phase='delete', current='') + add_log(f'Lösche {len(verified_pairs)} Quelldateien…') + del_errors = 0 + for src_f, _ in verified_pairs: + try: + src_f.unlink() + except Exception as e: + del_errors += 1 + log.warning(f'Löschen fehlgeschlagen: {src_f}: {e}') + if del_errors: + msg_parts.append(f'{del_errors} Löschfehler') + else: + add_log('Quelle geleert ✓') + with copy_lock: copy_state['last_copy'] = datetime.now().isoformat() - add_log(f'Fertig! {total} Dateien kopiert nach {dst_dir.name}') + add_log('Fertig! ' + ', '.join(msg_parts)) - # Upload zu Fernzielen starten (falls konfiguriert) threading.Thread(target=run_uploads, args=(dst_dir, cfg), daemon=True).start() except Exception as e: @@ -428,6 +553,7 @@ def do_copy(src_dev, dst_dev, cfg): with copy_lock: copy_state['running'] = False copy_state['current'] = '' + copy_state['phase'] = 'idle' save_state() def check_auto_copy(): @@ -1161,27 +1287,74 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys -