aboutsummaryrefslogtreecommitdiffstats
path: root/archival.py
diff options
context:
space:
mode:
authorLeonard Kugis <leonard@kug.is>2026-09-04 04:01:49 +0200
committerLeonard Kugis <leonard@kug.is>2026-09-04 04:01:49 +0200
commit558c90807b09b16757c88f2e50bdbd123d425f37 (patch)
tree2f298bb77caf0ea93d6c01e94d1f25024e22082f /archival.py
downloadmedman-master.tar.gz
Initial commitHEADmaster
Diffstat (limited to 'archival.py')
-rw-r--r--archival.py860
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