import glob import os import sys import time from core.connection import BuzzerError TAG_MAGIC = b"TAG!" TAG_FOOTER_LEN = 7 TAG_VERSION_V1 = 0x01 TAG_TYPE_CRC32 = 0x10 def _split_audio_and_tag_blob(filepath: str) -> tuple[bytes, bytes | None]: with open(filepath, "rb") as f: data = f.read() if len(data) < TAG_FOOTER_LEN: return data, None if data[-4:] != TAG_MAGIC: return data, None tag_total_len = int.from_bytes(data[-6:-4], byteorder="little", signed=False) tag_version = data[-7] if tag_version != TAG_VERSION_V1: return data, None if tag_total_len < TAG_FOOTER_LEN or tag_total_len > len(data): return data, None audio_end = len(data) - tag_total_len tag_payload_len = tag_total_len - TAG_FOOTER_LEN tag_payload = data[audio_end:audio_end + tag_payload_len] audio_data = data[:audio_end] return audio_data, tag_payload def _upsert_crc32_tag(tag_blob: bytes | None, crc32: int) -> tuple[bytes, bool]: crc_payload = int(crc32).to_bytes(4, byteorder="little", signed=False) crc_tlv = bytes([TAG_TYPE_CRC32, 0x04, 0x00]) + crc_payload if not tag_blob: return crc_tlv, True pos = 0 out = bytearray() found_crc = False while pos < len(tag_blob): if pos + 3 > len(tag_blob): return crc_tlv, True tag_type = tag_blob[pos] tag_len = tag_blob[pos + 1] | (tag_blob[pos + 2] << 8) header = tag_blob[pos:pos + 3] pos += 3 if pos + tag_len > len(tag_blob): return crc_tlv, True value = tag_blob[pos:pos + tag_len] pos += tag_len if tag_type == TAG_TYPE_CRC32: if not found_crc: out.extend(crc_tlv) found_crc = True continue out.extend(header) out.extend(value) if not found_crc: out.extend(crc_tlv) return bytes(out), True def _collect_source_files(sources: list[str], recursive: bool) -> list[dict]: entries = [] for source in sources: matches = glob.glob(source) if not matches: print(f"⚠️ Keine Treffer für Quelle: {source}") continue for match in matches: if os.path.isfile(match): entries.append({"local": match, "relative": os.path.basename(match)}) continue if os.path.isdir(match): if recursive: for root, _, files in os.walk(match): for name in sorted(files): local_path = os.path.join(root, name) rel = os.path.relpath(local_path, match) entries.append({"local": local_path, "relative": rel.replace("\\", "/")}) else: for name in sorted(os.listdir(match)): local_path = os.path.join(match, name) if os.path.isfile(local_path): entries.append({"local": local_path, "relative": name}) return entries def _remote_parent(path: str) -> str: idx = path.rfind("/") if idx <= 0: return "/" return path[:idx] def _ensure_remote_dir(conn, remote_dir: str) -> None: if not remote_dir or remote_dir == "/": return current = "" for part in [p for p in remote_dir.split("/") if p]: current = f"{current}/{part}" try: conn.mkdir(current) except BuzzerError as e: msg = str(e) if "0x11" in msg or "existiert bereits" in msg: continue raise def _build_upload_plan(entries: list[dict], target: str) -> list[dict]: if not entries: return [] needs_dir_semantics = target.endswith("/") or len(entries) > 1 or any("/" in e["relative"] for e in entries) if not needs_dir_semantics: return [{"local": entries[0]["local"], "remote": target}] base = target.rstrip("/") if not base: base = "/" plan = [] for entry in entries: rel = entry["relative"].lstrip("/") if base == "/": remote = f"/{rel}" else: remote = f"{base}/{rel}" plan.append({"local": entry["local"], "remote": remote}) return plan def execute(conn, sources: list[str], target: str, recursive: bool = False): uploads = _build_upload_plan(_collect_source_files(sources, recursive=recursive), target) if not uploads: print("Keine gültigen Dateien gefunden.") return total_size_all = sum(os.path.getsize(item["local"]) for item in uploads) sent_all = 0 start_time_all = time.monotonic() last_ui_update = start_time_all for item in uploads: local_path = item["local"] remote_path = item["remote"] filename = os.path.basename(local_path) audio_data, tag_blob = _split_audio_and_tag_blob(local_path) audio_size = len(audio_data) _ensure_remote_dir(conn, _remote_parent(remote_path)) print(f"Sende 📄 {filename} ({audio_size / 1024:.1f} KB Audio) -> {remote_path}") start_time_file = time.monotonic() def progress_handler(chunk_len, sent_file, total_file): nonlocal sent_all, last_ui_update sent_all += chunk_len now = time.monotonic() if now - last_ui_update < 0.2 and sent_file < total_file: return last_ui_update = now elapsed = now - start_time_file speed = (sent_file / 1024.0) / elapsed if elapsed > 0 else 0.0 perc_file = (sent_file / total_file) * 100.0 if total_file > 0 else 100.0 perc_all = (sent_all / total_size_all) * 100.0 if total_size_all > 0 else 100.0 elapsed_all = now - start_time_all avg_speed_all = sent_all / elapsed_all if elapsed_all > 0 else 0.0 eta_sec = (total_size_all - sent_all) / avg_speed_all if avg_speed_all > 0 else 0.0 eta_str = f"{int(eta_sec // 60):02d}:{int(eta_sec % 60):02d}" sys.stdout.write( f"\r \033[90mProg: {perc_file:3.0f}% | Gesamt: {perc_all:3.0f}% | " f"{speed:6.1f} KB/s | ETA: {eta_str}\033[0m" ) sys.stdout.flush() try: audio_crc32 = conn.put_file_data(remote_path, audio_data, progress_callback=progress_handler) rewritten_blob, _ = _upsert_crc32_tag(tag_blob, audio_crc32) conn.set_tag_blob(remote_path, rewritten_blob) tag_note = " (CRC32-Tag gesetzt)" print(f"\r \033[32mFertig: {filename} übertragen{tag_note}.{' ' * 20}\033[0m") except Exception as e: print(f"\n ❌ \033[31mFehler: {e}\033[0m") total_duration = time.monotonic() - start_time_all total_kb = total_size_all / 1024.0 avg_speed = total_kb / total_duration if total_duration > 0 else 0.0 print(f"\nÜbertragung abgeschlossen: {total_kb:.1f} KB in {total_duration:.1f}s ({avg_speed:.1f} KB/s)")