[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:
Alexey Martemyanov committed 2026-09-26 13:31:13 +06:00
1 parent bf7fa3569d
commit 6ae7f397ef
5 files changed
+688 -86

No files matched your search

+155 -3
View File
@@ -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` прямо требует обновлять
+181
View File
@@ -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]] — альтернативный музыкальный сервер на той же папке