Füge erweiterte Datei-Filter- und Duplikatbehandlungsoptionen hinzu, verbessere den Kopierstatus und die Benutzeroberfläche

This commit is contained in:
2026-05-09 01:54:44 +02:00
parent 09209be203
commit def2120d9b

273
app.py
View File

@@ -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
</div>
<!-- ── Kopier-Einstellungen ── -->
<div class="card">
<div class="card col2">
<div class="card-head">
<div class="card-icon ylw">⚙</div>
<span class="card-title">Kopier-Einstellungen</span>
</div>
<div class="card-body">
<div class="field">
<label>Ordner-Datumsformat</label>
<select id="c-fmt">
<option value="%Y-%m-%d">JJJJ-MM-TT &nbsp;(2024-01-15)</option>
<option value="%Y%m%d">JJJJMMTT &nbsp;(20240115)</option>
<option value="%d-%m-%Y">TT-MM-JJJJ &nbsp;(15-01-2024)</option>
<option value="%Y/%m/%d">JJJJ/MM/TT &nbsp;(Unterordner)</option>
</select>
<div class="card-body" style="display:grid;grid-template-columns:1fr 1fr;gap:0 2rem">
<!-- Linke Spalte: Ordner & Auto -->
<div>
<div class="sec" style="margin-top:0">Ordnerstruktur</div>
<div class="field">
<label>Datumsformat</label>
<select id="c-fmt">
<option value="%Y-%m-%d">JJJJ-MM-TT &nbsp;(2024-01-15)</option>
<option value="%Y%m%d">JJJJMMTT &nbsp;(20240115)</option>
<option value="%d-%m-%Y">TT-MM-JJJJ &nbsp;(15-01-2024)</option>
<option value="%Y/%m/%d">JJJJ/MM/TT &nbsp;(Unterordner)</option>
</select>
</div>
<label class="tog"><input type="checkbox" id="c-time"><span>Uhrzeit im Ordnernamen</span></label>
<label class="tog"><input type="checkbox" id="c-sub"><span>Unterordner pro Quelle</span></label>
<label class="tog"><input type="checkbox" id="c-auto"><span>Automatisch kopieren</span></label>
<div class="sec">Dateifilter</div>
<div class="field">
<label>Nur diese Typen kopieren (leer = alle)</label>
<input type="text" id="c-filter" placeholder="jpg, raw, mp4, mov …">
</div>
<div style="display:flex;gap:.35rem;flex-wrap:wrap;margin-top:-.35rem;margin-bottom:.85rem">
<button class="btn sm ghost" onclick="setFilter('jpg,jpeg,heic,raw,cr2,nef,arw,dng,png')">📷 Fotos</button>
<button class="btn sm ghost" onclick="setFilter('mp4,mov,avi,mkv,mts,m2ts,wmv')">🎬 Videos</button>
<button class="btn sm ghost" onclick="setFilter('jpg,jpeg,heic,raw,cr2,nef,arw,dng,mp4,mov,mts,m2ts')">📷+🎬</button>
<button class="btn sm ghost" onclick="setFilter('')">✕ Alle</button>
</div>
<label class="tog"><input type="checkbox" id="c-excl"><span>Systemdateien ausschließen<br><span style="font-size:.72rem;color:var(--sub)">.DS_Store, Thumbs.db, RECYCLER, System Volume Information …</span></span></label>
</div>
<label class="tog"><input type="checkbox" id="c-time"><span>Uhrzeit im Ordnernamen</span></label>
<label class="tog"><input type="checkbox" id="c-sub"><span>Unterordner pro Quelle (Gerätebezeichnung)</span></label>
<label class="tog"><input type="checkbox" id="c-auto"><span>Automatisch kopieren wenn Quelle &amp; Ziel verbunden</span></label>
<div class="btn-row" style="margin-top:.6rem">
<button class="btn pri" onclick="saveCopyCfg()">✓&nbsp;Speichern</button>
<div id="copy-cfg-msg" class="flash ok" style="display:none;align-self:center">Gespeichert!</div>
<!-- Rechte Spalte: Duplikate & Sicherheit -->
<div>
<div class="sec" style="margin-top:0">Duplikate</div>
<div class="field">
<label>Wenn Zieldatei bereits existiert</label>
<select id="c-dup">
<option value="skip">Überspringen (empfohlen)</option>
<option value="overwrite">Überschreiben</option>
<option value="rename">Umbenennen &nbsp;(_1, _2 …)</option>
</select>
</div>
<div class="sec">Integrität &amp; Aufräumen</div>
<label class="tog" style="margin-bottom:.85rem">
<input type="checkbox" id="c-verify">
<span>Dateien nach Kopieren per MD5 verifizieren<br>
<span style="font-size:.72rem;color:var(--sub)">Stellt sicher dass jede Datei identisch ankam — dauert länger</span></span>
</label>
<label class="tog">
<input type="checkbox" id="c-delsrc">
<span style="color:var(--ylw)">⚠ Quelldateien nach Kopieren löschen<br>
<span style="font-size:.72rem;color:var(--sub)">Löscht Dateien von der Quelle nach erfolgreichem Kopieren (bei Verify: nur verifizierte)</span></span>
</label>
</div>
<!-- Speichern-Zeile über beide Spalten -->
<div style="grid-column:1/-1;margin-top:.25rem">
<div class="btn-row" style="margin-top:0">
<button class="btn pri" onclick="saveCopyCfg()">✓&nbsp;Speichern</button>
<div id="copy-cfg-msg" class="flash ok" style="display:none;align-self:center">Gespeichert!</div>
</div>
</div>
</div>
</div>
@@ -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='';