feat: add ImageFind application and release pipelines
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user