diff --git a/README.md b/README.md
index 8841e8f..dc3772a 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ RF4 Spotter — неофициальный сервис свежих точек
## Статус разработки
-**Проверка 15 сентября 2026: локальный контур готов к развёртыванию открытой альфы, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **178 passed, 1 skipped**; Astro check/build, web unit и API-тесты проходят. Граф миграций имеет единственную голову `0018`; production bootstrap и импорт реальных источников по-прежнему требуют отдельной инфраструктурной/разрешённой приёмки.
+**Проверка 20 сентября 2026: локальный кодовый контур проходит сборочные и тестовые gates, внешний запуск ждёт сервер и его настройки.** Пакет восстановления A01–A13 закрыт. Python: **191 passed, 1 skipped**; Astro check/build и web unit проходят. Граф миграций имеет единственную голову `0020`; OpenAPI artifact синхронизирован с FastAPI (`36 paths`). Изолированный production bootstrap с чистыми томами повторно пройден; живая БД, реальные секреты и импорт внешних источников по-прежнему требуют отдельной инфраструктурной/разрешённой приёмки.
Актуальные следующие задачи находятся только в [ROADMAP](docs/ROADMAP.md). Старые планы и аудиты сохранены как история и больше не задают порядок работ. До внешнего запуска нужны сервер, DNS/TLS, production-секреты, публичные контакты, внешний backup и канал уведомлений.
@@ -46,11 +46,11 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. После явного разрешения владельца от 14 сентября все скачанные и целостные материалы опубликованы в `/media`; публичный API отдаёт Git-копии по content-addressed URL, а каждая карточка показывает плашку и прямую ссылку на источник. Будущие загрузки по-прежнему не одобряются автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
-Актуальный offline-срез от 16.09.2026: 704 записи manifest, 466 `approved`, 226 `superseded`, 1 `duplicate` и 11 `invalid`; audit проходит без ошибок и orphan-файлов. У 252 из 253 рыбных изображений есть 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. Производные WebP/AVIF и provenance хранятся в Git; визуальная browser-приёмка остаётся отдельным пунктом B25.
+Актуальный offline-срез от 20.09.2026: 704 записи manifest, 466 `approved`, 226 `superseded`, 1 `duplicate` и 11 `invalid`; audit проходит без ошибок и orphan-файлов. У 252 из 253 рыбных изображений есть 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. Производные WebP/AVIF и provenance хранятся в Git; визуальная browser-приёмка остаётся отдельным пунктом B25.
-`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 --coverage` сравнивает manifest с датированным `data/media/catalog-baseline.json`: отдельно считает уникальные нормализованные подписи и кандидатов без подписи, поэтому альтернативные URL не завышают покрытие. Актуальный manifest содержит 704 записи: 466 approved, 226 superseded, 1 duplicate и 11 invalid; queue-представления больше нет. Опубликованы 253 fish-файла, все 149 найденных изображений снастей/приманок и 64 справочных материала; общий target снастей остаётся `null` до проверяемого полного счётчика. Водоёмы имеют отдельный canonical index из 19 карточек; detail-наполнение остаётся W02–W08.
-`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Текущий отчёт выявляет 227 PNG RF4MAP размером 48×48, которые увеличиваются в карточках до 180 px, и 16 WebP 1024×1024; для всех низких версий уже известны альтернативные RF4DB URL. `--queue-quality-upgrades` переводит только прямые альтернативы низкоразрешённых published-файлов в безопасную очередь, не снимая текущую версию с публикации. На 15.09 первая партия из 40 замен уже сохранена в карантине для сравнения и review; дальнейшие этапы ведутся в B21–B25 ROADMAP.
+`python -m rf4_research.media_cli --quality-report` выполняет offline-проверку разрешения опубликованных рыб. Контур quality-upgrade обработал 226 прямых RF4DB-альтернатив: 252 из 253 рыбных изображений теперь имеют 1024×1024 WebP, один 48×48 fallback сохранён из-за отсутствия проверенной альтернативы. `--queue-quality-upgrades` по-прежнему переводит только прямые альтернативы низкоразрешённых published-файлов в безопасную очередь, не снимая текущую версию с публикации; B21/B22 завершены, а browser-приёмка остаётся отдельным пунктом B25.
`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.
@@ -184,7 +184,7 @@ docker compose up --build
## Что реализовано
- FastAPI и SQLAlchemy 2;
-- PostgreSQL 17 и линейные миграции Alembic до `0018`;
+- PostgreSQL 17 и линейные миграции Alembic до `0020`;
- идемпотентный seed с двумя точками и свежими демо-уловами;
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
- `GET /api/v1/spots/{id}` и `/catches`;
diff --git a/apps/api/app/community_importer.py b/apps/api/app/community_importer.py
index 69b3108..1629a98 100644
--- a/apps/api/app/community_importer.py
+++ b/apps/api/app/community_importer.py
@@ -124,7 +124,7 @@ def _validate_waterbody_detail(detail: dict[str, Any]) -> dict[str, Any]:
external_id = _required(payload, "source_external_id", 200)
source_url = _required(payload, "source_url", 2000)
parsed_url = urlparse(source_url)
- if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com"}:
+ if parsed_url.scheme != "https" or parsed_url.hostname not in {"rf4db.com", "www.rf4db.com", "download.rf4db.com"}:
raise CommunityImportError("waterbody detail source_url does not match rf4db")
_required(payload, "name", 200)
_optional(payload, "description", 20_000)
diff --git a/apps/api/app/media_catalog.py b/apps/api/app/media_catalog.py
index 79cd037..332ca3d 100644
--- a/apps/api/app/media_catalog.py
+++ b/apps/api/app/media_catalog.py
@@ -49,12 +49,26 @@ def published_file(digest: str) -> tuple[Path, str] | None:
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:
+ media_type = None
+ local_path = None
+ if item:
+ media_type = item.get("content_type")
+ local_path = item.get("local_path")
+ else:
+ for row in manifest.get("assets", []):
+ if row.get("status") != "approved":
+ continue
+ variant = next((candidate for candidate in row.get("derivatives", []) if candidate.get("sha256") == digest), None)
+ if variant:
+ media_type = variant.get("content_type")
+ local_path = variant.get("local_path")
+ break
+ if not local_path:
return None
- target = (MEDIA_ROOT / item["local_path"]).resolve()
+ target = (MEDIA_ROOT / 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"])
+ return target, str(media_type or "application/octet-stream")
def review_assets(entity_type: str | None = None, status: str | None = None) -> list[dict]:
diff --git a/apps/api/openapi.json b/apps/api/openapi.json
index 8314f5b..17d7545 100644
--- a/apps/api/openapi.json
+++ b/apps/api/openapi.json
@@ -217,6 +217,21 @@
"title": "AdminCatchReportOut",
"type": "object"
},
+ "AdminMediaDecision": {
+ "properties": {
+ "note": {
+ "maxLength": 1000,
+ "minLength": 1,
+ "title": "Note",
+ "type": "string"
+ }
+ },
+ "required": [
+ "note"
+ ],
+ "title": "AdminMediaDecision",
+ "type": "object"
+ },
"AdminMediaDerivativeOut": {
"properties": {
"format": {
@@ -275,6 +290,10 @@
},
"AdminMediaReviewOut": {
"properties": {
+ "asset_url": {
+ "title": "Asset Url",
+ "type": "string"
+ },
"content_type": {
"anyOf": [
{
@@ -368,6 +387,17 @@
"title": "Status",
"type": "string"
},
+ "supersedes": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Supersedes"
+ },
"width": {
"anyOf": [
{
@@ -390,14 +420,38 @@
"height",
"content_type",
"image_url",
+ "asset_url",
"source_system",
"source_url",
"duplicate_of",
+ "supersedes",
"derivatives"
],
"title": "AdminMediaReviewOut",
"type": "object"
},
+ "AdminMediaRollback": {
+ "properties": {
+ "asset_url": {
+ "maxLength": 2000,
+ "minLength": 1,
+ "title": "Asset Url",
+ "type": "string"
+ },
+ "note": {
+ "maxLength": 1000,
+ "minLength": 1,
+ "title": "Note",
+ "type": "string"
+ }
+ },
+ "required": [
+ "note",
+ "asset_url"
+ ],
+ "title": "AdminMediaRollback",
+ "type": "object"
+ },
"AdminModerationHistoryOut": {
"properties": {
"action": {
@@ -3223,6 +3277,128 @@
"summary": "Admin Media Catalog"
}
},
+ "/api/v1/admin/media/upgrades/publish": {
+ "post": {
+ "description": "Atomically publish all stored quality upgrades after an explicit decision.",
+ "operationId": "admin_publish_media_upgrades_api_v1_admin_media_upgrades_publish_post",
+ "parameters": [
+ {
+ "in": "header",
+ "name": "authorization",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Authorization"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AdminMediaDecision"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": {
+ "type": "integer"
+ },
+ "title": "Response Admin Publish Media Upgrades Api V1 Admin Media Upgrades Publish Post",
+ "type": "object"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Admin Publish Media Upgrades"
+ }
+ },
+ "/api/v1/admin/media/upgrades/rollback": {
+ "post": {
+ "description": "Restore one superseded fallback while retaining the reviewed candidate.",
+ "operationId": "admin_rollback_media_upgrade_api_v1_admin_media_upgrades_rollback_post",
+ "parameters": [
+ {
+ "in": "header",
+ "name": "authorization",
+ "required": false,
+ "schema": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Authorization"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/AdminMediaRollback"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "200": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "title": "Response Admin Rollback Media Upgrade Api V1 Admin Media Upgrades Rollback Post",
+ "type": "object"
+ }
+ }
+ },
+ "description": "Successful Response"
+ },
+ "422": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ },
+ "description": "Validation Error"
+ }
+ },
+ "summary": "Admin Rollback Media Upgrade"
+ }
+ },
"/api/v1/admin/moderation-history": {
"get": {
"operationId": "admin_moderation_history_api_v1_admin_moderation_history_get",
diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py
index 935bb19..8211301 100644
--- a/apps/api/tests/test_api.py
+++ b/apps/api/tests/test_api.py
@@ -82,6 +82,9 @@ def test_published_media_catalog_and_content_addressed_file() -> None:
assert image.status_code == 200
assert image.headers["content-type"].startswith("image/")
assert image.headers["cache-control"] == "public, max-age=31536000, immutable"
+ variant = client.get(item["variants"][0]["url"])
+ assert variant.status_code == 200
+ assert variant.headers["content-type"].startswith("image/")
assert client.get("/api/v1/media/assets/not-a-hash").status_code == 404
diff --git a/apps/api/tests/test_community_importer.py b/apps/api/tests/test_community_importer.py
index e2a785d..74a152e 100644
--- a/apps/api/tests/test_community_importer.py
+++ b/apps/api/tests/test_community_importer.py
@@ -124,6 +124,21 @@ def test_waterbody_detail_updates_only_imported_identity_without_media_roles(db:
assert item.source_image_urls == ["https://oss.rf4db.com/map.webp"]
+def test_waterbody_detail_accepts_authorized_download_subdomain(db: Session) -> None:
+ upsert_waterbody_catalog(db, [waterbody_row()])
+ assert update_waterbody_detail(db, {
+ "source_system": "rf4db",
+ "source_external_id": "level_001_mosquito",
+ "source_url": "https://download.rf4db.com/ru/maps/level_001_mosquito",
+ "name": "оз. Комариное",
+ "description": None,
+ "aliases": [],
+ "fish_species": ["Щука"],
+ "image_urls": [],
+ "point_urls": [],
+ }) is True
+
+
def test_waterbody_detail_batch_validates_before_writing(db: Session) -> None:
upsert_waterbody_catalog(db, [waterbody_row()])
valid = {
diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro
index 592fcc2..2d4e05e 100644
--- a/apps/web/src/layouts/Layout.astro
+++ b/apps/web/src/layouts/Layout.astro
@@ -121,7 +121,7 @@ Astro.response.headers.set("Content-Security-Policy", [
Перейти к содержимому