From ee2f8ac61a1d44db56a95d7748ed548393a98bd5 Mon Sep 17 00:00:00 2001 From: kpa39l Date: Mon, 14 Sep 2026 18:29:54 +0000 Subject: [PATCH] =?UTF-8?q?fix(publisher):=20=D0=BC=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D0=B2=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D0=BA=D0=BE=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=D1=81=D1=8C=20=D0=B2=20Telegram=20=E2=80=94=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=20media/media?= =?UTF-8?q?=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20media?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Причина: compose монтировал ../../media/media:/srv/publisher/media, а краулер с 2026-09-12 качает файлы в media/ (новая раскладка). В контейнере Path(media/).exists() = False → медиа молча пропускалось, уходил только текст (пост #1017). Фикс: монтировать весь ../../media + _resolve_media() в publisher (пробует media/, media/media/). GoToSocial тоже. Change fix-media-mount (skip_specs: багфикс), e2e: media_message_id задан, тестовые сообщения удалены. --- .../changes/fix-media-mount/.openspec.yaml | 3 + openspec/changes/fix-media-mount/design.md | 93 +++++++++++++++++++ openspec/changes/fix-media-mount/proposal.md | 69 ++++++++++++++ openspec/changes/fix-media-mount/tasks.md | 9 ++ services/publisher/app/main.py | 32 ++++++- services/publisher/docker-compose.yml | 9 +- 6 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 openspec/changes/fix-media-mount/.openspec.yaml create mode 100644 openspec/changes/fix-media-mount/design.md create mode 100644 openspec/changes/fix-media-mount/proposal.md create mode 100644 openspec/changes/fix-media-mount/tasks.md diff --git a/openspec/changes/fix-media-mount/.openspec.yaml b/openspec/changes/fix-media-mount/.openspec.yaml new file mode 100644 index 0000000..cae7325 --- /dev/null +++ b/openspec/changes/fix-media-mount/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-09-14 +skip_specs: true \ No newline at end of file diff --git a/openspec/changes/fix-media-mount/design.md b/openspec/changes/fix-media-mount/design.md new file mode 100644 index 0000000..636a6b1 --- /dev/null +++ b/openspec/changes/fix-media-mount/design.md @@ -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/, старые лежали в media/media/. Обе раскладки + # доступны в контейнере: /srv/publisher/media/ и + # /srv/publisher/media/media/. + - ../../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/ (новая раскладка) + 2. media/media/ -> старая раскладка (media/media/) + Возвращает существующий путь или None (медиа молча пропускается). + """ + if not media: + return None + base = Path(media).name + # card.media из веба — относительный 'media/' → резолвится от 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). \ No newline at end of file diff --git a/openspec/changes/fix-media-mount/proposal.md b/openspec/changes/fix-media-mount/proposal.md new file mode 100644 index 0000000..5a1ccca --- /dev/null +++ b/openspec/changes/fix-media-mount/proposal.md @@ -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/` (внешний каталог), а не в +`media/media/`. В контейнере (CWD `/srv/publisher`) путь `media/` +резолвится в `/srv/publisher/media/` = `/opt/vesti/media/media/`, +которого для новых постов не существует. + +В 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/` = `media/` ✓ + - старые (если появятся/восстановятся): `/srv/publisher/media/media/` = `media/media/` ✓ + +2. **`services/publisher/app/main.py`**: устойчивый резолвинг медиа-файла в + контейнере. Вместо единственной проверки `Path(card.media).exists()` — + пробовать кандидатов в порядке приоритета (под контейнерный CWD): + - `card.media` как есть (заданный путь, напр. `media/`, + резолвится от `/srv/publisher`), + - `media/` (если путь в БД содержал подкаталог), + - `media/media/` (старая раскладка). + Первый существующий путь идёт в `send_media`. + + Это чинит и будущие случаи, когда веб пришлёт нормализованный путь, и + старые посты, файлы которых восстановят. + +3. **`publisher/card.py`** — не меняем (нормализация путей на стороне веба уже + работает, publisher получает `media='media/'`). + +## 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` и старый код, пересоздать + контейнер. \ No newline at end of file diff --git a/openspec/changes/fix-media-mount/tasks.md b/openspec/changes/fix-media-mount/tasks.md new file mode 100644 index 0000000..43964da --- /dev/null +++ b/openspec/changes/fix-media-mount/tasks.md @@ -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) \ No newline at end of file diff --git a/services/publisher/app/main.py b/services/publisher/app/main.py index 4337192..e92301d 100644 --- a/services/publisher/app/main.py +++ b/services/publisher/app/main.py @@ -74,6 +74,28 @@ def healthz(): } +def _resolve_media(media: str | None) -> str | None: + """Находит реальный путь медиа-файла в контейнере (CWD /srv/publisher). + + Пробует варианты в порядке приоритета: + 1. media как есть -> /srv/publisher/media/ (новая раскладка) + 2. media/media/ -> старая раскладка (media/media/) + Возвращает существующий путь или None (медиа молча пропускается). + """ + if not media: + return None + base = Path(media).name + # card.media из веба — относительный 'media/' → резолвится от 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: """Публикует карточку в GoToSocial (Mastodon API). Возвращает ChannelResult. @@ -83,8 +105,9 @@ def _publish_gotosocial(card: Card, ch: str) -> ChannelResult: res = ChannelResult() try: media_ids: list[str] = [] - if card.media and Path(card.media).exists(): - media_ids.append(gotosocial.upload_media(card.media)) + media_path = _resolve_media(card.media) + if media_path: + media_ids.append(gotosocial.upload_media(media_path)) sid = gotosocial.post_status( text=card.text, media_ids=media_ids or None, @@ -122,9 +145,10 @@ def publish(req: PublishRequest): for ch in channels: res = ChannelResult() try: - if req.card.media and Path(req.card.media).exists(): + media_path = _resolve_media(req.card.media) + if media_path: # медиа первым (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 # ПОЛНЫЙ текст отдельным сообщением (без предпросмотра ссылки) msg = telegram.send_message(ch, req.card.text) diff --git a/services/publisher/docker-compose.yml b/services/publisher/docker-compose.yml index 042f89f..e9a46eb 100644 --- a/services/publisher/docker-compose.yml +++ b/services/publisher/docker-compose.yml @@ -14,10 +14,11 @@ services: extra_hosts: - "host.docker.internal:host-gateway" volumes: - # медиа для карточек: веб шлёт media/ (media_path из БД). - # Физически файлы лежат в /opt/vesti/media/media/ (вложенный каталог, см. download_media в краулере). - # Монтируем именно внутренний каталог, чтобы Path('media/') в контейнере (от WORKDIR /srv/publisher) попал в файл. - - ../../media/media:/srv/publisher/media:ro + # ВЕСЬ каталог медиа (не media/media): краулер с 2026-09-12 качает в + # media/, старые лежали в media/media/. Обе раскладки + # доступны в контейнере: /srv/publisher/media/ и + # /srv/publisher/media/media/. + - ../../media:/srv/publisher/media:ro healthcheck: test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8410/healthz',timeout=4)"] interval: 30s