mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
fix(publisher): медиа постов не публиковалось в Telegram — контейнер монтировал media/media вместо media
Причина: compose монтировал ../../media/media:/srv/publisher/media, а краулер с 2026-09-12 качает файлы в media/<file> (новая раскладка). В контейнере Path(media/<file>).exists() = False → медиа молча пропускалось, уходил только текст (пост #1017). Фикс: монтировать весь ../../media + _resolve_media() в publisher (пробует media/<file>, media/media/<basename>). GoToSocial тоже. Change fix-media-mount (skip_specs: багфикс), e2e: media_message_id задан, тестовые сообщения удалены.
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-09-14
|
||||||
|
skip_specs: true
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
## Дизайн
|
||||||
|
|
||||||
|
### 1. docker-compose.yml — весь каталог media
|
||||||
|
|
||||||
|
Было:
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ../../media/media:/srv/publisher/media:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало:
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
# ВЕСЬ каталог медиа (не media/media): краулер с 2026-09-12 качает в
|
||||||
|
# media/<file>, старые лежали в media/media/<file>. Обе раскладки
|
||||||
|
# доступны в контейнере: /srv/publisher/media/<file> и
|
||||||
|
# /srv/publisher/media/media/<file>.
|
||||||
|
- ../../media:/srv/publisher/media:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. main.py — резолвинг медиа-пути в контейнере
|
||||||
|
|
||||||
|
В `publish()` для Telegram-каналов заменить прямую проверку на устойчивый
|
||||||
|
поиск:
|
||||||
|
|
||||||
|
Было:
|
||||||
|
```python
|
||||||
|
if req.card.media and Path(req.card.media).exists():
|
||||||
|
media_msg = telegram.send_media(ch, req.card.media, caption=req.card.text[:1000])
|
||||||
|
res.media_message_id = media_msg
|
||||||
|
msg = telegram.send_message(ch, req.card.text)
|
||||||
|
res.message_id = msg
|
||||||
|
```
|
||||||
|
|
||||||
|
Стало — хелпер `_resolve_media(card.media)`:
|
||||||
|
```python
|
||||||
|
def _resolve_media(media: str | None) -> str | None:
|
||||||
|
"""Находит реальный путь медиа-файла в контейнере (CWD /srv/publisher).
|
||||||
|
|
||||||
|
Пробует варианты в порядке приоритета:
|
||||||
|
1. media как есть -> /srv/publisher/media/<file> (новая раскладка)
|
||||||
|
2. media/media/<basename> -> старая раскладка (media/media/<file>)
|
||||||
|
Возвращает существующий путь или None (медиа молча пропускается).
|
||||||
|
"""
|
||||||
|
if not media:
|
||||||
|
return None
|
||||||
|
base = Path(media).name
|
||||||
|
# card.media из веба — относительный 'media/<file>' → резолвится от CWD /srv/publisher
|
||||||
|
cands = [Path(media), Path("media") / base, Path("media/media") / base]
|
||||||
|
for c in cands:
|
||||||
|
try:
|
||||||
|
if c.exists() and c.is_file():
|
||||||
|
return str(c)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
```python
|
||||||
|
media_path = _resolve_media(req.card.media)
|
||||||
|
if media_path:
|
||||||
|
media_msg = telegram.send_media(ch, media_path, caption=req.card.text[:1000])
|
||||||
|
res.media_message_id = media_msg
|
||||||
|
msg = telegram.send_message(ch, req.card.text)
|
||||||
|
res.message_id = msg
|
||||||
|
else:
|
||||||
|
msg = telegram.send_message(ch, req.card.text)
|
||||||
|
res.message_id = msg
|
||||||
|
```
|
||||||
|
|
||||||
|
Для GoToSocial — тот же `_resolve_media` в `_publish_gotosocial`:
|
||||||
|
```python
|
||||||
|
media_ids = []
|
||||||
|
media_path = _resolve_media(card.media)
|
||||||
|
if media_path:
|
||||||
|
media_ids.append(gotosocial.upload_media(media_path))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Что НЕ меняем
|
||||||
|
|
||||||
|
- `publisher/card.py` — медиа-нормализация на стороне веба остаётся.
|
||||||
|
- БД, `media_path` — без миграций.
|
||||||
|
- `web/app.py`, `web/store.py` — без изменений.
|
||||||
|
|
||||||
|
## Порядок деплоя
|
||||||
|
|
||||||
|
1. Правки compose + main.py.
|
||||||
|
2. `docker compose -f services/publisher/docker-compose.yml up -d` (volume
|
||||||
|
изменился — контейнер пересоздастся).
|
||||||
|
3. Проверка: `docker exec vesti-publisher python -c "import os; print(os.path.exists('media/linux_education_2772.jpg'))"` → True.
|
||||||
|
4. e2e: publish поста (текст+медиа) в канал → `media_message_id` задан, затем
|
||||||
|
удалить тестовые сообщения (delete_message).
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Пост #1017 (linux) опубликован без картинки: в Telegram ушёл только текст, хотя
|
||||||
|
у поста есть медиа (`media_path='media/linux_education_2772.jpg'`, файл на хосте
|
||||||
|
`/opt/vesti/media/linux_education_2772.jpg`, 151 КБ).
|
||||||
|
|
||||||
|
Причина: Docker-контейнер `vesti-publisher` монтирует
|
||||||
|
`../../media/media:/srv/publisher/media` — **внутренний** каталог `media/media/`.
|
||||||
|
С 2026-09-12 краулер качает медиа в `media/<file>` (внешний каталог), а не в
|
||||||
|
`media/media/<file>`. В контейнере (CWD `/srv/publisher`) путь `media/<file>`
|
||||||
|
резолвится в `/srv/publisher/media/<file>` = `/opt/vesti/media/media/<file>`,
|
||||||
|
которого для новых постов не существует.
|
||||||
|
|
||||||
|
В publisher (`services/publisher/app/main.py`):
|
||||||
|
```python
|
||||||
|
if req.card.media and Path(req.card.media).exists():
|
||||||
|
```
|
||||||
|
проверка проваливается → медиа молча пропускается, публикуется только текст.
|
||||||
|
Ошибки нет — файл «просто не найден».
|
||||||
|
|
||||||
|
Проверено в контейнере:
|
||||||
|
```
|
||||||
|
docker exec vesti-publisher python -c "import os; print(os.path.exists('media/linux_education_2772.jpg'))" # False
|
||||||
|
```
|
||||||
|
|
||||||
|
Затронуты все посты с медиа, скачанные с 2026-09-12 (новая раскладка). Старых
|
||||||
|
файлов в `media/media/` на диске нет (545 постов со старым путём вообще без
|
||||||
|
файлов — отдельная проблема, не этого change).
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
1. **`services/publisher/docker-compose.yml`**: монтировать весь каталог медиа
|
||||||
|
`../../media:/srv/publisher/media:ro` вместо `../../media/media`. Тогда в
|
||||||
|
контейнере видны ОБЕ раскладки:
|
||||||
|
- новые файлы: `/srv/publisher/media/<file>` = `media/<file>` ✓
|
||||||
|
- старые (если появятся/восстановятся): `/srv/publisher/media/media/<file>` = `media/media/<file>` ✓
|
||||||
|
|
||||||
|
2. **`services/publisher/app/main.py`**: устойчивый резолвинг медиа-файла в
|
||||||
|
контейнере. Вместо единственной проверки `Path(card.media).exists()` —
|
||||||
|
пробовать кандидатов в порядке приоритета (под контейнерный CWD):
|
||||||
|
- `card.media` как есть (заданный путь, напр. `media/<file>`,
|
||||||
|
резолвится от `/srv/publisher`),
|
||||||
|
- `media/<basename>` (если путь в БД содержал подкаталог),
|
||||||
|
- `media/media/<basename>` (старая раскладка).
|
||||||
|
Первый существующий путь идёт в `send_media`.
|
||||||
|
|
||||||
|
Это чинит и будущие случаи, когда веб пришлёт нормализованный путь, и
|
||||||
|
старые посты, файлы которых восстановят.
|
||||||
|
|
||||||
|
3. **`publisher/card.py`** — не меняем (нормализация путей на стороне веба уже
|
||||||
|
работает, publisher получает `media='media/<file>'`).
|
||||||
|
|
||||||
|
## Why Not
|
||||||
|
|
||||||
|
- Не переносить файлы из `media/` в `media/media/` и не править 12 строк БД:
|
||||||
|
новая раскладка правильная, старую не размножаем.
|
||||||
|
- Не отключать проверку существования файла: у 545 старых постов файла нет,
|
||||||
|
publisher должен молча пропускать отсутствующее медиа, а не падать.
|
||||||
|
- Не менять `media_path` в БД: веб (route `/media/{filename}`, MEDIA_DIRS)
|
||||||
|
и читает оба каталога, и publisher после фикса тоже.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Файлы: `services/publisher/docker-compose.yml`, `services/publisher/app/main.py`.
|
||||||
|
- Сервис: пересоздать контейнер publisher (`docker compose up -d` — volume
|
||||||
|
меняется), проверить healthz.
|
||||||
|
- Данные: без миграций БД.
|
||||||
|
- Rollback: вернуть монтирование `../../media/media` и старый код, пересоздать
|
||||||
|
контейнер.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# fix-media-mount
|
||||||
|
|
||||||
|
- [x] OpenSpec change создан (proposal/design)
|
||||||
|
- [x] docker-compose.yml: монтировать `../../media:/srv/publisher/media` (весь каталог)
|
||||||
|
- [x] main.py: `_resolve_media()` — резолвинг media-пути в контейнере (media/, media/media/, basename)
|
||||||
|
- [x] GoToSocial `_publish_gotosocial` тоже использует `_resolve_media`
|
||||||
|
- [x] `docker compose up -d --build` — контейнер пересоздан, healthz ok
|
||||||
|
- [x] Проверка: `docker exec` видит `media/linux_education_2772.jpg`
|
||||||
|
- [x] e2e: publish текста+медиа в канал → media_message_id задан (13, 17); тестовые сообщения удалены (delete True)
|
||||||
@@ -74,6 +74,28 @@ def healthz():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_media(media: str | None) -> str | None:
|
||||||
|
"""Находит реальный путь медиа-файла в контейнере (CWD /srv/publisher).
|
||||||
|
|
||||||
|
Пробует варианты в порядке приоритета:
|
||||||
|
1. media как есть -> /srv/publisher/media/<file> (новая раскладка)
|
||||||
|
2. media/media/<basename> -> старая раскладка (media/media/<file>)
|
||||||
|
Возвращает существующий путь или None (медиа молча пропускается).
|
||||||
|
"""
|
||||||
|
if not media:
|
||||||
|
return None
|
||||||
|
base = Path(media).name
|
||||||
|
# card.media из веба — относительный 'media/<file>' → резолвится от CWD /srv/publisher
|
||||||
|
cands = [Path(media), Path("media") / base, Path("media/media") / base]
|
||||||
|
for c in cands:
|
||||||
|
try:
|
||||||
|
if c.exists() and c.is_file():
|
||||||
|
return str(c)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _publish_gotosocial(card: Card, ch: str) -> ChannelResult:
|
def _publish_gotosocial(card: Card, ch: str) -> ChannelResult:
|
||||||
"""Публикует карточку в GoToSocial (Mastodon API). Возвращает ChannelResult.
|
"""Публикует карточку в GoToSocial (Mastodon API). Возвращает ChannelResult.
|
||||||
|
|
||||||
@@ -83,8 +105,9 @@ def _publish_gotosocial(card: Card, ch: str) -> ChannelResult:
|
|||||||
res = ChannelResult()
|
res = ChannelResult()
|
||||||
try:
|
try:
|
||||||
media_ids: list[str] = []
|
media_ids: list[str] = []
|
||||||
if card.media and Path(card.media).exists():
|
media_path = _resolve_media(card.media)
|
||||||
media_ids.append(gotosocial.upload_media(card.media))
|
if media_path:
|
||||||
|
media_ids.append(gotosocial.upload_media(media_path))
|
||||||
sid = gotosocial.post_status(
|
sid = gotosocial.post_status(
|
||||||
text=card.text,
|
text=card.text,
|
||||||
media_ids=media_ids or None,
|
media_ids=media_ids or None,
|
||||||
@@ -122,9 +145,10 @@ def publish(req: PublishRequest):
|
|||||||
for ch in channels:
|
for ch in channels:
|
||||||
res = ChannelResult()
|
res = ChannelResult()
|
||||||
try:
|
try:
|
||||||
if req.card.media and Path(req.card.media).exists():
|
media_path = _resolve_media(req.card.media)
|
||||||
|
if media_path:
|
||||||
# медиа первым (caption короткий: первые 1000 символов, т.к. лимит 1024)
|
# медиа первым (caption короткий: первые 1000 символов, т.к. лимит 1024)
|
||||||
media_msg = telegram.send_media(ch, req.card.media, caption=req.card.text[:1000])
|
media_msg = telegram.send_media(ch, media_path, caption=req.card.text[:1000])
|
||||||
res.media_message_id = media_msg
|
res.media_message_id = media_msg
|
||||||
# ПОЛНЫЙ текст отдельным сообщением (без предпросмотра ссылки)
|
# ПОЛНЫЙ текст отдельным сообщением (без предпросмотра ссылки)
|
||||||
msg = telegram.send_message(ch, req.card.text)
|
msg = telegram.send_message(ch, req.card.text)
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ services:
|
|||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
# медиа для карточек: веб шлёт media/<file> (media_path из БД).
|
# ВЕСЬ каталог медиа (не media/media): краулер с 2026-09-12 качает в
|
||||||
# Физически файлы лежат в /opt/vesti/media/media/ (вложенный каталог, см. download_media в краулере).
|
# media/<file>, старые лежали в media/media/<file>. Обе раскладки
|
||||||
# Монтируем именно внутренний каталог, чтобы Path('media/<file>') в контейнере (от WORKDIR /srv/publisher) попал в файл.
|
# доступны в контейнере: /srv/publisher/media/<file> и
|
||||||
- ../../media/media:/srv/publisher/media:ro
|
# /srv/publisher/media/media/<file>.
|
||||||
|
- ../../media:/srv/publisher/media:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8410/healthz',timeout=4)"]
|
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8410/healthz',timeout=4)"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
Reference in New Issue
Block a user