diff --git a/README.md b/README.md
index 3a0c9f4..aa30273 100644
--- a/README.md
+++ b/README.md
@@ -44,9 +44,9 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Тот же запрос служит проверкой точной исходной ссылки: `404/410` означает `missing`, `401/403/429` — `blocked`, остальные сбои — `temporary_error`; отдельного link-checker и дополнительных обращений нет. Пропажа элемента из агрегатного списка сама по себе удалением не считается. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
-Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
+Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
-`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL и общие учебные схемы не завышают покрытие. Объединение разрешённых каталогов RF4MAP и RF4DB теперь содержит все 252 названия рыб; 25 отсутствовавших изображений RF4DB ожидают загрузки, а 227 совпадений сохранены как provenance-only `duplicate`. Водоёмы пока не покрыты, а общий target снастей остаётся `null` до проверяемого полного счётчика.
+`python -m rf4_research.media_cli --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. На 14.09.2026 manifest содержит 704 записи: 456 approved, 227 duplicate, 10 queued и 11 invalid. Опубликованы 243 уникально подписанные рыбы из 252, все 149 найденных изображений снастей/приманок и 64 справочных материала; 10 оставшихся RF4DB URL будут загружены после cooldown. Карты 19 водоёмов ещё предстоит проиндексировать, а общий target снастей остаётся `null` до проверяемого полного счётчика.
`python -m rf4_research.media_cli --queue-plan` без сетевых запросов объединяет manifest с общим cooldown-state: показывает queued-состав каждого домена, оставшееся время и наиболее полезный следующий asset с приоритетом водоёмов и рыб. Разрешённое media-окно загружается командой `--download-batch --batch-limit 40`: до 40 assets на домен под одной резервацией, затем 30 минут до нового batch. Блокировка/rate limit/сетевая ошибка останавливает домен сразу, три последовательных невалидных ответа — досрочно. Все файлы остаются в карантине до ручного review. Точный поимённый список отсутствующих сущностей появится только после получения канонического перечня; разница между двумя несогласованными каталогами не выдаётся за доказанный gap.
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index f3c546b..968e761 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -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"]
diff --git a/apps/api/app/main.py b/apps/api/app/main.py
index a594158..7fcae13 100644
--- a/apps/api/app/main.py
+++ b/apps/api/app/main.py
@@ -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)
diff --git a/apps/api/app/media_catalog.py b/apps/api/app/media_catalog.py
new file mode 100644
index 0000000..336a382
--- /dev/null
+++ b/apps/api/app/media_catalog.py
@@ -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"])
diff --git a/apps/api/app/routers/media.py b/apps/api/app/routers/media.py
new file mode 100644
index 0000000..dae6a36
--- /dev/null
+++ b/apps/api/app/routers/media.py
@@ -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}"',
+ })
diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index 8e50799..ba64333 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -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",
diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py
index 74c2c8b..840f91c 100644
--- a/apps/api/tests/test_api.py
+++ b/apps/api/tests/test_api.py
@@ -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, [{
diff --git a/apps/web/src/components/EntityMedia.astro b/apps/web/src/components/EntityMedia.astro
new file mode 100644
index 0000000..7764cc6
--- /dev/null
+++ b/apps/web/src/components/EntityMedia.astro
@@ -0,0 +1,9 @@
+---
+import SourceBadge from "./SourceBadge.astro";
+import type { MediaAsset } from "../lib/api";
+const { asset, compact = false, sourceLink = false } = Astro.props as { asset: MediaAsset; compact?: boolean; sourceLink?: boolean };
+---
+
+
+ {asset.label ?? "Справочный материал"}
+
diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro
index ac62652..3f8431a 100644
--- a/apps/web/src/layouts/Layout.astro
+++ b/apps/web/src/layouts/Layout.astro
@@ -14,6 +14,7 @@ import "../styles/signal-pagination.css";
import "../styles/dashboard-polish.css";
import "../styles/loading-states.css";
import "../styles/theme.css";
+import "../styles/media-catalog.css";
import FishingIcon from "../components/FishingIcon.astro";
import AlphaBanner from "../components/AlphaBanner.astro";
const {
@@ -114,7 +115,7 @@ Astro.response.headers.set("Content-Security-Policy", [
Перейти к содержимому