feat: add ImageFind application and release pipelines

This commit is contained in:
2026-08-11 18:02:40 +08:00
commit 16239d7525
270 changed files with 59163 additions and 0 deletions
+804
View File
@@ -0,0 +1,804 @@
from __future__ import annotations
import asyncio
import contextlib
import json
import sys
import types
from pathlib import Path
import httpx
from imagefind import api as api_module
from imagefind.config import Settings
from imagefind.database import DatabaseTransientError, utcnow
from imagefind.main import create_app
def make_app(tmp_path: Path):
settings = Settings(data_dir=tmp_path / "data", embedding_backend="hash", upload_reserve_gb=0)
settings.prepare()
app = create_app(settings)
app.state.services.auth.setup("original administrator password")
_, token = app.state.services.auth.create_api_token("test")
return app, {"Authorization": f"Bearer {token}"}
def seed_videos(app) -> None:
now = utcnow()
with app.state.services.db.transaction() as conn:
conn.execute(
"INSERT INTO sources(id,kind,name,config_json,created_at,updated_at) VALUES(?,?,?,?,?,?)",
("source", "local", "Local", json.dumps({"path": "/media"}), now, now),
)
for index in range(3):
conn.execute(
"INSERT INTO videos(id,source_id,source_key,display_name,location,size_bytes,fingerprint,"
"duration_ms,status,available,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,'indexed',1,?,?)",
(
f"video-{index}",
"source",
f"video-{index}.mp4",
f"Video {index}",
f"/media/video-{index}.mp4",
100 + index,
f"fingerprint-{index}",
(index + 1) * 10_000,
now,
now,
),
)
conn.execute(
"INSERT INTO video_metadata(video_id,series,updated_at) VALUES('video-0','Series A',?),"
"('video-1','series a',?)",
(now, now),
)
conn.execute(
"INSERT INTO video_state(video_id,liked,favorited,progress_ms,completed,last_played_at,updated_at) "
"VALUES('video-0',1,0,3000,0,?,?),('video-1',0,1,10000,1,?,?)",
(now, now, now, now),
)
def test_video_list_batches_tag_items_in_one_read_connection(tmp_path: Path):
app, headers = make_app(tmp_path)
seed_videos(app)
service = app.state.services
now = utcnow()
with service.db.transaction() as conn:
conn.execute(
"INSERT INTO tag_groups(id,name,selection_mode,sort_order,created_at,updated_at) "
"VALUES('batch-group','类型','multi',1,?,?)",
(now, now),
)
conn.execute(
"INSERT INTO tags(id,group_id,name,created_at,updated_at) "
"VALUES('batch-tag','batch-group','测试标签',?,?)",
(now, now),
)
conn.executemany(
"INSERT INTO video_tags(video_id,tag_id) VALUES(?,'batch-tag')",
[(f"video-{index}",) for index in range(3)],
)
original_read = service.db.read
read_calls = []
@contextlib.contextmanager
def counted_read():
read_calls.append(1)
with original_read() as connection:
yield connection
service.db.read = counted_read
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/videos?limit=500", headers=headers)
assert response.status_code == 200
assert len(response.json()) == 3
assert all(item["tag_items"][0]["name"] == "测试标签" for item in response.json())
asyncio.run(scenario())
# The freshly issued API token is served by the bounded authentication
# cache, while one WAL read batches the complete video list/tag payload.
assert len(read_calls) == 1
def test_home_feed_groups_recent_collections_categories_and_unorganized(tmp_path: Path):
app, headers = make_app(tmp_path)
seed_videos(app)
now = utcnow()
with app.state.services.db.transaction() as conn:
conn.execute(
"INSERT INTO tag_groups(id,name,selection_mode,sort_order,created_at,updated_at) "
"VALUES('home-group','类型','multi',1,?,?)",
(now, now),
)
conn.execute(
"INSERT INTO tags(id,group_id,name,created_at,updated_at) VALUES"
"('home-tag-a','home-group','剧情',?,?),('home-tag-b','home-group','纪录',?,?),"
"('home-tag-small','home-group','单片',?,?)",
(now, now, now, now, now, now),
)
conn.executemany(
"INSERT INTO video_tags(video_id,tag_id) VALUES(?,?)",
[
("video-0", "home-tag-a"),
("video-1", "home-tag-a"),
("video-0", "home-tag-b"),
("video-1", "home-tag-b"),
("video-0", "home-tag-small"),
],
)
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
assert (await client.get("/api/v1/home")).status_code == 401
collection = await client.post(
"/api/v1/collections", headers=headers, json={"name": "首页合集", "video_ids": ["video-0"]}
)
assert collection.status_code == 201
response = await client.get("/api/v1/home?item_limit=4&tag_limit=1", headers=headers)
assert response.status_code == 200
feed = response.json()
assert feed["video_count"] == 3
assert [item["id"] for item in feed["recent"]["items"]] == ["video-0", "video-1", "video-2"]
assert feed["collections"]["items"][0]["name"] == "首页合集"
assert len(feed["categories"]) == 1
assert feed["categories"][0]["title"] == "剧情"
assert feed["categories"][0]["total"] == 2
assert feed["unorganized"]["total"] == 1
assert [item["id"] for item in feed["unorganized"]["items"]] == ["video-2"]
with app.state.services.db.transaction() as conn:
conn.execute("DELETE FROM video_tags")
conn.execute("DELETE FROM video_metadata")
conn.execute("DELETE FROM collection_items")
conn.execute("DELETE FROM collection_videos")
conn.execute("DELETE FROM collections")
deduplicated = (await client.get("/api/v1/home", headers=headers)).json()
assert deduplicated["categories"] == []
assert deduplicated["collections"]["items"] == []
assert deduplicated["unorganized"] is None
asyncio.run(scenario())
def test_profile_validation_stats_filters_and_password_revocation(tmp_path: Path):
app, headers = make_app(tmp_path)
seed_videos(app)
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
profile = await client.get("/api/v1/profile", headers=headers)
assert profile.status_code == 200
assert profile.json()["nickname"] == "管理员"
assert profile.json()["avatar"]["text"] == ""
assert profile.json()["avatar"]["accent"].startswith("#")
assert profile.json()["counts"] == {
"favorites": 1,
"likes": 1,
"history": 2,
"continue_watching": 1,
}
invalid = await client.patch("/api/v1/profile", headers=headers, json={"nickname": " "})
assert invalid.status_code == 400
changed = await client.patch("/api/v1/profile", headers=headers, json={"nickname": "NAS 管理员"})
assert changed.json()["avatar"]["text"] == "NA"
assert len((await client.get("/api/v1/videos?liked=true", headers=headers)).json()) == 1
assert len((await client.get("/api/v1/videos?favorite=true", headers=headers)).json()) == 1
assert len((await client.get("/api/v1/videos?continue_only=true", headers=headers)).json()) == 1
history = (await client.get("/api/v1/videos?played_only=true&sort=last_played", headers=headers)).json()
assert len(history) == 2
first = await client.post(
"/api/v1/auth/login", json={"password": "original administrator password"}
)
assert first.status_code == 200
old_cookie = first.cookies.get("imagefind_session")
wrong = await client.patch(
"/api/v1/profile/password",
headers=headers,
json={"current_password": "wrong", "new_password": "replacement administrator password"},
)
assert wrong.status_code == 400
changed_password = await client.patch(
"/api/v1/profile/password",
headers=headers,
json={
"current_password": "original administrator password",
"new_password": "replacement administrator password",
},
)
assert changed_password.status_code == 200
assert changed_password.json()["reauthenticate"] is True
client.cookies.set("imagefind_session", old_cookie)
revoked = await client.get("/api/v1/auth/me")
assert revoked.status_code == 401
assert (
await client.post("/api/v1/auth/login", json={"password": "replacement administrator password"})
).status_code == 200
asyncio.run(scenario())
def test_bulk_video_state_actions_are_atomic_and_preserve_unrelated_state(tmp_path: Path):
app, headers = make_app(tmp_path)
seed_videos(app)
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
missing = await client.patch(
"/api/v1/videos/state/bulk",
headers=headers,
json={"video_ids": ["video-0", "missing"], "action": "unlike"},
)
assert missing.status_code == 404
assert len((await client.get("/api/v1/videos?liked=true", headers=headers)).json()) == 1
assert (
await client.patch(
"/api/v1/videos/state/bulk",
headers=headers,
json={"video_ids": ["video-0", "video-0"], "action": "unlike"},
)
).json() == {"updated": 1, "action": "unlike"}
await client.patch(
"/api/v1/videos/state/bulk",
headers=headers,
json={"video_ids": ["video-1"], "action": "unfavorite"},
)
await client.patch(
"/api/v1/videos/state/bulk",
headers=headers,
json={"video_ids": ["video-0", "video-1"], "action": "clear_history"},
)
assert (await client.get("/api/v1/videos?liked=true", headers=headers)).json() == []
assert (await client.get("/api/v1/videos?favorite=true", headers=headers)).json() == []
assert (await client.get("/api/v1/videos?played_only=true", headers=headers)).json() == []
asyncio.run(scenario())
def test_jobs_support_stable_numbered_pagination_and_legacy_limit(tmp_path: Path):
app, headers = make_app(tmp_path)
diagnostic_job = ""
for index in range(23):
diagnostic_job = app.state.services.jobs.enqueue("scan_source", {"index": index})
app.state.services.jobs.set_diagnostics(
diagnostic_job,
{
"requested_device": "GPU.0",
"actual_device": "CPU",
"fallback_scope": "job",
"fallback_reason": "low_quality_result",
"private_value": "must not be exposed",
},
)
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = (await client.get("/api/v1/jobs?page=1&page_size=10", headers=headers)).json()
last = (await client.get("/api/v1/jobs?page=3&page_size=10", headers=headers)).json()
assert (first["page"], first["pages"], first["total"], len(first["items"])) == (1, 3, 23, 10)
assert (last["page"], len(last["items"])) == (3, 3)
assert not ({item["id"] for item in first["items"]} & {item["id"] for item in last["items"]})
diagnosed = next(item for item in first["items"] if item["id"] == diagnostic_job)
assert diagnosed["inference_diagnostics"] == {
"requested_device": "GPU.0",
"actual_device": "CPU",
"fallback_scope": "job",
"fallback_reason": "low_quality_result",
}
assert "diagnostics_json" not in diagnosed
legacy = (await client.get("/api/v1/jobs?limit=4", headers=headers)).json()
assert isinstance(legacy, list) and len(legacy) == 4
asyncio.run(scenario())
def test_job_diagnostics_are_best_effort_on_transient_database_conflict(
tmp_path: Path,
monkeypatch,
caplog,
):
app, _headers = make_app(tmp_path)
job_id = app.state.services.jobs.enqueue("scan_source", {"index": 1})
def conflict(*_args, **_kwargs):
raise DatabaseTransientError("temporary conflict")
monkeypatch.setattr(app.state.services.db, "write_with_retry", conflict)
app.state.services.jobs.set_diagnostics(job_id, {"actual_device": "GPU.0"})
assert "skipped diagnostics update" in caplog.text
def test_model_status_exposes_audio_circuit_breaker_state(tmp_path: Path):
app, headers = make_app(tmp_path)
accelerator = app.state.services.accelerator
accelerator.record_transient_failure("audio", "stall one", stage="inference_stall")
accelerator.record_transient_failure("audio", "stall two", stage="inference_stall")
accelerator.record_transient_failure("audio", "stall three", stage="inference_stall")
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/models", headers=headers)
assert response.status_code == 200
audio = response.json()["accelerator"]["components"]["audio"]
assert audio["circuit_state"] == "open"
assert audio["fallback_scope"] == "component"
assert audio["failure_count"] == 3
assert audio["retry_at"]
asyncio.run(scenario())
def test_upload_list_returns_last_snapshot_when_refresh_exceeds_budget(tmp_path: Path, monkeypatch):
app, headers = make_app(tmp_path)
cached = {"id": "cached-upload", "status": "receiving", "stage": "receiving"}
app.state.services.uploads._list_cache = [cached]
monkeypatch.setattr(api_module, "API_READ_DEADLINE_SECONDS", 0.01)
async def delayed(function, /, *args, **kwargs):
await asyncio.sleep(0.05)
return function(*args, **kwargs)
monkeypatch.setattr(api_module, "_background_api", delayed)
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/uploads", headers=headers)
assert response.status_code == 200
assert response.json() == [cached]
# Let the shielded refresh finish so the test loop closes cleanly.
await asyncio.sleep(0.06)
asyncio.run(scenario())
def test_upload_page_returns_matching_snapshot_when_refresh_exceeds_budget(tmp_path: Path, monkeypatch):
app, headers = make_app(tmp_path)
cached = {
"items": [{"id": "cached-page-upload", "status": "completed", "stage": "done"}],
"status_items": [{"id": "cached-live-upload", "status": "receiving", "stage": "receiving"}],
"active_count": 1,
"failed_count": 0,
"page": 2,
"page_size": 10,
"total": 11,
"pages": 2,
}
app.state.services.uploads._page_cache[(2, 10)] = cached
monkeypatch.setattr(api_module, "API_READ_DEADLINE_SECONDS", 0.01)
async def delayed(function, /, *args, **kwargs):
await asyncio.sleep(0.05)
return function(*args, **kwargs)
monkeypatch.setattr(api_module, "_background_api", delayed)
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/uploads?page=2&page_size=10", headers=headers)
assert response.status_code == 200
assert response.json() == cached
await asyncio.sleep(0.06)
asyncio.run(scenario())
def test_failed_jobs_can_be_retried_without_overwriting_history(tmp_path: Path):
app, headers = make_app(tmp_path)
failed_id = app.state.services.jobs.enqueue(
"scan_source",
{"source_id": "source-retry"},
dedupe_key="scan:source-retry",
)
with app.state.services.db.transaction() as connection:
connection.execute(
"UPDATE jobs SET status='failed',progress=.35,error='temporary failure',finished_at=? WHERE id=?",
(utcnow(), failed_id),
)
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(f"/api/v1/jobs/{failed_id}/retry", headers=headers, json={})
assert response.status_code == 202
retried_id = response.json()["job_id"]
assert response.json()["retried_from"] == failed_id
duplicate = await client.post(f"/api/v1/jobs/{failed_id}/retry", headers=headers, json={})
assert duplicate.json()["job_id"] == retried_id
active = await client.post(f"/api/v1/jobs/{retried_id}/retry", headers=headers, json={})
assert active.status_code == 409
missing = await client.post("/api/v1/jobs/missing/retry", headers=headers, json={})
assert missing.status_code == 404
with app.state.services.db.read() as connection:
failed = connection.execute("SELECT status,error FROM jobs WHERE id=?", (failed_id,)).fetchone()
retried = connection.execute(
"SELECT kind,payload_json,dedupe_key,status,attempts FROM jobs WHERE id=?",
(retried_id,),
).fetchone()
assert dict(failed) == {"status": "failed", "error": "temporary failure"}
assert retried["kind"] == "scan_source"
assert json.loads(retried["payload_json"]) == {"source_id": "source-retry"}
assert retried["dedupe_key"] == "scan:source-retry"
assert (retried["status"], retried["attempts"]) == ("queued", 0)
asyncio.run(scenario())
def test_failed_jobs_can_be_retried_in_bulk_by_lane_without_replaying_superseded_history(
tmp_path: Path,
):
app, headers = make_app(tmp_path)
jobs = app.state.services.jobs
scan_failed = jobs.enqueue(
"scan_source", {"source_id": "source-bulk"}, dedupe_key="scan:source-bulk"
)
audio_failed = jobs.enqueue(
"transcribe_audio", {"video_id": "video-bulk"}, dedupe_key="audio:video-bulk"
)
superseded_failed = jobs.enqueue(
"index_video", {"video_id": "video-superseded"}, dedupe_key="index:video-superseded"
)
with app.state.services.db.transaction() as connection:
connection.execute(
"UPDATE jobs SET status='failed',error='temporary failure',finished_at=? "
"WHERE id IN (?,?,?)",
(utcnow(), scan_failed, audio_failed, superseded_failed),
)
superseding_job = jobs.enqueue(
"index_video", {"video_id": "video-superseded"}, dedupe_key="index:video-superseded"
)
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
scan_page = await client.get(
"/api/v1/jobs?page=1&page_size=10&lane=scan", headers=headers
)
assert scan_page.status_code == 200
assert scan_page.json()["retryable_failed_count"] == 1
retried_scan = await client.post(
"/api/v1/jobs/retry-failed?lane=scan", headers=headers, json={}
)
assert retried_scan.status_code == 202
assert retried_scan.json()["retried"] == 1
assert retried_scan.json()["lane"] == "scan"
duplicate = await client.post(
"/api/v1/jobs/retry-failed?lane=scan", headers=headers, json={}
)
assert duplicate.status_code == 202
assert duplicate.json()["retried"] == 0
all_page = await client.get("/api/v1/jobs?page=1&page_size=10", headers=headers)
assert all_page.json()["retryable_failed_count"] == 1
retried_audio = await client.post("/api/v1/jobs/retry-failed", headers=headers, json={})
assert retried_audio.status_code == 202
assert retried_audio.json()["retried"] == 1
with app.state.services.db.read() as connection:
old_statuses = {
row["id"]: row["status"]
for row in connection.execute(
"SELECT id,status FROM jobs WHERE id IN (?,?,?)",
(scan_failed, audio_failed, superseded_failed),
).fetchall()
}
active = connection.execute(
"SELECT id,kind,dedupe_key FROM jobs WHERE status='queued' ORDER BY created_at"
).fetchall()
assert old_statuses == {
scan_failed: "failed",
audio_failed: "failed",
superseded_failed: "failed",
}
assert [row["dedupe_key"] for row in active].count("index:video-superseded") == 1
assert {row["kind"] for row in active} >= {
"scan_source",
"transcribe_audio",
"index_video",
}
assert superseding_job in {row["id"] for row in active}
asyncio.run(scenario())
def test_series_aggregate_merge_bulk_and_filter(tmp_path: Path):
app, headers = make_app(tmp_path)
seed_videos(app)
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
rows = (await client.get("/api/v1/series", headers=headers)).json()
assert rows[0]["video_count"] == 2
assert rows[0]["duration_ms"] == 30_000
renamed = await client.patch(
"/api/v1/series", headers=headers, json={"name": "SERIES A", "new_name": "Merged"}
)
assert renamed.json()["updated"] == 2
assigned = await client.post(
"/api/v1/videos/series/bulk",
headers=headers,
json={"video_ids": ["video-2"], "series": "Merged"},
)
assert assigned.json()["updated"] == 1
assert len((await client.get("/api/v1/videos?series=Merged", headers=headers)).json()) == 3
removed = await client.post(
"/api/v1/videos/series/bulk",
headers=headers,
json={"video_ids": ["video-2"], "series": None},
)
assert removed.json()["series"] is None
asyncio.run(scenario())
def test_collections_support_single_membership_order_cover_and_dissolve(tmp_path: Path):
app, headers = make_app(tmp_path)
seed_videos(app)
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
first = await client.post(
"/api/v1/collections",
headers=headers,
json={"name": "第一季", "description": "按顺序播放", "video_ids": ["video-0", "video-1"]},
)
assert first.status_code == 201
first_id = first.json()["id"]
second = await client.post(
"/api/v1/collections",
headers=headers,
json={"name": "临时合集", "video_ids": ["video-2"]},
)
second_id = second.json()["id"]
moved = await client.post(
f"/api/v1/collections/{first_id}/videos",
headers=headers,
json={"video_ids": ["video-2"]},
)
assert moved.json()["updated"] == 1
assert (await client.get(f"/api/v1/collections/{second_id}", headers=headers)).json()[
"video_count"
] == 0
detail = (await client.get(f"/api/v1/collections/{first_id}", headers=headers)).json()
assert [video["id"] for video in detail["videos"]] == ["video-0", "video-1", "video-2"]
reordered = await client.patch(
f"/api/v1/collections/{first_id}/videos/order",
headers=headers,
json={"video_ids": ["video-2", "video-0", "video-1"]},
)
assert reordered.json()["updated"] == 3
invalid = await client.patch(
f"/api/v1/collections/{first_id}/videos/order",
headers=headers,
json={"video_ids": ["video-0"]},
)
assert invalid.status_code == 400
changed = await client.patch(
f"/api/v1/collections/{first_id}",
headers=headers,
json={"description": "更新后的简介", "cover_video_id": "video-1"},
)
assert changed.json()["cover_video_id"] == "video-1"
filtered = (
await client.get(
f"/api/v1/videos?collection_id={first_id}&sort=collection",
headers=headers,
)
).json()
assert [video["id"] for video in filtered] == ["video-2", "video-0", "video-1"]
assert all(video["collection_id"] == first_id for video in filtered)
dissolved = await client.delete(f"/api/v1/collections/{first_id}", headers=headers)
assert dissolved.json()["detached_videos"] == 3
assert len((await client.get("/api/v1/videos?limit=20", headers=headers)).json()) == 3
with app.state.services.db.read() as conn:
assert conn.execute("SELECT count(*) FROM collection_videos").fetchone()[0] == 0
asyncio.run(scenario())
def test_proxy_is_encrypted_redacted_and_excluded_from_model_status(tmp_path: Path):
app, headers = make_app(tmp_path)
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.patch(
"/api/v1/models/config",
headers=headers,
json={
"hf_endpoint": "https://hf-mirror.example",
"proxy_url": "http://proxy.example:8080",
"proxy_username": "proxy-user",
"proxy_password": "proxy-secret",
},
)
assert response.status_code == 200
assert response.json()["proxy"] == {
"enabled": True,
"url": "http://proxy.example:8080",
"username": "proxy-user",
"has_password": True,
}
status = (await client.get("/api/v1/models", headers=headers)).json()
assert "proxy-secret" not in json.dumps(status)
assert "password" not in status["proxy"]
assert status["accelerator"]["policy"] == "gpu_preferred"
assert status["accelerator"]["cpu_threads"] == 1
assert set(status["accelerator"]["components"]) == {"visual", "ocr", "faces", "audio"}
assert status["pip"]["index_url"] == "https://pypi.tuna.tsinghua.edu.cn/simple"
disabled = await client.patch(
"/api/v1/models/config",
headers=headers,
json={"proxy_enabled": False},
)
assert disabled.json()["proxy"]["enabled"] is False
assert disabled.json()["proxy"]["has_password"] is True
assert app.state.services.models._httpx_proxy() is None
assert app.state.services.models._hf_proxies() is None
sources = await client.patch(
"/api/v1/models/config",
headers=headers,
json={
"pip_index_url": "https://pypi.example/simple/",
"pytorch_index_url": "https://torch.example/cpu/",
},
)
assert sources.json()["pip"] == {
"index_url": "https://pypi.example/simple",
"pytorch_index_url": "https://torch.example/cpu",
}
rejected = await client.patch(
"/api/v1/models/config",
headers=headers,
json={"pip_index_url": "https://user:secret@pypi.example/simple"},
)
assert rejected.status_code == 400
asyncio.run(scenario())
stored = app.state.services.db.setting("model_proxy_config")
assert "proxy-secret" not in json.dumps(stored)
assert app.state.services.models.proxy_config(include_password=True)["password"] == "proxy-secret"
def test_huggingface_downloads_ignore_inherited_proxy_when_switch_is_off_for_non_mirror_endpoint(
tmp_path: Path, monkeypatch
):
app, _ = make_app(tmp_path)
manager = app.state.services.models
factories = []
class Session:
def __init__(self):
self.trust_env = True
self.proxies = {}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(Session=Session))
monkeypatch.setitem(
sys.modules,
"huggingface_hub",
types.SimpleNamespace(
configure_http_backend=lambda *, backend_factory: factories.append(backend_factory)
),
)
manager.settings.model_hf_endpoint = "https://huggingface.co"
manager.set_proxy_config("http://proxy.example:8080", enabled=False)
manager._configure_hf_http_backend()
direct = factories[-1]()
assert direct.trust_env is False
assert direct.proxies == {}
manager.set_proxy_config("http://proxy.example:8080", enabled=True)
manager._configure_hf_http_backend()
proxied = factories[-1]()
assert proxied.trust_env is False
assert proxied.proxies == {
"http": "http://proxy.example:8080",
"https": "http://proxy.example:8080",
}
def test_hf_mirror_downloads_stay_direct_even_when_model_proxy_is_enabled(tmp_path: Path, monkeypatch):
app, _ = make_app(tmp_path)
manager = app.state.services.models
factories = []
class Session:
def __init__(self):
self.trust_env = True
self.proxies = {}
monkeypatch.setitem(sys.modules, "requests", types.SimpleNamespace(Session=Session))
monkeypatch.setitem(
sys.modules,
"huggingface_hub",
types.SimpleNamespace(configure_http_backend=lambda *, backend_factory: factories.append(backend_factory)),
)
manager.set_proxy_config("http://proxy.example:8080", enabled=True)
manager.settings.model_hf_endpoint = "https://hf-mirror.com"
assert manager._hf_proxies() is None
manager._configure_hf_http_backend()
direct = factories[-1]()
assert direct.trust_env is False
assert direct.proxies == {}
def test_model_uninstall_preserves_indexes_and_reports_usage(tmp_path: Path):
app, headers = make_app(tmp_path)
seed_videos(app)
root = app.state.services.settings.models_dir
(root / "ocr").mkdir(parents=True)
for name in ("det.onnx", "rec.onnx", "cls.onnx"):
(root / "ocr" / name).write_bytes(b"model-contents")
(root / "manifest.json").write_text(
json.dumps({"version": "v1", "components": {"ocr": {"version": "ocr-v1"}}}), encoding="utf-8"
)
with app.state.services.db.transaction() as conn:
conn.execute(
"INSERT INTO frames(id,video_id,timestamp_ms,segment_start_ms,segment_end_ms,thumbnail_path,"
"vector_blob,created_at) VALUES('frame','video-0',0,0,1,'thumb',?,?)",
(b"existing-vector", utcnow()),
)
app.state.services.accelerator.mark_ready("ocr", "CPU")
assert app.state.services.accelerator.status()["components"]["ocr"]["state"] == "ready"
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
deleted = await client.delete("/api/v1/models/ocr", headers=headers)
assert deleted.status_code == 200
assert deleted.json()["freed_bytes"] > 0
usage = (await client.get("/api/v1/storage/usage?refresh=true", headers=headers)).json()
assert set(usage["categories"]) == {
"database",
"ai_index",
"models",
"thumbnails",
"preview_cache",
"remote_cache",
"upload_staging",
"other",
}
assert usage["categories"]["ai_index"]["mode"] == "pgvector"
asyncio.run(scenario())
assert not (root / "ocr").exists()
assert app.state.services.accelerator.status()["components"]["ocr"]["state"] == "not_loaded"
with app.state.services.db.read() as conn:
assert conn.execute("SELECT vector_blob FROM frames WHERE id='frame'").fetchone()[0] == b"existing-vector"
def test_model_uninstall_conflicts_with_install_job(tmp_path: Path):
app, headers = make_app(tmp_path)
app.state.services.jobs.enqueue("install_models", {}, dedupe_key="install-models")
async def scenario() -> None:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.delete("/api/v1/models/all", headers=headers)
assert response.status_code == 409
asyncio.run(scenario())