#!/usr/bin/env bash
# Share a local model folder on Pirate Face in one go: builds a hybrid v1+v2 torrent,
# checks the files against Hugging Face, and submits the magnet. Your files
# are only read, never changed or uploaded.
#
#   curl -fsSL https://pirateface.co/package.sh | bash -s -- --repo author/model ./model-folder
#
# Or share models you already downloaded with Hugging Face tools. This finds
# them in the Hugging Face cache, shows what can be shared, then asks per model:
#
#   curl -fsSL https://pirateface.co/package.sh | bash -s -- --hf-cache
#
# Already seeding Pirate Face torrents? Add your account's seeding tracker to all of them
# (nothing is downloaded, re-shared or resubmitted):
#
#   curl -fsSL https://pirateface.co/package.sh | bash -s -- --update-trackers
#
# Needs: Python 3, curl, sha256sum (or shasum). On Windows: Git Bash (Git for Windows).
# Env: PIRATEFACE_KEY (else asked once and saved), HF_TOKEN for gated repos.
# Torrent client: TR_HOST, TR_AUTH=user:pass (Transmission); QBT_URL, QBT_USER, QBT_PASS (qBittorrent).
# -y / --yes skips the prompts; only for agents acting on the user's explicit request.
set -euo pipefail
# Braces make bash read the whole script before running it. Piped from curl, an
# early exit would otherwise close the pipe and curl would print error 23.
{

SITE="${PIRATEFACE_URL:-https://pirateface.co}"
# Fallback when the site does not answer; normally replaced by $SITE/api/trackers.
TRACKERS=(udp://tracker.pirateface.co:6969/announce http://tracker.pirateface.co:6969/announce)
TERMS_VERSION="2026-09-18.2"
KEY_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/pirateface/key"
REPO="" REVISION="" OUT="" YES="" NAME="" HF_CACHE="" UPDATE_TRACKERS=""

usage() {
  echo "usage: package.sh --repo author/model [--revision COMMIT] [--name NAME] [--out DIR] [-y] ./model-folder"
  echo "       package.sh --hf-cache [-y]"
  echo "       package.sh --update-trackers"
  exit "${1:-0}"
}
die() { echo "error: $*" >&2; exit 1; }
# Prompts read the keyboard, not stdin, so `curl | bash` still works.
ask() { [ -n "$YES" ] && return 0; [ -r /dev/tty ] || die "no terminal for prompts (agents: pass -y with the user's OK)"; read -r "$@" </dev/tty; }
# The licenses Pirate Face lists come from the site ($SITE/api/licenses), not a copy in this script.
load_licenses() { [ -n "${PF_LICENSES:-}" ] && return 0; PF_LICENSES="$(curl -fsSL "$SITE/api/licenses")" || die "could not read the license list from $SITE/api/licenses"; export PF_LICENSES; }
# lic ids | lic approved <repo> | lic required <license id>
lic() { python3 -c 'import json, os, sys
L, a = json.loads(os.environ["PF_LICENSES"]), sys.argv[1:]
if a[0] == "ids": print(" ".join(l["id"] for l in L["licenses"]))
if a[0] == "approved": print(next((r["license"] for r in L["repoLicenses"] if r["repoId"].lower() == a[1].lower()), ""))
if a[0] == "required": print(next((l["requiredFile"] or "" for l in L["licenses"] if l["id"] == a[1]), ""))' "$@"; }

# Windows (Git Bash): ln -s makes real links instead of copies (needs Developer Mode or an
# administrator shell). Python may be "python" there, and its \r\n line endings are cut to \n.
export MSYS="${MSYS:-winsymlinks:nativestrict}"
PYTHON=""
for py in python3 python; do "$py" -c 'import sys; sys.exit(sys.version_info < (3, 8))' 2>/dev/null && { PYTHON="$py"; break; }; done
python3() { command "$PYTHON" "$@" | tr -d '\r'; }

while [ $# -gt 0 ]; do
  case "$1" in
    --repo) REPO="${2:?}"; shift 2 ;;
    --revision) REVISION="${2:?}"; shift 2 ;;
    --out) OUT="${2:?}"; shift 2 ;;
    --name) NAME="${2:?}"; shift 2 ;;
    --hf-cache) HF_CACHE=1; shift ;;
    --update-trackers) UPDATE_TRACKERS=1; shift ;;
    -y|--yes) YES=1; shift ;;
    -h|--help) usage ;;
    -*) die "unknown option $1" ;;
    *) [ -z "${SRC:-}" ] || die "only one folder"; SRC="$1"; shift ;;
  esac
done

# Account key from $SITE/account, saved for next time.
load_key() {
  KEY="${PIRATEFACE_KEY:-$(cat "$KEY_FILE" 2>/dev/null || true)}"
  if [ -z "$KEY" ]; then
    [ -z "$YES" ] || die "set PIRATEFACE_KEY (from $SITE/account)"
    echo; echo "==> Your Pirate Face key: $SITE/account -> Community torrent key"
    ask -s -p "    Paste it (hidden): " KEY; echo
    [[ "$KEY" == pfct_* ]] || die "that doesn't look like a Pirate Face key (starts with pfct_)"
    mkdir -p "$(dirname "$KEY_FILE")"; (umask 077; echo "$KEY" > "$KEY_FILE")
    echo "    Saved to $KEY_FILE"
  fi
}

# Trackers come from the site (public ones plus this account's seeding tracker),
# so a tracker change never needs a new copy of this script.
load_trackers() {
  SITE_TRACKERS=()
  while IFS= read -r t; do
    [[ "$t" =~ ^(udp|https?)://[^[:space:]]+$ ]] && SITE_TRACKERS+=("$t")
  done < <(curl -fsS -m 15 -H "Authorization: Bearer $KEY" "$SITE/api/trackers" 2>/dev/null || true)
  [ ${#SITE_TRACKERS[@]} -eq 0 ] || TRACKERS=("${SITE_TRACKERS[@]}")
}

# --update-trackers: add this account's seeding tracker to every Pirate Face torrent already
# in Transmission or qBittorrent, so Pirate Face can count what the account seeds. Trackers
# are outside the info dict: the infohash stays the same and nothing is downloaded again.
if [ -n "$UPDATE_TRACKERS" ]; then
  [ -n "$PYTHON" ] || die "Python 3 not found (python.org, brew or apt)"
  command -v curl >/dev/null || die "curl not found (apt/brew install curl)"
  load_key
  load_trackers
  SEED_TRACKER=""; [ ${#SITE_TRACKERS[@]} -eq 0 ] || SEED_TRACKER="${SITE_TRACKERS[${#SITE_TRACKERS[@]}-1]}"
  [[ "$SEED_TRACKER" == *:6970/*/announce ]] || die "could not get your seeding tracker from $SITE/api/trackers (key rejected or site down)"
  python3 - "$SEED_TRACKER" <<'PY'
import base64, http.cookiejar, json, os, re, sys, urllib.error, urllib.parse, urllib.request
mine = sys.argv[1]
PF = re.compile(r"^(udp|https?)://tracker\.pirateface\.co[:/]")
KEYED = re.compile(r"^https?://tracker\.pirateface\.co:6970/[0-9a-f]+/announce$")
def pf(urls): return any(PF.match(u) for u in urls)
def report(client, added, current, skipped, extra=""):
    print(f"    {client}: added to {added}, already had it {current}, not Pirate Face {skipped}.{extra}")

def transmission():
    host = os.environ.get("TR_HOST") or "localhost:9091"
    url = host if host.startswith("http") else f"http://{host}"
    url = url.rstrip("/") + ("" if url.endswith("/rpc") else "/transmission/rpc")
    headers = {"Content-Type": "application/json"}
    if os.environ.get("TR_AUTH"):
        headers["Authorization"] = "Basic " + base64.b64encode(os.environ["TR_AUTH"].encode()).decode()
    def rpc(method, args):
        body = json.dumps({"method": method, "arguments": args}).encode()
        for _ in range(2):
            try:
                with urllib.request.urlopen(urllib.request.Request(url, body, headers), timeout=30) as r:
                    out = json.load(r)
                    if out.get("result") != "success": raise RuntimeError(out.get("result"))
                    return out["arguments"]
            except urllib.error.HTTPError as e:
                if e.code != 409: raise
                headers["X-Transmission-Session-Id"] = e.headers["X-Transmission-Session-Id"]
    try:
        version = rpc("session-get", {"fields": ["rpc-version"]}).get("rpc-version", 0)
    except Exception:
        return False
    added = current = skipped = 0; changed = []
    for t in rpc("torrent-get", {"fields": ["id", "trackers"]})["torrents"]:
        trackers = t["trackers"]; urls = [x["announce"] for x in trackers]
        if not pf(urls): skipped += 1; continue
        if mine in urls: current += 1; continue
        keep = [x for x in trackers if not KEYED.match(x["announce"])]  # a key replaced at /account
        if version >= 17:  # Transmission 4: trackerList, one tier per blank-line block
            tiers = {}
            for x in keep: tiers.setdefault(x["tier"], []).append(x["announce"])
            lines = "\n\n".join("\n".join(v) for _, v in sorted(tiers.items()))
            rpc("torrent-set", {"ids": [t["id"]], "trackerList": lines + "\n\n" + mine})
        else:
            old = [x["id"] for x in trackers if KEYED.match(x["announce"])]
            if old: rpc("torrent-set", {"ids": [t["id"]], "trackerRemove": old})
            rpc("torrent-set", {"ids": [t["id"]], "trackerAdd": [mine]})
        added += 1; changed.append(t["id"])
    if changed: rpc("torrent-reannounce", {"ids": changed})
    report("Transmission", added, current, skipped)
    return True

def qbittorrent():
    base = (os.environ.get("QBT_URL") or "http://localhost:8080").rstrip("/")
    op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
    op.addheaders = [("Referer", base)]
    def call(path, data=None):
        body = urllib.parse.urlencode(data).encode() if data is not None else None
        with op.open(base + "/api/v2/" + path, body, timeout=30) as r:
            return r.read().decode()
    try:
        if os.environ.get("QBT_USER"):
            call("auth/login", {"username": os.environ["QBT_USER"], "password": os.environ.get("QBT_PASS", "")})
        call("app/version")
    except Exception:
        return False
    # Announce to every tracker in every tier. qBittorrent's default stops at the first
    # tracker that answers, so the seeding tracker would never hear from it.
    prefs = json.loads(call("app/preferences"))
    extra = ""
    if not (prefs.get("announce_to_all_trackers") and prefs.get("announce_to_all_tiers")):
        call("app/setPreferences", {"json": json.dumps({"announce_to_all_trackers": True, "announce_to_all_tiers": True})})
        extra = " Turned on Options > Advanced > Always announce to all trackers in a tier / to all tiers."
    added = current = skipped = 0; changed = []
    for t in json.loads(call("torrents/info")):
        h = t["hash"]
        urls = [x["url"] for x in json.loads(call("torrents/trackers?hash=" + h)) if "://" in x["url"]]
        if not pf(urls): skipped += 1; continue
        if mine in urls: current += 1; continue
        old = [u for u in urls if KEYED.match(u)]
        if old: call("torrents/removeTrackers", {"hash": h, "urls": "|".join(old)})
        call("torrents/addTrackers", {"hash": h, "urls": mine})
        added += 1; changed.append(h)
    if changed: call("torrents/reannounce", {"hashes": "|".join(changed)})
    report("qBittorrent", added, current, skipped, extra)
    return True

print("==> Adding your seeding tracker to Pirate Face torrents", flush=True)
found = [transmission(), qbittorrent()]
if not any(found):
    sys.exit("error: no torrent client answered. Transmission: its remote access (RPC) must be on "
             "(TR_HOST, TR_AUTH=user:pass). qBittorrent: the Web UI must be on (QBT_URL, QBT_USER, QBT_PASS).")
print("    Done. Pirate Face counts your seeding from the next announce (within 10 minutes).")
PY
  exit 0
fi

# --hf-cache: list every model in the Hugging Face cache, then package the ones
# Pirate Face does not have yet. The cache names large files by their SHA-256,
# so matching them against Hugging Face needs no hashing.
if [ -n "$HF_CACHE" ]; then
  [ -n "$PYTHON" ] || die "Python 3 not found (python.org, brew or apt)"
  HUB="${HF_HUB_CACHE:-${HF_HOME:-$HOME/.cache/huggingface}/hub}"
  [ -d "$HUB" ] || die "no Hugging Face cache at $HUB (set HF_HUB_CACHE)"
  echo "==> Checking models in $HUB"
  PLAN="$(mktemp)"; trap 'rm -f "$PLAN"' EXIT
  load_licenses
  python3 - "$HUB" "$SITE" > "$PLAN" <<'PY'
import glob, json, os, re, sys, urllib.error, urllib.parse, urllib.request
hub, site = sys.argv[1:3]
def get(url, as_json=True):
    req = urllib.request.Request(url, headers={"User-Agent": "pirateface-package.sh"})
    if "huggingface.co" in url and os.environ.get("HF_TOKEN"):
        req.add_header("Authorization", "Bearer " + os.environ["HF_TOKEN"])
    with urllib.request.urlopen(req, timeout=30) as r:
        nxt = re.search(r'<([^>]+)>;\s*rel="next"', r.headers.get("Link") or "")
        return (json.load(r) if as_json else r.read().decode()), nxt and nxt.group(1)
def hidden(path): return any(part.startswith(".") for part in path.split("/"))
# The site's license list; some licenses require a file (LICENSE, LICENSE.md) in every copy.
LICENSES = json.loads(os.environ["PF_LICENSES"])
ALLOWED = tuple(l["id"] for l in LICENSES["licenses"])
REQUIRED = {l["id"]: l["requiredFile"] for l in LICENSES["licenses"] if l["requiredFile"]}
NAMES = {n: l["id"] for l in LICENSES["licenses"] for n in l["hfNames"]}
APPROVED = {r["repoId"].lower() for r in LICENSES["repoLicenses"]}
def gb(n): return f"{n / 1e9:.1f} GB" if n >= 1e8 else f"{n / 1e6:.0f} MB"
for snap in sorted(glob.glob(os.path.join(hub, "models--*", "snapshots", "*"))):
    repo = os.path.basename(os.path.dirname(os.path.dirname(snap)))[len("models--"):].replace("--", "/", 1)
    rev = os.path.basename(snap)
    row = lambda status, why, size="-", magnet="-": print("\t".join([status, repo, rev, snap, size, why, magnet]))
    try:
        info = get(f"https://huggingface.co/api/models/{repo}/revision/{rev}?blobs=true")[0]
        # Gated repos mask LFS hashes in the tree as asterisks; blobs=true lists them without access.
        blobs = {s["rfilename"]: s["lfs"]["sha256"] for s in info.get("siblings") or [] if s.get("lfs")}
        url, remote = f"https://huggingface.co/api/models/{repo}/tree/{rev}?recursive=true", {}
        while url:
            page, url = get(url)
            for e in page:
                if e.get("lfs") and e["path"] in blobs: e["lfs"]["oid"] = blobs[e["path"]]
            remote.update({e["path"]: e for e in page if e.get("type") == "file" and not hidden(e["path"])})
    except Exception:
        row("skip", "not readable on Hugging Face (gated? set HF_TOKEN)"); continue
    card = info.get("cardData") or {}
    raw = card.get("license")
    lics = [str(x).strip().lower() for x in (raw if isinstance(raw, list) else [raw]) if str(x or "").strip()]
    # Hugging Face tags custom licenses "other" and names them in license_name.
    name = NAMES.get(str(card.get("license_name") or "").strip().lower())
    if "other" in lics and name: lics.append(name)
    lic = next((x for x in ALLOWED if x in lics), lics[0] if lics else "none")
    size = gb(sum((e.get("lfs") or e).get("size", 0) for e in remote.values()))
    if lic not in ALLOWED and repo.lower() not in APPROVED:
        row("skip", f"license {lic}", size); continue
    have = missing = bad = 0
    for path, e in remote.items():
        local = os.path.join(snap, path)
        if not os.path.exists(local):
            missing += bool(e.get("lfs"))  # weights must all be here; a skipped README is fine
            continue
        want = (e.get("lfs") or {}).get("oid") or e.get("oid")
        # The snapshot link names the blob by its hash; huggingface_hub 2.x links that blob on to a
        # shared store under another name, so follow one level only.
        blob = os.readlink(local) if os.path.islink(local) else os.path.realpath(local)
        if os.path.basename(blob) != want: bad += 1
        else: have += 1
    if bad: row("skip", f"{bad} files differ from Hugging Face", size); continue
    if missing: row("skip", f"partial download: {missing} weight files missing", size); continue
    if lic in REQUIRED and not os.path.exists(os.path.join(snap, REQUIRED[lic])):
        row("skip", f"{lic} copies must include {REQUIRED[lic]}; download it too", size); continue
    try:
        listed = get(f"{site}/api/torrents?repo={urllib.parse.quote(repo, safe='/')}")[0]
        # License terms (e.g. gemma) withhold the magnet until accepted; it is still listed.
        magnet = listed.get("magnet") or (listed.get("terms") and f"{site}/{repo}")
    except urllib.error.HTTPError as e:
        if e.code != 404:
            row("skip", f"Pirate Face lookup failed (HTTP {e.code})", size); continue
        magnet = None  # not listed yet
    except Exception:
        row("skip", "could not reach Pirate Face", size); continue
    if magnet: row("listed", "already on Pirate Face", size, magnet)
    else: row("submit", "complete, not on Pirate Face yet", size)
PY
  echo
  while IFS=$'\t' read -r status repo rev snap size why magnet; do
    printf '    %-7s %-48s %9s  %s\n' "$status" "$repo" "$size" "$why"
  done < "$PLAN"
  COUNT=$(grep -c '^submit' "$PLAN" || true)
  [ "$COUNT" -gt 0 ] || { echo; echo "    Nothing new to submit."; exit 0; }
  # Re-run this script per model. Piped through curl there is no file, so fetch it.
  SELF="$0"
  if ! grep -q 'pirateface' "$SELF" 2>/dev/null; then
    SELF="$(mktemp)"; trap 'rm -f "$PLAN" "$SELF"' EXIT
    curl -fsSL "$SITE/package.sh" -o "$SELF" || die "could not download $SITE/package.sh"
  fi
  SEEDS="${PIRATEFACE_SEEDS:-$HOME/pirateface-seeds}"
  # Models that could not be added to a client are collected here and listed at the end.
  export PF_NEEDS_FILE="$(mktemp)"; FAILED=() LATER=()
  # Pirate Face takes 200 submissions an hour per account. Build at most 200 per run
  # so nobody hashes hundreds of GB only to be turned away.
  TRIES=0
  while IFS=$'\t' read -r status repo rev snap size why magnet <&3; do
    [ "$status" = submit ] || continue
    [ "$TRIES" -lt 200 ] || { LATER+=("$repo"); continue; }
    echo
    if [ -z "$YES" ]; then ask -p "==> Share $repo ($size)? [y/N] " OK; [[ "${OK:-}" =~ ^[Yy] ]] || continue; fi
    name="${repo#*/}"; TRIES=$((TRIES + 1)); RC=0
    bash "$SELF" --repo "$repo" --revision "$rev" --name "$name" --out "$SEEDS/$name" ${YES:+-y} "$snap" || RC=$?
    if [ "$RC" -eq 75 ]; then LATER+=("$repo"); TRIES=200; continue; fi
    if [ "$RC" -ne 0 ]; then
      grep -q "^$repo	" "$PF_NEEDS_FILE" || FAILED+=("$repo")
      echo "    $repo not submitted; continuing"
    fi
  done 3< "$PLAN"
  if [ -s "$PF_NEEDS_FILE" ]; then
    echo; echo "==> Needs you: no torrent client could be reached for these. Nothing was submitted."
    echo "    Open each .torrent in your torrent client and save to the folder shown. Then run"
    echo "    this again without -y and press Enter when each one says Seeding."
    while IFS=$'\t' read -r repo torrent dir; do
      echo "    $repo"; echo "        open:    $torrent"; echo "        save to: $dir"
    done < "$PF_NEEDS_FILE"
  fi
  if [ ${#FAILED[@]} -gt 0 ]; then
    echo; echo "==> Not submitted (see the error above each one): ${FAILED[*]}"
  fi
  if [ ${#LATER[@]} -gt 0 ]; then
    echo; echo "==> Next hour: Pirate Face takes 200 submissions an hour. Run this again in an hour for: ${LATER[*]}"
  fi
  rm -f "$PF_NEEDS_FILE"
  exit 0
fi

[ -n "${SRC:-}" ] && [ -n "$REPO" ] || usage 1
[ -d "$SRC" ] || die "$SRC is not a folder"
[[ "$REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || die "--repo must look like author/model"
[ -n "$PYTHON" ] || die "Python 3 not found (python.org, brew or apt)"
command -v curl >/dev/null || die "curl not found (apt/brew install curl)"
if command -v sha256sum >/dev/null; then SHA=(sha256sum); else SHA=(shasum -a 256); fi
[ -z "$REVISION" ] || [[ "$REVISION" =~ ^[0-9a-fA-F]{40}$ ]] || die "--revision must be a full 40-character commit ID"

SRC="$(cd "$SRC" && pwd)"
NAME="${NAME:-$(basename "$SRC")}"
[[ "$NAME" =~ ^[A-Za-z0-9._-]+$ ]] || die "--name may only use letters, numbers, . _ -"
OUT="${OUT:-$PWD/pirateface-$NAME}"
mkdir -p "$OUT"
STAGE="$(mktemp -d)"; trap 'rm -rf "$STAGE"' EXIT
mkdir "$STAGE/$NAME"

# Account key first: the torrent carries this account's seeding tracker, and the
# submission is signed with it.
load_key
load_trackers

# 1. Stage only model files as symlinks. Hidden paths (.git, .cache, .env),
#    keys and partial downloads stay out of the torrent.
echo "==> Selecting files in $SRC"
EXCLUDED=0
while IFS= read -r -d '' f; do
  rel="${f#"$SRC"/}"
  case "/$rel" in
    */.*|*/__pycache__/*|*.pem|*.key|*/id_rsa*|*.incomplete|*.lock|*.part)
      echo "    skip  $rel"; EXCLUDED=$((EXCLUDED + 1)); continue ;;
  esac
  mkdir -p "$STAGE/$NAME/$(dirname "$rel")"
  ln -s "$f" "$STAGE/$NAME/$rel" || die "could not link $rel. On Windows, turn on Developer Mode (Settings > System > For developers) or run Git Bash as administrator"
done < <(find -L "$SRC" -type f -print0 | sort -z)
COUNT=$(find "$STAGE/$NAME" -type l | wc -l | tr -d ' ')
[ "$COUNT" -gt 0 ] || die "no files left to package"
echo "    $COUNT files included, $EXCLUDED skipped"
# The client looks for files under <save folder>/$NAME. When the folder has a
# different name (the Hugging Face cache names it by commit), keep a folder of
# links with the right name next to the torrent and seed from there.
SAVE_DIR="$(dirname "$SRC")"
if [ "$NAME" != "$(basename "$SRC")" ]; then
  SAVE_DIR="$OUT/files"
  rm -rf "$SAVE_DIR/$NAME"; mkdir -p "$SAVE_DIR"; cp -a "$STAGE/$NAME" "$SAVE_DIR/"
fi

# 2. Checksums, paths relative to the torrent root.
echo "==> Hashing (SHA-256)"
(cd "$STAGE/$NAME" && find . -type l -print0 | sort -z | xargs -0 -n 8 -P 4 "${SHA[@]}") \
  | sed -E 's#^([0-9a-f]{64}) [ *]\./#\1  #' | sort -k2 > "$OUT/checksums.txt"

# 3. Hybrid BitTorrent v1+v2 torrent with the Pirate Face trackers. ~2000 pieces, 256 KiB-64 MiB
#    (64 MiB keeps the .torrent under the 3 MB upload limit up to ~10 TB).
PIECE=$(python3 - "$STAGE/$NAME" <<'PY'
import os, sys
total = sum(os.path.getsize(os.path.join(d, f)) for d, _, fs in os.walk(sys.argv[1], followlinks=True) for f in fs)
n = 18
while n < 26 and total / (1 << n) > 2000: n += 1
print(n)
PY
)
echo "==> Creating torrent (piece size 2^$PIECE)"
rm -f "$OUT/$NAME.torrent"
# Hybrid (v1 + v2, BEP 52) with BEP 47 pad files, so every file starts on a new piece.
# Web seed downloads of plain v1 multi-file torrents stall at ~99% in libtorrent clients.
# Same output as libtorrent's create_torrent. Prints the v1 infohash.
INFOHASH=$(python3 - "$STAGE/$NAME" "$NAME" "$PIECE" "$OUT/$NAME.torrent" "${TRACKERS[@]}" <<'PY'
import hashlib, os, sys
root, name, exp, out, *trackers = sys.argv[1:]
plen, BLOCK, ZERO = 1 << int(exp), 16384, bytes(32)
def enc(x):
    if isinstance(x, int): return b"i%de" % x
    if isinstance(x, str): x = x.encode()
    if isinstance(x, bytes): return b"%d:%s" % (len(x), x)
    if isinstance(x, list): return b"l" + b"".join(map(enc, x)) + b"e"
    return b"d" + b"".join(enc(k) + enc(x[k]) for k in sorted(x)) + b"e"
def merkle(layer, pad):  # pad to a power of two, then hash pairs up to the root
    n = 1
    while n < len(layer): n *= 2
    layer = layer + [pad] * (n - len(layer))
    while len(layer) > 1: layer = [hashlib.sha256(layer[i] + layer[i + 1]).digest() for i in range(0, len(layer), 2)]
    return layer[0]
per_piece = plen // BLOCK
piece_pad = merkle([ZERO] * per_piece, ZERO)
paths = sorted((os.path.relpath(os.path.join(d, f), root).split(os.sep) for d, _, fs in os.walk(root, followlinks=True) for f in fs),
               key=lambda p: [s.encode() for s in p])
files, tree, layers, v1, buf = [], {}, {}, [], b""
def add(data):
    global buf
    buf += data
    if len(buf) == plen: v1.append(hashlib.sha1(buf).digest()); buf = b""
for parts in paths:
    size, leaves, piece_roots = 0, [], []
    with open(os.path.join(root, *parts), "rb") as f:
        while block := f.read(BLOCK):
            size += len(block); add(block)
            leaves.append(hashlib.sha256(block).digest())
            if len(leaves) == per_piece: piece_roots.append(merkle(leaves, ZERO)); leaves = []
    entry = {"length": size}
    if size > plen:
        if leaves: piece_roots.append(merkle(leaves + [ZERO] * (per_piece - len(leaves)), ZERO))
        entry["pieces root"] = merkle(piece_roots, piece_pad)
        layers[entry["pieces root"]] = b"".join(piece_roots)
    elif size:
        entry["pieces root"] = piece_roots[0] if piece_roots else merkle(leaves, ZERO)
    node = tree
    for p in parts: node = node.setdefault(p, {})
    node[""] = entry
    files.append({"length": size, "path": parts})
    pad = -size % plen
    if pad and len(paths) > 1:
        files.append({"attr": "p", "length": pad, "path": [".pad", str(pad)]}); add(bytes(pad))
if buf: v1.append(hashlib.sha1(buf).digest())
info = {"file tree": tree, "files": files, "meta version": 2, "name": name, "piece length": plen, "pieces": b"".join(v1)}
open(out, "wb").write(enc({"announce": trackers[0], "announce-list": [[t] for t in trackers], "info": info, "piece layers": layers}))
print(hashlib.sha1(enc(info)).hexdigest())
PY
)
MAGNET="magnet:?xt=urn:btih:$INFOHASH"
echo "$MAGNET" > "$OUT/magnet.txt"
# Trackers stay out of the submitted magnet (the site adds them back). They
# belong on the link the user opens, or the client never announces.
SEED_MAGNET=$(python3 - "$INFOHASH" "$NAME" "${TRACKERS[@]}" <<'PY'
import sys, urllib.parse
ih, name, *trackers = sys.argv[1:]
q = urllib.parse.quote
print("magnet:?xt=urn:btih:" + ih + "&dn=" + q(name, safe="") + "".join("&tr=" + q(t, safe="") for t in trackers))
PY
)

# 4. Compare against Hugging Face LFS hashes and read the license. If every hash
#    matches, the submission is listed right away instead of going to review.
echo "==> Checking against huggingface.co/$REPO"
load_licenses
read -r HF_SHA HF_LICENSE HF_MATCH < <(python3 - "$REPO" "${REVISION:-main}" "$OUT/checksums.txt" <<'PY'
import json, os, re, sys, urllib.request
repo, rev, manifest = sys.argv[1:4]
def get(url):
    req = urllib.request.Request(url)
    if os.environ.get("HF_TOKEN"): req.add_header("Authorization", "Bearer " + os.environ["HF_TOKEN"])
    with urllib.request.urlopen(req, timeout=30) as r:
        nxt = re.search(r'<([^>]+)>;\s*rel="next"', r.headers.get("Link") or "")
        return json.load(r), nxt and nxt.group(1)
try:
    info = get(f"https://huggingface.co/api/models/{repo}/revision/{rev}?blobs=true")[0]
    sha = info["sha"]
    # Gated repos mask LFS hashes in the tree as asterisks; blobs=true lists them without access.
    blobs = {s["rfilename"]: s["lfs"]["sha256"] for s in info.get("siblings") or [] if s.get("lfs")}
    card = info.get("cardData") or {}
    raw = card.get("license")
    items = raw if isinstance(raw, list) else [raw]
    items = [str(x).strip().lower() for x in items if str(x or "").strip()]
    licenses = json.loads(os.environ["PF_LICENSES"])["licenses"]
    allowed = tuple(l["id"] for l in licenses)
    name = {n: l["id"] for l in licenses for n in l["hfNames"]}.get(str(card.get("license_name") or "").strip().lower())
    if "other" in items and name: items.append(name)
    lic = next((x for x in allowed if x in items), items[0] if items else "-")
    url, remote = f"https://huggingface.co/api/models/{repo}/tree/{sha}?recursive=true", {}
    while url:
        page, url = get(url)
        remote.update({e["path"]: blobs.get(e["path"], e["lfs"]["oid"]) for e in page if e.get("lfs")})
except Exception as e:
    print(f"    could not read it from Hugging Face ({e}); the review team will check by hand", file=sys.stderr)
    print("- - no"); sys.exit(0)
local = dict(reversed(l.rstrip("\n").split("  ", 1)) for l in open(manifest))
bad = [p for p in remote if p in local and local[p] != remote[p]]
missing = [p for p in remote if p not in local]
print(f"    revision {sha}: {len(remote) - len(bad) - len(missing)}/{len(remote)} LFS files match, license {lic}", file=sys.stderr)
for p in bad: print(f"    MISMATCH  {p}", file=sys.stderr)
for p in missing: print(f"    not in torrent  {p}", file=sys.stderr)
print(sha, lic, "yes" if remote and not bad else "no")
PY
)
if [ "$HF_MATCH" = yes ]; then
  REVISION="$HF_SHA"
elif [ -z "$REVISION" ]; then
  echo "    Hashes don't match Hugging Face, so it goes to manual review."
  ask -p "    Commit ID your copy came from (40 characters): " REVISION
  [[ "${REVISION:-}" =~ ^[0-9a-fA-F]{40}$ ]] || die "need the full 40-character commit ID; rerun with --revision"
fi

IDS=" $(lic ids) " APPROVED="$(lic approved "$REPO")"
if [ -n "$APPROVED" ] && [[ " $APPROVED other - " == *" $HF_LICENSE "* ]]; then
  # Approved one by one (e.g. Kimi-K3); with no Hugging Face license it takes the approved one.
  LICENSE="$HF_LICENSE"; [ "$LICENSE" != - ] || LICENSE="$APPROVED"
elif [[ "$IDS" == *" $HF_LICENSE "* ]]; then
  LICENSE="$HF_LICENSE"
elif [ "$HF_LICENSE" = - ]; then
  ask -p "    License ($(lic ids | sed 's/ /, /g')): " LICENSE
  [[ "$IDS" == *" ${LICENSE:-none} "* ]] || die "Pirate Face lists only the licenses at $SITE/policy"
else
  die "license is $HF_LICENSE; Pirate Face lists only the licenses at $SITE/policy"
fi
# Some licenses require a file in every copy; Pirate Face rejects the torrent without it.
NEED="$(lic required "$LICENSE")"
[ -z "$NEED" ] || grep -q "^[0-9a-f]\{64\}  $NEED\$" "$OUT/checksums.txt" || die "the $LICENSE license requires $NEED in the torrent; download it into the model folder and rerun"

if [ "$HF_MATCH" = yes ]; then
  EVIDENCE="Source: https://huggingface.co/$REPO at revision $REVISION. License per model card: $LICENSE. All Hugging Face LFS SHA-256 hashes at that revision match the local copy (checked by package.sh)."
else
  echo "    Where did this copy come from? Links, license proof, and why it's gone from Hugging Face if it is."
  ask -p "    Evidence: " EVIDENCE
  [ "${#EVIDENCE}" -ge 30 ] || die "evidence needs at least 30 characters"
fi

# 5. Seed first: a listed magnet is only useful if someone is sharing the files.
#    Add the torrent to a running Transmission or qBittorrent when one answers.
#    Otherwise print the magnet on its own line, wrapped as a terminal link, so a
#    click opens the torrent client.
add_to_client() {
  local torrent="$1" dir="$2" hash="$3" jar tr=(transmission-remote ${TR_HOST:-})
  [ -z "${TR_AUTH:-}" ] || tr+=(-n "$TR_AUTH")
  if command -v transmission-remote >/dev/null && "${tr[@]}" -l >/dev/null 2>&1; then
    if "${tr[@]}" -t "$hash" -i 2>/dev/null | grep -q "$hash"; then echo "Transmission (already there)"; return 0; fi
    "${tr[@]}" -w "$dir" -a "$torrent" >/dev/null && { echo Transmission; return 0; }
  fi
  # qBittorrent Web UI. No login needed when "Bypass authentication for clients on localhost" is on.
  local qbt="${QBT_URL:-http://localhost:8080}"
  jar="$(mktemp)"
  if [ -n "${QBT_USER:-}" ]; then
    curl -fsS -c "$jar" -H "Referer: $qbt" --data-urlencode "username=$QBT_USER" \
      --data-urlencode "password=${QBT_PASS:-}" "$qbt/api/v2/auth/login" >/dev/null 2>&1 || true
  fi
  # qBittorrent names a hybrid torrent by its v2 hash; the v1 one is only in infohash_v1.
  if curl -fsS -b "$jar" "$qbt/api/v2/torrents/info" 2>/dev/null | grep -q "\"$hash\""; then
    rm -f "$jar"; echo "qBittorrent (already there)"; return 0
  fi
  if curl -fsS -b "$jar" "$qbt/api/v2/app/version" >/dev/null 2>&1 \
    && curl -fsS -b "$jar" -F "torrents=@$torrent" -F "savepath=$dir" "$qbt/api/v2/torrents/add" >/dev/null 2>&1; then
    rm -f "$jar"; echo qBittorrent; return 0
  fi
  rm -f "$jar"; return 1
}

echo; echo "==> Start seeding"
if CLIENT="$(add_to_client "$OUT/$NAME.torrent" "$SAVE_DIR" "$INFOHASH")"; then
  case "$CLIENT" in
    *already*) echo "    Already in ${CLIENT% (*}." ;;
    *) echo "    Added to $CLIENT with the files in $SAVE_DIR. It checks them, then seeds." ;;
  esac
  echo "    Keep it running. BitTorrent shows your IP address to other peers."
else
  if [ -n "$YES" ] && [ -n "${PF_NEEDS_FILE:-}" ]; then printf '%s\t%s\t%s\n' "$REPO" "$OUT/$NAME.torrent" "$SAVE_DIR" >> "$PF_NEEDS_FILE"; fi
  [ -z "$YES" ] || die "no torrent client answered (Transmission, or qBittorrent Web UI at ${QBT_URL:-http://localhost:8080}). Open $OUT/$NAME.torrent, save to $SAVE_DIR, then rerun without -y."
  cat <<EOF
    Command-click the magnet (Control-click on Linux). Your torrent client opens.
    Save the files to $SAVE_DIR so it finds them and starts seeding.
    Keep it running. BitTorrent shows your IP address to other peers.
EOF
  printf '\033]8;;%s\a%s\033]8;;\a\n' "$SEED_MAGNET" "$SEED_MAGNET"
  echo "    Or open the file: $OUT/$NAME.torrent"
  ask -p "    Press Enter once it says Seeding. " _
fi

# 6. The same five statements as the web form, agreed to once.
cat <<EOF

==> Submitting $REPO ($LICENSE, $COUNT files) to $SITE. By typing yes you confirm:
    1. You have the complete files and the right to redistribute them.
    2. The repository, revision, license, checksums and evidence are accurate, and
       the files are not malware, mislabeled or tampered with. You take responsibility.
    3. Pirate Face may review, reject or remove it and respond to takedowns.
    4. BitTorrent shows your IP to peers, and the torrent has no secrets or personal info.
    5. You agree to the Terms ($SITE/terms#model-torrent-submissions) and Listing policy ($SITE/policy).
EOF
if [ -z "$YES" ]; then
  ask -p "    Type yes: " OK
  [ "${OK:-}" = yes ] || die "not submitted. Your torrent is in $OUT; rerun any time"
fi

BODY="$STAGE/body.json"
python3 - "$BODY" "$REPO" "$REVISION" "$MAGNET" "$LICENSE" "$EVIDENCE" "$OUT/checksums.txt" "$TERMS_VERSION" "$OUT/$NAME.torrent" "$STAGE/$NAME/README.md" <<'PY'
import base64, json, os, sys
out, repo, rev, magnet, lic, evidence, manifest, terms, torrent, readme_path = sys.argv[1:11]
files = [l.rstrip("\n").split("  ", 1) for l in open(manifest)]
# The .torrent lets the magnet work with no peer online. The site requires it and takes up to 3 MB.
data = open(torrent, "rb").read()
if len(data) > 3000000: sys.exit("error: the .torrent is over the 3 MB upload limit")
# README.md is the model card. Pirate Face shows it for models that aren't on Hugging Face.
readme = None
if os.path.isfile(readme_path) and os.path.getsize(readme_path) <= 200000:
    try: readme = open(readme_path, "rb").read().decode("utf-8")
    except UnicodeDecodeError: pass
json.dump({"repoId": repo, "revision": rev, "magnet": magnet, "license": lic, "evidence": evidence,
  "manifest": [{"path": p, "sha256": h} for h, p in files], "torrent": base64.b64encode(data).decode(),
  **dict.fromkeys(["redistributionConfirmed", "accuracyConfirmed", "reviewAcknowledged", "privacyAcknowledged", "termsAccepted"], True),
  "submissionTermsVersion": terms, **({"readme": readme} if readme else {})}, open(out, "w"))
PY
CODE=$(curl -sS -o "$STAGE/resp.json" -w '%{http_code}' -X POST "$SITE/api/community-torrents" \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" --data-binary "@$BODY") || die "could not reach $SITE"
RESULT=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("status") or d.get("error") or d)' "$STAGE/resp.json" 2>/dev/null || cat "$STAGE/resp.json")

echo
case "$CODE:$RESULT" in
  200:approved) echo "Listed! $SITE/$REPO" ;;
  200:pending) echo "Submitted. The repo is not on Hugging Face, so it's in the review queue; you'll see it on $SITE/$REPO once approved." ;;
  409:*)
    # Another torrent already covers this repo + revision: print how to seed it with these files.
    python3 - "$STAGE/resp.json" <<'PY' 2>/dev/null || echo "Already on Pirate Face: $SITE/$REPO"
import json, sys
d = json.load(open(sys.argv[1]))
if not d.get("agentPrompt"): print(d.get("error") or "Already submitted."); sys.exit()
print(d["error"], "\n\nExisting magnet:\n" + d["existing"]["magnet"], "\n\nSteps:")
for i, s in enumerate(d["steps"], 1): print(f"  {i}. {s}")
print("\nOr give this to your AI agent:\n\n" + d["agentPrompt"])
PY
    ;;
  401:*) rm -f "$KEY_FILE"; die "key rejected. Get a new one at $SITE/account and rerun" ;;
  429:*) echo "error: $RESULT" >&2; exit 75 ;;  # --hf-cache stops on this code
  *) die "HTTP $CODE: $RESULT" ;;
esac
echo "Keep seeding. Your files: $OUT"
}
