diff options
| author | Leonard Kugis <leonard@kug.is> | 2026-09-04 04:01:49 +0200 |
|---|---|---|
| committer | Leonard Kugis <leonard@kug.is> | 2026-09-04 04:01:49 +0200 |
| commit | 558c90807b09b16757c88f2e50bdbd123d425f37 (patch) | |
| tree | 2f298bb77caf0ea93d6c01e94d1f25024e22082f /archival.py | |
| download | medman-master.tar.gz | |
Diffstat (limited to 'archival.py')
| -rw-r--r-- | archival.py | 860 |
1 files changed, 860 insertions, 0 deletions
diff --git a/archival.py b/archival.py new file mode 100644 index 0000000..4465fcd --- /dev/null +++ b/archival.py @@ -0,0 +1,860 @@ +#!/usr/bin/env python3 +""" +archival.py — Create SquashFS (optionally LUKS-encrypted) containers from +MedMan-managed files, preserving the hash-based directory hierarchy and +embedding an index.json with tag metadata per container. + +The container size limit is applied BEFORE packing, meaning the sum of the +individual file sizes within a container must not exceed the limit. A small +margin is reserved for the index.json + filesystem overhead. + +Workflow: + 1. Query MedMan's API (or direct DB) for files matching filters (tags, + date range, MIME type). + 2. Group files into containers respecting the size limit (greedy bin-packing). + 3. For each container, write a temporary index.json, then stream files + plus the index.json via tar → sqfstar directly into a SquashFS image — + no on-disk staging directory needed (like squashr). + 4. Optionally encrypt the resulting .squashfs image with LUKS (cryptsetup). +""" + +import os +import sys +import json +import argparse +import sqlite3 +import shutil +import subprocess +import urllib.request +import urllib.error +from datetime import datetime, timezone +from typing import List, Tuple, Dict, Set, Optional + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def gdb(db_path: str) -> sqlite3.Connection: + """Open MedMan database with row factory.""" + if not os.path.isfile(db_path): + print(f"ERROR: database not found: {db_path}", file=sys.stderr) + sys.exit(1) + c = sqlite3.connect(db_path) + c.row_factory = sqlite3.Row + return c + + +def resolve_data_dirs(dat_specs: List[str]) -> List[str]: + """Resolve data directory list from repeated --dat arguments.""" + return [os.path.abspath(d.strip()) for d in dat_specs if d.strip()] + + +def fetch_medman_export(medman_url: str, tags: Optional[List[str]]) -> dict: + """ + Fetch the MedMan tag export JSON via the HTTP API. + + The full export includes ``tags`` (all known tags) and ``files``, a dict + keyed by hash — each value contains ``didx``, ``rel``, and ``tags``. + + When *tags* is given, the ``?tags=a,b`` query parameter is appended so + the server returns only matching files (AND semantics). + """ + url = medman_url.rstrip("/") + if not url.endswith("/api/tags/export"): + url += "/api/tags/export" + + if tags: + url += "?tags=" + ",".join(tags) + + print(f" Fetching: {url}") + try: + with urllib.request.urlopen(url, timeout=60) as resp: + data = json.loads(resp.read().decode("utf-8")) + except urllib.error.URLError as exc: + print(f"ERROR: cannot reach MedMan API at {url}: {exc}", file=sys.stderr) + sys.exit(1) + except json.JSONDecodeError as exc: + print(f"ERROR: invalid JSON from MedMan API: {exc}", file=sys.stderr) + sys.exit(1) + + return data + + +def resolve_files_from_export( + export: dict, + dat_dirs: List[str], + hashes: Optional[Set[str]], + mime_type: Optional[str], + from_date: Optional[str], + to_date: Optional[str], +) -> List[dict]: + """ + Given a MedMan export dict and data-directory list, resolve every file + hash to its absolute on-disk path. Apply additional optional filters + (hash set, MIME type, date range on mtime). + + Returns a list of dicts ready for ``split_files``. + """ + files_info = export.get("files", {}) + + # Parse date filters + from_dt = None + to_dt = None + if from_date: + try: + from_dt = datetime.fromisoformat(from_date) + except ValueError: + print(f"ERROR: invalid --from-date format: {from_date}", file=sys.stderr) + sys.exit(1) + if to_date: + try: + to_dt = datetime.fromisoformat(to_date) + except ValueError: + print(f"ERROR: invalid --to-date format: {to_date}", file=sys.stderr) + sys.exit(1) + + files: List[dict] = [] + for h, info in files_info.items(): + # Apply hash filter + if hashes is not None and h not in hashes: + continue + + didx = info.get("didx", 0) + file_tags_list = info.get("tags", []) + + # Reconstruct rel from hash (always deterministic) + pairs = [h[i:i+2] for i in range(0, len(h), 2)] + rel = "/".join(pairs) + + # Resolve absolute path + if didx >= len(dat_dirs): + print(f"WARNING: didx {didx} out of range for hash {h[:16]}…, skipping", + file=sys.stderr) + continue + + ap = os.path.join(dat_dirs[didx], rel) + if not os.path.isfile(ap): + print(f"WARNING: file not found on disk: {ap}", file=sys.stderr) + continue + + # Filesystem metadata + st = os.stat(ap) + size = st.st_size + mtime_ts = st.st_mtime + + # Date filter (on mtime) + if from_dt or to_dt: + mtime_dt = datetime.fromtimestamp(mtime_ts, tz=timezone.utc) + if from_dt and mtime_dt < from_dt: + continue + if to_dt and mtime_dt > to_dt: + continue + + # MIME type filter + if mime_type: + mt = _quick_mime(ap) + if mt != mime_type and not mt.startswith(mime_type): + continue + + files.append({ + "hash": h, + "didx": didx, + "rel": rel, # internal only; not exported + "size": size, + "mt": _quick_mime(ap), + "w": None, + "h": None, + "dur": None, + "abspath": ap, + "tags": file_tags_list, + }) + + return files + + +def _quick_mime(ap: str) -> str: + """Quick file-type detection by extension (offline-safe).""" + ext = os.path.splitext(ap)[1].lower() + if ext in {".mp4", ".mkv", ".webm", ".avi", ".mov", ".m4v", ".wmv", ".flv", ".ogv"}: + return "video" + if ext in {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".tiff", ".ico"}: + return "image" + if ext in {".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma", ".opus"}: + return "audio" + if ext in {".pdf", ".epub", ".txt", ".md", ".rst"}: + return "doc" + return "other" + + +# ── DB-based query (fallback / legacy) ─────────────────────────────────────── + +def query_files_by_tags( + db: sqlite3.Connection, + dat_dirs: List[str], + tags: Optional[List[str]], + hashes: Optional[Set[str]], + from_date: Optional[str] = None, + to_date: Optional[str] = None, + mime_type: Optional[str] = None, +) -> List[dict]: + """ + Return files from MedMan DB with their on-disk paths. + + Supports tag, hash, date-range, and MIME-type filters. + """ + if tags: + names = [t.strip().lower() for t in tags if t.strip()] + else: + names = [] + + # Parse date filters + from_dt: Optional[datetime] = None + to_dt: Optional[datetime] = None + if from_date: + try: + from_dt = datetime.fromisoformat(from_date) + except ValueError: + print(f"ERROR: invalid --from-date: {from_date}", file=sys.stderr) + sys.exit(1) + if to_date: + try: + to_dt = datetime.fromisoformat(to_date) + except ValueError: + print(f"ERROR: invalid --to-date: {to_date}", file=sys.stderr) + sys.exit(1) + + files: List[dict] = [] + + if names: + ph = ",".join("?" for _ in names) + rows = db.execute( + f"""SELECT ft.hash,f.didx,f.size,f.mt,f.w,f.h,f.dur,f.ts, + COUNT(DISTINCT t.id) AS mc + FROM ftags ft + JOIN tags t ON t.id=ft.tid + JOIN files f ON f.hash=ft.hash + WHERE t.name IN ({ph}) + GROUP BY ft.hash + HAVING mc=?""", + names + [len(names)], + ).fetchall() + else: + rows = db.execute( + "SELECT hash,didx,size,mt,w,h,dur,ts FROM files" + ).fetchall() + + for r in rows: + h = r["hash"] + if hashes is not None and h not in hashes: + continue + + rel = h2rel(h) + ap = os.path.join(dat_dirs[r["didx"]], rel) + if not os.path.isfile(ap): + continue + + # Date filter on filesystem mtime + st_mtime = os.path.getmtime(ap) + if from_dt or to_dt: + mtime_dt = datetime.fromtimestamp(st_mtime, tz=timezone.utc) + if from_dt and mtime_dt < from_dt: + continue + if to_dt and mtime_dt > to_dt: + continue + + # MIME type filter + if mime_type: + mt = r["mt"] or _quick_mime(ap) + if mt != mime_type and not mt.startswith(mime_type): + continue + + files.append({ + "hash": h, + "didx": r["didx"], + "size": r["size"] if r["size"] else os.path.getsize(ap), + "mt": r["mt"], + "w": r["w"], + "h": r["h"], + "dur": r["dur"], + "abspath": ap, + }) + return files + + +def query_all_tags(db: sqlite3.Connection) -> List[dict]: + """Return all known tags as list of dicts.""" + return [dict(r) for r in db.execute("SELECT id, name FROM tags ORDER BY name").fetchall()] + + +def query_file_tags(db: sqlite3.Connection, hashes: List[str]) -> Dict[str, List[str]]: + """Return {hash: [tag_name, ...]} for the given hashes.""" + result: Dict[str, List[str]] = {h: [] for h in hashes} + if not hashes: + return result + ph = ",".join("?" for _ in hashes) + rows = db.execute( + f"""SELECT ft.hash,t.name FROM ftags ft + JOIN tags t ON t.id=ft.tid + WHERE ft.hash IN ({ph}) + ORDER BY ft.hash,t.name""", + hashes, + ).fetchall() + for r in rows: + result[r["hash"]].append(r["name"]) + return result + + +# ── bin-packing ────────────────────────────────────────────────────────────── + +def split_files( + files: List[dict], + max_container_size: int, + margin_bytes: int = 0, +) -> Dict[int, List[dict]]: + """ + Greedy first-fit-decreasing bin packing. + + Returns {container_index: [file_dict, ...]}. + + *max_container_size* is the maximum sum of file sizes allowed per container. + *margin_bytes* is subtracted from max_container_size internally to leave + room for the index.json and filesystem structures. + """ + effective_limit = max_container_size - margin_bytes + if effective_limit <= 0: + print("ERROR: container size too small after margin", file=sys.stderr) + sys.exit(1) + + # Sort descending by size for best-fit-decreasing + files_sorted = sorted(files, key=lambda f: f["size"], reverse=True) + + containers: Dict[int, List[dict]] = {} + container_sizes: Dict[int, int] = {} + container_index = 0 + + for f in files_sorted: + fsize = f["size"] + + if fsize > effective_limit: + print( + f"WARNING: file too large for container " + f"({fsize} > {effective_limit}): {f['abspath']}", + file=sys.stderr, + ) + continue + + # Find first existing container where this file fits + placed = False + for ci in sorted(containers.keys()): + if container_sizes[ci] + fsize <= effective_limit: + containers[ci].append(f) + container_sizes[ci] += fsize + placed = True + break + + if not placed: + containers[container_index] = [f] + container_sizes[container_index] = fsize + container_index += 1 + + return containers + + +# ── index.json ─────────────────────────────────────────────────────────────── + +def build_index_json( + files: List[dict], + file_tags: Dict[str, List[str]], + all_tags: List[dict], +) -> str: + """Build the index.json content in MedMan export format. No ``rel`` — canonical from hash.""" + ft: Dict[str, dict] = {} + for f in files: + h = f["hash"] + entry: dict = {"didx": f["didx"]} + # Use tags from file dict if present (API path), else from DB lookup + if "tags" in f and f["tags"]: + entry["tags"] = f["tags"] + else: + entry["tags"] = file_tags.get(h, []) + ft[h] = entry + + export = { + "ts": datetime.now(timezone.utc).isoformat(), + "tags": all_tags, + "files": ft, + } + return json.dumps(export, indent=2) + + +# ── piped tar → sqfstar (like squashr's build_squash_image_tar_sqfstar) ────── + +def _find_tool(name: str) -> Optional[str]: + """Find a tool in PATH, returning its path or None.""" + return shutil.which(name) + + +def build_squashfs_piped( + container_files: List[dict], + index_json_str: str, + output_file: str, + mksquashfs_args: str, +) -> bool: + """ + Build a SquashFS image for *container_files* with *index_json_str* at + the root — without any on-disk staging directory. + + Strategy: + 1. Write index.json to a temporary file alongside the output. + 2. Build file list for tar: null-delimited entries for the index.json + (with --transform to place it at root) and every file to include. + 3. Pipe: tar (reading file list) → sqfstar, which creates the + SquashFS image directly (identical approach to squashr). + """ + sqfstar_bin = _find_tool("sqfstar") + if not sqfstar_bin: + print("ERROR: sqfstar not found in PATH (required for staging-free builds).", + file=sys.stderr) + return False + + # Write index.json to a temporary file next to the output so tar can + # include it with --transform to place it at the archive root. + idx_tmp = output_file + ".tmp-index-" + str(os.getpid()) + try: + with open(idx_tmp, "w", encoding="utf-8") as fh: + fh.write(index_json_str) + + # Build the tar input: first the index.json temporary file, then + # all file paths. We use the same approach as squashr: + # tar --null --files-from=- with / as root. + paths_for_tar: List[str] = [] + paths_for_tar.append(idx_tmp) + for fi in container_files: + paths_for_tar.append(fi["abspath"]) + + return _run_tar_sqfstar(paths_for_tar, output_file, + mksquashfs_args, sqfstar_bin, idx_tmp) + finally: + # Clean up the temporary index.json file + try: + os.unlink(idx_tmp) + except OSError: + pass + + +def _run_tar_sqfstar( + paths: List[str], + output_file: str, + squashfs_args: str, + sqfstar_bin: str, + idx_tmp_path: str, +) -> bool: + """ + Pipe: tar (reading a null-delimited file list from stdin) → sqfstar. + + The first path in *paths* is the temporary index.json file; a + --transform rule places it at the root of the archive as index.json. + + tar options: + --null --files-from=- → read null-delimited paths from stdin + -C / → resolve paths from filesystem root + --format=posix + -cf - → write tar stream to stdout + --numeric-owner + --ignore-failed-read + --transform … → place the temporary index.json as /index.json + + sqfstar options: + -comp <algo> ... → from *squashfs_args* + <output_file> - → read tar from stdin + """ + tar_args = [ + "tar", + "--null", + "--files-from=-", + "-C", "/", + "--format=posix", + "-cf", "-", + "--numeric-owner", + "--ignore-failed-read", + f"--transform=flags=r;s|^{idx_tmp_path.lstrip('/')}$|index.json|", + ] + + sqfstar_args = [sqfstar_bin] + if squashfs_args: + sqfstar_args.extend(squashfs_args.split()) + sqfstar_args.append(output_file) + sqfstar_args.append("-") # read from stdin + + print(f" Pipe: tar → sqfstar → {output_file}") + + try: + sqfstar_proc = subprocess.Popen( + sqfstar_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=False, + ) + + tar_proc = subprocess.Popen( + tar_args, + stdin=subprocess.PIPE, + stdout=sqfstar_proc.stdin, + stderr=subprocess.PIPE, + text=False, + ) + + # Close sqfstar's stdin in this process (tar writes to it) + if sqfstar_proc.stdin: + sqfstar_proc.stdin.close() + + # Write the null-delimited file list into tar's stdin + for p in paths: + tar_proc.stdin.write(p.encode("utf-8")) + tar_proc.stdin.write(b"\0") + tar_proc.stdin.close() + + # Wait for both + tar_rc = tar_proc.wait() + sq_rc = sqfstar_proc.wait() + + if tar_rc != 0: + stderr_data = tar_proc.stderr.read().decode("utf-8", errors="replace") + print(f"WARNING: tar exited with code {tar_rc}", file=sys.stderr) + if stderr_data: + print(f" tar stderr: {stderr_data[:500]}", file=sys.stderr) + + if sq_rc != 0: + stderr_data = sqfstar_proc.stderr.read().decode("utf-8", errors="replace") + print(f"ERROR: sqfstar failed with code {sq_rc}", file=sys.stderr) + if stderr_data: + print(f" sqfstar stderr: {stderr_data[:500]}", file=sys.stderr) + return False + + return True + + except FileNotFoundError as exc: + print(f"ERROR: required tool not found: {exc}", file=sys.stderr) + return False + except Exception as exc: + print(f"ERROR: tar → sqfstar pipe failed: {exc}", file=sys.stderr) + return False + + +# ── LUKS encryption ────────────────────────────────────────────────────────── + +def encrypt_container( + output_file: str, + cryptsetup_args: str, + key_file: Optional[str] = None, +) -> bool: + """ + Encrypt a SquashFS image with LUKS2 using cryptsetup reencrypt. + + Approach: + 1. Grow the image by 32 MiB to make room for the LUKS header. + 2. Run `cryptsetup reencrypt --encrypt --type luks2 --reduce-device-size 32M`. + 3. Trim 16 MiB from the image (leaving 16 MiB for LUKS header). + 4. Rename output_file → output_file.luks. + """ + print(f" Encrypting {output_file} …") + + # Step 1: grow + result = subprocess.run( + ["truncate", "-s", "+32M", output_file], capture_output=True, text=True + ) + if result.returncode != 0: + print("ERROR: truncate (grow) failed", file=sys.stderr) + return False + + # Step 2: LUKS encrypt in-place + cmd = [ + "cryptsetup", "-q", "reencrypt", "--encrypt", + "--type", "luks2", "--reduce-device-size", "32M", + ] + if key_file: + cmd.extend(["--key-file", key_file]) + if cryptsetup_args: + cmd.extend(cryptsetup_args.split()) + cmd.append(output_file) + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + print("ERROR: cryptsetup reencrypt failed", file=sys.stderr) + print(f" stderr: {result.stderr[:500]}", file=sys.stderr) + return False + + # Step 3: trim + result = subprocess.run( + ["truncate", "-s", "-16M", output_file], capture_output=True, text=True + ) + if result.returncode != 0: + print("ERROR: truncate (trim) failed", file=sys.stderr) + return False + + # Step 4: rename to .luks + luks_file = f"{output_file}.luks" + os.rename(output_file, luks_file) + print(f" Encrypted container: {luks_file}") + return True + + +# ── statistics ─────────────────────────────────────────────────────────────── + +def print_statistics(containers: Dict[int, List[dict]], max_size: int) -> None: + total_files = 0 + total_size = 0 + + print("\n--- Stats ---") + for ci in sorted(containers.keys()): + files = containers[ci] + csize = sum(f["size"] for f in files) + util = (csize / max_size * 100) if max_size > 0 else 0 + print(f"Container {ci}:") + print(f" Files: {len(files)}") + print(f" Size: {csize:,} bytes ({csize / 1024**2:.2f} MiB)") + print(f" Utilization: {util:.1f} %") + total_files += len(files) + total_size += csize + + print(f"\nTotal: {total_files} files, {total_size:,} bytes " + f"({total_size / 1024**2:.2f} MiB)") + if containers: + avg = total_size / (len(containers) * max_size) * 100 if max_size > 0 else 0 + print(f"Avg. container utilization: {avg:.1f} %") + + +# ── main ───────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Create SquashFS (optionally LUKS) containers from MedMan-managed files." + ) + + # ── MedMan data source (mutually exclusive: API or direct DB) ──────── + src_group = parser.add_mutually_exclusive_group(required=True) + src_group.add_argument( + "--medman-url", + help="Base URL of a running MedMan instance (e.g. http://localhost:8080). " + "Uses the HTTP API to fetch file/tag data.", + ) + src_group.add_argument( + "--db", + help="Path to MedMan SQLite database (medman.db) – direct access.", + ) + + parser.add_argument( + "--dat", "--data-dir", + action="append", + default=[], + help="MedMan data directory (can be repeated).", + ) + + # ── File selection ──────────────────────────────────────────────────── + parser.add_argument( + "--tags", + nargs="*", + default=[], + help="Only include files with ALL of these tags (AND).", + ) + parser.add_argument( + "--hashes", + nargs="*", + default=[], + help="Only include files with these hashes (space-separated).", + ) + parser.add_argument( + "--hashes-from-file", + help="Read hashes from file (one per line).", + ) + parser.add_argument( + "--from-date", + help="Only include files modified on or after this date (ISO format, e.g. 2024-01-01).", + ) + parser.add_argument( + "--to-date", + help="Only include files modified on or before this date (ISO format).", + ) + parser.add_argument( + "--mime-type", + help="Only include files of this media type (video, image, audio, doc, other).", + ) + + # ── Container sizing ────────────────────────────────────────────────── + parser.add_argument( + "--container-size", + "-c", + type=int, + required=True, + help="Maximum container size in bytes (sum of file sizes, pre-packing).", + ) + parser.add_argument( + "--margin", + type=int, + default=1_048_576, # 1 MiB + help="Margin in bytes reserved for index.json + FS metadata (default: 1 MiB).", + ) + + # ── Output ──────────────────────────────────────────────────────────── + parser.add_argument( + "--output-prefix", + "-o", + required=True, + help="Prefix for output container files.", + ) + + # ── mksquashfs / sqfstar ────────────────────────────────────────────── + parser.add_argument( + "--mksquashfs-args", + default="-comp xz -b 1M", + help="Additional arguments passed to mksquashfs or sqfstar " + "(default: '-comp xz -b 1M').", + ) + + # ── cryptsetup / LUKS ───────────────────────────────────────────────── + parser.add_argument( + "--cryptsetup", + "-e", + action="store_true", + help="Encrypt each container with LUKS2 after building.", + ) + parser.add_argument( + "--cryptsetup-args", + default="", + help="Additional arguments passed to 'cryptsetup reencrypt'.", + ) + parser.add_argument( + "--key-file", + help="Path to a key file for LUKS encryption (passed to cryptsetup).", + ) + + # ── misc ────────────────────────────────────────────────────────────── + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Print detailed statistics.", + ) + + args = parser.parse_args() + + # ── prerequisites ───────────────────────────────────────────────────── + if not shutil.which("tar"): + print("ERROR: tar not found in PATH.", file=sys.stderr) + sys.exit(1) + if args.cryptsetup and not shutil.which("cryptsetup"): + print("ERROR: cryptsetup not found in PATH.", file=sys.stderr) + sys.exit(1) + + if shutil.which("sqfstar") is None: + print("ERROR: sqfstar (squashfs-tools-ng) not found in PATH.", file=sys.stderr) + sys.exit(1) + + print("Using sqfstar for direct tar→squashfs streaming (no staging).") + + # ── resolve hashes ──────────────────────────────────────────────────── + hashes: Optional[Set[str]] = None + if args.hashes or args.hashes_from_file: + hashes = set(args.hashes or []) + if args.hashes_from_file: + with open(args.hashes_from_file, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if line and not line.startswith("#"): + hashes.add(line) + + if not args.dat: + print("ERROR: at least one --dat is required.", file=sys.stderr) + sys.exit(1) + + dat_dirs = resolve_data_dirs(args.dat) + tags_filter = args.tags if args.tags else None + + # ── query MedMan (API or DB) ────────────────────────────────────────── + all_tags: List[dict] = [] + file_tags: Dict[str, List[str]] = {} + files: List[dict] = [] + + if args.medman_url: + # ── API path ────────────────────────────────────────────────────── + export = fetch_medman_export(args.medman_url, tags_filter) + all_tags = export.get("tags", []) + + files = resolve_files_from_export( + export, dat_dirs, hashes, + args.mime_type, args.from_date, args.to_date, + ) + + # Tags are already embedded in each file dict from the export + for f in files: + file_tags[f["hash"]] = f.get("tags", []) + + else: + # ── Direct DB path ──────────────────────────────────────────────── + db = gdb(args.db) + try: + files = query_files_by_tags( + db, dat_dirs, tags_filter, hashes, + args.from_date, args.to_date, args.mime_type, + ) + all_tags = query_all_tags(db) + + all_f_hashes = [f["hash"] for f in files] + file_tags = query_file_tags(db, all_f_hashes) + finally: + db.close() + + if not files: + print("No files matched the selection criteria.", file=sys.stderr) + sys.exit(1) + + total_bytes = sum(f["size"] for f in files) + print(f"Selected {len(files)} files, total {total_bytes:,} bytes " + f"({total_bytes / 1024**2:.2f} MiB)") + + # ── split into containers ───────────────────────────────────────────── + containers = split_files(files, args.container_size, args.margin) + + if not containers: + print("No files could be packed (all too large?).", file=sys.stderr) + sys.exit(1) + + print(f"Split into {len(containers)} container(s).") + + # ── build each container ────────────────────────────────────────────── + for ci in sorted(containers.keys()): + c_files = containers[ci] + c_size = sum(f["size"] for f in c_files) + print(f"\n── Container {ci} ──") + print(f" Files: {len(c_files)}, {c_size:,} bytes " + f"({c_size / 1024**2:.2f} MiB)") + + # Build index.json for this container + index_json_str = build_index_json(c_files, file_tags, all_tags) + + # Build squashfs + squashfs_file = f"{args.output_prefix}-{ci}.squashfs" + ok = build_squashfs_piped( + c_files, index_json_str, squashfs_file, args.mksquashfs_args + ) + + if not ok: + print(f"ERROR: failed to create {squashfs_file}", file=sys.stderr) + sys.exit(1) + + print(f" Created {squashfs_file}") + + # Optional LUKS encryption + if args.cryptsetup: + if not encrypt_container(squashfs_file, args.cryptsetup_args, + args.key_file): + sys.exit(1) + + if args.verbose: + print_statistics(containers, args.container_size - args.margin) + + print(f"\nDone. Created {len(containers)} container(s).") + + +if __name__ == "__main__": + main()
\ No newline at end of file |
