191 lines
7.9 KiB
Python
191 lines
7.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Exercise mutable live APIs using only run-owned ImageFind records."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
|
|
def call(client: httpx.Client, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
response = client.request(method, path, **kwargs)
|
|
if response.is_error:
|
|
raise RuntimeError(f"{method} {path} -> {response.status_code}: {response.text[:1000]}")
|
|
return response
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--base-url", required=True)
|
|
auth = parser.add_mutually_exclusive_group(required=True)
|
|
auth.add_argument("--token-file", type=Path)
|
|
auth.add_argument("--password")
|
|
parser.add_argument("--run-dir", type=Path, required=True)
|
|
parser.add_argument("--case-id", default="")
|
|
args = parser.parse_args()
|
|
state = json.loads((args.run_dir / "state.json").read_text())
|
|
headers = {}
|
|
if args.token_file is not None:
|
|
headers["Authorization"] = f"Bearer {args.token_file.read_text().strip()}"
|
|
client = httpx.Client(
|
|
base_url=args.base_url.rstrip("/"),
|
|
headers=headers,
|
|
timeout=httpx.Timeout(60, connect=10),
|
|
)
|
|
if args.password is not None:
|
|
login = call(
|
|
client,
|
|
"POST",
|
|
"/api/v1/auth/login",
|
|
json={"password": args.password, "remember_device": False},
|
|
).json()
|
|
csrf = str(login.get("csrf_token") or "")
|
|
if not csrf:
|
|
raise AssertionError("login response omitted CSRF token")
|
|
client.headers["X-CSRF-Token"] = csrf
|
|
|
|
case_prefix = f"-{args.case_id.strip()}" if args.case_id.strip() else ""
|
|
expected_names = {
|
|
f"{state['run_id']}{case_prefix}-e2e-positive.mp4",
|
|
f"{state['run_id']}{case_prefix}-e2e-negative.mp4",
|
|
f"{state['run_id']}{case_prefix}-e2e-fallback.mpg",
|
|
}
|
|
videos = call(client, "GET", "/api/v1/videos?limit=500").json()
|
|
owned = {
|
|
item["source_key"].rsplit("/", 1)[-1]: item
|
|
for item in videos
|
|
if item["source_id"] == state["source_id"]
|
|
and item["source_key"].rsplit("/", 1)[-1] in expected_names
|
|
}
|
|
if set(owned) != expected_names:
|
|
raise AssertionError(f"unexpected run-owned videos: {sorted(owned)}")
|
|
positive = owned[f"{state['run_id']}{case_prefix}-e2e-positive.mp4"]
|
|
video_id = positive["id"]
|
|
|
|
metadata = {
|
|
"title": "星河验收影片 729",
|
|
"catalog_code": "IF-E2E-729",
|
|
"studio": "ImageFind 验收工作室",
|
|
"series": f"{state['run_id']}-合集",
|
|
"release_date": "2026-08-02",
|
|
"description": "用于验证标题、番号、片商、合集、日期、简介、演员和分类同步。",
|
|
"actors": ["测试演员 729"],
|
|
"tags": [],
|
|
"tag_ids": [state["tag_id"]],
|
|
}
|
|
call(client, "PATCH", f"/api/v1/videos/{video_id}/metadata", json=metadata)
|
|
call(
|
|
client,
|
|
"PATCH",
|
|
f"/api/v1/videos/{video_id}/state",
|
|
json={"liked": True, "favorited": True, "progress_ms": 4000, "completed": False},
|
|
)
|
|
|
|
refreshed = call(client, "GET", "/api/v1/videos?limit=500").json()
|
|
positive = next(item for item in refreshed if item["id"] == video_id)
|
|
for key in ("title", "catalog_code", "studio", "release_date", "description"):
|
|
if positive[key] != metadata[key]:
|
|
raise AssertionError(f"metadata mismatch {key}: {positive[key]!r}")
|
|
if positive["actors"] != ["测试演员 729"]:
|
|
raise AssertionError(f"actors mismatch: {positive['actors']}")
|
|
if positive["tag_items"][0]["id"] != state["tag_id"]:
|
|
raise AssertionError("tag assignment mismatch")
|
|
if not positive["liked"] or not positive["favorited"] or positive["progress_ms"] != 4000:
|
|
raise AssertionError("video state mismatch")
|
|
|
|
# Upload completion deliberately precedes the independent AI lane. The
|
|
# search result contract is frame-based, so wait for basic parsing instead
|
|
# of racing the freshly queued index job.
|
|
deadline = time.monotonic() + 300
|
|
while positive.get("index_state", {}).get("basic") == "pending":
|
|
if time.monotonic() >= deadline:
|
|
raise TimeoutError("basic video indexing did not reach a terminal state")
|
|
time.sleep(2)
|
|
refreshed = call(client, "GET", "/api/v1/videos?limit=500").json()
|
|
positive = next(item for item in refreshed if item["id"] == video_id)
|
|
|
|
filters = {}
|
|
for name, query in {
|
|
"favorite": "favorite=true",
|
|
"liked": "liked=true",
|
|
"played": "played_only=true&sort=last_played",
|
|
}.items():
|
|
result = call(client, "GET", f"/api/v1/videos?{query}&limit=500").json()
|
|
filters[name] = [item["id"] for item in result]
|
|
if video_id not in filters[name]:
|
|
raise AssertionError(f"{name} filter omitted the updated video")
|
|
|
|
profile = call(client, "GET", "/api/v1/profile").json()
|
|
expected_profile_counts = {"favorites": 1, "likes": 1}
|
|
for key, minimum in expected_profile_counts.items():
|
|
if int(profile.get("counts", {}).get(key, 0)) < minimum:
|
|
raise AssertionError(f"profile count did not include {key}")
|
|
collection = call(client, "GET", f"/api/v1/collections/{state['collection_id']}").json()
|
|
if video_id not in [item["id"] for item in collection["videos"]]:
|
|
raise AssertionError("collection detail omitted the updated video")
|
|
|
|
search = call(
|
|
client,
|
|
"POST",
|
|
"/api/v1/search",
|
|
json={"text": "IF-E2E-729", "recognition_types": ["metadata"]},
|
|
).json()
|
|
search_ids = [item["video_id"] for item in search["items"]]
|
|
search_deferred = positive.get("index_state", {}).get("basic") == "failed"
|
|
if video_id not in search_ids and not search_deferred:
|
|
raise AssertionError("metadata search did not return the matching video")
|
|
negative_search = call(
|
|
client,
|
|
"POST",
|
|
"/api/v1/search",
|
|
json={"text": "绝不应命中的验收词 9834721", "recognition_types": ["metadata"]},
|
|
).json()
|
|
if negative_search["items"]:
|
|
raise AssertionError("non-matching metadata search unexpectedly returned a video")
|
|
|
|
stream = call(
|
|
client,
|
|
"GET",
|
|
f"/api/v1/videos/{video_id}/stream",
|
|
headers={"Range": "bytes=0-1023"},
|
|
)
|
|
if stream.status_code != 206 or len(stream.content) != 1024:
|
|
raise AssertionError(f"range stream mismatch: {stream.status_code}, {len(stream.content)}")
|
|
with client.stream("GET", f"/api/v1/videos/{video_id}/download") as download:
|
|
if download.status_code != 200:
|
|
raise AssertionError(f"download status {download.status_code}")
|
|
disposition = download.headers.get("content-disposition", "")
|
|
if "attachment" not in disposition.lower():
|
|
raise AssertionError(f"download disposition mismatch: {disposition}")
|
|
first = next(download.iter_bytes(), b"")
|
|
if not first:
|
|
raise AssertionError("download returned an empty body")
|
|
|
|
report = {
|
|
"video_id": video_id,
|
|
"metadata": {key: positive[key] for key in metadata if key not in {"actors", "tags", "tag_ids"}},
|
|
"actors": positive["actors"],
|
|
"filters": filters,
|
|
"profile_counts": profile.get("counts", {}),
|
|
"collection_video_count": collection["video_count"],
|
|
"metadata_search_matches": len(search["items"]),
|
|
"metadata_search_deferred": search_deferred,
|
|
"range_stream": {"status": stream.status_code, "bytes": len(stream.content)},
|
|
"download": {"status": 200, "content_disposition": disposition},
|
|
}
|
|
(args.run_dir / "api-report.json").write_text(
|
|
json.dumps(report, ensure_ascii=False, indent=2) + "\n"
|
|
)
|
|
state["positive_video_id"] = video_id
|
|
(args.run_dir / "state.json").write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n")
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|