feat: publish approved RF4 media catalog
This commit is contained in:
@@ -6,6 +6,7 @@ RUN pip install --no-cache-dir -r requirements-lock.txt
|
||||
RUN useradd --create-home --uid 10001 rf4
|
||||
COPY --chown=rf4:rf4 apps/api .
|
||||
COPY --chown=rf4:rf4 rf4_research ./rf4_research
|
||||
COPY --chown=rf4:rf4 data/media ./data/media
|
||||
USER rf4
|
||||
EXPOSE 8000
|
||||
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"]
|
||||
|
||||
@@ -16,6 +16,7 @@ from .readiness import readiness_report
|
||||
from .routers.activity import router as activity_router
|
||||
from .routers.admin import router as admin_router
|
||||
from .routers.catalog import router as catalog_router
|
||||
from .routers.media import router as media_router
|
||||
from .routers.public_data import router as public_data_router
|
||||
from .routers.submissions import router as submissions_router
|
||||
from .storage import client as storage_client
|
||||
@@ -87,6 +88,7 @@ def ready(db: Db) -> JSONResponse:
|
||||
|
||||
|
||||
app.include_router(catalog_router)
|
||||
app.include_router(media_router)
|
||||
app.include_router(activity_router)
|
||||
app.include_router(public_data_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MEDIA_ROOT = Path(os.environ.get("MEDIA_ROOT", "data/media")).resolve()
|
||||
|
||||
|
||||
def published_assets(entity_type: str | None = None) -> list[dict]:
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
result = []
|
||||
for item in manifest.get("assets", []):
|
||||
if item.get("status") != "approved" or not item.get("sha256") or not item.get("local_path"):
|
||||
continue
|
||||
if entity_type and item.get("entity_type") != entity_type:
|
||||
continue
|
||||
source_page = str(item.get("source_page") or "")
|
||||
source = "rf4db" if "rf4db.com" in source_page else "rf4map" if "rf4map.ru" in source_page else "rf4-official"
|
||||
result.append({
|
||||
"id": item["sha256"],
|
||||
"entity_type": item.get("entity_type"),
|
||||
"entity_key": item.get("entity_key"),
|
||||
"label": item.get("label"),
|
||||
"width": item.get("width"),
|
||||
"height": item.get("height"),
|
||||
"content_type": item.get("content_type"),
|
||||
"image_url": f"/api/v1/media/assets/{item['sha256']}",
|
||||
"source_system": source,
|
||||
"source_url": source_page,
|
||||
})
|
||||
return sorted(result, key=lambda item: (str(item["entity_type"]), str(item["label"] or "").casefold(), item["id"]))
|
||||
|
||||
|
||||
def published_file(digest: str) -> tuple[Path, str] | None:
|
||||
if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
|
||||
return None
|
||||
manifest = json.loads((MEDIA_ROOT / "manifest.json").read_text(encoding="utf-8"))
|
||||
item = next((row for row in manifest.get("assets", []) if row.get("status") == "approved" and row.get("sha256") == digest), None)
|
||||
if not item:
|
||||
return None
|
||||
target = (MEDIA_ROOT / item["local_path"]).resolve()
|
||||
if not target.is_relative_to(MEDIA_ROOT.resolve()) or not target.is_file():
|
||||
return None
|
||||
return target, str(item["content_type"])
|
||||
@@ -0,0 +1,24 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from ..media_catalog import published_assets, published_file
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/v1/media/catalog")
|
||||
def media_catalog(entity_type: str | None = Query(None, pattern="^(fish|waterbody|tackle|reference)$")) -> list[dict]:
|
||||
return published_assets(entity_type)
|
||||
|
||||
|
||||
@router.get("/api/v1/media/assets/{digest}", response_class=FileResponse)
|
||||
def media_asset(digest: str) -> FileResponse:
|
||||
item = published_file(digest)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Media asset not found")
|
||||
path, media_type = item
|
||||
return FileResponse(path, media_type=media_type, headers={
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
"ETag": f'"{digest}"',
|
||||
})
|
||||
@@ -3156,6 +3156,90 @@
|
||||
"summary": "Imports"
|
||||
}
|
||||
},
|
||||
"/api/v1/media/assets/{digest}": {
|
||||
"get": {
|
||||
"operationId": "media_asset_api_v1_media_assets__digest__get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "path",
|
||||
"name": "digest",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"title": "Digest",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Media Asset"
|
||||
}
|
||||
},
|
||||
"/api/v1/media/catalog": {
|
||||
"get": {
|
||||
"operationId": "media_catalog_api_v1_media_catalog_get",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "query",
|
||||
"name": "entity_type",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"pattern": "^(fish|waterbody|tackle|reference)$",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Entity Type"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"title": "Response Media Catalog Api V1 Media Catalog Get",
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful Response"
|
||||
},
|
||||
"422": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Validation Error"
|
||||
}
|
||||
},
|
||||
"summary": "Media Catalog"
|
||||
}
|
||||
},
|
||||
"/api/v1/public-spot-pages": {
|
||||
"get": {
|
||||
"operationId": "public_spot_pages_api_v1_public_spot_pages_get",
|
||||
|
||||
@@ -61,6 +61,18 @@ def test_invalid_period_is_rejected() -> None:
|
||||
assert client.get("/api/v1/activity?sort=unknown").status_code == 422
|
||||
|
||||
|
||||
def test_published_media_catalog_and_content_addressed_file() -> None:
|
||||
catalog = client.get("/api/v1/media/catalog?entity_type=fish")
|
||||
assert catalog.status_code == 200
|
||||
assert catalog.json()
|
||||
item = catalog.json()[0]
|
||||
image = client.get(item["image_url"])
|
||||
assert image.status_code == 200
|
||||
assert image.headers["content-type"].startswith("image/")
|
||||
assert image.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404
|
||||
|
||||
|
||||
def test_review_queue_filters_before_pagination() -> None:
|
||||
with Session(engine) as db:
|
||||
stage_observations(db, [{
|
||||
|
||||
Reference in New Issue
Block a user