#!/usr/bin/env python3
"""both.pics API client and command-line tool (standard library only).

Library:
    from bothpics import Client
    c = Client("https://both.pics", token)
    url = c.upload("x264 vs x265", ["x264", "x265"], [["f1_a.png", "f1_b.png"], ["f2_a.png", "f2_b.png"]])

CLI (token from --token or BOTHPICS_TOKEN, or --guest to post without an account: public, limited per hour):
    bothpics.py upload --title "x264 vs x265" --versions x264,x265 [--unlisted] [--tmdb movie:603] [--tags x264,x265] f1_a.png f1_b.png f2_a.png f2_b.png
    bothpics.py status <collection-id>
    bothpics.py delete <collection-id>
    bothpics.py easy --title "Show S01E01" source.mkv encode.mkv [encode2.mkv:-3] --count 6
    bothpics.py shots --title "Show S01E01 screenshots" [--unlisted] [--tmdb tv:1399] [--json] a.png b.jpg c.webp

Files are given frame by frame: all versions of frame 1, then frame 2, and so on.

"easy" extracts the same frame numbers from local videos with your FFmpeg
(the first video is the source; "file:N" shifts that video by N frames when
it was trimmed differently) and uploads only the PNGs, like the website's
easy uploader, with the same seek rule and RGB conversion.

"shots" publishes a screenshot gallery (PNG, JPEG or WebP; the server removes
location and camera metadata, EXIF/XMP, pixels unchanged; one image per file,
labelled with the file name) and prints its /s/ page URL, or with
--json the page URL plus direct, thumbnail and BBCode links for every image.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from typing import Any, Sequence

__version__ = "1.0"


class ApiError(Exception):
    def __init__(self, status: int, body: Any):
        self.status = status
        self.body = body
        msg = body.get("message") or body.get("error") if isinstance(body, dict) else str(body)
        super().__init__(f"HTTP {status}: {msg}")


class Client:
    def __init__(self, base: str, token: str, timeout: float = 120.0):
        self.base = base.rstrip("/")
        self.token = token
        self.timeout = timeout

    def _request(self, method: str, path_or_url: str, body: Any = None, headers: dict | None = None, raw: bytes | None = None, auth: bool = True) -> Any:
        url = path_or_url if path_or_url.startswith("http") else self.base + path_or_url
        h = dict(headers or {})
        # An explicit user agent: Cloudflare's Browser Integrity Check rejects
        # the default "Python-urllib" one with error 1010.
        h.setdefault("User-Agent", f"bothpics-python/{__version__}")
        data = raw
        if body is not None:
            data = json.dumps(body).encode()
            h["Content-Type"] = "application/json"
        if auth:
            h["Authorization"] = f"Bearer {self.token}"
        req = urllib.request.Request(url, data=data, method=method, headers=h)
        try:
            with urllib.request.urlopen(req, timeout=self.timeout) as r:
                text = r.read()
                return json.loads(text) if text and r.headers.get_content_type() == "application/json" else None
        except urllib.error.HTTPError as e:
            text = e.read()
            try:
                parsed = json.loads(text)
            except ValueError:
                parsed = text.decode(errors="replace")
            raise ApiError(e.code, parsed) from None

    @classmethod
    def guest(cls, base: str, timeout: float = 120.0) -> "Client":
        """A client without an account (when the site allows it): a guest token for a day; uploads are public.
        Accepting means the person accepts the content policy (<base>/policy)."""
        c = cls(base, "", timeout)
        c.token = c._request("POST", "/v1/guest", {"acceptPolicy": True}, auth=False)["token"]
        return c

    def me(self) -> dict:
        return self._request("GET", "/v1/me")

    def create_screenshots(self, title: str, labels: Sequence[str], description: str = "", visibility: str = "public",
                           idempotency_key: str | None = None, tags: Sequence[str] | None = None, expires_in_days: int | None = None,
                           tmdb: str | None = None, season: int | None = None, episode: int | None = None,
                           show_on_title: bool | None = None) -> dict:
        body: dict[str, Any] = {"kind": "screenshots", "title": title, "frameLabels": list(labels), "description": description, "visibility": visibility}
        if tags:
            body["tags"] = list(tags)
        if tmdb:
            body["tmdb"] = tmdb
            if season is not None:
                body["season"] = season
            if episode is not None:
                body["episode"] = episode
        if show_on_title is not None:
            body["showOnTitle"] = show_on_title
        headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
        return self._request("POST", "/v1/collections", body, headers=headers)

    def upload_screenshots(self, title: str, files: Sequence[str], labels: Sequence[str] | None = None, **kwargs: Any) -> dict:
        """Create a screenshots collection (PNG/JPEG/WebP, one image per file), upload, finalize, wait.

        Returns the published collection (its "url" is the /s/ page)."""
        if not files:
            raise ValueError("give at least one image")
        labels = list(labels) if labels else [os.path.splitext(os.path.basename(f))[0] for f in files]
        if len(labels) != len(files):
            raise ValueError(f"{len(labels)} labels for {len(files)} files")
        types = [image_type(f) for f in files]
        created = self.create_screenshots(title, labels, **kwargs)
        col = created["collection"]
        slot_by_frame = {img["frameId"]: img["id"] for img in col["images"]}
        path_for: dict[str, str] = {}
        declared = {}
        for fi, p in enumerate(files):
            image_id = slot_by_frame[col["frames"][fi]["id"]]
            path_for[image_id] = p
            with open(p, "rb") as f:
                data = f.read()
            declared[image_id] = {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest(), "contentType": types[fi]}
        uploads = self._request("POST", f"/v1/collections/{col['id']}/uploads", {"declared": declared})["uploads"]
        for up in uploads:
            with open(path_for[up["imageId"]], "rb") as f:
                data = f.read()
            # Content-Type (the declared type) is part of the signature: send the headers as given.
            self._request("PUT", up["url"], raw=data, headers=up["headers"], auth=False)
        self.finalize(col["id"])
        result = self.wait(col["id"])
        if result["status"] != "published":
            errors = [f"  {labels[fi]}: {img['error']}" for fi, f in enumerate(result["frames"]) for img in result["images"] if img["frameId"] == f["id"] and img.get("error")]
            raise RuntimeError(f"publishing failed: {result.get('failure')}\n" + "\n".join(errors))
        return result

    def wait_thumbnails(self, collection_id: str, timeout: float = 60, interval: float = 1.0) -> dict:
        """Poll until every published image has its thumbnail (made just after publishing); returns the collection."""
        deadline = time.monotonic() + timeout
        while True:
            c = self.get(collection_id)
            if all(img.get("thumbUrl") for img in c["images"]) or time.monotonic() > deadline:
                return c
            time.sleep(interval)

    def create(self, title: str, versions: Sequence[str], frames: Sequence[str] | None = None, frame_count: int | None = None,
               description: str = "", visibility: str = "public", idempotency_key: str | None = None, expires_in_days: int | None = None,
               tags: Sequence[str] | None = None, tmdb: str | None = None, season: int | None = None, episode: int | None = None,
               show_on_title: bool | None = None, kind: str | None = None, clip_seconds: float | None = None) -> dict:
        body: dict[str, Any] = {"title": title, "versions": list(versions), "description": description, "visibility": visibility}
        if kind:
            body["kind"] = kind  # "video": one clip per version, no frames
        if clip_seconds:
            body["clipSeconds"] = clip_seconds
        if tags:
            body["tags"] = list(tags)
        if tmdb:
            body["tmdb"] = tmdb  # "movie:603" or "tv:1399"
            if season is not None:
                body["season"] = season
            if episode is not None:
                body["episode"] = episode
        if show_on_title is not None:
            body["showOnTitle"] = show_on_title
        if frames:
            body["frames"] = list(frames)
        if frame_count:
            body["frameCount"] = frame_count
        headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
        return self._request("POST", "/v1/collections", body, headers=headers)

    def put_file(self, upload: dict, path: str) -> None:
        with open(path, "rb") as f:
            data = f.read()
        headers = {k: v for k, v in upload["headers"].items() if k.lower() != "content-length"}
        self._request("PUT", upload["url"], raw=data, headers=headers, auth=False)

    def finalize(self, collection_id: str) -> dict:
        return self._request("POST", f"/v1/collections/{collection_id}/finalize", {})

    def get(self, ref: str) -> dict:
        return self._request("GET", f"/v1/collections/{ref}")

    def delete(self, collection_id: str) -> None:
        self._request("DELETE", f"/v1/collections/{collection_id}")

    def edit(self, collection_id: str, **fields: Any) -> dict:
        """Edit title, description, visibility, tags, versions or frames ([{"id", "label"}] in the new order),
        tmdb ("movie:603", "tv:1399" or None to unlink), season, episode and showOnTitle."""
        return self._request("PATCH", f"/v1/collections/{collection_id}", fields)

    def add_frames(self, collection_id: str, labels: Sequence[str]) -> dict:
        """Add frames; in a published collection they stay hidden until uploaded and finalized."""
        return self._request("POST", f"/v1/collections/{collection_id}/frames", {"frames": list(labels)})

    def publish_frames(self, collection_id: str, timeout: float = 600) -> dict:
        """Check and show frames added to a published comparison; raises if they were rejected."""
        self.finalize(collection_id)
        c = self.wait(collection_id, timeout=timeout)
        if any(f.get("state") == "pending" for f in c.get("frames", [])):
            errors = [f"  {img['id']}: {img['error']}" for img in c["images"] if img.get("error")]
            raise RuntimeError(f"new frames were not published: {c.get('failure')}\n" + "\n".join(errors))
        return c

    def delete_frame(self, collection_id: str, frame_id: str) -> dict:
        return self._request("DELETE", f"/v1/collections/{collection_id}/frames/{frame_id}")

    def wait(self, collection_id: str, timeout: float = 600, interval: float = 1.5) -> dict:
        """Poll until publishing (or checking added frames) has finished."""
        deadline = time.monotonic() + timeout
        while True:
            c = self.get(collection_id)
            checking = any(f.get("state") == "processing" for f in c.get("frames", []))
            if c["status"] in ("published", "failed", "deleted") and not checking:
                return c
            if time.monotonic() > deadline:
                raise TimeoutError(f"collection still {c['status']} after {timeout}s")
            time.sleep(interval)

    def upload(self, title: str, versions: Sequence[str], files_by_frame: Sequence[Sequence[str]], **kwargs: Any) -> str:
        """Create, upload every file (frame-major), finalize, wait. Returns the share URL."""
        frames = kwargs.pop("frames", None) or [f"Frame {i + 1}" for i in range(len(files_by_frame))]
        if any(len(row) != len(versions) for row in files_by_frame):
            raise ValueError("each frame needs exactly one file per version")
        # Declare sizes and hashes up front: sizes are signed into the upload URL, hashes are checked on finalize.
        created = self.create(title, versions, frames=frames, **kwargs)
        col = created["collection"]
        slot = {(img["frameId"], img["versionId"]): img["id"] for img in col["images"]}
        path_for: dict[str, str] = {}
        for fi, row in enumerate(files_by_frame):
            for vi, p in enumerate(row):
                path_for[slot[(col["frames"][fi]["id"], col["versions"][vi]["id"])]] = p
        declared = {}
        for image_id, p in path_for.items():
            with open(p, "rb") as f:
                data = f.read()
            declared[image_id] = {"bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()}
        uploads = self._request("POST", f"/v1/collections/{col['id']}/uploads", {"declared": declared})["uploads"]
        for up in uploads:
            with open(path_for[up["imageId"]], "rb") as f:
                data = f.read()
            self._request("PUT", up["url"], raw=data, headers=up["headers"], auth=False)
        self.finalize(col["id"])
        result = self.wait(col["id"])
        if result["status"] != "published":
            errors = [f"  {img['id']}: {img['error']}" for img in result["images"] if img.get("error")]
            raise RuntimeError(f"publishing failed: {result.get('failure')}\n" + "\n".join(errors))
        return result["url"]

    def upload_video(self, title: str, versions: Sequence[str], clips: Sequence[str], starts: Sequence[float | None] | None = None,
                     clip_seconds: float | None = None, **kwargs: Any) -> dict:
        """Video comparison: one clip per version (source first; MP4, MKV, WebM or MOV with H.264, HEVC, AV1 or VP9).

        starts: per clip, where the compared part begins, in seconds of the clip's own timeline (None: its first
        frame). clip_seconds: the compared length (default: the shortest clip, at most 15 s). Returns the
        published collection (its "url" is the page). Uploads go straight to storage (presigned URLs)."""
        if len(clips) != len(versions):
            raise ValueError(f"{len(clips)} clips for {len(versions)} versions")
        starts = list(starts) if starts is not None else [None] * len(clips)
        created = self.create(title, versions, kind="video", clip_seconds=clip_seconds, **kwargs)
        col = created["collection"]
        slot = {img["versionId"]: img["id"] for img in col["images"]}
        path_for: dict[str, str] = {}
        declared = {}
        for vi, p in enumerate(clips):
            image_id = slot[col["versions"][vi]["id"]]
            path_for[image_id] = p
            sha = hashlib.sha256()
            with open(p, "rb") as f:
                for chunk in iter(lambda: f.read(1 << 20), b""):
                    sha.update(chunk)
            declared[image_id] = {"bytes": os.path.getsize(p), "sha256": sha.hexdigest(), "contentType": clip_type(p)}
            if starts[vi] is not None:
                declared[image_id]["startSeconds"] = starts[vi]
        uploads = self._request("POST", f"/v1/collections/{col['id']}/uploads", {"declared": declared})["uploads"]
        for up in uploads:
            with open(path_for[up["imageId"]], "rb") as f:
                data = f.read()
            self._request("PUT", up["url"], raw=data, headers=up["headers"], auth=False)
        self.finalize(col["id"])
        result = self.wait(col["id"])
        if result["status"] != "published":
            errors = [f"  {v['label']}: {img['error']}" for v in result["versions"] for img in result["images"] if img["versionId"] == v["id"] and img.get("error")]
            raise RuntimeError(f"publishing failed: {result.get('failure')}\n" + "\n".join(errors))
        return result


# ---------------------------------------------------------------- video comparisons
CLIP_TYPES = {".mkv": "video/x-matroska", ".webm": "video/webm", ".mov": "video/quicktime", ".mp4": "video/mp4", ".m4v": "video/mp4"}


def clip_type(path: str) -> str:
    return CLIP_TYPES.get(os.path.splitext(path)[1].lower(), "video/mp4")


def cut_clip(video: str, info: dict, n: int, seconds: float, out: str, audio: bool = False) -> float:
    """Copy `seconds` of a video from its frame n (0-based) without re-encoding, as the website's Easy
    uploader does: from the keyframe at or before it, keeping the original timestamps (-copyts), so
    the server finds frame n in the clip by the start returned (its time in the file's own timeline)."""
    src = os.path.abspath(video)  # never read as an option or a protocol
    j = json.loads(subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=start_time:format=start_time",
                                   "-of", "json", "-i", src], check=True, capture_output=True, text=True).stdout)
    vstart = _num((j.get("streams") or [{}])[0].get("start_time")) or 0.0
    fstart = _num((j.get("format") or {}).get("start_time")) or 0.0
    step = info["fps_den"] / info["fps_num"]
    start = vstart + n * step
    rows = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-read_intervals", f"{max(0.0, start - 15):.6f}%{start + 0.5:.6f}",
                           "-show_entries", "packet=pts_time,flags", "-of", "csv=p=0", "-i", src], check=True, capture_output=True, text=True).stdout
    keys = [float(t) for t, _, flags in (line.partition(",") for line in rows.splitlines()) if "K" in flags and _num(t) is not None and start - 15 <= float(t) <= start + step / 2]
    if not keys:
        raise RuntimeError(f"{video}: no keyframe in the 15 s before frame {n} (the server takes at most 15 s of lead-in)")
    key = max(keys)
    # Just before the keyframe: packets before -ss are dropped, so the keyframe is the first one kept.
    r = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", "-y",
                        "-ss", f"{max(0.0, key - fstart - 0.0005):.6f}", "-t", f"{start - key + seconds + min(4 * step, 0.4):.6f}", "-i", src,
                        "-map", "0:v:0", *(["-map", "0:a:0?"] if audio else []), "-c", "copy", "-copyts", "-sn", "-dn", "-map_metadata", "-1",
                        "-f", "matroska", out], capture_output=True, text=True, errors="replace")
    if r.returncode != 0 or not os.path.exists(out) or not os.path.getsize(out):
        raise RuntimeError(f"{video}: the copy failed: {r.stderr.strip()[-300:]}")
    return start


def parse_start(v: str, info: dict) -> int:
    """ "1234" (frame) or "51.4" / "0:51.4" / "1:02:03.5" (seconds) -> frame number at the video's rate."""
    v = v.strip()
    if v.isdigit():
        return int(v)
    parts = v.split(":")
    try:
        secs = sum(float(x) * 60 ** i for i, x in enumerate(reversed(parts)))
    except ValueError:
        raise ValueError(f"--start {v}: a frame number (1234) or a time (0:51.4)") from None
    return round(secs * info["fps_num"] / info["fps_den"])


# ---------------------------------------------------------------- screenshots
MIME_BY_MAGIC = (
    (b"\x89PNG\r\n\x1a\n", "image/png"),
    (b"\xff\xd8\xff", "image/jpeg"),
)


def image_type(path: str) -> str:
    """image/png, image/jpeg or image/webp from the file's first bytes (the server checks the same way)."""
    with open(path, "rb") as f:
        head = f.read(12)
    for magic, mime in MIME_BY_MAGIC:
        if head.startswith(magic):
            return mime
    if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
        return "image/webp"
    raise ValueError(f"{path}: not a PNG, JPEG or WebP image")


def screenshot_links(collection: dict, labels: Sequence[str] | None = None) -> dict:
    """Page URL plus, per image in upload order: its own page, direct (versioned original), thumbnail and
    BBCode (a 240 px thumbnail linking to the image's page, like image hosts)."""
    by_frame = {img["frameId"]: img for img in collection["images"]}
    images = []
    for fi, frame in enumerate(collection["frames"]):
        img = by_frame.get(frame["id"])
        if not img or not img.get("url"):
            continue
        direct = f"{img['url']}?v={img['sha256'][:8]}" if img.get("sha256") else img["url"]
        thumb = img.get("thumbUrl") or direct
        small = img.get("thumb240Url") or thumb
        page = f"{collection['url']}/{len(images) + 1}"
        images.append({"id": img["id"], "label": frame["label"], "mime": img.get("mime"), "width": img.get("width"), "height": img.get("height"),
                       "bytes": img.get("bytes"), "page": page, "direct": direct, "thumb": thumb, "bbcode": f"[url={page}][img]{small}[/img][/url]"})
    return {"url": collection["url"], "key": collection["key"], "images": images, "bbcode": "\n".join(i["bbcode"] for i in images)}


# ---------------------------------------------------------------- local frame extraction
# Seconds decoded before a frame when a seek lands past it (MPEG-TS/M2TS seek to
# any packet, not a keyframe, and the decoder then skips to the next keyframe).
SEEK_MARGINS = (0, 3, 12, 60)
MIN_SS = 0.000001


def _num(v: Any) -> float | None:
    try:
        x = float(v)
    except (TypeError, ValueError):
        return None
    return x if x == x and x not in (float("inf"), float("-inf")) else None


def _rate(v: str | None) -> tuple[int, int] | None:
    try:
        num, den = (int(x) for x in (v or "").split("/"))
    except ValueError:
        return None
    return (num, den) if num > 0 and den > 0 else None


def _hms(v: Any) -> float | None:
    """ "00:00:04.004000000" (Matroska DURATION tag) -> seconds."""
    try:
        h, m, sec = str(v).split(":")
        return int(h) * 3600 + int(m) * 60 + float(sec)
    except ValueError:
        return None


def probe_video(path: str, count: bool = False) -> dict:
    """Frame rate (as a fraction), frame count, start offset and colour tags of the first video stream (ffprobe).

    The frame count comes from the container (nb_frames) or duration x frame rate; only with
    count=True, and when both are missing, are the packets counted (a full read of the file)."""
    out = subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "v:0",
         "-show_entries", "stream=r_frame_rate,avg_frame_rate,nb_frames,duration,start_time,height,color_space,color_range:"
                          "stream_tags=DURATION,NUMBER_OF_FRAMES,NUMBER_OF_FRAMES-eng:format=start_time,duration",
         "-of", "json", "-i", path],
        check=True, capture_output=True, text=True).stdout
    j = json.loads(out)
    if not j.get("streams"):
        raise RuntimeError(f"{path}: no video stream")
    st = j["streams"][0]
    fmt = j.get("format") or {}
    avg, real = _rate(st.get("avg_frame_rate")), _rate(st.get("r_frame_rate"))
    rate = avg or real
    if not rate:
        raise RuntimeError(f"{path}: frame rate unknown")
    vfr = bool(avg and real and abs(avg[0] / avg[1] - real[0] / real[1]) > 1e-3 * real[0] / real[1])
    if vfr:
        print(f"warning: {path} has a variable frame rate: frame numbers are converted to times at its average rate, "
              "so frames may not match exactly", file=sys.stderr)
    vstart, fstart = _num(st.get("start_time")), _num(fmt.get("start_time"))
    start = max(0.0, vstart - fstart) if vstart is not None and fstart is not None else 0.0
    frames, exact = int(st.get("nb_frames") or 0), True
    if not frames:
        tags = st.get("tags") or {}
        counted = tags.get("NUMBER_OF_FRAMES") or tags.get("NUMBER_OF_FRAMES-eng")  # mkvmerge statistics
        end = _hms(tags.get("DURATION"))  # FFmpeg's Matroska tag: where the track ends
        duration = _num(st.get("duration"))
        if counted and str(counted).isdigit():
            frames = int(counted)
        elif duration is None and end is not None:
            duration = end - (vstart or 0.0)
        elif duration is None and _num(fmt.get("duration")) is not None:
            duration = _num(fmt["duration"]) - start
        if not frames and duration and duration > 0:
            frames, exact = round(duration * rate[0] / rate[1]), False
    if not frames and count:
        out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets",
                              "-show_entries", "stream=nb_read_packets", "-of", "json", "-i", path],
                             check=True, capture_output=True, text=True).stdout
        frames = int((json.loads(out).get("streams") or [{}])[0].get("nb_read_packets") or 0)
    return {"fps_num": rate[0], "fps_den": rate[1], "frames": frames, "frames_exact": exact and frames > 0,
            "start": start, "vfr": vfr, "height": int(st.get("height") or 0),
            "matrix": st.get("color_space") or "", "range": st.get("color_range") or ""}


def rgb_filter(info: dict) -> str:
    """The same explicit YUV→RGB conversion as the website (web/app/easy/frames.js)."""
    m = info.get("matrix", "")
    matrix = "bt2020" if "2020" in m else "bt709" if "709" in m else "bt601" if any(x in m for x in ("601", "470", "170m", "smpte170")) else ("bt709" if info.get("height", 0) >= 720 else "bt601")
    rng = "pc" if info.get("range") in ("pc", "full", "jpeg") else "tv"
    return f"scale=in_color_matrix={matrix}:in_range={rng}:out_range=pc:flags=accurate_rnd+full_chroma_int+full_chroma_inp+bitexact,format=rgb24"


def spread_frames(total: int, count: int) -> list[int]:
    """Frame numbers spread over the runtime, skipping the first and last 5 %."""
    if total < 1 or count < 1:
        return []
    lo = int(total * 0.05)
    hi = max(lo + 1, int(total * 0.95))
    span = hi - lo
    return sorted({min(total - 1, lo + int((i + 0.5) * span / count)) for i in range(count)})


def _first_shown(log: str) -> float | None:
    """Timestamp (s) of the first frame showinfo reported."""
    tb = None
    for line in log.splitlines():
        if "Parsed_showinfo" not in line:
            continue
        if tb is None and "config in time_base:" in line:
            a, _, b = line.split("config in time_base:")[1].split(",")[0].strip().partition("/")
            tb = int(a) / int(b) if a.strip().isdigit() and b.strip().isdigit() and int(b) else None
        parts = line.split()
        if "n:" in parts and parts[parts.index("n:") + 1] == "0" and "pts:" in parts:
            pts = parts[parts.index("pts:") + 1]
            if tb and pts.lstrip("-").isdigit():
                return int(pts) * tb
            t = next((p.split(":", 1)[1] for p in parts if p.startswith("pts_time:")), None)
            return _num(t)
    return None


def extract_frame(video: str, info: dict, n: int, out: str) -> None:
    """Frame n (0-based, presentation order): seek halfway before it, keep the first frame at or after.

    -ss counts from the container start (the earliest stream), so the video stream's own start is
    added. The first frame kept is checked to be within a frame of the seek point; when the seek
    landed past it, decoding starts earlier and trims to the exact timestamp (same rule as the website)."""
    if n < 0 or (info.get("frames_exact") and n >= info["frames"]):
        raise ValueError(f"{video}: frame {n} is outside this video (0-{info['frames'] - 1})")
    step = info["fps_den"] / info["fps_num"]
    at = max(0.0, info.get("start", 0.0) + (n - 0.5) * step)
    src = os.path.abspath(video)  # never read as an option or a protocol ("-x.mkv", "a:b.mkv")
    for margin in SEEK_MARGINS:
        # Never "-ss 0" unless the frame is the first: for MPEG-TS, FFmpeg then counts from
        # the video stream instead of the container start.
        start = min(at, max(MIN_SS, at - margin))
        skip = at - start
        trim = f"trim=start={skip:.6f}," if skip > 0 else ""
        if os.path.exists(out):
            os.remove(out)
        r = subprocess.run(["ffmpeg", "-hide_banner", "-nostdin", "-nostats", "-loglevel", "info", "-y", "-ss", f"{start:.6f}", "-i", src,
                            "-map", "0:v:0", "-an", "-sn", "-dn", "-frames:v", "1", "-vf", f"{trim}showinfo,{rgb_filter(info)}",
                            "-c:v", "png", "-f", "image2", out], capture_output=True, text=True, errors="replace")
        if r.returncode != 0:
            errors = [x for x in r.stderr.splitlines() if "Parsed_showinfo" not in x][-3:]
            raise RuntimeError(f"{video}: ffmpeg failed on frame {n}: " + " ".join(errors))
        got = os.path.exists(out) and os.path.getsize(out) > 0
        t = _first_shown(r.stderr)
        if (got and (info.get("vfr") or t is None or t - skip < step)) or start <= MIN_SS:
            if not got:
                raise RuntimeError(f"{video}: no frame {n}")
            return
    raise RuntimeError(f"{video}: could not seek to frame {n} exactly (keyframes more than {SEEK_MARGINS[-1]} s apart?)")


def extract_frames(videos: Sequence[tuple[str, int]], frames: Sequence[int], out_dir: str, infos: Sequence[dict] | None = None) -> list[list[str]]:
    """PNG paths by frame, then version, for (video, offset) pairs."""
    infos = list(infos) if infos is not None else [probe_video(v) for v, _ in videos]
    rows = []
    for n in frames:
        row = []
        for vi, ((video, offset), info) in enumerate(zip(videos, infos)):
            out = os.path.join(out_dir, f"v{vi}-{n}.png")
            extract_frame(video, info, n + offset, out)
            row.append(out)
        rows.append(row)
    return rows


def main(argv: Sequence[str] | None = None) -> int:
    ap = argparse.ArgumentParser(prog="bothpics", description="both.pics command-line client")
    ap.add_argument("--base", default=os.environ.get("BOTHPICS_URL", "https://both.pics"))
    ap.add_argument("--token", default=os.environ.get("BOTHPICS_TOKEN"))
    ap.add_argument("--guest", action="store_true", help="post without an account (you accept the content policy; uploads are public)")
    sub = ap.add_subparsers(dest="cmd", required=True)
    up = sub.add_parser("upload", help="create and publish a comparison")
    up.add_argument("--title", required=True)
    up.add_argument("--versions", required=True, help="comma-separated version labels (columns)")
    up.add_argument("--frames", help="comma-separated frame labels (rows)")
    up.add_argument("--description", default="")
    up.add_argument("--tmdb", help='link a title, e.g. "movie:603" or "tv:1399"')
    up.add_argument("--season", type=int)
    up.add_argument("--episode", type=int)
    up.add_argument("--tags", help="comma-separated tags")
    up.add_argument("files", nargs="+", help="PNG files, frame by frame")
    st = sub.add_parser("status")
    st.add_argument("id")
    de = sub.add_parser("delete")
    de.add_argument("id")
    ez = sub.add_parser("easy", help="extract the same frames from local videos (FFmpeg) and publish them")
    ez.add_argument("--title", required=True)
    ez.add_argument("--versions", help="comma-separated labels (default: file names)")
    ez.add_argument("--count", type=int, default=6, help="frames spread over the runtime (default 6)")
    ez.add_argument("--frame", type=int, action="append", help="explicit source frame number (repeatable)")
    ez.add_argument("--description", default="")
    ez.add_argument("--tmdb", help='link a title, e.g. "movie:603" or "tv:1399"')
    ez.add_argument("videos", nargs="+", help="source first, then encodes; append :N to shift a video by N frames")
    vi = sub.add_parser("video", help="video comparison: copy the same clip (at most 15 s) out of local videos with FFmpeg, without re-encoding, and publish it")
    vi.add_argument("--title", required=True)
    vi.add_argument("--versions", help="comma-separated labels (default: file names)")
    vi.add_argument("--start", default="0", help="where the clip starts in the source: frame number (1234) or time (0:51.4)")
    vi.add_argument("--seconds", type=float, default=10.0, help="clip length, at most 15 (default 10)")
    vi.add_argument("--clips", action="store_true", help="the files are clips you cut already: upload them as they are")
    vi.add_argument("--description", default="")
    vi.add_argument("--tmdb", help='link a title, e.g. "movie:603" or "tv:1399"')
    vi.add_argument("videos", nargs="+", help="source first, then encodes; append :N to shift a video by N frames")
    sh = sub.add_parser("shots", help="publish a screenshot gallery (PNG, JPEG or WebP; EXIF/XMP metadata removed, pixels unchanged)")
    sh.add_argument("--title", required=True)
    sh.add_argument("--description", default="")
    sh.add_argument("--tmdb", help='link a title, e.g. "movie:603" or "tv:1399"')
    sh.add_argument("--season", type=int)
    sh.add_argument("--episode", type=int)
    sh.add_argument("--tags", help="comma-separated tags")
    sh.add_argument("--json", action="store_true", help="print the page URL and per-image direct, thumbnail and BBCode links as JSON")
    sh.add_argument("files", nargs="+", help="images, in gallery order (labels are the file names)")
    for uploader in (up, ez, vi, sh):
        visibility = uploader.add_mutually_exclusive_group()
        visibility.add_argument("--public", dest="visibility", action="store_const", const="public", help="show in Recent uploads (default)")
        visibility.add_argument("--unlisted", dest="visibility", action="store_const", const="unlisted", help="hide from public listings; anyone with the link can still view")
        uploader.set_defaults(visibility="public")
    args = ap.parse_args(argv)
    if not args.token and not args.guest:
        ap.error("set --token or BOTHPICS_TOKEN (or --guest to post without an account)")
    if getattr(args, "tmdb", None) is None and (getattr(args, "season", None) is not None or getattr(args, "episode", None) is not None):
        ap.error("--season and --episode need --tmdb tv:<id>")
    try:
        c = Client(args.base, args.token) if args.token else Client.guest(args.base)
        if args.cmd == "upload":
            versions = [v.strip() for v in args.versions.split(",") if v.strip()]
            if len(args.files) % len(versions):
                ap.error(f"{len(args.files)} files is not a multiple of {len(versions)} versions")
            rows = [args.files[i:i + len(versions)] for i in range(0, len(args.files), len(versions))]
            frames = [f.strip() for f in args.frames.split(",")] if args.frames else None
            opts: dict[str, Any] = {}
            if args.tmdb:
                opts.update(tmdb=args.tmdb, season=args.season, episode=args.episode)
            if args.tags:
                opts["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()]
            url = c.upload(args.title, versions, rows, frames=frames, description=args.description,
                           visibility=args.visibility, **opts)
            print(url)
        elif args.cmd == "easy":
            if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
                ap.error("easy needs ffmpeg and ffprobe on PATH")
            videos: list[tuple[str, int]] = []
            for v in args.videos:
                path, _, off = v.rpartition(":") if ":" in v and v.rsplit(":", 1)[1].lstrip("+-").isdigit() else (v, "", "0")
                videos.append((path, int(off)))
            if len(videos) < 2:
                ap.error("give the source and at least one encode")
            labels = [x.strip() for x in args.versions.split(",")] if args.versions else [os.path.splitext(os.path.basename(v))[0][:60] for v, _ in videos]
            if len(labels) != len(videos):
                ap.error(f"{len(labels)} labels for {len(videos)} videos")
            # One probe per file; only the source's frames are counted when its length is unknown.
            infos = [probe_video(v, count=(i == 0 and not args.frame)) for i, (v, _) in enumerate(videos)]
            if not args.frame and not infos[0]["frames"]:
                raise RuntimeError(f"{videos[0][0]}: frame count unknown; choose frames with --frame")
            frames = sorted(set(args.frame)) if args.frame else spread_frames(infos[0]["frames"], args.count)
            with tempfile.TemporaryDirectory(prefix="bothpics-") as tmp:
                rows = extract_frames(videos, frames, tmp, infos)
                extra = {"tmdb": args.tmdb} if args.tmdb else {}
                url = c.upload(args.title, labels, rows, frames=[str(n) for n in frames], description=args.description,
                               visibility=args.visibility, **extra)
            print(url)
        elif args.cmd == "video":
            videos = []
            for v in args.videos:
                path, _, off = v.rpartition(":") if ":" in v and v.rsplit(":", 1)[1].lstrip("+-").isdigit() else (v, "", "0")
                videos.append((path, int(off)))
            if len(videos) < 2:
                ap.error("give the source and at least one encode")
            labels = [x.strip() for x in args.versions.split(",")] if args.versions else [os.path.splitext(os.path.basename(v))[0][:60] for v, _ in videos]
            if len(labels) != len(videos):
                ap.error(f"{len(labels)} labels for {len(videos)} videos")
            extra = {"tmdb": args.tmdb} if args.tmdb else {}
            vis = args.visibility
            if args.clips:
                result = c.upload_video(args.title, labels, [v for v, _ in videos], description=args.description, visibility=vis, **extra)
            else:
                if not shutil.which("ffmpeg") or not shutil.which("ffprobe"):
                    ap.error("video needs ffmpeg and ffprobe on PATH (or --clips for clips you cut)")
                if not 0.5 <= args.seconds <= 15:
                    ap.error("--seconds is 0.5 to 15")
                infos = [probe_video(v) for v, _ in videos]
                n = parse_start(args.start, infos[0])
                with tempfile.TemporaryDirectory(prefix="bothpics-") as tmp:
                    clips, starts = [], []
                    for i, ((v, off), info) in enumerate(zip(videos, infos)):
                        out = os.path.join(tmp, f"v{i}.mkv")
                        starts.append(cut_clip(v, info, n + off, args.seconds, out, audio=(i == 0)))
                        clips.append(out)
                    result = c.upload_video(args.title, labels, clips, starts, clip_seconds=args.seconds, description=args.description, visibility=vis, **extra)
            print(result["url"])
        elif args.cmd == "shots":
            extra: dict[str, Any] = {}
            if args.tmdb:
                extra.update(tmdb=args.tmdb, season=args.season, episode=args.episode)
            if args.tags:
                extra["tags"] = [t.strip() for t in args.tags.split(",") if t.strip()]
            result = c.upload_screenshots(args.title, args.files, description=args.description,
                                          visibility=args.visibility, **extra)
            if args.json:
                result = c.wait_thumbnails(result["id"])
                print(json.dumps(screenshot_links(result), indent=2))
            else:
                print(result["url"])
        elif args.cmd == "status":
            print(json.dumps(c.get(args.id), indent=2))
        elif args.cmd == "delete":
            c.delete(args.id)
            print("deleted")
    except (ApiError, RuntimeError, TimeoutError, ValueError, OSError, subprocess.CalledProcessError) as e:
        print(f"error: {e}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
