Füge erweiterte Datei-Filter- und Duplikatbehandlungsoptionen hinzu, verbessere den Kopierstatus und die Benutzeroberfläche
This commit is contained in:
235
app.py
235
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,
|
||||
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,
|
||||
)
|
||||
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,14 +1287,18 @@ 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="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>Ordner-Datumsformat</label>
|
||||
<label>Datumsformat</label>
|
||||
<select id="c-fmt">
|
||||
<option value="%Y-%m-%d">JJJJ-MM-TT (2024-01-15)</option>
|
||||
<option value="%Y%m%d">JJJJMMTT (20240115)</option>
|
||||
@@ -1177,14 +1307,57 @@ body{background:var(--bg);color:var(--txt);font-family:-apple-system,BlinkMacSys
|
||||
</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 (Gerätebezeichnung)</span></label>
|
||||
<label class="tog"><input type="checkbox" id="c-auto"><span>Automatisch kopieren wenn Quelle & Ziel verbunden</span></label>
|
||||
<div class="btn-row" style="margin-top:.6rem">
|
||||
<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>
|
||||
|
||||
<!-- 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 (_1, _2 …)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sec">Integrität & 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()">✓ Speichern</button>
|
||||
<div id="copy-cfg-msg" class="flash ok" style="display:none;align-self:center">Gespeichert!</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Upload-Ziele ── -->
|
||||
<div class="card">
|
||||
@@ -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,6 +1757,20 @@ async function poll(){
|
||||
const cf=$('cur-file'),sum=$('st-summary'),time=$('st-time');
|
||||
const bS=$('btn-start'),bC=$('btn-cancel');
|
||||
if(c.running){
|
||||
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+'%';
|
||||
@@ -1581,6 +1779,7 @@ async function poll(){
|
||||
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='';
|
||||
|
||||
Reference in New Issue
Block a user