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 -
+
Kopier-Einstellungen
-
-
- - +
+ + +
+
Ordnerstruktur
+
+ + +
+ + + + +
Dateifilter
+
+ + +
+
+ + + + +
+
- - - -
- - + + +
+
Duplikate
+
+ + +
+ +
Integrität & Aufräumen
+ + +
+ + +
+
+ + +
@@ -1357,15 +1530,26 @@ async function loadCfg(){ cfg=await api('/config'); $('c-fmt').value=cfg.folder_format||'%Y-%m-%d'; $('c-time').checked=!!cfg.add_time; $('c-sub').checked=!!cfg.subfolder; $('c-auto').checked=!!cfg.auto_copy; + $('c-filter').value=cfg.file_filter||''; + $('c-excl').checked=cfg.exclude_system!==false; + $('c-dup').value=cfg.duplicate_handling||'skip'; + $('c-verify').checked=!!cfg.verify_checksum; + $('c-delsrc').checked=!!cfg.delete_source; $('w-ssid').value=cfg.wifi_ssid||''; $('ap-ssid').value=cfg.ap_ssid||'PiCopy'; } async function saveCopyCfg(){ cfg.folder_format=$('c-fmt').value; cfg.add_time=$('c-time').checked; cfg.subfolder=$('c-sub').checked; cfg.auto_copy=$('c-auto').checked; + cfg.file_filter=$('c-filter').value.trim(); + cfg.exclude_system=$('c-excl').checked; + cfg.duplicate_handling=$('c-dup').value; + cfg.verify_checksum=$('c-verify').checked; + cfg.delete_source=$('c-delsrc').checked; await api('/config','POST',cfg); const m=$('copy-cfg-msg'); m.style.display='block'; setTimeout(()=>m.style.display='none',2500); } +function setFilter(v){ $('c-filter').value=v; } // ── WiFi ────────────────────────────────────────────────────────────────────── async function scanNets(){ @@ -1573,14 +1757,29 @@ async function poll(){ const cf=$('cur-file'),sum=$('st-summary'),time=$('st-time'); const bS=$('btn-start'),bC=$('btn-cancel'); if(c.running){ - tx.className='st-headline st-run'; tx.textContent='Kopiert… '+c.progress+'%'; - pw.style.display='block'; pf.className='prog-fill'; pf.style.width=c.progress+'%'; - pp.style.display=''; pp.textContent=c.progress+'%'; - pfiles.style.display=''; pfiles.textContent=c.done+' / '+c.total+' Dateien'; - if(c.bytes_total>0){pbytes.style.display='';pbytes.textContent=fmtBytes(c.bytes_done)+' / '+fmtBytes(c.bytes_total);}else pbytes.style.display='none'; - const e=fmtETA(c.eta_sec); eta.style.display=e?'':'none'; eta.textContent=e?'⏱ '+e:''; - const s=fmtSpd(c.speed_bps); spd.style.display=s?'':'none'; spd.textContent=s?'⚡ '+s:''; - cf.textContent=c.current||''; + const ph=c.phase||'copy'; + if(ph==='verify'){ + tx.className='st-headline st-run'; tx.textContent='Verifiziere… '+c.progress+'%'; + pw.style.display='block'; pf.className='prog-fill'; pf.style.width=c.progress+'%'; + pp.style.display=''; pp.textContent=c.progress+'%'; + pfiles.style.display=''; pfiles.textContent=c.done+' / '+c.total+' geprüft'; + pbytes.style.display='none'; eta.style.display='none'; spd.style.display='none'; + cf.textContent=c.current||''; + } else if(ph==='delete'){ + tx.className='st-headline st-run'; tx.textContent='Quelle wird geleert…'; + pw.style.display='none'; pp.style.display='none'; pfiles.style.display='none'; + pbytes.style.display='none'; eta.style.display='none'; spd.style.display='none'; + cf.textContent=''; + } else { + tx.className='st-headline st-run'; tx.textContent='Kopiert… '+c.progress+'%'; + pw.style.display='block'; pf.className='prog-fill'; pf.style.width=c.progress+'%'; + pp.style.display=''; pp.textContent=c.progress+'%'; + pfiles.style.display=''; pfiles.textContent=c.done+' / '+c.total+' Dateien'; + if(c.bytes_total>0){pbytes.style.display='';pbytes.textContent=fmtBytes(c.bytes_done)+' / '+fmtBytes(c.bytes_total);}else pbytes.style.display='none'; + const e=fmtETA(c.eta_sec); eta.style.display=e?'':'none'; eta.textContent=e?'⏱ '+e:''; + const s=fmtSpd(c.speed_bps); spd.style.display=s?'':'none'; spd.textContent=s?'⚡ '+s:''; + cf.textContent=c.current||''; + } sum.textContent=''; bS.style.display='none'; bC.style.display=''; time.textContent=''; }else{ bS.style.display=''; bC.style.display=''; cf.textContent='';