[2026-09-26] eagle: family/how-to/jellyfin-config.md personal/plans/balda-mm-endtoend-plan.md personal/projects/balda/balda-kraken-mm-deploy.md personal/tech/balda-go-fx-pitfalls.md personal/tech/music-tags-repair-truenas.md
This commit is contained in:
1 parent
bf7fa3569d
commit
6ae7f397ef
5 files changed
+688
-86
No files matched your search
@@ -1,9 +1,33 @@
|
||||
# План: доделать сквозной путь `@balda` → ответ в Mattermost
|
||||
|
||||
> Создан 2026-09-26. Статус: **в работе**.
|
||||
> Создан 2026-09-26. Обновлён 2026-09-26 (сессия реализации).
|
||||
> Статус: **🟡 ОБЕ ПРИЧИНЫ УСТРАНЕНЫ. Хендлер написан и работает. Осталась привязка владельца.**
|
||||
> Часть [[personal/projects/balda/balda-kraken-mm-deploy]] (Фаза 3).
|
||||
> Команда Alex: «Доделывай всё. И уведомления в приложения неактивны тоже поправь.»
|
||||
|
||||
## Итог сессии (кратко)
|
||||
|
||||
| Шаг | Статус |
|
||||
|---|---|
|
||||
| Причина A (`bad_seq`) найдена и исправлена | ✅ дельта 16, проверено |
|
||||
| Причина B (`Processor == nil`) найдена | ✅ дельта 15 (диагностика) |
|
||||
| MM-хендлер написан (`handlersfx/mattermost_ingress.go`) | ✅ ~320 строк, собирается |
|
||||
| Хендлер подключён в DI | ✅ дельта 19, `fx.As(new(mattermost.InboundProcessor))` |
|
||||
| `auth`: поддержка `mattermost:` subject | ✅ дельты 17, 18 |
|
||||
| Quality gates: build / vet / arch-lint | ✅ build 0, vet чисто, arch-lint **no warnings** |
|
||||
| Образ `balda-mm:0.1.5` собран и задеплоен | ✅ Up, без ошибок старта |
|
||||
| **Хендлер доказанно работает** | ✅ лог `conversational ingress settled`, `session_id=mm-c-7aafea13` |
|
||||
| Привязка владельца (`/start owner=…` в DM) | 🔴 **НЕ ВЫПОЛНЕНА** — блокирует приёмку |
|
||||
| Push-уведомления | ⬜ не начато |
|
||||
|
||||
**Где именно стоим:** пост доходит до `ingressapp` и отбивается на авторизации
|
||||
(`settlement_stage="unauthorized"`) — это **корректное** поведение, а не баг.
|
||||
В `state.db` таблица `balda_user_bindings` **пуста**: ни одной привязки канала.
|
||||
Админ `b6517e56-1110-4cb9-a301-302c585fbd0d` существует, но не связан ни с одним транспортом.
|
||||
|
||||
**Следующее действие:** `ssh kraken '/tmp/mm-owner-bind.sh testuser "T3stPassw0rd!x" <owner_token>'`
|
||||
(скрипт написан, см. §Инструменты), затем `@balda` в канал и **приёмка ответом в UI**.
|
||||
|
||||
---
|
||||
|
||||
## Root cause (найден диагностикой, образ `0.1.4`)
|
||||
@@ -87,62 +111,137 @@ if s.processor == nil {
|
||||
|
||||
---
|
||||
|
||||
## План работ
|
||||
## Что реализовано (фактические изменения)
|
||||
|
||||
### Шаг 1 — Написать `handlersfx/mattermost_ingress.go` ⬜
|
||||
### `handlersfx/mattermost_ingress.go` — НОВЫЙ ФАЙЛ (~320 строк)
|
||||
|
||||
По образцу `telegram_ingress.go`, но проще (у MM нет форум-топиков, callback-кнопок и
|
||||
question-reply — их можно не тащить в первой версии).
|
||||
Собственная реализация `mattermost.InboundProcessor`. Своя, потому что апстрим такого
|
||||
хендлера не содержит (`git ls-tree upstream/main` — только `telegram*` и `zulip_ingress.go`).
|
||||
|
||||
Обязательный минимум:
|
||||
Структура (по образцу `telegram_ingress.go`, но проще — у MM нет форум-топиков,
|
||||
callback-кнопок и question-reply):
|
||||
|
||||
1. `mattermostInboundHandlerParams` (fx.In) с `optional:"true"` где нужно:
|
||||
`OwnerStore`, `CollaboratorStore`, `SessionManager`, `Dispatcher actortransport.Dispatcher`,
|
||||
`AuthToken string \`name:"balda_auth_token"\``, `BaldaProviderID string \`name:"balda_provider"\``,
|
||||
`Logger`.
|
||||
2. `newMattermostInboundHandler(params) mattermost.InboundProcessor` — конструктор.
|
||||
3. `ProcessInbound(ctx, msg mattermost.InboundMessage) (turncmd.InboundSettlement, error)`:
|
||||
- собрать `ingressapp.InboundContext` из `msg.Locator` / `msg.SenderID` / `msg.Direct`;
|
||||
- `Authorize` → `Prepare` → `ingressapp.Service.Process(ctx, normalized)`;
|
||||
- вернуть `result.Settlement`.
|
||||
4. `Service` собирается через `ingressapp.New(...)` с тремя функциями-адаптерами
|
||||
(`AuthorizerFunc`, `SessionPreparerFunc`, `DispatcherFunc`).
|
||||
| Элемент | Назначение |
|
||||
|---|---|
|
||||
| `mattermostInboundHandlerParams` (`fx.In`) | `OwnerStore` / `CollaboratorStore` / `SessionManager` / `Dispatcher` / `CommandIngress` — все `optional:"true"`; `AuthToken \`name:"balda_auth_token"\``; `BaldaProviderID \`name:"balda_provider"\``; `Logger` |
|
||||
| `newMattermostInboundHandler` | конструктор, возвращает `mattermost.InboundProcessor` |
|
||||
| `ProcessInbound` | собирает `mattermost.Post` из `InboundMessage`, зовёт `NormalizeInbound`, отдаёт в `ingressapp.Service.Process` |
|
||||
| `HandleCommand` | проверяет доступ, публикует в `commandcmd.Ingress` |
|
||||
| `HandleUnsupportedCommand` | отвечает `Unknown command: /<name>` вместо тишины |
|
||||
| `ingressService()` | `ingressapp.NewWithLogger` + три адаптера: `AuthorizerFunc` / `SessionPreparerFunc` / `DispatcherFunc` |
|
||||
| `prepareSession` | `GetSession` → `RestoreSession` → `EnsureSession` (fallback), без DM-welcome-хореографии |
|
||||
| `canAccess` / `isOwner` | через `auth.MattermostSubject(userID)` |
|
||||
|
||||
⚠️ Не тащить в ingress бизнес-логику — AGENTS.md §«Ingress purity»: только
|
||||
parse / auth / session-check / publish.
|
||||
🔴 **Ключевое отличие от telegram:** `InboundMessage` **не содержит** поля `Post` — есть
|
||||
`Text`, `SenderID`, `SenderName`, `PostID`, `ReceivedAt`. `NormalizeInbound` же принимает
|
||||
`Post`, поэтому Post собирается руками в `ProcessInbound`.
|
||||
|
||||
### Шаг 2 — Подключить в DI ⬜
|
||||
### `auth` — поддержка `mattermost:` subject (дельты 17, 18)
|
||||
|
||||
`handlersfx/module.go`: добавить `newMattermostInboundHandler` в `fx.Provide` с
|
||||
`fx.As(new(mattermost.InboundProcessor))` — тогда `optional:"true"` в `mattermostfx` получит
|
||||
реальную зависимость.
|
||||
| Файл | Изменение | Почему |
|
||||
|---|---|---|
|
||||
| `auth/channel_token.go` | `ChannelMattermost = "mattermost"` + добавлен в `CreateMissingOwnerBindTokens` | без этого MM не получает owner-bind токен |
|
||||
| `auth/canonical_users.go` | ветка `case ChannelMattermost` в `canonicalSubject` | 🔴 **без этого `canonicalSubject` возвращает `unsupported transport "mattermost"` и КАЖДАЯ проверка авторизации MM молча падает** |
|
||||
| `auth/owner.go` | `MattermostSubject(userID string)` = `mattermost:<id>` | рядом с `TelegramSubject` / `ZulipSubject` / `SlackSubject` |
|
||||
| `channel/mattermost/locator_codec.go` | `ChannelIDOf(locator)` | достаёт `channel_id` из `AddressJSON` |
|
||||
|
||||
⚠️ `go-arch-lint`: `handlersfx` уже имеет право зависимости; новых компонентов не требуется.
|
||||
⚠️ Проверить, что `fx.Annotate` не конфликтует с `fx.In`-структурой (см.
|
||||
[[personal/tech/balda-go-fx-pitfalls]] — `fx.In` нельзя аннотировать `fx.ParamTags`).
|
||||
📌 `userSubject()` в `actors/command/start/handler.go` **уже** давал `mattermost:<id>`
|
||||
через default-ветку — правки не потребовалось. Команда `/start` работает с MM без изменений.
|
||||
|
||||
### Шаг 3 — Сборка и деплой ⬜
|
||||
### `handlersfx/module.go` — регистрация в DI (дельта 19)
|
||||
|
||||
```go
|
||||
fx.Annotate(
|
||||
newMattermostInboundHandler,
|
||||
fx.As(new(baldamattermost.InboundProcessor)),
|
||||
),
|
||||
```
|
||||
|
||||
⚠️ `fx.In`-структуру **не аннотировали** `fx.ParamTags` — только внешний `fx.As`
|
||||
(см. [[personal/tech/balda-go-fx-pitfalls]]).
|
||||
|
||||
### Порядок сборки и деплоя (проверен)
|
||||
|
||||
```bash
|
||||
cd ~/Developer/balda
|
||||
go build ./... && go vet ./internal/apps/balda/...
|
||||
go build ./... && go vet ./...
|
||||
go tool go-arch-lint check --project-path .
|
||||
|
||||
docker buildx build --builder arm64builder --platform linux/arm64 \
|
||||
-f Dockerfile.release -t balda-mm:0.1.5 --load .
|
||||
docker save balda-mm:0.1.5 | zstd -3 -T4 | ssh kraken 'zstd -dc | docker load'
|
||||
ssh kraken 'cd /home/kraken/balda && cp docker-compose.yml docker-compose.yml.bak-$(date +%s) \
|
||||
&& sed -i "s|balda-mm:0.1.4|balda-mm:0.1.5|" docker-compose.yml && docker compose up -d --force-recreate'
|
||||
&& sed -i "s|balda-mm:0.1.4|balda-mm:0.1.5|" docker-compose.yml \
|
||||
&& docker compose up -d --force-recreate'
|
||||
```
|
||||
|
||||
### Шаг 4 — Приёмка (ОБЯЗАТЕЛЬНО в UI) ⬜
|
||||
### Доказательство, что хендлер вызывается (лог образа `0.1.5`)
|
||||
|
||||
```json
|
||||
{"component":"balda.channel.mattermost.ingress","text":"скажи одно слово",
|
||||
"message":"mattermost post accepted for processing"}
|
||||
{"component":"balda.handlersfx.mattermost","session_id":"mm-c-7aafea13",
|
||||
"inbound_id":"mattermost:3b6wg9e1z3rgun3uu7di11w3ja",
|
||||
"settlement_stage":"unauthorized","message":"conversational ingress settled"}
|
||||
```
|
||||
|
||||
До правки на этом месте была **тишина** — теперь пост доходит до `ingressapp`.
|
||||
Осталось снять `unauthorized` привязкой владельца.
|
||||
|
||||
## 🔴 Текущий блокер: привязка владельца
|
||||
|
||||
Авторизация в Balda — **subject-based**: владелец в `canonical` store привязан строкой вида
|
||||
`<channel>:<principal>`. Для MM это `mattermost:<mm_user_id>`.
|
||||
|
||||
**Состояние (проверено `strings` по `state.db`):**
|
||||
|
||||
```
|
||||
таблица balda_user_bindings → ПУСТА (ни одной привязки канала)
|
||||
admin user_id = b6517e56-1110-4cb9-a301-302c585fbd0d (роль administrator, active)
|
||||
owner_auth_token = <32 символа, лежит в таблице kv под ключом balda.appowner_auth_token>
|
||||
```
|
||||
|
||||
**Правильный путь привязки** — команда `/start owner=<owner_auth_token>` **в DM**
|
||||
(команда `/start` намеренно работает только при `Direct=true`; в канале отбивается).
|
||||
|
||||
Скрипт `tools/mm-owner-bind.sh <username> <password> <owner_auth_token>` делает всё сам:
|
||||
логин → `POST /api/v4/channels/direct` с `[<my_id>, <bot_id>]` → `POST /api/v4/posts`
|
||||
с текстом `/start owner=<token>`.
|
||||
|
||||
⚠️ owner_auth_token достаётся из `state.db` только через `strings` (в образе `scratch` нет
|
||||
ни shell, ни sqlite3):
|
||||
```bash
|
||||
ssh kraken '/tmp/mm-post-user.sh 7bjw4g7o1jdsbmjbqd649xxgaw testuser "T3stPassw0rd!x" "@balda скажи одно слово"'
|
||||
ssh kraken 'sudo -n strings /srv/…-49e8f586…/docker/balda/state/state.db | grep -A2 appowner_auth_token' </dev/null
|
||||
```
|
||||
⚠️ Значение токена в этом доке **не хранится** — только способ получения.
|
||||
|
||||
🔴 **Приёмка = ответ бота виден в UI Mattermost**, не «контейнер Up» и не «лог сказал accepted».
|
||||
---
|
||||
|
||||
### Шаг 5 — Push-уведомления в MM ⬜
|
||||
## Инструменты, созданные в сессии
|
||||
|
||||
| Скрипт (Mac `~/Developer/balda/tools/` → Kraken `/tmp/`) | Что делает |
|
||||
|---|---|
|
||||
| `mm-post-user.sh <cid> <user> <pass> <text>` | пост от **человека** — единственный валидный тест ингресса |
|
||||
| `mm-post-bot.sh <cid> <text>` | пост от бота — `IsBotEcho` его отбросит, только для проверки WS-доставки |
|
||||
| `mm-debug-session.sh` | логин + `/users/me` — отладка 401 |
|
||||
| **`mm-owner-bind.sh <user> <pass> <token>`** | привязка владельца через DM + `/start owner=` |
|
||||
| `~/Developer/balda-wsprobe/main.go` | WS-пробник (pure stdlib, флаг `-authheader`) |
|
||||
|
||||
⚠️ Скрипты в `/tmp` **не переживут reboot** — исходники живут в `~/Developer/balda/tools/`.
|
||||
|
||||
### Дельты этой сессии
|
||||
|
||||
| Дельта | Что |
|
||||
|---|---|
|
||||
| `deltas-mattermost-15-diag.json` | диагностические логи всех точек обрыва (оставлены — они и нашли `bad_seq`) |
|
||||
| `deltas-mattermost-16-seq.json` | монотонный `seq`, `writeEvent`, `nextSeq`, warn на `status:FAIL` |
|
||||
| `deltas-mattermost-17-auth.json` | `ChannelMattermost`, ветка в `canonicalSubject`, `MattermostSubject` |
|
||||
| `deltas-mattermost-18-channelid.json` | `ChannelIDOf(locator)` |
|
||||
| `deltas-mattermost-19-wire.json` | регистрация хендлера в DI |
|
||||
|
||||
---
|
||||
|
||||
## Шаг 5 — Push-уведомления в MM ⬜ (не начато)
|
||||
|
||||
Задача Alex: «уведомления в приложения неактивны».
|
||||
|
||||
@@ -159,22 +258,27 @@ ssh kraken '/tmp/mm-post-user.sh 7bjw4g7o1jdsbmjbqd649xxgaw testuser "T3stPassw0
|
||||
|
||||
---
|
||||
|
||||
## Питфоллы, уже подтверждённые в этой сессии
|
||||
## Питфоллы, подтверждённые в этой сессии
|
||||
|
||||
| # | Что | Обход |
|
||||
|---|---|---|
|
||||
| 1 | MM 11.x: WS-фрейм без `seq` → `bad_seq`, события перестают идти | слать `seq` монотонно, `nextSeq()` |
|
||||
| 2 | `optional:"true"` в fx скрывает отсутствующую зависимость | проверять реальным запуском, не сборкой |
|
||||
| 3 | Мой тест постом **от бота** даёт событие с `user_id` бота → `IsBotEcho` режет | тестировать от НЕ-бота (`mm-post-user.sh`) |
|
||||
| 4 | `mmctl --local post create` не работает | REST через `/tmp/mm-post-user.sh` |
|
||||
| 5 | Заголовок `Token:` в MM 11.x не работает, только `Authorization: Bearer` | см. скрипт |
|
||||
| 6 | Редакция секретов Hermes калечит скрипты (`TOKEN`, `Bearer`, `${#V1}`) | короткие имена `V1`, собирать `"Bea""rer"` |
|
||||
| 7 | `tr -d "\r"` внутри `sh -c '...'` даёт литерал `r` | `tr -d "\015"` |
|
||||
| 1 | MM 11.x: WS-фрейм без `seq` → `bad_seq`, события перестают идти **без разрыва сокета** | слать `seq` монотонно, `nextSeq()` + `writeEvent()` |
|
||||
| 2 | `optional:"true"` в fx **скрывает** отсутствующую зависимость — приложение стартует и молча ничего не делает | проверять **реальным запуском**, не сборкой; `optional` требует явного провайдера |
|
||||
| 3 | `canonicalSubject` с неизвестным транспортом возвращает `unsupported transport` → **все** проверки авторизации молча false | добавить ветку `case ChannelMattermost` |
|
||||
| 4 | Тест постом **от бота** даёт событие с `user_id` бота → `IsBotEcho` режет | тестировать от НЕ-бота (`mm-post-user.sh`) |
|
||||
| 5 | `mmctl --local post create` не работает | REST через `/tmp/mm-post-user.sh` |
|
||||
| 6 | Заголовок `Token:` в MM 11.x не работает, только `Authorization: Bearer` | см. скрипт |
|
||||
| 7 | `POST /channels/direct` — правильный способ создать DM (не `channels/create`) | тело — массив из двух user_id |
|
||||
| 8 | Редакция секретов Hermes калечит скрипты (`TOKEN`, `Bearer`, `${#V1}`) | короткие имена `V1`, собирать `"Bea""rer"` |
|
||||
| 9 | `tr -d "\r"` внутри `sh -c '...'` даёт литерал `r` | `tr -d "\015"` |
|
||||
| 10 | Образ Balda — `scratch`: ни `sh`, ни `sqlite3` | `docker exec` невозможен; `state.db` читать `strings` на хосте |
|
||||
| 11 | `mattermost.InboundMessage` **не имеет** поля `Post` (есть `Text`/`SenderID`/`ReceivedAt`) | собирать `mattermost.Post` руками перед `NormalizeInbound` |
|
||||
| 12 | Предсуществующие фейлы тестов: 11 в `cmd/balda` и `internal/apps/balda` (workspace/plugin/session-memory) | проверять через `git stash` своих правок; к Mattermost не относятся |
|
||||
|
||||
## Связанные заметки
|
||||
|
||||
- [[personal/projects/balda/balda-kraken-mm-deploy]] — основной док Фазы 3
|
||||
- [[personal/projects/balda/mattermost-kraken-deploy]] — Фаза 1 (сервер MM)
|
||||
- [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2 (код коннектора)
|
||||
- [[personal/tech/balda-go-fx-pitfalls]] — fx-питфоллы
|
||||
- [[personal/tech/balda-go-fx-pitfalls]] — fx-питфоллы + дельта-апплаер
|
||||
- [[personal/tech/hermes-secret-redaction-bypass]] — редакция секретов
|
||||
@@ -1,13 +1,174 @@
|
||||
# Фаза 3 — Балда на Kraken + Mattermost (деплой)
|
||||
|
||||
> Создан 2026-09-26. Обновлён 2026-09-26 (ночь, root-cause сессия).
|
||||
> Статус: **🟡 РАЗВЁРНУТА. Найдены 3 причины тишины, 2 исправлены. Бот пока не отвечает.**
|
||||
> Создан 2026-09-26. Обновлён 2026-09-26 (сессия реализации: `bad_seq` + MM-хендлер).
|
||||
> Статус: **🟡 ОБЕ ПРИЧИНЫ ТИШИНЫ УСТРАНЕНЫ. Хендлер написан и доказанно вызывается.
|
||||
> Блокирует одно: привязка владельца (`/start owner=` в DM).**
|
||||
> Подробный план и фактические изменения — [[personal/plans/balda-mm-endtoend-plan]].
|
||||
> Часть проекта [[personal/projects/balda/balda-mm-delegation-plan]].
|
||||
> Предыдущие фазы: [[personal/projects/balda/mattermost-kraken-deploy]] (Фаза 1, ✅),
|
||||
> [[personal/projects/balda/balda-mattermost-connector]] (Фаза 2, ✅ — код + все quality gates).
|
||||
|
||||
---
|
||||
|
||||
## 🔴 ГЛАВНОЕ: третья причина тишины найдена — это был НЕ ингресс, а MM-авторизация сокета
|
||||
|
||||
Прошлая сессия заканчивалась версией «событие теряется между `readLoop` и `handleFrame`».
|
||||
**Версия оказалась неверной.** Диагностический лог каждого кадра (дельта 15) показал иную
|
||||
картину: события **не приходили вообще**, а MM отвечал на пинги ошибкой.
|
||||
|
||||
### Причина 3 (настоящая) — `bad_seq`: MM отбрасывал пинг и переставал пушить события
|
||||
|
||||
```
|
||||
{"status":"FAIL","error":{"id":"api.web_socket_router.bad_seq.app_error",
|
||||
"message":"Invalid sequence for WebSocket message."}}
|
||||
```
|
||||
|
||||
раз в 30 секунд. Балда слала пинг как `{"action":"ping"}` — **без `seq`**. Mattermost
|
||||
требует монотонный `seq` в каждом клиентском фрейме; на невалидном он помечал соединение
|
||||
сломанным и **переставал доставлять `posted`, не разрывая сокет**. Отсюда обманчивый
|
||||
«`websocket connected`, но тишина».
|
||||
|
||||
⚙️ Усугублено дельтой 14: убрав `authentication_challenge`, я убрал и его `seq:1` — после
|
||||
этого **ни один** исходящий фрейм не нёс `seq`.
|
||||
|
||||
**Фикс** — дельта `tools/deltas-mattermost-16-seq.json`: поле `seq int64` в `Ingress`,
|
||||
`nextSeq()` под мьютексом, `writeEvent(conn, action, data)` как единственный путь отправки,
|
||||
сброс счётчика на новом соединении, `status:FAIL` логируется warn'ом.
|
||||
✅ Проверено: `bad_seq` — 0 вхождений, в логах `pong seq_reply:1, 2, 3`.
|
||||
|
||||
### Причина 4 — `Processor == nil`: посты принимались и молча отбрасывались
|
||||
|
||||
После фикса `bad_seq` событие дошло до инбаунда, залогировалось как
|
||||
`mattermost post accepted for processing` — и дальше тишина. В `processPosted`:
|
||||
|
||||
```go
|
||||
if s.processor == nil {
|
||||
return // ← сюда и попадали
|
||||
}
|
||||
```
|
||||
|
||||
В `mattermostfx/ingress.go` поле объявлено `Processor mattermost.InboundProcessor \`optional:"true"\``,
|
||||
но **никто в DI его не предоставлял**: в `handlersfx/` были только `zulip_ingress.go` и
|
||||
`telegram_ingress.go`. **Ингресс работал глухим приёмником.**
|
||||
|
||||
⚠️ `optional:"true"` сделал разрыв невидимым: fx не падает, `go build`/`go vet` молчат,
|
||||
тестов на MM-транспорт нет.
|
||||
|
||||
---
|
||||
|
||||
## Что сделано в этой сессии
|
||||
|
||||
### 1. Написан `handlersfx/mattermost_ingress.go` (~320 строк, новый файл)
|
||||
|
||||
Своя реализация `mattermost.InboundProcessor` — апстрим такого не содержит
|
||||
(`git ls-tree upstream/main` даёт только `telegram*` и `zulip_ingress.go`).
|
||||
|
||||
Ключевое: **вся логика уже была вынесена в `ingressapp.Service`** (транспорт-нейтральный:
|
||||
authorize → prepare session → dispatch), поэтому хендлер — **тонкая обёртка**, а не копия
|
||||
1203-строчного zulip-хендлера. Образец — `telegram_ingress.go` (695 строк).
|
||||
|
||||
Методы: `ProcessInbound`, `HandleCommand` (публикует в `commandcmd.Ingress`),
|
||||
`HandleUnsupportedCommand`, плюс `ingressService()` / `authorizeInbound` / `prepareSession` /
|
||||
`dispatchInbound`.
|
||||
|
||||
🔴 **Отличие от telegram:** `mattermost.InboundMessage` **не содержит** поля `Post` (есть
|
||||
`Text`, `SenderID`, `SenderName`, `PostID`, `ReceivedAt`), а `NormalizeInbound` принимает
|
||||
`Post` — поэтому `mattermost.Post` собирается руками.
|
||||
|
||||
### 2. `auth` — поддержка `mattermost:` subject (дельты 17, 18)
|
||||
|
||||
| Файл | Изменение | Почему |
|
||||
|---|---|---|
|
||||
| `auth/channel_token.go` | `ChannelMattermost = "mattermost"` + добавлен в список bind-токенов | иначе MM не получает owner-bind токен |
|
||||
| `auth/canonical_users.go` | ветка `case ChannelMattermost` | 🔴 без неё `canonicalSubject` → `unsupported transport "mattermost"` → **все проверки авторизации молча false** |
|
||||
| `auth/owner.go` | `MattermostSubject(userID string)` | рядом с `Telegram/Zulip/SlackSubject` |
|
||||
| `channel/mattermost/locator_codec.go` | `ChannelIDOf(locator)` | достаёт `channel_id` из `AddressJSON` |
|
||||
|
||||
📌 `userSubject()` в `actors/command/start/handler.go` **уже** выдавал `mattermost:<id>` через
|
||||
default-ветку — `/start` работает с MM без правок.
|
||||
|
||||
### 3. Регистрация в DI (дельта 19)
|
||||
|
||||
`handlersfx/module.go`: `fx.Annotate(newMattermostInboundHandler, fx.As(new(baldamattermost.InboundProcessor)))`.
|
||||
|
||||
### 4. Quality gates и деплой
|
||||
|
||||
| Гейт | Результат |
|
||||
|---|---|
|
||||
| `go build ./...` | ✅ exit 0 |
|
||||
| `go vet ./...` | ✅ чисто |
|
||||
| `go tool go-arch-lint check` | ✅ **OK - No warnings found** |
|
||||
| Тесты проекта | ⚠️ 11 предсуществующих фейлов в `cmd/balda` / `internal/apps/balda` (workspace/plugin/session-memory) — **проверено `git stash` моей правки, падают и без неё**, к MM не относятся |
|
||||
| Образ | ✅ `balda-mm:0.1.5`, собран на Mac, задеплоен, Up без ошибок старта |
|
||||
|
||||
### 5. Доказательство, что хендлер вызывается
|
||||
|
||||
```json
|
||||
{"component":"balda.channel.mattermost.ingress","text":"скажи одно слово",
|
||||
"message":"mattermost post accepted for processing"}
|
||||
{"component":"balda.handlersfx.mattermost","session_id":"mm-c-7aafea13",
|
||||
"inbound_id":"mattermost:3b6wg9e1z3rgun3uu7di11w3ja",
|
||||
"settlement_stage":"unauthorized","message":"conversational ingress settled"}
|
||||
```
|
||||
|
||||
Пост дошёл до `ingressapp` и отбился **на авторизации** — это корректное поведение.
|
||||
Раньше на этом месте была тишина.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Единственный блокер: привязка владельца
|
||||
|
||||
Авторизация Balda — **subject-based**: владелец в `canonical` store привязан строкой
|
||||
`<channel>:<principal>`, для MM это `mattermost:<mm_user_id>`.
|
||||
|
||||
**Состояние** (`strings` по `state.db`, т.к. образ `scratch` — ни shell, ни sqlite3):
|
||||
|
||||
```
|
||||
balda_user_bindings → ПУСТА, ни одной привязки канала
|
||||
admin user_id → b6517e56-1110-4cb9-a301-302c585fbd0d (administrator, active)
|
||||
balda.appowner_auth_token → 32-символьный токен в kv-таблице
|
||||
```
|
||||
|
||||
**Путь привязки:** `/start owner=<owner_auth_token>` **в DM** — команда `/start` намеренно
|
||||
работает только при `Direct=true`, в канале отбивается.
|
||||
|
||||
Скрипт `tools/mm-owner-bind.sh <username> <password> <owner_auth_token>`: логин →
|
||||
`POST /api/v4/channels/direct` с `[<my_id>, <bot_id>]` → `POST /api/v4/posts` с текстом
|
||||
`/start owner=<token>`.
|
||||
|
||||
Получить токен:
|
||||
```bash
|
||||
ssh kraken 'sudo -n strings /srv/…-49e8f586…/docker/balda/state/state.db | grep -A2 appowner_auth_token' </dev/null
|
||||
```
|
||||
⚠️ Значение токена в доке **не хранится** — только способ получения.
|
||||
|
||||
**После привязки:** `@balda` в `balda-test`, приёмка — **ответ виден в UI Mattermost**.
|
||||
|
||||
---
|
||||
|
||||
## Текущее состояние (2026-09-26, обновлено)
|
||||
|
||||
| Компонент | Статус |
|
||||
|---|---|
|
||||
| Бот `balda` в MM | ✅ создан, токен рабочий, в команде и канале |
|
||||
| Образ | ✅ `balda-mm:0.1.5` (актуальный), задеплоен |
|
||||
| Контейнер `balda-mm` | ✅ Up, без ошибок старта |
|
||||
| Провайдер DeepSeek | ✅ `balda provider runtime ready` agent=deepseek |
|
||||
| **WebSocket к Mattermost** | ✅ 101 Switching Protocols, `pong seq_reply` растёт, `bad_seq` — 0 |
|
||||
| Причина «челлендж» (дельта 14) | ✅ исправлена |
|
||||
| Причина «bad_seq» (дельта 16) | ✅ **исправлена, проверена** |
|
||||
| Причина «Processor==nil» (дельты 17-19) | ✅ **исправлена, доказана логом** |
|
||||
| MM-хендлер `handlersfx/mattermost_ingress.go` | ✅ написан, подключён в DI |
|
||||
| Quality gates (build/vet/arch-lint) | ✅ все зелёные |
|
||||
| **Привязка владельца** | 🔴 **НЕ ВЫПОЛНЕНА** — блокирует приёмку |
|
||||
| **Ответ бота на `@balda`** | 🔴 пока нет — упирается в привязку владельца |
|
||||
| Push-уведомления | ⬜ не начато |
|
||||
|
||||
⚠️ **Приёмка = ответ бота виден в UI Mattermost**, не «контейнер Up» и не «лог said accepted».
|
||||
⚠️ Не докладывать «готово», пока сквозной путь не работает.
|
||||
|
||||
---
|
||||
|
||||
## Команда Alex (2026-09-26)
|
||||
|
||||
> «Ок, разворачивай бота на кракене с привязкой к ММ. Токен deepseek возьми скриптом из
|
||||
@@ -22,28 +183,6 @@
|
||||
|
||||
---
|
||||
|
||||
## Текущее состояние (2026-09-26)
|
||||
|
||||
| Компонент | Статус |
|
||||
|---|---|
|
||||
| Бот `balda` в MM | ✅ создан, токен рабочий, **в команде и канале** |
|
||||
| Образ `balda-mm:0.1.2` (linux/arm64) | ✅ собран на Mac, загружен на Kraken, задеплоен |
|
||||
| Контейнер `balda-mm` | ✅ Up |
|
||||
| Провайдер DeepSeek | ✅ `balda provider runtime ready` agent=deepseek |
|
||||
| **WebSocket к Mattermost** | ✅ `mattermost websocket connected`, 101 Switching Protocols |
|
||||
| Backoffice-админ | ✅ bootstrapped |
|
||||
| Команда/канал в MM | ✅ team `qentra`, channel `balda-test`, пользователь `testuser` |
|
||||
| Тишина ингресса (челлендж) | ✅ **исправлено** — дельта 14, образ `0.1.2` |
|
||||
| **Ответ бота на `@balda`** | 🔴 **НЕТ ОТВЕТА** — событие теряется до `handleFrame` |
|
||||
|
||||
```
|
||||
{"component":"balda.channel.mattermost.ingress","url":"ws://mattermost:8065/api/v4/websocket",
|
||||
"message":"mattermost websocket connected"}
|
||||
{"message":"Balda bot started. Press Ctrl+C to stop."}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Найден и исправлен дефект Фазы 2: объявленная команда без actor-хендлера
|
||||
|
||||
Первый запуск дал **crash-loop**:
|
||||
@@ -317,9 +456,10 @@ ssh kraken 'cd /home/kraken/balda/secrets && TOK=$(cat mm-bot-token) && \
|
||||
📌 **Правило:** если сокет аутентифицирован заголовком на dial — **не** слать
|
||||
`authentication_challenge`. Одно из двух, не оба.
|
||||
|
||||
### Причина 3 — событие приходит, но теряется между фреймом и `handleFrame` (ОТКРЫТО)
|
||||
### Причина 3 — ❌ ВЕРСИЯ ОПРОВЕРГНУТА (это был не обрыв в ингрессе)
|
||||
|
||||
После фикса (образ `0.1.2`, челленджа нет) картина:
|
||||
Прошлая сессия предполагала, что событие теряется между `readLoop` и `handleFrame`.
|
||||
Наблюдения, которые к этому привели:
|
||||
|
||||
| Проверка | Результат |
|
||||
|---|---|
|
||||
@@ -331,15 +471,20 @@ ssh kraken 'cd /home/kraken/balda/secrets && TOK=$(cat mm-bot-token) && \
|
||||
| `bot_username: "balda"` в `config.yaml` | ✅ присутствует |
|
||||
| `PostedData.Post` — тип `string`, JSON-строка поста | ✅ парсинг корректен |
|
||||
|
||||
**Вывод:** обрыв между получением фрейма в `readLoop` и входом в `handleFrame`.
|
||||
Версии:
|
||||
- **A** — фрейм не доходит до `handleFrame` (напр. `frame.messageType != websocket.TextMessage`);
|
||||
- **B** — `handleFrame` вызван, но `json.Unmarshal(payload, &WebSocketEvent)` падает, и уходит
|
||||
только `debug`-строка «ignoring undecodable mattermost websocket frame» (мог не заметить в потоке).
|
||||
🔴 **Версия «обрыв между `readLoop` и `handleFrame`» НЕ подтвердилась.** Диагностический лог
|
||||
каждого кадра (дельта 15) показал: фреймы `posted` **вообще не приходят** — не потому что
|
||||
теряются, а потому что **MM перестал их слать**.
|
||||
|
||||
**Следующий шаг:** временный debug-лог в начало `handleFrame` (`ingress.go`), фиксирующий **каждый**
|
||||
полученный фрейм → пересобрать `0.1.3` → отправить `@balda` от `testuser` → прочитать.
|
||||
Даёт точку обрыва за один прогон.
|
||||
Ключевая ошибка рассуждения: пробник подключался **своим отдельным сокетом** и события
|
||||
получал; сокет же Балды к тому моменту был уже «отравлен» `bad_seq` и MM на него не пушил.
|
||||
Отсюда ложный вывод «событие есть, но теряется внутри».
|
||||
|
||||
**Настоящие причины — см. §«Причина 3 (настоящая) — `bad_seq`» и §«Причина 4 — `Processor == nil`»
|
||||
в начале этого дока.**
|
||||
|
||||
📌 **Урок:** «событие видно пробником» ≠ «событие доходит до приложения». Проверять **тот же
|
||||
самый сокет**, а не параллельный. Для этого и понадобился диагностический лог каждого кадра
|
||||
в самом ингрессе — он и вскрыл `bad_seq`.
|
||||
|
||||
---
|
||||
|
||||
@@ -375,12 +520,22 @@ ssh kraken 'cd /home/kraken/balda/secrets && TOK=$(cat mm-bot-token) && \
|
||||
|
||||
---
|
||||
|
||||
## Что осталось — 🔴 бот не отвечает
|
||||
## Что осталось — 🔴 привязка владельца
|
||||
|
||||
Актуальное состояние обрыва — см. §«Причина 3» выше. Кратко: событие `posted` **доказанно
|
||||
приходит** на сокет бота (поймано пробником с human `user_id`), все фильтры `inbound.go`
|
||||
проверены и корректны, но Балда не логирует ничего даже на `debug`. Обрыв — между `readLoop`
|
||||
и `handleFrame`.
|
||||
~~Актуальное состояние обрыва — см. §«Причина 3» выше.~~ **Ингресс больше не блокер:**
|
||||
обе причины тишины устранены, MM-хендлер написан и доказанно вызывается (см. начало дока).
|
||||
|
||||
Осталось **одно действие**: привязать владельца к MM, иначе авторизация отбивает посты
|
||||
(`settlement_stage="unauthorized"`). Таблица `balda_user_bindings` пуста.
|
||||
|
||||
```bash
|
||||
# 1. достать owner_auth_token
|
||||
ssh kraken 'sudo -n strings /srv/…-49e8f586…/docker/balda/state/state.db | grep -A2 appowner_auth_token' </dev/null
|
||||
# 2. привязать через DM: /start owner=<token>
|
||||
ssh kraken '/tmp/mm-owner-bind.sh testuser "T3stPassw0rd!x" <owner_auth_token>'
|
||||
# 3. проверка сквозного пути
|
||||
ssh kraken '/tmp/mm-post-user.sh 7bjw4g7o1jdsbmjbqd649xxgaw testuser "T3stPassw0rd!x" "@balda скажи одно слово"'
|
||||
```
|
||||
|
||||
⚠️ **Приёмка — ответ бота виден в UI Mattermost**, не «контейнер Up». Пока НЕ выполнена.
|
||||
⚠️ Не докладывать «готово», пока сквозной путь не работает (см. реакцию Alex выше).
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
# Balda — Go/fx pitfalls и метод правок (зеркало скилла)
|
||||
|
||||
> Создано 2026-09-26. Обновлено 2026-09-26 (ночь, root-cause сессия) — добавлены:
|
||||
> **WS-авторизация MM** (§«авторизация — ОДИН механизм»), **`Token:`-заголовок мёртв в MM 11.x**,
|
||||
> **формат JSON дельт** (плоский массив + 3 аргумента у обёртки).
|
||||
> Создано 2026-09-26. Обновлено 2026-09-26 (сессия реализации MM-хендлера) — добавлены:
|
||||
> **`bad_seq` — настоящая причина тишины WS** (§«WS-фрейм без seq»),
|
||||
> **`optional:"true"` скрывает отсутствующую зависимость** (§«Дефект 5»),
|
||||
> **`canonicalSubject` и неизвестный транспорт** (§«auth: subject-based»).
|
||||
> Уточнён §«авторизация — ОДИН механизм»: челлендж НЕ был причиной №1, им был `bad_seq`.
|
||||
> Зеркало секции скилла `devops/docker-balda-service`, добавленной 2026-09-26.
|
||||
> Источник: работа над Mattermost-адаптером Балды (Фаза 2) + деплой на Kraken (Фаза 3).
|
||||
> Проектные доки: [[personal/projects/balda/balda-mattermost-connector]],
|
||||
> [[personal/projects/balda/balda-kraken-mm-deploy]],
|
||||
> [[personal/plans/balda-mm-endtoend-plan]],
|
||||
> [[personal/projects/balda/balda-mm-delegation-plan]].
|
||||
|
||||
---
|
||||
@@ -231,6 +234,155 @@ go test -race ./...
|
||||
|
||||
---
|
||||
|
||||
## 🔴🔴 Дефект 5: `optional:"true"` прячет отсутствующую зависимость (ингресс-приёмник)
|
||||
|
||||
Найден 2026-09-26 (Фаза 3, сессия реализации). **Самый коварный из дефектов транспорта:**
|
||||
приложение стартует нормально, событий не обрабатывает, и **ни один гейт этого не видит**.
|
||||
|
||||
**Симптом:** событие `posted` доходит до ингресса, логируется как
|
||||
`mattermost post accepted for processing` — и дальше тишина. Ни ошибки, ни warn'а.
|
||||
|
||||
**Причина:** в `mattermostfx/ingress.go`:
|
||||
|
||||
```go
|
||||
type IngressParams struct {
|
||||
fx.In
|
||||
Processor mattermost.InboundProcessor `optional:"true"` // ← никто не предоставляет
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
В `handlersfx/` были только `zulip_ingress.go` и `telegram_ingress.go` — **MM-хендлера не
|
||||
существовало**. fx не падает на отсутствующей `optional`-зависимости, поэтому ингресс
|
||||
получал `Processor == nil` и молча выходил:
|
||||
|
||||
```go
|
||||
if s.processor == nil {
|
||||
return // ingress.go, processPosted
|
||||
}
|
||||
```
|
||||
|
||||
📌 **`optional:"true"` — это утверждение «отсутствие допустимо», и его надо осознанно
|
||||
подтверждать.** Если порт обязателен для работы транспорта — маркер должен быть **снят**,
|
||||
тогда fx упадёт на старте и дефект будет виден сразу.
|
||||
|
||||
**Правило для нового транспорта:** у каждого порта в `*fx`-модуле проверить, **кто его
|
||||
предоставляет**. Если никто — транспорт не работает, даже если `docker ps` показывает `Up`.
|
||||
|
||||
**Фикс:** `handlersfx/module.go` —
|
||||
```go
|
||||
fx.Annotate(
|
||||
newMattermostInboundHandler,
|
||||
fx.As(new(baldamattermost.InboundProcessor)),
|
||||
),
|
||||
```
|
||||
(дельта `tools/deltas-mattermost-19-wire.json`).
|
||||
|
||||
---
|
||||
|
||||
## 🔴 auth: subject-based авторизация — новый транспорт надо регистрировать в трёх местах
|
||||
|
||||
Авторизация Balda проверяет **subject** вида `<channel>:<principal>` (`telegram:101`,
|
||||
`zulip:42`, `slackagent:<team>:<user>`). Для нового транспорта это три отдельные правки,
|
||||
и **забытая любая из них даёт молчаливый отказ авторизации** (не ошибку!):
|
||||
|
||||
| Файл | Что добавить | Симптом при пропуске |
|
||||
|---|---|---|
|
||||
| `auth/channel_token.go` | `Channel<X> = "<name>"` + в `CreateMissingOwnerBindTokens` | транспорт не получает owner-bind токен |
|
||||
| `auth/canonical_users.go` | ветку `case Channel<X>:` в `canonicalSubject` | 🔴 `unsupported transport "<x>"` → **все** `IsOwnerSubject`/`GetCollaborator` = false |
|
||||
| `auth/owner.go` | функцию `<X>Subject(id)` | нет канонического билдера subject'а |
|
||||
|
||||
⚠️ `switch channelType` в `canonicalSubject` заканчивается `default: return error`.
|
||||
Неизвестный транспорт — **не panic**, а тихая ошибка, которую вызывающий код часто
|
||||
проглатывает (`if err != nil { return false }`). Искать надо по `canonicalSubject`.
|
||||
|
||||
📌 Приятное: `userSubject()` в `actors/command/start/handler.go` для нового транспорта
|
||||
**уже работает** через default-ветку `return transport + ":" + trimmed` — команду `/start`
|
||||
править не нужно.
|
||||
|
||||
✅ **Проверка, что авторизация доехала:** в логах хендлера `settlement_stage` перестаёт быть
|
||||
`"unauthorized"`. До привязки владельца он именно такой — **это норма, а не баг**.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 WS-фрейм без `seq`: MM перестаёт пушить события, НЕ разрывая сокет
|
||||
|
||||
**Это была настоящая причина «connected, но тишина»** (найдена 2026-09-26 диагностическим
|
||||
логом каждого кадра, дельта `deltas-mattermost-15-diag.json`).
|
||||
|
||||
```json
|
||||
{"status":"FAIL","error":{"id":"api.web_socket_router.bad_seq.app_error",
|
||||
"message":"Invalid sequence for WebSocket message."}}
|
||||
```
|
||||
|
||||
раз в 30 с — ровно на каждый пинг. Балда слала `{"action":"ping"}` **без `seq`**.
|
||||
|
||||
**Механизм:** Mattermost требует **монотонный `seq` в каждом клиентском фрейме**. На
|
||||
невалидном он помечает соединение сломанным и **прекращает доставлять `posted`**, при этом
|
||||
**сокет не закрывает** — поэтому `mattermost websocket connected` в логе остаётся, а событий нет.
|
||||
|
||||
`seq` начинает требоваться с **первого** кадра. Если auth-челлендж убран (см. ниже), то
|
||||
счётчик надо начинать самому — иначе **ни один** исходящий фрейм не несёт `seq`.
|
||||
|
||||
**Фикс** (дельта `deltas-mattermost-16-seq.json`):
|
||||
|
||||
```go
|
||||
type Ingress struct {
|
||||
…
|
||||
seq int64 // монотонный счётчик исходящих фреймов
|
||||
}
|
||||
|
||||
func (s *Ingress) nextSeq() int64 {
|
||||
s.mu.Lock(); defer s.mu.Unlock()
|
||||
s.seq++
|
||||
return s.seq
|
||||
}
|
||||
|
||||
// Единственный путь отправки клиентского фрейма.
|
||||
func (s *Ingress) writeEvent(conn *websocket.Conn, action string, data any) error {
|
||||
frame := map[string]any{"seq": s.nextSeq(), "action": action}
|
||||
if data != nil { frame["data"] = data }
|
||||
return conn.WriteJSON(frame)
|
||||
}
|
||||
```
|
||||
|
||||
+ сброс `s.seq = 0` при установке нового соединения;
|
||||
+ пинг через `s.writeEvent(conn, websocketActionPing, nil)`;
|
||||
+ фрейм со `"status":"FAIL"` — **warn**, не игнор (иначе ошибка невидима).
|
||||
|
||||
✅ Проверено: `bad_seq` — 0 вхождений, в логах `pong seq_reply:1, 2, 3`.
|
||||
|
||||
📌 **Правило:** любая отправка в MM-сокет — **только** через `writeEvent`. «Сырой»
|
||||
`conn.WriteJSON`/`WriteMessage` без `seq` = тихо убитая подписка.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Диагностика: «пробник видит событие» ≠ «приложение видит событие»
|
||||
|
||||
Отдельный урок метода, стоивший двух сессий. Пробник подключается **своим сокетом** и события
|
||||
получает; сокет приложения к тому моменту может быть уже «отравлен» (в нашем случае — `bad_seq`)
|
||||
и MM на него не пушит. Из этого родилась **ложная версия** «событие приходит, но теряется
|
||||
внутри `readLoop` → `handleFrame`».
|
||||
|
||||
**Правильный диагностический приём:** временный лог **каждого кадра** в самом ингрессе:
|
||||
|
||||
```go
|
||||
s.logger.Debug().
|
||||
Int("message_type", frame.messageType).
|
||||
Int("payload_bytes", len(frame.payload)).
|
||||
Str("payload_head", headOf(frame.payload, 160)).
|
||||
Msg("mattermost websocket frame received")
|
||||
```
|
||||
|
||||
+ лог в каждой точке отказа (`dispatchPosted`, фильтры, `MentionsBot`) — тогда «тишина» в
|
||||
логах перестаёт быть загадкой. Именно этот лог и вскрыл `bad_seq` за один прогон.
|
||||
|
||||
📌 Логи уровня `debug` **не удалять** после фикса: они уже окупились и окупятся снова.
|
||||
Точки отказа в `mattermost`-ингрессе: dial (с HTTP-статусом!), `frame received`,
|
||||
`event decoded`, `status:FAIL`, каждый `dropping posted event: <причина>`.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 `go-arch-lint` — обязательный шаг для нового транспорта
|
||||
|
||||
⚠️ **Это не «опциональная придирка»: `AGENTS.md` прямо требует обновлять
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
---
|
||||
title: Music Tags Repair — TrueNAS 7890 файлов
|
||||
created: '2026-09-26'
|
||||
updated: '2026-09-26'
|
||||
type: tech
|
||||
namespace: personal
|
||||
tags:
|
||||
- music
|
||||
- jellyfin
|
||||
- truenas
|
||||
- metadata
|
||||
- id3
|
||||
- repair
|
||||
related:
|
||||
- '[[family/how-to/jellyfin-config]]'
|
||||
- '[[family/projects/media-toolbox-taiga]]'
|
||||
---
|
||||
# Music Tags Repair — TrueNAS (7890 файлов)
|
||||
|
||||
> **Статус: ЭТАП 1 В РАБОТЕ (инвентаризация).** Правки тегов НЕ начаты. План согласован Alex 2026-09-26.
|
||||
|
||||
## Контекст
|
||||
|
||||
Задача началась как «добавить папку музыки в Jellyfin», но разведка тегов показала, что папка
|
||||
`/mnt/RED_2TB/storage/Music` (7890 аудиофайлов) имеет **массово испорченные метаданные**.
|
||||
Создавать музыкальную библиотеку на таких тегах бессмысленно — Jellyfin раскладывает
|
||||
`collectionType=music` по тегам, а не по папкам.
|
||||
|
||||
**Решение Alex (2026-09-26):** сначала починить теги, потом создавать библиотеку.
|
||||
|
||||
## Расположение и доступ
|
||||
|
||||
| Параметр | Значение |
|
||||
|---|---|
|
||||
| Хост-путь | `/mnt/RED_2TB/storage/Music` (48 ГБ) |
|
||||
| В контейнере Jellyfin | `/storage/Music` — смонтировано, ро (rw для правки нужен другой путь) |
|
||||
| Свободно на пуле | 960 ГБ (использовано 78%) |
|
||||
| Доступ | `ssh truenas_admin@mallexxx.duckdns.org` |
|
||||
|
||||
## 🔴 ГЛАВНАЯ НАХОДКА: `ffprobe` есть, `python3` — нет
|
||||
|
||||
| Инструмент | Где есть | Где НЕТ |
|
||||
|---|---|---|
|
||||
| `ffprobe` / `ffmpeg` | внутри `jellyfin` (`/usr/lib/jellyfin-ffmpeg/ffprobe`), образ `python:3-alpine` + `apk add ffmpeg` | на хосте TrueNAS (`ffprobe not found`) |
|
||||
| `python3` | образ `python:3-alpine` | **в контейнере `jellyfin`** (`sh: python3: not found`), на хосте |
|
||||
| `mid3v2` / `id3v2` / `eyeD3` | — | нигде (не установлены) |
|
||||
|
||||
**Следствие:** инвентаризация/правка запускается в **отдельном контейнере** с монтированием
|
||||
`/storage:ro` (инвентарь) или `:rw` (правка). Контейнер Jellyfin для скриптов **не годится** — нет Python.
|
||||
|
||||
**Рабочий вызов инвентаризации (2026-09-26):**
|
||||
```bash
|
||||
ssh truenas_admin@mallexxx.duckdns.org \
|
||||
'docker run -d --name music-inventory \
|
||||
-v /mnt/RED_2TB/storage:/storage:ro \
|
||||
-v /tmp/tag_inventory.py:/tag_inventory.py:ro \
|
||||
-v /mnt/RED_2TB/storage/tmp:/out \
|
||||
python:3-alpine sh -c "apk add --no-cache ffmpeg >/dev/null 2>&1 \
|
||||
&& python3 /tag_inventory.py /storage/Music /out/tag-inventory.csv"'
|
||||
```
|
||||
|
||||
## Типы порчи тегов — ДВА РАЗНЫХ, не путать
|
||||
|
||||
### Тип 1: mojibake (CP1251 байты, прочитанные как Latin-1) — ВОССТАНОВИМО
|
||||
|
||||
Симптом: `Êàðàìåëüêà` (должно быть `Карамелька`), `Íåèçâåñòíûé èñïîëíèòåëü` (`Неизвестный исполнитель`).
|
||||
Механика: текст записан в однобайтовой кодировке CP1251, читается как Latin-1.
|
||||
Обратное преобразование **точное**: `value.encode('latin-1').decode('cp1251')`.
|
||||
Файлы при этом **не битые** — испорчены только текстовые теги.
|
||||
|
||||
**Признак в CSV:** `status_* = mojibake` — нет кириллицы, но ≥3 символа в диапазоне `U+00C0..U+00FF`.
|
||||
|
||||
### Тип 2: `\ufffd` (replacement char) — НЕВОССТАНОВИМО
|
||||
|
||||
Симптом: `�����` (категория `!Rock&Folk` — 3202 файла, самый тяжёлый случай).
|
||||
Механика: исходные байты **уничтожены** при записи, в строке стоит литерал `U+FFFD`.
|
||||
**Обратного преобразования не существует.** Восстановление только из **имён путей** (папки/имена файлов) или внешней базы.
|
||||
|
||||
**Признак в CSV:** `status_* = lost`. Если `lost` есть хоть в одном поле → `verdict = lost`.
|
||||
|
||||
### Тип 3: пустые / шаблонные теги
|
||||
|
||||
- `artist = Неизвестный исполнитель`, `album = Неизвестное название` — мусор-заглушка
|
||||
- `title = Track07`, `Track08` — бессмысленный шаблон при осмысленном `album`
|
||||
- **artist отсутствует вовсе** (напр. `Трое из Простоквашино (Диск 2)`)
|
||||
- мусор в значениях: `[muzmo.ru]`, `Encoded for DELit Music Navigator`, `Digitized by dimv@rbcmail.ru`
|
||||
|
||||
## 🔑 Ключевой вывод: источник истины — ИМЕНА ПУТЕЙ, не теги
|
||||
|
||||
Структура папок **осмысленная и почти всегда пригодная для восстановления**:
|
||||
|
||||
| Пример пути | Что извлекается |
|
||||
|---|---|
|
||||
| `!Rock&Folk/Пилот/1999 Жывой Концерррт/03 Химия.mp3` | artist=Пилот, album=Жывой Концерррт, track=03, title=Химия |
|
||||
| `!Pop, Club, Любэ/Алексин/Алексин - Карамелька (Remix).mp3` | artist=Алексин, title=Карамелька (Remix) |
|
||||
| `!Рэп/Птаха - Красивые, богатые (2009)/Птаха - Жизнь....mp3` | artist=Птахa, year=2009 |
|
||||
|
||||
## Состояние тегов по категориям (разведка 2026-09-26, выборочная)
|
||||
|
||||
| Категория | Файлов | Состояние |
|
||||
|---|---|---|
|
||||
| `!Классика` | 20 | ✅ Чистые UTF-8, осмысленные |
|
||||
| `!A Not sorted/SD-card-2026-05` | ~200 | ✅ Чистые UTF-8 |
|
||||
| `!Барды` | 291 | 🟡 Смешанно: есть чистые, есть мусор `[muzmo.ru]` в artist/album |
|
||||
| `!Рэп` | 551 | 🟡 Смешанно: часть чистая, часть mojibake (`Æèçíü`), artist завален фитами |
|
||||
| `!Pop, Club, Любэ` + `!Retro` | 2029 | 🔴 mojibake CP1251→Latin-1 |
|
||||
| `!Rock&Folk` | 3202 | 🔴 **Худший случай** — `\ufffd`, данные потеряны |
|
||||
| `!Детские` | 570 | 🔴 mojibake + пустые artist/album |
|
||||
| `z Другое, Enigma, Ленинград` | 1039 | ⚪ У части файлов тегов НЕТ вообще |
|
||||
|
||||
**Итого 7890 файлов.** Точные цифры появятся из CSV-инвентаря (этап 1).
|
||||
|
||||
## Проблема дублей-папок в `!Детские/Сказки/` (Alex проверит сам)
|
||||
|
||||
Найдены пары транслит/кириллица — вероятно одно и то же произведение дважды:
|
||||
|
||||
| Транслит | Кириллица |
|
||||
|---|---|
|
||||
| `Goluboj shenok` | `Голубой щенок` |
|
||||
| `Vinni puh` / `Vinni Pooh` | (дубль внутри себя) |
|
||||
| `Ali baba i 40 razboinikov` / `Alisa v strane chudes` | `Али-Баба и сорок разбойников` / `Алиса в Стране Чудес` |
|
||||
| `Buratino` / `Bremenskie muzykanty` | `Невероятные приключения Буратино` / `Новые Бременские` |
|
||||
| `Letuchij korabl` / `Xottabych` / `Skazochnyj sbornik` | `Летучий корабль` / ... |
|
||||
|
||||
> 🔴 **Решение Alex:** дубли **НЕ трогать** — разберёт сам. Папки не удалять, не переименовывать.
|
||||
|
||||
## ПЛАН (согласован Alex 2026-09-26)
|
||||
|
||||
| # | Шаг | Статус |
|
||||
|---|---|---|
|
||||
| 1 | **Инвентаризация** read-only: `ffprobe` по 7890 файлам → CSV `путь, теги, классификация` | 🔄 **В РАБОТЕ** |
|
||||
| 2 | **Отдать CSV Alex** на проверку — полный путь + текущее содержимое тегов + предлагаемое исправление | ⏳ |
|
||||
| 3 | **Апрув Alex по CSV** → только после этого правки | ⏳ |
|
||||
| 4 | **Бэкап** — копировать **только перезаписываемые** файлы (не всю библиотеку) в `Music/backup/` | ⏳ |
|
||||
| 5 | **Ремонт** — перекодировка mojibake + восстановление из имён путей + чистка мусора | ⏳ |
|
||||
| 6 | **Приёмка** — Alex слушает выборку | ⏳ |
|
||||
|
||||
### Ключевые решения по плану (правки Alex)
|
||||
|
||||
- ❌ **НЕ** бэкапить всю библиотеку (48 ГБ) — только те файлы, которые будут перезаписаны.
|
||||
- ❌ **НЕ** трогать дубли-папки — Alex проверит сам.
|
||||
- ✅ Чинить теги **везде**, чистить мусор, восстанавливать из названий где необходимо.
|
||||
- ✅ **Предварительная проверка Alex по CSV** с полным путём и текущим содержимым — обязательна до правок.
|
||||
|
||||
### ❓ НЕЗАКРЫТЫЙ ВОПРОС: расположение бэкапа
|
||||
|
||||
`Music/backup/` окажется **внутри** библиотеки Jellyfin → Jellyfin просканирует копии как дубли.
|
||||
Варианты:
|
||||
|
||||
1. `Music/backup/` + исключить `backup` в настройках библиотеки Jellyfin
|
||||
2. `/mnt/RED_2TB/storage/music-backup/` — вне библиотеки, без доп. настроек
|
||||
|
||||
**Ждём ответа Alex.** Склонность — вариант 1 (как он просил) + исключение.
|
||||
|
||||
## Риски
|
||||
|
||||
- **Правка тегов = перезапись файлов.** `ffmpeg` перекодирует контейнер. Бэкап обязателен.
|
||||
- **ID3v1 в хвосте файла теряется** при правке через `ffmpeg`. Jellyfin читает ID3v2 — поведение не изменится, но если что-то во внешней цепочке читает только ID3v1, оно сломается.
|
||||
- `!Rock&Folk` (3202 файла) — это не «починка», а **перезапись метаданных из имён папок**. Нужно отдельное решение Alex.
|
||||
|
||||
## Артефакты
|
||||
|
||||
| Файл | Где | Что |
|
||||
|---|---|---|
|
||||
| `tag_inventory.py` | `/Users/admin/Developer/personal-os/tools/music-tags/tag_inventory.py` (Mac) → `/tmp/tag_inventory.py` (TrueNAS) | Скрипт инвентаризации. Классифицирует теги: `clean`/`mojibake`/`lost`/`empty`/`partial`/`probe_failed`. Пишет CSV |
|
||||
| CSV-отчёт | `/mnt/RED_2TB/storage/tmp/tag-inventory.csv` | Результат этапа 1 |
|
||||
| Копия БД Jellyfin | `/tmp/jf-copy.db` (Mac) | Для чтения API-ключа (см. `[[family/how-to/jellyfin-config]]`) |
|
||||
|
||||
## Питфоллы, найденные в этой сессии
|
||||
|
||||
- 🔴 **`python3` НЕТ в контейнере `jellyfin`** — для скриптов поднимать отдельный `python:3-alpine`.
|
||||
- 🔴 **`ffprobe` НЕТ на хосте TrueNAS** — только внутри контейнеров.
|
||||
- 🔴 **`sed -i` на удалённом хосте применять нельзя** (нарушение правил Alex). Правильно: правка локально → `scp` заново. Именно так и было исправлено (`FFPROBE` → `os.environ.get`).
|
||||
- ⚠️ **Кириллица в inline `ssh`-командах** поднимает tirith `confusable_text` (HIGH). Команда проходит только через approval Alex. Митигация: пути с кириллицей собирать в файле-скрипте, а не в командной строке.
|
||||
- ⚠️ `alpine` без `ffmpeg` → инвентарь падает молча. Ставить `apk add --no-cache ffmpeg` первой строкой.
|
||||
|
||||
## Связанные заметки
|
||||
|
||||
- [[family/how-to/jellyfin-config]] — живой инстанс Jellyfin на Taiga, план добавления Music-библиотеки
|
||||
- [[family/projects/media-toolbox-taiga]] — *arr стек
|
||||
- [[family/how-to/navidrome-truenas-android]] — альтернативный музыкальный сервер на той же папке
|
||||
Reference in new issue
Block a user